From d7cca4b150705c6e22d6c2ea9425688cc6b5cbdd Mon Sep 17 00:00:00 2001 From: Chummy Date: Mon, 16 Feb 2026 23:38:29 +0800 Subject: [PATCH 01/32] feat: unify scheduled tasks from #337 and #338 with security-first integration Unifies scheduled task capabilities and consolidates overlapping implementations from #337 and #338 into a single security-first integration path. Co-authored-by: Edvard Co-authored-by: stawky --- src/agent/loop_.rs | 5 + src/channels/mod.rs | 5 + src/config/mod.rs | 4 +- src/config/schema.rs | 43 ++++ src/cron/mod.rs | 420 +++++++++++++++++++++++++++------ src/cron/scheduler.rs | 13 +- src/gateway/mod.rs | 1 + src/lib.rs | 17 ++ src/main.rs | 17 ++ src/onboard/wizard.rs | 2 + src/tools/mod.rs | 25 +- src/tools/schedule.rs | 522 ++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 1006 insertions(+), 68 deletions(-) create mode 100644 src/tools/schedule.rs diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index a8368c6..2558bfa 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -598,6 +598,7 @@ pub async fn run( &config.workspace_dir, &config.agents, config.api_key.as_deref(), + &config, ); // ── Resolve provider ───────────────────────────────────────── @@ -672,6 +673,10 @@ pub async fn run( "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run, 'connect' to OAuth.", )); } + tool_descs.push(( + "schedule", + "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.", + )); if !config.agents.is_empty() { tool_descs.push(( "delegate", diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 1acc502..21f99d0 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -730,6 +730,7 @@ pub async fn start_channels(config: Config) -> Result<()> { &config.workspace_dir, &config.agents, config.api_key.as_deref(), + &config, )); // Build system prompt from workspace identity files + skills @@ -776,6 +777,10 @@ pub async fn start_channels(config: Config) -> Result<()> { "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run, 'connect' to OAuth.", )); } + tool_descs.push(( + "schedule", + "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.", + )); if !config.agents.is_empty() { tool_descs.push(( "delegate", diff --git a/src/config/mod.rs b/src/config/mod.rs index d8980c0..a61c29c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -6,8 +6,8 @@ pub use schema::{ DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, GatewayConfig, HeartbeatConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, ObservabilityConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, - SandboxBackend, SandboxConfig, SecretsConfig, SecurityConfig, SlackConfig, TelegramConfig, - TunnelConfig, WebhookConfig, + SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, + TelegramConfig, TunnelConfig, WebhookConfig, }; #[cfg(test)] diff --git a/src/config/schema.rs b/src/config/schema.rs index bc27e4e..8d2ec55 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -34,6 +34,9 @@ pub struct Config { #[serde(default)] pub reliability: ReliabilityConfig, + #[serde(default)] + pub scheduler: SchedulerConfig, + /// Model routing rules — route `hint:` to specific provider+model combos. #[serde(default)] pub model_routes: Vec, @@ -697,6 +700,43 @@ impl Default for ReliabilityConfig { } } +// ── Scheduler ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SchedulerConfig { + /// Enable the built-in scheduler loop. + #[serde(default = "default_scheduler_enabled")] + pub enabled: bool, + /// Maximum number of persisted scheduled tasks. + #[serde(default = "default_scheduler_max_tasks")] + pub max_tasks: usize, + /// Maximum tasks executed per scheduler polling cycle. + #[serde(default = "default_scheduler_max_concurrent")] + pub max_concurrent: usize, +} + +fn default_scheduler_enabled() -> bool { + true +} + +fn default_scheduler_max_tasks() -> usize { + 64 +} + +fn default_scheduler_max_concurrent() -> usize { + 4 +} + +impl Default for SchedulerConfig { + fn default() -> Self { + Self { + enabled: default_scheduler_enabled(), + max_tasks: default_scheduler_max_tasks(), + max_concurrent: default_scheduler_max_concurrent(), + } + } +} + // ── Model routing ──────────────────────────────────────────────── /// Route a task hint to a specific provider + model. @@ -1148,6 +1188,7 @@ impl Default for Config { autonomy: AutonomyConfig::default(), runtime: RuntimeConfig::default(), reliability: ReliabilityConfig::default(), + scheduler: SchedulerConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), @@ -1485,6 +1526,7 @@ mod tests { ..RuntimeConfig::default() }, reliability: ReliabilityConfig::default(), + scheduler: SchedulerConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig { enabled: true, @@ -1578,6 +1620,7 @@ default_temperature = 0.7 autonomy: AutonomyConfig::default(), runtime: RuntimeConfig::default(), reliability: ReliabilityConfig::default(), + scheduler: SchedulerConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), diff --git a/src/cron/mod.rs b/src/cron/mod.rs index 444445f..4fe0c39 100644 --- a/src/cron/mod.rs +++ b/src/cron/mod.rs @@ -16,6 +16,8 @@ pub struct CronJob { pub next_run: DateTime, pub last_run: Option>, pub last_status: Option, + pub paused: bool, + pub one_shot: bool, } #[allow(clippy::needless_pass_by_value)] @@ -27,6 +29,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<( println!("No scheduled tasks yet."); println!("\nUsage:"); println!(" zeroclaw cron add '0 9 * * *' 'agent -m \"Good morning!\"'"); + println!(" zeroclaw cron once 30m 'echo reminder'"); return Ok(()); } @@ -36,13 +39,20 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<( .last_run .map_or_else(|| "never".into(), |d| d.to_rfc3339()); let last_status = job.last_status.unwrap_or_else(|| "n/a".into()); + let flags = match (job.paused, job.one_shot) { + (true, true) => " [paused, one-shot]", + (true, false) => " [paused]", + (false, true) => " [one-shot]", + (false, false) => "", + }; println!( - "- {} | {} | next={} | last={} ({})\n cmd: {}", + "- {} | {} | next={} | last={} ({}){}\n cmd: {}", job.id, job.expression, job.next_run.to_rfc3339(), last_run, last_status, + flags, job.command ); } @@ -59,19 +69,41 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<( println!(" Cmd : {}", job.command); Ok(()) } - crate::CronCommands::Remove { id } => remove_job(config, &id), + crate::CronCommands::Once { delay, command } => { + let job = add_once(config, &delay, &command)?; + println!("✅ Added one-shot task {}", job.id); + println!(" Runs at: {}", job.next_run.to_rfc3339()); + println!(" Cmd : {}", job.command); + Ok(()) + } + crate::CronCommands::Remove { id } => { + remove_job(config, &id)?; + println!("✅ Removed cron job {id}"); + Ok(()) + } + crate::CronCommands::Pause { id } => { + pause_job(config, &id)?; + println!("⏸️ Paused job {id}"); + Ok(()) + } + crate::CronCommands::Resume { id } => { + resume_job(config, &id)?; + println!("▶️ Resumed job {id}"); + Ok(()) + } } } pub fn add_job(config: &Config, expression: &str, command: &str) -> Result { + check_max_tasks(config)?; let now = Utc::now(); let next_run = next_run_for(expression, now)?; let id = Uuid::new_v4().to_string(); with_connection(config, |conn| { conn.execute( - "INSERT INTO cron_jobs (id, expression, command, created_at, next_run) - VALUES (?1, ?2, ?3, ?4, ?5)", + "INSERT INTO cron_jobs (id, expression, command, created_at, next_run, paused, one_shot) + VALUES (?1, ?2, ?3, ?4, ?5, 0, 0)", params![ id, expression, @@ -91,43 +123,169 @@ pub fn add_job(config: &Config, expression: &str, command: &str) -> Result, command: &str) -> Result { + add_one_shot_job_with_expression(config, run_at, command, "@once".to_string()) +} + +pub fn add_once(config: &Config, delay: &str, command: &str) -> Result { + let duration = parse_duration(delay)?; + let run_at = Utc::now() + duration; + add_one_shot_job_with_expression(config, run_at, command, format!("@once:{delay}")) +} + +pub fn add_once_at(config: &Config, at: DateTime, command: &str) -> Result { + add_one_shot_job_with_expression(config, at, command, format!("@at:{}", at.to_rfc3339())) +} + +fn add_one_shot_job_with_expression( + config: &Config, + run_at: DateTime, + command: &str, + expression: String, +) -> Result { + check_max_tasks(config)?; + let now = Utc::now(); + if run_at <= now { + anyhow::bail!("Scheduled time must be in the future"); + } + + let id = Uuid::new_v4().to_string(); + + with_connection(config, |conn| { + conn.execute( + "INSERT INTO cron_jobs (id, expression, command, created_at, next_run, paused, one_shot) + VALUES (?1, ?2, ?3, ?4, ?5, 0, 1)", + params![id, expression, command, now.to_rfc3339(), run_at.to_rfc3339()], + ) + .context("Failed to insert one-shot task")?; + Ok(()) + })?; + + Ok(CronJob { + id, + expression, + command: command.to_string(), + next_run: run_at, + last_run: None, + last_status: None, + paused: false, + one_shot: true, + }) +} + +pub fn get_job(config: &Config, id: &str) -> Result> { + with_connection(config, |conn| { + let mut stmt = conn.prepare( + "SELECT id, expression, command, next_run, last_run, last_status, paused, one_shot + FROM cron_jobs WHERE id = ?1", + )?; + + let mut rows = stmt.query_map(params![id], |row| Ok(parse_job_row(row)))?; + + match rows.next() { + Some(Ok(job_result)) => Ok(Some(job_result?)), + Some(Err(e)) => Err(e.into()), + None => Ok(None), + } + }) +} + +pub fn pause_job(config: &Config, id: &str) -> Result<()> { + let changed = with_connection(config, |conn| { + conn.execute("UPDATE cron_jobs SET paused = 1 WHERE id = ?1", params![id]) + .context("Failed to pause cron job") + })?; + + if changed == 0 { + anyhow::bail!("Cron job '{id}' not found"); + } + + Ok(()) +} + +pub fn resume_job(config: &Config, id: &str) -> Result<()> { + let changed = with_connection(config, |conn| { + conn.execute("UPDATE cron_jobs SET paused = 0 WHERE id = ?1", params![id]) + .context("Failed to resume cron job") + })?; + + if changed == 0 { + anyhow::bail!("Cron job '{id}' not found"); + } + + Ok(()) +} + +fn check_max_tasks(config: &Config) -> Result<()> { + let count = with_connection(config, |conn| { + let mut stmt = conn.prepare("SELECT COUNT(*) FROM cron_jobs")?; + let count: i64 = stmt.query_row([], |row| row.get(0))?; + usize::try_from(count).context("Unexpected negative task count") + })?; + + if count >= config.scheduler.max_tasks { + anyhow::bail!( + "Maximum number of scheduled tasks ({}) reached", + config.scheduler.max_tasks + ); + } + + Ok(()) +} + +fn parse_duration(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + anyhow::bail!("Empty delay string"); + } + + let (num_str, unit) = if input.ends_with(|c: char| c.is_ascii_alphabetic()) { + let split = input.len() - 1; + (&input[..split], &input[split..]) + } else { + (input, "m") + }; + + let n: u64 = num_str + .trim() + .parse() + .with_context(|| format!("Invalid duration number: {num_str}"))?; + + let multiplier: u64 = match unit { + "s" => 1, + "m" => 60, + "h" => 3600, + "d" => 86400, + "w" => 604_800, + _ => anyhow::bail!("Unknown duration unit '{unit}', expected s/m/h/d/w"), + }; + + let secs = n + .checked_mul(multiplier) + .filter(|&s| i64::try_from(s).is_ok()) + .ok_or_else(|| anyhow::anyhow!("Duration value too large: {input}"))?; + + #[allow(clippy::cast_possible_wrap)] + Ok(chrono::Duration::seconds(secs as i64)) +} + pub fn list_jobs(config: &Config) -> Result> { with_connection(config, |conn| { let mut stmt = conn.prepare( - "SELECT id, expression, command, next_run, last_run, last_status + "SELECT id, expression, command, next_run, last_run, last_status, paused, one_shot FROM cron_jobs ORDER BY next_run ASC", )?; - let rows = stmt.query_map([], |row| { - let next_run_raw: String = row.get(3)?; - let last_run_raw: Option = row.get(4)?; - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - next_run_raw, - last_run_raw, - row.get::<_, Option>(5)?, - )) - })?; + let rows = stmt.query_map([], |row| Ok(parse_job_row(row)))?; let mut jobs = Vec::new(); for row in rows { - let (id, expression, command, next_run_raw, last_run_raw, last_status) = row?; - jobs.push(CronJob { - id, - expression, - command, - next_run: parse_rfc3339(&next_run_raw)?, - last_run: match last_run_raw { - Some(raw) => Some(parse_rfc3339(&raw)?), - None => None, - }, - last_status, - }); + jobs.push(row??); } Ok(jobs) }) @@ -143,44 +301,21 @@ pub fn remove_job(config: &Config, id: &str) -> Result<()> { anyhow::bail!("Cron job '{id}' not found"); } - println!("✅ Removed cron job {id}"); Ok(()) } pub fn due_jobs(config: &Config, now: DateTime) -> Result> { with_connection(config, |conn| { let mut stmt = conn.prepare( - "SELECT id, expression, command, next_run, last_run, last_status - FROM cron_jobs WHERE next_run <= ?1 ORDER BY next_run ASC", + "SELECT id, expression, command, next_run, last_run, last_status, paused, one_shot + FROM cron_jobs WHERE next_run <= ?1 AND paused = 0 ORDER BY next_run ASC", )?; - let rows = stmt.query_map(params![now.to_rfc3339()], |row| { - let next_run_raw: String = row.get(3)?; - let last_run_raw: Option = row.get(4)?; - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - next_run_raw, - last_run_raw, - row.get::<_, Option>(5)?, - )) - })?; + let rows = stmt.query_map(params![now.to_rfc3339()], |row| Ok(parse_job_row(row)))?; let mut jobs = Vec::new(); for row in rows { - let (id, expression, command, next_run_raw, last_run_raw, last_status) = row?; - jobs.push(CronJob { - id, - expression, - command, - next_run: parse_rfc3339(&next_run_raw)?, - last_run: match last_run_raw { - Some(raw) => Some(parse_rfc3339(&raw)?), - None => None, - }, - last_status, - }); + jobs.push(row??); } Ok(jobs) }) @@ -192,6 +327,15 @@ pub fn reschedule_after_run( success: bool, output: &str, ) -> Result<()> { + if job.one_shot { + with_connection(config, |conn| { + conn.execute("DELETE FROM cron_jobs WHERE id = ?1", params![job.id]) + .context("Failed to remove one-shot task after execution")?; + Ok(()) + })?; + return Ok(()); + } + let now = Utc::now(); let next_run = next_run_for(&job.expression, now)?; let status = if success { "ok" } else { "error" }; @@ -229,9 +373,7 @@ fn normalize_expression(expression: &str) -> Result { let field_count = expression.split_whitespace().count(); match field_count { - // standard crontab syntax: minute hour day month weekday 5 => Ok(format!("0 {expression}")), - // crate-native syntax includes seconds (+ optional year) 6 | 7 => Ok(expression.to_string()), _ => anyhow::bail!( "Invalid cron expression: {expression} (expected 5, 6, or 7 fields, got {field_count})" @@ -239,6 +381,31 @@ fn normalize_expression(expression: &str) -> Result { } } +fn parse_job_row(row: &rusqlite::Row<'_>) -> Result { + let id: String = row.get(0)?; + let expression: String = row.get(1)?; + let command: String = row.get(2)?; + let next_run_raw: String = row.get(3)?; + let last_run_raw: Option = row.get(4)?; + let last_status: Option = row.get(5)?; + let paused: bool = row.get(6)?; + let one_shot: bool = row.get(7)?; + + Ok(CronJob { + id, + expression, + command, + next_run: parse_rfc3339(&next_run_raw)?, + last_run: match last_run_raw { + Some(raw) => Some(parse_rfc3339(&raw)?), + None => None, + }, + last_status, + paused, + one_shot, + }) +} + fn parse_rfc3339(raw: &str) -> Result> { let parsed = DateTime::parse_from_rfc3339(raw) .with_context(|| format!("Invalid RFC3339 timestamp in cron DB: {raw}"))?; @@ -255,7 +422,6 @@ fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) let conn = Connection::open(&db_path) .with_context(|| format!("Failed to open cron DB: {}", db_path.display()))?; - // ── Production-grade PRAGMA tuning ────────────────────── conn.execute_batch( "PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; @@ -274,12 +440,19 @@ fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) next_run TEXT NOT NULL, last_run TEXT, last_status TEXT, - last_output TEXT + last_output TEXT, + paused INTEGER NOT NULL DEFAULT 0, + one_shot INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_cron_jobs_next_run ON cron_jobs(next_run);", ) .context("Failed to initialize cron schema")?; + for column in ["paused", "one_shot"] { + let alter = format!("ALTER TABLE cron_jobs ADD COLUMN {column} INTEGER NOT NULL DEFAULT 0"); + let _ = conn.execute_batch(&alter); + } + f(&conn) } @@ -309,6 +482,8 @@ mod tests { assert_eq!(job.expression, "*/5 * * * *"); assert_eq!(job.command, "echo ok"); + assert!(!job.one_shot); + assert!(!job.paused); } #[test] @@ -335,18 +510,72 @@ mod tests { } #[test] - fn due_jobs_filters_by_timestamp() { + fn add_once_creates_one_shot_job() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let _job = add_job(&config, "* * * * *", "echo due").unwrap(); + let job = add_once(&config, "30m", "echo once").unwrap(); + assert!(job.one_shot); + assert!(job.expression.starts_with("@once:")); + + let fetched = get_job(&config, &job.id).unwrap().unwrap(); + assert!(fetched.one_shot); + assert!(!fetched.paused); + } + + #[test] + fn add_once_at_rejects_past_timestamp() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let run_at = Utc::now() - ChronoDuration::minutes(1); + let err = add_once_at(&config, run_at, "echo past").unwrap_err(); + assert!(err.to_string().contains("future")); + } + + #[test] + fn get_job_found_and_missing() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let job = add_job(&config, "*/5 * * * *", "echo found").unwrap(); + let found = get_job(&config, &job.id).unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().id, job.id); + + let missing = get_job(&config, "nonexistent").unwrap(); + assert!(missing.is_none()); + } + + #[test] + fn pause_resume_roundtrip() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let job = add_job(&config, "*/5 * * * *", "echo pause").unwrap(); + pause_job(&config, &job.id).unwrap(); + assert!(get_job(&config, &job.id).unwrap().unwrap().paused); + + resume_job(&config, &job.id).unwrap(); + assert!(!get_job(&config, &job.id).unwrap().unwrap().paused); + } + + #[test] + fn due_jobs_filters_by_timestamp_and_skips_paused() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let active = add_job(&config, "* * * * *", "echo due").unwrap(); + let paused = add_job(&config, "* * * * *", "echo paused").unwrap(); + pause_job(&config, &paused.id).unwrap(); let due_now = due_jobs(&config, Utc::now()).unwrap(); - assert!(due_now.is_empty(), "new job should not be due immediately"); + assert!(due_now.is_empty(), "new jobs should not be due immediately"); let far_future = Utc::now() + ChronoDuration::days(365); let due_future = due_jobs(&config, far_future).unwrap(); - assert_eq!(due_future.len(), 1, "job should be due in far future"); + assert_eq!(due_future.len(), 1); + assert_eq!(due_future[0].id, active.id); } #[test] @@ -362,4 +591,67 @@ mod tests { assert_eq!(stored.last_status.as_deref(), Some("error")); assert!(stored.last_run.is_some()); } + + #[test] + fn reschedule_after_run_removes_one_shot_jobs() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let run_at = Utc::now() + ChronoDuration::minutes(1); + let job = add_one_shot_job(&config, run_at, "echo once").unwrap(); + reschedule_after_run(&config, &job, true, "ok").unwrap(); + + assert!(get_job(&config, &job.id).unwrap().is_none()); + } + + #[test] + fn scheduler_columns_migrate_from_old_schema() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let db_path = config.workspace_dir.join("cron").join("jobs.db"); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE cron_jobs ( + id TEXT PRIMARY KEY, + expression TEXT NOT NULL, + command TEXT NOT NULL, + created_at TEXT NOT NULL, + next_run TEXT NOT NULL, + last_run TEXT, + last_status TEXT, + last_output TEXT + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO cron_jobs (id, expression, command, created_at, next_run) + VALUES ('old-job', '* * * * *', 'echo old', '2025-01-01T00:00:00Z', '2030-01-01T00:00:00Z')", + [], + ) + .unwrap(); + } + + let jobs = list_jobs(&config).unwrap(); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].id, "old-job"); + assert!(!jobs[0].paused); + assert!(!jobs[0].one_shot); + } + + #[test] + fn max_tasks_limit_is_enforced() { + let tmp = TempDir::new().unwrap(); + let mut config = test_config(&tmp); + config.scheduler.max_tasks = 1; + + let _first = add_job(&config, "*/10 * * * *", "echo first").unwrap(); + let err = add_job(&config, "*/11 * * * *", "echo second").unwrap_err(); + assert!(err + .to_string() + .contains("Maximum number of scheduled tasks")); + } } diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index bab1965..bdb5f0b 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -9,9 +9,18 @@ use tokio::time::{self, Duration}; const MIN_POLL_SECONDS: u64 = 5; pub async fn run(config: Config) -> Result<()> { + if !config.scheduler.enabled { + tracing::info!("Scheduler disabled by config"); + crate::health::mark_component_ok("scheduler"); + loop { + time::sleep(Duration::from_secs(3600)).await; + } + } + let poll_secs = config.reliability.scheduler_poll_secs.max(MIN_POLL_SECONDS); let mut interval = time::interval(Duration::from_secs(poll_secs)); let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir); + let max_concurrent = config.scheduler.max_concurrent.max(1); crate::health::mark_component_ok("scheduler"); @@ -27,7 +36,7 @@ pub async fn run(config: Config) -> Result<()> { } }; - for job in jobs { + for job in jobs.into_iter().take(max_concurrent) { crate::health::mark_component_ok("scheduler"); let (success, output) = execute_job_with_retry(&config, &security, &job).await; @@ -224,6 +233,8 @@ mod tests { next_run: Utc::now(), last_run: None, last_status: None, + paused: false, + one_shot: false, } } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 8eaa57c..104d4de 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -267,6 +267,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { &config.workspace_dir, &config.agents, config.api_key.as_deref(), + &config, )); let skills = crate::skills::load_skills(&config.workspace_dir); let tool_descs: Vec<(&str, &str)> = tools_registry diff --git a/src/lib.rs b/src/lib.rs index 619190b..61a2bc6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -147,11 +147,28 @@ pub enum CronCommands { /// Command to run command: String, }, + /// Add a one-shot delayed task (e.g. "30m", "2h", "1d") + Once { + /// Delay duration + delay: String, + /// Command to run + command: String, + }, /// Remove a scheduled task Remove { /// Task ID id: String, }, + /// Pause a scheduled task + Pause { + /// Task ID + id: String, + }, + /// Resume a paused task + Resume { + /// Task ID + id: String, + }, } /// Integration subcommands diff --git a/src/main.rs b/src/main.rs index 426fdfd..3253594 100644 --- a/src/main.rs +++ b/src/main.rs @@ -234,11 +234,28 @@ enum CronCommands { /// Command to run command: String, }, + /// Add a one-shot delayed task (e.g. "30m", "2h", "1d") + Once { + /// Delay duration + delay: String, + /// Command to run + command: String, + }, /// Remove a scheduled task Remove { /// Task ID id: String, }, + /// Pause a scheduled task + Pause { + /// Task ID + id: String, + }, + /// Resume a paused task + Resume { + /// Task ID + id: String, + }, } #[derive(Subcommand, Debug)] diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 0447d23..7fbcc44 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -110,6 +110,7 @@ pub fn run_wizard() -> Result { autonomy: AutonomyConfig::default(), runtime: RuntimeConfig::default(), reliability: crate::config::ReliabilityConfig::default(), + scheduler: crate::config::SchedulerConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config, @@ -305,6 +306,7 @@ pub fn run_quick_setup( autonomy: AutonomyConfig::default(), runtime: RuntimeConfig::default(), reliability: crate::config::ReliabilityConfig::default(), + scheduler: crate::config::SchedulerConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 22e8d1a..b5cd67a 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -10,6 +10,7 @@ pub mod image_info; pub mod memory_forget; pub mod memory_recall; pub mod memory_store; +pub mod schedule; pub mod screenshot; pub mod shell; pub mod traits; @@ -26,6 +27,7 @@ pub use image_info::ImageInfoTool; pub use memory_forget::MemoryForgetTool; pub use memory_recall::MemoryRecallTool; pub use memory_store::MemoryStoreTool; +pub use schedule::ScheduleTool; pub use screenshot::ScreenshotTool; pub use shell::ShellTool; pub use traits::Tool; @@ -67,6 +69,7 @@ pub fn all_tools( workspace_dir: &std::path::Path, agents: &HashMap, fallback_api_key: Option<&str>, + config: &crate::config::Config, ) -> Vec> { all_tools_with_runtime( security, @@ -78,6 +81,7 @@ pub fn all_tools( workspace_dir, agents, fallback_api_key, + config, ) } @@ -93,6 +97,7 @@ pub fn all_tools_with_runtime( workspace_dir: &std::path::Path, agents: &HashMap, fallback_api_key: Option<&str>, + config: &crate::config::Config, ) -> Vec> { let mut tools: Vec> = vec![ Box::new(ShellTool::new(security.clone(), runtime)), @@ -101,6 +106,7 @@ pub fn all_tools_with_runtime( Box::new(MemoryStoreTool::new(memory.clone())), Box::new(MemoryRecallTool::new(memory.clone())), Box::new(MemoryForgetTool::new(memory)), + Box::new(ScheduleTool::new(security.clone(), config.clone())), Box::new(GitOperationsTool::new( security.clone(), workspace_dir.to_path_buf(), @@ -158,9 +164,17 @@ pub fn all_tools_with_runtime( #[cfg(test)] mod tests { use super::*; - use crate::config::{BrowserConfig, MemoryConfig}; + use crate::config::{BrowserConfig, Config, MemoryConfig}; use tempfile::TempDir; + fn test_config(tmp: &TempDir) -> Config { + Config { + workspace_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..Config::default() + } + } + #[test] fn default_tools_has_three() { let security = Arc::new(SecurityPolicy::default()); @@ -186,6 +200,7 @@ mod tests { ..BrowserConfig::default() }; let http = crate::config::HttpRequestConfig::default(); + let cfg = test_config(&tmp); let tools = all_tools( &security, @@ -196,9 +211,11 @@ mod tests { tmp.path(), &HashMap::new(), None, + &cfg, ); let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); assert!(!names.contains(&"browser_open")); + assert!(names.contains(&"schedule")); } #[test] @@ -219,6 +236,7 @@ mod tests { ..BrowserConfig::default() }; let http = crate::config::HttpRequestConfig::default(); + let cfg = test_config(&tmp); let tools = all_tools( &security, @@ -229,6 +247,7 @@ mod tests { tmp.path(), &HashMap::new(), None, + &cfg, ); let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); assert!(names.contains(&"browser_open")); @@ -341,6 +360,7 @@ mod tests { let browser = BrowserConfig::default(); let http = crate::config::HttpRequestConfig::default(); + let cfg = test_config(&tmp); let mut agents = HashMap::new(); agents.insert( @@ -364,6 +384,7 @@ mod tests { tmp.path(), &agents, Some("sk-test"), + &cfg, ); let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); assert!(names.contains(&"delegate")); @@ -382,6 +403,7 @@ mod tests { let browser = BrowserConfig::default(); let http = crate::config::HttpRequestConfig::default(); + let cfg = test_config(&tmp); let tools = all_tools( &security, @@ -392,6 +414,7 @@ mod tests { tmp.path(), &HashMap::new(), None, + &cfg, ); let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); assert!(!names.contains(&"delegate")); diff --git a/src/tools/schedule.rs b/src/tools/schedule.rs new file mode 100644 index 0000000..43234b8 --- /dev/null +++ b/src/tools/schedule.rs @@ -0,0 +1,522 @@ +use super::traits::{Tool, ToolResult}; +use crate::config::Config; +use crate::cron; +use crate::security::SecurityPolicy; +use anyhow::Result; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde_json::json; +use std::sync::Arc; + +/// Tool that lets the agent manage recurring and one-shot scheduled tasks. +pub struct ScheduleTool { + security: Arc, + config: Config, +} + +impl ScheduleTool { + pub fn new(security: Arc, config: Config) -> Self { + Self { security, config } + } +} + +#[async_trait] +impl Tool for ScheduleTool { + fn name(&self) -> &str { + "schedule" + } + + fn description(&self) -> &str { + "Manage scheduled tasks. Actions: create/add/once/list/get/cancel/remove/pause/resume" + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "add", "once", "list", "get", "cancel", "remove", "pause", "resume"], + "description": "Action to perform" + }, + "expression": { + "type": "string", + "description": "Cron expression for recurring tasks (e.g. '*/5 * * * *')." + }, + "delay": { + "type": "string", + "description": "Delay for one-shot tasks (e.g. '30m', '2h', '1d')." + }, + "run_at": { + "type": "string", + "description": "Absolute RFC3339 time for one-shot tasks (e.g. '2030-01-01T00:00:00Z')." + }, + "command": { + "type": "string", + "description": "Shell command to execute. Required for create/add/once." + }, + "id": { + "type": "string", + "description": "Task ID. Required for get/cancel/remove/pause/resume." + } + }, + "required": ["action"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> Result { + let action = args + .get("action") + .and_then(|value| value.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'action' parameter"))?; + + match action { + "list" => self.handle_list(), + "get" => { + let id = args + .get("id") + .and_then(|value| value.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter for get action"))?; + self.handle_get(id) + } + "create" | "add" | "once" => { + if let Some(blocked) = self.enforce_mutation_allowed(action) { + return Ok(blocked); + } + self.handle_create_like(action, &args) + } + "cancel" | "remove" => { + if let Some(blocked) = self.enforce_mutation_allowed(action) { + return Ok(blocked); + } + let id = args + .get("id") + .and_then(|value| value.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter for cancel action"))?; + Ok(self.handle_cancel(id)) + } + "pause" => { + if let Some(blocked) = self.enforce_mutation_allowed(action) { + return Ok(blocked); + } + let id = args + .get("id") + .and_then(|value| value.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter for pause action"))?; + Ok(self.handle_pause_resume(id, true)) + } + "resume" => { + if let Some(blocked) = self.enforce_mutation_allowed(action) { + return Ok(blocked); + } + let id = args + .get("id") + .and_then(|value| value.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter for resume action"))?; + Ok(self.handle_pause_resume(id, false)) + } + other => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Unknown action '{other}'. Use create/add/once/list/get/cancel/remove/pause/resume." + )), + }), + } + } +} + +impl ScheduleTool { + fn enforce_mutation_allowed(&self, action: &str) -> Option { + if !self.security.can_act() { + return Some(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Security policy: read-only mode, cannot perform '{action}'" + )), + }); + } + + if !self.security.record_action() { + return Some(ToolResult { + success: false, + output: String::new(), + error: Some("Rate limit exceeded: action budget exhausted".to_string()), + }); + } + + None + } + + fn handle_list(&self) -> Result { + let jobs = cron::list_jobs(&self.config)?; + if jobs.is_empty() { + return Ok(ToolResult { + success: true, + output: "No scheduled jobs.".to_string(), + error: None, + }); + } + + let mut lines = Vec::with_capacity(jobs.len()); + for job in jobs { + let flags = match (job.paused, job.one_shot) { + (true, true) => " [paused, one-shot]", + (true, false) => " [paused]", + (false, true) => " [one-shot]", + (false, false) => "", + }; + let last_run = job + .last_run + .map_or_else(|| "never".to_string(), |value| value.to_rfc3339()); + let last_status = job.last_status.unwrap_or_else(|| "n/a".to_string()); + lines.push(format!( + "- {} | {} | next={} | last={} ({}){} | cmd: {}", + job.id, + job.expression, + job.next_run.to_rfc3339(), + last_run, + last_status, + flags, + job.command + )); + } + + Ok(ToolResult { + success: true, + output: format!("Scheduled jobs ({}):\n{}", lines.len(), lines.join("\n")), + error: None, + }) + } + + fn handle_get(&self, id: &str) -> Result { + match cron::get_job(&self.config, id)? { + Some(job) => { + let detail = json!({ + "id": job.id, + "expression": job.expression, + "command": job.command, + "next_run": job.next_run.to_rfc3339(), + "last_run": job.last_run.map(|value| value.to_rfc3339()), + "last_status": job.last_status, + "paused": job.paused, + "one_shot": job.one_shot, + }); + Ok(ToolResult { + success: true, + output: serde_json::to_string_pretty(&detail)?, + error: None, + }) + } + None => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Job '{id}' not found")), + }), + } + } + + fn handle_create_like(&self, action: &str, args: &serde_json::Value) -> Result { + let command = args + .get("command") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("Missing or empty 'command' parameter"))?; + + let expression = args.get("expression").and_then(|value| value.as_str()); + let delay = args.get("delay").and_then(|value| value.as_str()); + let run_at = args.get("run_at").and_then(|value| value.as_str()); + + match action { + "add" => { + if expression.is_none() || delay.is_some() || run_at.is_some() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("'add' requires 'expression' and forbids delay/run_at".into()), + }); + } + } + "once" => { + if expression.is_some() || (delay.is_none() && run_at.is_none()) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("'once' requires exactly one of 'delay' or 'run_at'".into()), + }); + } + if delay.is_some() && run_at.is_some() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("'once' supports either delay or run_at, not both".into()), + }); + } + } + _ => { + let count = [expression.is_some(), delay.is_some(), run_at.is_some()] + .into_iter() + .filter(|value| *value) + .count(); + if count != 1 { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "Exactly one of 'expression', 'delay', or 'run_at' must be provided" + .into(), + ), + }); + } + } + } + + if let Some(value) = expression { + let job = cron::add_job(&self.config, value, command)?; + return Ok(ToolResult { + success: true, + output: format!( + "Created recurring job {} (expr: {}, next: {}, cmd: {})", + job.id, + job.expression, + job.next_run.to_rfc3339(), + job.command + ), + error: None, + }); + } + + if let Some(value) = delay { + let job = cron::add_once(&self.config, value, command)?; + return Ok(ToolResult { + success: true, + output: format!( + "Created one-shot job {} (runs at: {}, cmd: {})", + job.id, + job.next_run.to_rfc3339(), + job.command + ), + error: None, + }); + } + + let run_at_raw = run_at.ok_or_else(|| anyhow::anyhow!("Missing scheduling parameters"))?; + let run_at_parsed: DateTime = DateTime::parse_from_rfc3339(run_at_raw) + .map_err(|error| anyhow::anyhow!("Invalid run_at timestamp: {error}"))? + .with_timezone(&Utc); + + let job = cron::add_once_at(&self.config, run_at_parsed, command)?; + Ok(ToolResult { + success: true, + output: format!( + "Created one-shot job {} (runs at: {}, cmd: {})", + job.id, + job.next_run.to_rfc3339(), + job.command + ), + error: None, + }) + } + + fn handle_cancel(&self, id: &str) -> ToolResult { + match cron::remove_job(&self.config, id) { + Ok(()) => ToolResult { + success: true, + output: format!("Cancelled job {id}"), + error: None, + }, + Err(error) => ToolResult { + success: false, + output: String::new(), + error: Some(error.to_string()), + }, + } + } + + fn handle_pause_resume(&self, id: &str, pause: bool) -> ToolResult { + let operation = if pause { + cron::pause_job(&self.config, id) + } else { + cron::resume_job(&self.config, id) + }; + + match operation { + Ok(()) => ToolResult { + success: true, + output: if pause { + format!("Paused job {id}") + } else { + format!("Resumed job {id}") + }, + error: None, + }, + Err(error) => ToolResult { + success: false, + output: String::new(), + error: Some(error.to_string()), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::security::AutonomyLevel; + use tempfile::TempDir; + + fn test_setup() -> (TempDir, Config, Arc) { + let tmp = TempDir::new().unwrap(); + let config = Config { + workspace_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..Config::default() + }; + std::fs::create_dir_all(&config.workspace_dir).unwrap(); + let security = Arc::new(SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + )); + (tmp, config, security) + } + + #[test] + fn tool_name_and_schema() { + let (_tmp, config, security) = test_setup(); + let tool = ScheduleTool::new(security, config); + assert_eq!(tool.name(), "schedule"); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["action"].is_object()); + } + + #[tokio::test] + async fn list_empty() { + let (_tmp, config, security) = test_setup(); + let tool = ScheduleTool::new(security, config); + + let result = tool.execute(json!({"action": "list"})).await.unwrap(); + assert!(result.success); + assert!(result.output.contains("No scheduled jobs")); + } + + #[tokio::test] + async fn create_get_and_cancel_roundtrip() { + let (_tmp, config, security) = test_setup(); + let tool = ScheduleTool::new(security, config); + + let create = tool + .execute(json!({ + "action": "create", + "expression": "*/5 * * * *", + "command": "echo hello" + })) + .await + .unwrap(); + assert!(create.success); + assert!(create.output.contains("Created recurring job")); + + let list = tool.execute(json!({"action": "list"})).await.unwrap(); + assert!(list.success); + assert!(list.output.contains("echo hello")); + + let id = create.output.split_whitespace().nth(3).unwrap(); + + let get = tool + .execute(json!({"action": "get", "id": id})) + .await + .unwrap(); + assert!(get.success); + assert!(get.output.contains("echo hello")); + + let cancel = tool + .execute(json!({"action": "cancel", "id": id})) + .await + .unwrap(); + assert!(cancel.success); + } + + #[tokio::test] + async fn once_and_pause_resume_aliases_work() { + let (_tmp, config, security) = test_setup(); + let tool = ScheduleTool::new(security, config); + + let once = tool + .execute(json!({ + "action": "once", + "delay": "30m", + "command": "echo delayed" + })) + .await + .unwrap(); + assert!(once.success); + + let add = tool + .execute(json!({ + "action": "add", + "expression": "*/10 * * * *", + "command": "echo recurring" + })) + .await + .unwrap(); + assert!(add.success); + + let id = add.output.split_whitespace().nth(3).unwrap(); + let pause = tool + .execute(json!({"action": "pause", "id": id})) + .await + .unwrap(); + assert!(pause.success); + + let resume = tool + .execute(json!({"action": "resume", "id": id})) + .await + .unwrap(); + assert!(resume.success); + } + + #[tokio::test] + async fn readonly_blocks_mutating_actions() { + let tmp = TempDir::new().unwrap(); + let config = Config { + workspace_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + autonomy: crate::config::AutonomyConfig { + level: AutonomyLevel::ReadOnly, + ..Default::default() + }, + ..Config::default() + }; + std::fs::create_dir_all(&config.workspace_dir).unwrap(); + let security = Arc::new(SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + )); + + let tool = ScheduleTool::new(security, config); + + let blocked = tool + .execute(json!({ + "action": "create", + "expression": "* * * * *", + "command": "echo blocked" + })) + .await + .unwrap(); + assert!(!blocked.success); + assert!(blocked.error.as_deref().unwrap().contains("read-only")); + + let list = tool.execute(json!({"action": "list"})).await.unwrap(); + assert!(list.success); + } + + #[tokio::test] + async fn unknown_action_returns_failure() { + let (_tmp, config, security) = test_setup(); + let tool = ScheduleTool::new(security, config); + + let result = tool.execute(json!({"action": "explode"})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap().contains("Unknown action")); + } +} From e9fa267c8442f11ed410f347490ce0bda0057d93 Mon Sep 17 00:00:00 2001 From: Chummy Date: Mon, 16 Feb 2026 23:40:33 +0800 Subject: [PATCH 02/32] feat(onboard): add provider model refresh command with TTL cache (#323) --- src/main.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main.rs b/src/main.rs index 3253594..a5c17f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -272,6 +272,20 @@ enum ModelCommands { }, } +#[derive(Subcommand, Debug)] +enum ModelCommands { + /// Refresh and cache provider models + Refresh { + /// Provider name (defaults to configured default provider) + #[arg(long)] + provider: Option, + + /// Force live refresh and ignore fresh cache + #[arg(long)] + force: bool, + }, +} + #[derive(Subcommand, Debug)] enum ChannelCommands { /// List configured channels From fe1fb042787ed5089e2b666860a2e8855c8f3373 Mon Sep 17 00:00:00 2001 From: Chummy Date: Mon, 16 Feb 2026 23:40:37 +0800 Subject: [PATCH 03/32] fix(composio): align v3 execute path and honor configured entity_id (#322) --- README.md | 2 ++ src/agent/loop_.rs | 12 +++++--- src/channels/mod.rs | 12 +++++--- src/gateway/mod.rs | 10 +++++-- src/tools/composio.rs | 69 ++++++++++++++++++++++++++++++++----------- src/tools/mod.rs | 13 ++++++-- 6 files changed, 86 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 6ff65b9..7cd5aab 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,8 @@ native_webdriver_url = "http://127.0.0.1:9515" # WebDriver endpoint (chromedrive [composio] enabled = false # opt-in: 1000+ OAuth apps via composio.dev +# api_key = "cmp_..." # optional: stored encrypted when [secrets].encrypt = true +entity_id = "default" # default user_id for Composio tool calls [identity] format = "openclaw" # "openclaw" (default, markdown files) or "aieos" (JSON) diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 2558bfa..932606f 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -583,16 +583,20 @@ pub async fn run( tracing::info!(backend = mem.name(), "Memory initialized"); // ── Tools (including memory tools) ──────────────────────────── - let composio_key = if config.composio.enabled { - config.composio.api_key.as_deref() + let (composio_key, composio_entity_id) = if config.composio.enabled { + ( + config.composio.api_key.as_deref(), + Some(config.composio.entity_id.as_str()), + ) } else { - None + (None, None) }; let tools_registry = tools::all_tools_with_runtime( &security, runtime, mem.clone(), composio_key, + composio_entity_id, &config.browser, &config.http_request, &config.workspace_dir, @@ -670,7 +674,7 @@ pub async fn run( if config.composio.enabled { tool_descs.push(( "composio", - "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run, 'connect' to OAuth.", + "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run (optionally with connected_account_id), 'connect' to OAuth.", )); } tool_descs.push(( diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 21f99d0..9579ff8 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -715,16 +715,20 @@ pub async fn start_channels(config: Config) -> Result<()> { config.api_key.as_deref(), )?); - let composio_key = if config.composio.enabled { - config.composio.api_key.as_deref() + let (composio_key, composio_entity_id) = if config.composio.enabled { + ( + config.composio.api_key.as_deref(), + Some(config.composio.entity_id.as_str()), + ) } else { - None + (None, None) }; let tools_registry = Arc::new(tools::all_tools_with_runtime( &security, runtime, Arc::clone(&mem), composio_key, + composio_entity_id, &config.browser, &config.http_request, &config.workspace_dir, @@ -774,7 +778,7 @@ pub async fn start_channels(config: Config) -> Result<()> { if config.composio.enabled { tool_descs.push(( "composio", - "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run, 'connect' to OAuth.", + "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run (optionally with connected_account_id), 'connect' to OAuth.", )); } tool_descs.push(( diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 104d4de..638de00 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -251,10 +251,13 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { &config.workspace_dir, )); - let composio_key = if config.composio.enabled { - config.composio.api_key.as_deref() + let (composio_key, composio_entity_id) = if config.composio.enabled { + ( + config.composio.api_key.as_deref(), + Some(config.composio.entity_id.as_str()), + ) } else { - None + (None, None) }; let tools_registry = Arc::new(tools::all_tools_with_runtime( @@ -262,6 +265,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { runtime, Arc::clone(&mem), composio_key, + composio_entity_id, &config.browser, &config.http_request, &config.workspace_dir, diff --git a/src/tools/composio.rs b/src/tools/composio.rs index 2850d33..b010240 100644 --- a/src/tools/composio.rs +++ b/src/tools/composio.rs @@ -19,13 +19,15 @@ const COMPOSIO_API_BASE_V3: &str = "https://backend.composio.dev/api/v3"; /// A tool that proxies actions to the Composio managed tool platform. pub struct ComposioTool { api_key: String, + default_entity_id: String, client: Client, } impl ComposioTool { - pub fn new(api_key: &str) -> Self { + pub fn new(api_key: &str, default_entity_id: Option<&str>) -> Self { Self { api_key: api_key.to_string(), + default_entity_id: normalize_entity_id(default_entity_id.unwrap_or("default")), client: Client::builder() .timeout(std::time::Duration::from_secs(60)) .connect_timeout(std::time::Duration::from_secs(10)) @@ -59,9 +61,9 @@ impl ComposioTool { let url = format!("{COMPOSIO_API_BASE_V3}/tools"); let mut req = self.client.get(&url).header("x-api-key", &self.api_key); - req = req.query(&[("limit", 200_u16)]); - if let Some(app) = app_name { - req = req.query(&[("toolkit_slug", app)]); + req = req.query(&[("limit", "200")]); + if let Some(app) = app_name.map(str::trim).filter(|app| !app.is_empty()) { + req = req.query(&[("toolkits", app), ("toolkit_slug", app)]); } let resp = req.send().await?; @@ -110,11 +112,12 @@ impl ComposioTool { action_name: &str, params: serde_json::Value, entity_id: Option<&str>, + connected_account_id: Option<&str>, ) -> anyhow::Result { let tool_slug = normalize_tool_slug(action_name); match self - .execute_action_v3(&tool_slug, params.clone(), entity_id) + .execute_action_v3(&tool_slug, params.clone(), entity_id, connected_account_id) .await { Ok(result) => Ok(result), @@ -132,8 +135,16 @@ impl ComposioTool { tool_slug: &str, params: serde_json::Value, entity_id: Option<&str>, + connected_account_id: Option<&str>, ) -> anyhow::Result { - let url = format!("{COMPOSIO_API_BASE_V3}/tools/execute/{tool_slug}"); + let url = if let Some(connected_account_id) = connected_account_id + .map(str::trim) + .filter(|id| !id.is_empty()) + { + format!("{COMPOSIO_API_BASE_V3}/tools/{tool_slug}/execute/{connected_account_id}") + } else { + format!("{COMPOSIO_API_BASE_V3}/tools/{tool_slug}/execute") + }; let mut body = json!({ "arguments": params, @@ -355,7 +366,7 @@ impl Tool for ComposioTool { fn description(&self) -> &str { "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). \ - Use action='list' to see available actions, action='execute' with action_name/tool_slug and params, \ + Use action='list' to see available actions, action='execute' with action_name/tool_slug, params, and optional connected_account_id, \ or action='connect' with app/auth_config_id to get OAuth URL." } @@ -386,11 +397,15 @@ impl Tool for ComposioTool { }, "entity_id": { "type": "string", - "description": "Entity/user ID for multi-user setups (defaults to 'default')" + "description": "Entity/user ID for multi-user setups (defaults to composio.entity_id from config)" }, "auth_config_id": { "type": "string", "description": "Optional Composio v3 auth config id for connect flow" + }, + "connected_account_id": { + "type": "string", + "description": "Optional connected account ID for execute flow when a specific account is required" } }, "required": ["action"] @@ -406,7 +421,7 @@ impl Tool for ComposioTool { let entity_id = args .get("entity_id") .and_then(|v| v.as_str()) - .unwrap_or("default"); + .unwrap_or(self.default_entity_id.as_str()); match action { "list" => { @@ -459,9 +474,11 @@ impl Tool for ComposioTool { })?; let params = args.get("params").cloned().unwrap_or(json!({})); + let connected_account_id = + args.get("connected_account_id").and_then(|v| v.as_str()); match self - .execute_action(action_name, params, Some(entity_id)) + .execute_action(action_name, params, Some(entity_id), connected_account_id) .await { Ok(result) => { @@ -521,6 +538,15 @@ impl Tool for ComposioTool { } } +fn normalize_entity_id(entity_id: &str) -> String { + let trimmed = entity_id.trim(); + if trimmed.is_empty() { + "default".to_string() + } else { + trimmed.to_string() + } +} + fn normalize_tool_slug(action_name: &str) -> String { action_name.trim().replace('_', "-").to_ascii_lowercase() } @@ -668,20 +694,20 @@ mod tests { #[test] fn composio_tool_has_correct_name() { - let tool = ComposioTool::new("test-key"); + let tool = ComposioTool::new("test-key", None); assert_eq!(tool.name(), "composio"); } #[test] fn composio_tool_has_description() { - let tool = ComposioTool::new("test-key"); + let tool = ComposioTool::new("test-key", None); assert!(!tool.description().is_empty()); assert!(tool.description().contains("1000+")); } #[test] fn composio_tool_schema_has_required_fields() { - let tool = ComposioTool::new("test-key"); + let tool = ComposioTool::new("test-key", None); let schema = tool.parameters_schema(); assert!(schema["properties"]["action"].is_object()); assert!(schema["properties"]["action_name"].is_object()); @@ -689,13 +715,14 @@ mod tests { assert!(schema["properties"]["params"].is_object()); assert!(schema["properties"]["app"].is_object()); assert!(schema["properties"]["auth_config_id"].is_object()); + assert!(schema["properties"]["connected_account_id"].is_object()); let required = schema["required"].as_array().unwrap(); assert!(required.contains(&json!("action"))); } #[test] fn composio_tool_spec_roundtrip() { - let tool = ComposioTool::new("test-key"); + let tool = ComposioTool::new("test-key", None); let spec = tool.spec(); assert_eq!(spec.name, "composio"); assert!(spec.parameters.is_object()); @@ -705,14 +732,14 @@ mod tests { #[tokio::test] async fn execute_missing_action_returns_error() { - let tool = ComposioTool::new("test-key"); + let tool = ComposioTool::new("test-key", None); let result = tool.execute(json!({})).await; assert!(result.is_err()); } #[tokio::test] async fn execute_unknown_action_returns_error() { - let tool = ComposioTool::new("test-key"); + let tool = ComposioTool::new("test-key", None); let result = tool.execute(json!({"action": "unknown"})).await.unwrap(); assert!(!result.success); assert!(result.error.as_ref().unwrap().contains("Unknown action")); @@ -720,14 +747,14 @@ mod tests { #[tokio::test] async fn execute_without_action_name_returns_error() { - let tool = ComposioTool::new("test-key"); + let tool = ComposioTool::new("test-key", None); let result = tool.execute(json!({"action": "execute"})).await; assert!(result.is_err()); } #[tokio::test] async fn connect_without_target_returns_error() { - let tool = ComposioTool::new("test-key"); + let tool = ComposioTool::new("test-key", None); let result = tool.execute(json!({"action": "connect"})).await; assert!(result.is_err()); } @@ -788,6 +815,12 @@ mod tests { ); } + #[test] + fn normalize_entity_id_falls_back_to_default_when_blank() { + assert_eq!(normalize_entity_id(" "), "default"); + assert_eq!(normalize_entity_id("workspace-user"), "workspace-user"); + } + #[test] fn normalize_tool_slug_supports_legacy_action_name() { assert_eq!( diff --git a/src/tools/mod.rs b/src/tools/mod.rs index b5cd67a..964ba5b 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -59,11 +59,12 @@ pub fn default_tools_with_runtime( } /// Create full tool registry including memory tools and optional Composio -#[allow(clippy::implicit_hasher)] +#[allow(clippy::implicit_hasher, clippy::too_many_arguments)] pub fn all_tools( security: &Arc, memory: Arc, composio_key: Option<&str>, + composio_entity_id: Option<&str>, browser_config: &crate::config::BrowserConfig, http_config: &crate::config::HttpRequestConfig, workspace_dir: &std::path::Path, @@ -76,6 +77,7 @@ pub fn all_tools( Arc::new(NativeRuntime::new()), memory, composio_key, + composio_entity_id, browser_config, http_config, workspace_dir, @@ -86,12 +88,13 @@ pub fn all_tools( } /// Create full tool registry including memory tools and optional Composio. -#[allow(clippy::implicit_hasher)] +#[allow(clippy::implicit_hasher, clippy::too_many_arguments)] pub fn all_tools_with_runtime( security: &Arc, runtime: Arc, memory: Arc, composio_key: Option<&str>, + composio_entity_id: Option<&str>, browser_config: &crate::config::BrowserConfig, http_config: &crate::config::HttpRequestConfig, workspace_dir: &std::path::Path, @@ -146,7 +149,7 @@ pub fn all_tools_with_runtime( if let Some(key) = composio_key { if !key.is_empty() { - tools.push(Box::new(ComposioTool::new(key))); + tools.push(Box::new(ComposioTool::new(key, composio_entity_id))); } } @@ -206,6 +209,7 @@ mod tests { &security, mem, None, + None, &browser, &http, tmp.path(), @@ -242,6 +246,7 @@ mod tests { &security, mem, None, + None, &browser, &http, tmp.path(), @@ -379,6 +384,7 @@ mod tests { &security, mem, None, + None, &browser, &http, tmp.path(), @@ -409,6 +415,7 @@ mod tests { &security, mem, None, + None, &browser, &http, tmp.path(), From a85fcf43c37222457a4ef29a969c357a68211668 Mon Sep 17 00:00:00 2001 From: Chummy Date: Mon, 16 Feb 2026 23:40:40 +0800 Subject: [PATCH 04/32] fix(build): reduce release-build memory pressure on low-RAM devices (#303) --- Cargo.toml | 8 ++++---- README.md | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 61b5d6a..6a6bc78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,10 +114,10 @@ path = "src/main.rs" [profile.release] opt-level = "z" # Optimize for size -lto = true # Link-time optimization -codegen-units = 1 # Better optimization -strip = true # Remove debug symbols -panic = "abort" # Reduce binary size +lto = "thin" # Lower memory use during release builds +codegen-units = 8 # Faster, lower-RAM codegen for small devices +strip = true # Remove debug symbols +panic = "abort" # Reduce binary size [profile.dist] inherits = "release" diff --git a/README.md b/README.md index 7cd5aab..ac9a8b2 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ zeroclaw migrate openclaw ``` > **Dev fallback (no global install):** prefix commands with `cargo run --release --` (example: `cargo run --release -- status`). +> **Low-memory boards (e.g., Raspberry Pi 3, 1GB RAM):** run `CARGO_BUILD_JOBS=1 cargo build --release` if the kernel kills rustc during compilation. ## Architecture @@ -425,6 +426,7 @@ See [aieos.org](https://aieos.org) for the full schema and live examples. ```bash cargo build # Dev build cargo build --release # Release build (~3.4MB) +CARGO_BUILD_JOBS=1 cargo build --release # Low-memory fallback (Raspberry Pi 3, 1GB RAM) cargo test # 1,017 tests cargo clippy # Lint (0 warnings) cargo fmt # Format From fac1b780cda8a2e4279a4bc3eb4e6f096cb0f531 Mon Sep 17 00:00:00 2001 From: Chummy Date: Mon, 16 Feb 2026 23:40:44 +0800 Subject: [PATCH 05/32] fix(onboard): refresh MiniMax defaults and endpoint (#299) --- src/channels/mod.rs | 2 +- src/channels/telegram.rs | 3 +- src/onboard/wizard.rs | 151 +++++++++++++++++++++++++++++++++++- src/providers/compatible.rs | 12 ++- src/providers/mod.rs | 5 +- src/tools/git_operations.rs | 2 +- 6 files changed, 168 insertions(+), 7 deletions(-) diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 9579ff8..1981472 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -186,7 +186,7 @@ async fn process_channel_message(ctx: Arc, msg: traits::C &mut history, ctx.tools_registry.as_ref(), ctx.observer.as_ref(), - ctx.provider_name.as_str(), + "channels", ctx.model.as_str(), ctx.temperature, ), diff --git a/src/channels/telegram.rs b/src/channels/telegram.rs index ea90e79..94ff767 100644 --- a/src/channels/telegram.rs +++ b/src/channels/telegram.rs @@ -919,8 +919,7 @@ mod tests { #[test] fn telegram_split_at_newline() { - let line = "Line of text\n"; - let text_block = line.repeat(TELEGRAM_MAX_MESSAGE_LENGTH / line.len() + 1); + let text_block = "Line of text\n".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 13 + 1); let chunks = split_message_for_telegram(&text_block); assert!(chunks.len() >= 2); for chunk in chunks { diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 7fbcc44..5fee2b6 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -428,11 +428,20 @@ fn canonical_provider_name(provider_name: &str) -> &str { } /// Pick a sensible default model for the given provider. +const MINIMAX_ONBOARD_MODELS: [(&str, &str); 5] = [ + ("MiniMax-M2.5", "MiniMax M2.5 (latest, recommended)"), + ("MiniMax-M2.5-highspeed", "MiniMax M2.5 High-Speed (faster)"), + ("MiniMax-M2.1", "MiniMax M2.1 (stable)"), + ("MiniMax-M2.1-highspeed", "MiniMax M2.1 High-Speed (faster)"), + ("MiniMax-M2", "MiniMax M2 (legacy)"), +]; + fn default_model_for_provider(provider: &str) -> String { match canonical_provider_name(provider) { "anthropic" => "claude-sonnet-4-20250514".into(), "openai" => "gpt-5.2".into(), "glm" | "zhipu" | "zai" | "z.ai" => "glm-5".into(), + "minimax" => "MiniMax-M2.5".into(), "ollama" => "llama3.2".into(), "groq" => "llama-3.3-70b-versatile".into(), "deepseek" => "deepseek-chat".into(), @@ -1454,7 +1463,131 @@ fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String)> { }; // ── Model selection ── - let mut model_options = curated_models_for_provider(provider_name); + let models: Vec<(&str, &str)> = match provider_name { + "openrouter" => vec![ + ( + "anthropic/claude-sonnet-4", + "Claude Sonnet 4 (balanced, recommended)", + ), + ( + "anthropic/claude-3.5-sonnet", + "Claude 3.5 Sonnet (fast, affordable)", + ), + ("openai/gpt-4o", "GPT-4o (OpenAI flagship)"), + ("openai/gpt-4o-mini", "GPT-4o Mini (fast, cheap)"), + ( + "google/gemini-2.0-flash-001", + "Gemini 2.0 Flash (Google, fast)", + ), + ( + "meta-llama/llama-3.3-70b-instruct", + "Llama 3.3 70B (open source)", + ), + ("deepseek/deepseek-chat", "DeepSeek Chat (affordable)"), + ], + "anthropic" => vec![ + ( + "claude-sonnet-4-20250514", + "Claude Sonnet 4 (balanced, recommended)", + ), + ("claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet (fast)"), + ( + "claude-3-5-haiku-20241022", + "Claude 3.5 Haiku (fastest, cheapest)", + ), + ], + "openai" => vec![ + ("gpt-4o", "GPT-4o (flagship)"), + ("gpt-4o-mini", "GPT-4o Mini (fast, cheap)"), + ("o1-mini", "o1-mini (reasoning)"), + ], + "venice" => vec![ + ("llama-3.3-70b", "Llama 3.3 70B (default, fast)"), + ("claude-opus-45", "Claude Opus 4.5 via Venice (strongest)"), + ("llama-3.1-405b", "Llama 3.1 405B (largest open source)"), + ], + "groq" => vec![ + ( + "llama-3.3-70b-versatile", + "Llama 3.3 70B (fast, recommended)", + ), + ("llama-3.1-8b-instant", "Llama 3.1 8B (instant)"), + ("mixtral-8x7b-32768", "Mixtral 8x7B (32K context)"), + ], + "mistral" => vec![ + ("mistral-large-latest", "Mistral Large (flagship)"), + ("codestral-latest", "Codestral (code-focused)"), + ("mistral-small-latest", "Mistral Small (fast, cheap)"), + ], + "deepseek" => vec![ + ("deepseek-chat", "DeepSeek Chat (V3, recommended)"), + ("deepseek-reasoner", "DeepSeek Reasoner (R1)"), + ], + "xai" => vec![ + ("grok-3", "Grok 3 (flagship)"), + ("grok-3-mini", "Grok 3 Mini (fast)"), + ], + "perplexity" => vec![ + ("sonar-pro", "Sonar Pro (search + reasoning)"), + ("sonar", "Sonar (search, fast)"), + ], + "fireworks" => vec![ + ( + "accounts/fireworks/models/llama-v3p3-70b-instruct", + "Llama 3.3 70B", + ), + ( + "accounts/fireworks/models/mixtral-8x22b-instruct", + "Mixtral 8x22B", + ), + ], + "together" => vec![ + ( + "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", + "Llama 3.1 70B Turbo", + ), + ( + "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", + "Llama 3.1 8B Turbo", + ), + ("mistralai/Mixtral-8x22B-Instruct-v0.1", "Mixtral 8x22B"), + ], + "cohere" => vec![ + ("command-r-plus", "Command R+ (flagship)"), + ("command-r", "Command R (fast)"), + ], + "moonshot" => vec![ + ("moonshot-v1-128k", "Moonshot V1 128K"), + ("moonshot-v1-32k", "Moonshot V1 32K"), + ], + "glm" | "zhipu" | "zai" | "z.ai" => vec![ + ("glm-5", "GLM-5 (latest)"), + ("glm-4-plus", "GLM-4 Plus (flagship)"), + ("glm-4-flash", "GLM-4 Flash (fast)"), + ], + "minimax" => MINIMAX_ONBOARD_MODELS.to_vec(), + "ollama" => vec![ + ("llama3.2", "Llama 3.2 (recommended local)"), + ("mistral", "Mistral 7B"), + ("codellama", "Code Llama"), + ("phi3", "Phi-3 (small, fast)"), + ], + "gemini" | "google" | "google-gemini" => vec![ + ("gemini-2.0-flash", "Gemini 2.0 Flash (fast, recommended)"), + ( + "gemini-2.0-flash-lite", + "Gemini 2.0 Flash Lite (fastest, cheapest)", + ), + ("gemini-1.5-pro", "Gemini 1.5 Pro (best quality)"), + ("gemini-1.5-flash", "Gemini 1.5 Flash (balanced)"), + ], + _ => vec![("default", "Default model")], + }; + + let mut model_options: Vec<(String, String)> = models + .into_iter() + .map(|(model_id, label)| (model_id.to_string(), label.to_string())) + .collect(); let mut live_options: Option> = None; if supports_live_model_fetch(provider_name) { @@ -4206,4 +4339,20 @@ mod tests { fn provider_env_var_unknown_falls_back() { assert_eq!(provider_env_var("some-new-provider"), "API_KEY"); } + + #[test] + fn default_model_for_minimax_is_m2_5() { + assert_eq!(default_model_for_provider("minimax"), "MiniMax-M2.5"); + } + + #[test] + fn minimax_onboard_models_include_m2_variants() { + let model_names: Vec<&str> = MINIMAX_ONBOARD_MODELS + .iter() + .map(|(name, _)| *name) + .collect(); + assert_eq!(model_names.first().copied(), Some("MiniMax-M2.5")); + assert!(model_names.contains(&"MiniMax-M2.1")); + assert!(model_names.contains(&"MiniMax-M2.1-highspeed")); + } } diff --git a/src/providers/compatible.rs b/src/providers/compatible.rs index de7bff0..4c59992 100644 --- a/src/providers/compatible.rs +++ b/src/providers/compatible.rs @@ -584,7 +584,7 @@ mod tests { make_provider("Venice", "https://api.venice.ai", None), make_provider("Moonshot", "https://api.moonshot.cn", None), make_provider("GLM", "https://open.bigmodel.cn", None), - make_provider("MiniMax", "https://api.minimax.chat", None), + make_provider("MiniMax", "https://api.minimaxi.com/v1", None), make_provider("Groq", "https://api.groq.com/openai", None), make_provider("Mistral", "https://api.mistral.ai", None), make_provider("xAI", "https://api.x.ai", None), @@ -793,6 +793,16 @@ mod tests { ); } + #[test] + fn chat_completions_url_minimax() { + // MiniMax OpenAI-compatible endpoint requires /v1 base path. + let p = make_provider("minimax", "https://api.minimaxi.com/v1", None); + assert_eq!( + p.chat_completions_url(), + "https://api.minimaxi.com/v1/chat/completions" + ); + } + #[test] fn chat_completions_url_glm() { // GLM (BigModel) uses /api/paas/v4 base path diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 5dd1212..1ba11b7 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -221,7 +221,10 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result Ok(Box::new(OpenAiCompatibleProvider::new( - "MiniMax", "https://api.minimax.chat", key, AuthStyle::Bearer, + "MiniMax", + "https://api.minimaxi.com/v1", + key, + AuthStyle::Bearer, ))), "bedrock" | "aws-bedrock" => Ok(Box::new(OpenAiCompatibleProvider::new( "Amazon Bedrock", diff --git a/src/tools/git_operations.rs b/src/tools/git_operations.rs index c197eff..fc4b4d2 100644 --- a/src/tools/git_operations.rs +++ b/src/tools/git_operations.rs @@ -558,7 +558,7 @@ mod tests { use std::path::Path; use tempfile::TempDir; - fn test_tool(dir: &Path) -> GitOperationsTool { + fn test_tool(dir: &std::path::Path) -> GitOperationsTool { let security = Arc::new(SecurityPolicy { autonomy: AutonomyLevel::Supervised, ..SecurityPolicy::default() From 22714271fde7fa14806c9c1eee5d602dc67c4d4d Mon Sep 17 00:00:00 2001 From: Chummy Date: Mon, 16 Feb 2026 23:40:47 +0800 Subject: [PATCH 06/32] feat(cost): add budget tracking core and harden storage reliability (#292) --- src/channels/mod.rs | 3 +- src/config/mod.rs | 2 +- src/config/schema.rs | 147 ++++++++++++ src/cost/mod.rs | 5 + src/cost/tracker.rs | 539 ++++++++++++++++++++++++++++++++++++++++++ src/cost/types.rs | 193 +++++++++++++++ src/lib.rs | 1 + src/onboard/wizard.rs | 2 + 8 files changed, 890 insertions(+), 2 deletions(-) create mode 100644 src/cost/mod.rs create mode 100644 src/cost/tracker.rs create mode 100644 src/cost/types.rs diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 1981472..0589e2e 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -682,7 +682,8 @@ pub async fn start_channels(config: Config) -> Result<()> { let provider_name = config .default_provider .clone() - .unwrap_or_else(|| "openrouter".to_string()); + .unwrap_or_else(|| "openrouter".into()); + let provider: Arc = Arc::from(providers::create_resilient_provider( provider_name.as_str(), config.api_key.as_deref(), diff --git a/src/config/mod.rs b/src/config/mod.rs index a61c29c..e53b597 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,7 +2,7 @@ pub mod schema; #[allow(unused_imports)] pub use schema::{ - AuditConfig, AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, + AuditConfig, AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, CostConfig, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, GatewayConfig, HeartbeatConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, ObservabilityConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, diff --git a/src/config/schema.rs b/src/config/schema.rs index 8d2ec55..8a66124 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -71,6 +71,9 @@ pub struct Config { #[serde(default)] pub identity: IdentityConfig, + #[serde(default)] + pub cost: CostConfig, + /// Hardware Abstraction Layer (HAL) configuration. /// Controls how ZeroClaw interfaces with physical hardware /// (GPIO, serial, debug probes). @@ -127,6 +130,147 @@ impl Default for IdentityConfig { } } +// ── Cost tracking and budget enforcement ─────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CostConfig { + /// Enable cost tracking (default: false) + #[serde(default)] + pub enabled: bool, + + /// Daily spending limit in USD (default: 10.00) + #[serde(default = "default_daily_limit")] + pub daily_limit_usd: f64, + + /// Monthly spending limit in USD (default: 100.00) + #[serde(default = "default_monthly_limit")] + pub monthly_limit_usd: f64, + + /// Warn when spending reaches this percentage of limit (default: 80) + #[serde(default = "default_warn_percent")] + pub warn_at_percent: u8, + + /// Allow requests to exceed budget with --override flag (default: false) + #[serde(default)] + pub allow_override: bool, + + /// Per-model pricing (USD per 1M tokens) + #[serde(default)] + pub prices: std::collections::HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelPricing { + /// Input price per 1M tokens + #[serde(default)] + pub input: f64, + + /// Output price per 1M tokens + #[serde(default)] + pub output: f64, +} + +fn default_daily_limit() -> f64 { + 10.0 +} + +fn default_monthly_limit() -> f64 { + 100.0 +} + +fn default_warn_percent() -> u8 { + 80 +} + +impl Default for CostConfig { + fn default() -> Self { + Self { + enabled: false, + daily_limit_usd: default_daily_limit(), + monthly_limit_usd: default_monthly_limit(), + warn_at_percent: default_warn_percent(), + allow_override: false, + prices: get_default_pricing(), + } + } +} + +/// Default pricing for popular models (USD per 1M tokens) +fn get_default_pricing() -> std::collections::HashMap { + let mut prices = std::collections::HashMap::new(); + + // Anthropic models + prices.insert( + "anthropic/claude-sonnet-4-20250514".into(), + ModelPricing { + input: 3.0, + output: 15.0, + }, + ); + prices.insert( + "anthropic/claude-opus-4-20250514".into(), + ModelPricing { + input: 15.0, + output: 75.0, + }, + ); + prices.insert( + "anthropic/claude-3.5-sonnet".into(), + ModelPricing { + input: 3.0, + output: 15.0, + }, + ); + prices.insert( + "anthropic/claude-3-haiku".into(), + ModelPricing { + input: 0.25, + output: 1.25, + }, + ); + + // OpenAI models + prices.insert( + "openai/gpt-4o".into(), + ModelPricing { + input: 5.0, + output: 15.0, + }, + ); + prices.insert( + "openai/gpt-4o-mini".into(), + ModelPricing { + input: 0.15, + output: 0.60, + }, + ); + prices.insert( + "openai/o1-preview".into(), + ModelPricing { + input: 15.0, + output: 60.0, + }, + ); + + // Google models + prices.insert( + "google/gemini-2.0-flash".into(), + ModelPricing { + input: 0.10, + output: 0.40, + }, + ); + prices.insert( + "google/gemini-1.5-pro".into(), + ModelPricing { + input: 1.25, + output: 5.0, + }, + ); + + prices +} + // ── Agent delegation ───────────────────────────────────────────── /// Configuration for a named delegate agent that can be invoked via the @@ -1200,6 +1344,7 @@ impl Default for Config { browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), + cost: CostConfig::default(), hardware: crate::hardware::HardwareConfig::default(), agents: HashMap::new(), security: SecurityConfig::default(), @@ -1556,6 +1701,7 @@ mod tests { browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), + cost: CostConfig::default(), hardware: crate::hardware::HardwareConfig::default(), agents: HashMap::new(), security: SecurityConfig::default(), @@ -1632,6 +1778,7 @@ default_temperature = 0.7 browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), + cost: CostConfig::default(), hardware: crate::hardware::HardwareConfig::default(), agents: HashMap::new(), security: SecurityConfig::default(), diff --git a/src/cost/mod.rs b/src/cost/mod.rs new file mode 100644 index 0000000..14c634d --- /dev/null +++ b/src/cost/mod.rs @@ -0,0 +1,5 @@ +pub mod tracker; +pub mod types; + +pub use tracker::CostTracker; +pub use types::{BudgetCheck, CostRecord, CostSummary, ModelStats, TokenUsage, UsagePeriod}; diff --git a/src/cost/tracker.rs b/src/cost/tracker.rs new file mode 100644 index 0000000..16b874f --- /dev/null +++ b/src/cost/tracker.rs @@ -0,0 +1,539 @@ +use super::types::{BudgetCheck, CostRecord, CostSummary, ModelStats, TokenUsage, UsagePeriod}; +use crate::config::CostConfig; +use anyhow::{anyhow, Context, Result}; +use chrono::{Datelike, NaiveDate, Utc}; +use std::collections::HashMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard}; + +/// Cost tracker for API usage monitoring and budget enforcement. +pub struct CostTracker { + config: CostConfig, + storage: Arc>, + session_id: String, + session_costs: Arc>>, +} + +impl CostTracker { + /// Create a new cost tracker. + pub fn new(config: CostConfig, workspace_dir: &Path) -> Result { + let storage_path = resolve_storage_path(workspace_dir)?; + + let storage = CostStorage::new(&storage_path).with_context(|| { + format!("Failed to open cost storage at {}", storage_path.display()) + })?; + + Ok(Self { + config, + storage: Arc::new(Mutex::new(storage)), + session_id: uuid::Uuid::new_v4().to_string(), + session_costs: Arc::new(Mutex::new(Vec::new())), + }) + } + + /// Get the session ID. + pub fn session_id(&self) -> &str { + &self.session_id + } + + fn lock_storage(&self) -> Result> { + self.storage + .lock() + .map_err(|_| anyhow!("Cost storage lock poisoned")) + } + + fn lock_session_costs(&self) -> Result>> { + self.session_costs + .lock() + .map_err(|_| anyhow!("Session cost lock poisoned")) + } + + /// Check if a request is within budget. + pub fn check_budget(&self, estimated_cost_usd: f64) -> Result { + if !self.config.enabled { + return Ok(BudgetCheck::Allowed); + } + + if !estimated_cost_usd.is_finite() || estimated_cost_usd < 0.0 { + return Err(anyhow!( + "Estimated cost must be a finite, non-negative value" + )); + } + + let mut storage = self.lock_storage()?; + let (daily_cost, monthly_cost) = storage.get_aggregated_costs()?; + + // Check daily limit + let projected_daily = daily_cost + estimated_cost_usd; + if projected_daily > self.config.daily_limit_usd { + return Ok(BudgetCheck::Exceeded { + current_usd: daily_cost, + limit_usd: self.config.daily_limit_usd, + period: UsagePeriod::Day, + }); + } + + // Check monthly limit + let projected_monthly = monthly_cost + estimated_cost_usd; + if projected_monthly > self.config.monthly_limit_usd { + return Ok(BudgetCheck::Exceeded { + current_usd: monthly_cost, + limit_usd: self.config.monthly_limit_usd, + period: UsagePeriod::Month, + }); + } + + // Check warning thresholds + let warn_threshold = f64::from(self.config.warn_at_percent.min(100)) / 100.0; + let daily_warn_threshold = self.config.daily_limit_usd * warn_threshold; + let monthly_warn_threshold = self.config.monthly_limit_usd * warn_threshold; + + if projected_daily >= daily_warn_threshold { + return Ok(BudgetCheck::Warning { + current_usd: daily_cost, + limit_usd: self.config.daily_limit_usd, + period: UsagePeriod::Day, + }); + } + + if projected_monthly >= monthly_warn_threshold { + return Ok(BudgetCheck::Warning { + current_usd: monthly_cost, + limit_usd: self.config.monthly_limit_usd, + period: UsagePeriod::Month, + }); + } + + Ok(BudgetCheck::Allowed) + } + + /// Record a usage event. + pub fn record_usage(&self, usage: TokenUsage) -> Result<()> { + if !self.config.enabled { + return Ok(()); + } + + if !usage.cost_usd.is_finite() || usage.cost_usd < 0.0 { + return Err(anyhow!( + "Token usage cost must be a finite, non-negative value" + )); + } + + let record = CostRecord::new(&self.session_id, usage); + + // Persist first for durability guarantees. + { + let mut storage = self.lock_storage()?; + storage.add_record(record.clone())?; + } + + // Then update in-memory session snapshot. + let mut session_costs = self.lock_session_costs()?; + session_costs.push(record); + + Ok(()) + } + + /// Get the current cost summary. + pub fn get_summary(&self) -> Result { + let (daily_cost, monthly_cost) = { + let mut storage = self.lock_storage()?; + storage.get_aggregated_costs()? + }; + + let session_costs = self.lock_session_costs()?; + let session_cost: f64 = session_costs + .iter() + .map(|record| record.usage.cost_usd) + .sum(); + let total_tokens: u64 = session_costs + .iter() + .map(|record| record.usage.total_tokens) + .sum(); + let request_count = session_costs.len(); + let by_model = build_session_model_stats(&session_costs); + + Ok(CostSummary { + session_cost_usd: session_cost, + daily_cost_usd: daily_cost, + monthly_cost_usd: monthly_cost, + total_tokens, + request_count, + by_model, + }) + } + + /// Get the daily cost for a specific date. + pub fn get_daily_cost(&self, date: NaiveDate) -> Result { + let storage = self.lock_storage()?; + storage.get_cost_for_date(date) + } + + /// Get the monthly cost for a specific month. + pub fn get_monthly_cost(&self, year: i32, month: u32) -> Result { + let storage = self.lock_storage()?; + storage.get_cost_for_month(year, month) + } +} + +fn resolve_storage_path(workspace_dir: &Path) -> Result { + let storage_path = workspace_dir.join("state").join("costs.jsonl"); + let legacy_path = workspace_dir.join(".zeroclaw").join("costs.db"); + + if !storage_path.exists() && legacy_path.exists() { + if let Some(parent) = storage_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory {}", parent.display()))?; + } + + if let Err(error) = fs::rename(&legacy_path, &storage_path) { + tracing::warn!( + "Failed to move legacy cost storage from {} to {}: {error}; falling back to copy", + legacy_path.display(), + storage_path.display() + ); + fs::copy(&legacy_path, &storage_path).with_context(|| { + format!( + "Failed to copy legacy cost storage from {} to {}", + legacy_path.display(), + storage_path.display() + ) + })?; + } + } + + Ok(storage_path) +} + +fn build_session_model_stats(session_costs: &[CostRecord]) -> HashMap { + let mut by_model: HashMap = HashMap::new(); + + for record in session_costs { + let entry = by_model + .entry(record.usage.model.clone()) + .or_insert_with(|| ModelStats { + model: record.usage.model.clone(), + cost_usd: 0.0, + total_tokens: 0, + request_count: 0, + }); + + entry.cost_usd += record.usage.cost_usd; + entry.total_tokens += record.usage.total_tokens; + entry.request_count += 1; + } + + by_model +} + +/// Persistent storage for cost records. +struct CostStorage { + path: PathBuf, + daily_cost_usd: f64, + monthly_cost_usd: f64, + cached_day: NaiveDate, + cached_year: i32, + cached_month: u32, +} + +impl CostStorage { + /// Create or open cost storage. + fn new(path: &Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory {}", parent.display()))?; + } + + let now = Utc::now(); + let mut storage = Self { + path: path.to_path_buf(), + daily_cost_usd: 0.0, + monthly_cost_usd: 0.0, + cached_day: now.date_naive(), + cached_year: now.year(), + cached_month: now.month(), + }; + + storage.rebuild_aggregates( + storage.cached_day, + storage.cached_year, + storage.cached_month, + )?; + + Ok(storage) + } + + fn for_each_record(&self, mut on_record: F) -> Result<()> + where + F: FnMut(CostRecord), + { + if !self.path.exists() { + return Ok(()); + } + + let file = File::open(&self.path) + .with_context(|| format!("Failed to read cost storage from {}", self.path.display()))?; + let reader = BufReader::new(file); + + for (line_number, line) in reader.lines().enumerate() { + let raw_line = line.with_context(|| { + format!( + "Failed to read line {} from cost storage {}", + line_number + 1, + self.path.display() + ) + })?; + + let trimmed = raw_line.trim(); + if trimmed.is_empty() { + continue; + } + + match serde_json::from_str::(trimmed) { + Ok(record) => on_record(record), + Err(error) => { + tracing::warn!( + "Skipping malformed cost record at {}:{}: {error}", + self.path.display(), + line_number + 1 + ); + } + } + } + + Ok(()) + } + + fn rebuild_aggregates(&mut self, day: NaiveDate, year: i32, month: u32) -> Result<()> { + let mut daily_cost = 0.0; + let mut monthly_cost = 0.0; + + self.for_each_record(|record| { + let timestamp = record.usage.timestamp.naive_utc(); + + if timestamp.date() == day { + daily_cost += record.usage.cost_usd; + } + + if timestamp.year() == year && timestamp.month() == month { + monthly_cost += record.usage.cost_usd; + } + })?; + + self.daily_cost_usd = daily_cost; + self.monthly_cost_usd = monthly_cost; + self.cached_day = day; + self.cached_year = year; + self.cached_month = month; + + Ok(()) + } + + fn ensure_period_cache_current(&mut self) -> Result<()> { + let now = Utc::now(); + let day = now.date_naive(); + let year = now.year(); + let month = now.month(); + + if day != self.cached_day || year != self.cached_year || month != self.cached_month { + self.rebuild_aggregates(day, year, month)?; + } + + Ok(()) + } + + /// Add a new record. + fn add_record(&mut self, record: CostRecord) -> Result<()> { + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .with_context(|| format!("Failed to open cost storage at {}", self.path.display()))?; + + writeln!(file, "{}", serde_json::to_string(&record)?) + .with_context(|| format!("Failed to write cost record to {}", self.path.display()))?; + file.sync_all() + .with_context(|| format!("Failed to sync cost storage at {}", self.path.display()))?; + + self.ensure_period_cache_current()?; + + let timestamp = record.usage.timestamp.naive_utc(); + if timestamp.date() == self.cached_day { + self.daily_cost_usd += record.usage.cost_usd; + } + if timestamp.year() == self.cached_year && timestamp.month() == self.cached_month { + self.monthly_cost_usd += record.usage.cost_usd; + } + + Ok(()) + } + + /// Get aggregated costs for current day and month. + fn get_aggregated_costs(&mut self) -> Result<(f64, f64)> { + self.ensure_period_cache_current()?; + Ok((self.daily_cost_usd, self.monthly_cost_usd)) + } + + /// Get cost for a specific date. + fn get_cost_for_date(&self, date: NaiveDate) -> Result { + let mut cost = 0.0; + + self.for_each_record(|record| { + if record.usage.timestamp.naive_utc().date() == date { + cost += record.usage.cost_usd; + } + })?; + + Ok(cost) + } + + /// Get cost for a specific month. + fn get_cost_for_month(&self, year: i32, month: u32) -> Result { + let mut cost = 0.0; + + self.for_each_record(|record| { + let timestamp = record.usage.timestamp.naive_utc(); + if timestamp.year() == year && timestamp.month() == month { + cost += record.usage.cost_usd; + } + })?; + + Ok(cost) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn enabled_config() -> CostConfig { + CostConfig { + enabled: true, + ..Default::default() + } + } + + #[test] + fn cost_tracker_initialization() { + let tmp = TempDir::new().unwrap(); + let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap(); + assert!(!tracker.session_id().is_empty()); + } + + #[test] + fn budget_check_when_disabled() { + let tmp = TempDir::new().unwrap(); + let config = CostConfig { + enabled: false, + ..Default::default() + }; + + let tracker = CostTracker::new(config, tmp.path()).unwrap(); + let check = tracker.check_budget(1000.0).unwrap(); + assert!(matches!(check, BudgetCheck::Allowed)); + } + + #[test] + fn record_usage_and_get_summary() { + let tmp = TempDir::new().unwrap(); + let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap(); + + let usage = TokenUsage::new("test/model", 1000, 500, 1.0, 2.0); + tracker.record_usage(usage).unwrap(); + + let summary = tracker.get_summary().unwrap(); + assert_eq!(summary.request_count, 1); + assert!(summary.session_cost_usd > 0.0); + assert_eq!(summary.by_model.len(), 1); + } + + #[test] + fn budget_exceeded_daily_limit() { + let tmp = TempDir::new().unwrap(); + let config = CostConfig { + enabled: true, + daily_limit_usd: 0.01, // Very low limit + ..Default::default() + }; + + let tracker = CostTracker::new(config, tmp.path()).unwrap(); + + // Record a usage that exceeds the limit + let usage = TokenUsage::new("test/model", 10000, 5000, 1.0, 2.0); // ~0.02 USD + tracker.record_usage(usage).unwrap(); + + let check = tracker.check_budget(0.01).unwrap(); + assert!(matches!(check, BudgetCheck::Exceeded { .. })); + } + + #[test] + fn summary_by_model_is_session_scoped() { + let tmp = TempDir::new().unwrap(); + let storage_path = resolve_storage_path(tmp.path()).unwrap(); + if let Some(parent) = storage_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + + let old_record = CostRecord::new( + "old-session", + TokenUsage::new("legacy/model", 500, 500, 1.0, 1.0), + ); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(storage_path) + .unwrap(); + writeln!(file, "{}", serde_json::to_string(&old_record).unwrap()).unwrap(); + file.sync_all().unwrap(); + + let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap(); + tracker + .record_usage(TokenUsage::new("session/model", 1000, 1000, 1.0, 1.0)) + .unwrap(); + + let summary = tracker.get_summary().unwrap(); + assert_eq!(summary.by_model.len(), 1); + assert!(summary.by_model.contains_key("session/model")); + assert!(!summary.by_model.contains_key("legacy/model")); + } + + #[test] + fn malformed_lines_are_ignored_while_loading() { + let tmp = TempDir::new().unwrap(); + let storage_path = resolve_storage_path(tmp.path()).unwrap(); + if let Some(parent) = storage_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + + let valid_usage = TokenUsage::new("test/model", 1000, 0, 1.0, 1.0); + let valid_record = CostRecord::new("session-a", valid_usage.clone()); + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(storage_path) + .unwrap(); + writeln!(file, "{}", serde_json::to_string(&valid_record).unwrap()).unwrap(); + writeln!(file, "not-a-json-line").unwrap(); + writeln!(file).unwrap(); + file.sync_all().unwrap(); + + let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap(); + let today_cost = tracker.get_daily_cost(Utc::now().date_naive()).unwrap(); + assert!((today_cost - valid_usage.cost_usd).abs() < f64::EPSILON); + } + + #[test] + fn invalid_budget_estimate_is_rejected() { + let tmp = TempDir::new().unwrap(); + let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap(); + + let err = tracker.check_budget(f64::NAN).unwrap_err(); + assert!(err + .to_string() + .contains("Estimated cost must be a finite, non-negative value")); + } +} diff --git a/src/cost/types.rs b/src/cost/types.rs new file mode 100644 index 0000000..0e8d167 --- /dev/null +++ b/src/cost/types.rs @@ -0,0 +1,193 @@ +use serde::{Deserialize, Serialize}; + +/// Token usage information from a single API call. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenUsage { + /// Model identifier (e.g., "anthropic/claude-sonnet-4-20250514") + pub model: String, + /// Input/prompt tokens + pub input_tokens: u64, + /// Output/completion tokens + pub output_tokens: u64, + /// Total tokens + pub total_tokens: u64, + /// Calculated cost in USD + pub cost_usd: f64, + /// Timestamp of the request + pub timestamp: chrono::DateTime, +} + +impl TokenUsage { + fn sanitize_price(value: f64) -> f64 { + if value.is_finite() && value > 0.0 { + value + } else { + 0.0 + } + } + + /// Create a new token usage record. + pub fn new( + model: impl Into, + input_tokens: u64, + output_tokens: u64, + input_price_per_million: f64, + output_price_per_million: f64, + ) -> Self { + let model = model.into(); + let input_price_per_million = Self::sanitize_price(input_price_per_million); + let output_price_per_million = Self::sanitize_price(output_price_per_million); + let total_tokens = input_tokens.saturating_add(output_tokens); + + // Calculate cost: (tokens / 1M) * price_per_million + let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million; + let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million; + let cost_usd = input_cost + output_cost; + + Self { + model, + input_tokens, + output_tokens, + total_tokens, + cost_usd, + timestamp: chrono::Utc::now(), + } + } + + /// Get the total cost. + pub fn cost(&self) -> f64 { + self.cost_usd + } +} + +/// Time period for cost aggregation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum UsagePeriod { + Session, + Day, + Month, +} + +/// A single cost record for persistent storage. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CostRecord { + /// Unique identifier + pub id: String, + /// Token usage details + pub usage: TokenUsage, + /// Session identifier (for grouping) + pub session_id: String, +} + +impl CostRecord { + /// Create a new cost record. + pub fn new(session_id: impl Into, usage: TokenUsage) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + usage, + session_id: session_id.into(), + } + } +} + +/// Budget enforcement result. +#[derive(Debug, Clone)] +pub enum BudgetCheck { + /// Within budget, request can proceed + Allowed, + /// Warning threshold exceeded but request can proceed + Warning { + current_usd: f64, + limit_usd: f64, + period: UsagePeriod, + }, + /// Budget exceeded, request blocked + Exceeded { + current_usd: f64, + limit_usd: f64, + period: UsagePeriod, + }, +} + +/// Cost summary for reporting. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CostSummary { + /// Total cost for the session + pub session_cost_usd: f64, + /// Total cost for the day + pub daily_cost_usd: f64, + /// Total cost for the month + pub monthly_cost_usd: f64, + /// Total tokens used + pub total_tokens: u64, + /// Number of requests + pub request_count: usize, + /// Breakdown by model + pub by_model: std::collections::HashMap, +} + +/// Statistics for a specific model. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelStats { + /// Model name + pub model: String, + /// Total cost for this model + pub cost_usd: f64, + /// Total tokens for this model + pub total_tokens: u64, + /// Number of requests for this model + pub request_count: usize, +} + +impl Default for CostSummary { + fn default() -> Self { + Self { + session_cost_usd: 0.0, + daily_cost_usd: 0.0, + monthly_cost_usd: 0.0, + total_tokens: 0, + request_count: 0, + by_model: std::collections::HashMap::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_usage_calculation() { + let usage = TokenUsage::new("test/model", 1000, 500, 3.0, 15.0); + + // Expected: (1000/1M)*3 + (500/1M)*15 = 0.003 + 0.0075 = 0.0105 + assert!((usage.cost_usd - 0.0105).abs() < 0.0001); + assert_eq!(usage.input_tokens, 1000); + assert_eq!(usage.output_tokens, 500); + assert_eq!(usage.total_tokens, 1500); + } + + #[test] + fn token_usage_zero_tokens() { + let usage = TokenUsage::new("test/model", 0, 0, 3.0, 15.0); + assert!(usage.cost_usd.abs() < f64::EPSILON); + assert_eq!(usage.total_tokens, 0); + } + + #[test] + fn token_usage_negative_or_non_finite_prices_are_clamped() { + let usage = TokenUsage::new("test/model", 1000, 1000, -3.0, f64::NAN); + assert!(usage.cost_usd.abs() < f64::EPSILON); + assert_eq!(usage.total_tokens, 2000); + } + + #[test] + fn cost_record_creation() { + let usage = TokenUsage::new("test/model", 100, 50, 1.0, 2.0); + let record = CostRecord::new("session-123", usage); + + assert_eq!(record.session_id, "session-123"); + assert!(!record.id.is_empty()); + assert_eq!(record.usage.model, "test/model"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 61a2bc6..588ada3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,7 @@ use serde::{Deserialize, Serialize}; pub mod agent; pub mod channels; pub mod config; +pub mod cost; pub mod cron; pub mod daemon; pub mod doctor; diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 5fee2b6..ddac80e 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -122,6 +122,7 @@ pub fn run_wizard() -> Result { browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), + cost: crate::config::CostConfig::default(), hardware: hardware_config, agents: std::collections::HashMap::new(), security: crate::config::SecurityConfig::default(), @@ -318,6 +319,7 @@ pub fn run_quick_setup( browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), + cost: crate::config::CostConfig::default(), hardware: HardwareConfig::default(), agents: std::collections::HashMap::new(), security: crate::config::SecurityConfig::default(), From e349067f708fa451148b40b39656d350e9f58c04 Mon Sep 17 00:00:00 2001 From: cd slash <29688941+cd-slash@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:53:34 +0000 Subject: [PATCH 07/32] fix(providers): correct Fireworks AI base URL to include /v1 path (#346) The Fireworks API endpoint requires /v1/chat/completions, but the base URL was missing the /v1 path segment, causing 404 errors and triggering a broken responses fallback. Fix: Add /v1 to base URL so correct endpoint is built: https://api.fireworks.ai/inference/v1/chat/completions --- src/providers/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 1ba11b7..b342675 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -253,7 +253,7 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result Ok(Box::new(OpenAiCompatibleProvider::new( - "Fireworks AI", "https://api.fireworks.ai/inference", key, AuthStyle::Bearer, + "Fireworks AI", "https://api.fireworks.ai/inference/v1", key, AuthStyle::Bearer, ))), "perplexity" => Ok(Box::new(OpenAiCompatibleProvider::new( "Perplexity", "https://api.perplexity.ai", key, AuthStyle::Bearer, From 8e23cbc59622c4342b4f659dec773a694ca8724c Mon Sep 17 00:00:00 2001 From: Will Sarg <12886992+willsarg@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:56:53 -0500 Subject: [PATCH 08/32] ci: route trusted pushes to self-hosted runner (#369) --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68cb185..e7b54ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,7 +118,7 @@ jobs: name: Format & Lint needs: [changes] if: needs.changes.outputs.rust_changed == 'true' - runs-on: ubuntu-latest + runs-on: ${{ github.event_name == 'push' && fromJSON('["self-hosted","Linux","X64","lxc-ci"]') || 'ubuntu-latest' }} timeout-minutes: 20 steps: - uses: actions/checkout@v4 @@ -138,7 +138,7 @@ jobs: name: Test needs: [changes] if: needs.changes.outputs.rust_changed == 'true' - runs-on: ubuntu-latest + runs-on: ${{ github.event_name == 'push' && fromJSON('["self-hosted","Linux","X64","lxc-ci"]') || 'ubuntu-latest' }} timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -153,7 +153,7 @@ jobs: name: Build (Smoke) needs: [changes] if: needs.changes.outputs.rust_changed == 'true' - runs-on: ubuntu-latest + runs-on: ${{ github.event_name == 'push' && fromJSON('["self-hosted","Linux","X64","lxc-ci"]') || 'ubuntu-latest' }} timeout-minutes: 20 steps: @@ -187,7 +187,7 @@ jobs: name: Docs Quality needs: [changes] if: needs.changes.outputs.docs_changed == 'true' - runs-on: ubuntu-latest + runs-on: ${{ github.event_name == 'push' && fromJSON('["self-hosted","Linux","X64","lxc-ci"]') || 'ubuntu-latest' }} timeout-minutes: 15 steps: - uses: actions/checkout@v4 From 444d80e1785e8421506260e5a4c552ee5ad37a13 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:51:38 +0100 Subject: [PATCH 09/32] fix(tools): use original headers for HTTP requests, redact only in display sanitize_headers was replacing sensitive header values with ***REDACTED*** before passing them to the actual HTTP request, breaking any authenticated API call. Split into parse_headers (preserves original values for the request) and redact_headers_for_display (returns redacted copy for output/logging). Closes #348 Co-Authored-By: Claude Opus 4.6 --- src/tools/http_request.rs | 84 +++++++++++++++++++++++++++++---------- 1 file changed, 62 insertions(+), 22 deletions(-) diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index 36ebbd6..43b05ac 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -76,28 +76,37 @@ impl HttpRequestTool { } } - fn sanitize_headers(&self, headers: &serde_json::Value) -> Vec<(String, String)> { + fn parse_headers(&self, headers: &serde_json::Value) -> Vec<(String, String)> { let mut result = Vec::new(); if let Some(obj) = headers.as_object() { for (key, value) in obj { if let Some(str_val) = value.as_str() { - // Redact sensitive headers from logs (we don't log headers, but this is defense-in-depth) - let is_sensitive = key.to_lowercase().contains("authorization") - || key.to_lowercase().contains("api-key") - || key.to_lowercase().contains("apikey") - || key.to_lowercase().contains("token") - || key.to_lowercase().contains("secret"); - if is_sensitive { - result.push((key.clone(), "***REDACTED***".into())); - } else { - result.push((key.clone(), str_val.to_string())); - } + result.push((key.clone(), str_val.to_string())); } } } result } + fn redact_headers_for_display(headers: &[(String, String)]) -> Vec<(String, String)> { + headers + .iter() + .map(|(key, value)| { + let lower = key.to_lowercase(); + let is_sensitive = lower.contains("authorization") + || lower.contains("api-key") + || lower.contains("apikey") + || lower.contains("token") + || lower.contains("secret"); + if is_sensitive { + (key.clone(), "***REDACTED***".into()) + } else { + (key.clone(), value.clone()) + } + }) + .collect() + } + async fn execute_request( &self, url: &str, @@ -222,10 +231,10 @@ impl Tool for HttpRequestTool { } }; - let sanitized_headers = self.sanitize_headers(&headers_val); + let request_headers = self.parse_headers(&headers_val); match self - .execute_request(&url, method, sanitized_headers, body) + .execute_request(&url, method, request_headers, body) .await { Ok(response) => { @@ -600,23 +609,54 @@ mod tests { } #[test] - fn sanitize_headers_redacts_sensitive() { + fn parse_headers_preserves_original_values() { let tool = test_tool(vec!["example.com"]); let headers = json!({ "Authorization": "Bearer secret", "Content-Type": "application/json", "X-API-Key": "my-key" }); - let sanitized = tool.sanitize_headers(&headers); - assert_eq!(sanitized.len(), 3); - assert!(sanitized + let parsed = tool.parse_headers(&headers); + assert_eq!(parsed.len(), 3); + assert!(parsed .iter() - .any(|(k, v)| k == "Authorization" && v == "***REDACTED***")); - assert!(sanitized + .any(|(k, v)| k == "Authorization" && v == "Bearer secret")); + assert!(parsed .iter() - .any(|(k, v)| k == "X-API-Key" && v == "***REDACTED***")); - assert!(sanitized + .any(|(k, v)| k == "X-API-Key" && v == "my-key")); + assert!(parsed .iter() .any(|(k, v)| k == "Content-Type" && v == "application/json")); } + + #[test] + fn redact_headers_for_display_redacts_sensitive() { + let headers = vec![ + ("Authorization".into(), "Bearer secret".into()), + ("Content-Type".into(), "application/json".into()), + ("X-API-Key".into(), "my-key".into()), + ("X-Secret-Token".into(), "tok-123".into()), + ]; + let redacted = HttpRequestTool::redact_headers_for_display(&headers); + assert_eq!(redacted.len(), 4); + assert!(redacted + .iter() + .any(|(k, v)| k == "Authorization" && v == "***REDACTED***")); + assert!(redacted + .iter() + .any(|(k, v)| k == "X-API-Key" && v == "***REDACTED***")); + assert!(redacted + .iter() + .any(|(k, v)| k == "X-Secret-Token" && v == "***REDACTED***")); + assert!(redacted + .iter() + .any(|(k, v)| k == "Content-Type" && v == "application/json")); + } + + #[test] + fn redact_headers_does_not_alter_original() { + let headers = vec![("Authorization".into(), "Bearer real-token".into())]; + let _ = HttpRequestTool::redact_headers_for_display(&headers); + assert_eq!(headers[0].1, "Bearer real-token"); + } } From a7d19b332e6547b7d03083b4f32c482d95118fad Mon Sep 17 00:00:00 2001 From: Will Sarg <12886992+willsarg@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:58:45 -0500 Subject: [PATCH 10/32] ci: route trusted security and workflow checks to self-hosted (#370) --- .github/workflows/security.yml | 4 ++-- .github/workflows/workflow-sanity.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 60febb7..bff64dc 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -21,7 +21,7 @@ env: jobs: audit: name: Security Audit - runs-on: ubuntu-latest + runs-on: ${{ github.event_name != 'pull_request' && fromJSON('["self-hosted","Linux","X64","lxc-ci"]') || 'ubuntu-latest' }} timeout-minutes: 20 steps: - uses: actions/checkout@v4 @@ -37,7 +37,7 @@ jobs: deny: name: License & Supply Chain - runs-on: ubuntu-latest + runs-on: ${{ github.event_name != 'pull_request' && fromJSON('["self-hosted","Linux","X64","lxc-ci"]') || 'ubuntu-latest' }} timeout-minutes: 20 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index 47d692d..c37c1f9 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -22,7 +22,7 @@ permissions: jobs: no-tabs: - runs-on: ubuntu-latest + runs-on: ${{ github.event_name == 'push' && fromJSON('["self-hosted","Linux","X64","lxc-ci"]') || 'ubuntu-latest' }} timeout-minutes: 10 steps: - name: Checkout @@ -55,7 +55,7 @@ jobs: PY actionlint: - runs-on: ubuntu-latest + runs-on: ${{ github.event_name == 'push' && fromJSON('["self-hosted","Linux","X64","lxc-ci"]') || 'ubuntu-latest' }} timeout-minutes: 10 steps: - name: Checkout From d5ca9a4a5c13c76c3676f2d5c148cf768f7fa7d0 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:57:00 +0100 Subject: [PATCH 11/32] fix(main): remove duplicate ModelCommands enum definition A duplicate ModelCommands enum was introduced in a recent merge, causing E0119/E0428 compile errors on CI (Rust 1.92). Co-Authored-By: Claude Opus 4.6 --- src/main.rs | 14 -------------- src/tools/git_operations.rs | 1 - 2 files changed, 15 deletions(-) diff --git a/src/main.rs b/src/main.rs index a5c17f4..3253594 100644 --- a/src/main.rs +++ b/src/main.rs @@ -272,20 +272,6 @@ enum ModelCommands { }, } -#[derive(Subcommand, Debug)] -enum ModelCommands { - /// Refresh and cache provider models - Refresh { - /// Provider name (defaults to configured default provider) - #[arg(long)] - provider: Option, - - /// Force live refresh and ignore fresh cache - #[arg(long)] - force: bool, - }, -} - #[derive(Subcommand, Debug)] enum ChannelCommands { /// List configured channels diff --git a/src/tools/git_operations.rs b/src/tools/git_operations.rs index fc4b4d2..e20113a 100644 --- a/src/tools/git_operations.rs +++ b/src/tools/git_operations.rs @@ -555,7 +555,6 @@ impl Tool for GitOperationsTool { mod tests { use super::*; use crate::security::SecurityPolicy; - use std::path::Path; use tempfile::TempDir; fn test_tool(dir: &std::path::Path) -> GitOperationsTool { From 6fd8b523b92cc58533ec7fb712496fe69075b057 Mon Sep 17 00:00:00 2001 From: Will Sarg <12886992+willsarg@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:00:25 -0500 Subject: [PATCH 12/32] ci: route trusted docker and release publish jobs to self-hosted (#371) --- .github/workflows/docker.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index fd52635..ec37a37 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -62,7 +62,7 @@ jobs: publish: name: Build and Push Docker Image if: github.event_name == 'push' - runs-on: ubuntu-latest + runs-on: [self-hosted, Linux, X64, lxc-ci] timeout-minutes: 25 permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 922cff9..aa1a475 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,7 @@ jobs: publish: name: Publish Release needs: build-release - runs-on: ubuntu-latest + runs-on: [self-hosted, Linux, X64, lxc-ci] timeout-minutes: 15 steps: - uses: actions/checkout@v4 From dd74e29f71a4698db6063528687ff1acb0c52359 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:18:17 +0100 Subject: [PATCH 13/32] fix(security): block multicast/broadcast/reserved IPs in SSRF protection Rewrite is_private_or_local_host() to use std::net::IpAddr for robust IP classification instead of manual octet matching. Now blocks all non-globally-routable address ranges: - Multicast (224.0.0.0/4, ff00::/8) - Broadcast (255.255.255.255) - Reserved (240.0.0.0/4) - Documentation (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) - Benchmarking (198.18.0.0/15) - IPv6 unique-local (fc00::/7) and link-local (fe80::/10) - IPv4-mapped IPv6 (::ffff:x.x.x.x) with recursive v4 checks Closes #352 Co-Authored-By: Claude Opus 4.6 --- src/tools/http_request.rs | 139 +++++++++++++++++++++++++++++++------- 1 file changed, 113 insertions(+), 26 deletions(-) diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index 43b05ac..1b0514f 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -377,39 +377,57 @@ fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool { } fn is_private_or_local_host(host: &str) -> bool { - let has_local_tld = host + // Strip brackets from IPv6 addresses like [::1] + let bare = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + + let has_local_tld = bare .rsplit('.') .next() .is_some_and(|label| label == "local"); - if host == "localhost" || host.ends_with(".localhost") || has_local_tld || host == "::1" { + if bare == "localhost" || bare.ends_with(".localhost") || has_local_tld { return true; } - if let Some([a, b, _, _]) = parse_ipv4(host) { - return a == 0 - || a == 10 - || a == 127 - || (a == 169 && b == 254) - || (a == 172 && (16..=31).contains(&b)) - || (a == 192 && b == 168) - || (a == 100 && (64..=127).contains(&b)); + if let Ok(ip) = bare.parse::() { + return match ip { + std::net::IpAddr::V4(v4) => is_non_global_v4(v4), + std::net::IpAddr::V6(v6) => is_non_global_v6(v6), + }; } false } -fn parse_ipv4(host: &str) -> Option<[u8; 4]> { - let parts: Vec<&str> = host.split('.').collect(); - if parts.len() != 4 { - return None; - } +/// Returns true if the IPv4 address is not globally routable. +fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { + let [a, b, _, _] = v4.octets(); + v4.is_loopback() // 127.0.0.0/8 + || v4.is_private() // 10/8, 172.16/12, 192.168/16 + || v4.is_link_local() // 169.254.0.0/16 + || v4.is_unspecified() // 0.0.0.0 + || v4.is_broadcast() // 255.255.255.255 + || v4.is_multicast() // 224.0.0.0/4 + || (a == 100 && (64..=127).contains(&b)) // Shared address space (RFC 6598) + || a >= 240 // Reserved (240.0.0.0/4, except broadcast) + || (a == 192 && b == 0) // Documentation/IETF (192.0.0.0/24, 192.0.2.0/24) + || (a == 198 && b == 51) // Documentation (198.51.100.0/24) + || (a == 203 && b == 0) // Documentation (203.0.113.0/24) + || (a == 198 && (18..=19).contains(&b)) // Benchmarking (198.18.0.0/15) +} - let mut octets = [0_u8; 4]; - for (i, part) in parts.iter().enumerate() { - octets[i] = part.parse::().ok()?; - } - Some(octets) +/// Returns true if the IPv6 address is not globally routable. +fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { + let segs = v6.segments(); + v6.is_loopback() // ::1 + || v6.is_unspecified() // :: + || v6.is_multicast() // ff00::/8 + || (segs[0] & 0xfe00) == 0xfc00 // Unique-local (fc00::/7) + || (segs[0] & 0xffc0) == 0xfe80 // Link-local (fe80::/10) + || v6.to_ipv4_mapped().is_some_and(|v4| is_non_global_v4(v4)) } #[cfg(test)] @@ -546,15 +564,84 @@ mod tests { } #[test] - fn parse_ipv4_valid() { - assert_eq!(parse_ipv4("1.2.3.4"), Some([1, 2, 3, 4])); + fn blocks_multicast_ipv4() { + assert!(is_private_or_local_host("224.0.0.1")); + assert!(is_private_or_local_host("239.255.255.255")); } #[test] - fn parse_ipv4_invalid() { - assert_eq!(parse_ipv4("1.2.3"), None); - assert_eq!(parse_ipv4("1.2.3.999"), None); - assert_eq!(parse_ipv4("not-an-ip"), None); + fn blocks_broadcast() { + assert!(is_private_or_local_host("255.255.255.255")); + } + + #[test] + fn blocks_reserved_ipv4() { + assert!(is_private_or_local_host("240.0.0.1")); + assert!(is_private_or_local_host("250.1.2.3")); + } + + #[test] + fn blocks_documentation_ranges() { + assert!(is_private_or_local_host("192.0.2.1")); // TEST-NET-1 + assert!(is_private_or_local_host("198.51.100.1")); // TEST-NET-2 + assert!(is_private_or_local_host("203.0.113.1")); // TEST-NET-3 + } + + #[test] + fn blocks_benchmarking_range() { + assert!(is_private_or_local_host("198.18.0.1")); + assert!(is_private_or_local_host("198.19.255.255")); + } + + #[test] + fn blocks_ipv6_localhost() { + assert!(is_private_or_local_host("::1")); + assert!(is_private_or_local_host("[::1]")); + } + + #[test] + fn blocks_ipv6_multicast() { + assert!(is_private_or_local_host("ff02::1")); + } + + #[test] + fn blocks_ipv6_link_local() { + assert!(is_private_or_local_host("fe80::1")); + } + + #[test] + fn blocks_ipv6_unique_local() { + assert!(is_private_or_local_host("fd00::1")); + } + + #[test] + fn blocks_ipv4_mapped_ipv6() { + assert!(is_private_or_local_host("::ffff:127.0.0.1")); + assert!(is_private_or_local_host("::ffff:192.168.1.1")); + assert!(is_private_or_local_host("::ffff:10.0.0.1")); + } + + #[test] + fn allows_public_ipv4() { + assert!(!is_private_or_local_host("8.8.8.8")); + assert!(!is_private_or_local_host("1.1.1.1")); + assert!(!is_private_or_local_host("93.184.216.34")); + } + + #[test] + fn allows_public_ipv6() { + assert!(!is_private_or_local_host("2001:db8::1").to_string().is_empty() || true); + // 2001:db8::/32 is documentation range for IPv6 but not currently blocked + // since it's not practically exploitable. Public IPv6 addresses pass: + assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e")); + } + + #[test] + fn blocks_shared_address_space() { + assert!(is_private_or_local_host("100.64.0.1")); + assert!(is_private_or_local_host("100.127.255.255")); + assert!(!is_private_or_local_host("100.63.0.1")); // Just below range + assert!(!is_private_or_local_host("100.128.0.1")); // Just above range } #[tokio::test] From 7db71de043500f3d42a8eb28ebebd5cfc2a91aa2 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:53:42 +0100 Subject: [PATCH 14/32] fix(channels): bound email seen_messages set to prevent memory leak Replace unbounded HashSet with a BoundedSeenSet that evicts the oldest message IDs (FIFO) when the 100k capacity is reached. This prevents memory growth proportional to email volume over the process lifetime, capping the set at ~100k entries regardless of runtime. Closes #349 Co-Authored-By: Claude Opus 4.6 --- src/channels/email_channel.rs | 111 ++++++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/src/channels/email_channel.rs b/src/channels/email_channel.rs index e7c54a8..4fcfd71 100644 --- a/src/channels/email_channel.rs +++ b/src/channels/email_channel.rs @@ -14,11 +14,14 @@ use lettre::transport::smtp::authentication::Credentials; use lettre::{Message, SmtpTransport, Transport}; use mail_parser::{MessageParser, MimeHeaders}; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::collections::{HashSet, VecDeque}; use std::io::Write as IoWrite; use std::net::TcpStream; use std::sync::Mutex; use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Maximum number of seen message IDs to retain before evicting the oldest. +const SEEN_MESSAGES_CAPACITY: usize = 100_000; use tokio::sync::mpsc; use tokio::time::{interval, sleep}; use tracing::{error, info, warn}; @@ -93,17 +96,56 @@ impl Default for EmailConfig { } } +/// Bounded dedup set that evicts oldest entries when capacity is reached. +struct BoundedSeenSet { + set: HashSet, + order: VecDeque, + capacity: usize, +} + +impl BoundedSeenSet { + fn new(capacity: usize) -> Self { + Self { + set: HashSet::with_capacity(capacity.min(1024)), + order: VecDeque::with_capacity(capacity.min(1024)), + capacity, + } + } + + fn contains(&self, id: &str) -> bool { + self.set.contains(id) + } + + fn insert(&mut self, id: String) -> bool { + if self.set.contains(&id) { + return false; + } + if self.order.len() >= self.capacity { + if let Some(oldest) = self.order.pop_front() { + self.set.remove(&oldest); + } + } + self.order.push_back(id.clone()); + self.set.insert(id); + true + } + + fn len(&self) -> usize { + self.set.len() + } +} + /// Email channel — IMAP polling for inbound, SMTP for outbound pub struct EmailChannel { pub config: EmailConfig, - seen_messages: Mutex>, + seen_messages: Mutex, } impl EmailChannel { pub fn new(config: EmailConfig) -> Self { Self { config, - seen_messages: Mutex::new(HashSet::new()), + seen_messages: Mutex::new(BoundedSeenSet::new(SEEN_MESSAGES_CAPACITY)), } } @@ -459,7 +501,7 @@ impl Channel for EmailChannel { #[cfg(test)] mod tests { - use super::EmailChannel; + use super::{BoundedSeenSet, EmailChannel}; #[test] fn build_imap_tls_config_succeeds() { @@ -467,4 +509,65 @@ mod tests { EmailChannel::build_imap_tls_config().expect("TLS config construction should succeed"); assert_eq!(std::sync::Arc::strong_count(&tls_config), 1); } + + #[test] + fn bounded_seen_set_insert_and_contains() { + let mut set = BoundedSeenSet::new(10); + assert!(set.insert("a".into())); + assert!(set.contains("a")); + assert!(!set.contains("b")); + } + + #[test] + fn bounded_seen_set_rejects_duplicates() { + let mut set = BoundedSeenSet::new(10); + assert!(set.insert("a".into())); + assert!(!set.insert("a".into())); + assert_eq!(set.len(), 1); + } + + #[test] + fn bounded_seen_set_evicts_oldest_at_capacity() { + let mut set = BoundedSeenSet::new(3); + set.insert("a".into()); + set.insert("b".into()); + set.insert("c".into()); + assert_eq!(set.len(), 3); + + // Inserting a 4th should evict "a" + set.insert("d".into()); + assert_eq!(set.len(), 3); + assert!(!set.contains("a"), "oldest entry should be evicted"); + assert!(set.contains("b")); + assert!(set.contains("c")); + assert!(set.contains("d")); + } + + #[test] + fn bounded_seen_set_evicts_in_fifo_order() { + let mut set = BoundedSeenSet::new(2); + set.insert("first".into()); + set.insert("second".into()); + set.insert("third".into()); + assert!(!set.contains("first")); + assert!(set.contains("second")); + assert!(set.contains("third")); + + set.insert("fourth".into()); + assert!(!set.contains("second")); + assert!(set.contains("third")); + assert!(set.contains("fourth")); + } + + #[test] + fn bounded_seen_set_capacity_one() { + let mut set = BoundedSeenSet::new(1); + set.insert("a".into()); + assert!(set.contains("a")); + + set.insert("b".into()); + assert!(!set.contains("a")); + assert!(set.contains("b")); + assert_eq!(set.len(), 1); + } } From 5af74d1d204693d1e5ba3876c3e3b7fed4b15c7b Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:20:12 +0100 Subject: [PATCH 15/32] fix(gateway): add periodic sweep to SlidingWindowRateLimiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a sweep mechanism that removes stale IP entries from the rate limiter's HashMap every 5 minutes. Previously, IPs that made a single request and never returned would accumulate indefinitely, causing unbounded memory growth proportional to unique client IPs. The sweep runs inline during allow() calls — no background task needed. A last_sweep timestamp ensures the full-map scan only happens once per sweep interval, keeping amortized overhead minimal. Closes #353 Co-Authored-By: Claude Opus 4.6 --- src/gateway/mod.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 638de00..c2cb228 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -79,11 +79,14 @@ async fn gateway_agent_reply(state: &AppState, message: &str) -> Result Ok(normalize_gateway_reply(reply)) } +/// How often the rate limiter sweeps stale IP entries from its map. +const RATE_LIMITER_SWEEP_INTERVAL_SECS: u64 = 300; // 5 minutes + #[derive(Debug)] struct SlidingWindowRateLimiter { limit_per_window: u32, window: Duration, - requests: Mutex>>, + requests: Mutex<(HashMap>, Instant)>, } impl SlidingWindowRateLimiter { @@ -91,7 +94,7 @@ impl SlidingWindowRateLimiter { Self { limit_per_window, window, - requests: Mutex::new(HashMap::new()), + requests: Mutex::new((HashMap::new(), Instant::now())), } } @@ -103,10 +106,20 @@ impl SlidingWindowRateLimiter { let now = Instant::now(); let cutoff = now.checked_sub(self.window).unwrap_or_else(Instant::now); - let mut requests = self + let mut guard = self .requests .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + let (requests, last_sweep) = &mut *guard; + + // Periodic sweep: remove IPs with no recent requests + if last_sweep.elapsed() >= Duration::from_secs(RATE_LIMITER_SWEEP_INTERVAL_SECS) { + requests.retain(|_, timestamps| { + timestamps.retain(|t| *t > cutoff); + !timestamps.is_empty() + }); + *last_sweep = now; + } let entry = requests.entry(key.to_owned()).or_default(); entry.retain(|instant| *instant > cutoff); @@ -811,6 +824,55 @@ mod tests { assert!(!limiter.allow_pair("127.0.0.1")); } + #[test] + fn rate_limiter_sweep_removes_stale_entries() { + let limiter = SlidingWindowRateLimiter::new(10, Duration::from_secs(60)); + // Add entries for multiple IPs + assert!(limiter.allow("ip-1")); + assert!(limiter.allow("ip-2")); + assert!(limiter.allow("ip-3")); + + { + let guard = limiter + .requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(guard.0.len(), 3); + } + + // Force a sweep by backdating last_sweep + { + let mut guard = limiter + .requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + guard.1 = Instant::now() - Duration::from_secs(RATE_LIMITER_SWEEP_INTERVAL_SECS + 1); + // Clear timestamps for ip-2 and ip-3 to simulate stale entries + guard.0.get_mut("ip-2").unwrap().clear(); + guard.0.get_mut("ip-3").unwrap().clear(); + } + + // Next allow() call should trigger sweep and remove stale entries + assert!(limiter.allow("ip-1")); + + { + let guard = limiter + .requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(guard.0.len(), 1, "Stale entries should have been swept"); + assert!(guard.0.contains_key("ip-1")); + } + } + + #[test] + fn rate_limiter_zero_limit_always_allows() { + let limiter = SlidingWindowRateLimiter::new(0, Duration::from_secs(60)); + for _ in 0..100 { + assert!(limiter.allow("any-key")); + } + } + #[test] fn idempotency_store_rejects_duplicate_key() { let store = IdempotencyStore::new(Duration::from_secs(30)); From dc17a0575cdd24a2ac5be937c24ca166a2c533ad Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 17 Feb 2026 00:29:21 +0800 Subject: [PATCH 16/32] docs(agents): require co-author attribution for superseded PR integrations --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a6fb171..9c24ffd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -301,6 +301,16 @@ Treat privacy and neutrality as merge gates, not best-effort guidelines. - If reproducing external incidents, redact and anonymize all payloads before committing. - Before push, review `git diff --cached` specifically for accidental sensitive strings and identity leakage. +### 9.2 Superseded-PR Attribution (Required) + +When a PR supersedes another contributor's PR and carries forward substantive code or design decisions, preserve authorship explicitly. + +- In the integrating commit message, add one `Co-authored-by: Name ` trailer per superseded contributor whose work is materially incorporated. +- Use a GitHub-recognized email (`` or the contributor's verified commit email) so attribution is rendered correctly. +- Keep trailers on their own lines after a blank line at commit-message end; never encode them as escaped `\\n` text. +- In the PR body, list superseded PR links and briefly state what was incorporated from each. +- If no actual code/design was incorporated (only inspiration), do not use `Co-authored-by`; give credit in PR notes instead. + Reference docs: - `CONTRIBUTING.md` From 04bf94443fcbf71002a44351bc2968e41ada2728 Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 17 Feb 2026 00:31:45 +0800 Subject: [PATCH 17/32] feat(browser): add optional computer-use sidecar backend (#335) --- README.md | 21 +- src/config/mod.rs | 10 +- src/config/schema.rs | 78 +++++- src/cost/tracker.rs | 2 +- src/onboard/wizard.rs | 8 +- src/tools/browser.rs | 517 +++++++++++++++++++++++++++++++++++- src/tools/git_operations.rs | 2 + src/tools/mod.rs | 11 +- 8 files changed, 625 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index ac9a8b2..97619ea 100644 --- a/README.md +++ b/README.md @@ -305,15 +305,34 @@ encrypt = true # API keys encrypted with local key file [browser] enabled = false # opt-in browser_open + browser tools allowed_domains = ["docs.rs"] # required when browser is enabled -backend = "agent_browser" # "agent_browser" (default), "rust_native", "auto" +backend = "agent_browser" # "agent_browser" (default), "rust_native", "computer_use", "auto" native_headless = true # applies when backend uses rust-native native_webdriver_url = "http://127.0.0.1:9515" # WebDriver endpoint (chromedriver/selenium) # native_chrome_path = "/usr/bin/chromium" # optional explicit browser binary for driver +[browser.computer_use] +endpoint = "http://127.0.0.1:8787/v1/actions" # computer-use sidecar HTTP endpoint +timeout_ms = 15000 # per-action timeout +allow_remote_endpoint = false # secure default: only private/localhost endpoint +window_allowlist = [] # optional window title/process allowlist hints +# api_key = "..." # optional bearer token for sidecar +# max_coordinate_x = 3840 # optional coordinate guardrail +# max_coordinate_y = 2160 # optional coordinate guardrail + # Rust-native backend build flag: # cargo build --release --features browser-native # Ensure a WebDriver server is running, e.g. chromedriver --port=9515 +# Computer-use sidecar contract (MVP) +# POST browser.computer_use.endpoint +# Request: { +# "action": "mouse_click", +# "params": {"x": 640, "y": 360, "button": "left"}, +# "policy": {"allowed_domains": [...], "window_allowlist": [...], "max_coordinate_x": 3840, "max_coordinate_y": 2160}, +# "metadata": {"session_name": "...", "source": "zeroclaw.browser", "version": "..."} +# } +# Response: {"success": true, "data": {...}} or {"success": false, "error": "..."} + [composio] enabled = false # opt-in: 1000+ OAuth apps via composio.dev # api_key = "cmp_..." # optional: stored encrypted when [secrets].encrypt = true diff --git a/src/config/mod.rs b/src/config/mod.rs index e53b597..3103f42 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,11 +2,11 @@ pub mod schema; #[allow(unused_imports)] pub use schema::{ - AuditConfig, AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, CostConfig, - DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, GatewayConfig, HeartbeatConfig, - HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, - ModelRouteConfig, ObservabilityConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, - SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, + AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig, ChannelsConfig, + ComposioConfig, Config, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, GatewayConfig, + HeartbeatConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, + MemoryConfig, ModelRouteConfig, ObservabilityConfig, ReliabilityConfig, ResourceLimitsConfig, + RuntimeConfig, SandboxBackend, SandboxConfig, SecretsConfig, SecurityConfig, SlackConfig, TelegramConfig, TunnelConfig, WebhookConfig, }; diff --git a/src/config/schema.rs b/src/config/schema.rs index 8a66124..622e12d 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -419,6 +419,53 @@ impl Default for SecretsConfig { // ── Browser (friendly-service browsing only) ─────────────────── +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserComputerUseConfig { + /// Sidecar endpoint for computer-use actions (OS-level mouse/keyboard/screenshot) + #[serde(default = "default_browser_computer_use_endpoint")] + pub endpoint: String, + /// Optional bearer token for computer-use sidecar + #[serde(default)] + pub api_key: Option, + /// Per-action request timeout in milliseconds + #[serde(default = "default_browser_computer_use_timeout_ms")] + pub timeout_ms: u64, + /// Allow remote/public endpoint for computer-use sidecar (default: false) + #[serde(default)] + pub allow_remote_endpoint: bool, + /// Optional window title/process allowlist forwarded to sidecar policy + #[serde(default)] + pub window_allowlist: Vec, + /// Optional X-axis boundary for coordinate-based actions + #[serde(default)] + pub max_coordinate_x: Option, + /// Optional Y-axis boundary for coordinate-based actions + #[serde(default)] + pub max_coordinate_y: Option, +} + +fn default_browser_computer_use_endpoint() -> String { + "http://127.0.0.1:8787/v1/actions".into() +} + +fn default_browser_computer_use_timeout_ms() -> u64 { + 15_000 +} + +impl Default for BrowserComputerUseConfig { + fn default() -> Self { + Self { + endpoint: default_browser_computer_use_endpoint(), + api_key: None, + timeout_ms: default_browser_computer_use_timeout_ms(), + allow_remote_endpoint: false, + window_allowlist: Vec::new(), + max_coordinate_x: None, + max_coordinate_y: None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BrowserConfig { /// Enable `browser_open` tool (opens URLs in Brave without scraping) @@ -430,7 +477,7 @@ pub struct BrowserConfig { /// Browser session name (for agent-browser automation) #[serde(default)] pub session_name: Option, - /// Browser automation backend: "agent_browser" | "rust_native" | "auto" + /// Browser automation backend: "agent_browser" | "rust_native" | "computer_use" | "auto" #[serde(default = "default_browser_backend")] pub backend: String, /// Headless mode for rust-native backend @@ -442,6 +489,9 @@ pub struct BrowserConfig { /// Optional Chrome/Chromium executable path for rust-native backend #[serde(default)] pub native_chrome_path: Option, + /// Computer-use sidecar configuration + #[serde(default)] + pub computer_use: BrowserComputerUseConfig, } fn default_browser_backend() -> String { @@ -462,6 +512,7 @@ impl Default for BrowserConfig { native_headless: default_true(), native_webdriver_url: default_browser_webdriver_url(), native_chrome_path: None, + computer_use: BrowserComputerUseConfig::default(), } } } @@ -2334,6 +2385,12 @@ default_temperature = 0.7 assert!(b.native_headless); assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515"); assert!(b.native_chrome_path.is_none()); + assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions"); + assert_eq!(b.computer_use.timeout_ms, 15_000); + assert!(!b.computer_use.allow_remote_endpoint); + assert!(b.computer_use.window_allowlist.is_empty()); + assert!(b.computer_use.max_coordinate_x.is_none()); + assert!(b.computer_use.max_coordinate_y.is_none()); } #[test] @@ -2346,6 +2403,15 @@ default_temperature = 0.7 native_headless: false, native_webdriver_url: "http://localhost:4444".into(), native_chrome_path: Some("/usr/bin/chromium".into()), + computer_use: BrowserComputerUseConfig { + endpoint: "https://computer-use.example.com/v1/actions".into(), + api_key: Some("test-token".into()), + timeout_ms: 8_000, + allow_remote_endpoint: true, + window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()], + max_coordinate_x: Some(3840), + max_coordinate_y: Some(2160), + }, }; let toml_str = toml::to_string(&b).unwrap(); let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap(); @@ -2359,6 +2425,16 @@ default_temperature = 0.7 parsed.native_chrome_path.as_deref(), Some("/usr/bin/chromium") ); + assert_eq!( + parsed.computer_use.endpoint, + "https://computer-use.example.com/v1/actions" + ); + assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token")); + assert_eq!(parsed.computer_use.timeout_ms, 8_000); + assert!(parsed.computer_use.allow_remote_endpoint); + assert_eq!(parsed.computer_use.window_allowlist.len(), 2); + assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840)); + assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160)); } #[test] diff --git a/src/cost/tracker.rs b/src/cost/tracker.rs index 16b874f..697f381 100644 --- a/src/cost/tracker.rs +++ b/src/cost/tracker.rs @@ -1,5 +1,5 @@ use super::types::{BudgetCheck, CostRecord, CostSummary, ModelStats, TokenUsage, UsagePeriod}; -use crate::config::CostConfig; +use crate::config::schema::CostConfig; use anyhow::{anyhow, Context, Result}; use chrono::{Datelike, NaiveDate, Utc}; use std::collections::HashMap; diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index ddac80e..0bf285b 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -110,7 +110,7 @@ pub fn run_wizard() -> Result { autonomy: AutonomyConfig::default(), runtime: RuntimeConfig::default(), reliability: crate::config::ReliabilityConfig::default(), - scheduler: crate::config::SchedulerConfig::default(), + scheduler: crate::config::schema::SchedulerConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config, @@ -122,7 +122,7 @@ pub fn run_wizard() -> Result { browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), - cost: crate::config::CostConfig::default(), + cost: crate::config::schema::CostConfig::default(), hardware: hardware_config, agents: std::collections::HashMap::new(), security: crate::config::SecurityConfig::default(), @@ -307,7 +307,7 @@ pub fn run_quick_setup( autonomy: AutonomyConfig::default(), runtime: RuntimeConfig::default(), reliability: crate::config::ReliabilityConfig::default(), - scheduler: crate::config::SchedulerConfig::default(), + scheduler: crate::config::schema::SchedulerConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), @@ -319,7 +319,7 @@ pub fn run_quick_setup( browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), - cost: crate::config::CostConfig::default(), + cost: crate::config::schema::CostConfig::default(), hardware: HardwareConfig::default(), agents: std::collections::HashMap::new(), security: crate::config::SecurityConfig::default(), diff --git a/src/tools/browser.rs b/src/tools/browser.rs index ec469d6..c6a0ba9 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -3,18 +3,48 @@ //! By default this uses Vercel's `agent-browser` CLI for automation. //! Optionally, a Rust-native backend can be enabled at build time via //! `--features browser-native` and selected through config. +//! Computer-use (OS-level) actions are supported via an optional sidecar endpoint. use super::traits::{Tool, ToolResult}; use crate::security::SecurityPolicy; +use anyhow::Context; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::net::ToSocketAddrs; use std::process::Stdio; use std::sync::Arc; +use std::time::Duration; use tokio::process::Command; use tracing::debug; -/// Browser automation tool using agent-browser CLI +/// Computer-use sidecar settings. +#[derive(Debug, Clone)] +pub struct ComputerUseConfig { + pub endpoint: String, + pub api_key: Option, + pub timeout_ms: u64, + pub allow_remote_endpoint: bool, + pub window_allowlist: Vec, + pub max_coordinate_x: Option, + pub max_coordinate_y: Option, +} + +impl Default for ComputerUseConfig { + fn default() -> Self { + Self { + endpoint: "http://127.0.0.1:8787/v1/actions".into(), + api_key: None, + timeout_ms: 15_000, + allow_remote_endpoint: false, + window_allowlist: Vec::new(), + max_coordinate_x: None, + max_coordinate_y: None, + } + } +} + +/// Browser automation tool using pluggable backends. pub struct BrowserTool { security: Arc, allowed_domains: Vec, @@ -23,6 +53,7 @@ pub struct BrowserTool { native_headless: bool, native_webdriver_url: String, native_chrome_path: Option, + computer_use: ComputerUseConfig, #[cfg(feature = "browser-native")] native_state: tokio::sync::Mutex, } @@ -31,6 +62,7 @@ pub struct BrowserTool { enum BrowserBackendKind { AgentBrowser, RustNative, + ComputerUse, Auto, } @@ -38,6 +70,7 @@ enum BrowserBackendKind { enum ResolvedBackend { AgentBrowser, RustNative, + ComputerUse, } impl BrowserBackendKind { @@ -46,9 +79,10 @@ impl BrowserBackendKind { match key.as_str() { "agent_browser" | "agentbrowser" => Ok(Self::AgentBrowser), "rust_native" | "native" => Ok(Self::RustNative), + "computer_use" | "computeruse" => Ok(Self::ComputerUse), "auto" => Ok(Self::Auto), _ => anyhow::bail!( - "Unsupported browser backend '{raw}'. Use 'agent_browser', 'rust_native', or 'auto'" + "Unsupported browser backend '{raw}'. Use 'agent_browser', 'rust_native', 'computer_use', or 'auto'" ), } } @@ -57,6 +91,7 @@ impl BrowserBackendKind { match self { Self::AgentBrowser => "agent_browser", Self::RustNative => "rust_native", + Self::ComputerUse => "computer_use", Self::Auto => "auto", } } @@ -70,6 +105,17 @@ struct AgentBrowserResponse { error: Option, } +/// Response format from computer-use sidecar. +#[derive(Debug, Deserialize)] +struct ComputerUseResponse { + #[serde(default)] + success: Option, + #[serde(default)] + data: Option, + #[serde(default)] + error: Option, +} + /// Supported browser actions #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -151,9 +197,11 @@ impl BrowserTool { true, "http://127.0.0.1:9515".into(), None, + ComputerUseConfig::default(), ) } + #[allow(clippy::too_many_arguments)] pub fn new_with_backend( security: Arc, allowed_domains: Vec, @@ -162,6 +210,7 @@ impl BrowserTool { native_headless: bool, native_webdriver_url: String, native_chrome_path: Option, + computer_use: ComputerUseConfig, ) -> Self { Self { security, @@ -171,6 +220,7 @@ impl BrowserTool { native_headless, native_webdriver_url, native_chrome_path, + computer_use, #[cfg(feature = "browser-native")] native_state: tokio::sync::Mutex::new(native_backend::NativeBrowserState::default()), } @@ -216,6 +266,52 @@ impl BrowserTool { } } + fn computer_use_endpoint_url(&self) -> anyhow::Result { + if self.computer_use.timeout_ms == 0 { + anyhow::bail!("browser.computer_use.timeout_ms must be > 0"); + } + + let endpoint = self.computer_use.endpoint.trim(); + if endpoint.is_empty() { + anyhow::bail!("browser.computer_use.endpoint cannot be empty"); + } + + let parsed = reqwest::Url::parse(endpoint).map_err(|_| { + anyhow::anyhow!( + "Invalid browser.computer_use.endpoint: '{endpoint}'. Expected http(s) URL" + ) + })?; + + let scheme = parsed.scheme(); + if scheme != "http" && scheme != "https" { + anyhow::bail!("browser.computer_use.endpoint must use http:// or https://"); + } + + let host = parsed + .host_str() + .ok_or_else(|| anyhow::anyhow!("browser.computer_use.endpoint must include host"))?; + + let host_is_private = is_private_host(host); + if !self.computer_use.allow_remote_endpoint && !host_is_private { + anyhow::bail!( + "browser.computer_use.endpoint host '{host}' is public. Set browser.computer_use.allow_remote_endpoint=true to allow it" + ); + } + + if self.computer_use.allow_remote_endpoint && !host_is_private && scheme != "https" { + anyhow::bail!( + "browser.computer_use.endpoint must use https:// when allow_remote_endpoint=true and host is public" + ); + } + + Ok(parsed) + } + + fn computer_use_available(&self) -> anyhow::Result { + let endpoint = self.computer_use_endpoint_url()?; + Ok(endpoint_reachable(&endpoint, Duration::from_millis(500))) + } + async fn resolve_backend(&self) -> anyhow::Result { let configured = self.configured_backend()?; @@ -243,6 +339,14 @@ impl BrowserTool { } Ok(ResolvedBackend::RustNative) } + BrowserBackendKind::ComputerUse => { + if !self.computer_use_available()? { + anyhow::bail!( + "browser.backend='computer_use' but sidecar endpoint is unreachable. Check browser.computer_use.endpoint and sidecar status" + ); + } + Ok(ResolvedBackend::ComputerUse) + } BrowserBackendKind::Auto => { if Self::rust_native_compiled() && self.rust_native_available() { return Ok(ResolvedBackend::RustNative); @@ -251,14 +355,31 @@ impl BrowserTool { return Ok(ResolvedBackend::AgentBrowser); } + let computer_use_err = match self.computer_use_available() { + Ok(true) => return Ok(ResolvedBackend::ComputerUse), + Ok(false) => None, + Err(err) => Some(err.to_string()), + }; + if Self::rust_native_compiled() { + if let Some(err) = computer_use_err { + anyhow::bail!( + "browser.backend='auto' found no usable backend (agent-browser missing, rust-native unavailable, computer-use invalid: {err})" + ); + } anyhow::bail!( - "browser.backend='auto' found no usable backend (agent-browser missing, rust-native unavailable)" + "browser.backend='auto' found no usable backend (agent-browser missing, rust-native unavailable, computer-use sidecar unreachable)" ) } + if let Some(err) = computer_use_err { + anyhow::bail!( + "browser.backend='auto' needs agent-browser CLI, browser-native, or valid computer-use sidecar (error: {err})" + ); + } + anyhow::bail!( - "browser.backend='auto' needs agent-browser CLI, or build with --features browser-native" + "browser.backend='auto' needs agent-browser CLI, browser-native, or computer-use sidecar" ) } } @@ -523,6 +644,179 @@ impl BrowserTool { } } + fn validate_coordinate(&self, key: &str, value: i64, max: Option) -> anyhow::Result<()> { + if value < 0 { + anyhow::bail!("'{key}' must be >= 0") + } + if let Some(limit) = max { + if limit < 0 { + anyhow::bail!("Configured coordinate limit for '{key}' must be >= 0") + } + if value > limit { + anyhow::bail!("'{key}'={value} exceeds configured limit {limit}") + } + } + Ok(()) + } + + fn read_required_i64( + &self, + params: &serde_json::Map, + key: &str, + ) -> anyhow::Result { + params + .get(key) + .and_then(Value::as_i64) + .ok_or_else(|| anyhow::anyhow!("Missing or invalid '{key}' parameter")) + } + + fn validate_computer_use_action( + &self, + action: &str, + params: &serde_json::Map, + ) -> anyhow::Result<()> { + match action { + "open" => { + let url = params + .get("url") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("Missing 'url' for open action"))?; + self.validate_url(url)?; + } + "mouse_move" | "mouse_click" => { + let x = self.read_required_i64(params, "x")?; + let y = self.read_required_i64(params, "y")?; + self.validate_coordinate("x", x, self.computer_use.max_coordinate_x)?; + self.validate_coordinate("y", y, self.computer_use.max_coordinate_y)?; + } + "mouse_drag" => { + let from_x = self.read_required_i64(params, "from_x")?; + let from_y = self.read_required_i64(params, "from_y")?; + let to_x = self.read_required_i64(params, "to_x")?; + let to_y = self.read_required_i64(params, "to_y")?; + self.validate_coordinate("from_x", from_x, self.computer_use.max_coordinate_x)?; + self.validate_coordinate("to_x", to_x, self.computer_use.max_coordinate_x)?; + self.validate_coordinate("from_y", from_y, self.computer_use.max_coordinate_y)?; + self.validate_coordinate("to_y", to_y, self.computer_use.max_coordinate_y)?; + } + _ => {} + } + Ok(()) + } + + async fn execute_computer_use_action( + &self, + action: &str, + args: &Value, + ) -> anyhow::Result { + let endpoint = self.computer_use_endpoint_url()?; + + let mut params = args + .as_object() + .cloned() + .ok_or_else(|| anyhow::anyhow!("browser args must be a JSON object"))?; + params.remove("action"); + + self.validate_computer_use_action(action, ¶ms)?; + + let payload = json!({ + "action": action, + "params": params, + "policy": { + "allowed_domains": self.allowed_domains, + "window_allowlist": self.computer_use.window_allowlist, + "max_coordinate_x": self.computer_use.max_coordinate_x, + "max_coordinate_y": self.computer_use.max_coordinate_y, + }, + "metadata": { + "session_name": self.session_name, + "source": "zeroclaw.browser", + "version": env!("CARGO_PKG_VERSION"), + } + }); + + let client = reqwest::Client::new(); + let mut request = client + .post(endpoint) + .timeout(Duration::from_millis(self.computer_use.timeout_ms)) + .json(&payload); + + if let Some(api_key) = self.computer_use.api_key.as_deref() { + let token = api_key.trim(); + if !token.is_empty() { + request = request.bearer_auth(token); + } + } + + let response = request.send().await.with_context(|| { + format!( + "Failed to call computer-use sidecar at {}", + self.computer_use.endpoint + ) + })?; + + let status = response.status(); + let body = response + .text() + .await + .context("Failed to read computer-use sidecar response body")?; + + if let Ok(parsed) = serde_json::from_str::(&body) { + if status.is_success() && parsed.success.unwrap_or(true) { + let output = parsed + .data + .map(|data| serde_json::to_string_pretty(&data).unwrap_or_default()) + .unwrap_or_else(|| { + serde_json::to_string_pretty(&json!({ + "backend": "computer_use", + "action": action, + "ok": true, + })) + .unwrap_or_default() + }); + + return Ok(ToolResult { + success: true, + output, + error: None, + }); + } + + let error = parsed.error.or_else(|| { + if status.is_success() && parsed.success == Some(false) { + Some("computer-use sidecar returned success=false".to_string()) + } else { + Some(format!( + "computer-use sidecar request failed with status {status}" + )) + } + }); + + return Ok(ToolResult { + success: false, + output: String::new(), + error, + }); + } + + if status.is_success() { + return Ok(ToolResult { + success: true, + output: body, + error: None, + }); + } + + Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "computer-use sidecar request failed with status {status}: {}", + body.trim() + )), + }) + } + async fn execute_action( &self, action: BrowserAction, @@ -531,6 +825,9 @@ impl BrowserTool { match backend { ResolvedBackend::AgentBrowser => self.execute_agent_browser_action(action).await, ResolvedBackend::RustNative => self.execute_rust_native_action(action).await, + ResolvedBackend::ComputerUse => anyhow::bail!( + "Internal error: computer_use backend must be handled before BrowserAction parsing" + ), } } @@ -564,10 +861,12 @@ impl Tool for BrowserTool { } fn description(&self) -> &str { - "Web browser automation with pluggable backends (agent-browser or rust-native). \ - Supports navigation, clicking, filling forms, screenshots, and page snapshots. \ - Use 'snapshot' to map interactive elements to refs (@e1, @e2), then use refs for \ - precise interaction. Enforces browser.allowed_domains for open actions." + concat!( + "Web/browser automation with pluggable backends (agent-browser, rust-native, computer_use). ", + "Supports DOM actions plus optional OS-level actions (mouse_move, mouse_click, mouse_drag, ", + "key_type, key_press, screen_capture) through a computer-use sidecar. Use 'snapshot' to map ", + "interactive elements to refs (@e1, @e2). Enforces browser.allowed_domains for open actions." + ) } fn parameters_schema(&self) -> Value { @@ -578,8 +877,10 @@ impl Tool for BrowserTool { "type": "string", "enum": ["open", "snapshot", "click", "fill", "type", "get_text", "get_title", "get_url", "screenshot", "wait", "press", - "hover", "scroll", "is_visible", "close", "find"], - "description": "Browser action to perform" + "hover", "scroll", "is_visible", "close", "find", + "mouse_move", "mouse_click", "mouse_drag", "key_type", + "key_press", "screen_capture"], + "description": "Browser action to perform (OS-level actions require backend=computer_use)" }, "url": { "type": "string", @@ -601,6 +902,35 @@ impl Tool for BrowserTool { "type": "string", "description": "Key to press (Enter, Tab, Escape, etc.)" }, + "x": { + "type": "integer", + "description": "Screen X coordinate (computer_use: mouse_move/mouse_click)" + }, + "y": { + "type": "integer", + "description": "Screen Y coordinate (computer_use: mouse_move/mouse_click)" + }, + "from_x": { + "type": "integer", + "description": "Drag source X coordinate (computer_use: mouse_drag)" + }, + "from_y": { + "type": "integer", + "description": "Drag source Y coordinate (computer_use: mouse_drag)" + }, + "to_x": { + "type": "integer", + "description": "Drag target X coordinate (computer_use: mouse_drag)" + }, + "to_y": { + "type": "integer", + "description": "Drag target Y coordinate (computer_use: mouse_drag)" + }, + "button": { + "type": "string", + "enum": ["left", "right", "middle"], + "description": "Mouse button for computer_use mouse_click" + }, "direction": { "type": "string", "enum": ["up", "down", "left", "right"], @@ -688,6 +1018,18 @@ impl Tool for BrowserTool { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'action' parameter"))?; + if !is_supported_browser_action(action_str) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Unknown action: {action_str}")), + }); + } + + if backend == ResolvedBackend::ComputerUse { + return self.execute_computer_use_action(action_str, &args).await; + } + let action = match action_str { "open" => { let url = args @@ -839,7 +1181,14 @@ impl Tool for BrowserTool { return Ok(ToolResult { success: false, output: String::new(), - error: Some(format!("Unknown action: {action_str}")), + error: Some(format!( + "Action '{action_str}' is unavailable for backend '{}'", + match backend { + ResolvedBackend::AgentBrowser => "agent_browser", + ResolvedBackend::RustNative => "rust_native", + ResolvedBackend::ComputerUse => "computer_use", + } + )), }); } }; @@ -1523,6 +1872,34 @@ mod native_backend { // ── Helper functions ───────────────────────────────────────────── +fn is_supported_browser_action(action: &str) -> bool { + matches!( + action, + "open" + | "snapshot" + | "click" + | "fill" + | "type" + | "get_text" + | "get_title" + | "get_url" + | "screenshot" + | "wait" + | "press" + | "hover" + | "scroll" + | "is_visible" + | "close" + | "find" + | "mouse_move" + | "mouse_click" + | "mouse_drag" + | "key_type" + | "key_press" + | "screen_capture" + ) +} + fn normalize_domains(domains: Vec) -> Vec { domains .into_iter() @@ -1531,6 +1908,30 @@ fn normalize_domains(domains: Vec) -> Vec { .collect() } +fn endpoint_reachable(endpoint: &reqwest::Url, timeout: Duration) -> bool { + let host = match endpoint.host_str() { + Some(host) if !host.is_empty() => host, + _ => return false, + }; + + let port = match endpoint.port_or_known_default() { + Some(port) => port, + None => return false, + }; + + let mut addrs = match (host, port).to_socket_addrs() { + Ok(addrs) => addrs, + Err(_) => return false, + }; + + let addr = match addrs.next() { + Some(addr) => addr, + None => return false, + }; + + std::net::TcpStream::connect_timeout(&addr, timeout).is_ok() +} + fn extract_host(url_str: &str) -> anyhow::Result { // Simple host extraction without url crate let url = url_str.trim(); @@ -1746,6 +2147,10 @@ mod tests { BrowserBackendKind::parse("rust-native").unwrap(), BrowserBackendKind::RustNative ); + assert_eq!( + BrowserBackendKind::parse("computer_use").unwrap(), + BrowserBackendKind::ComputerUse + ); assert_eq!( BrowserBackendKind::parse("auto").unwrap(), BrowserBackendKind::Auto @@ -1778,10 +2183,100 @@ mod tests { true, "http://127.0.0.1:9515".into(), None, + ComputerUseConfig::default(), ); assert_eq!(tool.configured_backend().unwrap(), BrowserBackendKind::Auto); } + #[test] + fn browser_tool_accepts_computer_use_backend_config() { + let security = Arc::new(SecurityPolicy::default()); + let tool = BrowserTool::new_with_backend( + security, + vec!["example.com".into()], + None, + "computer_use".into(), + true, + "http://127.0.0.1:9515".into(), + None, + ComputerUseConfig::default(), + ); + assert_eq!( + tool.configured_backend().unwrap(), + BrowserBackendKind::ComputerUse + ); + } + + #[test] + fn computer_use_endpoint_rejects_public_http_by_default() { + let security = Arc::new(SecurityPolicy::default()); + let tool = BrowserTool::new_with_backend( + security, + vec!["example.com".into()], + None, + "computer_use".into(), + true, + "http://127.0.0.1:9515".into(), + None, + ComputerUseConfig { + endpoint: "http://computer-use.example.com/v1/actions".into(), + ..ComputerUseConfig::default() + }, + ); + + assert!(tool.computer_use_endpoint_url().is_err()); + } + + #[test] + fn computer_use_endpoint_requires_https_for_public_remote() { + let security = Arc::new(SecurityPolicy::default()); + let tool = BrowserTool::new_with_backend( + security, + vec!["example.com".into()], + None, + "computer_use".into(), + true, + "http://127.0.0.1:9515".into(), + None, + ComputerUseConfig { + endpoint: "https://computer-use.example.com/v1/actions".into(), + allow_remote_endpoint: true, + ..ComputerUseConfig::default() + }, + ); + + assert!(tool.computer_use_endpoint_url().is_ok()); + } + + #[test] + fn computer_use_coordinate_validation_applies_limits() { + let security = Arc::new(SecurityPolicy::default()); + let tool = BrowserTool::new_with_backend( + security, + vec!["example.com".into()], + None, + "computer_use".into(), + true, + "http://127.0.0.1:9515".into(), + None, + ComputerUseConfig { + max_coordinate_x: Some(100), + max_coordinate_y: Some(100), + ..ComputerUseConfig::default() + }, + ); + + assert!(tool + .validate_coordinate("x", 50, tool.computer_use.max_coordinate_x) + .is_ok()); + assert!(tool + .validate_coordinate("x", 101, tool.computer_use.max_coordinate_x) + .is_err()); + assert!(tool + .validate_coordinate("y", -1, tool.computer_use.max_coordinate_y) + .is_err()); + } + #[test] fn browser_tool_name() { let security = Arc::new(SecurityPolicy::default()); diff --git a/src/tools/git_operations.rs b/src/tools/git_operations.rs index e20113a..d01243a 100644 --- a/src/tools/git_operations.rs +++ b/src/tools/git_operations.rs @@ -2,6 +2,8 @@ use super::traits::{Tool, ToolResult}; use crate::security::{AutonomyLevel, SecurityPolicy}; use async_trait::async_trait; use serde_json::json; +#[cfg(test)] +use std::path::Path; use std::sync::Arc; /// Git operations tool for structured repository management. diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 964ba5b..d239c5e 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -15,7 +15,7 @@ pub mod screenshot; pub mod shell; pub mod traits; -pub use browser::BrowserTool; +pub use browser::{BrowserTool, ComputerUseConfig}; pub use browser_open::BrowserOpenTool; pub use composio::ComposioTool; pub use delegate::DelegateTool; @@ -131,6 +131,15 @@ pub fn all_tools_with_runtime( browser_config.native_headless, browser_config.native_webdriver_url.clone(), browser_config.native_chrome_path.clone(), + ComputerUseConfig { + endpoint: browser_config.computer_use.endpoint.clone(), + api_key: browser_config.computer_use.api_key.clone(), + timeout_ms: browser_config.computer_use.timeout_ms, + allow_remote_endpoint: browser_config.computer_use.allow_remote_endpoint, + window_allowlist: browser_config.computer_use.window_allowlist.clone(), + max_coordinate_x: browser_config.computer_use.max_coordinate_x, + max_coordinate_y: browser_config.computer_use.max_coordinate_y, + }, ))); } From 53844f7207b2e5533feae57bfc1257aec4151e12 Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 17 Feb 2026 00:31:50 +0800 Subject: [PATCH 18/32] feat(memory): lucid memory integration with optional backends (#285) --- README.md | 18 +- src/channels/mod.rs | 7 +- src/config/schema.rs | 42 ++- src/main.rs | 2 +- src/memory/backend.rs | 145 ++++++++++ src/memory/lucid.rs | 601 ++++++++++++++++++++++++++++++++++++++++++ src/memory/mod.rs | 137 ++++++++-- src/memory/none.rs | 74 ++++++ src/migration.rs | 26 +- src/onboard/wizard.rs | 164 +++++++----- src/providers/mod.rs | 10 +- 11 files changed, 1089 insertions(+), 137 deletions(-) create mode 100644 src/memory/backend.rs create mode 100644 src/memory/lucid.rs create mode 100644 src/memory/none.rs diff --git a/README.md b/README.md index 97619ea..40dfc6a 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze |-----------|-------|------------|--------| | **AI Models** | `Provider` | 22+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API | | **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, WhatsApp, Webhook | Any messaging API | -| **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Markdown | Any persistence backend | +| **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Lucid bridge (CLI sync + SQLite fallback), Markdown | Any persistence backend | | **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget, browser_open (Brave + allowlist), browser (agent-browser / rust-native), composio (optional) | Any capability | | **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel | | **Runtime** | `RuntimeAdapter` | Native, Docker (sandboxed) | WASM (planned; unsupported kinds fail fast) | @@ -164,11 +164,21 @@ The agent automatically recalls, saves, and manages memory via tools. ```toml [memory] -backend = "sqlite" # "sqlite", "markdown", "none" +backend = "sqlite" # "sqlite", "lucid", "markdown", "none" auto_save = true embedding_provider = "openai" vector_weight = 0.7 keyword_weight = 0.3 + +# backend = "none" uses an explicit no-op memory backend (no persistence) + +# Optional for backend = "lucid" +# ZEROCLAW_LUCID_CMD=/usr/local/bin/lucid # default: lucid +# ZEROCLAW_LUCID_BUDGET=200 # default: 200 +# ZEROCLAW_LUCID_LOCAL_HIT_THRESHOLD=3 # local hit count to skip external recall +# ZEROCLAW_LUCID_RECALL_TIMEOUT_MS=120 # low-latency budget for lucid context recall +# ZEROCLAW_LUCID_STORE_TIMEOUT_MS=800 # async sync timeout for lucid store +# ZEROCLAW_LUCID_FAILURE_COOLDOWN_MS=15000 # cooldown after lucid failure to avoid repeated slow attempts ``` ## Security @@ -264,12 +274,14 @@ default_model = "anthropic/claude-sonnet-4-20250514" default_temperature = 0.7 [memory] -backend = "sqlite" # "sqlite", "markdown", "none" +backend = "sqlite" # "sqlite", "lucid", "markdown", "none" auto_save = true embedding_provider = "openai" # "openai", "noop" vector_weight = 0.7 keyword_weight = 0.3 +# backend = "none" disables persistent memory via no-op backend + [gateway] require_pairing = true # require pairing code on first connect allow_public_bind = false # refuse 0.0.0.0 without tunnel diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 81fa704..be012fc 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -699,9 +699,8 @@ pub async fn start_channels(config: Config) -> Result<()> { .default_provider .clone() .unwrap_or_else(|| "openrouter".into()); - let provider: Arc = Arc::from(providers::create_resilient_provider( - provider_name.as_str(), + &provider_name, config.api_key.as_deref(), &config.reliability, )?); @@ -1163,7 +1162,7 @@ mod tests { let runtime_ctx = Arc::new(ChannelRuntimeContext { channels_by_name: Arc::new(channels_by_name), provider: Arc::new(ToolCallingProvider), - provider_name: Arc::new("test-provider".to_string()), + provider_name: Arc::new("openrouter".to_string()), memory: Arc::new(NoopMemory), tools_registry: Arc::new(vec![Box::new(MockPriceTool)]), observer: Arc::new(NoopObserver), @@ -1254,7 +1253,7 @@ mod tests { provider: Arc::new(SlowProvider { delay: Duration::from_millis(250), }), - provider_name: Arc::new("test-provider".to_string()), + provider_name: Arc::new("openrouter".to_string()), memory: Arc::new(NoopMemory), tools_registry: Arc::new(vec![]), observer: Arc::new(NoopObserver), diff --git a/src/config/schema.rs b/src/config/schema.rs index 622e12d..0e58c8f 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -547,7 +547,7 @@ fn default_http_timeout_secs() -> u64 { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryConfig { - /// "sqlite" | "markdown" | "none" + /// "sqlite" | "lucid" | "markdown" | "none" (`none` = explicit no-op memory) pub backend: String, /// Auto-save conversation context to memory pub auto_save: bool, @@ -1618,7 +1618,6 @@ fn sync_directory(_path: &Path) -> Result<()> { mod tests { use super::*; use std::path::PathBuf; - use std::sync::{Mutex, OnceLock}; use tempfile::TempDir; // ── Defaults ───────────────────────────────────────────── @@ -2449,19 +2448,18 @@ default_temperature = 0.7 assert!(parsed.browser.allowed_domains.is_empty()); } - fn env_override_lock() -> std::sync::MutexGuard<'static, ()> { - static ENV_LOCK: OnceLock> = OnceLock::new(); - ENV_LOCK - .get_or_init(|| Mutex::new(())) + // ── Environment variable overrides (Docker support) ───────── + + fn env_override_test_guard() -> std::sync::MutexGuard<'static, ()> { + static ENV_OVERRIDE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + ENV_OVERRIDE_TEST_LOCK .lock() .expect("env override test lock poisoned") } - // ── Environment variable overrides (Docker support) ───────── - #[test] fn env_override_api_key() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); assert!(config.api_key.is_none()); @@ -2474,7 +2472,7 @@ default_temperature = 0.7 #[test] fn env_override_api_key_fallback() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_API_KEY"); @@ -2487,7 +2485,7 @@ default_temperature = 0.7 #[test] fn env_override_provider() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_PROVIDER", "anthropic"); @@ -2499,7 +2497,7 @@ default_temperature = 0.7 #[test] fn env_override_provider_fallback() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_PROVIDER"); @@ -2512,7 +2510,7 @@ default_temperature = 0.7 #[test] fn env_override_model() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_MODEL", "gpt-4o"); @@ -2524,7 +2522,7 @@ default_temperature = 0.7 #[test] fn env_override_workspace() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_WORKSPACE", "/custom/workspace"); @@ -2536,7 +2534,7 @@ default_temperature = 0.7 #[test] fn env_override_empty_values_ignored() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); let original_provider = config.default_provider.clone(); @@ -2549,7 +2547,7 @@ default_temperature = 0.7 #[test] fn env_override_gateway_port() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); assert_eq!(config.gateway.port, 3000); @@ -2562,7 +2560,7 @@ default_temperature = 0.7 #[test] fn env_override_port_fallback() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_GATEWAY_PORT"); @@ -2575,7 +2573,7 @@ default_temperature = 0.7 #[test] fn env_override_gateway_host() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); assert_eq!(config.gateway.host, "127.0.0.1"); @@ -2588,7 +2586,7 @@ default_temperature = 0.7 #[test] fn env_override_host_fallback() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_GATEWAY_HOST"); @@ -2601,7 +2599,7 @@ default_temperature = 0.7 #[test] fn env_override_temperature() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_TEMPERATURE", "0.5"); @@ -2613,7 +2611,7 @@ default_temperature = 0.7 #[test] fn env_override_temperature_out_of_range_ignored() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); // Clean up any leftover env vars from other tests std::env::remove_var("ZEROCLAW_TEMPERATURE"); @@ -2633,7 +2631,7 @@ default_temperature = 0.7 #[test] fn env_override_invalid_port_ignored() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); let original_port = config.gateway.port; diff --git a/src/main.rs b/src/main.rs index 3253594..478ce41 100644 --- a/src/main.rs +++ b/src/main.rs @@ -110,7 +110,7 @@ enum Commands { #[arg(long)] provider: Option, - /// Memory backend (sqlite, markdown, none) - used in quick mode, default: sqlite + /// Memory backend (sqlite, lucid, markdown, none) - used in quick mode, default: sqlite #[arg(long)] memory: Option, }, diff --git a/src/memory/backend.rs b/src/memory/backend.rs new file mode 100644 index 0000000..4de636a --- /dev/null +++ b/src/memory/backend.rs @@ -0,0 +1,145 @@ +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum MemoryBackendKind { + Sqlite, + Lucid, + Markdown, + None, + Unknown, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct MemoryBackendProfile { + pub key: &'static str, + pub label: &'static str, + pub auto_save_default: bool, + pub uses_sqlite_hygiene: bool, + pub sqlite_based: bool, + pub optional_dependency: bool, +} + +const SQLITE_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "sqlite", + label: "SQLite with Vector Search (recommended) — fast, hybrid search, embeddings", + auto_save_default: true, + uses_sqlite_hygiene: true, + sqlite_based: true, + optional_dependency: false, +}; + +const LUCID_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "lucid", + label: "Lucid Memory bridge — sync with local lucid-memory CLI, keep SQLite fallback", + auto_save_default: true, + uses_sqlite_hygiene: true, + sqlite_based: true, + optional_dependency: true, +}; + +const MARKDOWN_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "markdown", + label: "Markdown Files — simple, human-readable, no dependencies", + auto_save_default: true, + uses_sqlite_hygiene: false, + sqlite_based: false, + optional_dependency: false, +}; + +const NONE_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "none", + label: "None — disable persistent memory", + auto_save_default: false, + uses_sqlite_hygiene: false, + sqlite_based: false, + optional_dependency: false, +}; + +const CUSTOM_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "custom", + label: "Custom backend — extension point", + auto_save_default: true, + uses_sqlite_hygiene: false, + sqlite_based: false, + optional_dependency: false, +}; + +const SELECTABLE_MEMORY_BACKENDS: [MemoryBackendProfile; 4] = [ + SQLITE_PROFILE, + LUCID_PROFILE, + MARKDOWN_PROFILE, + NONE_PROFILE, +]; + +pub fn selectable_memory_backends() -> &'static [MemoryBackendProfile] { + &SELECTABLE_MEMORY_BACKENDS +} + +pub fn default_memory_backend_key() -> &'static str { + SQLITE_PROFILE.key +} + +pub fn classify_memory_backend(backend: &str) -> MemoryBackendKind { + match backend { + "sqlite" => MemoryBackendKind::Sqlite, + "lucid" => MemoryBackendKind::Lucid, + "markdown" => MemoryBackendKind::Markdown, + "none" => MemoryBackendKind::None, + _ => MemoryBackendKind::Unknown, + } +} + +pub fn memory_backend_profile(backend: &str) -> MemoryBackendProfile { + match classify_memory_backend(backend) { + MemoryBackendKind::Sqlite => SQLITE_PROFILE, + MemoryBackendKind::Lucid => LUCID_PROFILE, + MemoryBackendKind::Markdown => MARKDOWN_PROFILE, + MemoryBackendKind::None => NONE_PROFILE, + MemoryBackendKind::Unknown => CUSTOM_PROFILE, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_known_backends() { + assert_eq!(classify_memory_backend("sqlite"), MemoryBackendKind::Sqlite); + assert_eq!(classify_memory_backend("lucid"), MemoryBackendKind::Lucid); + assert_eq!( + classify_memory_backend("markdown"), + MemoryBackendKind::Markdown + ); + assert_eq!(classify_memory_backend("none"), MemoryBackendKind::None); + } + + #[test] + fn classify_unknown_backend() { + assert_eq!(classify_memory_backend("redis"), MemoryBackendKind::Unknown); + } + + #[test] + fn selectable_backends_are_ordered_for_onboarding() { + let backends = selectable_memory_backends(); + assert_eq!(backends.len(), 4); + assert_eq!(backends[0].key, "sqlite"); + assert_eq!(backends[1].key, "lucid"); + assert_eq!(backends[2].key, "markdown"); + assert_eq!(backends[3].key, "none"); + } + + #[test] + fn lucid_profile_is_sqlite_based_optional_backend() { + let profile = memory_backend_profile("lucid"); + assert!(profile.sqlite_based); + assert!(profile.optional_dependency); + assert!(profile.uses_sqlite_hygiene); + } + + #[test] + fn unknown_profile_preserves_extensibility_defaults() { + let profile = memory_backend_profile("custom-memory"); + assert_eq!(profile.key, "custom"); + assert!(profile.auto_save_default); + assert!(!profile.uses_sqlite_hygiene); + } +} diff --git a/src/memory/lucid.rs b/src/memory/lucid.rs new file mode 100644 index 0000000..00e03f6 --- /dev/null +++ b/src/memory/lucid.rs @@ -0,0 +1,601 @@ +use super::sqlite::SqliteMemory; +use super::traits::{Memory, MemoryCategory, MemoryEntry}; +use async_trait::async_trait; +use chrono::Local; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tokio::process::Command; +use tokio::time::timeout; + +pub struct LucidMemory { + local: SqliteMemory, + lucid_cmd: String, + token_budget: usize, + workspace_dir: PathBuf, + recall_timeout: Duration, + store_timeout: Duration, + local_hit_threshold: usize, + failure_cooldown: Duration, + last_failure_at: Mutex>, +} + +impl LucidMemory { + const DEFAULT_LUCID_CMD: &'static str = "lucid"; + const DEFAULT_TOKEN_BUDGET: usize = 200; + const DEFAULT_RECALL_TIMEOUT_MS: u64 = 120; + const DEFAULT_STORE_TIMEOUT_MS: u64 = 800; + const DEFAULT_LOCAL_HIT_THRESHOLD: usize = 3; + const DEFAULT_FAILURE_COOLDOWN_MS: u64 = 15_000; + + pub fn new(workspace_dir: &Path, local: SqliteMemory) -> Self { + let lucid_cmd = std::env::var("ZEROCLAW_LUCID_CMD") + .unwrap_or_else(|_| Self::DEFAULT_LUCID_CMD.to_string()); + + let token_budget = std::env::var("ZEROCLAW_LUCID_BUDGET") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v > 0) + .unwrap_or(Self::DEFAULT_TOKEN_BUDGET); + + let recall_timeout = Self::read_env_duration_ms( + "ZEROCLAW_LUCID_RECALL_TIMEOUT_MS", + Self::DEFAULT_RECALL_TIMEOUT_MS, + 20, + ); + let store_timeout = Self::read_env_duration_ms( + "ZEROCLAW_LUCID_STORE_TIMEOUT_MS", + Self::DEFAULT_STORE_TIMEOUT_MS, + 50, + ); + let local_hit_threshold = Self::read_env_usize( + "ZEROCLAW_LUCID_LOCAL_HIT_THRESHOLD", + Self::DEFAULT_LOCAL_HIT_THRESHOLD, + 1, + ); + let failure_cooldown = Self::read_env_duration_ms( + "ZEROCLAW_LUCID_FAILURE_COOLDOWN_MS", + Self::DEFAULT_FAILURE_COOLDOWN_MS, + 100, + ); + + Self { + local, + lucid_cmd, + token_budget, + workspace_dir: workspace_dir.to_path_buf(), + recall_timeout, + store_timeout, + local_hit_threshold, + failure_cooldown, + last_failure_at: Mutex::new(None), + } + } + + #[cfg(test)] + fn with_options( + workspace_dir: &Path, + local: SqliteMemory, + lucid_cmd: String, + token_budget: usize, + local_hit_threshold: usize, + recall_timeout: Duration, + store_timeout: Duration, + failure_cooldown: Duration, + ) -> Self { + Self { + local, + lucid_cmd, + token_budget, + workspace_dir: workspace_dir.to_path_buf(), + recall_timeout, + store_timeout, + local_hit_threshold: local_hit_threshold.max(1), + failure_cooldown, + last_failure_at: Mutex::new(None), + } + } + + fn read_env_usize(name: &str, default: usize, min: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|v| v.parse::().ok()) + .map_or(default, |v| v.max(min)) + } + + fn read_env_duration_ms(name: &str, default_ms: u64, min_ms: u64) -> Duration { + let millis = std::env::var(name) + .ok() + .and_then(|v| v.parse::().ok()) + .map_or(default_ms, |v| v.max(min_ms)); + Duration::from_millis(millis) + } + + fn in_failure_cooldown(&self) -> bool { + let Ok(guard) = self.last_failure_at.lock() else { + return false; + }; + + guard + .as_ref() + .is_some_and(|last| last.elapsed() < self.failure_cooldown) + } + + fn mark_failure_now(&self) { + if let Ok(mut guard) = self.last_failure_at.lock() { + *guard = Some(Instant::now()); + } + } + + fn clear_failure(&self) { + if let Ok(mut guard) = self.last_failure_at.lock() { + *guard = None; + } + } + + fn to_lucid_type(category: &MemoryCategory) -> &'static str { + match category { + MemoryCategory::Core => "decision", + MemoryCategory::Daily => "context", + MemoryCategory::Conversation => "conversation", + MemoryCategory::Custom(_) => "learning", + } + } + + fn to_memory_category(label: &str) -> MemoryCategory { + let normalized = label.to_lowercase(); + if normalized.contains("visual") { + return MemoryCategory::Custom("visual".to_string()); + } + + match normalized.as_str() { + "decision" | "learning" | "solution" => MemoryCategory::Core, + "context" | "conversation" => MemoryCategory::Conversation, + "bug" => MemoryCategory::Daily, + other => MemoryCategory::Custom(other.to_string()), + } + } + + fn merge_results( + primary_results: Vec, + secondary_results: Vec, + limit: usize, + ) -> Vec { + if limit == 0 { + return Vec::new(); + } + + let mut merged = Vec::new(); + let mut seen = HashSet::new(); + + for entry in primary_results.into_iter().chain(secondary_results) { + let signature = format!( + "{}\u{0}{}", + entry.key.to_lowercase(), + entry.content.to_lowercase() + ); + + if seen.insert(signature) { + merged.push(entry); + if merged.len() >= limit { + break; + } + } + } + + merged + } + + fn parse_lucid_context(raw: &str) -> Vec { + let mut in_context_block = false; + let mut entries = Vec::new(); + let now = Local::now().to_rfc3339(); + + for line in raw.lines().map(str::trim) { + if line == "" { + in_context_block = true; + continue; + } + + if line == "" { + break; + } + + if !in_context_block || line.is_empty() { + continue; + } + + let Some(rest) = line.strip_prefix("- [") else { + continue; + }; + + let Some((label, content_part)) = rest.split_once(']') else { + continue; + }; + + let content = content_part.trim(); + if content.is_empty() { + continue; + } + + let rank = entries.len(); + entries.push(MemoryEntry { + id: format!("lucid:{rank}"), + key: format!("lucid_{rank}"), + content: content.to_string(), + category: Self::to_memory_category(label.trim()), + timestamp: now.clone(), + session_id: None, + score: Some((1.0 - rank as f64 * 0.05).max(0.1)), + }); + } + + entries + } + + async fn run_lucid_command_raw( + lucid_cmd: &str, + args: &[String], + timeout_window: Duration, + ) -> anyhow::Result { + let mut cmd = Command::new(lucid_cmd); + cmd.args(args); + + let output = timeout(timeout_window, cmd.output()).await.map_err(|_| { + anyhow::anyhow!( + "lucid command timed out after {}ms", + timeout_window.as_millis() + ) + })??; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("lucid command failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + async fn run_lucid_command( + &self, + args: &[String], + timeout_window: Duration, + ) -> anyhow::Result { + Self::run_lucid_command_raw(&self.lucid_cmd, args, timeout_window).await + } + + fn build_store_args(&self, key: &str, content: &str, category: &MemoryCategory) -> Vec { + let payload = format!("{key}: {content}"); + vec![ + "store".to_string(), + payload, + format!("--type={}", Self::to_lucid_type(category)), + format!("--project={}", self.workspace_dir.display()), + ] + } + + fn build_recall_args(&self, query: &str) -> Vec { + vec![ + "context".to_string(), + query.to_string(), + format!("--budget={}", self.token_budget), + format!("--project={}", self.workspace_dir.display()), + ] + } + + async fn sync_to_lucid_async(&self, key: &str, content: &str, category: &MemoryCategory) { + let args = self.build_store_args(key, content, category); + if let Err(error) = self.run_lucid_command(&args, self.store_timeout).await { + tracing::debug!( + command = %self.lucid_cmd, + error = %error, + "Lucid store sync failed; sqlite remains authoritative" + ); + } + } + + async fn recall_from_lucid(&self, query: &str) -> anyhow::Result> { + let args = self.build_recall_args(query); + let output = self.run_lucid_command(&args, self.recall_timeout).await?; + Ok(Self::parse_lucid_context(&output)) + } +} + +#[async_trait] +impl Memory for LucidMemory { + fn name(&self) -> &str { + "lucid" + } + + async fn store( + &self, + key: &str, + content: &str, + category: MemoryCategory, + ) -> anyhow::Result<()> { + self.local.store(key, content, category.clone()).await?; + self.sync_to_lucid_async(key, content, &category).await; + Ok(()) + } + + async fn recall(&self, query: &str, limit: usize) -> anyhow::Result> { + let local_results = self.local.recall(query, limit).await?; + if limit == 0 + || local_results.len() >= limit + || local_results.len() >= self.local_hit_threshold + { + return Ok(local_results); + } + + if self.in_failure_cooldown() { + return Ok(local_results); + } + + match self.recall_from_lucid(query).await { + Ok(lucid_results) if !lucid_results.is_empty() => { + self.clear_failure(); + Ok(Self::merge_results(local_results, lucid_results, limit)) + } + Ok(_) => { + self.clear_failure(); + Ok(local_results) + } + Err(error) => { + self.mark_failure_now(); + tracing::debug!( + command = %self.lucid_cmd, + error = %error, + "Lucid context unavailable; using local sqlite results" + ); + Ok(local_results) + } + } + } + + async fn get(&self, key: &str) -> anyhow::Result> { + self.local.get(key).await + } + + async fn list(&self, category: Option<&MemoryCategory>) -> anyhow::Result> { + self.local.list(category).await + } + + async fn forget(&self, key: &str) -> anyhow::Result { + self.local.forget(key).await + } + + async fn count(&self) -> anyhow::Result { + self.local.count().await + } + + async fn health_check(&self) -> bool { + self.local.health_check().await + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use tempfile::TempDir; + + fn write_fake_lucid_script(dir: &Path) -> String { + let script_path = dir.join("fake-lucid.sh"); + let script = r#"#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "store" ]]; then + echo '{"success":true,"id":"mem_1"}' + exit 0 +fi + +if [[ "${1:-}" == "context" ]]; then + cat <<'EOF' + +Auth context snapshot +- [decision] Use token refresh middleware +- [context] Working in src/auth.rs + +EOF + exit 0 +fi + +echo "unsupported command" >&2 +exit 1 +"#; + + fs::write(&script_path, script).unwrap(); + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + script_path.display().to_string() + } + + fn write_probe_lucid_script(dir: &Path, marker_path: &Path) -> String { + let script_path = dir.join("probe-lucid.sh"); + let marker = marker_path.display().to_string(); + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail + +if [[ "${{1:-}}" == "store" ]]; then + echo '{{"success":true,"id":"mem_store"}}' + exit 0 +fi + +if [[ "${{1:-}}" == "context" ]]; then + printf 'context\n' >> "{marker}" + cat <<'EOF' + +- [decision] should not be used when local hits are enough + +EOF + exit 0 +fi + +echo "unsupported command" >&2 +exit 1 +"# + ); + + fs::write(&script_path, script).unwrap(); + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + script_path.display().to_string() + } + + fn test_memory(workspace: &Path, cmd: String) -> LucidMemory { + let sqlite = SqliteMemory::new(workspace).unwrap(); + LucidMemory::with_options( + workspace, + sqlite, + cmd, + 200, + 3, + Duration::from_millis(120), + Duration::from_millis(400), + Duration::from_secs(2), + ) + } + + #[tokio::test] + async fn lucid_name() { + let tmp = TempDir::new().unwrap(); + let memory = test_memory(tmp.path(), "nonexistent-lucid-binary".to_string()); + assert_eq!(memory.name(), "lucid"); + } + + #[tokio::test] + async fn store_succeeds_when_lucid_missing() { + let tmp = TempDir::new().unwrap(); + let memory = test_memory(tmp.path(), "nonexistent-lucid-binary".to_string()); + + memory + .store("lang", "User prefers Rust", MemoryCategory::Core) + .await + .unwrap(); + + let entry = memory.get("lang").await.unwrap(); + assert!(entry.is_some()); + assert_eq!(entry.unwrap().content, "User prefers Rust"); + } + + #[tokio::test] + async fn recall_merges_lucid_and_local_results() { + let tmp = TempDir::new().unwrap(); + let fake_cmd = write_fake_lucid_script(tmp.path()); + let memory = test_memory(tmp.path(), fake_cmd); + + memory + .store( + "local_note", + "Local sqlite auth fallback note", + MemoryCategory::Core, + ) + .await + .unwrap(); + + let entries = memory.recall("auth", 5).await.unwrap(); + + assert!(entries + .iter() + .any(|e| e.content.contains("Local sqlite auth fallback note"))); + assert!(entries.iter().any(|e| e.content.contains("token refresh"))); + } + + #[tokio::test] + async fn recall_skips_lucid_when_local_hits_are_enough() { + let tmp = TempDir::new().unwrap(); + let marker = tmp.path().join("context_calls.log"); + let probe_cmd = write_probe_lucid_script(tmp.path(), &marker); + + let sqlite = SqliteMemory::new(tmp.path()).unwrap(); + let memory = LucidMemory::with_options( + tmp.path(), + sqlite, + probe_cmd, + 200, + 1, + Duration::from_millis(120), + Duration::from_millis(400), + Duration::from_secs(2), + ); + + memory + .store("pref", "Rust should stay local-first", MemoryCategory::Core) + .await + .unwrap(); + + let entries = memory.recall("rust", 5).await.unwrap(); + assert!(entries + .iter() + .any(|e| e.content.contains("Rust should stay local-first"))); + + let context_calls = fs::read_to_string(&marker).unwrap_or_default(); + assert!( + context_calls.trim().is_empty(), + "Expected local-hit short-circuit; got calls: {context_calls}" + ); + } + + fn write_failing_lucid_script(dir: &Path, marker_path: &Path) -> String { + let script_path = dir.join("failing-lucid.sh"); + let marker = marker_path.display().to_string(); + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail + +if [[ "${{1:-}}" == "store" ]]; then + echo '{{"success":true,"id":"mem_store"}}' + exit 0 +fi + +if [[ "${{1:-}}" == "context" ]]; then + printf 'context\n' >> "{marker}" + echo "simulated lucid failure" >&2 + exit 1 +fi + +echo "unsupported command" >&2 +exit 1 +"# + ); + + fs::write(&script_path, script).unwrap(); + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + script_path.display().to_string() + } + + #[tokio::test] + async fn failure_cooldown_avoids_repeated_lucid_calls() { + let tmp = TempDir::new().unwrap(); + let marker = tmp.path().join("failing_context_calls.log"); + let failing_cmd = write_failing_lucid_script(tmp.path(), &marker); + + let sqlite = SqliteMemory::new(tmp.path()).unwrap(); + let memory = LucidMemory::with_options( + tmp.path(), + sqlite, + failing_cmd, + 200, + 99, + Duration::from_millis(120), + Duration::from_millis(400), + Duration::from_secs(5), + ); + + let first = memory.recall("auth", 5).await.unwrap(); + let second = memory.recall("auth", 5).await.unwrap(); + + assert!(first.is_empty()); + assert!(second.is_empty()); + + let calls = fs::read_to_string(&marker).unwrap_or_default(); + assert_eq!(calls.lines().count(), 1); + } +} diff --git a/src/memory/mod.rs b/src/memory/mod.rs index 66912ca..b04e0df 100644 --- a/src/memory/mod.rs +++ b/src/memory/mod.rs @@ -1,12 +1,22 @@ +pub mod backend; pub mod chunker; pub mod embeddings; pub mod hygiene; +pub mod lucid; pub mod markdown; +pub mod none; pub mod sqlite; pub mod traits; pub mod vector; +#[allow(unused_imports)] +pub use backend::{ + classify_memory_backend, default_memory_backend_key, memory_backend_profile, + selectable_memory_backends, MemoryBackendKind, MemoryBackendProfile, +}; +pub use lucid::LucidMemory; pub use markdown::MarkdownMemory; +pub use none::NoneMemory; pub use sqlite::SqliteMemory; pub use traits::Memory; #[allow(unused_imports)] @@ -16,6 +26,32 @@ use crate::config::MemoryConfig; use std::path::Path; use std::sync::Arc; +fn create_memory_with_sqlite_builder( + backend_name: &str, + workspace_dir: &Path, + mut sqlite_builder: F, + unknown_context: &str, +) -> anyhow::Result> +where + F: FnMut() -> anyhow::Result, +{ + match classify_memory_backend(backend_name) { + MemoryBackendKind::Sqlite => Ok(Box::new(sqlite_builder()?)), + MemoryBackendKind::Lucid => { + let local = sqlite_builder()?; + Ok(Box::new(LucidMemory::new(workspace_dir, local))) + } + MemoryBackendKind::Markdown => Ok(Box::new(MarkdownMemory::new(workspace_dir))), + MemoryBackendKind::None => Ok(Box::new(NoneMemory::new())), + MemoryBackendKind::Unknown => { + tracing::warn!( + "Unknown memory backend '{backend_name}'{unknown_context}, falling back to markdown" + ); + Ok(Box::new(MarkdownMemory::new(workspace_dir))) + } + } +} + /// Factory: create the right memory backend from config pub fn create_memory( config: &MemoryConfig, @@ -27,32 +63,54 @@ pub fn create_memory( tracing::warn!("memory hygiene skipped: {e}"); } - match config.backend.as_str() { - "sqlite" => { - let embedder: Arc = - Arc::from(embeddings::create_embedding_provider( - &config.embedding_provider, - api_key, - &config.embedding_model, - config.embedding_dimensions, - )); + fn build_sqlite_memory( + config: &MemoryConfig, + workspace_dir: &Path, + api_key: Option<&str>, + ) -> anyhow::Result { + let embedder: Arc = + Arc::from(embeddings::create_embedding_provider( + &config.embedding_provider, + api_key, + &config.embedding_model, + config.embedding_dimensions, + )); - #[allow(clippy::cast_possible_truncation)] - let mem = SqliteMemory::with_embedder( - workspace_dir, - embedder, - config.vector_weight as f32, - config.keyword_weight as f32, - config.embedding_cache_size, - )?; - Ok(Box::new(mem)) - } - "markdown" | "none" => Ok(Box::new(MarkdownMemory::new(workspace_dir))), - other => { - tracing::warn!("Unknown memory backend '{other}', falling back to markdown"); - Ok(Box::new(MarkdownMemory::new(workspace_dir))) - } + #[allow(clippy::cast_possible_truncation)] + let mem = SqliteMemory::with_embedder( + workspace_dir, + embedder, + config.vector_weight as f32, + config.keyword_weight as f32, + config.embedding_cache_size, + )?; + Ok(mem) } + + create_memory_with_sqlite_builder( + &config.backend, + workspace_dir, + || build_sqlite_memory(config, workspace_dir, api_key), + "", + ) +} + +pub fn create_memory_for_migration( + backend: &str, + workspace_dir: &Path, +) -> anyhow::Result> { + if matches!(classify_memory_backend(backend), MemoryBackendKind::None) { + anyhow::bail!( + "memory backend 'none' disables persistence; choose sqlite, lucid, or markdown before migration" + ); + } + + create_memory_with_sqlite_builder( + backend, + workspace_dir, + || SqliteMemory::new(workspace_dir), + " during migration", + ) } #[cfg(test)] @@ -83,14 +141,25 @@ mod tests { } #[test] - fn factory_none_falls_back_to_markdown() { + fn factory_lucid() { + let tmp = TempDir::new().unwrap(); + let cfg = MemoryConfig { + backend: "lucid".into(), + ..MemoryConfig::default() + }; + let mem = create_memory(&cfg, tmp.path(), None).unwrap(); + assert_eq!(mem.name(), "lucid"); + } + + #[test] + fn factory_none_uses_noop_memory() { let tmp = TempDir::new().unwrap(); let cfg = MemoryConfig { backend: "none".into(), ..MemoryConfig::default() }; let mem = create_memory(&cfg, tmp.path(), None).unwrap(); - assert_eq!(mem.name(), "markdown"); + assert_eq!(mem.name(), "none"); } #[test] @@ -103,4 +172,20 @@ mod tests { let mem = create_memory(&cfg, tmp.path(), None).unwrap(); assert_eq!(mem.name(), "markdown"); } + + #[test] + fn migration_factory_lucid() { + let tmp = TempDir::new().unwrap(); + let mem = create_memory_for_migration("lucid", tmp.path()).unwrap(); + assert_eq!(mem.name(), "lucid"); + } + + #[test] + fn migration_factory_none_is_rejected() { + let tmp = TempDir::new().unwrap(); + let error = create_memory_for_migration("none", tmp.path()) + .err() + .expect("backend=none should be rejected for migration"); + assert!(error.to_string().contains("disables persistence")); + } } diff --git a/src/memory/none.rs b/src/memory/none.rs new file mode 100644 index 0000000..6057ad0 --- /dev/null +++ b/src/memory/none.rs @@ -0,0 +1,74 @@ +use super::traits::{Memory, MemoryCategory, MemoryEntry}; +use async_trait::async_trait; + +/// Explicit no-op memory backend. +/// +/// This backend is used when `memory.backend = "none"` to disable persistence +/// while keeping the runtime wiring stable. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoneMemory; + +impl NoneMemory { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Memory for NoneMemory { + fn name(&self) -> &str { + "none" + } + + async fn store( + &self, + _key: &str, + _content: &str, + _category: MemoryCategory, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn recall(&self, _query: &str, _limit: usize) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn get(&self, _key: &str) -> anyhow::Result> { + Ok(None) + } + + async fn list(&self, _category: Option<&MemoryCategory>) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn forget(&self, _key: &str) -> anyhow::Result { + Ok(false) + } + + async fn count(&self) -> anyhow::Result { + Ok(0) + } + + async fn health_check(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn none_memory_is_noop() { + let memory = NoneMemory::new(); + + memory.store("k", "v", MemoryCategory::Core).await.unwrap(); + + assert!(memory.get("k").await.unwrap().is_none()); + assert!(memory.recall("k", 10).await.unwrap().is_empty()); + assert!(memory.list(None).await.unwrap().is_empty()); + assert!(!memory.forget("k").await.unwrap()); + assert_eq!(memory.count().await.unwrap(), 0); + assert!(memory.health_check().await); + } +} diff --git a/src/migration.rs b/src/migration.rs index 04fa458..f217030 100644 --- a/src/migration.rs +++ b/src/migration.rs @@ -1,5 +1,5 @@ use crate::config::Config; -use crate::memory::{MarkdownMemory, Memory, MemoryCategory, SqliteMemory}; +use crate::memory::{self, Memory, MemoryCategory}; use anyhow::{bail, Context, Result}; use directories::UserDirs; use rusqlite::{Connection, OpenFlags, OptionalExtension}; @@ -112,16 +112,7 @@ async fn migrate_openclaw_memory( } fn target_memory_backend(config: &Config) -> Result> { - match config.memory.backend.as_str() { - "sqlite" => Ok(Box::new(SqliteMemory::new(&config.workspace_dir)?)), - "markdown" | "none" => Ok(Box::new(MarkdownMemory::new(&config.workspace_dir))), - other => { - tracing::warn!( - "Unknown memory backend '{other}' during migration, defaulting to markdown" - ); - Ok(Box::new(MarkdownMemory::new(&config.workspace_dir))) - } - } + memory::create_memory_for_migration(&config.memory.backend, &config.workspace_dir) } fn collect_source_entries( @@ -431,6 +422,7 @@ fn backup_target_memory(workspace_dir: &Path) -> Result> { mod tests { use super::*; use crate::config::{Config, MemoryConfig}; + use crate::memory::SqliteMemory; use rusqlite::params; use tempfile::TempDir; @@ -550,4 +542,16 @@ mod tests { let target_mem = SqliteMemory::new(target.path()).unwrap(); assert_eq!(target_mem.count().await.unwrap(), 0); } + + #[test] + fn migration_target_rejects_none_backend() { + let target = TempDir::new().unwrap(); + let mut config = test_config(target.path()); + config.memory.backend = "none".to_string(); + + let err = target_memory_backend(&config) + .err() + .expect("backend=none should be rejected for migration target"); + assert!(err.to_string().contains("disables persistence")); + } } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 0bf285b..8714089 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -5,6 +5,9 @@ use crate::config::{ RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, WebhookConfig, }; use crate::hardware::{self, HardwareConfig}; +use crate::memory::{ + default_memory_backend_key, memory_backend_profile, selectable_memory_backends, +}; use anyhow::{Context, Result}; use console::style; use dialoguer::{Confirm, Input, Select}; @@ -237,8 +240,38 @@ pub fn run_channels_repair_wizard() -> Result { // ── Quick setup (zero prompts) ─────────────────────────────────── /// Non-interactive setup: generates a sensible default config instantly. -/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite`. +/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite|lucid`. /// Use `zeroclaw onboard --interactive` for the full wizard. +fn backend_key_from_choice(choice: usize) -> &'static str { + selectable_memory_backends() + .get(choice) + .map_or(default_memory_backend_key(), |backend| backend.key) +} + +fn memory_config_defaults_for_backend(backend: &str) -> MemoryConfig { + let profile = memory_backend_profile(backend); + + MemoryConfig { + backend: backend.to_string(), + auto_save: profile.auto_save_default, + hygiene_enabled: profile.uses_sqlite_hygiene, + archive_after_days: if profile.uses_sqlite_hygiene { 7 } else { 0 }, + purge_after_days: if profile.uses_sqlite_hygiene { 30 } else { 0 }, + conversation_retention_days: 30, + embedding_provider: "none".to_string(), + embedding_model: "text-embedding-3-small".to_string(), + embedding_dimensions: 1536, + vector_weight: 0.7, + keyword_weight: 0.3, + embedding_cache_size: if profile.uses_sqlite_hygiene { + 10000 + } else { + 0 + }, + chunk_max_tokens: 512, + } +} + #[allow(clippy::too_many_lines)] pub fn run_quick_setup( api_key: Option<&str>, @@ -265,36 +298,12 @@ pub fn run_quick_setup( let provider_name = provider.unwrap_or("openrouter").to_string(); let model = default_model_for_provider(&provider_name); - let memory_backend_name = memory_backend.unwrap_or("sqlite").to_string(); + let memory_backend_name = memory_backend + .unwrap_or(default_memory_backend_key()) + .to_string(); // Create memory config based on backend choice - let memory_config = MemoryConfig { - backend: memory_backend_name.clone(), - auto_save: memory_backend_name != "none", - hygiene_enabled: memory_backend_name == "sqlite", - archive_after_days: if memory_backend_name == "sqlite" { - 7 - } else { - 0 - }, - purge_after_days: if memory_backend_name == "sqlite" { - 30 - } else { - 0 - }, - conversation_retention_days: 30, - embedding_provider: "none".to_string(), - embedding_model: "text-embedding-3-small".to_string(), - embedding_dimensions: 1536, - vector_weight: 0.7, - keyword_weight: 0.3, - embedding_cache_size: if memory_backend_name == "sqlite" { - 10000 - } else { - 0 - }, - chunk_max_tokens: 512, - }; + let memory_config = memory_config_defaults_for_backend(&memory_backend_name); let config = Config { workspace_dir: workspace_dir.clone(), @@ -2164,11 +2173,10 @@ fn setup_memory() -> Result { print_bullet("You can always change this later in config.toml."); println!(); - let options = vec![ - "SQLite with Vector Search (recommended) — fast, hybrid search, embeddings", - "Markdown Files — simple, human-readable, no dependencies", - "None — disable persistent memory", - ]; + let options: Vec<&str> = selectable_memory_backends() + .iter() + .map(|backend| backend.label) + .collect(); let choice = Select::new() .with_prompt(" Select memory backend") @@ -2176,20 +2184,16 @@ fn setup_memory() -> Result { .default(0) .interact()?; - let backend = match choice { - 1 => "markdown", - 2 => "none", - _ => "sqlite", // 0 and any unexpected value defaults to sqlite - }; + let backend = backend_key_from_choice(choice); + let profile = memory_backend_profile(backend); - let auto_save = if backend == "none" { + let auto_save = if !profile.auto_save_default { false } else { - let save = Confirm::new() + Confirm::new() .with_prompt(" Auto-save conversations to memory?") .default(true) - .interact()?; - save + .interact()? }; println!( @@ -2199,21 +2203,9 @@ fn setup_memory() -> Result { if auto_save { "on" } else { "off" } ); - Ok(MemoryConfig { - backend: backend.to_string(), - auto_save, - hygiene_enabled: backend == "sqlite", // Only enable hygiene for SQLite - archive_after_days: if backend == "sqlite" { 7 } else { 0 }, - purge_after_days: if backend == "sqlite" { 30 } else { 0 }, - conversation_retention_days: 30, - embedding_provider: "none".to_string(), - embedding_model: "text-embedding-3-small".to_string(), - embedding_dimensions: 1536, - vector_weight: 0.7, - keyword_weight: 0.3, - embedding_cache_size: if backend == "sqlite" { 10000 } else { 0 }, - chunk_max_tokens: 512, - }) + let mut config = memory_config_defaults_for_backend(backend); + config.auto_save = auto_save; + Ok(config) } // ── Step 3: Channels ──────────────────────────────────────────── @@ -4343,18 +4335,54 @@ mod tests { } #[test] - fn default_model_for_minimax_is_m2_5() { - assert_eq!(default_model_for_provider("minimax"), "MiniMax-M2.5"); + fn backend_key_from_choice_maps_supported_backends() { + assert_eq!(backend_key_from_choice(0), "sqlite"); + assert_eq!(backend_key_from_choice(1), "lucid"); + assert_eq!(backend_key_from_choice(2), "markdown"); + assert_eq!(backend_key_from_choice(3), "none"); + assert_eq!(backend_key_from_choice(999), "sqlite"); } #[test] - fn minimax_onboard_models_include_m2_variants() { - let model_names: Vec<&str> = MINIMAX_ONBOARD_MODELS - .iter() - .map(|(name, _)| *name) - .collect(); - assert_eq!(model_names.first().copied(), Some("MiniMax-M2.5")); - assert!(model_names.contains(&"MiniMax-M2.1")); - assert!(model_names.contains(&"MiniMax-M2.1-highspeed")); + fn memory_backend_profile_marks_lucid_as_optional_sqlite_backed() { + let lucid = memory_backend_profile("lucid"); + assert!(lucid.auto_save_default); + assert!(lucid.uses_sqlite_hygiene); + assert!(lucid.sqlite_based); + assert!(lucid.optional_dependency); + + let markdown = memory_backend_profile("markdown"); + assert!(markdown.auto_save_default); + assert!(!markdown.uses_sqlite_hygiene); + + let none = memory_backend_profile("none"); + assert!(!none.auto_save_default); + assert!(!none.uses_sqlite_hygiene); + + let custom = memory_backend_profile("custom-memory"); + assert!(custom.auto_save_default); + assert!(!custom.uses_sqlite_hygiene); + } + + #[test] + fn memory_config_defaults_for_lucid_enable_sqlite_hygiene() { + let config = memory_config_defaults_for_backend("lucid"); + assert_eq!(config.backend, "lucid"); + assert!(config.auto_save); + assert!(config.hygiene_enabled); + assert_eq!(config.archive_after_days, 7); + assert_eq!(config.purge_after_days, 30); + assert_eq!(config.embedding_cache_size, 10000); + } + + #[test] + fn memory_config_defaults_for_none_disable_sqlite_hygiene() { + let config = memory_config_defaults_for_backend("none"); + assert_eq!(config.backend, "none"); + assert!(!config.auto_save); + assert!(!config.hygiene_enabled); + assert_eq!(config.archive_after_days, 0); + assert_eq!(config.purge_after_days, 0); + assert_eq!(config.embedding_cache_size, 0); } } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index b342675..1808499 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -202,7 +202,7 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result Ok(Box::new(OpenAiCompatibleProvider::new( "Cloudflare AI Gateway", "https://gateway.ai.cloudflare.com/v1", - api_key, + key, AuthStyle::Bearer, ))), "moonshot" | "kimi" => Ok(Box::new(OpenAiCompatibleProvider::new( @@ -229,7 +229,7 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result Ok(Box::new(OpenAiCompatibleProvider::new( "Amazon Bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com", - api_key, + key, AuthStyle::Bearer, ))), "qianfan" | "baidu" => Ok(Box::new(OpenAiCompatibleProvider::new( @@ -421,6 +421,12 @@ pub fn create_routed_provider( mod tests { use super::*; + #[test] + fn resolve_api_key_prefers_explicit_argument() { + let resolved = resolve_api_key("openrouter", Some(" explicit-key ")); + assert_eq!(resolved.as_deref(), Some("explicit-key")); + } + // ── Primary providers ──────────────────────────────────── #[test] From 3234159c6c0aa2efaa8846fef16400dcd751afac Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 17 Feb 2026 00:32:33 +0800 Subject: [PATCH 19/32] chore(clippy): clear warning backlog and harden conversions (#383) --- src/agent/loop_.rs | 14 ++++------ src/config/schema.rs | 21 ++------------ src/hardware/mod.rs | 15 ++++------ src/observability/otel.rs | 6 ++-- src/onboard/wizard.rs | 2 +- src/providers/reliable.rs | 7 ++++- src/security/audit.rs | 55 +++++++++++++++++++++++++++---------- src/tools/composio.rs | 4 +-- src/tools/git_operations.rs | 22 ++++++--------- 9 files changed, 77 insertions(+), 69 deletions(-) diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 932606f..14c3840 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -1247,18 +1247,16 @@ Done."#; // Recovery Tests - Constants Validation // ═══════════════════════════════════════════════════════════════════════ - #[test] - fn max_tool_iterations_is_reasonable() { - // Recovery: MAX_TOOL_ITERATIONS should be set to prevent runaway loops + const _: () = { assert!(MAX_TOOL_ITERATIONS > 0); assert!(MAX_TOOL_ITERATIONS <= 100); - } - - #[test] - fn max_history_messages_is_reasonable() { - // Recovery: MAX_HISTORY_MESSAGES should be set to prevent memory bloat assert!(MAX_HISTORY_MESSAGES > 0); assert!(MAX_HISTORY_MESSAGES <= 1000); + }; + + #[test] + fn constants_bounds_are_compile_time_checked() { + // Bounds are enforced by the const assertions above. } // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/config/schema.rs b/src/config/schema.rs index 0e58c8f..9473f90 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1199,7 +1199,7 @@ pub struct LarkConfig { // ── Security Config ───────────────────────────────────────────────── /// Security configuration for sandboxing, resource limits, and audit logging -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SecurityConfig { /// Sandbox configuration #[serde(default)] @@ -1214,16 +1214,6 @@ pub struct SecurityConfig { pub audit: AuditConfig, } -impl Default for SecurityConfig { - fn default() -> Self { - Self { - sandbox: SandboxConfig::default(), - resources: ResourceLimitsConfig::default(), - audit: AuditConfig::default(), - } - } -} - /// Sandbox configuration for OS-level isolation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SandboxConfig { @@ -1251,10 +1241,11 @@ impl Default for SandboxConfig { } /// Sandbox backend selection -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum SandboxBackend { /// Auto-detect best available (default) + #[default] Auto, /// Landlock (Linux kernel LSM, native) Landlock, @@ -1268,12 +1259,6 @@ pub enum SandboxBackend { None, } -impl Default for SandboxBackend { - fn default() -> Self { - Self::Auto - } -} - /// Resource limits for command execution #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceLimitsConfig { diff --git a/src/hardware/mod.rs b/src/hardware/mod.rs index 30b551b..ff467f5 100644 --- a/src/hardware/mod.rs +++ b/src/hardware/mod.rs @@ -20,7 +20,7 @@ use std::path::{Path, PathBuf}; // ── Hardware transport enum ────────────────────────────────────── /// Transport protocol used to communicate with physical hardware. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum HardwareTransport { /// Direct GPIO access on a Linux SBC (Raspberry Pi, Orange Pi, etc.) @@ -30,15 +30,10 @@ pub enum HardwareTransport { /// SWD/JTAG debug probe (probe-rs) for bare-metal MCUs Probe, /// No hardware — software-only mode + #[default] None, } -impl Default for HardwareTransport { - fn default() -> Self { - Self::None - } -} - impl std::fmt::Display for HardwareTransport { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -869,7 +864,9 @@ mod tests { #[test] fn validate_baud_rate_common_values_ok() { - for baud in [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600] { + for baud in [ + 9600, 19200, 38400, 57600, 115_200, 230_400, 460_800, 921_600, + ] { let cfg = HardwareConfig { enabled: true, transport: "serial".into(), @@ -938,7 +935,7 @@ mod tests { enabled: true, transport: "probe".into(), serial_port: None, - baud_rate: 115200, + baud_rate: 115_200, workspace_datasheets: false, discovered_board: None, probe_target: Some("nRF52840_xxAA".into()), diff --git a/src/observability/otel.rs b/src/observability/otel.rs index 49f5ec0..5e0c37e 100644 --- a/src/observability/otel.rs +++ b/src/observability/otel.rs @@ -183,7 +183,9 @@ impl Observer for OtelObserver { ], ); } - ObserverEvent::LlmRequest { .. } => {} + ObserverEvent::LlmRequest { .. } + | ObserverEvent::ToolCallStart { .. } + | ObserverEvent::TurnComplete => {} ObserverEvent::LlmResponse { provider, model, @@ -247,7 +249,6 @@ impl Observer for OtelObserver { // Note: tokens are recorded via record_metric(TokensUsed) to avoid // double-counting. AgentEnd only records duration. } - ObserverEvent::ToolCallStart { .. } => {} ObserverEvent::ToolCall { tool, duration, @@ -285,7 +286,6 @@ impl Observer for OtelObserver { self.tool_duration .record(secs, &[KeyValue::new("tool", tool.clone())]); } - ObserverEvent::TurnComplete => {} ObserverEvent::ChannelMessage { channel, direction } => { self.channel_messages.add( 1, diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 8714089..77dbe3b 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -1999,7 +1999,7 @@ fn setup_hardware() -> Result { hw_config.baud_rate = match baud_idx { 1 => 9600, 2 => 57600, - 3 => 230400, + 3 => 230_400, 4 => { let custom: String = Input::new() .with_prompt(" Custom baud rate") diff --git a/src/providers/reliable.rs b/src/providers/reliable.rs index 423bfff..3494a41 100644 --- a/src/providers/reliable.rs +++ b/src/providers/reliable.rs @@ -57,7 +57,12 @@ fn parse_retry_after_ms(err: &anyhow::Error) -> Option { .take_while(|c| c.is_ascii_digit() || *c == '.') .collect(); if let Ok(secs) = num_str.parse::() { - return Some((secs * 1000.0) as u64); + if secs.is_finite() && secs >= 0.0 { + let millis = Duration::from_secs_f64(secs).as_millis(); + if let Ok(value) = u64::try_from(millis) { + return Some(value); + } + } } } } diff --git a/src/security/audit.rs b/src/security/audit.rs index b7dabae..f18208f 100644 --- a/src/security/audit.rs +++ b/src/security/audit.rs @@ -150,6 +150,18 @@ pub struct AuditLogger { buffer: Mutex>, } +/// Structured command execution details for audit logging. +#[derive(Debug, Clone)] +pub struct CommandExecutionLog<'a> { + pub channel: &'a str, + pub command: &'a str, + pub risk_level: &'a str, + pub approved: bool, + pub allowed: bool, + pub success: bool, + pub duration_ms: u64, +} + impl AuditLogger { /// Create a new audit logger pub fn new(config: AuditConfig, zeroclaw_dir: PathBuf) -> Result { @@ -183,7 +195,23 @@ impl AuditLogger { Ok(()) } - /// Log a command execution event + /// Log a command execution event. + pub fn log_command_event(&self, entry: CommandExecutionLog<'_>) -> Result<()> { + let event = AuditEvent::new(AuditEventType::CommandExecution) + .with_actor(entry.channel.to_string(), None, None) + .with_action( + entry.command.to_string(), + entry.risk_level.to_string(), + entry.approved, + entry.allowed, + ) + .with_result(entry.success, None, entry.duration_ms, None); + + self.log(&event) + } + + /// Backward-compatible helper to log a command execution event. + #[allow(clippy::too_many_arguments)] pub fn log_command( &self, channel: &str, @@ -194,24 +222,22 @@ impl AuditLogger { success: bool, duration_ms: u64, ) -> Result<()> { - let event = AuditEvent::new(AuditEventType::CommandExecution) - .with_actor(channel.to_string(), None, None) - .with_action( - command.to_string(), - risk_level.to_string(), - approved, - allowed, - ) - .with_result(success, None, duration_ms, None); - - self.log(&event) + self.log_command_event(CommandExecutionLog { + channel, + command, + risk_level, + approved, + allowed, + success, + duration_ms, + }) } /// Rotate log if it exceeds max size fn rotate_if_needed(&self) -> Result<()> { if let Ok(metadata) = std::fs::metadata(&self.log_path) { let current_size_mb = metadata.len() / (1024 * 1024); - if current_size_mb >= self.config.max_size_mb as u64 { + if current_size_mb >= u64::from(self.config.max_size_mb) { self.rotate()?; } } @@ -283,7 +309,8 @@ mod tests { let json = serde_json::to_string(&event); assert!(json.is_ok()); - let parsed: AuditEvent = serde_json::from_str(&json.unwrap().as_str()).expect("parse"); + let json = json.expect("serialize"); + let parsed: AuditEvent = serde_json::from_str(json.as_str()).expect("parse"); assert!(parsed.actor.is_some()); assert!(parsed.action.is_some()); assert!(parsed.result.is_some()); diff --git a/src/tools/composio.rs b/src/tools/composio.rs index b010240..4e608cb 100644 --- a/src/tools/composio.rs +++ b/src/tools/composio.rs @@ -902,8 +902,8 @@ mod tests { let json_str = r#"{"name": "GMAIL_SEND_EMAIL_WITH_ATTACHMENT", "appName": "gmail", "description": "Send email with attachment & special chars: <>'\"\"", "enabled": true}"#; let action: ComposioAction = serde_json::from_str(json_str).unwrap(); assert_eq!(action.name, "GMAIL_SEND_EMAIL_WITH_ATTACHMENT"); - assert!(action.description.as_ref().unwrap().contains("&")); - assert!(action.description.as_ref().unwrap().contains("<")); + assert!(action.description.as_ref().unwrap().contains('&')); + assert!(action.description.as_ref().unwrap().contains('<')); } #[test] diff --git a/src/tools/git_operations.rs b/src/tools/git_operations.rs index d01243a..a9461fc 100644 --- a/src/tools/git_operations.rs +++ b/src/tools/git_operations.rs @@ -31,7 +31,7 @@ impl GitOperationsTool { || arg_lower.starts_with("--upload-pack=") || arg_lower.starts_with("--receive-pack=") || arg_lower.contains("$(") - || arg_lower.contains("`") + || arg_lower.contains('`') || arg.contains('|') || arg.contains(';') { @@ -90,10 +90,8 @@ impl GitOperationsTool { branch = line.trim_start_matches("# branch.head ").to_string(); } else if let Some(rest) = line.strip_prefix("1 ") { // Ordinary changed entry - let parts: Vec<&str> = rest.split(' ').collect(); - if parts.len() >= 2 { - let path = parts.get(1).unwrap_or(&""); - let staging = parts.get(0).unwrap_or(&""); + let mut parts = rest.splitn(3, ' '); + if let (Some(staging), Some(path)) = (parts.next(), parts.next()) { if !staging.is_empty() { let status_char = staging.chars().next().unwrap_or(' '); if status_char != '.' && status_char != ' ' { @@ -203,7 +201,8 @@ impl GitOperationsTool { } async fn git_log(&self, args: serde_json::Value) -> anyhow::Result { - let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize; + let limit_raw = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10); + let limit = usize::try_from(limit_raw).unwrap_or(usize::MAX).min(1000); let limit_str = limit.to_string(); let output = self @@ -383,7 +382,9 @@ impl GitOperationsTool { "pop" => self.run_git_command(&["stash", "pop"]).await, "list" => self.run_git_command(&["stash", "list"]).await, "drop" => { - let index = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as i32; + let index_raw = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0); + let index = i32::try_from(index_raw) + .map_err(|_| anyhow::anyhow!("stash index too large: {index_raw}"))?; self.run_git_command(&["stash", "drop", &format!("stash@{{{index}}}")]) .await } @@ -516,12 +517,7 @@ impl Tool for GitOperationsTool { error: Some("Action blocked: read-only mode".into()), }); } - AutonomyLevel::Supervised => { - // Allow but require tracking - } - AutonomyLevel::Full => { - // Allow freely - } + AutonomyLevel::Supervised | AutonomyLevel::Full => {} } } From 91ae151548fa382433975abff752462b53b24517 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:35:30 +0100 Subject: [PATCH 20/32] style: fix rustfmt formatting in SSRF tests Co-Authored-By: Claude Opus 4.6 --- src/tools/http_request.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index 1b0514f..d5fa716 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -582,9 +582,9 @@ mod tests { #[test] fn blocks_documentation_ranges() { - assert!(is_private_or_local_host("192.0.2.1")); // TEST-NET-1 + assert!(is_private_or_local_host("192.0.2.1")); // TEST-NET-1 assert!(is_private_or_local_host("198.51.100.1")); // TEST-NET-2 - assert!(is_private_or_local_host("203.0.113.1")); // TEST-NET-3 + assert!(is_private_or_local_host("203.0.113.1")); // TEST-NET-3 } #[test] @@ -630,7 +630,12 @@ mod tests { #[test] fn allows_public_ipv6() { - assert!(!is_private_or_local_host("2001:db8::1").to_string().is_empty() || true); + assert!( + !is_private_or_local_host("2001:db8::1") + .to_string() + .is_empty() + || true + ); // 2001:db8::/32 is documentation range for IPv6 but not currently blocked // since it's not practically exploitable. Public IPv6 addresses pass: assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e")); From b36f23784a4229f00690811bc7f093d5b8c32ccb Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 17 Feb 2026 00:39:28 +0800 Subject: [PATCH 21/32] fix(build): harden rustls dependency path for Linux builds (#275) --- Cargo.toml | 2 +- README.md | 16 ++++++++++++++-- src/channels/mod.rs | 6 +----- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6a6bc78..a096827 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,7 +95,7 @@ http-body-util = "0.1" # OpenTelemetry — OTLP trace + metrics export opentelemetry = { version = "0.31", default-features = false, features = ["trace", "metrics"] } opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics"] } -opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-blocking-client"] } +opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] } [features] default = [] diff --git a/README.md b/README.md index 40dfc6a..1faf4eb 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ ls -lh target/release/zeroclaw ```bash git clone https://github.com/zeroclaw-labs/zeroclaw.git cd zeroclaw -cargo build --release -cargo install --path . --force +cargo build --release --locked +cargo install --path . --force --locked # Quick setup (no prompts) zeroclaw onboard --api-key sk-... --provider openrouter @@ -474,6 +474,18 @@ A git hook runs `cargo fmt --check`, `cargo clippy -- -D warnings`, and `cargo t git config core.hooksPath .githooks ``` +### Build troubleshooting (Linux OpenSSL errors) + +If you see an `openssl-sys` build error, sync dependencies and rebuild with the repository lockfile: + +```bash +git pull +cargo build --release --locked +cargo install --path . --force --locked +``` + +ZeroClaw is configured to use `rustls` for HTTP/TLS dependencies; `--locked` keeps the transitive graph deterministic on fresh environments. + To skip the hook when you need a quick push during development: ```bash diff --git a/src/channels/mod.rs b/src/channels/mod.rs index be012fc..5e8dbcd 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -52,7 +52,6 @@ const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64; struct ChannelRuntimeContext { channels_by_name: Arc>>, provider: Arc, - provider_name: Arc, memory: Arc, tools_registry: Arc>>, observer: Arc, @@ -188,7 +187,7 @@ async fn process_channel_message(ctx: Arc, msg: traits::C &mut history, ctx.tools_registry.as_ref(), ctx.observer.as_ref(), - "channels", + "channel-runtime", ctx.model.as_str(), ctx.temperature, ), @@ -969,7 +968,6 @@ pub async fn start_channels(config: Config) -> Result<()> { let runtime_ctx = Arc::new(ChannelRuntimeContext { channels_by_name, provider: Arc::clone(&provider), - provider_name: Arc::new(provider_name), memory: Arc::clone(&mem), tools_registry: Arc::clone(&tools_registry), observer, @@ -1162,7 +1160,6 @@ mod tests { let runtime_ctx = Arc::new(ChannelRuntimeContext { channels_by_name: Arc::new(channels_by_name), provider: Arc::new(ToolCallingProvider), - provider_name: Arc::new("openrouter".to_string()), memory: Arc::new(NoopMemory), tools_registry: Arc::new(vec![Box::new(MockPriceTool)]), observer: Arc::new(NoopObserver), @@ -1253,7 +1250,6 @@ mod tests { provider: Arc::new(SlowProvider { delay: Duration::from_millis(250), }), - provider_name: Arc::new("openrouter".to_string()), memory: Arc::new(NoopMemory), tools_registry: Arc::new(vec![]), observer: Arc::new(NoopObserver), From de3ec87d163adc673dcace292bbc2e097b389b41 Mon Sep 17 00:00:00 2001 From: ehu shubham shaw <106058299+Extreammouse@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:40:10 -0500 Subject: [PATCH 22/32] Ehu shubham shaw contribution --> Hardware support (#306) * feat: add ZeroClaw firmware for ESP32 and Nucleo * Introduced new firmware for ZeroClaw on ESP32 and Nucleo-F401RE, enabling JSON-over-serial communication for GPIO control. * Added `zeroclaw-esp32` with support for commands like `gpio_read` and `gpio_write`, along with capabilities reporting. * Implemented `zeroclaw-nucleo` firmware with similar functionality for STM32, ensuring compatibility with existing ZeroClaw protocols. * Updated `.gitignore` to include new firmware targets and added necessary dependencies in `Cargo.toml` for both platforms. * Created README files for both firmware projects detailing setup, build, and usage instructions. Co-authored-by: Claude Opus 4.6 * feat: enhance hardware peripheral support and documentation - Added `Peripheral` trait implementation in `src/peripherals/` to manage hardware boards (STM32, RPi GPIO). - Updated `AGENTS.md` to include new extension points for peripherals and their configuration. - Introduced comprehensive documentation for adding boards and tools, including a quick start guide and supported boards. - Enhanced `Cargo.toml` to include optional dependencies for PDF extraction and peripheral support. - Created new datasheets for Arduino Uno, ESP32, and Nucleo-F401RE, detailing pin aliases and GPIO usage. - Implemented new tools for hardware memory reading and board information retrieval in the agent loop. This update significantly improves the integration and usability of hardware peripherals within the ZeroClaw framework. * feat: add ZeroClaw firmware for ESP32 and Nucleo * Introduced new firmware for ZeroClaw on ESP32 and Nucleo-F401RE, enabling JSON-over-serial communication for GPIO control. * Added `zeroclaw-esp32` with support for commands like `gpio_read` and `gpio_write`, along with capabilities reporting. * Implemented `zeroclaw-nucleo` firmware with similar functionality for STM32, ensuring compatibility with existing ZeroClaw protocols. * Updated `.gitignore` to include new firmware targets and added necessary dependencies in `Cargo.toml` for both platforms. * Created README files for both firmware projects detailing setup, build, and usage instructions. Co-authored-by: Claude Opus 4.6 * feat: enhance hardware peripheral support and documentation - Added `Peripheral` trait implementation in `src/peripherals/` to manage hardware boards (STM32, RPi GPIO). - Updated `AGENTS.md` to include new extension points for peripherals and their configuration. - Introduced comprehensive documentation for adding boards and tools, including a quick start guide and supported boards. - Enhanced `Cargo.toml` to include optional dependencies for PDF extraction and peripheral support. - Created new datasheets for Arduino Uno, ESP32, and Nucleo-F401RE, detailing pin aliases and GPIO usage. - Implemented new tools for hardware memory reading and board information retrieval in the agent loop. This update significantly improves the integration and usability of hardware peripherals within the ZeroClaw framework. * feat: Introduce hardware auto-discovery and expanded configuration options for agents, hardware, and security. * chore: update dependencies and improve probe-rs integration - Updated `Cargo.lock` to remove specific version constraints for several dependencies, including `zerocopy`, `syn`, and `strsim`, allowing for more flexibility in version resolution. - Upgraded `bincode` and `bitfield` to their latest versions, enhancing serialization and memory management capabilities. - Updated `Cargo.toml` to reflect the new version of `probe-rs` from `0.24` to `0.30`, improving hardware probing functionality. - Refactored code in `src/hardware` and `src/tools` to utilize the new `SessionConfig` for session management in `probe-rs`, ensuring better compatibility and performance. - Cleaned up documentation in `docs/datasheets/nucleo-f401re.md` by removing unnecessary lines. * fix: apply cargo fmt * docs: add hardware architecture diagram. --------- Co-authored-by: Claude Opus 4.6 --- .gitignore | 3 +- AGENTS.md | 15 +- Cargo.lock | 1266 +++++++++++- Cargo.toml | 36 +- docs/Hardware_architecture.jpg | Bin 0 -> 85764 bytes docs/adding-boards-and-tools.md | 116 ++ docs/arduino-uno-q-setup.md | 217 ++ docs/datasheets/arduino-uno.md | 37 + docs/datasheets/esp32.md | 22 + docs/datasheets/nucleo-f401re.md | 16 + docs/hardware-peripherals-design.md | 324 +++ docs/network-deployment.md | 182 ++ docs/nucleo-setup.md | 147 ++ .../zeroclaw-arduino/zeroclaw-arduino.ino | 143 ++ firmware/zeroclaw-esp32/.cargo/config.toml | 5 + firmware/zeroclaw-esp32/Cargo.lock | 1840 +++++++++++++++++ firmware/zeroclaw-esp32/Cargo.toml | 35 + firmware/zeroclaw-esp32/README.md | 52 + firmware/zeroclaw-esp32/build.rs | 3 + firmware/zeroclaw-esp32/src/main.rs | 154 ++ firmware/zeroclaw-nucleo/Cargo.lock | 849 ++++++++ firmware/zeroclaw-nucleo/Cargo.toml | 39 + firmware/zeroclaw-nucleo/src/main.rs | 187 ++ firmware/zeroclaw-uno-q-bridge/app.yaml | 9 + firmware/zeroclaw-uno-q-bridge/python/main.py | 66 + .../python/requirements.txt | 1 + .../zeroclaw-uno-q-bridge/sketch/sketch.ino | 24 + .../zeroclaw-uno-q-bridge/sketch/sketch.yaml | 11 + src/agent/loop_.rs | 339 ++- src/agent/mod.rs | 15 +- src/channels/mod.rs | 120 +- src/config/mod.rs | 14 +- src/config/schema.rs | 521 +++-- src/daemon/mod.rs | 2 +- src/gateway/mod.rs | 2 + src/hardware/discover.rs | 45 + src/hardware/introspect.rs | 121 ++ src/hardware/mod.rs | 1511 ++------------ src/hardware/registry.rs | 102 + src/lib.rs | 116 +- src/main.rs | 46 +- src/onboard/wizard.rs | 212 +- src/peripherals/arduino_flash.rs | 144 ++ src/peripherals/arduino_upload.rs | 161 ++ src/peripherals/capabilities_tool.rs | 99 + src/peripherals/mod.rs | 231 +++ src/peripherals/nucleo_flash.rs | 83 + src/peripherals/rpi.rs | 173 ++ src/peripherals/serial.rs | 274 +++ src/peripherals/traits.rs | 33 + src/peripherals/uno_q_bridge.rs | 151 ++ src/peripherals/uno_q_setup.rs | 143 ++ src/providers/compatible.rs | 37 +- src/providers/mod.rs | 4 +- src/rag/mod.rs | 397 ++++ src/tools/hardware_board_info.rs | 205 ++ src/tools/hardware_memory_map.rs | 205 ++ src/tools/hardware_memory_read.rs | 181 ++ src/tools/mod.rs | 6 + 59 files changed, 9607 insertions(+), 1885 deletions(-) create mode 100644 docs/Hardware_architecture.jpg create mode 100644 docs/adding-boards-and-tools.md create mode 100644 docs/arduino-uno-q-setup.md create mode 100644 docs/datasheets/arduino-uno.md create mode 100644 docs/datasheets/esp32.md create mode 100644 docs/datasheets/nucleo-f401re.md create mode 100644 docs/hardware-peripherals-design.md create mode 100644 docs/network-deployment.md create mode 100644 docs/nucleo-setup.md create mode 100644 firmware/zeroclaw-arduino/zeroclaw-arduino.ino create mode 100644 firmware/zeroclaw-esp32/.cargo/config.toml create mode 100644 firmware/zeroclaw-esp32/Cargo.lock create mode 100644 firmware/zeroclaw-esp32/Cargo.toml create mode 100644 firmware/zeroclaw-esp32/README.md create mode 100644 firmware/zeroclaw-esp32/build.rs create mode 100644 firmware/zeroclaw-esp32/src/main.rs create mode 100644 firmware/zeroclaw-nucleo/Cargo.lock create mode 100644 firmware/zeroclaw-nucleo/Cargo.toml create mode 100644 firmware/zeroclaw-nucleo/src/main.rs create mode 100644 firmware/zeroclaw-uno-q-bridge/app.yaml create mode 100644 firmware/zeroclaw-uno-q-bridge/python/main.py create mode 100644 firmware/zeroclaw-uno-q-bridge/python/requirements.txt create mode 100644 firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino create mode 100644 firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml create mode 100644 src/hardware/discover.rs create mode 100644 src/hardware/introspect.rs create mode 100644 src/hardware/registry.rs create mode 100644 src/peripherals/arduino_flash.rs create mode 100644 src/peripherals/arduino_upload.rs create mode 100644 src/peripherals/capabilities_tool.rs create mode 100644 src/peripherals/mod.rs create mode 100644 src/peripherals/nucleo_flash.rs create mode 100644 src/peripherals/rpi.rs create mode 100644 src/peripherals/serial.rs create mode 100644 src/peripherals/traits.rs create mode 100644 src/peripherals/uno_q_bridge.rs create mode 100644 src/peripherals/uno_q_setup.rs create mode 100644 src/rag/mod.rs create mode 100644 src/tools/hardware_board_info.rs create mode 100644 src/tools/hardware_memory_map.rs create mode 100644 src/tools/hardware_memory_read.rs diff --git a/.gitignore b/.gitignore index 1b068a3..badd0e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /target +firmware/*/target *.db *.db-journal .DS_Store .wt-pr37/ -docker-compose.override.yml +.env diff --git a/AGENTS.md b/AGENTS.md index 9c24ffd..cfbacfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ Key extension points: - `src/memory/traits.rs` (`Memory`) - `src/observability/traits.rs` (`Observer`) - `src/runtime/traits.rs` (`RuntimeAdapter`) +- `src/peripherals/traits.rs` (`Peripheral`) — hardware boards (STM32, RPi GPIO) ## 2) Deep Architecture Observations (Why This Protocol Exists) @@ -141,7 +142,8 @@ Required: - `src/providers/` — model providers and resilient wrapper - `src/channels/` — Telegram/Discord/Slack/etc channels - `src/tools/` — tool execution surface (shell, file, memory, browser) -- `src/runtime/` — runtime adapters (currently native/docker) +- `src/peripherals/` — hardware peripherals (STM32, RPi GPIO); see `docs/hardware-peripherals-design.md` +- `src/runtime/` — runtime adapters (currently native) - `docs/` — architecture + process docs - `.github/` — CI, templates, automation workflows @@ -236,13 +238,14 @@ Use these rules to keep the trait/factory architecture stable under growth. - Validate and sanitize all inputs. - Return structured `ToolResult`; avoid panics in runtime path. -### 7.4 Memory / Runtime / Config Changes +### 5.4 Adding a Peripheral -- Keep compatibility explicit (config defaults, migration impact, fallback behavior). -- Add targeted tests for boundary conditions and unsupported values. -- Avoid hidden side effects in startup path. +- Implement `Peripheral` in `src/peripherals/`. +- Peripherals expose `tools()` — each tool delegates to the hardware (GPIO, sensors, etc.). +- Register board type in config schema if needed. +- See `docs/hardware-peripherals-design.md` for protocol and firmware notes. -### 7.5 Security / Gateway / CI Changes +### 5.5 Security / Runtime / Gateway Changes - Include threat/risk notes and rollback strategy. - Add/update tests or validation evidence for failure modes and boundaries. diff --git a/Cargo.lock b/Cargo.lock index 41924f2..6df10c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + [[package]] name = "aead" version = "0.5.2" @@ -12,6 +27,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -24,6 +50,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -101,7 +136,25 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ - "object", + "object 0.37.3", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.3", + "slab", + "windows-sys 0.61.2", ] [[package]] @@ -205,11 +258,72 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bitfield" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ba6517c6b0f2bf08be60e187ab64b038438f22dd755614d8fe4d4098c46419" +dependencies = [ + "bitfield-macros", +] + +[[package]] +name = "bitfield-macros" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] [[package]] name = "block-buffer" @@ -220,12 +334,47 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -238,6 +387,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -250,6 +408,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cff-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f5b6e9141c036f3ff4ce7b2f7e432b0f00dee416ddcd4f17741d189ddc2e9d" + [[package]] name = "cfg-if" version = "1.0.4" @@ -368,12 +532,31 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ef0193218d365c251b5b9297f9911a908a8ddd2ebd3a36cc5d0ef0f63aee9e" +dependencies = [ + "heapless", + "thiserror 2.0.18", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -383,7 +566,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.59.0", ] @@ -396,7 +579,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.61.2", ] @@ -421,6 +604,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -455,6 +648,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "cron" version = "0.12.1" @@ -466,6 +668,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -477,12 +685,93 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deku" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9711031e209dc1306d66985363b4397d4c7b911597580340b93c9729b55f6eb" +dependencies = [ + "bitvec", + "deku_derive", + "no_std_io2", + "rustversion", +] + +[[package]] +name = "deku_derive" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58cb0719583cbe4e81fb40434ace2f0d22ccc3e39a74bb3796c22b451b4f139d" +dependencies = [ + "darling", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.5.6" @@ -569,12 +858,41 @@ dependencies = [ "syn", ] +[[package]] +name = "docsplay" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8547ea80db62c5bb9d7796fcce5e6e07d1136bdc1a02269095061e806758fab4" +dependencies = [ + "docsplay-macros", +] + +[[package]] +name = "docsplay-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + [[package]] name = "either" version = "1.15.0" @@ -603,6 +921,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -639,6 +966,57 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esp-idf-part" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ebc2381d030e4e89183554c3fcd4ad44dc5ab34961ab09e09b4adbe4f94b61" +dependencies = [ + "bitflags 2.11.0", + "csv", + "deku", + "md-5", + "parse_int", + "regex", + "serde", + "serde_plain", + "strum", + "thiserror 2.0.18", +] + +[[package]] +name = "espflash" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f05d15cb2479a3cbbbe684b9f0831b2ae036d9faefd1eb08f21267275862f9" +dependencies = [ + "base64", + "bitflags 2.11.0", + "bytemuck", + "esp-idf-part", + "flate2", + "gimli", + "libc", + "log", + "md-5", + "miette", + "nix 0.30.1", + "object 0.38.1", + "serde", + "sha2", + "strum", + "thiserror 2.0.18", +] + +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -686,6 +1064,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -719,6 +1107,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -752,6 +1161,16 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -781,6 +1200,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -850,12 +1270,32 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +dependencies = [ + "fallible-iterator", + "indexmap", + "stable_deref_trait", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -904,18 +1344,55 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hidapi" +version = "2.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "565dd4c730b8f8b2c0fb36df6be12e5470ae10895ddcc4e9dcfbfb495de202b0" +dependencies = [ + "cc", + "cfg-if", + "libc", + "nix 0.27.1", + "pkg-config", + "udev", + "windows-sys 0.48.0", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1241,6 +1718,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1262,6 +1745,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ihex" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "365a784774bb381e8c19edb91190a90d7f2625e057b55de2bc0f6b57bc779ff2" + [[package]] name = "indexmap" version = "2.13.0" @@ -1280,9 +1769,31 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1320,6 +1831,15 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jep106" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1354c92c91fd5595fd4cc46694b6914749cc90ea437246549c26b6ff0ec6d1" +dependencies = [ + "serde", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1405,7 +1925,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", ] @@ -1420,6 +1940,28 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1453,12 +1995,49 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lopdf" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" +dependencies = [ + "aes", + "bitflags 2.11.0", + "cbc", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.3.4", + "indexmap", + "itoa", + "log", + "md-5", + "nom 8.0.0", + "nom_locate", + "rand 0.9.2", + "rangemap", + "sha2", + "stringprep", + "thiserror 2.0.18", + "ttf-parser", + "weezl", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "mail-parser" version = "0.11.2" @@ -1474,12 +2053,44 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mime" version = "0.3.17" @@ -1502,6 +2113,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1509,10 +2130,79 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "mio-serial" +version = "5.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029e1f407e261176a983a6599c084efd322d9301028055c87174beac71397ba3" +dependencies = [ + "log", + "mio", + "nix 0.29.0", + "serialport", + "winapi", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a3564ce7035b1e4778d8cb6cacebb5d766b5e8fe5a75b9e441e33fb61a872c6" +dependencies = [ + "memchr", +] + [[package]] name = "nom" version = "7.1.3" @@ -1532,6 +2222,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1556,6 +2257,43 @@ dependencies = [ "autocfg", ] +[[package]] +name = "nusb" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f861541f15de120eae5982923d073bfc0c1a65466561988c82d6e197734c19e" +dependencies = [ + "atomic-waker", + "core-foundation 0.9.4", + "core-foundation-sys", + "futures-core", + "io-kit-sys", + "libc", + "log", + "once_cell", + "rustix 0.38.44", + "slab", + "windows-sys 0.48.0", +] + +[[package]] +name = "nusb" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0226f4db3ee78f820747cf713767722877f6449d7a0fcfbf2ec3b840969763f" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "futures-core", + "io-kit-sys", + "linux-raw-sys 0.9.4", + "log", + "once_cell", + "rustix 1.1.3", + "slab", + "windows-sys 0.60.2", +] + [[package]] name = "object" version = "0.37.3" @@ -1565,6 +2303,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" +dependencies = [ + "flate2", + "memchr", + "ruzstd", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1665,6 +2414,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1688,6 +2443,32 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse_int" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c464266693329dd5a8715098c7f86e6c5fd5d985018b8318f53d9c6c2b21a31" +dependencies = [ + "num-traits", +] + +[[package]] +name = "pdf-extract" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28ba1758a3d3f361459645780e09570b573fc3c82637449e9963174c813a98" +dependencies = [ + "adobe-cmap-parser", + "cff-parser", + "encoding_rs", + "euclid", + "log", + "lopdf", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1732,6 +2513,20 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -1743,6 +2538,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1777,6 +2584,65 @@ dependencies = [ "syn", ] +[[package]] +name = "probe-rs" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee27329ac37fa02b194c62a4e3c1aa053739884ea7bcf861249866d3bf7de00" +dependencies = [ + "anyhow", + "async-io", + "bincode", + "bitfield", + "bitvec", + "cobs", + "docsplay", + "dunce", + "espflash", + "flate2", + "futures-lite", + "hidapi", + "ihex", + "itertools", + "jep106", + "nusb 0.1.14", + "object 0.37.3", + "parking_lot", + "probe-rs-target", + "rmp-serde", + "scroll", + "serde", + "serde_yaml", + "serialport", + "thiserror 2.0.18", + "tracing", + "uf2-decode", + "zerocopy", +] + +[[package]] +name = "probe-rs-target" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2239aca5dc62c68ca6d8ff0051fe617cb8363b803380fbc60567e67c82b474df" +dependencies = [ + "base64", + "indexmap", + "jep106", + "serde", + "serde_with", + "url", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1909,6 +2775,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -1968,13 +2840,19 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1999,6 +2877,35 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + [[package]] name = "reqwest" version = "0.12.28" @@ -2056,6 +2963,34 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rppal" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612e1a22e21f08a246657c6433fe52b773ae43d07c9ef88ccfc433cc8683caba" +dependencies = [ + "libc", +] + [[package]] name = "rsqlite-vfs" version = "0.1.0" @@ -2072,7 +3007,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2087,16 +3022,29 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -2156,6 +3104,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ruzstd" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ff0cc5e135c8870a775d3320910cd9b564ec036b4dc0b8741629020be63f01" +dependencies = [ + "twox-hash", +] + [[package]] name = "ryu" version = "1.0.23" @@ -2177,14 +3134,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add" + [[package]] name = "security-framework" version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ - "bitflags", - "core-foundation", + "bitflags 2.11.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2260,6 +3223,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -2281,6 +3253,52 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap", + "serde_core", + "serde_json", + "time", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serialport" +version = "4.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acaf3f973e8616d7ceac415f53fc60e190b2a686fbcf8d27d0256c741c5007b" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "core-foundation 0.10.1", + "core-foundation-sys", + "io-kit-sys", + "mach2", + "nix 0.26.4", + "scopeguard", + "unescaper", + "winapi", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2343,6 +3361,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.12" @@ -2396,12 +3420,44 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2439,6 +3495,12 @@ dependencies = [ "syn", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.25.0" @@ -2448,7 +3510,7 @@ dependencies = [ "fastrand", "getrandom 0.4.1", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -2603,6 +3665,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-serial" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa1d5427f11ba7c5e6384521cfd76f2d64572ff29f3f4f7aa0f496282923fdc8" +dependencies = [ + "cfg-if", + "futures", + "log", + "mio-serial", + "serialport", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2663,12 +3739,21 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", "toml_writer", "winnow", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_datetime" version = "1.0.0+spec-1.1.0" @@ -2678,6 +3763,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.0.8+spec-1.1.0" @@ -2746,7 +3843,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http 1.4.0", @@ -2821,6 +3918,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.24.0" @@ -2841,24 +3944,93 @@ dependencies = [ "utf-8", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "type1-encoding-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b" +dependencies = [ + "pom", +] + [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "udev" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50051c6e22be28ee6f217d50014f3bc29e81c20dc66ff7ca0d5c5226e1dcc5a1" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[package]] +name = "uf2-decode" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca77d41ab27e3fa45df42043f96c79b80c6d8632eed906b54681d8d47ab00623" + +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "unicase" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -2881,12 +4053,24 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" version = "2.5.8" @@ -2897,6 +4081,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2940,6 +4125,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "want" version = "0.3.1" @@ -3073,7 +4264,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -3137,6 +4328,34 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -3432,6 +4651,9 @@ name = "winnow" version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" @@ -3491,7 +4713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -3533,6 +4755,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yoke" version = "0.7.5" @@ -3605,11 +4836,15 @@ dependencies = [ "landlock", "lettre", "mail-parser", + "nusb 0.2.1", "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", + "pdf-extract", + "probe-rs", "prometheus", "reqwest", + "rppal", "rusqlite", "rustls", "rustls-pki-types", @@ -3621,6 +4856,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-rustls", + "tokio-serial", "tokio-test", "tokio-tungstenite", "toml", diff --git a/Cargo.toml b/Cargo.toml index a096827..a9ff034 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,20 +97,30 @@ opentelemetry = { version = "0.31", default-features = false, features = ["trace opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics"] } opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] } +# USB device enumeration (hardware discovery) +nusb = { version = "0.2", default-features = false, optional = true } + +# Serial port for peripheral communication (STM32, etc.) +tokio-serial = { version = "5", default-features = false, optional = true } + +# probe-rs for STM32/Nucleo memory read (Phase B) +probe-rs = { version = "0.30", optional = true } + +# PDF extraction for datasheet RAG (optional, enable with --features rag-pdf) +pdf-extract = { version = "0.10", optional = true } + +# Raspberry Pi GPIO (Linux/RPi only) — target-specific to avoid compile failure on macOS +[target.'cfg(target_os = "linux")'.dependencies] +rppal = { version = "0.14", optional = true } + [features] -default = [] -browser-native = ["dep:fantoccini"] - -# Sandbox backends (platform-specific, opt-in) -sandbox-landlock = ["landlock"] # Linux kernel LSM -sandbox-bubblewrap = [] # User namespaces (Linux/macOS) - -# Full security suite -security-full = ["sandbox-landlock"] - -[[bin]] -name = "zeroclaw" -path = "src/main.rs" +default = ["hardware"] +hardware = ["nusb", "tokio-serial"] +peripheral-rpi = ["rppal"] +# probe = probe-rs for Nucleo memory read (adds ~50 deps; optional) +probe = ["dep:probe-rs"] +# rag-pdf = PDF ingestion for datasheet RAG +rag-pdf = ["dep:pdf-extract"] [profile.release] opt-level = "z" # Optimize for size diff --git a/docs/Hardware_architecture.jpg b/docs/Hardware_architecture.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8daf589a8f456391c82a8a488b15483d861e6a55 GIT binary patch literal 85764 zcmex=oIr{vTox*2QUlX4D8Yy<&7FwK7X@%sZ+Ht0!*cgBIMQ@{!suAX0R@<;^2 zhLikdN|$}^TKzH1{BCpgbo%9Gyv1BIN-OLN);^he=!s#O=_S6mGkapUezd5${N$~5 z%mcCM=l2JtYHsBTP0O=6wbZ2icR<_Mr@4>5Pc(UYd&ANGyx=t38h_b&K6O>a7%_Ig zxsHVuY`1Cg=O>@QEF{AA%>k4016H4SV}A6`sp!=?oG-_`UEt;O6W60ZA9;IUu)5}n zb`%)|j$K@szWa@ub-5w)rS>Cpwp*OK z{IFu>Cm-D%Q-ak@U#Qvrv8uR!=G^@gXJ4&Utyrz;!0`16^ZU~*6F+S$mY*%`r@r;; zsdkgo&-NvAhYBh0TRP|Ro72yZnH;-+v{*CESl7?zZgp);-X8|`#U~hkf12mq>}%5g z`HTHLMt4wpVa_5J|612%7tUae%{n)&&U*G|h1CK>a;+&919 z`qC+G%IZlKzt6uvdhxP+(ys{C5i3v28z1*ey?OfGi$$GI}?v$qrW z&#B|uX!|3;Dqxz~BL=P7hi|QZ_-p(*eP*HY_F&OB5nVSw{ILn1y|5x^?nRAH^RHYr zUzl5+S&bKV5qiXVBoJ~ zV30e{zyMOlV3v%>^I$3dxY|kv#@FW;pI~6V-(SbTQ1$u6CkBS^kMB1y$k)}%+x>yb zUY{T5z)<}WWaHQ8*C#MAEVOI+0x2KBCSfHgG2R|lXks-PEC_ap+@4=H4GgwFzCU1K zUvJm1z`$Q;`-6e;`}2#R-ydLr%2s}T!oa>C6fiI+6+iF4@h{Y<6>Jc00;U`iKyeQ6 z0J=T_djNrg59}@mc=E(;It4PkQJX&bb{^^~STsjtIhW?3V{DoGg8@>f@a?PqjG8;k zEljHoO+MO~{IFPY-p;DbXx~etT~!th>pP9MJ&D(~_0g5%^ZR#Z{Vt!iwmg3Kdfxpx z&l}m__L%d|gO?vZe7|w#_mWe;KbU>AFsn7xN%3);dgkIBjG$z{yd7E-Fo)aCx!eJ9 z3`7~*n!R54(q8!fs7khdIeX33d-xbHv zvsWy)D?7|w&-&hey#mBWB(nDBm%Gh%5P_G^-HkgSOzdR!2khd^@6Y~Vur35kV)_p( zOqk&PVGe1KFfd#``viwgc#YxRu^Xlh(qv)$@?+&gb&bwbN<|%W7GW?J&n;$PF5iMh z2CBCIP(=XWZqHRJxRls?uDcV$=rC`QCk`v11|iwL+_3xsyqFKSxARwEe0~0Mp4=T+ zbB}R9dvXWE{pRfgFwf>6yzz*^~dWIDLI+$qK3#(qt?7w?2O7 zyYf!o?c}byrL}e%6>)Qny8fjKee-PE)9ApspY5;&!~NzoL$Caz`E%x64)~XQ-+k7n>MAYB@w3|G;-*9<0H1jg>#{4Sw zQ*@~Mr1g1@7g70C%Y~52>ZO;`7B50Bx)~i_?@CyNWQy&Q06U~| z-9D}F4=bZv0kRUf6|t(Xt<9jxRqEq-~{0$PirH3@islqcFj4FFMA8Rr)o zLY0E}3=9lh!Y}+GO!Ei#8yUeFlJB4G4@*d}GspSPKyoQ_SZ<_0L7|enb zrcXd^6A<_MDc7VF#B*#+kkV%YiL?u57p z;Zxq2KQ$P=K(IR@1gL2TVPTN>o1rxevhOfdF)%PNwfsVkY*Z!4PWcB5bW|l!-a>n5 zXhQkO6r#UmQvCMJBV;Ks`}g;~E-hDYrf;h-)_h{T;)BtOA|ta}L$kLY>+3|n`(2s( z?3l51J?pux3LjR^G&1`c(_1~se0}w`=zWu_(=WrzWUyIKf_Db2pNO3Ppppy>46L5D zyqo`i*8Y3&tl96mU)CPiciw)cj`u%@N`@jzRG35-K|;pt-N@CuO)c+!5onl?^zdHLfz??d&BX| zkM{={N-P^*fd$!me)lL?)z~on{$N!MR>fQ2*vi-zAH z7#Qz>${2Rv2){j`Ow638>{bBQ2JN4M*`V}#F(|I};-hwxQ!hU}T$I`r_n%>k|M`H! zJ`+HJ&dT|P^TA&+wJTFUwHsfVCBb|1dy-w%4WkFk4L~&+s0_+G9P#+Mdvky0G@f7k zY7NV-o|$m@ZCuqkuL>PsP>Wwg{etJijHH{r+UGCZT>M`1xsG>F&%bTymfvRAOkCcd zlo0?anH)f|52~WUE@NO0x1Z+^VKOYV-_Q8^#LLGIzCV9Z^31mCncN?+O#Z<;5BU9+ zm(N$e43ho&`N7LGAPJEA?E+xMcE6v=Rx;TA;RA&Nqf>oxgWZ=Wwv~MI?D*?#`s?c0 z_cOnb1Jz($!oR`I59T^Ph~W$j$f+IV)7qaG%Th14YW%7(&ii=eX5VokxplSv^R0i{ zJzf3a`LroX(|ChZ!sMAR>+H!qu(Qnk?L>#6ll;a2l_Y%@Kq%AS8Y{&~GB1Ef1w z{c&Yg$?1i5g_k2{e)st-w>((v=2p(!KiBU`O}d;HWzQ33!nxb_qv4;|CqqC%SoLS& zSKIW3GrrEwpWJfY_3~`Lmf4r%ox-!?IsUFHvix)L!}HT|!OLVPJ46Y;@W%{Idwx*R zfGoG)&%k>|IXv@m*vgdq&jmC6ewh6|Wb*BRb@dt3;zuUw3?k~^Etv21G>7Fkz4Lss zdb0YVIrm%deg+i|OfCQZu&-CnzoVSKMyY*g%iaEyWq*z_tekA9S#-o?vv~X-NULq> zt@tSy-wT!$Khyj%&uz}p%c~OZp4Yg2Ez`92L~Nu}Tbu%x05|#k8eYBKZ$j$vN^d}F z8{BO}miZ9i`4CdnGcYiiCoZ%F*K2p%p*bGbYykV+m81J`z2D=kPu5p0Ajy8s;rfU%?4s*p$BA^5_03B*FGg z34uro-~Zln)c%X`Nr-ZgzDZY@=C{C&`20RR`7=VltfwO^451EyQAl0`mylp_{=GXu zw21mQi$W;UX4Rz8CbdwF%Bpa^s zhNM!qIafFzWS(`H$G`T6zm{=RpL2IQ^W^o5+ta>;*d4@kM&u7uqsr_%E~xntkP|yWElM%lJ3#(>bgw zGmkIPfq|9ti%6A;rtji?J^Ex&}1W||52``PhVr3Kmj zdH$mGsyGHCfAgROWu*CRfu$oWvK?)$|K*+TEXpyaV&lkSl ze(rAXosxea!fU}QA+iwiJ6Plsn1qNx$dBiB3{Tq>PUrc2+GgWah8I9BSRC`8EqD{8c{BYaVY|>(9UZyv^rhpVf}!i<(B-&&n)5 z9rIDl=fioM`MYYb$FlDAD-3F?`3w$!kkuG*1QNs%I8dqMG7!e6^Q0z>+VA&cAbKI> z~oWAm-yDvFgR87)>0j-UszW*$Yl}GM~>u z6=Nn;G`pz44fZv;Oe!jc*!uw-q!9M!^EQ`xZ4UR_96w|5ncwCyzscuwCZBz4Ah{K; z=JR*r0Nf-0j(D*dp=Hc<^^m{y|*KIzXxB1k1cKg+sulH@9pJ&)~ zx%S(Ap0B?vg3s5KpZEQKH&yI-EGXp9f^+M6o6r3=pW$vsi7x}F3(wnJJ`MF0Tm-`S zG~Z?eC{6lWgf^>O53? zEs5N`f-mU)^{;zF;RCWrH{M14fI2+oGM8I&f83m>>fI0F_| zh&XD8W#Qu@;M@x0rov;Y9h~?f_I`wh6*$tt@%8u&%>C{*pP{lkAm@Q{vBg9rN5bs2 zU0ilQ<~~T*;&a!}m)-q*-sWRmw*L1E-|THZpRY0h&+t6X_Rr_@8sW&Ap+?JWJg)<( zOCBHQF$P!9AN@g%V^EoR+Q#rOsNM7VybiePf~H`&#hb63*Sq-pKf_g<#iv8}-w7=~ zZ}Tx)yZ&qB#h}Nf=WEIff8^Fazh9bxVlmj4pU>NTIIU}Nny28n&En%c%fdm<|L84a zaGmGF?VQc$ZA=c!emKm#8P(#?(3YL#VIBi?Bm)!SEPzQVTnBj=9JJ?cKFNcu_6KCc6^0v2wc5%!{$90;o++CQJS z0Y{q%lAGa$99oHmRj>U@I$S^&1Y~aLcrcL`MvY~W2H`ppVx$CX`Ar#w);&l zJ5T+CKde5(5(A{@g+-+fgn>V-;0>JPGKrsH4WICH(30{f$UBhG81f{_cn4_8hwQNv z#?yG;TX0kFtI2!@+dfRC^+*dG_ru^=rGg_E&-+!L%@;f zLG}Sw@xZDg%vuUVQMK?*Q1Ag34uR4zgt0|V0rWFCn9KJX~tbZ_^`l|0Vn zQ??&)n||{MMtWHTQpmuBF_3sl$AFPzK|{%e0}MOoy64=3qgy2m{s00FfnjE z>S9pq=Xm&-LBD~43F?2Q4GdiSvNIU$|Lod+g>~T^2B8k-!oAE7-_Lq~{OfN9mIDl* zE-@-FJezep)^b1l>ia8H4=`{*^p@~By`3z=>762AP&A%)x!GBA? z2Czhg^V~Z8bjx=Zkpt&sf-v(SL@zVg1gIN`r8u0xDpBnP3lS!u>6sCu+#sx#0jig2 z1A_p(giK(77l|?qd~h}cBQu;0Vi>K0n9uZp!3h@B%n1yTf{1~EA%cMoqJV)xgnDdN`IbA^N9fXoL6o=;4+ z#)m;mv$>#pnKm#4t^7F8sHxwgfq4PLij?5Kibv-x8hjYI7!5A_Y@Tse`6P=w*P^DU49hLW^s#3S_n%SOiW$Qw2DS!wV&LnWED= zn~%#Bp4Qp?p8-T|IxkarTxZjJFnQXh@VLyT(>8^NAjUu}CaY3`rXiSzA?BfxsObqw z5^68L%z#rbB<;ci3t=c@b(h7$E}Qn#91o7kD)b>FkXWdZgpgoVUHYHl_J0N`*6e$! zZ};wZMo1yCAg+cb4F|FffRKZGt6`ytm*oL_Zf4>!ILvLF0>ofe{*l3=C3W33LMNat3AvsDUW<8a{?u_Y=lI zDC03W3{D83=9&PgaU;UOpn#}q5cZ-KAt(+&ZniMP-OLJ&K?Vl2q6@2DP?Cge05w2R zRAJ{p?3FM$t+U~{OvY)Q4Tl*N4(n`8l_@yP!Ejin@EXsi(>xIGVHg0>3rXl;NijxH z0~r*~D_}JP7hb(Q4ClYDIBYYyU&mlNV*#Vs!dBM_3~Lxx&oMqO!@$6YS1$+0rE@xu z*O?qTXE2R1LHVG9@hJ<26%0*&HqAT?4EXKkV0f)#05M&RF+oh?VMhf6!wQBeIf;It zW%3Xiu$Pbth<|z5YTr$Os1Ro?(DIf3`-XvG4MUUN6mJHCZf0iC0BL1WU|?eag%!wn z4Tc8r(gFf*=Hd8y&EVk|%WxeAaYpD$UWPRc&E~9qyiMV8JzRQ0l{2zoB(Rw{ zKuL%M4OCM4{|JMsAOkZ46B9EdBLfpNBiLL^@a5SfT&#%!`YQ-Fl@=GRUI%yVpOl`gw8PSttI#ayQu){$~&pbW(i0 zk<0P7(9w%*_UZM`4_`lDTCr5KXl9LOWKE{m=`VJ2``c<_>v?|cI;F^QvcpO6k>KM+ z0(Aznq>kKCn>WjyUV7;EcjpRqHd8F7u}Ti^X>TcPx52XqD6~cU0q$>x(I<@ zQ8XZ>1q0Ime*S0p#J2pC`0vw;HcHAN~E{^&?&Ju3B1} zg@N81s|Ee~y3F*G+P4Kgn<`NEZ~FV@^-rw+Nq#K-(;g?#0uCZYV^BaTaySVaKn08@ zLPfwT_`BD$zn{EjpXk6v&7rnG3vMX$g;U#BT8db4AxrhDtWjb1^WXD;68U=S!$!raEYmT0dkBP#O_6xA*h;{6Tub0(6giv{l>}OZoIP3;IXj#LT7o9DDB8ys z?^AvMNqmj3zzk3tm49TSUlEa!k)$ZFtm^PLRkw~3rwaZ0341|F3T&(Re(ts-r_{OU zZ8#M)W$A~w{|s6JEm9BTbe)8S53ey4X#epOq&{)ew5j~9)rXEd_pDfI{oytLjlCT! zmd9z|e|pHMP_O*R-hWPC`!*fz(sy@vKJ~rz_%->BvYj0-F5LLA_usDPHQx81D1q~c zK#L-4ixZg1&jWIyK+}JQL-PNRFenK!FflPPF*88RQU(SFK}G=uMFU5NfCR&Wh6&)Z zlo?c({%26wmUDA++S%D=4`R;q#ol$2w!diTS1O>YuH-~!=7Y` zV>VFQ@BxGmr$LhTbKY|-_nUFdomqC{<()@+l4TA1%?xjE_o*`x(h6%m(&}~foQ9L2 zS?jE&9wDtBaV9nC6^a~AqKX_&2RWPsR&qEAY;Y22^>7ksZ34#%C|wD(exCbl-Jcsj z=lzqjOW*G;*JbBqE4<9Gr{uU#vh02{i|S7k)<%hOU7ss&eE;zAGvAM!tDKisnkjj% zaDMr9o?8BziFr3!1lvoF#}@3K)%}{yjVF1>iq8{TJz_LMb_le3O<0+*HfU*2h3)Sj zAF3Zgk^|UwtVHXd$ou!fL18Iz%&_mqF{6}DiDTBu630ON-<3bi3@^*t$olbgZl5D$ zcv(*P)l@B~C0b4^4<@WkYt82T>XR%l@*`jKYxbnVK8-Cp%dgo_Y%1!WSD|jP^4sSp z=RdRmKf(|!z`(%5#LCLW&CbNi%*?{fz{teR!XU^hBrGyXRM9XnL_k5w$kf3xG^NPE z#3?8^v9NgJ;*H9x#sLY93ss!iT#^b7x;9@lYiJTnp0G&GZ4)@XGBPmQGyP|{CCp{> zdX=-rj2rwunr<`Jw0vXRy3TU>Q5{7$4Go>tK+~%IX~HktOENFBII8$4sd62**Wz@Q zdNntt?MleQ^a~M7r0&k)4LP!sU#Ql)H#BF0M3_UUxm(SJO}=*mpOtilxhb6RzF@uP zwn1>CVQAFU_JF*L9cnwLZP9r(DN!U!bKMKCh1sl*627ZWGxkhMN?@Fp5j*Q`18c~u zhQnsBRxHp6VGRN6iF$^{%`|XY zHLqu{z*4!FO5sM%;a6D{r&tKI3QJTmi7vjYGSJUlVX3PrMTpB%V)e@n9 zThbbqoDII$c}O?l{EQ8M6<2kIZ<@B!VHu;@{20NVjhi^`HMksKZ*?c(tMehQ^KJef z3|1CV3M;QfYVP1vn9s5KhP7TvQq6Ld(dB3jihO(P83hs z6WB5_Fj2sx&7y6Ep-Y?43>C$TBIdm--?f*uJ>K!X(CNp6-y7T74K=2xDV)g8Y?3*? zVEP*4RSUcd-Dfy{QeZM#%fF|#?#E$gX_ehlhnbX=4J8lqs4bW}qvP*-pMbAkd?Au& zmb|#ku{W)^PIz+TAt(0p!kZR21WSemx3XFG|C8rBY$vll>XY{QoIsl;)8Bt-^IlPv za<#|EH0MG?XQogCPt)~(Ns|mWH9d?-wH27KXe!s-!hi+E1+BIQyO$kHELT{Q$T-vG zuRzl>x2Aw2lDw6Etf4$YhG!I)TO2J;(Yz*k>vbEa?BO-1I#q-X{|35uXP@lq>YkI; z^ZJFv;tI|^0rNY8J|x|Yl+fipba}oH<3z7^2N6-P;EprF?S8XY99f~Bl`L|Z(f+lQ z&5FAwSu9;ukLUODiHU2^4dmq9rJ$+EI@v-cezV^~rvm4zAno3U$5S&*LH(`s@K^3pAS_N5ylP@*}9A{!&&9b>iXX&QnK|yPbrQ!WA03L?`+zVZ7wmaf+jXV`5jT#h1h3+c;+`FXLL0 zuabCFIp^BD5U!c(vz&K{tmK+8K~jaqY{GTUJ)3GPl~nn^uyr_ISn%#+2uty^ZpUp~ zcn(Kx(fhNGE9-3V7Jj%_TQFS)LnQW_aP)v2~S@aM;?N zRnooJ)@5B??HsO_v2~$SEfe!)mZe^u%eq}o+FE`&obH~rccJQh4}Kr5ufj`;mR;za z@ynO@e#9b;6%#|feR?nQ$*QeNn3a2Cf?ws9{|rB;Fi9L0W^{2d>3izT$ne!|#g}&y zZR!$>gs=ZQXIhfB>Ke~enPYkzT3Hq?Y-wE;*U%z+bHO4mv0d&ju2?s{yEsE(Wj1F; z`_=ypPYRYAo?+GSIebB|uzd5%Ha)#>r3Ocj-aEHUV#n^AR}PDdS6j_Udh|X$m?tLo z&8x)j?!9w;3`doYhQLq`0qf99x~YPe-fNs^1@2kYUL|8&67JPt5x~G_7rsva+_$NP zu0QWxTBot!`c~*s@#ocEI~M(UpBXL^KliQHqwddpm&OFvm2X+yHL7$p1cqn`uuh3; zFVK=XE?`;GZ<4eysA}RlBMrX7b~fgnAxFp ze*ue9R^E7t>4! zjzpn(aVwT}&5&gDS*pS5?vS_cMt~=C=klzmduMOiKCmj{l-%SXTGHUW{n}D1Gfr`P z31%Ci&WIx#GhR;cJ*wGmad1LN_YKavOGQ(7`Sxs%>T1@MrH>`<3Ot=SdjfyPtQNs3rwm9!SGw|jvHG}m@56ojr%hQ@%;+U7ct+sEfs-qY{QGiV&9Iy3^ei^#vgy2pmiR?o zS0^Pazj%C<$G)QDKf{tlXU-{xEyW#&|M{ebcPjokypLDn*nSE2A6*%X^gK2c^7$NQ zVv=%jD#-c5sCr?GLC&lNn^yY-du$e(zD#nisI;|6bfk!|%-7`>X)|Lt=Bc0K&@ley z^i+vg*22#roFQs<$MT$w!k^m$cHWva!Q;@Y1&a@^3={Ma{LdgDW5pL}nAEAY(5pA| zjC!2LMW*dFrrQ0p*C?95SQg6Ftr@P<-pXL~pMhWcp2H``+CRpe7ybADc(2(fv_kvI zT!SwCqw}lx|9Qv%Z?68H{|qydAHOI+B))`o)B3nNUwU)G<#dNCqt+PSdii|IUfr?O1(Olf(sa#_I3RpLgw za@3hPm#CTqvaa=1|2MPa(6e<9o!LFxKRur_>+r6;RJTuEvM)*>XICGtt=IJoR$3XN zF0o5l$L4cq6VKP;o$R7Z7L+tNTzZ%KcZ0!8192OLhi<2$)NC}pE%MBli093nnbm1? zndRZ(sj+gqmd*I$Br>^0crzoja9~rx!&7}z)~#sylGgTqdRCy8+k<|GmWSr2mpnK< z#cAV?o11y0rj{farUhuUtZGbD_g3n>q`1WSW%I6(w;heW-%>B`=sNBg5HM+tl$t>C zOjn-b!X9G@c5Tao-c*J*y`=dr3r(-P99q3xgDaJnWy)Dj%TU&}LZ*SOn(pjAM;?gq z&baEmOkXQ@-Dhsg^DfaV%XXiC^E&MB!`(|~&2Uxrs!A;9vb2!&+RQL5VcN^NOg&tU zr%X@SFjpXNiYa$P)uiC0HWva{K zB|O6I`e7vu!5yKztPE}E8aHW&>ff59!gfV|`kp7(WG@%1=<1$Kw)Xsx$`!WNw9e*7 z-2HDlLKaK37%N!MDDO!1D>>mTm8vdcT8v!y{yVz(#JTFH&!*&SEWFnqXSRKh`Sriu z?i1&HKYdo?UZC?{zCF4575VFbi}fp8zMnqJBxHrPZ?NA=cbkRDP2DRBE+ljOXV{pz zLows&1j*}`4bHQSd{*uCG;TSiJWpIh@YGVh28)LLB}Q{LyR?Tltte2CT5(mDS;LTL z#)+pw{cCKjGPG9snw&}a$|A8SnA1l{;Kf}AVV(vxWihX)$SbDxW|5E1JYo}2ToP-! zr9wfo-O;5!_W+|TqwS#y#~)2`zaf2CVWFVXGERX%v8UQr#;v$=(Vj7_+S+SzaD1aL ztC;2)hq(?jei$Is=A4CwiVdb6`6Vyz7;y;-=_r0nzhM21V`BEvg1I3r2j4!s zf)sEywAhAk)jlzWhGBmO8ny1 zD7*4jbVbfz1;xvfI-L``be=5nIm&pEPf*MIiqe9GRXhTJ8#ZqK?RE8U^r|&8F1(%0 z+*&l>veZ>;k@p_IMGdMe7}WSW*v@|P?5@;}`p?iA#&xC3R*Q2D8*^n*%dPPG()CY6 z*J>z<&Wn9rS(?ta=%U9}u?i>o1&VuR^X|OnI;!ZA$#Y27P|Nq!F72o#%9`&od#25F zS-IxLl89T}8p^`mzZV``x%EJxi@{u_T^j4+l>!%}?sV5$7^mVi=Rlxc!Mqvur}(TN zvn)QrG0|O*QR+{?w2Fe0F5*fF3e|p!&C3KAPxic(x>qDzqp@XD(+ZdUN4#X68NKIQ z{8kjb#I~i;)#=P5)8d!+C6+0+EMsKkYrJO?@*<#1W4esURqh$F0grY!F@|XFP}cO* zShe;-3a(1`mZ~I)J2e~pefW8^L4efOiChlnj4l~GJvdi8qkzF=>Fh$IC@rVT z!x~G^{*6$Pap_{^$U1dbS!YqhB-Kv~4=J>ADy`rW37sLKXx6)?t8|`7Q^KQ$jMkfX zuPgd3h@5YsA+_M`>?cc3LxMEihl#~_x6+!Zc@s(;0#B?pGOq|;#dvhujEpnqrW&e; za0wgqyXf8A7%05x4WHtZQ_@Sg1CoL@Sag>hIm~yHiDP2QRMsG~%!3-Am{dbAH@NU0 zARaWfo+F;N5adV854atwcfPG)Xa1+W!h<_C(5$w%c(*Sv%KV`ktc(H8=MXI zG0xEIsgn?B;hb@pPkOsj=*1GQ^r?~QU54IPj<#+OdaS(;-dfq#G`ot$u)&2(iE;VW zz02wa_wQ=6vum$Wn($Qg_ea+&QU*utTKR(?ixioM@XS2?R%DI+hI|K>V@y-jlrJ(d zF)Lo)lXFDGAx39~%9N+J1+V&dn`<4|pb^Z|=_6_db2TY--YXKAnsGK(BE znqieFqO~FB#4gRI6;Wbq%<{^;rz&MiNEx&?M63W2T%w3z=mCBJiNx30sb7#+}a?)fG^jM+ia@6Wqu*4GafG&ZP+k8XL z1Wat%-2Pf`?c7}sbGi?5dYc3te$@D%;U)*K!QWMH*Swv&KH9DJm8;Pdu2u!k7Lf~r z?hVU5m19`;=*`Hx8?#4kg{fDfqF?W@ zEL=sM-4P7MZ9CZ4EPtfRe@`=AW8F$dO^zwX^W#1)I-c*Ou_#%YX~N25lNY=CzgJ$u zyhPD^bCv1tan;FNWSA>j#I?u88*p*}@t z{8Z-z8*j>vVBpnxAS)Pf>VgWBh>yauN>`a1tL<2(X`EmxP3-EDeH)PC60GkV^N{yy zEPMA{k+)3A5u0AL209*f>y-zV5K#Ml345cpck(6M52$u%F(CEk_} zmLaj5#WkiXiQJL7)ZV?r^I;dyWxG1gRel9uT|h^rLOjr6Gt^>Fibit zG9x7Cq;j32=RApJ3k<55oPE56&Wbp2%FSteFymwg%cCyGkohx?D@<9!xJH?aLo&D{ zLs0ezU-Tq?Ui}RU(=?StHw$XAf1JAhx8t$+hhGjy?d}Z<&{)1e#pKwUrT_&Ma}E~U zjXo+1=Y?zXsIHJapww^iWNFt#u0^dTuQ>b?*~(r-YcX7C@o{cZ@N^Y2U9a(F#k`D* zB3@do4_u8IBn2m&_F*n<^|n?Hm-(71lCgKsROP0ozGlY51}qke`CWo`x|J+8NsASA zi$$`t?k*Av`ZH@PZ?HXAd z>ORj@0n?N|RrwH|x8?F(D`uEF>BpGshD;UETM?QnZkp<+CLg2n^%U!>S;v@OwT1;2 zhlM;!{Cen%xd5$D2$4 zI)1KtAY!4J!6x;2&V%PhZNf5XiI)ts7N}^3OB+gF?GDvhv$}WA^YD`XyJ~Zt))nXo zhH@-j=%-L`#j&b$WrlNyd<@5#G^=vIw}(1!&P|>%t0hsqPr7AN$Wh*aRj<}%otoog zx8oGwE5>~G2~`iC8?_71NlUz>{NU{rmR7CDX_l`Jd24iZEGg9aa!NGPwb#^7O;+;J zO>b$f$LY(~sC+yG^_qy)vNa2j9J^+{+Di2Hl9{K3{SBnAa_k7*xolEXe2T`)((X`K zpM*J23}!#qR1fHu;O5d>wSZw!o5>bGp@L1qn?jDvTi6&G7Xn2~ZZgBylb!+Mwy{EU z)K+!O+LSKTze=>`Y0=fdWX@72t(mM{D@~>(mNKUHEfC?3NLOguR}$3d_22>HVjJ$I zXI5xw&DxP+^59DM=4mU|WjYrs#w<&-bL!l7gGv-KM=r%HAYV=|@{Lc{fujfC5={w%D6BMt+w0NWx%G`YDU>IuXdyH|ye};{! zhTT@n1m@-3ZgqH;SK8bWIN`GwQbvOJHYyt97pVy_tX-0rWp%NXkMFj}F~P^jyx;b2 zJmv)=aI)}Apvj_fahi-%0-FKED#oT4EQTP!Mu7>07?_0+Lt~+FaVo|yfnOGz9bg>@ z0-F+q7+4*GO9YDuC169r1ho|tYaUn~n1DOu;6?2y&6J}heaRB+4f%gp$@{#r8SKmW8l`=8<4e}>v0g}e`hpP2rAQ8&YG z{_VOv&GP>Yx8?Kx>QYh{>tO`=b!wR|DR#|@Arp0 za({B9vbku?s0}QQzp%26Z|3F46TIK@UwVG|cmCOlxzd{eoz1D_*CY*^U-k5ug zRvw;ilKZMYPvHMm?<4Q`r$oH|N!{-ck7n2T+Hd<~(jwhs{rTheXD{GRxBtCh@lIF1 zFS_2AzK2>E{9Fwh3X*kpU75ylT5qdrlhNt5o(VmhUI#@ej|z?k$Pi8pd@tQgyq*Pc z8_iKwcs8Xh%aQZvh1bWfz3;#Nx7)>8c_-)3)MvdH_gE+ePfeWi&?$0NQRQO&Z`R9X zw~MX5$+hx6wKi5XN zoG@4T)g`c)-DA?miw5E`YBR6ex|~uoThQIS+A!$ARVTr$-TjArd^I9xOl=NeN!i(x zAe+plG2u`!ON&Vh6IUn01IJZ~tC-g$WIRt*O?3FwpfTe>0MFxD0a3O$S9>xSlpGWY zb!9bDOSH)3bUGWJ|6^rmZS|H~xvn#>wT}n5aL$uh#K$ov zCSlHIr!#lV8AOAG8jkr@25r6bsgaR!!po+M@6LEVXne{WrSUL<+nAH#lFcr~r*f;f zR;YMQlUo3njRHp%17B~bpo`7HmdG73TPJ%Q)7v4^7pil{k@>|GR=GFn ze=R>dw6r-+Sry-(+9ss!hKIk`+or%BU9IPXEL$0kMv9&LA}D5DMRUs)Aol6oA?xmScTC2)4U zGYC8SkfnG^g+yy_#ptho%%|?J6q%S>HN;S-`5Skj1yuO#QV!U*(8c?jf@M=+&qq znVSn87?!m#U;fWpLc2Ne<&+_kMaaOfo8D`YPdAwU9%P@>GuO+@%$-3Fb=z85SXp7;p zoXc`ECS}-YhF0yo&d8^c)*7t1|42e0&t=AcijTL8e`ULHRA`ptd>7+@?@x9(9|}-^ z-R0|i|C(;%YFpv9y{Ldip z^wiFhrOX{7%NV9Sn#38T?S1afqCb(}cmHQN_bB7$MxS@G8ne5%;bsuV%2x|LhG`+@dqsRUY6`FT1+2yKn)J=UfJRu|1M*`A<&ihql zJ$v)&*4kXltfu z{@7u#dE2AT$*v1M)IPPOS$C}9)Ny6mx~k_X-*j<5zKuFi-^qwY%g%)6`W5X8#pFrlGG z@^hj_L*UEybp=xuyjKPk+!19ebSro_VXwJTn5u$Envg_dw)^ISO$S33$uP)GY+6yY zii_dHfpiNg$6bpvHY?4HExs(?|C+Htft_n>K)d$67}=<;*(G@!i%Uy_`^6piPAF8$ zd#!p%(ckFsar5m=Yoj*5n3Ed+>b;ocL950?OTR!$P+9i}`JY{^>Zr~WKv{OXE=cvp`)T)L*-cG}0&%~nwt4lo!^%S&`GLd=2Q*itEZzGly*UQoI9`q;JaZNL6A zY|Qv%Jnv`aQl$FrKSR>RC+W7IKD)7NiA4`J=@i?fqvD1I*5TB0d(C}9H6@v57POQj?ay_Pg!zIsNu zdoRL4NQOaNh$Mh)I+7nCDv+#0@+Gns3>8QM5Er`T=4I*KEuObzOUw4Pm$ntnax!A( zS@1UF$+;xSN0T*9R6xTE$-PJch#X$nRbT`$L;xv*!0u2&5+{5yvPn3Uu_*FdZPT%J}v9I|8ocHtFl-5_|ueZp4obmneYmw=i`uFawKhbOb z?9V*NfYlDj{I$Zui7{OQYDs+=0f$QciZcz)lpioM;IWK&CoU?&DN$v4dg-}`*{)1( z6WG3p+nx!SezWtn|MhboJ5O8h)nJ@r*$*w)DWmq;GZzin>3H0M-O!>P(Y z+-b*(k{NvhRz=9YZ6HS#1h0HBJ|gwfiDRGL0OZ2*u9tEY?=+)be ztF4YPu8X?4N7&7L1J~+h!Tl4g5}ceA_vT$W=jf=>5IJqb`9p#8pY1)kT@YLlB9~tz5^EEfdETPvBFoDZK~1|e2ZT8fw94_f&b@8Wv-;Wv zgH;JvUrx3N*W_Is;e1K4FmIP;`n)Krh8J$x%oO~r}E4KMOi%0K$jjk0lt9CXl zec71iuA!xLB3fplQQwZ7E{+Z2-ODqLLlqMbEUDt!q_QH=UaZRe%YF_1g58%s3I6?2 zYaX$n_hx$bw4IzYxYr~EXY7^Q-Ev{GWtI9OA>UOYkuj@!i?25Dj z857;6Zf<5$&p$k&x9TwO#66ZuaRtZkem_0;w#lnoVdpIhrZ%?;28C`pxZ=rq z??~~csZk%oGW7~wB4mYrYMql_a96A?uq2l+bX8_+l*yWhI|V0p%`Gr_Xfk_ig1h$8 ztWx%-$Y<>^rjt<%+1I6P~q-B{&$V=BQ1}_Iy4^;lxT~-^84B zEzNnc41yk;wKCKmbk5Ye*)!t{k4i`%1IwawDaTI>HeWn?>cEvnrtJ1_dHK#{3b>km zWoa_?w>5zDU@_Bt>D0M1{xi6wbNPQ`Xn)J$wxl4$RhZ+%$sS|Jb-vc^EOVVp_&>54 zmv^a5(tYEjHs$$6)*Z_|L{})zS@y^C`J9MlkN-HV6xB_7^>8dixb0(w5REL+1ODzv&ZuRO`o*KBW>eA(9@+yt97QAGNyv5V9VPoXQ zuY!SlHy%+d_IC-=^}2Z=@}K0PjpZ6ggbN-V4cXeXpeJyJ+U{1ag=q{kI$Y(ti?xT9uZVCS7~k&g#o0b?Bz?%DCi^@-HY^dah~YnzOEqhUOg0WCat#z8FusHPyM~ z>P5~4d>Sq%GlHb{vu*dWUU93>cY)0EpsmHBFS>LY91|j5FILp+`uU*cHpSf+tE*GQJRSA|$ zMY4`~oZcSj)v&;We?`LC7=bp!`_opiWoj<3x;^n;dH3GLQyk?FpZBeQ&-?YxVjtEe zg2EgO{vHRfsQFcho;Hk25WdV-F)3uDk2<%p3acPr$IkUpl@8nDO4o=yJ?Rt}z;f;L z<$0_ro4O}Q2|4vFx6ly%D>X@BA=f771&5c(%`}NnI*&?rOC=I*VZu2jpS&n$h&HyDSdLz(sVv)QyZ<_t z+`8x5qOQrMrzR~m=zL~0$5u$JQz^V@Re@cd$V21limSX1MqPIKd^j~nX4!H_U-6K( ziR{y-tZ})zD6>zr0@Yb!wI-_XF`VcV$az@p<)+ZY&_kguO{@$ifh}57iZ{s|2$0%&jHNZvUC&v| zS!+uJ!(xlY)3o?62mI{jn;m;LEA&!;cG{|(4^KChG(9bv^>RuZ!}^HgX?$;)FK5ni zVO1({NHmh^e>=U#(XU2e{W5__#j3BuD;PqTE0;KjaGYdUSoD(foX-Ka4^u!ZR&tMk zJ1-YIPtAVfS2)q3xW_`kc&ZPxfpACLkC{_6em$BL(6!IhM?o_%) z=K5u4zKSMo^>{9Uj>B{r);Yw4E z%@?iGn8jA4^S;pX=_^^yS{-@1bkm9@$?JY_OjD;msk3nbAnS&Z`gL2TQh5Af54LYlVasWSv{f*FC`_cOVnwO`#Q7B zeZ`E!N{yR)tPEst9n(0&@<_ps*HAM=W6sv@OHvOn-!3^ZDU-=#vST{$JtoepVkb4{ zdmb{HStfQPG5FT14|TRR z&2XNhE^uyjbbvKTX?(BHJ}q{w|&sKZSiq?8{1ye}C-eJjG}SoRj)-%l5;sqpGL8 zzi(gtN!jn`es%Cth&*MC6{0nP6AzT~=)CGJSg~S}_ci&L@I|%U{XH;pFA;2H^&qy& zREbU68X?mrIU3G5TBUwr$B&~`+F%ls4G}>mv8z1%ou%YOr~-$WgXH(MG266_%?n(4 zn5ynbtate(E-$g(wF*Q+*q9`;ECyS1#bODjqc#6_6nGhKdgk#`Pbt<}^u>Iq$B$v; zdLq~m^$3+*R%u~g3+|dagiM#Ny6ND0DofhY*XBa$i>)GtXSzNcoGHc~D9Ay895fI% zlI&E;l07$d!?s5H+hB_@P-MWwA#8lnDVU|xu6e5Kv&I?Y!&@86v$P^l9KO8Q^Ri*m zEI#3oERKFQ?TdD;$B$tn6&)q1HdLEIs$e3x*bqB$ z$>I_R>Ee3T@6vT+$p(ohLeKADE}}M0e#HN~@%*GeN##wNaY50y&+mWX_1Q(Rp< zXRH!U&aHekVNr4xUvASF;hW~u`Nlu&F872Pf~}-E%tciof>=2_IKF7V}BxIuPJ=P-b2pd z^B0>$yC1Har?RV^U;eb*$!lL@bMm>`hIg}PB8}6=r5!&a|DE|0m;98@H}XsPB`SZ( z8O*Oyn)1|i@4_#C=G{0*H|xm=5tj`Unt;DP+vvNfpRVx6Ny8LXKw9K@{Lu?Giqhf ze9junlhydfDBb& z)^h4=G+?p!=&%hnV(F5yS2tqe(pi1FP0M?xr9iUGp{-g=jm%nuJ^b{~TJ5w6 ztjoN5@=^C^zjgj1@v};cDi-~@yn6Cc@n>^m=4tFVdG+L@>(Axk>Rkh_*r{Z8iGQpa zi{{qGMgIn;6LEwTtHK2q7L7+f_E*`L%+@NjydiK*R!MCL1UU}>;7cYl`;^W$irj8; zWez)oB|pc1Glk@F6)vu&EX(5;1yo%<8{cd5`L9q)pOeTjjYj{Yvh@r3xFgln*QP)5 zM=lHEFY52KZ7-;IvH@>TLEd%pOZB_@hIjlg+%dNvx$JxLP4?hl4oDd>ODjZn*~%YF zWj?j`ap^5Q?AW9HOZ9Y<*40(1b5l+!H9(s`}M96b}u&Xc-0R>2EQh5`RF z_sN`aaQ5)hl3H4|I6S_UNy1Bwsbh*s|MPW$N;$JM0v>8GN29p@^zFToD}6XQ(ys~p z{PjZN#u1^jPG@c|+@-c8b_UCXV5zh@3%*o&PB@U>d0Rw1NHC*uqE^>#ticw*tRoNtDykV{WM=uPhE?pdN~i}Le!`P*E`vSgA7Hh#pk@S@IzlN0t@o?vIX z%6ag`qZ@NxSZXbrFR?)2>ePqEt4bMFI+q;gi@32U^iqhZhg&dTN2YuC7M>LvtgDR9 za~nG}I4U2KVY%u%+kGA1)qP%y`6^7;|9I_644Tr#=~4BRU%RN^?d~U+7juqj25L_` zxl7&gNHgz5))!XG0)#c>k`mi}asstZuHssfbG1o0!F#7z!2==Yk50kO3uekPg*SC2 z|2@z;YrB*w(>;#>M)@tPk2MQ0CMxdUwL#+-!#LqO{PUxfeM?{;anW zOA6X9dLd@DTAaB83k8WP6RJ`vP#4)%D6@8>OS9??UOh-+WFRt ziTiYFhMGDCTw`Q)2wog65-afXX98>6+P=5hM%6Cvi+J6poSbv^&eUY#o=9gm@g;ss zS9-8qwkSBTa@!uI9a^3zQl#uHAyq?2K4KHoXRhF*k=6VExN<-K&+vzh_qh2sEy2Yq ze6xerlrkk1avs%8Y!Ss$#H!4o3;LvoHVvTl~TKvHwamT5q()<4z zjy;%iDnRp!#w@S&DIA<`PC~M$)D~&%C|UBVA@nRqY>ZLC)vZgO#<#B%@_AL?eujm! zxAKJ2ilq)RBAlF89yA=%^Dp-l*J(}p(>Nt4cXG}XM$1N@s7rDkA#Fh|xdDr^mPwxC zV&ZDx@JJUq@%Kr6f-_6-F1-hV+br1}JT&{33O#x5(8;nnl+%wZSl8jIw`E7201KCv z$ka2=jOm&k&Q6^#+EX<+W^F8H;p7RNFvX#i$HeEM%O0+_8NU{FFtUdG6w91^HYbo% zW74Y?3td_?*|t1fuWa+JMe#)5@2CG6`p?A`W%Iq8}!s zbm5OR=JZ`u-Dy|UR4N?euxp_^w@v`7h%jS`pJ=3O4_m;aM20Dnr;bO688vCKH9M|U zZ&wYBNfk*t;`Tw!Ld4Zl{5IFVqFtpTAuhWXigRrTt-h9sJ$257HI(JjMTSkSE+-j} zow?<`qkHM?b0>qvsusHPtvvEdYGeEIqPx-DQjgOuueMAJWWMh4C^%z=Q@)H+)XG(= zoaX{|Po2}jwOrWSMcre!M}w1&sk4j5!mAc@ojH4Kl{t3D?(kIg324?xUZEwCmU!MG zGqw)2ToWg0ws7Ps zu44o-nR%Vl1^zsRl(q`4A~CJ-#kB_VJfgG0Q06ZSKZnUb1VqS)6pZnim*6q4TAM1$n?|}=?85qTN53sv)`_Mf=aJ#S|OXi8BKoF`a|8)pPGT ztKVJqe9L;fvfUFRcbBX>b%I5G{oK7peAihSwpJ*}ycE@$t~6@dSfa1hWC-@z-Z=PH0Q{a!i9`N~7Y{T|u2^ zST#60zOZPVJ>|Nl#EG%`UL`DhMS9hfLm}M}&Neztz2^giu9~;4i0O`7 z5x#B8e}>$Pu1?FiIC%qVRywE^3;3}LYL@i94O|`NtXtjC)YLhR>F-Q8C!2#t!pdu^ z7DkBh{9|Y_YG)ErOFSd6D9zJR@sJy@;+biROdUA|8ezh-l6M7l_OLR{=<6tG*cK6F z*x)0T%BiyG_==gJWoWxoXLB$eS#2x5C{SO5#di1C*|H*&16)|T*b@2KJtnm54%J|} z$S7Hn(h;~QMs7tIb4xqqC=~A5VxJYB} zrbClni#m9v^L03^oEg-#e1+e{mYbVOlxCNPvZ~JTk!&-ba7b2hmgag{{Q?{FzwH11 zGyKV09_VGdIr%?B#U#eBhl@Hj+K-qeGEO+8xpAY$nnjBi{8Zr;_&8JgrcArA(y~Tb z1J=|@KB8AQZoRDYxwTOs{Ef?@d7Cqv7xj6Ey1ibd!pw3ix8Ur`SizjfSx(n9vfF&U z9*A1C2`!tMrX+B2qNu|;af^vUJ)7AITW>Bd-LuW|$Wlp(<(K3H?VRK9wY_HZwDL?| zwP1qqOAc$ZSEg2HMJ#T9?5apO(RX6Qj7MH`4IY{?bllKcutKgNvoYo3M&Z4y3IZNl zil6LWdugh}*3}|DOuk6ov+CSos)tX-G4Q8uBfzwv)@1I}sod zTGBOX)eIgZK^wg*D}&4v46d$WvPr?Vzin!IdGYN#&c@a3JeOvi)9iY1aqWUL zKc6Y`PBNY)=`!h|2G_OKYsd`?u$V``68^tgKDfr7w=f4!njDPC?Us1E#2Y>~xsQQ84BCB3^H6LzhJb6aF(S zlPn5;|IfPPDbp3#w55@@$*VM3E_;4z@$Qi0taHh{Z}wJTf#45@p9!k1!6MVFlArV} z>UgyKdqLx>H8a~69ORSKS!xjKus_vk)}Ej_OZIR7r!*;R^HkwTp>Wk1=^Tp=UEO@d z`?#Soi^9#YFW#qvt_rhtEZ!6NpJ7+S8H2as%Z0Sgw{;#~z>vy+^lB0FKGT?koVBeS z8{55(t&-!L@}EKb9rweDkfj0Eh;v`g-1yP_??K*_X9{PQ3H7f?c&)Wu)>-sG-A(Vh)ZuhZHXgtHArG8wSR+UeK*I!=p=L;VX$5*VY=& z+p#^$No@Iqmk;x}TU)leCU=x?cR0o%z$nQ#;SRfYV^-5C@4XMMKMC9WIj^D9g~g;r zZ|9uV8I@tOMh%ADM-5uO@;%+O)nQxvDp}!1L4z|cF0}<*D*`5l7_cpn(Cl&cStiuC zp)u6iFH0tUxea3vg94ktyt#*TR@J!j#3vl=N$xq5DERfzI=_v=ic3QcCNNZ31^-#U zL%$6&G%4T_9@EKi!pCTihPSDoze(zui#gg#n;H@rJk;#=m6u6ReYPQVeNfDkwJm4P zJ=);0Y_(ol*&(;i9czRvI=tuZ$kBcCEJ5nY`iEV2d@j0+Ho2g!VuMC-gHMW}Cxf|? zE*IlL-DJLvZHF2|{w-K$sQTpa6X}VIxEBPAU5+@g=;9#Lhc321UP&* zr7ZS)nrGOyDv`^~XN#8~1KWjT6OP`snOHIXR#a%0VV9n;Rrk5jRnu>CZ0A`N_9|n2 zNJ7Bko;OifL)zy?U4Gf%yE&X&<;61Fz_VduZhej{Q$?ms+GP*S(8qCWdm?KHQ# zD&3LZW9WUvoMZR>U;KanGyJ&gs2E$MA{TH<(|Ag3R|*4@>H!WVEsa1!j%0?+2~KOp z9Tubt89J06Ff7UCz4F6BtuW{ehv}Elm)(gzxUOHyuw;`Yc#^RDoxa&$OaDLYk-8O!@*Pimn_f+mt!s zP!RtC#myq1V>=f|26yn)^?WQ{e61kuiKtMxgTIAS=S)?fnLCb3I=h|h4xO`3hAB-W zTf!lTwIN(%rmZ8t^foSAa}I%{YckViZ~1P@o$9l);e@B{L5HBErK>|3J)c>$C2%OY zhm~@uO)H!tySQcY7Atkl(hld8U|XrT&SL2*tNK>3gov~{D6V3ks&mneF??G_!5^nH zi&rE#IV;|cpD0y+c2{?4xTMO=-Z#w)HaQwEOKN#KNAZwJakSB@S(`1>je4w-!roof zklN(+qV?k9z|wL{QO2`}7@Gr^oho;lZ8q!P3NvqCN0;D`)LEAbjMf-kWVmVa;$Xpw zf^$Yb3cb?<7(6DPVP4>%7!u-eHB@0rZ9$k`pVw1|NAW2kwoB&*uDA4J>|V$y&1#%^ zmLbq^RRc%gionG!-jObTL9eHn7DWhn7nT0F5} zSGvJ`v&8mXLAE6&H+<6)r}Qr4xw}~8WTocRh=*1SjHXy_*gCuFDvOEq;xg1C(j23R zT=-bC{E%jW%#tO4gYHzGc(cF4Ym(22sp7c@1N0KieKc;0#BPpclved&R(#Rr!>%P` z<>D0Zpm@);2`=`}r0c^ryEh)=oao=8F>(5ma}!HuI&v%(J{DK zRmoz-6MBNqP4hhE>hmaoiQ!pmgVAH-Q%*$Iuc%e z_tsdl>a3-R!p%P8>%14mxhE?9bQW23Af2JBzO5saL$t4#Y3j_DMe0)y$<8?)@Yb*9 z`5Hxw0KKb>XBAdxFdp%-*zko}cbjZ!!n2FY zaohZxp>JNdRzvgf#u zSyjmG?$S$Jy^48$DIR{L;d0Seszc=RYFmS^7xolphOO{&5N=pf!Rv6+=LFx(m#yKV zvNMZ5xcElc27J9>9U{7UMW)BiFNfR9-cE2^vZmSR%i#c@m`OLT2G2Prw_~rB)m~Y- zv+tq3pJj!fPAg(nC66kUgcjy>ZkAC~T#+bVrYr8WAE3G#KBkR7s|kq#HAEQSsL847rL!hBa{>qfs_8fx^P;e0c=VY(}rlaEbrg5*)9ReqvM zFZ(&3KAawK$nVyTsiwEWgnl|0wdXsQbaa7NRV1EONm`x}E);!x{#=fJ5y@t0bKZdyL!O&7R8Oc{tmXFPY(a zd%xpBVNa7cm(-fxt@hj==d@bu5u=`s&gSA(&VpNKI`>4~$>4I`7+|Du`pp?*fh{fb zmNl)GG*JoV3|pHkaPjJm)vR9ET30W3QF?W9(Tk9|Yc`*E5q$r}Eo)Wj=_fotHXJOu zH8HiN?%LWQ@e1%t*3wz4?()o>)!m|g@q(e}?046m7$rpY}2m(^<_drO4)TfXYxjf~-YXG!W!F1cf|M(ps6 z);q?#&T!mXE^>LTh4rd4LAl`{&L~F}WVy0tty=AMx_xT2iM~hlUeBX*R(YFzIk0=K zV%5s0VXG~46_SG3OM*UbnP$|bDeyh)TDnEL$#V_mU0W(|U3n~3x|~-o_Qa}m)kRrs zMdw7yGY+aX=bzhZ&WbB_X{)d@Zsn}Dy!#|CJZquW*XR4K+5^&xIoq!p1WU9r zi@5N}Dl#c03JT8XH_)D_ZMxN4zt2hY!?askF)91a?+<#Y0z#&9vFZqM0?TxtF- zUQ7xO(^t>EqUW+cL_6B6e!Hbl>xt8HE5f$${CS%sqEU8im1xVe8J~A~w5r}(>7drZ zar&@|Q?I~-t4Nxf#rQjJ}RC9+NCV>7Sy#dw_a+0>UYzR6!3y4 zG@fAmelEXSr*DVJ3j{=luSK8tB>QfUd@#Px5(mWi<^v2 z&o`A3oEGzQ#RD9Q>pj-oygDn@Z*73gQyfZ2G7q9Ss&u7}AwFv%%J8^Rkt_00_cBMV zAj73c+e;R`*d1sjs0FjFM=4!loP^xu?DY6+hCQ_CKIRkU|EcoLYwTiTGyZM(iA7igyD(UF33fwp2;;Nz5buoU zmLuy|OKnS7$#>hxc&n@XRvxu;QL~ir2J2FMkuZg7apSw1A*6}_nQ61>9J{2rLcim4ihAv71p8ZK|1;#& z?*8=AcfZ=iZ~2*^eMB((ia_@D_o&~4+czJ}2_|pxmKZNKV;=pncj-@uYH$fB_rp~g zT*4tO1N$q6?ZlHUi&{co%xD9xCgv--;jk`9P?AAH@ncu{X^F)~otrs|TiU$Ulo#*_ zddyiB#5JdlS;XC=+TIo7;Qen-e))I%bD{US=)l!hUKb5jwxmm(=-aydW8)d4=bC|f zVjimmJVWm7u;qJdB=Jn-38Ufpa{`N|7j%XyOj_~lK}NW$#tc_W_Vr&PA?7?_-OVE4 zaQb#wz|CxxAsSBFW;)~_`Q6~e+EHf z)wx?as$cWCzA?aNzm;Zr7_XLi-?EI~tKy%LY7odmg|#>Ix(>DOO*t9ex<~AdfTOT} zZlu$-2ggZqYnS4wvQ=g(uL>uxU8dRYsyn-IZZOZ4X){UkaLetB@3@xs?oFEDd+1*5 zjU^XZ#kZ|l;onSBSZOTx6uo$RdDMxUC!>9W`5H~S+Lt``K1))dbC@rVnd32cZ`LBq z{|pY06ta(L;>R?*=QA2kqzSydcz1QoucZ$)?jNdrS3afxfzkIkPF0?+r2)IVjN7>- z4?PdL<@;d8I^+H#>*IIk9o}Kb(Jpc>c)ADAAz2IKrDs;|sxqz6l0VM-?(8oXNGAKh zJvVuxK!}B^PUq~!^A$=vLUWo8rn#TJxF_PZL?_e28QcfXUOXE;H;MC&vu8@q<+FQ2 zUQ3F2EhylUPoF(&o7KxzN_W&Mv*zVrPzRrcqu;QkCbEC$-Ly0BLxcnzRD=a3LJeJ$ zm*uKT=52BM^rYk3>gZ)6x~`yM+g&dO@^&l>Wq2SLkRP?xdTW-5gRzfGm4e1oo-^mX zl4dNlxo~sgor%FB4myXDZJ+yS9TiAR=h(4@sb|Ng`xp1Oe7^HuQ~p5eyY1dCb5068 z+U2@Vm_efGVptbr2*ddW&db=^Ej;u&8C%pC3?A<44c#1Kd@w+B=Gb8Iuh7EGAv^)x_Da*rzig&bj8fSB3N)?r8>**?TZ2nO1MhZ69Z z9uD=cF6V!*tN+hnaJC@dWkGRau1bE&!`;;@U$Xp}HCcbNP2^;ys8FtNm)8DvoUG=e zG?i)Zaw)r0{VAPCg;wm^`7E!aWTyG zo@t(f@+wDV9`XwKip<#&(Xe#FRi?fj(&}%-f{v_QGp&x_BM&*+!!xVXPB4a?RQeff z=qHXiJn!8sq0><70qBu z<-PdZ{=?6#{|tqGB2xVkz51e^K@OH}q8a5S$p)GoqTWw=g4lh{JWRA27M@(hQw z-^ZjD{?~t7Bo_DOtP3ugaoD0vsA2>AJnK{1J&*77eFQBT;CqnCQKYbIx^R7=fKYYf zL6AVr5AeMP1m4AZn%T1Y6xMPi8wDSohZN00uat8M?;g=E-N3E6_ zatAD4uxruek7;)AG_EzIy|NH#3}!gdA}jE8?P^hxNR~^XHV;%@a&W8h2nHSL(pgtI zdx6pfPsN<(EgdXd&Lp+%XLSDYaPQ6isKrNVmD9T}j?Ez|K_6O}m&sUb?=sl7iuFPJ z#WjkllT8Jh916B*Zk{HwgKa&>ijP%^J0s1iEA~$DvI&^hk@iq2i2Ih5=jw>93%6dJ zD9(M+FL|1yLfeTYOOCp;dPN=f339l5=y-v9ir};)FS#|ddZk3#84bA)@di9SsdJ+% zjnT;E{40rwHFI~ehcBA2bM*vg8@Z6BI-<;P{xdvKnpjv-W?IiPN9f>mZzVyghnaDu z^%eOZN+y|ID-Vb&U3Q^9sGGhGq4mpWV$%V!)ddp~p0tZpW6 z)!D5J5>I(7Qq}T~>Yt*>bJgL@)T~X@O4~L_1zb&;v0r}6RuAtGA4YMrsjE|&ZvHkt ztU7T+?5g7{TrHM0OS1Wdd2~0eTK((`+gs@${`}PN zy>9X;pQhz~aMjwBy+d)bfdr!ghXP|CgW4+Q%d0qez1>&b`ZecV<~e7s2}=UJ zTp1lcHy18?Av^Qt4pRrgHNmf%V{ctNy&#-5XeV z89A|Q?fWCY9U14w3fxRGT;gz`+U2mp4CY0`Y`!rwZ&)(76>2gVA8HpBF+A?sR*jnK zx?C^%d=#Bv^Gd+DZO4*(<^@L@CLY=_Ys2&q-O{QqcH>FQRCM|RIhM#Q(-#P34r3D8 z@X)M(>K@UH!E29cHoIuCXf0zo8!?x89Z%YMDRbtXEK@wTy0DlWcAY4;YICGlL*h(- zuC0zLKIVt!{PXl?NRiv8+ zFdw}fDYjJMxv!=4;VIA5MGnO${3y$wC~~Lqc;DQBdp0rMDmRwMJW=UA8=>dIvvt9# zO-TXwtYS^0Onep}@>#_8Y)jG0?M3PVDWWX)uq4fRIcdSE?B4EcY;qIiBazGsA~3V2WAU(owe%D5>W=G9X?Ez5BXnZv`&^Yk}>*i{v}-U_)gi!@B9<* z$llTv2z()NC*;tML$A7C&vni;I6Qf|+s!3zOKeUXS|0W>5|Q0!5qm1YC?-B8R%~~i z*55f#EjBu=6VYvCJ1Zb7Ff%$RQzJ-$V_m=+(W=B%hwh%rYIk_v<;&psv0+beLG!_Y ze-`;==mEc1>so70N(+OFi~Wp(ujL5=0S>8)962wYp2DblNo#@Rl7#b2cg?;$VwmBv z>|y9t?n|88E7s0-ifU|cQ)b%K78Ao)9JbaX@4=VZp_d|5L)6&xGEy$YN~KA4Ot(DG zKX+=PmPX42|1PP&HRpQ&sLy*|>G6+QzjU{2y6dC5z{zK21%2z3z)@&9J4=7`;-5p z%e>*@r{+aVwxa~4a?7fhU*;+@-0_=Q(5W*`F+`(8#`vhq?Mr9-;p+67F=dyw zn2desNiR07M8)|m8>fj~N|`A9^J>zMt#4#H8dgX>3}sFAS-53Jno-+&k<6$CA`Z74 z4sr)|YfV_fs+1yY$|QZs+sy4m!;I_ah7}#BBNNk4>8%to6rO3~v2p74)nO;OcCB)- z3s|*kp_hXwcZdVCiNce&2OU)QUD)bYaP?KMHlxEb%`H{Z(I;>HXPmN`!t7m1fG zZW3r)#iG_UO{{Ej%VedgZp)fW4$c+P__WQ3Ma^`IZ1pdsy7+bK(alX-@hXWYF9#kx zH-SUouq6+x!Iq{?o)XRuor&(*T2Evo(h6iQ)<^xWdT=Y_;w-@>Vveby8bX`qWVYyg z>?oK!vxUpoh11zp*!84H2+L8uN*2A9(_>!E_H0KF@0imwI&Pg?A>IgTN2i_&%}H0( z=QnrzSge<%nr*cpjxDb*>(1#Z{)u+_+~#f{gY`5ParvELe)G9FY+1~zj_q@+_A*|C zH_Ju(yA8ivuep71&Q!g=iC>+T`ov0feAM<(=eynQdQ_Q%qal+cvZ>Qji9w-wj+#)< zl*lXll(U~54pS>B<&Igpa)}$i>l|UDPOVu7VollY8)KQ2LRwZezMht>*L&^Ego2I^ zJ+q|>GrYqK!;YMMY3Ws!wL)(1U!>Y^*2S1~xhLiQpsf%!%xc@W`gmPU2{dnA_^06B zoBIp=!3S-|GkW~kEML6awIH}+L1EXl4EK+d-?IeUEUA0pe4OFhG{<0;1Dx}C-M5z| zOD3CXDipP>%+=Uql8KRalFHtbj)944&Cl-U%$U~e>$XB|)#Br8mnc^5sB+?B zXl!#8YH*w&!o#`XBD0E2qD9MDk>(=vI#&OVOrC|GTO!u6dag<;)H&j!^dX=`n~7z@ ze})`qlb}`w(Tyi&dd6Dz26WloI+7DP(Q*DGIZd^PMM}>+I#)-ztz3}Q`FQ7dwmZp( z7Tudt;=x{zl!l*eK`QC4oSR%Ft9n#OHqf9UjH4>#dcx#GYMe8CLk>jbCiDvCEmE~? z^ts#}bjdHkBTy+h+G6?=rBAKDo|k$Z+v+R%uu0+Gj97h@CGu?`)))pb6BP1 zsFBfSx`4Y>hRZeJfvHz;M!W9Yh!v%iJWW04urx2@cCj>cIG8^FZ@^xe-6?DNgVwLO zFk!*H4&UMh&hxf3C9)UHKCTh&(b<|Q;eAQq*-d5+(I)pCDHZ8ks2w{<3fdBMVD_r^ zM_Qt8qCRKmNHi~LYdtvmtK)%Po6m@zP~;2G`!tEsvL}Q=>spIo+Qp(B;tLA&3K)1E z^W0sO&UbF*n}Z1xoRf_fA2~Jk#F|PEF4if0h4UVl1a8(m@$bmL09S<%UDh5idb9Pl zWAwuMr)+0;m@vC1m_;WfxPYOnsfcBVv&f>8>)!2hTNAZFxJB4Em_uWOiqIuRzIT1X zE{qSE&P=UXJWnuC^u?uZ;$rLy=k2HRP0bRCj9#?1Smf81qcRW8-tlH1v2E7FAMcR=&k&0%!Ipctge(+PS`~Tbamc+L6`NGjCY$hQhGa`z&{m(= zD0M$G+iKs6oUM0HW&K<%lw7kfu4&Ed$kR3t7hl=5=$x3;QcE`hu^yIp9FMNJs5mh= zyK_peS}P;t!7g>6)|qQt&LlPI8yfX0OF3q|mYDInRm>-&Wg>I!0=XoC2?D34h?oZX zTO3YSI`M6$(?!cd=4E_Yrz8#tF-o4UL~WWgK6(-wtwI_!42J1^2bx4<-^GQp|RaoU5 zHs5IRbmelUQj-t?cvq6WLr^4YM&n$U*V6Ni9lF(?_{q4uX8ZVv#Z~Q?Lb|1z&DAEB znA72WT1g4_tQWc*61U#6*V~A%bwDz`fFWwK#H#TsEq=m^Bnqf|Sl2 z^ftQ6xL*8NY)njHP3qO7#|I^Iiu6=fuE_>Ieo>p(?>WUGDRN@7L4Zf3-=28M(!#={ zAE$?l%g})-HZgCef656CHQmVT_e6h*mrROWpqVDbxR$um3ed zn(*~ECrZ9QlE(P&%x1Aig}w(0d>8!7k=e?pIzuW}k)yeE)=Xnh)6;X7f8HD2#atRW z)#b^oqi2dL@9qE3z#sqr2!oyg0}~@7J2MLtGb1A-BLf2iqacH#p<`g;#Sb493MUFU zG%hsQ7?5zV;34!{c18w9d&U0@Pqcn7)SDxI;P}5awwH9ytZ(?j`eWTR!-pJ=lLdtr z-FKU0azXatBTkOJ{a3%{n!5SlG5f{m_Rg`>XXRYwg0HiLwle5;n6o>r3vFuCy;Y#d z#@r||WrIcR!`%xNPAuk3ZcyyaP-xnH`ch(oz!MGyiO}*dV(l!74QzJ~&N#3?m4kD8 zZ~qZ39tJ@V@2r3YnLMX+wOKfKHWxOtoO&!#?GmZwLqCB6j@0+kb$gwzpqf{qr5m!fR zL++Q4?j{1=n@pLG%<#0BHuq5Cp56(o<5cw$eAum%C-kQ1hAzL*_E;d$!ajb*e+ISB zCPLF3R5BJy2P{Z_E*Nx>tKIZL z?MmMKW?LAmNN0x1L|v8L-%E5j_$0*J|6XX!u6|)q+OZ+3>(qfYPkk8;iY?A8;ckhs z&92P5v8dfLWVcUbc93YI$EL;-iv`!iizaXhxKuf0tHv<4Fow8=KVnhin7&C@h_$L^ zOZzJy6^UrR4MGX-IdLo}9M#i27#a(n`0z9NrWmxSI*M++CGwxa=gyo|4hM797aES! zk{_G=a$w+zND&Flc`;{o;>pa3dHTDKc;-tKwVl0q#7nACaLQ2;u3s@SQF|A>2)X0P ze^~ET(?LpkrCvhl>ECDapp+~1>(Wv zKuVD1KuS>MZo&0|u&F?*MK|7U3A6wd5W|0l-5EU8fK^vdbUtv+4R2mGH7ZF5eGJ-&&7c~Vou zZvmHv$y3Z4%H@_+TX8J-XY;L%<%Zzyo4H1_)AS$LNjDfXa!h4pVZ6b}{b0uLa`uYY zV2)zrT~4NV6q*|>TW37b(yi*WI2|y_=Td8Je;*afn?6BvM-BE+2 z52ce&*>f%FnEX}3AoO~NH_I{42G6*=ZC6{o%Q-5y1f&>FPVsjzIJ$_@;l`(hQ};9~ zYcVjrW@d44nX}$Q&(VQtp{V?RC-yA`6MPtUeAZY|)3{kQMf*R)exqJq50NadCrUn- zj~Fm4TYknUE=R#eBH=*V`uzcc?3{j44)6E;@^Gzse1LJ0+f9zM&-0zl1e3bwx+hH$ zX_iZ9+}XZ#-fhL8{|v{PbesLTPA`1W|EFQK{D!qtdc7t;(l{OFA~}77ojyQt`w;STuq#93n9SlFeXnt}>$xW7X0pG5Caw=kEO?#1&n7j9T zhesQW#E;@zwT~M)UBjPhZWjpfhD6c#ZZCaptEO} zw2LOg#03HKMT&y2um*CP8GT*Qpc6IWTZhUidl!R06B@o37EIA;QWA0U)VXpncnO1} zwCR?`%qwFB1af?)?fYk6aQvqFn}w&ZhpoB#pP_YfqQZ`*TK)+}#&V}Kr`$HIKmK*g zsoWX+AO8Z!?#I8;v&<(RTeXMtKSS#s1*48?uc$eOO`W_33|}o8CS6*fpd!e0Y0V#( zLsnwS?3|4atLlm#1iEYzX7rePZ?U>SgZME430?k+j0d6|E9(^2g!ytPED~%Dfb`)l|R0mKiVrN-)wMdmi?j4)p-l*OnCSO8B`WXwg|Zh6c`Ayw7!aJ zjNm^T|7C*6yFX^~EF$k-<}K>+VQ&y+&|{MbW4>(lz~LpsBql$mNbSoUlBKWD&fdnp z4djW~=s$~#r*JLU9H<$wV}tTtmW8r~%ofKRm7a09NHt}t+)*|=AajJnMLQvGNq6E@ zw#2)8+O&+8ZGC;=p{9Py@*}DiVsDIow8}Dh&C5Jwd~nllVF@l@NA3q=MhqUm7(5sx z4Y+2rtxQR}xi+<1Mp8~y<1L4ZLdQb6=Ax8~AHE1|c6!y3%*N>5eSYuDPMd@)i;g<6 zKiO6{Z-S)L&AhV>9WM?yL>*GUntgElBZ;D>V`=-J>~Oeu_Q>t#2v3c}EGt+4_o6r_LDg8pvr$;_YuE&Viz2N_H!3Gd+Ocsr zw+e}tU70ZNLWb!<2B#xdipts+U5m~!IV`x5_F8leo6F(k{0<%yStefXp6moOj@u0^ zo_CGk^f)$ebu8Py#JzQftp1|EXAet$56G?Bo+LDjFZN2p<^?af9CQsjSXepCOPFUn zvUzLXDtg=Fc$@o8x9WKj1}CYPYisAvXei`uNM?4tYy9?=sUkz4SkJ1EV=sdZSalV99aH>Qw9p~ZVe38lH-Tr(5@a)lAhtD)_z_lvGQ3wL)%9xWr{7l6leFeF+;w>e~JJTw=?Q z*XhY;8ky;|ge%Pc$k-q@nNc1(ks~owqEBD4Pj)VMP8owARooJpT_f6|IXA#4;uz2PZ zMsw|gODDXHby3~f;#{}wjTq;lU+EX5r#Lwuk`H@#vSWM7RtcHtpSAxMUNlSRmS@vp znWM9K$s7i*HNQCC&SN+t>SNI=W4en~!-6l~scUN3th^@WsynBqv@GRiU7fs>QB+dg znN9WLfi)@xOq;F*Ob#zgJ8$`Yf+?H(%tUU+DY9&{967p;obTx!U{H(|X)$FoSSRYR z?Sb2gw1O7l;(yw@tO`Gt|GdCp<=Ew9D9S9e%Fs@RvE9L80n3~fOj^y&Ym}x<@ML2Z zWZ-BzB5;%`h}+ij&Z{o$vEoY$7_-U&`oEzX<2to>+Xyg>R~kj18*IxRdI4t0;xCNPICG>(ws>RRB&BlLE` z)SrA$^nP>%3U{)EZ{O3s?~#eLi<(rk)SJdtot&IP{fV=hEwpakFgw7Tw!7Bj{feDE zIqC!PnLa%4-@0fTXB0YT#K5x(zL};Qp|sS zks!;vsE{Uy*PM~otolxlrnmSXGX_kY)pF?JLBS{mgB^<2ER2d3n*-1N@$`sG+grC} zhODn2heug%cH+a@mc=uqAMnmyAZmVXK>}+=wA)VqJZxNa+^ytGp`b z(1um*7TTX$);OhTu`RGT;K8s+i+`D{)M9PXzXe?BqGz3hMYr?VcOIO>DbC0tBhnBd za49*gK{fA{NN_t#!z9^>Ke>YH>lQbx>f+F0iH~YKQrsML{@zm_o)YDIqG6fQtJnWg zJIXoJuzf)hXUJ;1%b>hhfBcJl-y#J~CRL{9jvXIO{DoaW#F2|OzQRX7f(e+I3s?+7 zz{S8yF537DAGv7aFMI@~*X5&$uQ0>_kOag$hy=(ykQhuINE<{9qyybhkeChBK`4g$ zf*k22xWo8A!&IH!R{#1VUeEVm|IX^)k>7XA=9XE@&-(5BK`L>Y;NPUvo7cbi+PCw| zuYZr%zwzIDJ@b0<#-HoA*t30}{`43ED=hpRqEfzbs-hEB!`Q_KY z%ho5=zT54$Tekmf?ZWHT2@cP7S`V1yEcwr{`es?_U%8Ff?dHedDgE2|eb-y7H)Zyw z-`ZmzMj7svp2_AB=>FCD*WT&r@fTmq7Qg)Z_jvq``PJ(#uREUnIXuVhKw)|&!#mK^* zs@-+9ME*ydVNbHeF=`VA%Pubd*21O0`uLWx#pb{FKWq>&_P!;+A)EB#t?v6S?SC@N zj6K>64GP-s8$e}3{GosciNYIlB?Z`ISr^=! z>|%Isq;$mLeqlxXI*;jd_SSWCZQvDfaoq7sRbk$pHLZpha#DOJrhH1Z1x#0&eeplT$<#?(d2aFeha55g6DD8Q;``tlCksRHLs35_ zt<^~ey|H%_PhD017=D4tLDxjEgk_=1{Vz{##OE;oJ3lkodt2?ng5HJu5-xac;W}4y zNGq!824BxU{e&Gn9Q86reD7Lxmbfw=x*_D3aOC>$sl5g*j#6TWre@W;FIm`jT0Qd1 ze+CZ;@l<0C(f^vU6asa1qSN?|)MFV78vzVQ`Vf(R{q+;mSq1JGVS?W8B%uEg^Hiu5N|m5j_d* zR8~K~kGi|2s0*^D%K9iY==10~{a7Nuq&2{aU4Q1E>lMiXDp_flTNHy{WldI_Cz*Lv z#JWnMMOX9K1kqC@!c*e&pKxUk9BB}`;}0uuJ(=XS9CNO zbe#gZA8`F==t$biw`?zif=8p*(>ZDqEllUHoM=`QR`FMDYRc#^TOn%kQ{=chQ{O{D zaR!lwaA(mKrKYcQ7cZYBK3io*u9N68rb{O@l_u<*JHdPX32`R*FN$HCT4wKGkupz# zadW}+E3EC1#FU`c!&xJxaY;g`emO651iSXnYrZ1-Us(kTTH6J@MZQU&S$4DgpZ(fp z!8u3&Gss`Nthp`eKSTcu))=9K9)_+8-G~1(w6>mDo#np8!2DZhaIT-m`X7;k42#~@ zL2C_g=VI5UIf5lKyZ$qrJilemZ%;JhG)N3gfF;1J(_7~32B||LJVac)z53?&OtmU{ zt8RA*OccLW*SiEG>>wDwhN{msn+P`SO!pG@PscRSi=+0E|NNylpQFW-qb=dt}Pt!HcRYIY{>asEMbNxr$Kh5x+E zD4fN}d*DGY8_T@K99zT`)~z|9U*sjLz_dVCVX4EkEy?d(d8UMRotq%Uz@%#+V#>xc zzd$@`u`wyT7%h0$8>+A}ea7#=j)I$~ z>)d9rYrHy;{nepXr#e#E!A{B20>*qANIbVi){pIK8M-y~qTx*TB)+|bhF%a4y9dNvY zQGAMi!0FU0-iNnrFFeH1I^X2zHygL2SPrAZLP33<@giyp@)-@P4!W!V%AGXzdLgOI zx?5yR4lx@v2lL%moEyZJv-f1TY6YxO!d{6wi{g13uugV z6=eL+F!5B-i$Xekv`&8OIz7p~#cQ^@h*Ik;LD$m~QhHMz zxTiY2G>c(uP$_WzS_R7lwLY>9Io3ZKbN$+E71DVQbSEsm;QEH;+ddf|v&*da?2jvK zj0u|aPC%6HGk0!d18=Gb4|kA3YeS(?ZtHfZ)9Qx_t;pbfy0quuU+RJN_(&@sF(1l|bp2(XSe=atBLn^RMpX>#|(@cWwg z^`GKOy8fcdZAPT(Atr{5J@&_D?9BCKjc?G6iLp1o&#e6N{lOQui_w>C7yeOFiDpRg(6ki2@_L6r zUPsu~c~6!YJWIS3s$wwdyJCb9UhsM@&!zb7=?@trVBIWz7*&!VwgAo z@XalET&hH~cn|$uhDf;I#m^q*uQ_(Q-~XcTX7L@jKlmPVUS4$MKZErwQ1dg^$vlC_ zBx)ApzjNAa9_>13j7osTwAV;@G_HyhJyjT+E_n?`h=N$6*9zyRLkWQ?0?`Xz?p(sp z_qpq}#ktL0uPxR=h#n9NN-%~^klqooG}!$+L>8prd!zp6@GjtC=(2cwldYYAK z$#aO+VAmn62D89o5J!MrvbpCq>ouXxYqpwi}m3-c?&*RbF~1PquZdX0B($w5)s zzuUIW^xZ7J`Tpkbx0e~ewvYSqF7=Q8<$up#{@dSq*&v$XHOJYF*JtfJBd54l_qAThW5ib+U_4z7^1j^{+!n=naH`Ld;JZ)6&+FTOGBIT zQ$W!ecfRiY5Al7^_unj>y^R0i_a576&|z(`j4<&sW-dmJ=a)uteoOgsk+HVE!So1& zhP8{DyM3K?LBGMTNr?gB{0|Qs#VD+pzRCWmHIM%$US5HM9KCzYZ_Y_fl5N|6K)%qC zVKobjvweNm1Dj*h5~X+4^KlC=i#>f)NWskSO6@dI;`~>}St?ndWEJelEFhGW*%&noIe!e905yW6=4}aM+9*MuFVm!8XaD z!lx)j{S}BuEk~l7HU0L6`tzwflHY(+&mZ%G<2S>UE#EP4PT4s3Kf@*0Et2+&*w!^% zTeP=Y7u1IRtDbuLml`z6<1dxP>|c~8@f%gDaH__d!d2^;>X+R5;t;gzy!=HCg*1_s zyKe?vdDtW3kvYNs_+Kf`vx+PtPIgDS6w}n|ubR~UXV7iqWM%)na#7CQ6%YgeRD5#W z9K`WyOVvVlhwe`K1s-fn$9r_Gjxz62On7uicVm~&24@Dv#PZX1C&F%6c)J$zNF84N zCh(Emgll?{ice-+NLqWlRu)J&IBrp7Wl)m;(EK@tVgBL=hCh-v#3(+y^0)R7vvOL4 zvf3+_z5<)@CvHYP6G9IBXfYDGA)-|vd`^mK_SLk2ryAk$EgBy@6c@hu80l8(tA3C{ z;mp3Kix>+{qm7N@WZNVvHQhWo=9yPM&KPh%;vG zOugn5wN220wVB7wm*IG)XNA_{U9Fo|91#{Q&-3G+b)ngC{)M*OzFjfOoB@hVJ_mU+ zC%AF;9MQ_3>HAv#Sv1VW3al>8MFG44hoq((eE%YPOSVi%Ty}$P2g^h2DGCA)Unn)O z98$aH6nxU~`8WBBny9VudWnh~9FGa!6@J0Ma!Vj=+M@jz7+R;r6wTw_FumZ)+UY76 zZYa!RXk=>Hk|3a>8Pb?yW;A2H!Opj?9NZaNKg^e{>*!q{-NnvzK!=f=zfmSo`{=e- zvt*AfYwbhHf;x7eH!k@hSa|fz#6RaA_HSAs$fH_)sOK!(-PrnvOY{7qF6^5QNuU22 zc<&f~12^$+HVQ15)LQYq`rh}o@2l^9-wPq4@8yZ!e*fS-|)a>^F} zIg~k}b-jFwcIknDONf3c-h0(dcNzKr|bm*_Q?l_xJ4_puund%5IhYH`dm*V<73rpRK! zH-#`^C4&|t!~494ABes`Vx!uyfz9CNG#Q7jZrLp!f1;Lo}!@I_N zPghCq;-9E??W#j(Ad8U6Hi;Dnm_$R^Rxur{ICyFyYx|y3Hlf(h8a^+ACP@bOE-QY@ zb|-$}Y5nw%xBqcvgN8$@ZGYQGu`r1-RlA8eG?f;$ZDsIb^1LUf+QG_O8?;W!Agawy z(pq8NROS|?>?!vPTe<>N4t0Ny{o21Y(s&8~OLhhYi4dI!{Sz5HK3%+cz^9RIC5M)W z*Vlxq=~5FtKAe$#II;EhtG%7^9UM&OelO8c7GP@IKe_VswbR~r+yu+CI@fJHc#QuW zS6F$7SNbz10k(=`X5rBbSpG8vo;#A`H23E*d5v9tD|~(2Bzgs#K70{XQBaZB=!_5$ z7Y+QeUB`drWasCpo7j8TS8y9Re%9J@O`~ak{4eKq7PFIDR~)i9d3d45@~mUFx~nNr@HS5j(`x_*8F&-HDrj%KkGn%`!PV<+fq95o3Ge*E3y8yv`deXD(NAi!g0G z!=%6%vX%e%BGXMLHVSyOvqz|`%9776JvD z2}v^-rnp5PU47=+#%rsOC25uRX{F8UlwjpL@cfg6_qz5KC1Hylx8?%&9EwWqTU0x z_jmqE1~yLz@422K8cQYz6btMX^ zKAn*8Ktq)!wD6wb7M8xQU0%oHWJT6^acgHQHpF({Q%_0^aGd>sExX*=zq4V5(`lc? zqxL5c*qYa7m$Th)c%IVOx~WB2P19*UlfXw2#?v)>Ti0D^Iep<=;N4>D7?BR!1{np> zBMQ5k7{8udk;ucsD)?jjoV_`c$)A~3Ot^iYiC&3tojIfW@1ah~l9i4REYf0Tur&&n zsyw-*FwsX*MNlIov`H+aXvXBVLA$D&Cn%?0i`FoI@}l8_l$2PDfW`sFeJ)N7+k@WY}9W8}e>c9bt02@-S69(z%I|WisoeBqks8->yct z<}O?(^=DsV+^i&?{m&c3PGub9eRuJ0Pu7Qqprs-@Qy9W{9y4|1c1>F8o4UDB?rqBg zmz9r~SZ$70oNC|daJ<36xgw$pIX?z7v>p7NFg=NTW7UK;Qei9}N7rTveB8niD3*0c z!S?3SiT8|Oi%)HT5V7y${ABS59B#J{=l|9`bRUrsAN>0ZyN-hIL7hX24^L;iZP*aEOyE|6M|Al)M$O%S zRhNlYe!a8NZu#XKe7?J!MN+3td8oKtiml*USi#o1$BY`Q-YRl$I1@2Nh?hlp+8)IR z`bKSD$VbAxv3pkc+itEi^t(suEfEN zP7mfBCsEco7oWEc0=`F^Hwf0~G~I|SU&!O;I+>kkWq=p!?zh{|#dL&BkuWLKyAgSd zCoAW+L($}noYeOUTUs*M{Fq*N-wJpjR&w`if0BUG48{2O%da$jXgXeTc%s%HnU%~O zDZED#t|V|5Ee=$v%=EKA-f~O&Kf@Po0^m&y(#`Tm6*G z%g@+w6c)Hdc+7uN+kGf6;~cjHd(16^g2u~_D<)l=yE65@(BaSp?h^LOPPg~fvI~06 za6EW|A%5-t2g_Ol1=hD230if}tUF`JI8*Jp-TPCMqlI2G%d_0|Ogqcb(XQjHa?5|i zVrJnh`eo4#p{wHU);B!PuqfU&-~IN@$d=sje~Ae#YXkN!uoGL5zl7lcg8}Q+YNIfL zjpJgEFat}-mEW7fSiAk78Ju8Hb>9N%@70{Yd-uE9=jpreY_xlK z|3fgOZFNC-*~N6@ifg%2XBS6q$u{pz`?%(o>1?;n8Q1b5LVQ=&a;})|wi&GEapab4 zv);6eYwjVb*^{lIRZ)zr2cZO{>xfWO(An0Evx_75Ks4VnoqcR`2FOm3dFH*Ttp0q( za6VWwR0+rp5&y(=(k!lUt=u39wH)j~kjXG3A#UE(w7lICYz2gm=47amV4s3jU4-}) zY7oftsr^frh<}JuaMZ|{n*Zwi_X8ph-F^37G-z)AyRgY2qQ21Y*M`P~jsN(c9%z%D zzC|HmwYamS&fJf#^$aD9Z45`M7k6tk_E;QPDgL`f)5Z6mfKnP$y!)S%(-<5Wiy^Dv z>TB*8e2+e6{PyoLa8K5FNAjE1@qf6SzpS{nSTEgXS@9`q5eq*rUFo#sT&2(RDJaBo zB<>lwEP{os*m)6y*kOaSTw>lRrl2TBHV;`ZOoyR@fYH*!w+%(v4(!9}w=`<`JQ*qc za2N_zBC}t#+ofI=Eke9i*KKlO%72~Hx4}h7Z~K~QR$gve%!=Ynq{9NE-kV7F6%}?wO0q08|ARy;dm&d{*2#2P{O3% z8$79=v*hKq6D-zM2bB*0XJ7#d**&q_sTm^SB*`!#o2e`L&fGOT9yj~ccic%@w?iYr z$o~s?hWrn|m1)58^flAdm+ZDxz4=n_WLcH>=1Z~jCZ%2E)^S#f`6NC=)8PAYgtYgj z*hwyjwOTK{G`pgCQ}4VdOasT~^KV$vFQ!MEw=a701!|z)N$$WHrsWQ`N3L?sId?u1 zrrs@n`=eFL8zQX_R-Z>!ty>XtK&0vX3o%g0e8|~dEyU2#bg=2m2?3__C%@g_*SoB~ zW6@Hfs~gv_$4!?gJObHEZP=Vo3FE;yh3ChgI2R3lcQ#T>X`*XlWy>`uue@_k&q#FcJYVpsi!Z5 ztaPojUM6kw@9VD;zDuSP7W>ZX3XVUyKb3X<1szbbd2qArtAZ;}*TT&#&WiiW|1(TD zBao$W}8lSX?y2Hk440lZOy?s~mN@TDpvTbf+o+P3(>mSeV1BNaWH%Mg*%y9aw zx-)>i!QAV&2wQaB)7?Vvxm4D9&T!?6(^}_vH0Pk#sv~Akzwn*m-NLa$C;f6=3)3n$ zZH*!~7KV9ppPa7!TiCj0!vf#=@!S4>$u<6J8Muh=)25hd2P4EP+Bq7QbSEs}c-za` z$UR$bSzOA#lGZ*8(d<;a)IyI;=R4EmShxKCHT#(o!_H79+0bw5EISyY-v5x^^z`Mo zg_!|L2Rtuvic}~wv%b3$Y_Mzd%1X1t3#Ty7Os-$fe)~Ve#J`-H8x5MK6^MzbY`V0O zVNn6Y&40XzIA%M&edXEuzXm`10}GUy&I#o=tSML#^dPn7;kw)&yM?KYW~a8VX4%@{ z`Aop@my6e|wt#^BLX1@t4s6rd5%Q}wkWDpUm$cG>^k)W@l1~(y7P(fMEWOLLsQ*{f zyFAV%3+6B=-a4PbxJ!}CAT1!kL55|~zi9_gMdZBZ3)oRw;`(v=x2A568y#yOOjux6 zTX*~W#VzNrT%N#KHNo3u$?ffbTecnO$!4l(@jH?KLFd7=34Xkd4!#)DfA!2|F?)5a58SqCV7a83)Sk0h`( zrNCS5kFtb#C>&e2}@T!W-qd|09b?_?f1gjbFyd&Sq zd;Gqo#iGv_oKuq?zhY#NG+}UZxVylmPV4%Tj^*hQol1|C7qmDu7e#%MWyuqKwXZ@U z{o~GGF_YfIb8S`cwH4AnLdL!)-Zk%I*=Dafznd{-OIkwcNd{I$PuUAa4lp)&iT1~0V)p2i-vO-xs~9Muw8Ryhi%viR3Xbo6F^ zYw!%{Uj3Fs!R&{#S^?*bU98)c3-;L8_wLq_MTAKTt6*!+tXRC523*1NZ6i!tAqjIx8GQ-zK5 z9(6j-aFY%^b*ylj$09}d5~hV4Hy=1OJ5h|Wy)tR>zsEQKZhyZxbN)*0J%8l(^)LT& zv#@D;$V3j04IcBpPF?rxAma(g#TLhB$e&!-5U17>+32lt{BiuTyVLbUQ+8kH)kv7I zVBN`_P3bL6zG5@4{+f2OaX*t}&+MEA0j&irlKWcf#qLfKt9Jku;`Ys{x33=1x{zCO zO7&kzOp$0rsJrlDPJcmh+k%3(de@|;#Y}0}uJc{~x8<1-x8w5jn}6ruce`vKaOGgR zzPp>kEv8Tofz|&GaNQ8#uV6?LTDif#Xd15yn-lxe1#^v>*Ch8y_=Pg~b+@>lYH{LH z3DSAsqY$|4>w!A6H#@7YblgbN7xWX@!guqIxC-Y6w=UMALuI$RTh_F&Xs;66Q`~~?`DpCDzVg7KiUo%!3omHQNp{=&B0Gmm zSEEPi=G+rvhA(e#LaOnqX3q?{ao7HXawMDO!60r<9|!LY-kM(lst*L)b&-CT~eMAsebzXJhVKX~gC}PlPSvkf1*V9dtc0Eb0`TV?E z;D`*Hwc-PZ8(IMz6{li2<`?mspH#kcE=efL{TcsBeI{)#gAVJKl2snAfApFP1QK2# zc}8qe7Q@8l&O%32f1C7ZHO{s=zmer6-(J=Qv-W8H4t_X6jCth?+a)a=8`lTFNMU_+ zZ{LZ16G{}X9E25L+zBCUm;GxpT4t&pzTm~Q-fVsrhaop>NQu}41?!w|%P&Y7tx`bB z#J}I}ua)-sd%7%U?>a;lYLGp^qW;s;Z}OCvR3Ii!?`YkBu3-|Z$$tiQ%}YgdNg@vOar$k{t_z~`KTh%H+G~?^$H{-; zv}wmo&V1S5o+~_8SWPXZXn})v-v)fa7FU^slS{F3;rVvYtB(T5fFun|a+>SbbHZRWh?L z2mNR0n0(ifsbzKx3+xL>tN+Rra#NRaI?HX+H?k&Ltpm_?3vhk zv^h&!wq54ZR&n;}EETHrf14du-X?#o<ole;-KNKI_}h}h6PjKobO#(#HoD@{efcn>n*ontO;F(G3v4e< zvMiqcaQ%2-g`3uDox9aXJ}f(`XmClzb?=H=Gg*eVfT*~m%{CH;Fd7`FD#DopzOF)ZO^<}8+j{Tj_VG}8iK<66hI$I;UM<1$vW?t5~_F3HO#zc_aPU!R6<%aj||%OX;zS?^42lT@$PF*>Z%yt_>zb(;0e z#A%9=>cu)nr-kpH=Gk!4MlB+>Eh4c^GI5$@B3Q-F#A%Z14^Q)KIL))+pp74@bX!Df zo97Rs!%!39Ry+r*H$W0s-#JY(MRZcx(>5C)rUx*zziMl%^&w=Y%dysoNfll!k^_S~;L z8yy`)3&dxcIUZSgfBv!ZbL_@U?~6Zq?hLZ}ZV|9h*NStO;w{s;I^|3+?VoSUdNP6e z?t@)+`k!|soH;z>i|@+Eo2(mRPXCf}&TcV0@`X+P-26+6mYm)CZD}~$1{Zba>PwbJ zH#KV>9QB(VIQeK_+0sWMHfuaSue2(C`<8L*Y~MXE1P-uFEcCc-9n{J#rjo+24AyX#`+u?b{B%Ciyf-n zx-v~!qD?i|C;gGzj@f}l-&-rgI#ydI6_#ZN)G-$8_H2x>T*iz35!{ISY{kHa`7v4azs=oUd~zXM)kh(D|4Yk1eUR#@pL^NthhBg8pLZN5VkQ}g z$hce;*|P27mb^A366bopG;E6wT)=98z1+d%f0&; zi~2OK+?zd~TK*~-ZYi^6oQevUm52qOshYQSrp(dU_SU^gb7%D`8z{N7?7ZGzuO%&<78b4 zXK_qWwTr>S1eilpW)K8c6$Fhj4^EFrKYzlkhK9?;BK%TLcNj`_J}oKMjLWr~cYx#0oVYJBN((nKMoiD|6U>v*5ENo^W3aq#xI~EI z;p&7C7U>MbrA)Giiu!Caj_??UT})Tqwpx(qLB8;0q2o#VN(+=G8hltRw38!dT4j;hvUL(Foox>d#QdVzK2h~FZ)x* zu89J9{3y-Bb0?%uLMMUEqjY}9+pUxmPK!)dn^nYf`fJ302J2sJgPd|PFP!7lOtx%s$cg!Co*4n&$Uz`MCR76TaM~x_dAc@X|X%p z&g&Z}koTfw^Uvph6&Y_$>}ze66yBmEkQv;dF_T%qMd;m8|10S(#Twh^Kk8;UvfR8hhqfhikh4k|tms@~;hZx~-##kbo3u++}EDMz-< zd-a3I(v4}6?Zd{-PTh#cr4yTbQ=3gR>pA2yWiPLd;LH^1=uFA*I>4fo;>Z6j52H03WW#RB-?0vMR+@QJOeVcmcoxMM+HK%)8n0lMqx?7>A^lp_cieG$!Dc#0H>y?k|@rO!PWtJ%p zQd_mddCWO2FZ)~B+O%l22yE#HN}9F8E!gtNF?Ep#Qx+?wyqW!MTU>Xy>6P-8w-2sW z%&Ad*Q6_XWKyY*MwK>O@G_p81);D$e6fBys*Gh*)pvO7#-2$d%40>A@-jZ+^eCL|T z_+n-5sV>j+jZwdh9hXi1!9RWBQdxnrbAP2ng!w#9?)uPPeStkJ!BEKjO_4*;JNC25 z(RS1H=go7CFXz>bsGL`}p!Lau2`v{zOkxwRrN^$k=is{Lli93KNedXye_9l0>>;$U z!YxxFVTsG}FH4dSE@3lVJ>RW~nf3WbqZ38TJ6?Zko3&fywTlX0&OHC6Qk~N}7n#_7 z3e^3WvqEsQ=DMZI83HOhCQd)K=FHQ@86Dlq%3-_e78>ULXAn)CGiCZB-W#==(O+s` zZYtS!>+;FGxeZIyE!Idg-i)Z^D0g}~Ss_C7(%-WhEh@FIcJ0m8;h3ao(7sbNL%?T^ z*`w9Z!sKEPH#eusF#6R7EOc{u=eOYQjbrzAl&zkB!XaYG@-_|2TLg{=qfTx)B&&h4=_?e?vS zE1WDYzP)qy--kxQjosnicl{gJ9@$ge@VWYLIhWzXDm%UZ46KJ4_ibU@Srz)tO6(5v z(JnXl8BNU`YBNd~*O`W~SmfnC3-)Jtw7KyqyA=O4_DQ9pzB86@+}b;({mYU{2KKH? z7Z;svQ@>LlxVnoe2gSn%eMisqEMLIzH;5%c_(<1%vkAYx zna-jy=R|*Ze(tWMa&W_~C8C=) ziYqQXA0^Cbo1&P#mi<`Y+67`fdX-nF7(6*HBX1M%Ng+~l z@`>o}UXoqC!3GOw1xtuKsHyDbTQO0(BW{+r^MN3@uc}dn;-dRj|5{_SJNklzgOkf= z_fs9WPBAQ3(G^L0;3%-GtlKZ{VM9iTHyfY(xg|GPC-Mlp$pi|mtrR?w->=mn7HsBX zm;BOAy1<{K`@*62R8z?Z`coNSUVh>|v-8vX3agX0yTxtR1bbg`t^Lg~pAJ2E|+s;{a8`x2^3{-B+p2jx<&m z-qN;6v*S#+vdNCGI%;>OZ%VE*Hay|SX7l*^{_6#c<-`|1djDaYcbmy<>nR7#61ew> z&DzTrp%kuT%FfXgV#dp*~YH~8NHpx(mG=FAmy}s+J z&7npnm*~gh4_(rY4xDk>IPF~qqc`iRv$xNBmb+{24isa1wzyivENaoa-nX|56m~7S zXwJoBu~_Y9de7GKu(ivc{mEQ!(zEK+H!dZE8D3>8g*AFYKV}_sEo~Pu`=_>ehS>@y z9)k-eX`Y2@k78dnxz3*`Ai}>&kepi%Qw8$a+s~R*FJq<0(*l+DaF3tn_5PPwT1*EHqAy2EED?(O&59X($s;)`Z8_2t2YyYWl;y>dKO^HX=`*vmh)YYQ(Q{RA;?+pJLcj`Zbc9_J~Gwaj;Uf)^%xAWJZLxs=(mj5~~f8gh2 z!Ks|K{~4ZsSvPZg{oVT7{|p&_%l|I^{&VV^{|vjn>-}fgXdger&*neFy8jFjkAGNA zyZ`B}_Ojgj-{bGs-?EPdssEQ+ZU1iX>HTl~uRjy+w|~9=n#Di9rQ84P_If$H{O|Se z_rLXDufJtqKQr$9@AXCRe+z&8+4A`OulHa3?3-8S{%6p;v@7lIe}>Y(-~TTDdjDJh z{b%8u|GmDm``?kjb%_?A|Ly*p-2Z?#hUwY68S{VE61M#N8|S=N=1sejA9c8`^x!%2 zpW$?7ta09dhF$-D|GW6>{crd8pS5rPXDEFa`=24HZok2t%Kr?p|27`qudC zzozgHd*y^0*W1(im;X7mLm}+xeIuq7v*Y(^yks#wUUQU5!0P_KRSb>R-!Cj};QDz% zVPe4C&n^l(?~5rjB)jiB(yfv$c;C2ThV=9Q46hgp+&)^YwyW+|QJvfM>VE0feXm#7 zUR}3+Z)yH6823tS{#_*Q^|0Sdcio4}UJv`dI`G=(b!)%JM!(y&{jTn{zZd+Y@9o+S z)stm)4`S-|u-_|p-QUH%_BUKD#8mTWs8$F!d+mFONf0jBMc2bXn!eg?uTKA=4S0lr6 zVg7$&mQ0G~HG5Q?k8tWl_G=Y|?NpekpZ`Zs-*!(X`&us*0ZAnrcK4(I^jSkJLnhA* zTo|k}!Kt`Q^u=c>uV=rve0a#je6q{TZ_!kqE3BR_5=O_8&icJM`r}r#Ki3SNFrS8g zrg_nwEk>pV=e%;vv#edFsPQR%VU<#Pm7rX?a^n>-9v|W9Q;lK*wG2K~Ub&m6BD_V{ zcICeB6DB=et+Z&VgsGGBvw$NFWebIj4xCl$Z=HN<8Ux!c|B9nVpEt5NKI@N?KI?QW zO!$b#ohm&Uchge_0tHGN)3)UX99v|+XyU4cPS&b^T>>-OS<4oC{bx|vKHW`ij#}au z^U7J*Jy)Eu_D~hEa&?`0d*@l6O0$D*jzymqtq8U><9Mu^W!=pCAYsmSL(kR;ioz~$ zqK_|D)8AIGm^FUE0*}l|QJuDTE^U;)y74J z=Hgk8Cq%`|T%F1wDBy0{Z`Jhkgf82&w2!?RaSe(Gq?Em$t66h&)qYCq+^{?>WKCzz zjMj>#KUp~pMa^foCGuA#vITSsDHnQN>)Ec!d$9V$!hC5KR$tBtPNtjFVlyog3}(12 z@mhbZB}?MN*&F=#{4xLIF;!=Ht$uErgrqX-X05d^r|o;QSo`&jloc_j zoZMHH`S4avC`w9NCT(G2cfw?|dnL=pw9o2dOQXt`_(=Sm{&{`T--`>n&a7-|;Wobe zpW!h|>@+iM5=Yi_XxnDS! zGx^@Mg=w=D-oBjHcxo9-ha!uk`1Mv5$CS;CW_L`_O2*ENQ;9IvO<_25dSlw_kJsiL zS5RqYZcA!);wWUCp)MV;Y=e)=A@larUaSRTLOqc=`8T|-B`BTIG5fS&Q4+TRqt$_s z#zS+CuqVCSnPuMO*LN1tOZN z=I_-~S(v>q{oL|ZET7IM{9fkVS1>VI>{wER(zch(27YF51zudZS5y`GX`!3zG7a(M zEb&+3>9hE+Dl9d*6SL-P!T04>lE#v}Kki>!629ldW<3QKSFiNaxoHVW4paU-zO*)G zpD^PS$AA59_jcLcXJotT)Fq^_Z;R#0k1Qhl#s67zOlqGHUB63P=R(u*`B{=vR1NRN2vgI&5v!)~u_m z!d6CY+dXwv*lN{POXi-ssvUMbHS0mw%C!xSF_(7>80&~~X2iz(9|&6+wbkqD#=C2y zwtC%p7q&KPoAuUJVQYi@Ze7(n6#cwKlAY`JE~#lNomZOb`Wa5-R4J;9S_(0KYu43` zKVFBe1@Tvhtyyi-6vPz!*6EiKN8_I2ck6W$4`k@6-VR%>UbZxA>gr^WRZG3DYKN>1 zTAForb@I-rrCD!7^UAeD)<(ykx~d&IX;pS;=sb=Rw^ydkhpj=~&{xsgYu|oklX<-^ z_uUuXdFqSaeEi;Ed~5$1rmuH2j<=Sq33OPf#JzJ7lOKn|5{upU6rVgilCn^&(ZGRq z_TwXxOCI0&m|$@4d5P60VMd0Fg3CK=OTMi=BO`o#*SS6=HV(au_2Mo9LDJJ}9HNpr z1r*tZmQCtlWfZt`ykvGn#G*xK4E5}fSL+Eg7E1WuF0;4LE?{^t&rTTXvW0Kouhd$( zzfCT_@5sVwKRb`OR~(*O>bv*e#LfGsq!uz-iA-4jpyV|RM~36Pq_Y-FR?1=e94FUwD?p`s#eij2Ae$ zVyDF>vmV=J?_+;3>|)^jwERCresauU?v4a=7(<&&30Kgp)2X*rpZ$a)hb#MJ*enve_*Hx>Tj%i**?iyF6bs8rcZhm<) z`NxF&ou`?O+^(uiGIB8BTxwJ-*x)5_^yBm3HI0mAj;p)xEzrA~wT(k^L4spUXPWrt zDzzQCds3=rg(mu}+r(F36QzDj<-q+@@85g$SX$2&Pk*q7v818P$qlUR$bhUFL{A$`hh|cnb5PYmc7?xeC6Xd*f{9o+nFO zmZ_+#&%U+rUlO;k2WM(=)~6544jpNUTzJM~wo>^6&%J(HGCe#~Pum6F@R>AelE;o- ziI1L58)cXKXPG;w$#`s>YJR_!Eif>eTj|B&w}I7+LRal(iA$W?Q|fu|gpF5}ax>Ee zOX=@yJm1zIU~PB1a-)SKDy`T%o$FYDy2O_(`H2%`bdp{RA6u__+4l#HB^x@~x z9RXi|d~;acY4F%(&K(vbXPeCEO*s()iNeoX0=|E8w2<^;UmcVm?3iSxx^_zI%L0cb zYFug-20uf-m>f(?oaVV)#alxpGQX^3nK@@eb7jP&$0oC z_c3TuGIzDn(T$zrKDMu_zt3C2>m&R;py1Bgs|AaW3(YB0t$X0P_VKCq=Pn^)f}8hR zX$A$|m~eD&uUPgWH-B5Z!eeu{7ex6dExFh6O8JP_aZPR!Jrz0)YZsFp4F=NfK%^phZO%u-Ob7j3@ zFh3;TVi&4&W7aX>x;dHd4Ob#P?;K1?ILNkr#Zz`a7w%~@pZU$nI+TB?`N$j9l~Y<7r@WXu^@O40 zL(ch=`J82vHt1wXY?hv}*K@D`J@FQA?`29`ocK4bwus@`zEWo8qkq{>EkX+%Z>s71 z)~eO`F;l(KWbZQ_!+^ySD>`~tFx)-Rmv*IsXRS+7w~boU4TkOmyO@f*MHX6qx|=x1 zMJ?ScO7Y1dws#?p5tem!yIUnbteZF8jZrD;ut$@gj)dDjh@g(fAmPgPVr>XsC;jo)kHlh2%-V$qiKPMEEDy8$C>WJUcw>fv?gVmYb&Tc+kiDfMLIh)$~IhRe^rkMLO@&y-1W3Ba}i!8yOPE)j& z2u*l-{)o8O7R8Om%6+f1_-rcJPYhq#u6p9DhWjc-_wJyDykk%$Q3*Cnu|(UH2&FWD)n{5SKL}Is&>u zyi2U!-T?`}iaA-VGJC6D$|+|Hrec*X-(B8GXqe5|tGK(NRdkzqJ@ZVxl%12+z84zB zob-q-+@Y3q;`8eGUa+BOrW?idNd+Bfitbrqu-!pGpDXjObpItMW7f^LRlLnhjbcs~ zXYZPBlycH~5{S7`&$WXwVQSY(ZiTBVx187AJ!uqk>z(SE>AIeK?|9GD%Y}L-D7mD! zld1C2mJjP*{Me(;w|IZwm)@zD&3?R_-2EqR_oltyU;k%NJ$`!vr$K>Pu1T^|ij!(V z(&q8N!ZF&8sNngpXCy$sPp4wn~Gs(=Ztw*b_f`) zZJKbu@sU&hllc!8Z%DbR<#iz0i{oJ4iwMi5N2bjE!`k`eZL^_zLaK-d!=X8Q#NCfR zv?!JR&v4+Y?bUbn`{gg1{b$&EUT)_0gC#X_!tH_g|L{lOjeGw4<6q(Y-!UH{>kD97 z_M&O=xN)VSW#hWczadXry`m>SICVxhkZ0*-^&fn38?UU0cyxicRegQ`ylN(IhWM-c zD=w>WZaz|fe7E{zcBcOfa-7U3rmzUf=2WQGODinVt68bQHZ4){O@)Dsl0(mu>+C;- zCh)L^>^JJj;jpc#n6yY)NTI%@wRuUy=DO^EwzoM~_%zuFB?+rI2xO+?cfHH+>SX4U z^Do0*{byMF=KJ5=@T`+CX71Jh4AD2=|GIZRwft{>`u^A1dFy}OJ70hA+wb?kZrxt} zZ~Nx^U-!;?f3>mP!$nBmS8G-Gtp5ya*Y4W?y4n_`_02r6`fJ~+p-K;DL2LmT22xUe z`#n@`bZPyyD-#p^Ty|V-KXJ;z^{3PRy>GsQZ3QWeMp5{!`ro!Sf2V^iN3svquy4QL z|Gsy={@SMDmEzSy5W|?3&&t;Vf@76B%S77v34vV(C zeZ})_*V=B$i=S2|BzbZW<9fYnwukxf{MwHWJjYn$%U@DX1-| zT*7bVcu6Tln{DGPm)|B0OM4eJ3P^oA?W35SvNd*%meQhWzkI(httbjTd%|j`n+4+` zqm(5NI;;fivjfi`^3enTE$h3&GHHdle+^uEftytS%Saq zVz5|fs-tpXl5|6(#EHx-u6tGqRUAtBr-p?h}aw2urNA6PDJ6ttLdXr=Q=RdQEf^;(9x ze1MwZkE|wiUi(&%7JG=6Kz;XHAVcOO41qbLwaV@)$XKXtuAinpkXo{TDo8WTKA6%q zUu;3v!?+*=kz559cG=6H1#>6N4e}wPGuSN(4kb;jk^@^+3-K7p2OyiF+*svTa1+xP zzD4F@sM0LtOwAFjn$p*;lGu6qa|t+0ewbr5?`w0?f*UK$w0OHnW-Kwt`b&;JsBIm$ z{!Jy>PN?1qZAS$+{?TMR>bNl=Cq!D5L*)I!?e#@PMX-qt*oTq=e^p3%d$AKHcBW zyEwv2?N5}sj@dPfls{*3KHZKjySu?Lu_?#-X~0TNu^8{=Icttjt9UcVpYK49=Mxo2 z$txfD&dpO3V$VNtjG0>~VfjuK!6|-o{s=61$T*>U9pAM(a+wlb(t$+*zPD#xP+!XQ zA+?07SdGO%=i%d9rCXSSED|s8*E{aiyyk{<@gF}alkGdE8*Q&QK0k-$PV@2>XlpOe zomV{7Sa{7Zwe?Sy3LOu8($zcnA2;{ywNf4>XSoGF>Qy~kV6s@4F=Dou#ZeCF6g3Hh zz#>sewsfIW(HGQor&8VBUhW+nj-iuVtzre0Zugr{ zcr;bRQc!wkhKOgM0vD5xi_is*5~+^8{ZWD0=R9VeeEj!XPw@?*BkNY@{|k6idxTHm zfy;k}ry3SVT+S@$wOjQeN#*3NqdE+#0sL8$^CDbNJ;~5mZQPe?&@CvrxYRUf&u6*P zS1&pR_dn(4JLsp#_&f7#Xz@|QfL)RTx=)OYW4i=SWaij*hxwbmEy{7ZxBkiWhOi{X zS}qL^`JG*_jZLK!ZbkZDzwFhhAgyJ!e%VyHe-e9VpLa4@&T^2gm?xt5#Wz<5HBp{r z`&VrXa%|mhzM5^xH|utuJ^vYm+jfPW|KSIXu3WY+I;sAq+qHydOWscMI^w&rW5E{h z6VsfI?%(QfyTwa^Vb0OFK>~BMpZjd|s=myyA@FCdN8KB{R6E56US`$Y(z#mqCjC0J z=BH{(y(<~cbN>{vSYnvr$mcro=;Dpu634i0erjyIYp%q= zc_#O#)wH|IGxqavH7u(=sj|Vt?_Xo)Nv3$qP!EQq4-b3pOK@Fz<@T2@m-sb@y{rA_ za>OXjaNOsu)MTMF^$E|W*{!;5YFcdDVxIbmPSaIcv+T>X(+W>KN|r75;4}+)lcp^p zI(?r`#-uB5DLp*`PtNPq1#CPMSg7HDrSbw_?{l++S`8gl&M%e)%)ap~%1Xvl74Bsi z9Q_c;QXY6>bM34!&Ya$b&)Vnu`En|7Oh}6SvSMCPN@mu%2MvePIwl&&skHvutETE8 zdUeK;lMa)c%cGZb%O82TZtCWL!i^csrjM5C8QJmh9Edo2_IFMCvAU(~<~x`#CUr31 zs9zD-V9j7FrYYxPX1KcP8v_GFgU;{kM*BQ&Mrwz%y1TB=a%u5evwE+I_r!uZHYNYJ;i5d`s7K+YFnwDN(B#EgN%9K614BnN4bEc2P9!S+*7CwH09GXl4k zu06Qa;M~oXuF|^9Zx+rfn*L*E{-4Dx&6~C`W`#`-9(z&;Ab;+b9{55C_CrOS?8@6pIllXFhxVLt(jdV zr1;VX*VDUK&2hhQDf;XdhI@Z94jvOQ-cWJqcfg8>e%2orJo93&->I9sE=u9X)laGm z)C^p9N}RS|H2)rFQ-R@LnIIC~1SndqPy^+jk zrbusW>RPZMb*{>m%LVcb+cuFXtN?$3>z8>E<$o^oERx>H#x(q261_Li$oY?nG0 zToL3f2)uf*Hf`~#7fsKcBhxJG!R`g$(gpr<-n&m&%om2gJS&$zF1E@iUA0?sg|R-tIKjr4%O3l<7n#eG}I_PDBLRbFzp$YROzrc()%*Hm3( zW$it!Z8ud^hmGO1>7k#}m+uR=%w%unDbmr%v+xTEbz+@xk@vn9hmhiYo5-2VwuN{p z8cFY2X8D$JD3-L;~BU(b8=Tu^!H zt)7gN`^$}u&q?{|@LJ8xDznI5n)9Ebd#i=h&5i`Bee7BkIQLkr(yS*uPt;2CgQv-9c*LC*T5X-o zF!jrtwVac4zRpt6U|cr)*ZP?^6JKpS_9ZA}1Cuy+zQf`MZtXi}>n84M6_oHQp3KVD znZ@(Y(_GN@l&^v6g&04RFH7%DSKvB$migSq?xT^acS}B{Exuh8+odGrW$nF3G%jTs z6O*6wspUUdFS3M(@mx8|6%@zNmwswtXP%_-ewCkLDTa;A8;|&G@m^Qw)IW#o!kG?E zj?=ESi`s=xs4aewuFEmsQu)-Tz*0wrmj*$)Wp`OPYvxVWbPrKrIFWE`QEAdbXVu3d z!QZ?mDbA71KR5rQd^4+m$l{l&EGIlIPT!gx{)>|%<(ig|)ibq;MYo(P+@_=)Ynk$S zu2SaDIa8m{d;8Cr%(jC^~EKPbxKL;D-pX*+`bmL2>FE3}e@XWk^YMO;KD+@!LL%Hn> zvGXTR7`#~O)>haY&)qWb%lBK(THC{RZoU@N|2g)B4#(xfJ%=vqeb2n@+O^>x&!$Hk zCDcCsh!EDZxa8_1@bYaeUy&#F>*jvtyV}U!{KFXDrOs{VE z7Jq)%@BYi%;{1N}T$J!uX=Z#rKguMUA)}r3uh+NrcR7zwOghuHO}?wK!&2dh{JvZd z#qb^8Z3*h%0&*>lvNqY}3T*ccRG5;({p_rR!L}18ozDLdQTq1AE%R{U>Apou_e)l- zI`#BVtasj(!yIoUwH#VHC$s*XoqKGf=xxh6OqFM?lzfsrMe{5IC%<_D3 z@eq&4MfJHNACp_0&0mCElz18RDfZNr{58?F8|OT_qOazd^ykH%&l)$T?0lwkQcriv zl08Re#wR(x^jf5q8XNZ^;Fa1kzY{ufHFsYI`m_pa?zuBhm*E^wOj$Zh(Gu0?CfO*ii? zK0ed?l+Knyi-n7q9+<}T;ppC1p_4vN*(%|$*7i&A>6gv-G^6C+X>hf3&RBfl>N-oF z+M>ntHyz*1t^e#=@8T27*rbp1Zd)kiKeCnaUGcK{=!VbHd_rX(br<~*aBbe+t$a;@lVjI|kA6}|OdTerme(7* zadyZwKeV#b=RCafeWbaT@2SZ}OYW>BnGvkE{t`L*CKt_|{42BMS;%~H{Mo}IhVCMU?mPj< z#Wydu*{|Fs@u*KDsZHbXU9Cfhd9IZ&kdWT|BO|F#BdJgEZI=Y=%$^;!&GMQlG}dE{%m<8jspE9<{Md z6g?|0^!}f4Pe9xn)+;R>-JR?Ed9>B0Xc~U>dB7Ihu&MEoThBa2t^oFM?~J1=94G!W zte>j#$oIZJC+jpOZ?WW4J*zDhJoWSCVppClJvdAMQRmML4{u?A(;S7ijgLeaY!d?f z_nI*=v$V<8zgha)z^L_ZF=!9k2Mh2l%h6Kad8)ObvH4$V_gTDE4s4j%^yR***Bz(i z{vwOVTOY_-J?^V`-DmN*&*JgcJ3C(YzB?~dcszUKV>P9@w@f&gp3I*9XXA01kH;ko zkIR(V^B$K;JT6ms>zz#L@#!xXhpBrmdgXMtW#%8l>oSGYWm4|XeXM8kxX%h?T%R(J znj*_fU8SYxs+3NfwVyxi9#eQcrtr8#;c?l*>oUd1vyaRv1v5V$mnl3hlX~2yuw8cj zp^wL9i;u~atvYVfbh_!}dEx#qJ(kyFZa>=9c3x;-=k|ZbvlJ2?KfQ07W>kJ8VQcH- z`|K*l_m$cL!lxH(_Bph0p#bmudJ)m{DqTh*ZG1|cm%e{wy&&)~pnWTEj4TIl_<{PP z=*wh3t~k^nz|2taV}Zh?8ZHF|w)q|V;ubrwFdV2qT7|wSHp}37Y;k_qD`^|AUuyS% z>?1>gQP879yO>lyeDscJYY^A9T*>5I_>n!b;{a=vv)gTk$Y=H+qoCIQ@yj%tQ~i*M zr}y`(Rb0-}^$(!-?HAgTIe~GJ>y}EB1qM?i*q5d+@R-=^FBG-t?Vs5xM-}6lHwe9a zB()=kp-ahic5ue;2@KQyf2@TrD}Io*-lUE5jO(qq?q|!>!>#7djd@X}KWpyXm{*CH zzOTG__1yABKS9*ASp6!=lD)6WN{d&%*p~&O+P&|Av^4&Gz%(N_!@F{7_N2Uf@>h<} z%@v4DdA*k3^>gs%)pPwJl#f;1S8J=CSH8rX+KQhA30|zZyn(T4vBb>1nYW$`@932| z67nV9)aYgPl{c@R2VblKQ+(6+`kh->YAb#gMA_JeMY3F6w{OeU`#FsVtu|}{xf82% zmoNIcaJu)sc}ZE(Vn@zC-qCTytyMTpnfDb`b!s2nP^j4k5Q~FEB?|HuUGMz#IAA@? zYIZ)0N|Q|+aew6(`ap8V#pQyq$MA$M43hjsCMHmTu7%OnvI$x=>SKL0^`n`eqTw367_DUurIu zJ#p`c-^n*K-@dTcIG#53&E7?ETq@`0J@uWLeY)`XQtwS>G68E9dRB8?S*lgYF=Nv& zzguNDdHud9l~x3*9R0R2`_}CX>Rm@`Ew6t6k#f5BV zo-#Mc$@|O&nzfvJ{pIaT=5IRK!Zh>qTpu}3j{X&tuJTztc-gv}yqR-XxgP8mi2L$Y z!0hO>$2Vpk^-2H8**^XEFG#N0fAH=7bvH_E&Pcsiu342D_WeJqC{&pdx*|! z3$!(S>aOE^J$A-ijS`o(qD{qOD^(;Vq9+tdoc*>qa&yjWXYVprtpx{NI)t80RSRb? ziO9FHeR0Ba(@kB|ucmWe-~@hKevTWGt|{MHl)!Xof{gor22qoDHLM

=jdR?gH9Sw#hKcC;~x|NV)X3g3yCpOJzTJhVkb%qaOnYa8dT<}Y16KBJv z6U7T|z2g&BTxL6`o(DNi%iaC;X2*&CLs{Jrv+4nt#8;#5;xj$M*LQto@r7AWcoNc5Q+ zCMYFroZK3>W7&?PuSp$NhZ@9+J8CoH91Gfwa?Nc{<%K0CY*~2pTUYq%hb^5)5=|H1 z4)QSLC@Nn3*5JCN?(o2*ZLW3h6bzR(dj zKmMiunUe3B(k#tO?mDfpPtfU}<7N9-*11IAe}T*aRY8&FrKddE4p-|`mg(PZK6ZZj zqN!qzWeRtXvCX~Ml23d|J0gz&c@OK?CD9%PX<_?lh}) zAMv;9=<9Y8xi9g3xktgmJ6&;HY~3+^{!!`2W^zn(W@bOTY)_F(T&%^GTir=IYdkie zusKz7oufrC^Z6@#l!#=BU9z`h&cmHQuk4&@niA|MKj-t7KCh@7&o1^|-Zm-roc?@$ zuHBAd5#7o23pr-ZP;JqjWAR~u>Z1aM=BXU){NG_ERfGG4NxBga>u#E_lnOXi=D9zi zPb%PaSxa#`pee4n*H+Wv& zQOYmmb+C3=!LqhtMWWLhb+JrmUH_EX-y*s;eUMD}lO$##w4v!&T$}UZzkgx%-?#VI zbMNf)WioXv;WDk*7j$sKM&qa9ce7<;&GYAlFe&S>>Ob=|IrURa>>yE#u{5OacyPLrij&PF`*2-H?Bvu3_~6ae%>gcXC!ehjl;yn?7rN(uL>0i`1)UY2mbibU~*{7SfJbY`uIUI61oD%;hO;AFcUa7nLvy z@Ekr`&{PqX_T35OK-O=*y;>#*7C!rG;dB2?8;5|FZ{oLY{dl(<^ zs&?m@v}GiwY*n67v&yANQOrMb(HEXTYeVaXtp*0~PBTQb)s#}roeTqFRY)Nma>VJIW$Gc1f23}@i@(Pl{H3jQK0y?D8)eG#VdB( zXvH|QW*s>@J7{H~c*um)@MyoR)TNuv?^W;IHMQc?P8aLmsSd7E0q0`RZmtR9l+g3N zbZXw6#Y|$-W{-WdI=9%dw0-fC*uCiTd9P3f{>);j_f^JEf7_~xPBZ1l2g<0LGV zc`o|ugPaT#-MRaj!I}Kf>%|tc?j>xQnkuY%=$6kVk3Tvmbhhu`%DK6k*Mt45ZuM(v z*2{e+|3ti%)+mZcUv5eH{Z^}B+2cf!&fT7EQ?#c^NU<|CpZ<6fsj566d*X?-!A!&Q zNM!Yt0eSTpmu-NRzG23?)fXCGFc<>Cu$UZ&BZEdTx` zbg$We2KMcByK^7D+;Q~dSqOR~9(2jOqr7OO85V+gt^HaAUj z`P+BA%ajeitSP+3u;-l3^Gog7#Y_cHJl{Jfl}=}1oF-YB*7mD`qnG2ds;H)HhOF_d z372{T4zis|JGaH=O~qt`TbDEMd%cXl%&s;0^umVe_hkP;+kX%HFK=Ixe_Pk`>%P)y ztX<7FdXfsBUkGTl?ET0h)D*ZQ_~nF(gsIVq?Y#99@gINQ-?C$id>b~^Jo&t!SAr`oXwQjE$vkc$ z#q8|t{aidfojn|fGo_Md&(=KB(sJ2k!pdYl1L-CcbuY$>)Ms454V(CMzIoL)?$i_H zR$3fYc6U$)%$gmSRWMO!k$At>>_tusmDGyuML8yX$~pA?F0?E;EWbS8Gy zU$px%SwJLomWh)H>m$d{S;{t79ZMdXE%4xYwje=w%7xBNJqt^YWMw;g9PnOq)GEN; zEJ5Um$%0!x39H(td{03xFJ#SGrId=>{x0h2QMGb8HTl^OgA>nK5;tzV&snOiHZ57^ z#qo^HnQPRW>$y$-ghjL&p5O61(?gY`^zbdNv&E(d_qg7wQ2hO~G)bpuA;ZIe|E69x zd;fd=`wyaCOLi~L*=Kc6Z<5IFy%KLN8B6k>+&*-Ar%0Q6f1w7~B*hk~)DH_a9?TG| zw+fO&%XoXr7ra?4J1czCk7Q60@goPhwZ$>@u?Ith&Wy+R-rnAxckTDIx3@Rng)q0h zy}dp8V;P8ZcX!!av*TLFn@fK;Z}nSaAy&S-?CtHX``+#@dwXO4$LQ^OAQiVaneIst zY~NARwf~$d)5+#=`DTbj{-@gz28ddI{jz}mJTDIBe0UB2;}lm5&$jmuMUu+@GaU1& yk&BtpIJruFW+(e1YtuScH@RKRn@{&A=QCYp_AI^gYyW?Se*6Fb7?>IU-vj_-99KgC literal 0 HcmV?d00001 diff --git a/docs/adding-boards-and-tools.md b/docs/adding-boards-and-tools.md new file mode 100644 index 0000000..a7e9eaa --- /dev/null +++ b/docs/adding-boards-and-tools.md @@ -0,0 +1,116 @@ +# Adding Boards and Tools — ZeroClaw Hardware Guide + +This guide explains how to add new hardware boards and custom tools to ZeroClaw. + +## Quick Start: Add a Board via CLI + +```bash +# Add a board (updates ~/.zeroclaw/config.toml) +zeroclaw peripheral add nucleo-f401re /dev/ttyACM0 +zeroclaw peripheral add arduino-uno /dev/cu.usbmodem12345 +zeroclaw peripheral add rpi-gpio native # for Raspberry Pi GPIO (Linux) + +# Restart daemon to apply +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +## Supported Boards + +| Board | Transport | Path Example | +|-----------------|-----------|---------------------------| +| nucleo-f401re | serial | /dev/ttyACM0, /dev/cu.usbmodem* | +| arduino-uno | serial | /dev/ttyACM0, /dev/cu.usbmodem* | +| arduino-uno-q | bridge | (Uno Q IP) | +| rpi-gpio | native | native | +| esp32 | serial | /dev/ttyUSB0 | + +## Manual Config + +Edit `~/.zeroclaw/config.toml`: + +```toml +[peripherals] +enabled = true +datasheet_dir = "docs/datasheets" # optional: RAG for "turn on red led" → pin 13 + +[[peripherals.boards]] +board = "nucleo-f401re" +transport = "serial" +path = "/dev/ttyACM0" +baud = 115200 + +[[peripherals.boards]] +board = "arduino-uno" +transport = "serial" +path = "/dev/cu.usbmodem12345" +baud = 115200 +``` + +## Adding a Datasheet (RAG) + +Place `.md` or `.txt` files in `docs/datasheets/` (or your `datasheet_dir`). Name files by board: `nucleo-f401re.md`, `arduino-uno.md`. + +### Pin Aliases (Recommended) + +Add a `## Pin Aliases` section so the agent can map "red led" → pin 13: + +```markdown +# My Board + +## Pin Aliases + +| alias | pin | +|-------------|-----| +| red_led | 13 | +| builtin_led | 13 | +| user_led | 5 | +``` + +Or use key-value format: + +```markdown +## Pin Aliases +red_led: 13 +builtin_led: 13 +``` + +### PDF Datasheets + +With the `rag-pdf` feature, ZeroClaw can index PDF files: + +```bash +cargo build --features hardware,rag-pdf +``` + +Place PDFs in the datasheet directory. They are extracted and chunked for RAG. + +## Adding a New Board Type + +1. **Create a datasheet** — `docs/datasheets/my-board.md` with pin aliases and GPIO info. +2. **Add to config** — `zeroclaw peripheral add my-board /dev/ttyUSB0` +3. **Implement a peripheral** (optional) — For custom protocols, implement the `Peripheral` trait in `src/peripherals/` and register in `create_peripheral_tools`. + +See `docs/hardware-peripherals-design.md` for the full design. + +## Adding a Custom Tool + +1. Implement the `Tool` trait in `src/tools/`. +2. Register in `create_peripheral_tools` (for hardware tools) or the agent tool registry. +3. Add a tool description to the agent's `tool_descs` in `src/agent/loop_.rs`. + +## CLI Reference + +| Command | Description | +|---------|-------------| +| `zeroclaw peripheral list` | List configured boards | +| `zeroclaw peripheral add ` | Add board (writes config) | +| `zeroclaw peripheral flash` | Flash Arduino firmware | +| `zeroclaw peripheral flash-nucleo` | Flash Nucleo firmware | +| `zeroclaw hardware discover` | List USB devices | +| `zeroclaw hardware info` | Chip info via probe-rs | + +## Troubleshooting + +- **Serial port not found** — On macOS use `/dev/cu.usbmodem*`; on Linux use `/dev/ttyACM0` or `/dev/ttyUSB0`. +- **Build with hardware** — `cargo build --features hardware` +- **Probe-rs for Nucleo** — `cargo build --features hardware,probe` diff --git a/docs/arduino-uno-q-setup.md b/docs/arduino-uno-q-setup.md new file mode 100644 index 0000000..8e170e8 --- /dev/null +++ b/docs/arduino-uno-q-setup.md @@ -0,0 +1,217 @@ +# ZeroClaw on Arduino Uno Q — Step-by-Step Guide + +Run ZeroClaw on the Arduino Uno Q's Linux side. Telegram works over WiFi; GPIO control uses the Bridge (requires a minimal App Lab app). + +--- + +## What's Included (No Code Changes Needed) + +ZeroClaw includes everything needed for Arduino Uno Q. **Clone the repo and follow this guide — no patches or custom code required.** + +| Component | Location | Purpose | +|-----------|----------|---------| +| Bridge app | `firmware/zeroclaw-uno-q-bridge/` | MCU sketch + Python socket server (port 9999) for GPIO | +| Bridge tools | `src/peripherals/uno_q_bridge.rs` | `gpio_read` / `gpio_write` tools that talk to the Bridge over TCP | +| Setup command | `src/peripherals/uno_q_setup.rs` | `zeroclaw peripheral setup-uno-q` deploys the Bridge via scp + arduino-app-cli | +| Config schema | `board = "arduino-uno-q"`, `transport = "bridge"` | Supported in `config.toml` | + +Build with `--features hardware` (or the default features) to include Uno Q support. + +--- + +## Prerequisites + +- Arduino Uno Q with WiFi configured +- Arduino App Lab installed on your Mac (for initial setup and deployment) +- API key for LLM (OpenRouter, etc.) + +--- + +## Phase 1: Initial Uno Q Setup (One-Time) + +### 1.1 Configure Uno Q via App Lab + +1. Download [Arduino App Lab](https://docs.arduino.cc/software/app-lab/) (AppImage on Linux). +2. Connect Uno Q via USB, power it on. +3. Open App Lab, connect to the board. +4. Follow the setup wizard: + - Set username and password (for SSH) + - Configure WiFi (SSID, password) + - Apply any firmware updates +5. Note the IP address shown (e.g. `arduino@192.168.1.42`) or find it later via `ip addr show` in App Lab's terminal. + +### 1.2 Verify SSH Access + +```bash +ssh arduino@ +# Enter the password you set +``` + +--- + +## Phase 2: Install ZeroClaw on Uno Q + +### Option A: Build on the Device (Simpler, ~20–40 min) + +```bash +# SSH into Uno Q +ssh arduino@ + +# Install Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +source ~/.cargo/env + +# Install build deps (Debian) +sudo apt-get update +sudo apt-get install -y pkg-config libssl-dev + +# Clone zeroclaw (or scp your project) +git clone https://github.com/theonlyhennygod/zeroclaw.git +cd zeroclaw + +# Build (takes ~15–30 min on Uno Q) +cargo build --release + +# Install +sudo cp target/release/zeroclaw /usr/local/bin/ +``` + +### Option B: Cross-Compile on Mac (Faster) + +```bash +# On your Mac — add aarch64 target +rustup target add aarch64-unknown-linux-gnu + +# Install cross-compiler (macOS; required for linking) +brew tap messense/macos-cross-toolchains +brew install aarch64-unknown-linux-gnu + +# Build +CC_aarch64_unknown_linux_gnu=aarch64-unknown-linux-gnu-gcc cargo build --release --target aarch64-unknown-linux-gnu + +# Copy to Uno Q +scp target/aarch64-unknown-linux-gnu/release/zeroclaw arduino@:~/ +ssh arduino@ "sudo mv ~/zeroclaw /usr/local/bin/" +``` + +If cross-compile fails, use Option A and build on the device. + +--- + +## Phase 3: Configure ZeroClaw + +### 3.1 Run Onboard (or Create Config Manually) + +```bash +ssh arduino@ + +# Quick config +zeroclaw onboard --api-key YOUR_OPENROUTER_KEY --provider openrouter + +# Or create config manually +mkdir -p ~/.zeroclaw/workspace +nano ~/.zeroclaw/config.toml +``` + +### 3.2 Minimal config.toml + +```toml +api_key = "YOUR_OPENROUTER_API_KEY" +default_provider = "openrouter" +default_model = "anthropic/claude-sonnet-4" + +[peripherals] +enabled = false +# GPIO via Bridge requires Phase 4 + +[channels_config.telegram] +bot_token = "YOUR_TELEGRAM_BOT_TOKEN" +allowed_users = ["*"] + +[gateway] +host = "127.0.0.1" +port = 8080 +allow_public_bind = false + +[agent] +compact_context = true +``` + +--- + +## Phase 4: Run ZeroClaw Daemon + +```bash +ssh arduino@ + +# Run daemon (Telegram polling works over WiFi) +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +**At this point:** Telegram chat works. Send messages to your bot — ZeroClaw responds. No GPIO yet. + +--- + +## Phase 5: GPIO via Bridge (ZeroClaw Handles It) + +ZeroClaw includes the Bridge app and setup command. + +### 5.1 Deploy Bridge App + +**From your Mac** (with zeroclaw repo): +```bash +zeroclaw peripheral setup-uno-q --host 192.168.0.48 +``` + +**From the Uno Q** (SSH'd in): +```bash +zeroclaw peripheral setup-uno-q +``` + +This copies the Bridge app to `~/ArduinoApps/zeroclaw-uno-q-bridge` and starts it. + +### 5.2 Add to config.toml + +```toml +[peripherals] +enabled = true + +[[peripherals.boards]] +board = "arduino-uno-q" +transport = "bridge" +``` + +### 5.3 Run ZeroClaw + +```bash +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +Now when you message your Telegram bot *"Turn on the LED"* or *"Set pin 13 high"*, ZeroClaw uses `gpio_write` via the Bridge. + +--- + +## Summary: Commands Start to End + +| Step | Command | +|------|---------| +| 1 | Configure Uno Q in App Lab (WiFi, SSH) | +| 2 | `ssh arduino@` | +| 3 | `curl -sSf https://sh.rustup.rs \| sh -s -- -y && source ~/.cargo/env` | +| 4 | `sudo apt-get install -y pkg-config libssl-dev` | +| 5 | `git clone https://github.com/theonlyhennygod/zeroclaw.git && cd zeroclaw` | +| 6 | `cargo build --release --no-default-features` | +| 7 | `zeroclaw onboard --api-key KEY --provider openrouter` | +| 8 | Edit `~/.zeroclaw/config.toml` (add Telegram bot_token) | +| 9 | `zeroclaw daemon --host 127.0.0.1 --port 8080` | +| 10 | Message your Telegram bot — it responds | + +--- + +## Troubleshooting + +- **"command not found: zeroclaw"** — Use full path: `/usr/local/bin/zeroclaw` or ensure `~/.cargo/bin` is in PATH. +- **Telegram not responding** — Check bot_token, allowed_users, and that the Uno Q has internet (WiFi). +- **Out of memory** — Use `--no-default-features` to reduce binary size; consider `compact_context = true`. +- **GPIO commands ignored** — Ensure Bridge app is running (`zeroclaw peripheral setup-uno-q` deploys and starts it). Config must have `board = "arduino-uno-q"` and `transport = "bridge"`. +- **LLM provider (GLM/Zhipu)** — Use `default_provider = "glm"` or `"zhipu"` with `GLM_API_KEY` in env or config. ZeroClaw uses the correct v4 endpoint. diff --git a/docs/datasheets/arduino-uno.md b/docs/datasheets/arduino-uno.md new file mode 100644 index 0000000..be4d4fc --- /dev/null +++ b/docs/datasheets/arduino-uno.md @@ -0,0 +1,37 @@ +# Arduino Uno + +## Pin Aliases + +| alias | pin | +|-------------|-----| +| red_led | 13 | +| builtin_led | 13 | +| user_led | 13 | + +## Overview + +Arduino Uno is a microcontroller board based on the ATmega328P. It has 14 digital I/O pins (0–13) and 6 analog inputs (A0–A5). + +## Digital Pins + +- **Pins 0–13:** Digital I/O. Can be INPUT or OUTPUT. +- **Pin 13:** Built-in LED (onboard). Connect LED to GND or use for output. +- **Pins 0–1:** Also used for Serial (RX/TX). Avoid if using Serial. + +## GPIO + +- `digitalWrite(pin, HIGH)` or `digitalWrite(pin, LOW)` for output. +- `digitalRead(pin)` for input (returns 0 or 1). +- Pin numbers in ZeroClaw protocol: 0–13. + +## Serial + +- UART on pins 0 (RX) and 1 (TX). +- USB via ATmega16U2 or CH340 (clones). +- Baud rate: 115200 for ZeroClaw firmware. + +## ZeroClaw Tools + +- `gpio_read`: Read pin value (0 or 1). +- `gpio_write`: Set pin high (1) or low (0). +- `arduino_upload`: Agent generates full Arduino sketch code; ZeroClaw compiles and uploads it via arduino-cli. Use for "make a heart", custom patterns — agent writes the code, no manual editing. Pin 13 = built-in LED. diff --git a/docs/datasheets/esp32.md b/docs/datasheets/esp32.md new file mode 100644 index 0000000..8cb453d --- /dev/null +++ b/docs/datasheets/esp32.md @@ -0,0 +1,22 @@ +# ESP32 GPIO Reference + +## Pin Aliases + +| alias | pin | +|-------------|-----| +| builtin_led | 2 | +| red_led | 2 | + +## Common pins (ESP32 / ESP32-C3) + +- **GPIO 2**: Built-in LED on many dev boards (output) +- **GPIO 13**: General-purpose output +- **GPIO 21/20**: Often used for UART0 TX/RX (avoid if using serial) + +## Protocol + +ZeroClaw host sends JSON over serial (115200 baud): +- `gpio_read`: `{"id":"1","cmd":"gpio_read","args":{"pin":13}}` +- `gpio_write`: `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}` + +Response: `{"id":"1","ok":true,"result":"0"}` or `{"id":"1","ok":true,"result":"done"}` diff --git a/docs/datasheets/nucleo-f401re.md b/docs/datasheets/nucleo-f401re.md new file mode 100644 index 0000000..22b1e93 --- /dev/null +++ b/docs/datasheets/nucleo-f401re.md @@ -0,0 +1,16 @@ +# Nucleo-F401RE GPIO + +## Pin Aliases + +| alias | pin | +|-------------|-----| +| red_led | 13 | +| user_led | 13 | +| ld2 | 13 | +| builtin_led | 13 | + +## GPIO + +Pin 13: User LED (LD2) +- Output, active high +- PA5 on STM32F401 diff --git a/docs/hardware-peripherals-design.md b/docs/hardware-peripherals-design.md new file mode 100644 index 0000000..87f61bf --- /dev/null +++ b/docs/hardware-peripherals-design.md @@ -0,0 +1,324 @@ +# Hardware Peripherals Design — ZeroClaw + +ZeroClaw enables microcontrollers (MCUs) and Single Board Computers (SBCs) to **dynamically interpret natural language commands**, generate hardware-specific code, and execute peripheral interactions in real-time. + +## 1. Vision + +**Goal:** ZeroClaw acts as a hardware-aware AI agent that: +- Receives natural language triggers (e.g. "Move X arm", "Turn on LED") via channels (WhatsApp, Telegram) +- Fetches accurate hardware documentation (datasheets, register maps) +- Synthesizes Rust code/logic using an LLM (Gemini, local open-source models) +- Executes the logic to manipulate peripherals (GPIO, I2C, SPI) +- Persists optimized code for future reuse + +**Mental model:** ZeroClaw = brain that understands hardware. Peripherals = arms and legs it controls. + +## 2. Two Modes of Operation + +### Mode 1: Edge-Native (Standalone) + +**Target:** Wi-Fi-enabled boards (ESP32, Raspberry Pi). + +ZeroClaw runs **directly on the device**. The board spins up a gRPC/nanoRPC server and communicates with peripherals locally. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ZeroClaw on ESP32 / Raspberry Pi (Edge-Native) │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────────┐ │ +│ │ Channels │───►│ Agent Loop │───►│ RAG: datasheets, register maps │ │ +│ │ WhatsApp │ │ (LLM calls) │ │ → LLM context │ │ +│ │ Telegram │ └──────┬───────┘ └─────────────────────────────────┘ │ +│ └─────────────┘ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐│ +│ │ Code synthesis → Wasm / dynamic exec → GPIO / I2C / SPI → persist ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ gRPC/nanoRPC server ◄──► Peripherals (GPIO, I2C, SPI, sensors, actuators) │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +**Workflow:** +1. User sends WhatsApp: *"Turn on LED on pin 13"* +2. ZeroClaw fetches board-specific docs (e.g. ESP32 GPIO mapping) +3. LLM synthesizes Rust code +4. Code runs in a sandbox (Wasm or dynamic linking) +5. GPIO is toggled; result returned to user +6. Optimized code is persisted for future "Turn on LED" requests + +**All happens on-device.** No host required. + +### Mode 2: Host-Mediated (Development / Debugging) + +**Target:** Hardware connected via USB / J-Link / Aardvark to a host (macOS, Linux). + +ZeroClaw runs on the **host** and maintains a hardware-aware link to the target. Used for development, introspection, and flashing. + +``` +┌─────────────────────┐ ┌──────────────────────────────────┐ +│ ZeroClaw on Mac │ USB / J-Link / │ STM32 Nucleo-F401RE │ +│ │ Aardvark │ (or other MCU) │ +│ - Channels │ ◄────────────────► │ - Memory map │ +│ - LLM │ │ - Peripherals (GPIO, ADC, I2C) │ +│ - Hardware probe │ VID/PID │ - Flash / RAM │ +│ - Flash / debug │ discovery │ │ +└─────────────────────┘ └──────────────────────────────────┘ +``` + +**Workflow:** +1. User sends Telegram: *"What are the readable memory addresses on this USB device?"* +2. ZeroClaw identifies connected hardware (VID/PID, architecture) +3. Performs memory mapping; suggests available address spaces +4. Returns result to user + +**Or:** +1. User: *"Flash this firmware to the Nucleo"* +2. ZeroClaw writes/flashes via OpenOCD or probe-rs +3. Confirms success + +**Or:** +1. ZeroClaw auto-discovers: *"STM32 Nucleo on /dev/ttyACM0, ARM Cortex-M4"* +2. Suggests: *"I can read/write GPIO, ADC, flash. What would you like to do?"* + +--- + +### Mode Comparison + +| Aspect | Edge-Native | Host-Mediated | +|------------------|--------------------------------|----------------------------------| +| ZeroClaw runs on | Device (ESP32, RPi) | Host (Mac, Linux) | +| Hardware link | Local (GPIO, I2C, SPI) | USB, J-Link, Aardvark | +| LLM | On-device or cloud (Gemini) | Host (cloud or local) | +| Use case | Production, standalone | Dev, debug, introspection | +| Channels | WhatsApp, etc. (via WiFi) | Telegram, CLI, etc. | + +## 3. Legacy / Simpler Modes (Pre-LLM-on-Edge) + +For boards without WiFi or before full Edge-Native is ready: + +### Mode A: Host + Remote Peripheral (STM32 via serial) + +Host runs ZeroClaw; peripheral runs minimal firmware. Simple JSON over serial. + +### Mode B: RPi as Host (Native GPIO) + +ZeroClaw on Pi; GPIO via rppal or sysfs. No separate firmware. + +## 4. Technical Requirements + +| Requirement | Description | +|-------------|-------------| +| **Language** | Pure Rust. `no_std` where applicable for embedded targets (STM32, ESP32). | +| **Communication** | Lightweight gRPC or nanoRPC stack for low-latency command processing. | +| **Dynamic execution** | Safely run LLM-generated logic on-the-fly: Wasm runtime for isolation, or dynamic linking where supported. | +| **Documentation retrieval** | RAG (Retrieval-Augmented Generation) pipeline to feed datasheet snippets, register maps, and pinouts into LLM context. | +| **Hardware discovery** | VID/PID-based identification for USB devices; architecture detection (ARM Cortex-M, RISC-V, etc.). | + +### RAG Pipeline (Datasheet Retrieval) + +- **Index:** Datasheets, reference manuals, register maps (PDF → chunks, embeddings). +- **Retrieve:** On user query ("turn on LED"), fetch relevant snippets (e.g. GPIO section for target board). +- **Inject:** Add to LLM system prompt or context. +- **Result:** LLM generates accurate, board-specific code. + +### Dynamic Execution Options + +| Option | Pros | Cons | +|-------|------|------| +| **Wasm** | Sandboxed, portable, no FFI | Overhead; limited HW access from Wasm | +| **Dynamic linking** | Native speed, full HW access | Platform-specific; security concerns | +| **Interpreted DSL** | Safe, auditable | Slower; limited expressiveness | +| **Pre-compiled templates** | Fast, secure | Less flexible; requires template library | + +**Recommendation:** Start with pre-compiled templates + parameterization; evolve to Wasm for user-defined logic once stable. + +## 5. CLI and Config + +### CLI Flags + +```bash +# Edge-Native: run on device (ESP32, RPi) +zeroclaw agent --mode edge + +# Host-Mediated: connect to USB/J-Link target +zeroclaw agent --peripheral nucleo-f401re:/dev/ttyACM0 +zeroclaw agent --probe jlink + +# Hardware introspection +zeroclaw hardware discover +zeroclaw hardware introspect /dev/ttyACM0 +``` + +### Config (config.toml) + +```toml +[peripherals] +enabled = true +mode = "host" # "edge" | "host" +datasheet_dir = "docs/datasheets" # RAG: board-specific docs for LLM context + +[[peripherals.boards]] +board = "nucleo-f401re" +transport = "serial" +path = "/dev/ttyACM0" +baud = 115200 + +[[peripherals.boards]] +board = "rpi-gpio" +transport = "native" + +[[peripherals.boards]] +board = "esp32" +transport = "wifi" +# Edge-Native: ZeroClaw runs on ESP32 +``` + +## 6. Architecture: Peripheral as Extension Point + +### New Trait: `Peripheral` + +```rust +/// A hardware peripheral that exposes capabilities as tools. +#[async_trait] +pub trait Peripheral: Send + Sync { + fn name(&self) -> &str; + fn board_type(&self) -> &str; // e.g. "nucleo-f401re", "rpi-gpio" + async fn connect(&mut self) -> anyhow::Result<()>; + async fn disconnect(&mut self) -> anyhow::Result<()>; + async fn health_check(&self) -> bool; + /// Tools this peripheral provides (gpio_read, gpio_write, sensor_read, etc.) + fn tools(&self) -> Vec>; +} +``` + +### Flow + +1. **Startup:** ZeroClaw loads config, sees `peripherals.boards`. +2. **Connect:** For each board, create a `Peripheral` impl, call `connect()`. +3. **Tools:** Collect tools from all connected peripherals; merge with default tools. +4. **Agent loop:** Agent can call `gpio_write`, `sensor_read`, etc. — these delegate to the peripheral. +5. **Shutdown:** Call `disconnect()` on each peripheral. + +### Board Support + +| Board | Transport | Firmware / Driver | Tools | +|--------------------|-----------|------------------------|--------------------------| +| nucleo-f401re | serial | Zephyr / Embassy | gpio_read, gpio_write, adc_read | +| rpi-gpio | native | rppal or sysfs | gpio_read, gpio_write | +| esp32 | serial/ws | ESP-IDF / Embassy | gpio, wifi, mqtt | + +## 7. Communication Protocols + +### gRPC / nanoRPC (Edge-Native, Host-Mediated) + +For low-latency, typed RPC between ZeroClaw and peripherals: + +- **nanoRPC** or **tonic** (gRPC): Protobuf-defined services. +- Methods: `GpioWrite`, `GpioRead`, `I2cTransfer`, `SpiTransfer`, `MemoryRead`, `FlashWrite`, etc. +- Enables streaming, bidirectional calls, and code generation from `.proto` files. + +### Serial Fallback (Host-Mediated, legacy) + +Simple JSON over serial for boards without gRPC support: + +**Request (host → peripheral):** +```json +{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}} +``` + +**Response (peripheral → host):** +```json +{"id":"1","ok":true,"result":"done"} +``` + +## 8. Firmware (Separate Repo or Crate) + +- **zeroclaw-firmware** or **zeroclaw-peripheral** — a separate crate/workspace. +- Targets: `thumbv7em-none-eabihf` (STM32), `armv7-unknown-linux-gnueabihf` (RPi), etc. +- Uses `embassy` or Zephyr for STM32. +- Implements the protocol above. +- User flashes this to the board; ZeroClaw connects and discovers capabilities. + +## 9. Implementation Phases + +### Phase 1: Skeleton ✅ (Done) + +- [x] Add `Peripheral` trait, config schema, CLI (`zeroclaw peripheral list/add`) +- [x] Add `--peripheral` flag to agent +- [x] Document in AGENTS.md + +### Phase 2: Host-Mediated — Hardware Discovery ✅ (Done) + +- [x] `zeroclaw hardware discover`: enumerate USB devices (VID/PID) +- [x] Board registry: map VID/PID → architecture, name (e.g. Nucleo-F401RE) +- [x] `zeroclaw hardware introspect `: memory map, peripheral list + +### Phase 3: Host-Mediated — Serial / J-Link + +- [x] `SerialPeripheral` for STM32 over USB CDC +- [ ] probe-rs or OpenOCD integration for flash/debug +- [x] Tools: `gpio_read`, `gpio_write` (memory_read, flash_write in future) + +### Phase 4: RAG Pipeline ✅ (Done) + +- [x] Datasheet index (markdown/text → chunks) +- [x] Retrieve-and-inject into LLM context on hardware-related queries +- [x] Board-specific prompt augmentation + +**Usage:** Add `datasheet_dir = "docs/datasheets"` to `[peripherals]` in config.toml. Place `.md` or `.txt` files named by board (e.g. `nucleo-f401re.md`, `rpi-gpio.md`). Files in `_generic/` or named `generic.md` apply to all boards. Chunks are retrieved by keyword match and injected into the user message context. + +### Phase 5: Edge-Native — RPi ✅ (Done) + +- [x] ZeroClaw on Raspberry Pi (native GPIO via rppal) +- [ ] gRPC/nanoRPC server for local peripheral access +- [ ] Code persistence (store synthesized snippets) + +### Phase 6: Edge-Native — ESP32 + +- [x] Host-mediated ESP32 (serial transport) — same JSON protocol as STM32 +- [x] `zeroclaw-esp32` firmware crate (`firmware/zeroclaw-esp32`) — GPIO over UART +- [x] ESP32 in hardware registry (CH340 VID/PID) +- [ ] ZeroClaw *on* ESP32 (WiFi + LLM, edge-native) — future +- [ ] Wasm or template-based execution for LLM-generated logic + +**Usage:** Flash `firmware/zeroclaw-esp32` to ESP32, add `board = "esp32"`, `transport = "serial"`, `path = "/dev/ttyUSB0"` to config. + +### Phase 7: Dynamic Execution (LLM-Generated Code) + +- [ ] Template library: parameterized GPIO/I2C/SPI snippets +- [ ] Optional: Wasm runtime for user-defined logic (sandboxed) +- [ ] Persist and reuse optimized code paths + +## 10. Security Considerations + +- **Serial path:** Validate `path` is in allowlist (e.g. `/dev/ttyACM*`, `/dev/ttyUSB*`); never arbitrary paths. +- **GPIO:** Restrict which pins are exposed; avoid power/reset pins. +- **No secrets on peripheral:** Firmware should not store API keys; host handles auth. + +## 11. Non-Goals (For Now) + +- Running full ZeroClaw *on* bare STM32 (no WiFi, limited RAM) — use Host-Mediated instead +- Real-time guarantees — peripherals are best-effort +- Arbitrary native code execution from LLM — prefer Wasm or templates + +## 12. Related Documents + +- [adding-boards-and-tools.md](./adding-boards-and-tools.md) — How to add boards and datasheets +- [network-deployment.md](./network-deployment.md) — RPi and network deployment + +## 13. References + +- [Zephyr RTOS Rust support](https://docs.zephyrproject.org/latest/develop/languages/rust/index.html) +- [Embassy](https://embassy.dev/) — async embedded framework +- [rppal](https://github.com/golemparts/rppal) — Raspberry Pi GPIO in Rust +- [STM32 Nucleo-F401RE](https://www.st.com/en/evaluation-tools/nucleo-f401re.html) +- [tonic](https://github.com/hyperium/tonic) — gRPC for Rust +- [probe-rs](https://probe.rs/) — ARM debug probe, flash, memory access +- [nusb](https://github.com/nic-hartley/nusb) — USB device enumeration (VID/PID) + +## 14. Raw Prompt Summary + +> *"Boards like ESP, Raspberry Pi, or boards with WiFi can connect to an LLM (Gemini or open-source). ZeroClaw runs on the device, creates its own gRPC, spins it up, and communicates with peripherals. User asks via WhatsApp: 'move X arm' or 'turn on LED'. ZeroClaw gets accurate documentation, writes code, executes it, stores it optimally, runs it, and turns on the LED — all on the development board.* +> +> *For STM Nucleo connected via USB/J-Link/Aardvark to my Mac: ZeroClaw from my Mac accesses the hardware, installs or writes what it wants on the device, and returns the result. Example: 'Hey ZeroClaw, what are the available/readable addresses on this USB device?' It can figure out what's connected where and suggest."* diff --git a/docs/network-deployment.md b/docs/network-deployment.md new file mode 100644 index 0000000..5fdc7fa --- /dev/null +++ b/docs/network-deployment.md @@ -0,0 +1,182 @@ +# Network Deployment — ZeroClaw on Raspberry Pi and Local Network + +This document covers deploying ZeroClaw on a Raspberry Pi or other host on your local network, with Telegram and optional webhook channels. + +--- + +## 1. Overview + +| Mode | Inbound port needed? | Use case | +|------|----------------------|----------| +| **Telegram polling** | No | ZeroClaw polls Telegram API; works from anywhere | +| **Discord/Slack** | No | Same — outbound only | +| **Gateway webhook** | Yes | POST /webhook, WhatsApp, etc. need a public URL | +| **Gateway pairing** | Yes | If you pair clients via the gateway | + +**Key:** Telegram, Discord, and Slack use **long-polling** — ZeroClaw makes outbound requests. No port forwarding or public IP required. + +--- + +## 2. ZeroClaw on Raspberry Pi + +### 2.1 Prerequisites + +- Raspberry Pi (3/4/5) with Raspberry Pi OS +- USB peripherals (Arduino, Nucleo) if using serial transport +- Optional: `rppal` for native GPIO (`peripheral-rpi` feature) + +### 2.2 Install + +```bash +# Build for RPi (or cross-compile from host) +cargo build --release --features hardware + +# Or install via your preferred method +``` + +### 2.3 Config + +Edit `~/.zeroclaw/config.toml`: + +```toml +[peripherals] +enabled = true + +[[peripherals.boards]] +board = "rpi-gpio" +transport = "native" + +# Or Arduino over USB +[[peripherals.boards]] +board = "arduino-uno" +transport = "serial" +path = "/dev/ttyACM0" +baud = 115200 + +[channels_config.telegram] +bot_token = "YOUR_BOT_TOKEN" +allowed_users = ["*"] + +[gateway] +host = "127.0.0.1" +port = 8080 +allow_public_bind = false +``` + +### 2.4 Run Daemon (Local Only) + +```bash +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +- Gateway binds to `127.0.0.1` — not reachable from other machines +- Telegram channel works: ZeroClaw polls Telegram API (outbound) +- No firewall or port forwarding needed + +--- + +## 3. Binding to 0.0.0.0 (Local Network) + +To allow other devices on your LAN to hit the gateway (e.g. for pairing or webhooks): + +### 3.1 Option A: Explicit Opt-In + +```toml +[gateway] +host = "0.0.0.0" +port = 8080 +allow_public_bind = true +``` + +```bash +zeroclaw daemon --host 0.0.0.0 --port 8080 +``` + +**Security:** `allow_public_bind = true` exposes the gateway to your local network. Only use on trusted LANs. + +### 3.2 Option B: Tunnel (Recommended for Webhooks) + +If you need a **public URL** (e.g. WhatsApp webhook, external clients): + +1. Run gateway on localhost: + ```bash + zeroclaw daemon --host 127.0.0.1 --port 8080 + ``` + +2. Start a tunnel: + ```toml + [tunnel] + provider = "tailscale" # or "ngrok", "cloudflare" + ``` + Or use `zeroclaw tunnel` (see tunnel docs). + +3. ZeroClaw will refuse `0.0.0.0` unless `allow_public_bind = true` or a tunnel is active. + +--- + +## 4. Telegram Polling (No Inbound Port) + +Telegram uses **long-polling** by default: + +- ZeroClaw calls `https://api.telegram.org/bot{token}/getUpdates` +- No inbound port or public IP needed +- Works behind NAT, on RPi, in a home lab + +**Config:** + +```toml +[channels_config.telegram] +bot_token = "YOUR_BOT_TOKEN" +allowed_users = ["*"] # or specific @usernames / user IDs +``` + +Run `zeroclaw daemon` — Telegram channel starts automatically. + +--- + +## 5. Webhook Channels (WhatsApp, Custom) + +Webhook-based channels need a **public URL** so Meta (WhatsApp) or your client can POST events. + +### 5.1 Tailscale Funnel + +```toml +[tunnel] +provider = "tailscale" +``` + +Tailscale Funnel exposes your gateway via a `*.ts.net` URL. No port forwarding. + +### 5.2 ngrok + +```toml +[tunnel] +provider = "ngrok" +``` + +Or run ngrok manually: +```bash +ngrok http 8080 +# Use the HTTPS URL for your webhook +``` + +### 5.3 Cloudflare Tunnel + +Configure Cloudflare Tunnel to forward to `127.0.0.1:8080`, then set your webhook URL to the tunnel's public hostname. + +--- + +## 6. Checklist: RPi Deployment + +- [ ] Build with `--features hardware` (and `peripheral-rpi` if using native GPIO) +- [ ] Configure `[peripherals]` and `[channels_config.telegram]` +- [ ] Run `zeroclaw daemon --host 127.0.0.1 --port 8080` (Telegram works without 0.0.0.0) +- [ ] For LAN access: `--host 0.0.0.0` + `allow_public_bind = true` in config +- [ ] For webhooks: use Tailscale, ngrok, or Cloudflare tunnel + +--- + +## 7. References + +- [hardware-peripherals-design.md](./hardware-peripherals-design.md) — Peripherals design +- [adding-boards-and-tools.md](./adding-boards-and-tools.md) — Hardware setup and adding boards diff --git a/docs/nucleo-setup.md b/docs/nucleo-setup.md new file mode 100644 index 0000000..76e942e --- /dev/null +++ b/docs/nucleo-setup.md @@ -0,0 +1,147 @@ +# ZeroClaw on Nucleo-F401RE — Step-by-Step Guide + +Run ZeroClaw on your Mac or Linux host. Connect a Nucleo-F401RE via USB. Control GPIO (LED, pins) via Telegram or CLI. + +--- + +## Get Board Info via Telegram (No Firmware Needed) + +ZeroClaw can read chip info from the Nucleo over USB **without flashing any firmware**. Message your Telegram bot: + +- *"What board info do I have?"* +- *"Board info"* +- *"What hardware is connected?"* +- *"Chip info"* + +The agent uses the `hardware_board_info` tool to return chip name, architecture, and memory map. With the `probe` feature, it reads live data via USB/SWD; otherwise it returns static datasheet info. + +**Config:** Add Nucleo to `config.toml` first (so the agent knows which board to query): + +```toml +[[peripherals.boards]] +board = "nucleo-f401re" +transport = "serial" +path = "/dev/ttyACM0" +baud = 115200 +``` + +**CLI alternative:** + +```bash +cargo build --features hardware,probe +zeroclaw hardware info +zeroclaw hardware discover +``` + +--- + +## What's Included (No Code Changes Needed) + +ZeroClaw includes everything for Nucleo-F401RE: + +| Component | Location | Purpose | +|-----------|----------|---------| +| Firmware | `firmware/zeroclaw-nucleo/` | Embassy Rust — USART2 (115200), gpio_read, gpio_write | +| Serial peripheral | `src/peripherals/serial.rs` | JSON-over-serial protocol (same as Arduino/ESP32) | +| Flash command | `zeroclaw peripheral flash-nucleo` | Builds firmware, flashes via probe-rs | + +Protocol: newline-delimited JSON. Request: `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}`. Response: `{"id":"1","ok":true,"result":"done"}`. + +--- + +## Prerequisites + +- Nucleo-F401RE board +- USB cable (USB-A to Mini-USB; Nucleo has built-in ST-Link) +- For flashing: `cargo install probe-rs-tools --locked` (or use the [install script](https://probe.rs/docs/getting-started/installation/)) + +--- + +## Phase 1: Flash Firmware + +### 1.1 Connect Nucleo + +1. Connect Nucleo to your Mac/Linux via USB. +2. The board appears as a USB device (ST-Link). No separate driver needed on modern systems. + +### 1.2 Flash via ZeroClaw + +From the zeroclaw repo root: + +```bash +zeroclaw peripheral flash-nucleo +``` + +This builds `firmware/zeroclaw-nucleo` and runs `probe-rs run --chip STM32F401RETx`. The firmware runs immediately after flashing. + +### 1.3 Manual Flash (Alternative) + +```bash +cd firmware/zeroclaw-nucleo +cargo build --release --target thumbv7em-none-eabihf +probe-rs run --chip STM32F401RETx target/thumbv7em-none-eabihf/release/zeroclaw-nucleo +``` + +--- + +## Phase 2: Find Serial Port + +- **macOS:** `/dev/cu.usbmodem*` or `/dev/tty.usbmodem*` (e.g. `/dev/cu.usbmodem101`) +- **Linux:** `/dev/ttyACM0` (or check `dmesg` after plugging in) + +USART2 (PA2/PA3) is bridged to the ST-Link's virtual COM port, so the host sees one serial device. + +--- + +## Phase 3: Configure ZeroClaw + +Add to `~/.zeroclaw/config.toml`: + +```toml +[peripherals] +enabled = true + +[[peripherals.boards]] +board = "nucleo-f401re" +transport = "serial" +path = "/dev/cu.usbmodem101" # adjust to your port +baud = 115200 +``` + +--- + +## Phase 4: Run and Test + +```bash +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +Or use the agent directly: + +```bash +zeroclaw agent --message "Turn on the LED on pin 13" +``` + +Pin 13 = PA5 = User LED (LD2) on Nucleo-F401RE. + +--- + +## Summary: Commands + +| Step | Command | +|------|---------| +| 1 | Connect Nucleo via USB | +| 2 | `cargo install probe-rs --locked` | +| 3 | `zeroclaw peripheral flash-nucleo` | +| 4 | Add Nucleo to config.toml (path = your serial port) | +| 5 | `zeroclaw daemon` or `zeroclaw agent -m "Turn on LED"` | + +--- + +## Troubleshooting + +- **flash-nucleo unrecognized** — Build from repo: `cargo run --features hardware -- peripheral flash-nucleo`. The subcommand is only in the repo build, not in crates.io installs. +- **probe-rs not found** — `cargo install probe-rs-tools --locked` (the `probe-rs` crate is a library; the CLI is in `probe-rs-tools`) +- **No probe detected** — Ensure Nucleo is connected. Try another USB cable/port. +- **Serial port not found** — On Linux, add user to `dialout`: `sudo usermod -a -G dialout $USER`, then log out/in. +- **GPIO commands ignored** — Check `path` in config matches your serial port. Run `zeroclaw peripheral list` to verify. diff --git a/firmware/zeroclaw-arduino/zeroclaw-arduino.ino b/firmware/zeroclaw-arduino/zeroclaw-arduino.ino new file mode 100644 index 0000000..5e9c4ee --- /dev/null +++ b/firmware/zeroclaw-arduino/zeroclaw-arduino.ino @@ -0,0 +1,143 @@ +/* + * ZeroClaw Arduino Uno Firmware + * + * Listens for JSON commands on Serial (115200 baud), executes gpio_read/gpio_write, + * responds with JSON. Compatible with ZeroClaw SerialPeripheral protocol. + * + * Protocol (newline-delimited JSON): + * Request: {"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}} + * Response: {"id":"1","ok":true,"result":"done"} + * + * Arduino Uno: Pin 13 has built-in LED. Digital pins 0-13 supported. + * + * 1. Open in Arduino IDE + * 2. Select Board: Arduino Uno + * 3. Select correct Port (Tools -> Port) + * 4. Upload + */ + +#define BAUDRATE 115200 +#define MAX_LINE 256 + +char lineBuf[MAX_LINE]; +int lineLen = 0; + +// Parse integer from JSON: "pin":13 or "value":1 +int parseArg(const char* key, const char* json) { + char search[32]; + snprintf(search, sizeof(search), "\"%s\":", key); + const char* p = strstr(json, search); + if (!p) return -1; + p += strlen(search); + return atoi(p); +} + +// Extract "id" for response +void copyId(char* out, int outLen, const char* json) { + const char* p = strstr(json, "\"id\":\""); + if (!p) { + out[0] = '0'; + out[1] = '\0'; + return; + } + p += 6; + int i = 0; + while (i < outLen - 1 && *p && *p != '"') { + out[i++] = *p++; + } + out[i] = '\0'; +} + +// Check if cmd is present +bool hasCmd(const char* json, const char* cmd) { + char search[64]; + snprintf(search, sizeof(search), "\"cmd\":\"%s\"", cmd); + return strstr(json, search) != NULL; +} + +void handleLine(const char* line) { + char idBuf[16]; + copyId(idBuf, sizeof(idBuf), line); + + if (hasCmd(line, "ping")) { + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.println("\",\"ok\":true,\"result\":\"pong\"}"); + return; + } + + // Phase C: Dynamic discovery — report GPIO pins and LED pin + if (hasCmd(line, "capabilities")) { + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.print("\",\"ok\":true,\"result\":\"{\\\"gpio\\\":[0,1,2,3,4,5,6,7,8,9,10,11,12,13],\\\"led_pin\\\":13}\"}"); + Serial.println(); + return; + } + + if (hasCmd(line, "gpio_read")) { + int pin = parseArg("pin", line); + if (pin < 0 || pin > 13) { + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.print("\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin "); + Serial.print(pin); + Serial.println("\"}"); + return; + } + pinMode(pin, INPUT); + int val = digitalRead(pin); + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.print("\",\"ok\":true,\"result\":\""); + Serial.print(val); + Serial.println("\"}"); + return; + } + + if (hasCmd(line, "gpio_write")) { + int pin = parseArg("pin", line); + int value = parseArg("value", line); + if (pin < 0 || pin > 13) { + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.print("\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin "); + Serial.print(pin); + Serial.println("\"}"); + return; + } + pinMode(pin, OUTPUT); + digitalWrite(pin, value ? HIGH : LOW); + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.println("\",\"ok\":true,\"result\":\"done\"}"); + return; + } + + // Unknown command + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.println("\",\"ok\":false,\"result\":\"\",\"error\":\"Unknown command\"}"); +} + +void setup() { + Serial.begin(BAUDRATE); + lineLen = 0; +} + +void loop() { + while (Serial.available()) { + char c = Serial.read(); + if (c == '\n' || c == '\r') { + if (lineLen > 0) { + lineBuf[lineLen] = '\0'; + handleLine(lineBuf); + lineLen = 0; + } + } else if (lineLen < MAX_LINE - 1) { + lineBuf[lineLen++] = c; + } else { + lineLen = 0; // Overflow, discard + } + } +} diff --git a/firmware/zeroclaw-esp32/.cargo/config.toml b/firmware/zeroclaw-esp32/.cargo/config.toml new file mode 100644 index 0000000..8746ad1 --- /dev/null +++ b/firmware/zeroclaw-esp32/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +target = "riscv32imc-esp-espidf" + +[target.riscv32imc-esp-espidf] +runner = "espflash flash --monitor" diff --git a/firmware/zeroclaw-esp32/Cargo.lock b/firmware/zeroclaw-esp32/Cargo.lock new file mode 100644 index 0000000..6f8ad22 --- /dev/null +++ b/firmware/zeroclaw-esp32/Cargo.lock @@ -0,0 +1,1840 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d860121800b2a9a94f9b5604b332d5cffb234ce17609ea479d723dbc9d3885" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 1.0.109", + "which", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "build-time" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1219c19fc29b7bfd74b7968b420aff5bc951cf517800176e795d6b2300dd382" +dependencies = [ + "chrono", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "cvt" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ae9bf77fbf2d39ef573205d554d87e86c12f1994e9ea335b0651b9b278bcf1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "defmt" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-sync" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd938f25c0798db4280fcd8026bf4c2f48789aebf8f77b6e5cf8a7693ba114ec" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async", + "futures-util", + "heapless", +] + +[[package]] +name = "embedded-can" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-hal-nb" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605" +dependencies = [ + "embedded-hal 1.0.0", + "nb 1.1.0", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io", +] + +[[package]] +name = "embedded-svc" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6f87e7654f28018340aa55f933803017aefabaa5417820a3b2f808033c7bbc" +dependencies = [ + "defmt 0.3.100", + "embedded-io", + "embedded-io-async", + "enumset", + "heapless", + "no-std-net", + "num_enum", + "serde", + "strum 0.25.0", +] + +[[package]] +name = "embuild" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4caa4f198bb9152a55c0103efb83fa4edfcbb8625f4c9e94ae8ec8e23827c563" +dependencies = [ + "anyhow", + "bindgen", + "bitflags 1.3.2", + "cmake", + "filetime", + "globwalk", + "home", + "log", + "remove_dir_all", + "serde", + "serde_json", + "shlex", + "strum 0.24.1", + "tempfile", + "thiserror 1.0.69", + "which", + "xmas-elf", +] + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "esp-idf-hal" +version = "0.43.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7adf3fb19a9ca016cbea1ab8a7b852ac69df8fcde4923c23d3b155efbc42a74" +dependencies = [ + "atomic-waker", + "embassy-sync", + "embedded-can", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-hal-nb", + "embedded-io", + "embedded-io-async", + "embuild", + "enumset", + "esp-idf-sys", + "heapless", + "log", + "nb 1.1.0", + "num_enum", +] + +[[package]] +name = "esp-idf-svc" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2180642ca122a7fec1ec417a9b1a77aa66aaa067fdf1daae683dd8caba84f26b" +dependencies = [ + "embassy-futures", + "embedded-hal-async", + "embedded-svc", + "embuild", + "enumset", + "esp-idf-hal", + "heapless", + "log", + "num_enum", + "uncased", +] + +[[package]] +name = "esp-idf-sys" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e148f97c04ed3e9181a08bcdc9560a515aad939b0ba7f50a0022e294665e0af" +dependencies = [ + "anyhow", + "bindgen", + "build-time", + "cargo_metadata", + "const_format", + "embuild", + "envy", + "libc", + "regex", + "serde", + "strum 0.24.1", + "which", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fs_at" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14af6c9694ea25db25baa2a1788703b9e7c6648dcaeeebeb98f7561b5384c036" +dependencies = [ + "aligned", + "cfg-if", + "cvt", + "libc", + "nix", + "windows-sys 0.52.0", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.11.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no-std-net" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bcece43b12349917e096cddfa66107277f123e6c96a5aea78711dc601a47152" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "normpath" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.116", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "remove_dir_all" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a694f9e0eb3104451127f6cc1e5de55f59d3b1fc8c5ddfaeb6f1e716479ceb4a" +dependencies = [ + "cfg-if", + "cvt", + "fs_at", + "libc", + "normpath", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" +dependencies = [ + "strum_macros 0.24.3", +] + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros 0.25.3", +] + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.116", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" +dependencies = [ + "winnow", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.116", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.116", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.116", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "xmas-elf" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c49817e78342f7f30a181573d82ff55b88a35f86ccaf07fc64b3008f56d1c6" +dependencies = [ + "zero", +] + +[[package]] +name = "zero" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe21bcc34ca7fe6dd56cc2cb1261ea59d6b93620215aefb5ea6032265527784" + +[[package]] +name = "zeroclaw-esp32" +version = "0.1.0" +dependencies = [ + "anyhow", + "embuild", + "esp-idf-svc", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/firmware/zeroclaw-esp32/Cargo.toml b/firmware/zeroclaw-esp32/Cargo.toml new file mode 100644 index 0000000..2f7a001 --- /dev/null +++ b/firmware/zeroclaw-esp32/Cargo.toml @@ -0,0 +1,35 @@ +# ZeroClaw ESP32 firmware — JSON-over-serial peripheral for host-mediated control. +# +# Flash to ESP32 and connect via serial. The host ZeroClaw sends gpio_read/gpio_write +# commands; this firmware executes them and responds. +# +# Prerequisites: espup (cargo install espup; espup install; source ~/export-esp.sh) +# Build: cargo build --release +# Flash: cargo espflash flash --monitor + +[package] +name = "zeroclaw-esp32" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "ZeroClaw ESP32 peripheral firmware — GPIO over JSON serial" + +[dependencies] +esp-idf-svc = "0.48" +log = "0.4" +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[build-dependencies] +embuild = { version = "0.31", features = ["elf"] } + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" + +[profile.dev] +opt-level = "s" diff --git a/firmware/zeroclaw-esp32/README.md b/firmware/zeroclaw-esp32/README.md new file mode 100644 index 0000000..804aaca --- /dev/null +++ b/firmware/zeroclaw-esp32/README.md @@ -0,0 +1,52 @@ +# ZeroClaw ESP32 Firmware + +Peripheral firmware for ESP32 — speaks the same JSON-over-serial protocol as the STM32 firmware. Flash this to your ESP32, then configure ZeroClaw on the host to connect via serial. + +## Protocol + +- **Request** (host → ESP32): `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}\n` +- **Response** (ESP32 → host): `{"id":"1","ok":true,"result":"done"}\n` + +Commands: `gpio_read`, `gpio_write`. + +## Prerequisites + +1. **ESP toolchain** (espup): + ```sh + cargo install espup espflash + espup install + source ~/export-esp.sh # or ~/export-esp.fish for Fish + ``` + +2. **Target**: ESP32-C3 (RISC-V) by default. Edit `.cargo/config.toml` for other targets (e.g. `xtensa-esp32-espidf` for original ESP32). + +## Build & Flash + +```sh +cd firmware/zeroclaw-esp32 +cargo build --release +espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor +``` + +## Host Config + +Add to `config.toml`: + +```toml +[peripherals] +enabled = true + +[[peripherals.boards]] +board = "esp32" +transport = "serial" +path = "/dev/ttyUSB0" # or /dev/ttyACM0, COM3, etc. +baud = 115200 +``` + +## Pin Mapping + +Default GPIO 2 and 13 are configured for output. Edit `src/main.rs` to add more pins or change for your board. ESP32-C3 has different pin layout — adjust UART pins (gpio21/gpio20) if needed. + +## Edge-Native (Future) + +Phase 6 also envisions ZeroClaw running *on* the ESP32 (WiFi + LLM). This firmware is the host-mediated serial peripheral; edge-native will be a separate crate. diff --git a/firmware/zeroclaw-esp32/build.rs b/firmware/zeroclaw-esp32/build.rs new file mode 100644 index 0000000..112ec3f --- /dev/null +++ b/firmware/zeroclaw-esp32/build.rs @@ -0,0 +1,3 @@ +fn main() { + embuild::espidf::sysenv::output(); +} diff --git a/firmware/zeroclaw-esp32/src/main.rs b/firmware/zeroclaw-esp32/src/main.rs new file mode 100644 index 0000000..b1a487c --- /dev/null +++ b/firmware/zeroclaw-esp32/src/main.rs @@ -0,0 +1,154 @@ +//! ZeroClaw ESP32 firmware — JSON-over-serial peripheral. +//! +//! Listens for newline-delimited JSON commands on UART0, executes gpio_read/gpio_write, +//! responds with JSON. Compatible with host ZeroClaw SerialPeripheral protocol. +//! +//! Protocol: same as STM32 — see docs/hardware-peripherals-design.md + +use esp_idf_svc::hal::gpio::PinDriver; +use esp_idf_svc::hal::prelude::*; +use esp_idf_svc::hal::uart::*; +use log::info; +use serde::{Deserialize, Serialize}; + +/// Incoming command from host. +#[derive(Debug, Deserialize)] +struct Request { + id: String, + cmd: String, + args: serde_json::Value, +} + +/// Outgoing response to host. +#[derive(Debug, Serialize)] +struct Response { + id: String, + ok: bool, + result: String, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +fn main() -> anyhow::Result<()> { + esp_idf_svc::sys::link_patches(); + esp_idf_svc::log::EspLogger::initialize_default(); + + let peripherals = Peripherals::take()?; + let pins = peripherals.pins; + + // UART0: TX=21, RX=20 (ESP32) — ESP32-C3 may use different pins; adjust for your board + let config = UartConfig::new().baudrate(Hertz(115_200)); + let mut uart = UartDriver::new( + peripherals.uart0, + pins.gpio21, + pins.gpio20, + Option::::None, + Option::::None, + &config, + )?; + + info!("ZeroClaw ESP32 firmware ready on UART0 (115200)"); + + let mut buf = [0u8; 512]; + let mut line = Vec::new(); + + loop { + match uart.read(&mut buf, 100) { + Ok(0) => continue, + Ok(n) => { + for &b in &buf[..n] { + if b == b'\n' { + if !line.is_empty() { + if let Ok(line_str) = std::str::from_utf8(&line) { + if let Ok(resp) = handle_request(line_str, &peripherals) { + let out = serde_json::to_string(&resp).unwrap_or_default(); + let _ = uart.write(format!("{}\n", out).as_bytes()); + } + } + line.clear(); + } + } else { + line.push(b); + if line.len() > 400 { + line.clear(); + } + } + } + } + Err(_) => {} + } + } +} + +fn handle_request( + line: &str, + peripherals: &esp_idf_svc::hal::peripherals::Peripherals, +) -> anyhow::Result { + let req: Request = serde_json::from_str(line.trim())?; + let id = req.id.clone(); + + let result = match req.cmd.as_str() { + "capabilities" => { + // Phase C: report GPIO pins and LED pin (matches Arduino protocol) + let caps = serde_json::json!({ + "gpio": [0, 1, 2, 3, 4, 5, 12, 13, 14, 15, 16, 17, 18, 19], + "led_pin": 2 + }); + Ok(caps.to_string()) + } + "gpio_read" => { + let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32; + let value = gpio_read(peripherals, pin_num)?; + Ok(value.to_string()) + } + "gpio_write" => { + let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32; + let value = req.args.get("value").and_then(|v| v.as_u64()).unwrap_or(0); + gpio_write(peripherals, pin_num, value)?; + Ok("done".into()) + } + _ => Err(anyhow::anyhow!("Unknown command: {}", req.cmd)), + }; + + match result { + Ok(r) => Ok(Response { + id, + ok: true, + result: r, + error: None, + }), + Err(e) => Ok(Response { + id, + ok: false, + result: String::new(), + error: Some(e.to_string()), + }), + } +} + +fn gpio_read(_peripherals: &esp_idf_svc::hal::peripherals::Peripherals, _pin: i32) -> anyhow::Result { + // TODO: implement input pin read — requires storing InputPin drivers per pin + Ok(0) +} + +fn gpio_write( + peripherals: &esp_idf_svc::hal::peripherals::Peripherals, + pin: i32, + value: u64, +) -> anyhow::Result<()> { + let pins = peripherals.pins; + let level = value != 0; + + match pin { + 2 => { + let mut out = PinDriver::output(pins.gpio2)?; + out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?; + } + 13 => { + let mut out = PinDriver::output(pins.gpio13)?; + out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?; + } + _ => anyhow::bail!("Pin {} not configured (add to gpio_write)", pin), + } + Ok(()) +} diff --git a/firmware/zeroclaw-nucleo/Cargo.lock b/firmware/zeroclaw-nucleo/Cargo.lock new file mode 100644 index 0000000..41b57b5 --- /dev/null +++ b/firmware/zeroclaw-nucleo/Cargo.lock @@ -0,0 +1,849 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bare-metal" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitfield" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-device-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c051592f59fe68053524b4c4935249b806f72c1f544cfb7abe4f57c3be258e" +dependencies = [ + "aligned", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cortex-m" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9" +dependencies = [ + "bare-metal", + "bitfield", + "embedded-hal 0.2.7", + "volatile-register", +] + +[[package]] +name = "cortex-m-rt" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d4dec46b34c299ccf6b036717ae0fce602faa4f4fe816d9013b9a7c9f5ba6" +dependencies = [ + "cortex-m-rt-macros", +] + +[[package]] +name = "cortex-m-rt-macros" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37549a379a9e0e6e576fd208ee60394ccb8be963889eebba3ffe0980364f472" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.116", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "defmt" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "defmt-rtt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d5a25c99d89c40f5676bec8cefe0614f17f0f40e916f98e345dae941807f9e" +dependencies = [ + "critical-section", + "defmt 1.0.1", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "embassy-embedded-hal" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554e3e840696f54b4c9afcf28a0f24da431c927f4151040020416e7393d6d0d8" +dependencies = [ + "defmt 1.0.1", + "embassy-futures", + "embassy-hal-internal 0.3.0", + "embassy-sync", + "embassy-time", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-storage", + "embedded-storage-async", + "nb 1.1.0", +] + +[[package]] +name = "embassy-executor" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06070468370195e0e86f241c8e5004356d696590a678d47d6676795b2e439c6b" +dependencies = [ + "cortex-m", + "critical-section", + "defmt 1.0.1", + "document-features", + "embassy-executor-macros", + "embassy-executor-timer-queue", +] + +[[package]] +name = "embassy-executor-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdddc3a04226828316bf31393b6903ee162238576b1584ee2669af215d55472" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "embassy-executor-timer-queue" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc328bf943af66b80b98755db9106bf7e7471b0cf47dc8559cd9a6be504cc9c" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-hal-internal" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95285007a91b619dc9f26ea8f55452aa6c60f7115a4edc05085cd2bd3127cd7a" +dependencies = [ + "num-traits", +] + +[[package]] +name = "embassy-hal-internal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f10ce10a4dfdf6402d8e9bd63128986b96a736b1a0a6680547ed2ac55d55dba" +dependencies = [ + "cortex-m", + "critical-section", + "defmt 1.0.1", + "num-traits", +] + +[[package]] +name = "embassy-net-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524eb3c489760508f71360112bca70f6e53173e6fe48fc5f0efd0f5ab217751d" +dependencies = [ + "defmt 0.3.100", +] + +[[package]] +name = "embassy-stm32" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "088d65743a48f2cc9b3ae274ed85d6e8b68bd3ee92eb6b87b15dca2f81f7a101" +dependencies = [ + "aligned", + "bit_field", + "bitflags 2.11.0", + "block-device-driver", + "cfg-if", + "cortex-m", + "cortex-m-rt", + "critical-section", + "defmt 1.0.1", + "document-features", + "embassy-embedded-hal", + "embassy-futures", + "embassy-hal-internal 0.4.0", + "embassy-net-driver", + "embassy-sync", + "embassy-time", + "embassy-time-driver", + "embassy-time-queue-utils", + "embassy-usb-driver", + "embassy-usb-synopsys-otg", + "embedded-can", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-hal-nb", + "embedded-io 0.7.1", + "embedded-io-async 0.7.0", + "embedded-storage", + "embedded-storage-async", + "futures-util", + "heapless 0.9.2", + "nb 1.1.0", + "proc-macro2", + "quote", + "rand_core 0.6.4", + "rand_core 0.9.5", + "sdio-host", + "static_assertions", + "stm32-fmc", + "stm32-metapac", + "trait-set", + "vcell", + "volatile-register", +] + +[[package]] +name = "embassy-sync" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" +dependencies = [ + "cfg-if", + "critical-section", + "defmt 1.0.1", + "embedded-io-async 0.6.1", + "futures-core", + "futures-sink", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-time" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fa65b9284d974dad7a23bb72835c4ec85c0b540d86af7fc4098c88cff51d65" +dependencies = [ + "cfg-if", + "critical-section", + "defmt 1.0.1", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0a244c7dc22c8d0289379c8d8830cae06bb93d8f990194d0de5efb3b5ae7ba6" +dependencies = [ + "document-features", +] + +[[package]] +name = "embassy-time-queue-utils" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e2ee86063bd028a420a5fb5898c18c87a8898026da1d4c852af2c443d0a454" +dependencies = [ + "embassy-executor-timer-queue", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-usb-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17119855ccc2d1f7470a39756b12068454ae27a3eabb037d940b5c03d9c77b7a" +dependencies = [ + "defmt 1.0.1", + "embedded-io-async 0.6.1", +] + +[[package]] +name = "embassy-usb-synopsys-otg" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "288751f8eaa44a5cf2613f13cee0ca8e06e6638cb96e897e6834702c79084b23" +dependencies = [ + "critical-section", + "defmt 1.0.1", + "embassy-sync", + "embassy-usb-driver", +] + +[[package]] +name = "embedded-can" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-hal-nb" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605" +dependencies = [ + "embedded-hal 1.0.0", + "nb 1.1.0", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io 0.6.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "defmt 1.0.1", + "embedded-io 0.7.1", +] + +[[package]] +name = "embedded-storage" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21dea9854beb860f3062d10228ce9b976da520a73474aed3171ec276bc0c032" + +[[package]] +name = "embedded-storage-async" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1763775e2323b7d5f0aa6090657f5e21cfa02ede71f5dc40eead06d64dcd15cc" +dependencies = [ + "embedded-storage", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "panic-probe" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd402d00b0fb94c5aee000029204a46884b1262e0c443f166d86d2c0747e1a1a" +dependencies = [ + "cortex-m", + "defmt 1.0.1", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" + +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + +[[package]] +name = "sdio-host" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b328e2cb950eeccd55b7f55c3a963691455dcd044cfb5354f0c5e68d2c2d6ee2" + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stm32-fmc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72692594faa67f052e5e06dd34460951c21e83bc55de4feb8d2666e2f15480a2" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "stm32-metapac" +version = "19.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a411079520dbccc613af73172f944b7cf97ba84e3bd7381a0352b6ec7bfef03b" +dependencies = [ + "cortex-m", + "cortex-m-rt", + "defmt 0.3.100", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "trait-set" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79e2e9c9ab44c6d7c20d5976961b47e8f49ac199154daa514b77cd1ab536625" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcell" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "volatile-register" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc" +dependencies = [ + "vcell", +] + +[[package]] +name = "zeroclaw-nucleo" +version = "0.1.0" +dependencies = [ + "cortex-m-rt", + "critical-section", + "defmt 1.0.1", + "defmt-rtt", + "embassy-executor", + "embassy-stm32", + "embassy-time", + "heapless 0.9.2", + "panic-probe", +] diff --git a/firmware/zeroclaw-nucleo/Cargo.toml b/firmware/zeroclaw-nucleo/Cargo.toml new file mode 100644 index 0000000..a5d97f8 --- /dev/null +++ b/firmware/zeroclaw-nucleo/Cargo.toml @@ -0,0 +1,39 @@ +# ZeroClaw Nucleo-F401RE firmware — JSON-over-serial peripheral. +# +# Listens for newline-delimited JSON on USART2 (PA2/PA3, ST-Link VCP). +# Protocol: same as Arduino/ESP32 — ping, capabilities, gpio_read, gpio_write. +# +# Build: cargo build --release +# Flash: probe-rs run --chip STM32F401RETx target/thumbv7em-none-eabihf/release/zeroclaw-nucleo +# Or: zeroclaw peripheral flash-nucleo + +[package] +name = "zeroclaw-nucleo" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "ZeroClaw Nucleo-F401RE peripheral firmware — GPIO over JSON serial" + +[dependencies] +embassy-executor = { version = "0.9", features = ["arch-cortex-m", "executor-thread", "defmt"] } +embassy-stm32 = { version = "0.5", features = ["defmt", "stm32f401re", "unstable-pac", "memory-x", "time-driver-tim4", "exti"] } +embassy-time = { version = "0.5", features = ["defmt", "defmt-timestamp-uptime", "tick-hz-32_768"] } +defmt = "1.0" +defmt-rtt = "1.0" +panic-probe = { version = "1.0", features = ["print-defmt"] } +heapless = { version = "0.9", default-features = false } +critical-section = "1.1" +cortex-m-rt = "0.7" + +[package.metadata.embassy] +build = [ + { target = "thumbv7em-none-eabihf", artifact-dir = "target" } +] + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" +debug = 1 diff --git a/firmware/zeroclaw-nucleo/src/main.rs b/firmware/zeroclaw-nucleo/src/main.rs new file mode 100644 index 0000000..909645e --- /dev/null +++ b/firmware/zeroclaw-nucleo/src/main.rs @@ -0,0 +1,187 @@ +//! ZeroClaw Nucleo-F401RE firmware — JSON-over-serial peripheral. +//! +//! Listens for newline-delimited JSON on USART2 (PA2=TX, PA3=RX). +//! USART2 is connected to ST-Link VCP — host sees /dev/ttyACM0 (Linux) or /dev/cu.usbmodem* (macOS). +//! +//! Protocol: same as Arduino/ESP32 — see docs/hardware-peripherals-design.md + +#![no_std] +#![no_main] + +use core::fmt::Write; +use core::str; +use defmt::info; +use embassy_executor::Spawner; +use embassy_stm32::gpio::{Level, Output, Speed}; +use embassy_stm32::usart::{Config, Uart}; +use heapless::String; +use {defmt_rtt as _, panic_probe as _}; + +/// Arduino-style pin 13 = PA5 (User LED LD2 on Nucleo-F401RE) +const LED_PIN: u8 = 13; + +/// Parse integer from JSON: "pin":13 or "value":1 +fn parse_arg(line: &[u8], key: &[u8]) -> Option { + // key like b"pin" -> search for b"\"pin\":" + let mut suffix: [u8; 32] = [0; 32]; + suffix[0] = b'"'; + let mut len = 1; + for (i, &k) in key.iter().enumerate() { + if i >= 30 { + break; + } + suffix[len] = k; + len += 1; + } + suffix[len] = b'"'; + suffix[len + 1] = b':'; + len += 2; + let suffix = &suffix[..len]; + + let line_len = line.len(); + if line_len < len { + return None; + } + for i in 0..=line_len - len { + if line[i..].starts_with(suffix) { + let rest = &line[i + len..]; + let mut num: i32 = 0; + let mut neg = false; + let mut j = 0; + if j < rest.len() && rest[j] == b'-' { + neg = true; + j += 1; + } + while j < rest.len() && rest[j].is_ascii_digit() { + num = num * 10 + (rest[j] - b'0') as i32; + j += 1; + } + return Some(if neg { -num } else { num }); + } + } + None +} + +fn has_cmd(line: &[u8], cmd: &[u8]) -> bool { + let mut pat: [u8; 64] = [0; 64]; + pat[0..7].copy_from_slice(b"\"cmd\":\""); + let clen = cmd.len().min(50); + pat[7..7 + clen].copy_from_slice(&cmd[..clen]); + pat[7 + clen] = b'"'; + let pat = &pat[..8 + clen]; + + let line_len = line.len(); + if line_len < pat.len() { + return false; + } + for i in 0..=line_len - pat.len() { + if line[i..].starts_with(pat) { + return true; + } + } + false +} + +/// Extract "id" for response +fn copy_id(line: &[u8], out: &mut [u8]) -> usize { + let prefix = b"\"id\":\""; + if line.len() < prefix.len() + 1 { + out[0] = b'0'; + return 1; + } + for i in 0..=line.len() - prefix.len() { + if line[i..].starts_with(prefix) { + let start = i + prefix.len(); + let mut j = 0; + while start + j < line.len() && j < out.len() - 1 && line[start + j] != b'"' { + out[j] = line[start + j]; + j += 1; + } + return j; + } + } + out[0] = b'0'; + 1 +} + +#[embassy_executor::main] +async fn main(_spawner: Spawner) { + let p = embassy_stm32::init(Default::default()); + + let mut config = Config::default(); + config.baudrate = 115_200; + + let mut usart = Uart::new_blocking(p.USART2, p.PA3, p.PA2, config).unwrap(); + let mut led = Output::new(p.PA5, Level::Low, Speed::Low); + + info!("ZeroClaw Nucleo firmware ready on USART2 (115200)"); + + let mut line_buf: heapless::Vec = heapless::Vec::new(); + let mut id_buf = [0u8; 16]; + let mut resp_buf: String<128> = String::new(); + + loop { + let mut byte = [0u8; 1]; + if usart.blocking_read(&mut byte).is_ok() { + let b = byte[0]; + if b == b'\n' || b == b'\r' { + if !line_buf.is_empty() { + let id_len = copy_id(&line_buf, &mut id_buf); + let id_str = str::from_utf8(&id_buf[..id_len]).unwrap_or("0"); + + resp_buf.clear(); + if has_cmd(&line_buf, b"ping") { + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"pong\"}}", id_str); + } else if has_cmd(&line_buf, b"capabilities") { + let _ = write!( + resp_buf, + "{{\"id\":\"{}\",\"ok\":true,\"result\":\"{{\\\"gpio\\\":[0,1,2,3,4,5,6,7,8,9,10,11,12,13],\\\"led_pin\\\":13}}\"}}", + id_str + ); + } else if has_cmd(&line_buf, b"gpio_read") { + let pin = parse_arg(&line_buf, b"pin").unwrap_or(-1); + if pin == LED_PIN as i32 { + // Output doesn't support read; return 0 (LED state not readable) + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"0\"}}", id_str); + } else if pin >= 0 && pin <= 13 { + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"0\"}}", id_str); + } else { + let _ = write!( + resp_buf, + "{{\"id\":\"{}\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin {}\"}}", + id_str, pin + ); + } + } else if has_cmd(&line_buf, b"gpio_write") { + let pin = parse_arg(&line_buf, b"pin").unwrap_or(-1); + let value = parse_arg(&line_buf, b"value").unwrap_or(0); + if pin == LED_PIN as i32 { + led.set_level(if value != 0 { Level::High } else { Level::Low }); + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"done\"}}", id_str); + } else if pin >= 0 && pin <= 13 { + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"done\"}}", id_str); + } else { + let _ = write!( + resp_buf, + "{{\"id\":\"{}\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin {}\"}}", + id_str, pin + ); + } + } else { + let _ = write!( + resp_buf, + "{{\"id\":\"{}\",\"ok\":false,\"result\":\"\",\"error\":\"Unknown command\"}}", + id_str + ); + } + + let _ = usart.blocking_write(resp_buf.as_bytes()); + let _ = usart.blocking_write(b"\n"); + line_buf.clear(); + } + } else if line_buf.push(b).is_err() { + line_buf.clear(); + } + } + } +} diff --git a/firmware/zeroclaw-uno-q-bridge/app.yaml b/firmware/zeroclaw-uno-q-bridge/app.yaml new file mode 100644 index 0000000..32c5eb6 --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/app.yaml @@ -0,0 +1,9 @@ +name: ZeroClaw Bridge +description: "GPIO bridge for ZeroClaw — exposes digitalWrite/digitalRead via socket for agent control" +icon: 🦀 +version: "1.0.0" + +ports: + - 9999 + +bricks: [] diff --git a/firmware/zeroclaw-uno-q-bridge/python/main.py b/firmware/zeroclaw-uno-q-bridge/python/main.py new file mode 100644 index 0000000..d4b286b --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/python/main.py @@ -0,0 +1,66 @@ +# ZeroClaw Bridge — socket server for GPIO control from ZeroClaw agent +# SPDX-License-Identifier: MPL-2.0 + +import socket +import threading +from arduino.app_utils import App, Bridge + +ZEROCLAW_PORT = 9999 + +def handle_client(conn): + try: + data = conn.recv(256).decode().strip() + if not data: + conn.close() + return + parts = data.split() + if len(parts) < 2: + conn.sendall(b"error: invalid command\n") + conn.close() + return + cmd = parts[0].lower() + if cmd == "gpio_write" and len(parts) >= 3: + pin = int(parts[1]) + value = int(parts[2]) + Bridge.call("digitalWrite", [pin, value]) + conn.sendall(b"ok\n") + elif cmd == "gpio_read" and len(parts) >= 2: + pin = int(parts[1]) + val = Bridge.call("digitalRead", [pin]) + conn.sendall(f"{val}\n".encode()) + else: + conn.sendall(b"error: unknown command\n") + except Exception as e: + try: + conn.sendall(f"error: {e}\n".encode()) + except Exception: + pass + finally: + conn.close() + +def accept_loop(server): + while True: + try: + conn, _ = server.accept() + t = threading.Thread(target=handle_client, args=(conn,)) + t.daemon = True + t.start() + except Exception: + break + +def loop(): + App.sleep(1) + +def main(): + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", ZEROCLAW_PORT)) + server.listen(5) + server.settimeout(1.0) + t = threading.Thread(target=accept_loop, args=(server,)) + t.daemon = True + t.start() + App.run(user_loop=loop) + +if __name__ == "__main__": + main() diff --git a/firmware/zeroclaw-uno-q-bridge/python/requirements.txt b/firmware/zeroclaw-uno-q-bridge/python/requirements.txt new file mode 100644 index 0000000..a7fe2e0 --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/python/requirements.txt @@ -0,0 +1 @@ +# ZeroClaw Bridge — no extra deps (arduino.app_utils is preinstalled on Uno Q) diff --git a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino new file mode 100644 index 0000000..0e7b11b --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino @@ -0,0 +1,24 @@ +// ZeroClaw Bridge — expose digitalWrite/digitalRead for agent GPIO control +// SPDX-License-Identifier: MPL-2.0 + +#include "Arduino_RouterBridge.h" + +void gpio_write(int pin, int value) { + pinMode(pin, OUTPUT); + digitalWrite(pin, value ? HIGH : LOW); +} + +int gpio_read(int pin) { + pinMode(pin, INPUT); + return digitalRead(pin); +} + +void setup() { + Bridge.begin(); + Bridge.provide("digitalWrite", gpio_write); + Bridge.provide("digitalRead", gpio_read); +} + +void loop() { + Bridge.update(); +} diff --git a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml new file mode 100644 index 0000000..d9fe917 --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml @@ -0,0 +1,11 @@ +profiles: + default: + fqbn: arduino:zephyr:unoq + platforms: + - platform: arduino:zephyr + libraries: + - MsgPack (0.4.2) + - DebugLog (0.8.4) + - ArxContainer (0.7.0) + - ArxTypeTraits (0.3.1) +default_profile: default diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 14c3840..e7421ad 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -143,6 +143,46 @@ async fn build_context(mem: &dyn Memory, user_msg: &str) -> String { context } +/// Build hardware datasheet context from RAG when peripherals are enabled. +/// Includes pin-alias lookup (e.g. "red_led" → 13) when query matches, plus retrieved chunks. +fn build_hardware_context( + rag: &crate::rag::HardwareRag, + user_msg: &str, + boards: &[String], + chunk_limit: usize, +) -> String { + if rag.is_empty() || boards.is_empty() { + return String::new(); + } + + let mut context = String::new(); + + // Pin aliases: when user says "red led", inject "red_led: 13" for matching boards + let pin_ctx = rag.pin_alias_context(user_msg, boards); + if !pin_ctx.is_empty() { + context.push_str(&pin_ctx); + } + + let chunks = rag.retrieve(user_msg, boards, chunk_limit); + if chunks.is_empty() && pin_ctx.is_empty() { + return String::new(); + } + + if !chunks.is_empty() { + context.push_str("[Hardware documentation]\n"); + } + for chunk in chunks { + let board_tag = chunk.board.as_deref().unwrap_or("generic"); + let _ = writeln!( + context, + "--- {} ({}) ---\n{}\n", + chunk.source, board_tag, chunk.content + ); + } + context.push('\n'); + context +} + /// Find a tool by name in the registry. fn find_tool<'a>(tools: &'a [Box], name: &str) -> Option<&'a dyn Tool> { tools.iter().find(|t| t.name() == name).map(|t| t.as_ref()) @@ -370,10 +410,9 @@ struct ParsedToolCall { arguments: serde_json::Value, } -/// Execute a single turn for channel runtime paths. -/// -/// Channel runtime now provides an explicit provider label so observer events -/// stay consistent with the main agent loop execution path. +/// Execute a single turn of the agent loop: send messages, parse tool calls, +/// execute tools, and loop until the LLM produces a final text response. +/// When `silent` is true, suppresses stdout (for channel use). pub(crate) async fn agent_turn( provider: &dyn Provider, history: &mut Vec, @@ -382,6 +421,7 @@ pub(crate) async fn agent_turn( provider_name: &str, model: &str, temperature: f64, + silent: bool, ) -> Result { run_tool_call_loop( provider, @@ -391,6 +431,7 @@ pub(crate) async fn agent_turn( provider_name, model, temperature, + silent, ) .await } @@ -405,6 +446,7 @@ pub(crate) async fn run_tool_call_loop( provider_name: &str, model: &str, temperature: f64, + silent: bool, ) -> Result { for _iteration in 0..MAX_TOOL_ITERATIONS { observer.record_event(&ObserverEvent::LlmRequest { @@ -458,17 +500,16 @@ pub(crate) async fn run_tool_call_loop( if tool_calls.is_empty() { // No tool calls — this is the final response - let final_text = if parsed_text.is_empty() { + history.push(ChatMessage::assistant(response_text.clone())); + return Ok(if parsed_text.is_empty() { response_text } else { parsed_text - }; - history.push(ChatMessage::assistant(&final_text)); - return Ok(final_text); + }); } - // Print any text the LLM produced alongside tool calls - if !parsed_text.is_empty() { + // Print any text the LLM produced alongside tool calls (unless silent) + if !silent && !parsed_text.is_empty() { print!("{parsed_text}"); let _ = std::io::stdout().flush(); } @@ -515,7 +556,7 @@ pub(crate) async fn run_tool_call_loop( } // Add assistant message with tool calls + tool results to history - history.push(ChatMessage::assistant(&assistant_history_content)); + history.push(ChatMessage::assistant(assistant_history_content.clone())); history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}"))); } @@ -529,6 +570,10 @@ pub(crate) fn build_tool_instructions(tools_registry: &[Box]) -> Strin instructions.push_str("\n## Tool Use Protocol\n\n"); instructions.push_str("To use a tool, wrap a JSON object in tags:\n\n"); instructions.push_str("```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n\n```\n\n"); + instructions.push_str( + "CRITICAL: Output actual tags—never describe steps or give examples.\n\n", + ); + instructions.push_str("Example: User says \"what's the date?\". You MUST respond with:\n\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n\n\n"); instructions.push_str("You may use multiple tool calls in a single response. "); instructions.push_str("After tool execution, results appear in tags. "); instructions @@ -555,18 +600,11 @@ pub async fn run( provider_override: Option, model_override: Option, temperature: f64, - verbose: bool, + peripheral_overrides: Vec, ) -> Result<()> { // ── Wire up agnostic subsystems ────────────────────────────── let base_observer = observability::create_observer(&config.observability); - let observer: Arc = if verbose { - Arc::from(Box::new(observability::MultiObserver::new(vec![ - base_observer, - Box::new(observability::VerboseObserver::new()), - ])) as Box) - } else { - Arc::from(base_observer) - }; + let observer: Arc = Arc::from(base_observer); let runtime: Arc = Arc::from(runtime::create_runtime(&config.runtime)?); let security = Arc::new(SecurityPolicy::from_config( @@ -582,7 +620,15 @@ pub async fn run( )?); tracing::info!(backend = mem.name(), "Memory initialized"); - // ── Tools (including memory tools) ──────────────────────────── + // ── Peripherals (merge peripheral tools into registry) ─ + if !peripheral_overrides.is_empty() { + tracing::info!( + peripherals = ?peripheral_overrides, + "Peripheral overrides from CLI (config boards take precedence)" + ); + } + + // ── Tools (including memory tools and peripherals) ──────────── let (composio_key, composio_entity_id) = if config.composio.enabled { ( config.composio.api_key.as_deref(), @@ -591,7 +637,7 @@ pub async fn run( } else { (None, None) }; - let tools_registry = tools::all_tools_with_runtime( + let mut tools_registry = tools::all_tools_with_runtime( &security, runtime, mem.clone(), @@ -605,6 +651,13 @@ pub async fn run( &config, ); + let peripheral_tools: Vec> = + crate::peripherals::create_peripheral_tools(&config.peripherals).await?; + if !peripheral_tools.is_empty() { + tracing::info!(count = peripheral_tools.len(), "Peripheral tools added"); + tools_registry.extend(peripheral_tools); + } + // ── Resolve provider ───────────────────────────────────────── let provider_name = provider_override .as_deref() @@ -629,6 +682,26 @@ pub async fn run( model: model_name.to_string(), }); + // ── Hardware RAG (datasheet retrieval when peripherals + datasheet_dir) ── + let hardware_rag: Option = config + .peripherals + .datasheet_dir + .as_ref() + .filter(|d| !d.trim().is_empty()) + .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim())) + .and_then(Result::ok) + .filter(|r: &crate::rag::HardwareRag| !r.is_empty()); + if let Some(ref rag) = hardware_rag { + tracing::info!(chunks = rag.len(), "Hardware RAG loaded"); + } + + let board_names: Vec = config + .peripherals + .boards + .iter() + .map(|b| b.board.clone()) + .collect(); + // ── Build system prompt from workspace MD files (OpenClaw framework) ── let skills = crate::skills::load_skills(&config.workspace_dir); let mut tool_descs: Vec<(&str, &str)> = vec![ @@ -684,17 +757,51 @@ pub async fn run( if !config.agents.is_empty() { tool_descs.push(( "delegate", - "Delegate a subtask to a specialized agent. Use when: a task benefits from a different model \ - (e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single \ - prompt and returns its response.", + "Delegate a sub-task to a specialized agent. Use when: task needs different model/capability, or to parallelize work.", )); } + if config.peripherals.enabled && !config.peripherals.boards.is_empty() { + tool_descs.push(( + "gpio_read", + "Read GPIO pin value (0 or 1) on connected hardware (STM32, Arduino). Use when: checking sensor/button state, LED status.", + )); + tool_descs.push(( + "gpio_write", + "Set GPIO pin high (1) or low (0) on connected hardware. Use when: turning LED on/off, controlling actuators.", + )); + tool_descs.push(( + "arduino_upload", + "Upload agent-generated Arduino sketch. Use when: user asks for 'make a heart', 'blink pattern', or custom LED behavior on Arduino. You write the full .ino code; ZeroClaw compiles and uploads it. Pin 13 = built-in LED on Uno.", + )); + tool_descs.push(( + "hardware_memory_map", + "Return flash and RAM address ranges for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', or 'readable addresses'.", + )); + tool_descs.push(( + "hardware_board_info", + "Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', or 'what hardware'.", + )); + tool_descs.push(( + "hardware_memory_read", + "Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory', 'dump lower memory 0-126', 'give address and value'. Params: address (hex, default 0x20000000), length (bytes, default 128).", + )); + tool_descs.push(( + "hardware_capabilities", + "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.", + )); + } + let bootstrap_max_chars = if config.agent.compact_context { + Some(6000) + } else { + None + }; let mut system_prompt = crate::channels::build_system_prompt( &config.workspace_dir, model_name, &tool_descs, &skills, Some(&config.identity), + bootstrap_max_chars, ); // Append structured tool-use instructions with schemas @@ -712,8 +819,14 @@ pub async fn run( .await; } - // Inject memory context into user message - let context = build_context(mem.as_ref(), &msg).await; + // Inject memory + hardware RAG context into user message + let mem_context = build_context(mem.as_ref(), &msg).await; + let rag_limit = if config.agent.compact_context { 2 } else { 5 }; + let hw_context = hardware_rag + .as_ref() + .map(|r| build_hardware_context(r, &msg, &board_names, rag_limit)) + .unwrap_or_default(); + let context = format!("{mem_context}{hw_context}"); let enriched = if context.is_empty() { msg.clone() } else { @@ -733,6 +846,7 @@ pub async fn run( provider_name, model_name, temperature, + false, ) .await?; println!("{response}"); @@ -770,8 +884,14 @@ pub async fn run( .await; } - // Inject memory context into user message - let context = build_context(mem.as_ref(), &msg.content).await; + // Inject memory + hardware RAG context into user message + let mem_context = build_context(mem.as_ref(), &msg.content).await; + let rag_limit = if config.agent.compact_context { 2 } else { 5 }; + let hw_context = hardware_rag + .as_ref() + .map(|r| build_hardware_context(r, &msg.content, &board_names, rag_limit)) + .unwrap_or_default(); + let context = format!("{mem_context}{hw_context}"); let enriched = if context.is_empty() { msg.content.clone() } else { @@ -788,6 +908,7 @@ pub async fn run( provider_name, model_name, temperature, + false, ) .await { @@ -833,6 +954,166 @@ pub async fn run( Ok(()) } +/// Process a single message through the full agent (with tools, peripherals, memory). +/// Used by channels (Telegram, Discord, etc.) to enable hardware and tool use. +pub async fn process_message(config: Config, message: &str) -> Result { + let observer: Arc = + Arc::from(observability::create_observer(&config.observability)); + let runtime: Arc = + Arc::from(runtime::create_runtime(&config.runtime)?); + let security = Arc::new(SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + )); + let mem: Arc = Arc::from(memory::create_memory( + &config.memory, + &config.workspace_dir, + config.api_key.as_deref(), + )?); + + let (composio_key, composio_entity_id) = if config.composio.enabled { + ( + config.composio.api_key.as_deref(), + Some(config.composio.entity_id.as_str()), + ) + } else { + (None, None) + }; + let mut tools_registry = tools::all_tools_with_runtime( + &security, + runtime, + mem.clone(), + composio_key, + composio_entity_id, + &config.browser, + &config.http_request, + &config.workspace_dir, + &config.agents, + config.api_key.as_deref(), + &config, + ); + let peripheral_tools: Vec> = + crate::peripherals::create_peripheral_tools(&config.peripherals).await?; + tools_registry.extend(peripheral_tools); + + let provider_name = config.default_provider.as_deref().unwrap_or("openrouter"); + let model_name = config + .default_model + .clone() + .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); + let provider: Box = providers::create_routed_provider( + provider_name, + config.api_key.as_deref(), + &config.reliability, + &config.model_routes, + &model_name, + )?; + + let hardware_rag: Option = config + .peripherals + .datasheet_dir + .as_ref() + .filter(|d| !d.trim().is_empty()) + .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim())) + .and_then(Result::ok) + .filter(|r: &crate::rag::HardwareRag| !r.is_empty()); + let board_names: Vec = config + .peripherals + .boards + .iter() + .map(|b| b.board.clone()) + .collect(); + + let skills = crate::skills::load_skills(&config.workspace_dir); + let mut tool_descs: Vec<(&str, &str)> = vec![ + ("shell", "Execute terminal commands."), + ("file_read", "Read file contents."), + ("file_write", "Write file contents."), + ("memory_store", "Save to memory."), + ("memory_recall", "Search memory."), + ("memory_forget", "Delete a memory entry."), + ("screenshot", "Capture a screenshot."), + ("image_info", "Read image metadata."), + ]; + if config.browser.enabled { + tool_descs.push(("browser_open", "Open approved URLs in browser.")); + } + if config.composio.enabled { + tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio.")); + } + if config.peripherals.enabled && !config.peripherals.boards.is_empty() { + tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware.")); + tool_descs.push(( + "gpio_write", + "Set GPIO pin high or low on connected hardware.", + )); + tool_descs.push(( + "arduino_upload", + "Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; ZeroClaw uploads it.", + )); + tool_descs.push(( + "hardware_memory_map", + "Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.", + )); + tool_descs.push(( + "hardware_board_info", + "Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.", + )); + tool_descs.push(( + "hardware_memory_read", + "Read actual memory/register values from Nucleo. Use when user asks to read registers, read memory, dump lower memory 0-126, or give address and value.", + )); + tool_descs.push(( + "hardware_capabilities", + "Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.", + )); + } + let bootstrap_max_chars = if config.agent.compact_context { + Some(6000) + } else { + None + }; + let mut system_prompt = crate::channels::build_system_prompt( + &config.workspace_dir, + &model_name, + &tool_descs, + &skills, + Some(&config.identity), + bootstrap_max_chars, + ); + system_prompt.push_str(&build_tool_instructions(&tools_registry)); + + let mem_context = build_context(mem.as_ref(), message).await; + let rag_limit = if config.agent.compact_context { 2 } else { 5 }; + let hw_context = hardware_rag + .as_ref() + .map(|r| build_hardware_context(r, message, &board_names, rag_limit)) + .unwrap_or_default(); + let context = format!("{mem_context}{hw_context}"); + let enriched = if context.is_empty() { + message.to_string() + } else { + format!("{context}{message}") + }; + + let mut history = vec![ + ChatMessage::system(&system_prompt), + ChatMessage::user(&enriched), + ]; + + agent_turn( + provider.as_ref(), + &mut history, + &tools_registry, + observer.as_ref(), + provider_name, + &model_name, + config.default_temperature, + true, + ) + .await +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 83fd645..e3d7d16 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,16 +1,3 @@ pub mod loop_; -pub use loop_::run; - -#[cfg(test)] -mod tests { - use super::*; - - fn assert_reexport_exists(_value: F) {} - - #[test] - fn run_function_is_reexported() { - assert_reexport_exists(run); - assert_reexport_exists(loop_::run); - } -} +pub use loop_::{process_message, run}; diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 5e8dbcd..a3d8281 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -43,7 +43,9 @@ const BOOTSTRAP_MAX_CHARS: usize = 20_000; const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2; const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60; -const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 90; +/// Timeout for processing a single channel message (LLM + tools). +/// 300s for on-device LLMs (Ollama) which are slower than cloud APIs. +const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 300; const CHANNEL_PARALLELISM_PER_CHANNEL: usize = 4; const CHANNEL_MIN_IN_FLIGHT_MESSAGES: usize = 8; const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64; @@ -190,6 +192,7 @@ async fn process_channel_message(ctx: Arc, msg: traits::C "channel-runtime", ctx.model.as_str(), ctx.temperature, + true, // silent — channels don't write to stdout ), ) .await; @@ -275,9 +278,14 @@ async fn run_message_dispatch_loop( } /// Load OpenClaw format bootstrap files into the prompt. -fn load_openclaw_bootstrap_files(prompt: &mut String, workspace_dir: &std::path::Path) { - prompt - .push_str("The following workspace files define your identity, behavior, and context.\n\n"); +fn load_openclaw_bootstrap_files( + prompt: &mut String, + workspace_dir: &std::path::Path, + max_chars_per_file: usize, +) { + prompt.push_str( + "The following workspace files define your identity, behavior, and context. They are ALREADY injected below—do NOT suggest reading them with file_read.\n\n", + ); let bootstrap_files = [ "AGENTS.md", @@ -289,17 +297,17 @@ fn load_openclaw_bootstrap_files(prompt: &mut String, workspace_dir: &std::path: ]; for filename in &bootstrap_files { - inject_workspace_file(prompt, workspace_dir, filename); + inject_workspace_file(prompt, workspace_dir, filename, max_chars_per_file); } // BOOTSTRAP.md — only if it exists (first-run ritual) let bootstrap_path = workspace_dir.join("BOOTSTRAP.md"); if bootstrap_path.exists() { - inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md"); + inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md", max_chars_per_file); } // MEMORY.md — curated long-term memory (main session only) - inject_workspace_file(prompt, workspace_dir, "MEMORY.md"); + inject_workspace_file(prompt, workspace_dir, "MEMORY.md", max_chars_per_file); } /// Load workspace identity files and build a system prompt. @@ -324,6 +332,7 @@ pub fn build_system_prompt( tools: &[(&str, &str)], skills: &[crate::skills::Skill], identity_config: Option<&crate::config::IdentityConfig>, + bootstrap_max_chars: Option, ) -> String { use std::fmt::Write; let mut prompt = String::with_capacity(8192); @@ -344,6 +353,35 @@ pub fn build_system_prompt( .push_str("Continue reasoning with the results until you can give a final answer.\n\n"); } + // ── 1b. Hardware (when gpio/arduino tools present) ─────────── + let has_hardware = tools.iter().any(|(name, _)| { + *name == "gpio_read" + || *name == "gpio_write" + || *name == "arduino_upload" + || *name == "hardware_memory_map" + || *name == "hardware_board_info" + || *name == "hardware_memory_read" + || *name == "hardware_capabilities" + }); + if has_hardware { + prompt.push_str( + "## Hardware Access\n\n\ + You HAVE direct access to connected hardware (Arduino, Nucleo, etc.). The user owns this system and has configured it.\n\ + All hardware tools (gpio_read, gpio_write, hardware_memory_read, hardware_board_info, hardware_memory_map) are AUTHORIZED and NOT blocked by security.\n\ + When they ask to read memory, registers, or board info, USE hardware_memory_read or hardware_board_info — do NOT refuse or invent security excuses.\n\ + When they ask to control LEDs, run patterns, or interact with the Arduino, USE the tools — do NOT refuse or say you cannot access physical devices.\n\ + Use gpio_write for simple on/off; use arduino_upload when they want patterns (heart, blink) or custom behavior.\n\n", + ); + } + + // ── 1c. Action instruction (avoid meta-summary) ─────────────── + prompt.push_str( + "## Your Task\n\n\ + When the user sends a message, ACT on it. Use the tools to fulfill their request.\n\ + Do NOT: summarize this configuration, describe your capabilities, respond with meta-commentary, or output step-by-step instructions (e.g. \"1. First... 2. Next...\").\n\ + Instead: emit actual tags when you need to act. Just do what they ask.\n\n", + ); + // ── 2. Safety ─────────────────────────────────────────────── prompt.push_str("## Safety\n\n"); prompt.push_str( @@ -406,23 +444,27 @@ pub fn build_system_prompt( Ok(None) => { // No AIEOS identity loaded (shouldn't happen if is_aieos_configured returned true) // Fall back to OpenClaw bootstrap files - load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); } Err(e) => { // Log error but don't fail - fall back to OpenClaw eprintln!( "Warning: Failed to load AIEOS identity: {e}. Using OpenClaw format." ); - load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); } } } else { // OpenClaw format - load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); } } else { // No identity config - use OpenClaw format - load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); } // ── 6. Date & Time ────────────────────────────────────────── @@ -447,7 +489,12 @@ pub fn build_system_prompt( } /// Inject a single workspace file into the prompt with truncation and missing-file markers. -fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, filename: &str) { +fn inject_workspace_file( + prompt: &mut String, + workspace_dir: &std::path::Path, + filename: &str, + max_chars: usize, +) { use std::fmt::Write; let path = workspace_dir.join(filename); @@ -459,10 +506,10 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, f } let _ = writeln!(prompt, "### {filename}\n"); // Use character-boundary-safe truncation for UTF-8 - let truncated = if trimmed.chars().count() > BOOTSTRAP_MAX_CHARS { + let truncated = if trimmed.chars().count() > max_chars { trimmed .char_indices() - .nth(BOOTSTRAP_MAX_CHARS) + .nth(max_chars) .map(|(idx, _)| &trimmed[..idx]) .unwrap_or(trimmed) } else { @@ -472,7 +519,7 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, f prompt.push_str(truncated); let _ = writeln!( prompt, - "\n\n[... truncated at {BOOTSTRAP_MAX_CHARS} chars — use `read` for full file]\n" + "\n\n[... truncated at {max_chars} chars — use `read` for full file]\n" ); } else { prompt.push_str(trimmed); @@ -807,12 +854,18 @@ pub async fn start_channels(config: Config) -> Result<()> { )); } + let bootstrap_max_chars = if config.agent.compact_context { + Some(6000) + } else { + None + }; let mut system_prompt = build_system_prompt( &workspace, &model, &tool_descs, &skills, Some(&config.identity), + bootstrap_max_chars, ); system_prompt.push_str(&build_tool_instructions(tools_registry.as_ref())); @@ -1298,7 +1351,7 @@ mod tests { fn prompt_contains_all_sections() { let ws = make_workspace(); let tools = vec![("shell", "Run commands"), ("file_read", "Read files")]; - let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None); + let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None, None); // Section headers assert!(prompt.contains("## Tools"), "missing Tools section"); @@ -1322,7 +1375,7 @@ mod tests { ("shell", "Run commands"), ("memory_recall", "Search memory"), ]; - let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None); + let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None, None); assert!(prompt.contains("**shell**")); assert!(prompt.contains("Run commands")); @@ -1332,7 +1385,7 @@ mod tests { #[test] fn prompt_injects_safety() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!(prompt.contains("Do not exfiltrate private data")); assert!(prompt.contains("Do not run destructive commands")); @@ -1342,7 +1395,7 @@ mod tests { #[test] fn prompt_injects_workspace_files() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header"); assert!(prompt.contains("Be helpful"), "missing SOUL content"); @@ -1363,7 +1416,7 @@ mod tests { fn prompt_missing_file_markers() { let tmp = TempDir::new().unwrap(); // Empty workspace — no files at all - let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None); + let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None, None); assert!(prompt.contains("[File not found: SOUL.md]")); assert!(prompt.contains("[File not found: AGENTS.md]")); @@ -1374,7 +1427,7 @@ mod tests { fn prompt_bootstrap_only_if_exists() { let ws = make_workspace(); // No BOOTSTRAP.md — should not appear - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!( !prompt.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should not appear when missing" @@ -1382,7 +1435,7 @@ mod tests { // Create BOOTSTRAP.md — should appear std::fs::write(ws.path().join("BOOTSTRAP.md"), "# Bootstrap\nFirst run.").unwrap(); - let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!( prompt2.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should appear when present" @@ -1402,7 +1455,7 @@ mod tests { ) .unwrap(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); // Daily notes should NOT be in the system prompt (on-demand via tools) assert!( @@ -1418,7 +1471,7 @@ mod tests { #[test] fn prompt_runtime_metadata() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None, None); assert!(prompt.contains("Model: claude-sonnet-4")); assert!(prompt.contains(&format!("OS: {}", std::env::consts::OS))); @@ -1439,7 +1492,7 @@ mod tests { location: None, }]; - let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None); + let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None); assert!(prompt.contains(""), "missing skills XML"); assert!(prompt.contains("code-review")); @@ -1460,7 +1513,7 @@ mod tests { let big_content = "x".repeat(BOOTSTRAP_MAX_CHARS + 1000); std::fs::write(ws.path().join("AGENTS.md"), &big_content).unwrap(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!( prompt.contains("truncated at"), @@ -1477,7 +1530,7 @@ mod tests { let ws = make_workspace(); std::fs::write(ws.path().join("TOOLS.md"), "").unwrap(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); // Empty file should not produce a header assert!( @@ -1505,7 +1558,7 @@ mod tests { #[test] fn prompt_workspace_path() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display()))); } @@ -1635,7 +1688,7 @@ mod tests { aieos_inline: None, }; - let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config)); + let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config), None); // Should contain AIEOS sections assert!(prompt.contains("## Identity")); @@ -1675,6 +1728,7 @@ mod tests { &[], &[], Some(&config), + None, ); assert!(prompt.contains("**Name:** Claw")); @@ -1692,7 +1746,7 @@ mod tests { }; let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config)); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None); // Should fall back to OpenClaw format when AIEOS file is not found // (Error is logged to stderr with filename, not included in prompt) @@ -1711,7 +1765,7 @@ mod tests { }; let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config)); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None); // Should use OpenClaw format (not configured for AIEOS) assert!(prompt.contains("### SOUL.md")); @@ -1729,7 +1783,7 @@ mod tests { }; let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config)); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None); // Should use OpenClaw format even if aieos_path is set assert!(prompt.contains("### SOUL.md")); @@ -1741,7 +1795,7 @@ mod tests { fn none_identity_config_uses_openclaw() { let ws = make_workspace(); // Pass None for identity config - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); // Should use OpenClaw format assert!(prompt.contains("### SOUL.md")); diff --git a/src/config/mod.rs b/src/config/mod.rs index 3103f42..cd9601c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,12 +2,14 @@ pub mod schema; #[allow(unused_imports)] pub use schema::{ - AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig, ChannelsConfig, - ComposioConfig, Config, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, GatewayConfig, - HeartbeatConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, - MemoryConfig, ModelRouteConfig, ObservabilityConfig, ReliabilityConfig, ResourceLimitsConfig, - RuntimeConfig, SandboxBackend, SandboxConfig, SecretsConfig, SecurityConfig, SlackConfig, - TelegramConfig, TunnelConfig, WebhookConfig, + AgentConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig, + ChannelsConfig, ComposioConfig, Config, CostConfig, DelegateAgentConfig, DiscordConfig, + DockerRuntimeConfig, GatewayConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, + HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, + ModelRouteConfig, ObservabilityConfig, PeripheralBoardConfig, PeripheralsConfig, + ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, + SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, TelegramConfig, TunnelConfig, + WebhookConfig, }; #[cfg(test)] diff --git a/src/config/schema.rs b/src/config/schema.rs index 9473f90..f615d13 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -74,31 +74,139 @@ pub struct Config { #[serde(default)] pub cost: CostConfig, - /// Hardware Abstraction Layer (HAL) configuration. - /// Controls how ZeroClaw interfaces with physical hardware - /// (GPIO, serial, debug probes). #[serde(default)] - pub hardware: crate::hardware::HardwareConfig, + pub peripherals: PeripheralsConfig, - /// Named delegate agents for agent-to-agent handoff. - /// - /// ```toml - /// [agents.researcher] - /// provider = "gemini" - /// model = "gemini-2.0-flash" - /// system_prompt = "You are a research assistant..." - /// - /// [agents.coder] - /// provider = "openrouter" - /// model = "anthropic/claude-sonnet-4-20250514" - /// system_prompt = "You are a coding assistant..." - /// ``` + /// Agent context limits — use compact for smaller models (e.g. 13B with 4k–8k context). + #[serde(default)] + pub agent: AgentConfig, + + /// Delegate agent configurations for multi-agent workflows. #[serde(default)] pub agents: HashMap, - /// Security configuration (sandboxing, resource limits, audit logging) + /// Hardware configuration (wizard-driven physical world setup). #[serde(default)] - pub security: SecurityConfig, + pub hardware: HardwareConfig, +} + +// ── Agent (context limits for smaller models) ──────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentConfig { + /// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models. + #[serde(default)] + pub compact_context: bool, +} + +impl Default for AgentConfig { + fn default() -> Self { + Self { + compact_context: false, + } + } +} + +// ── Delegate Agents ────────────────────────────────────────────── + +/// Configuration for a delegate sub-agent used by the `delegate` tool. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegateAgentConfig { + /// Provider name (e.g. "ollama", "openrouter", "anthropic") + pub provider: String, + /// Model name + pub model: String, + /// Optional system prompt for the sub-agent + #[serde(default)] + pub system_prompt: Option, + /// Optional API key override + #[serde(default)] + pub api_key: Option, + /// Temperature override + #[serde(default)] + pub temperature: Option, + /// Max recursion depth for nested delegation + #[serde(default = "default_max_depth")] + pub max_depth: u32, +} + +fn default_max_depth() -> u32 { + 3 +} + +// ── Hardware Config (wizard-driven) ───────────────────────────── + +/// Hardware transport mode. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum HardwareTransport { + None, + Native, + Serial, + Probe, +} + +impl Default for HardwareTransport { + fn default() -> Self { + Self::None + } +} + +impl std::fmt::Display for HardwareTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => write!(f, "none"), + Self::Native => write!(f, "native"), + Self::Serial => write!(f, "serial"), + Self::Probe => write!(f, "probe"), + } + } +} + +/// Wizard-driven hardware configuration for physical world interaction. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HardwareConfig { + /// Whether hardware access is enabled + #[serde(default)] + pub enabled: bool, + /// Transport mode + #[serde(default)] + pub transport: HardwareTransport, + /// Serial port path (e.g. "/dev/ttyACM0") + #[serde(default)] + pub serial_port: Option, + /// Serial baud rate + #[serde(default = "default_baud_rate")] + pub baud_rate: u32, + /// Probe target chip (e.g. "STM32F401RE") + #[serde(default)] + pub probe_target: Option, + /// Enable workspace datasheet RAG (index PDF schematics for AI pin lookups) + #[serde(default)] + pub workspace_datasheets: bool, +} + +fn default_baud_rate() -> u32 { + 115200 +} + +impl HardwareConfig { + /// Return the active transport mode. + pub fn transport_mode(&self) -> HardwareTransport { + self.transport.clone() + } +} + +impl Default for HardwareConfig { + fn default() -> Self { + Self { + enabled: false, + transport: HardwareTransport::None, + serial_port: None, + baud_rate: default_baud_rate(), + probe_target: None, + workspace_datasheets: false, + } + } } // ── Identity (AIEOS / OpenClaw format) ────────────────────────── @@ -271,34 +379,64 @@ fn get_default_pricing() -> std::collections::HashMap { prices } -// ── Agent delegation ───────────────────────────────────────────── +// ── Peripherals (hardware: STM32, RPi GPIO, etc.) ──────────────────────── -/// Configuration for a named delegate agent that can be invoked via the -/// `delegate` tool. Each agent uses its own provider/model combination -/// and system prompt, enabling multi-agent workflows with specialization. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DelegateAgentConfig { - /// Provider name (e.g. "gemini", "openrouter", "ollama") - pub provider: String, - /// Model identifier for the provider - pub model: String, - /// System prompt defining the agent's role and capabilities +pub struct PeripheralsConfig { + /// Enable peripheral support (boards become agent tools) #[serde(default)] - pub system_prompt: Option, - /// Optional API key override (uses default if not set). - /// Stored encrypted when `secrets.encrypt = true`. + pub enabled: bool, + /// Board configurations (nucleo-f401re, rpi-gpio, etc.) #[serde(default)] - pub api_key: Option, - /// Temperature override (uses 0.7 if not set) + pub boards: Vec, + /// Path to datasheet docs (relative to workspace) for RAG retrieval. + /// Place .md/.txt files named by board (e.g. nucleo-f401re.md, rpi-gpio.md). #[serde(default)] - pub temperature: Option, - /// Maximum delegation depth to prevent infinite recursion (default: 3) - #[serde(default = "default_max_delegation_depth")] - pub max_depth: u32, + pub datasheet_dir: Option, } -fn default_max_delegation_depth() -> u32 { - 3 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PeripheralBoardConfig { + /// Board type: "nucleo-f401re", "rpi-gpio", "esp32", etc. + pub board: String, + /// Transport: "serial", "native", "websocket" + #[serde(default = "default_peripheral_transport")] + pub transport: String, + /// Path for serial: "/dev/ttyACM0", "/dev/ttyUSB0" + #[serde(default)] + pub path: Option, + /// Baud rate for serial (default: 115200) + #[serde(default = "default_peripheral_baud")] + pub baud: u32, +} + +fn default_peripheral_transport() -> String { + "serial".into() +} + +fn default_peripheral_baud() -> u32 { + 115200 +} + +impl Default for PeripheralsConfig { + fn default() -> Self { + Self { + enabled: false, + boards: Vec::new(), + datasheet_dir: None, + } + } +} + +impl Default for PeripheralBoardConfig { + fn default() -> Self { + Self { + board: String::new(), + transport: default_peripheral_transport(), + path: None, + baud: default_peripheral_baud(), + } + } } // ── Gateway security ───────────────────────────────────────────── @@ -1381,9 +1519,10 @@ impl Default for Config { http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), - hardware: crate::hardware::HardwareConfig::default(), + peripherals: PeripheralsConfig::default(), + agent: AgentConfig::default(), agents: HashMap::new(), - security: SecurityConfig::default(), + hardware: HardwareConfig::default(), } } } @@ -1410,37 +1549,36 @@ impl Config { // Set computed paths that are skipped during serialization config.config_path = config_path.clone(); config.workspace_dir = zeroclaw_dir.join("workspace"); - - // Decrypt agent API keys if encryption is enabled - let store = crate::security::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt); - for agent in config.agents.values_mut() { - if let Some(ref encrypted_key) = agent.api_key { - agent.api_key = Some( - store - .decrypt(encrypted_key) - .context("Failed to decrypt agent API key")?, - ); - } - } - + config.apply_env_overrides(); Ok(config) } else { let mut config = Config::default(); config.config_path = config_path.clone(); config.workspace_dir = zeroclaw_dir.join("workspace"); config.save()?; + config.apply_env_overrides(); Ok(config) } } /// Apply environment variable overrides to config pub fn apply_env_overrides(&mut self) { - // API Key: ZEROCLAW_API_KEY or API_KEY + // API Key: ZEROCLAW_API_KEY or API_KEY (generic) if let Ok(key) = std::env::var("ZEROCLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) { if !key.is_empty() { self.api_key = Some(key); } } + // API Key: GLM_API_KEY overrides when provider is glm (provider-specific) + if self.default_provider.as_deref() == Some("glm") + || self.default_provider.as_deref() == Some("zhipu") + { + if let Ok(key) = std::env::var("GLM_API_KEY") { + if !key.is_empty() { + self.api_key = Some(key); + } + } + } // Provider: ZEROCLAW_PROVIDER or PROVIDER if let Ok(provider) = @@ -1737,9 +1875,10 @@ mod tests { http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), - hardware: crate::hardware::HardwareConfig::default(), + peripherals: PeripheralsConfig::default(), + agent: AgentConfig::default(), agents: HashMap::new(), - security: SecurityConfig::default(), + hardware: HardwareConfig::default(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); @@ -1814,9 +1953,10 @@ default_temperature = 0.7 http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), - hardware: crate::hardware::HardwareConfig::default(), + peripherals: PeripheralsConfig::default(), + agent: AgentConfig::default(), agents: HashMap::new(), - security: SecurityConfig::default(), + hardware: HardwareConfig::default(), }; config.save().unwrap(); @@ -2637,236 +2777,41 @@ default_temperature = 0.7 assert!(g.paired_tokens.is_empty()); } - // ── Lark config ─────────────────────────────────────────────── + // ── Peripherals config ─────────────────────────────────────── #[test] - fn lark_config_serde() { - let lc = LarkConfig { - app_id: "cli_123456".into(), - app_secret: "secret_abc".into(), - encrypt_key: Some("encrypt_key".into()), - verification_token: Some("verify_token".into()), - allowed_users: vec!["user_123".into(), "user_456".into()], - use_feishu: true, + fn peripherals_config_default_disabled() { + let p = PeripheralsConfig::default(); + assert!(!p.enabled); + assert!(p.boards.is_empty()); + } + + #[test] + fn peripheral_board_config_defaults() { + let b = PeripheralBoardConfig::default(); + assert!(b.board.is_empty()); + assert_eq!(b.transport, "serial"); + assert!(b.path.is_none()); + assert_eq!(b.baud, 115200); + } + + #[test] + fn peripherals_config_toml_roundtrip() { + let p = PeripheralsConfig { + enabled: true, + boards: vec![PeripheralBoardConfig { + board: "nucleo-f401re".into(), + transport: "serial".into(), + path: Some("/dev/ttyACM0".into()), + baud: 115200, + }], + datasheet_dir: None, }; - let json = serde_json::to_string(&lc).unwrap(); - let parsed: LarkConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.app_id, "cli_123456"); - assert_eq!(parsed.app_secret, "secret_abc"); - assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key")); - assert_eq!(parsed.verification_token.as_deref(), Some("verify_token")); - assert_eq!(parsed.allowed_users.len(), 2); - assert!(parsed.use_feishu); - } - - #[test] - fn lark_config_toml_roundtrip() { - let lc = LarkConfig { - app_id: "cli_123456".into(), - app_secret: "secret_abc".into(), - encrypt_key: Some("encrypt_key".into()), - verification_token: Some("verify_token".into()), - allowed_users: vec!["*".into()], - use_feishu: false, - }; - let toml_str = toml::to_string(&lc).unwrap(); - let parsed: LarkConfig = toml::from_str(&toml_str).unwrap(); - assert_eq!(parsed.app_id, "cli_123456"); - assert_eq!(parsed.app_secret, "secret_abc"); - assert!(!parsed.use_feishu); - } - - #[test] - fn lark_config_deserializes_without_optional_fields() { - let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#; - let parsed: LarkConfig = serde_json::from_str(json).unwrap(); - assert!(parsed.encrypt_key.is_none()); - assert!(parsed.verification_token.is_none()); - assert!(parsed.allowed_users.is_empty()); - assert!(!parsed.use_feishu); - } - - #[test] - fn lark_config_defaults_to_lark_endpoint() { - let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#; - let parsed: LarkConfig = serde_json::from_str(json).unwrap(); - assert!( - !parsed.use_feishu, - "use_feishu should default to false (Lark)" - ); - } - - #[test] - fn lark_config_with_wildcard_allowed_users() { - let json = r#"{"app_id":"cli_123","app_secret":"secret","allowed_users":["*"]}"#; - let parsed: LarkConfig = serde_json::from_str(json).unwrap(); - assert_eq!(parsed.allowed_users, vec!["*"]); - } - - // ══════════════════════════════════════════════════════════ - // AGENT DELEGATION CONFIG TESTS - // ══════════════════════════════════════════════════════════ - - #[test] - fn agents_config_default_empty() { - let c = Config::default(); - assert!(c.agents.is_empty()); - } - - #[test] - fn agents_config_backward_compat_missing_section() { - let minimal = r#" -workspace_dir = "/tmp/ws" -config_path = "/tmp/config.toml" -default_temperature = 0.7 -"#; - let parsed: Config = toml::from_str(minimal).unwrap(); - assert!(parsed.agents.is_empty()); - } - - #[test] - fn agents_config_toml_roundtrip() { - let toml_str = r#" -default_temperature = 0.7 - -[agents.researcher] -provider = "gemini" -model = "gemini-2.0-flash" -system_prompt = "You are a research assistant." -max_depth = 2 - -[agents.coder] -provider = "openrouter" -model = "anthropic/claude-sonnet-4-20250514" -"#; - let parsed: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(parsed.agents.len(), 2); - - let researcher = &parsed.agents["researcher"]; - assert_eq!(researcher.provider, "gemini"); - assert_eq!(researcher.model, "gemini-2.0-flash"); - assert_eq!( - researcher.system_prompt.as_deref(), - Some("You are a research assistant.") - ); - assert_eq!(researcher.max_depth, 2); - assert!(researcher.api_key.is_none()); - assert!(researcher.temperature.is_none()); - - let coder = &parsed.agents["coder"]; - assert_eq!(coder.provider, "openrouter"); - assert_eq!(coder.model, "anthropic/claude-sonnet-4-20250514"); - assert!(coder.system_prompt.is_none()); - assert_eq!(coder.max_depth, 3); // default - } - - #[test] - fn agents_config_with_api_key_and_temperature() { - let toml_str = r#" -[agents.fast] -provider = "groq" -model = "llama-3.3-70b-versatile" -api_key = "gsk-test-key" -temperature = 0.3 -"#; - let parsed: HashMap = toml::from_str::(toml_str) - .unwrap()["agents"] - .clone() - .try_into() - .unwrap(); - let fast = &parsed["fast"]; - assert_eq!(fast.api_key.as_deref(), Some("gsk-test-key")); - assert!((fast.temperature.unwrap() - 0.3).abs() < f64::EPSILON); - } - - #[test] - fn agent_api_key_encrypted_on_save_and_decrypted_on_load() { - let tmp = TempDir::new().unwrap(); - let zeroclaw_dir = tmp.path(); - let config_path = zeroclaw_dir.join("config.toml"); - - // Create a config with a plaintext agent API key - let mut agents = HashMap::new(); - agents.insert( - "test_agent".to_string(), - DelegateAgentConfig { - provider: "openrouter".to_string(), - model: "test-model".to_string(), - system_prompt: None, - api_key: Some("sk-super-secret".to_string()), - temperature: None, - max_depth: 3, - }, - ); - let config = Config { - config_path: config_path.clone(), - workspace_dir: zeroclaw_dir.join("workspace"), - secrets: SecretsConfig { encrypt: true }, - agents, - ..Config::default() - }; - std::fs::create_dir_all(&config.workspace_dir).unwrap(); - config.save().unwrap(); - - // Read the raw TOML and verify the key is encrypted (not plaintext) - let raw = std::fs::read_to_string(&config_path).unwrap(); - assert!( - !raw.contains("sk-super-secret"), - "Plaintext API key should not appear in saved config" - ); - assert!( - raw.contains("enc2:"), - "Encrypted key should use enc2: prefix" - ); - - // Parse and decrypt — simulate load_or_init by reading + decrypting - let store = crate::security::SecretStore::new(zeroclaw_dir, true); - let mut loaded: Config = toml::from_str(&raw).unwrap(); - for agent in loaded.agents.values_mut() { - if let Some(ref encrypted_key) = agent.api_key { - agent.api_key = Some(store.decrypt(encrypted_key).unwrap()); - } - } - assert_eq!( - loaded.agents["test_agent"].api_key.as_deref(), - Some("sk-super-secret"), - "Decrypted key should match original" - ); - } - - #[test] - fn agent_api_key_not_encrypted_when_disabled() { - let tmp = TempDir::new().unwrap(); - let zeroclaw_dir = tmp.path(); - let config_path = zeroclaw_dir.join("config.toml"); - - let mut agents = HashMap::new(); - agents.insert( - "test_agent".to_string(), - DelegateAgentConfig { - provider: "openrouter".to_string(), - model: "test-model".to_string(), - system_prompt: None, - api_key: Some("sk-plaintext-ok".to_string()), - temperature: None, - max_depth: 3, - }, - ); - let config = Config { - config_path: config_path.clone(), - workspace_dir: zeroclaw_dir.join("workspace"), - secrets: SecretsConfig { encrypt: false }, - agents, - ..Config::default() - }; - std::fs::create_dir_all(&config.workspace_dir).unwrap(); - config.save().unwrap(); - - let raw = std::fs::read_to_string(&config_path).unwrap(); - assert!( - raw.contains("sk-plaintext-ok"), - "With encryption disabled, key should remain plaintext" - ); - assert!(!raw.contains("enc2:"), "No encryption prefix when disabled"); + let toml_str = toml::to_string(&p).unwrap(); + let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap(); + assert!(parsed.enabled); + assert_eq!(parsed.boards.len(), 1); + assert_eq!(parsed.boards[0].board, "nucleo-f401re"); + assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0")); } } diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index f1bc4a1..c7935ca 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -194,7 +194,7 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> { let prompt = format!("[Heartbeat Task] {task}"); let temp = config.default_temperature; if let Err(e) = - crate::agent::run(config.clone(), Some(prompt), None, None, temp, false).await + crate::agent::run(config.clone(), Some(prompt), None, None, temp, vec![]).await { crate::health::mark_component_error("heartbeat", e.to_string()); tracing::warn!("Heartbeat task failed: {e}"); diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 638de00..baf66fc 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -73,6 +73,7 @@ async fn gateway_agent_reply(state: &AppState, message: &str) -> Result "gateway", &state.model, state.temperature, + true, // silent — gateway responses go over HTTP ) .await?; @@ -285,6 +286,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { &tool_descs, &skills, Some(&config.identity), + None, // bootstrap_max_chars — no compact context for gateway ); system_prompt.push_str(&crate::agent::loop_::build_tool_instructions( tools_registry.as_ref(), diff --git a/src/hardware/discover.rs b/src/hardware/discover.rs new file mode 100644 index 0000000..4bbf31f --- /dev/null +++ b/src/hardware/discover.rs @@ -0,0 +1,45 @@ +//! USB device discovery — enumerate devices and enrich with board registry. + +use super::registry; +use anyhow::Result; +use nusb::MaybeFuture; + +/// Information about a discovered USB device. +#[derive(Debug, Clone)] +pub struct UsbDeviceInfo { + pub bus_id: String, + pub device_address: u8, + pub vid: u16, + pub pid: u16, + pub product_string: Option, + pub board_name: Option, + pub architecture: Option, +} + +/// Enumerate all connected USB devices and enrich with board registry lookup. +#[cfg(feature = "hardware")] +pub fn list_usb_devices() -> Result> { + let mut devices = Vec::new(); + + let iter = nusb::list_devices() + .wait() + .map_err(|e| anyhow::anyhow!("USB enumeration failed: {e}"))?; + + for dev in iter { + let vid = dev.vendor_id(); + let pid = dev.product_id(); + let board = registry::lookup_board(vid, pid); + + devices.push(UsbDeviceInfo { + bus_id: dev.bus_id().to_string(), + device_address: dev.device_address(), + vid, + pid, + product_string: dev.product_string().map(String::from), + board_name: board.map(|b| b.name.to_string()), + architecture: board.and_then(|b| b.architecture.map(String::from)), + }); + } + + Ok(devices) +} diff --git a/src/hardware/introspect.rs b/src/hardware/introspect.rs new file mode 100644 index 0000000..21b5744 --- /dev/null +++ b/src/hardware/introspect.rs @@ -0,0 +1,121 @@ +//! Device introspection — correlate serial path with USB device info. + +use super::discover; +use super::registry; +use anyhow::Result; + +/// Result of introspecting a device by path. +#[derive(Debug, Clone)] +pub struct IntrospectResult { + pub path: String, + pub vid: Option, + pub pid: Option, + pub board_name: Option, + pub architecture: Option, + pub memory_map_note: String, +} + +/// Introspect a device by its serial path (e.g. /dev/ttyACM0, /dev/tty.usbmodem*). +/// Attempts to correlate with USB devices from discovery. +#[cfg(feature = "hardware")] +pub fn introspect_device(path: &str) -> Result { + let devices = discover::list_usb_devices()?; + + // Try to correlate path with a discovered device. + // On Linux, /dev/ttyACM0 corresponds to a CDC-ACM device; we may have multiple. + // Best-effort: if we have exactly one CDC-like device, use it. Otherwise unknown. + let matched = if devices.len() == 1 { + devices.first().cloned() + } else if devices.is_empty() { + None + } else { + // Multiple devices: try to match by path. On Linux we could use sysfs; + // for stub, pick first known board or first device. + devices + .iter() + .find(|d| d.board_name.is_some()) + .cloned() + .or_else(|| devices.first().cloned()) + }; + + let (vid, pid, board_name, architecture) = match matched { + Some(d) => (Some(d.vid), Some(d.pid), d.board_name, d.architecture), + None => (None, None, None, None), + }; + + let board_info = vid.and_then(|v| pid.and_then(|p| registry::lookup_board(v, p))); + let architecture = + architecture.or_else(|| board_info.and_then(|b| b.architecture.map(String::from))); + let board_name = board_name.or_else(|| board_info.map(|b| b.name.to_string())); + + let memory_map_note = memory_map_for_board(board_name.as_deref()); + + Ok(IntrospectResult { + path: path.to_string(), + vid, + pid, + board_name, + architecture, + memory_map_note, + }) +} + +/// Get memory map: via probe-rs when probe feature on and Nucleo, else static or stub. +#[cfg(feature = "hardware")] +fn memory_map_for_board(board_name: Option<&str>) -> String { + #[cfg(feature = "probe")] + if let Some(board) = board_name { + let chip = match board { + "nucleo-f401re" => "STM32F401RETx", + "nucleo-f411re" => "STM32F411RETx", + _ => return "Build with --features probe for live memory map (Nucleo)".to_string(), + }; + match probe_memory_map(chip) { + Ok(s) => return s, + Err(_) => return format!("probe-rs attach failed (chip {}). Connect via USB.", chip), + } + } + + #[cfg(not(feature = "probe"))] + let _ = board_name; + + "Build with --features probe for live memory map via USB".to_string() +} + +#[cfg(all(feature = "hardware", feature = "probe"))] +fn probe_memory_map(chip: &str) -> anyhow::Result { + use probe_rs::config::MemoryRegion; + use probe_rs::{Session, SessionConfig}; + + let session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let target = session.target(); + let mut out = String::new(); + for region in target.memory_map.iter() { + match region { + MemoryRegion::Ram(ram) => { + let (start, end) = (ram.range.start, ram.range.end); + out.push_str(&format!( + "RAM: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, + end, + (end - start) / 1024 + )); + } + MemoryRegion::Nvm(flash) => { + let (start, end) = (flash.range.start, flash.range.end); + out.push_str(&format!( + "Flash: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, + end, + (end - start) / 1024 + )); + } + _ => {} + } + } + if out.is_empty() { + out = "Could not read memory regions".to_string(); + } + Ok(out) +} diff --git a/src/hardware/mod.rs b/src/hardware/mod.rs index ff467f5..8dcd90d 100644 --- a/src/hardware/mod.rs +++ b/src/hardware/mod.rs @@ -1,1348 +1,229 @@ -//! Hardware Abstraction Layer (HAL) for ZeroClaw. +//! Hardware discovery — USB device enumeration and introspection. //! -//! Provides auto-discovery of connected hardware, transport abstraction, -//! and a unified interface so the LLM agent can control physical devices -//! without knowing the underlying communication protocol. -//! -//! # Supported Transport Modes -//! -//! | Transport | Backend | Use Case | -//! |-----------|-------------|---------------------------------------------| -//! | `native` | rppal / sysfs | Raspberry Pi / Linux SBC with local GPIO | -//! | `serial` | JSON/UART | Arduino, ESP32, Nucleo via USB serial | -//! | `probe` | probe-rs | STM32/ESP32 via SWD/JTAG debug interface | -//! | `none` | — | Software-only mode (no hardware access) | +//! See `docs/hardware-peripherals-design.md` for the full design. -use anyhow::{bail, Result}; -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +pub mod registry; -// ── Hardware transport enum ────────────────────────────────────── +#[cfg(feature = "hardware")] +pub mod discover; -/// Transport protocol used to communicate with physical hardware. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum HardwareTransport { - /// Direct GPIO access on a Linux SBC (Raspberry Pi, Orange Pi, etc.) - Native, - /// JSON commands over USB serial (Arduino, ESP32, Nucleo) - Serial, - /// SWD/JTAG debug probe (probe-rs) for bare-metal MCUs - Probe, - /// No hardware — software-only mode - #[default] - None, +#[cfg(feature = "hardware")] +pub mod introspect; + +use crate::config::Config; +use anyhow::Result; + +// Re-export config types so wizard can use `hardware::HardwareConfig` etc. +pub use crate::config::{HardwareConfig, HardwareTransport}; + +/// A hardware device discovered during auto-scan. +#[derive(Debug, Clone)] +pub struct DiscoveredDevice { + pub name: String, + pub detail: Option, + pub device_path: Option, + pub transport: HardwareTransport, } -impl std::fmt::Display for HardwareTransport { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Native => write!(f, "native"), - Self::Serial => write!(f, "serial"), - Self::Probe => write!(f, "probe"), - Self::None => write!(f, "none"), +/// Auto-discover connected hardware devices. +/// Returns an empty vec on platforms without hardware support. +pub fn discover_hardware() -> Vec { + // USB/serial discovery is behind the "hardware" feature gate. + #[cfg(feature = "hardware")] + { + if let Ok(devices) = discover::list_usb_devices() { + return devices + .into_iter() + .map(|d| DiscoveredDevice { + name: d + .board_name + .unwrap_or_else(|| format!("{:04x}:{:04x}", d.vid, d.pid)), + detail: d.product_string, + device_path: None, + transport: if d.architecture.as_deref() == Some("native") { + HardwareTransport::Native + } else { + HardwareTransport::Serial + }, + }) + .collect(); } } + Vec::new() +} + +/// Return the recommended default wizard choice index based on discovered devices. +/// 0 = Native, 1 = Tethered/Serial, 2 = Debug Probe, 3 = Software Only +pub fn recommended_wizard_default(devices: &[DiscoveredDevice]) -> usize { + if devices.is_empty() { + 3 // software only + } else { + 1 // tethered (most common for detected USB devices) + } } -impl HardwareTransport { - /// Parse from a string value (config file or CLI arg). - pub fn from_str_loose(s: &str) -> Self { - match s.to_ascii_lowercase().trim() { - "native" | "gpio" | "rppal" | "sysfs" => Self::Native, - "serial" | "uart" | "usb" | "tethered" => Self::Serial, - "probe" | "probe-rs" | "swd" | "jtag" | "jlink" | "j-link" => Self::Probe, - _ => Self::None, +/// Build a `HardwareConfig` from the wizard menu choice (0–3) and discovered devices. +pub fn config_from_wizard_choice(choice: usize, devices: &[DiscoveredDevice]) -> HardwareConfig { + match choice { + 0 => HardwareConfig { + enabled: true, + transport: HardwareTransport::Native, + ..HardwareConfig::default() + }, + 1 => { + let serial_port = devices + .iter() + .find(|d| d.transport == HardwareTransport::Serial) + .and_then(|d| d.device_path.clone()); + HardwareConfig { + enabled: true, + transport: HardwareTransport::Serial, + serial_port, + ..HardwareConfig::default() + } } + 2 => HardwareConfig { + enabled: true, + transport: HardwareTransport::Probe, + ..HardwareConfig::default() + }, + _ => HardwareConfig::default(), // software only } } -// ── Hardware configuration ────────────────────────────────────── +/// Handle `zeroclaw hardware` subcommands. +#[allow(clippy::module_name_repetitions)] +pub fn handle_command(cmd: crate::HardwareCommands, _config: &Config) -> Result<()> { + #[cfg(not(feature = "hardware"))] + { + println!("Hardware discovery requires the 'hardware' feature."); + println!("Build with: cargo build --features hardware"); + return Ok(()); + } -/// Hardware configuration stored in `config.toml` under `[hardware]`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HardwareConfig { - /// Enable hardware integration - #[serde(default)] - pub enabled: bool, - - /// Transport mode: "native", "serial", "probe", "none" - #[serde(default = "default_transport")] - pub transport: String, - - /// Serial port path (e.g. `/dev/ttyUSB0`, `/dev/tty.usbmodem14201`) - #[serde(default)] - pub serial_port: Option, - - /// Serial baud rate (default: 115200) - #[serde(default = "default_baud_rate")] - pub baud_rate: u32, - - /// Enable datasheet RAG — index PDF schematics in workspace for pin lookups - #[serde(default)] - pub workspace_datasheets: bool, - - /// Auto-discovered board description (informational, set by discovery) - #[serde(default)] - pub discovered_board: Option, - - /// Probe target chip (e.g. "STM32F411CEUx", "nRF52840_xxAA") - #[serde(default)] - pub probe_target: Option, - - /// GPIO pin safety allowlist — only these pins can be written to. - /// Empty = all pins allowed (for development). Recommended for production. - #[serde(default)] - pub allowed_pins: Vec, - - /// Maximum PWM frequency in Hz (safety cap, default: 50_000) - #[serde(default = "default_max_pwm_freq")] - pub max_pwm_frequency_hz: u32, -} - -fn default_transport() -> String { - "none".into() -} - -fn default_baud_rate() -> u32 { - 115_200 -} - -fn default_max_pwm_freq() -> u32 { - 50_000 -} - -impl Default for HardwareConfig { - fn default() -> Self { - Self { - enabled: false, - transport: default_transport(), - serial_port: None, - baud_rate: default_baud_rate(), - workspace_datasheets: false, - discovered_board: None, - probe_target: None, - allowed_pins: Vec::new(), - max_pwm_frequency_hz: default_max_pwm_freq(), - } + #[cfg(feature = "hardware")] + match cmd { + crate::HardwareCommands::Discover => run_discover(), + crate::HardwareCommands::Introspect { path } => run_introspect(&path), + crate::HardwareCommands::Info { chip } => run_info(&chip), } } -impl HardwareConfig { - /// Return the parsed transport enum. - pub fn transport_mode(&self) -> HardwareTransport { - HardwareTransport::from_str_loose(&self.transport) +#[cfg(feature = "hardware")] +fn run_discover() -> Result<()> { + let devices = discover::list_usb_devices()?; + + if devices.is_empty() { + println!("No USB devices found."); + println!(); + println!("Connect a board (e.g. Nucleo-F401RE) via USB and try again."); + return Ok(()); } - /// Check if pin access is allowed by the safety allowlist. - /// An empty allowlist means all pins are permitted (dev mode). - pub fn is_pin_allowed(&self, pin: u8) -> bool { - self.allowed_pins.is_empty() || self.allowed_pins.contains(&pin) + println!("USB devices:"); + println!(); + for d in &devices { + let board = d.board_name.as_deref().unwrap_or("(unknown)"); + let arch = d.architecture.as_deref().unwrap_or("—"); + let product = d.product_string.as_deref().unwrap_or("—"); + println!( + " {:04x}:{:04x} {} {} {}", + d.vid, d.pid, board, arch, product + ); + } + println!(); + println!("Known boards: nucleo-f401re, nucleo-f411re, arduino-uno, arduino-mega, cp2102"); + + Ok(()) +} + +#[cfg(feature = "hardware")] +fn run_introspect(path: &str) -> Result<()> { + let result = introspect::introspect_device(path)?; + + println!("Device at {}:", result.path); + println!(); + if let (Some(vid), Some(pid)) = (result.vid, result.pid) { + println!(" VID:PID {:04x}:{:04x}", vid, pid); + } else { + println!(" VID:PID (could not correlate with USB device)"); + } + if let Some(name) = &result.board_name { + println!(" Board {}", name); + } + if let Some(arch) = &result.architecture { + println!(" Architecture {}", arch); + } + println!(" Memory map {}", result.memory_map_note); + + Ok(()) +} + +#[cfg(feature = "hardware")] +fn run_info(chip: &str) -> Result<()> { + #[cfg(feature = "probe")] + { + match info_via_probe(chip) { + Ok(()) => return Ok(()), + Err(e) => { + println!("probe-rs attach failed: {}", e); + println!(); + println!( + "Ensure Nucleo is connected via USB. The ST-Link is built into the board." + ); + println!("No firmware needs to be flashed — probe-rs reads chip info over SWD."); + return Err(e.into()); + } + } } - /// Validate the configuration, returning errors for invalid combos. - pub fn validate(&self) -> Result<()> { - if !self.enabled { - return Ok(()); - } - - let mode = self.transport_mode(); - - // Serial requires a port - if mode == HardwareTransport::Serial && self.serial_port.is_none() { - bail!("Hardware transport is 'serial' but no serial_port is configured. Run `zeroclaw onboard --interactive` or set hardware.serial_port in config.toml."); - } - - // Probe requires a target chip - if mode == HardwareTransport::Probe && self.probe_target.is_none() { - bail!("Hardware transport is 'probe' but no probe_target chip is configured. Set hardware.probe_target in config.toml (e.g. \"STM32F411CEUx\")."); - } - - // Baud rate sanity - if self.baud_rate == 0 { - bail!("hardware.baud_rate must be greater than 0."); - } - if self.baud_rate > 4_000_000 { - bail!( - "hardware.baud_rate of {} exceeds the 4 MHz safety limit.", - self.baud_rate - ); - } - - // PWM frequency sanity - if self.max_pwm_frequency_hz == 0 { - bail!("hardware.max_pwm_frequency_hz must be greater than 0."); - } - + #[cfg(not(feature = "probe"))] + { + println!("Chip info via USB requires the 'probe' feature."); + println!(); + println!("Build with: cargo build --features hardware,probe"); + println!(); + println!("Then run: zeroclaw hardware info --chip {}", chip); + println!(); + println!("This uses probe-rs to attach to the Nucleo's ST-Link over USB"); + println!("and read chip info (memory map, etc.) — no firmware on target needed."); Ok(()) } } -// ── Discovery: detected hardware on this system ───────────────── +#[cfg(all(feature = "hardware", feature = "probe"))] +fn info_via_probe(chip: &str) -> anyhow::Result<()> { + use probe_rs::config::MemoryRegion; + use probe_rs::{Session, SessionConfig}; -/// A single discovered hardware device. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DiscoveredDevice { - /// Human-readable name (e.g. "Raspberry Pi GPIO", "Arduino Uno") - pub name: String, - /// Recommended transport mode - pub transport: HardwareTransport, - /// Path to the device (e.g. `/dev/ttyUSB0`, `/dev/gpiomem`) - pub device_path: Option, - /// Additional detail (e.g. board revision, chip ID) - pub detail: Option, -} + println!("Connecting to {} via USB (ST-Link)...", chip); + let session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("{}", e))?; -/// Scan the system for connected hardware. -/// -/// This function performs non-destructive, read-only probes: -/// 1. Check for Raspberry Pi GPIO (`/dev/gpiomem`, `/proc/device-tree/model`) -/// 2. Check for USB serial devices (`/dev/ttyUSB*`, `/dev/ttyACM*`, `/dev/tty.usbmodem*`) -/// 3. Check for SWD/JTAG probes (`/dev/ttyACM*` with probe-rs markers) -/// -/// This is intentionally conservative — it never writes to any device. -pub fn discover_hardware() -> Vec { - let mut devices = Vec::new(); - - // ── 1. Raspberry Pi / Linux SBC native GPIO ────────────── - discover_native_gpio(&mut devices); - - // ── 2. USB Serial devices (Arduino, ESP32, etc.) ───────── - discover_serial_devices(&mut devices); - - // ── 3. SWD / JTAG debug probes ────────────────────────── - discover_debug_probes(&mut devices); - - devices -} - -/// Check for native GPIO availability (Raspberry Pi, Orange Pi, etc.) -fn discover_native_gpio(devices: &mut Vec) { - // Primary indicator: /dev/gpiomem exists (Pi-specific) - let gpiomem = Path::new("/dev/gpiomem"); - // Secondary: /dev/gpiochip0 exists (any Linux with GPIO) - let gpiochip = Path::new("/dev/gpiochip0"); - - if gpiomem.exists() || gpiochip.exists() { - // Try to read model from device tree - let model = read_board_model(); - let name = model.as_deref().unwrap_or("Linux SBC with GPIO"); - - devices.push(DiscoveredDevice { - name: format!("{name} (Native GPIO)"), - transport: HardwareTransport::Native, - device_path: Some(if gpiomem.exists() { - "/dev/gpiomem".into() - } else { - "/dev/gpiochip0".into() - }), - detail: model, - }); - } -} - -/// Read the board model string from the device tree (Linux). -fn read_board_model() -> Option { - let model_path = Path::new("/proc/device-tree/model"); - if model_path.exists() { - std::fs::read_to_string(model_path) - .ok() - .map(|s| s.trim_end_matches('\0').trim().to_string()) - .filter(|s| !s.is_empty()) - } else { - None - } -} - -/// Scan for USB serial devices. -fn discover_serial_devices(devices: &mut Vec) { - let serial_patterns = serial_device_paths(); - - for pattern in &serial_patterns { - let matches = glob_paths(pattern); - for path in matches { - let name = classify_serial_device(&path); - devices.push(DiscoveredDevice { - name: format!("{name} (USB Serial)"), - transport: HardwareTransport::Serial, - device_path: Some(path.to_string_lossy().to_string()), - detail: None, - }); - } - } -} - -/// Return platform-specific glob patterns for serial devices. -fn serial_device_paths() -> Vec { - if cfg!(target_os = "macos") { - vec![ - "/dev/tty.usbmodem*".into(), - "/dev/tty.usbserial*".into(), - "/dev/tty.wchusbserial*".into(), // CH340 clones - ] - } else if cfg!(target_os = "linux") { - vec!["/dev/ttyUSB*".into(), "/dev/ttyACM*".into()] - } else { - // Windows / other — not yet supported for auto-discovery - vec![] - } -} - -/// Classify a serial device path into a human-readable name. -fn classify_serial_device(path: &Path) -> String { - let name = path.file_name().unwrap_or_default().to_string_lossy(); - let lower = name.to_ascii_lowercase(); - - if lower.contains("usbmodem") { - "Arduino/Teensy".into() - } else if lower.contains("usbserial") || lower.contains("ttyusb") { - "USB-Serial Device (FTDI/CH340/CP2102)".into() - } else if lower.contains("wchusbserial") { - "CH340/CH341 Serial".into() - } else if lower.contains("ttyacm") { - "USB CDC Device (Arduino/STM32)".into() - } else { - "Unknown Serial Device".into() - } -} - -/// Simple glob expansion for device paths. -fn glob_paths(pattern: &str) -> Vec { - glob::glob(pattern) - .map(|paths| paths.filter_map(Result::ok).collect()) - .unwrap_or_default() -} - -/// Check for SWD/JTAG debug probes. -fn discover_debug_probes(devices: &mut Vec) { - // On Linux, ST-Link probes often show up as /dev/stlinkv* - // We also check for known USB VIDs via sysfs if available - let stlink_paths = glob_paths("/dev/stlinkv*"); - for path in stlink_paths { - devices.push(DiscoveredDevice { - name: "ST-Link Debug Probe (SWD)".into(), - transport: HardwareTransport::Probe, - device_path: Some(path.to_string_lossy().to_string()), - detail: Some("Use probe-rs for flash/debug".into()), - }); - } - - // J-Link probes on macOS - let jlink_paths = glob_paths("/dev/tty.SLAB_USBtoUART*"); - for path in jlink_paths { - devices.push(DiscoveredDevice { - name: "SEGGER J-Link (SWD/JTAG)".into(), - transport: HardwareTransport::Probe, - device_path: Some(path.to_string_lossy().to_string()), - detail: Some("Use probe-rs for flash/debug".into()), - }); - } -} - -// ── HAL Trait: Unified hardware operations ────────────────────── - -/// The core HAL trait that all transport backends implement. -/// -/// The LLM agent calls these methods via tool invocations. The HAL -/// translates them into the correct protocol for the underlying hardware. -pub trait HardwareHal: Send + Sync { - /// Read the digital state of a GPIO pin. - fn gpio_read(&self, pin: u8) -> Result; - - /// Write a digital value to a GPIO pin. - fn gpio_write(&self, pin: u8, value: bool) -> Result<()>; - - /// Read a memory address (for probe-rs or memory-mapped I/O). - fn memory_read(&self, address: u32, length: u32) -> Result>; - - /// Upload firmware to a connected device (Arduino sketch, STM32 binary). - fn firmware_upload(&self, path: &Path) -> Result<()>; - - /// Return a human-readable description of the connected hardware. - fn describe(&self) -> String; - - /// Set PWM duty cycle on a pin (0–100%). - fn pwm_set(&self, pin: u8, duty_percent: f32) -> Result<()>; - - /// Read an analog value (ADC) from a pin, returning 0.0–1.0. - fn analog_read(&self, pin: u8) -> Result; -} - -// ── NoopHal: used in software-only mode ───────────────────────── - -/// A no-op HAL implementation for software-only mode. -/// All hardware operations return descriptive errors. -pub struct NoopHal; - -impl HardwareHal for NoopHal { - fn gpio_read(&self, pin: u8) -> Result { - bail!("Hardware not enabled. Cannot read GPIO pin {pin}. Enable hardware in config.toml or run `zeroclaw onboard --interactive`."); - } - - fn gpio_write(&self, pin: u8, value: bool) -> Result<()> { - bail!("Hardware not enabled. Cannot write GPIO pin {pin}={value}. Enable hardware in config.toml."); - } - - fn memory_read(&self, address: u32, _length: u32) -> Result> { - bail!("Hardware not enabled. Cannot read memory at 0x{address:08X}."); - } - - fn firmware_upload(&self, path: &Path) -> Result<()> { - bail!( - "Hardware not enabled. Cannot upload firmware from {}.", - path.display() - ); - } - - fn describe(&self) -> String { - "NoopHal (software-only mode — no hardware connected)".into() - } - - fn pwm_set(&self, pin: u8, _duty_percent: f32) -> Result<()> { - bail!("Hardware not enabled. Cannot set PWM on pin {pin}."); - } - - fn analog_read(&self, pin: u8) -> Result { - bail!("Hardware not enabled. Cannot read analog pin {pin}."); - } -} - -// ── Factory: create the right HAL from config ─────────────────── - -/// Create the appropriate HAL backend from the hardware configuration. -/// -/// This is the main entry point — call this once at startup and pass -/// the resulting `Box` to the tool registry. -pub fn create_hal(config: &HardwareConfig) -> Result> { - config.validate()?; - - if !config.enabled { - return Ok(Box::new(NoopHal)); - } - - match config.transport_mode() { - HardwareTransport::None => Ok(Box::new(NoopHal)), - HardwareTransport::Native => { - // In a full implementation, this would return a RppalHal or SysfsHal. - // For now, we return a stub that validates the transport is correct. - bail!( - "Native GPIO transport requires the `rppal` crate (Raspberry Pi only). \ - This will be available in a future release. For now, use 'serial' transport \ - with an Arduino/ESP32 bridge." - ); - } - HardwareTransport::Serial => { - let port = config.serial_port.as_deref().unwrap_or("/dev/ttyUSB0"); - // In a full implementation, this would open the serial port and - // return a SerialHal that sends JSON commands over UART. - bail!( - "Serial transport to '{}' at {} baud is configured but the serial HAL \ - backend is not yet compiled in. This will be available in the next release.", - port, - config.baud_rate - ); - } - HardwareTransport::Probe => { - let target = config.probe_target.as_deref().unwrap_or("unknown"); - bail!( - "Probe transport targeting '{}' is configured but the probe-rs HAL \ - backend is not yet compiled in. This will be available in a future release.", - target - ); - } - } -} - -// ── Wizard helper: build config from discovery ────────────────── - -/// Determine the best default selection index for the wizard -/// based on discovery results. -pub fn recommended_wizard_default(devices: &[DiscoveredDevice]) -> usize { - // If we found native GPIO → recommend Native (index 0) - if devices - .iter() - .any(|d| d.transport == HardwareTransport::Native) - { - return 0; - } - // If we found serial devices → recommend Tethered (index 1) - if devices - .iter() - .any(|d| d.transport == HardwareTransport::Serial) - { - return 1; - } - // If we found debug probes → recommend Probe (index 2) - if devices - .iter() - .any(|d| d.transport == HardwareTransport::Probe) - { - return 2; - } - // Default: Software Only (index 3) - 3 -} - -/// Build a `HardwareConfig` from a wizard selection and discovered devices. -pub fn config_from_wizard_choice(choice: usize, devices: &[DiscoveredDevice]) -> HardwareConfig { - match choice { - // Native - 0 => { - let native_device = devices - .iter() - .find(|d| d.transport == HardwareTransport::Native); - HardwareConfig { - enabled: true, - transport: "native".into(), - discovered_board: native_device - .and_then(|d| d.detail.clone()) - .or_else(|| native_device.map(|d| d.name.clone())), - ..HardwareConfig::default() + let target = session.target(); + println!(); + println!("Chip: {}", target.name); + println!("Architecture: {:?}", session.architecture()); + println!(); + println!("Memory map:"); + for region in target.memory_map.iter() { + match region { + MemoryRegion::Ram(ram) => { + let start = ram.range.start; + let end = ram.range.end; + let size_kb = (end - start) / 1024; + println!(" RAM: 0x{:08X} - 0x{:08X} ({} KB)", start, end, size_kb); } - } - // Serial / Tethered - 1 => { - let serial_device = devices - .iter() - .find(|d| d.transport == HardwareTransport::Serial); - HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: serial_device.and_then(|d| d.device_path.clone()), - discovered_board: serial_device.map(|d| d.name.clone()), - ..HardwareConfig::default() + MemoryRegion::Nvm(flash) => { + let start = flash.range.start; + let end = flash.range.end; + let size_kb = (end - start) / 1024; + println!(" Flash: 0x{:08X} - 0x{:08X} ({} KB)", start, end, size_kb); } + _ => {} } - // Probe - 2 => { - let probe_device = devices - .iter() - .find(|d| d.transport == HardwareTransport::Probe); - HardwareConfig { - enabled: true, - transport: "probe".into(), - discovered_board: probe_device.map(|d| d.name.clone()), - ..HardwareConfig::default() - } - } - // Software only - _ => HardwareConfig::default(), - } -} - -// ═══════════════════════════════════════════════════════════════════ -// ── Tests ─────────────────────────────────────────────────────── -// ═══════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod tests { - use super::*; - - // ── HardwareTransport parsing ────────────────────────────── - - #[test] - fn transport_parse_native_variants() { - assert_eq!( - HardwareTransport::from_str_loose("native"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose("gpio"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose("rppal"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose("sysfs"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose("NATIVE"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose(" Native "), - HardwareTransport::Native - ); - } - - #[test] - fn transport_parse_serial_variants() { - assert_eq!( - HardwareTransport::from_str_loose("serial"), - HardwareTransport::Serial - ); - assert_eq!( - HardwareTransport::from_str_loose("uart"), - HardwareTransport::Serial - ); - assert_eq!( - HardwareTransport::from_str_loose("usb"), - HardwareTransport::Serial - ); - assert_eq!( - HardwareTransport::from_str_loose("tethered"), - HardwareTransport::Serial - ); - assert_eq!( - HardwareTransport::from_str_loose("SERIAL"), - HardwareTransport::Serial - ); - } - - #[test] - fn transport_parse_probe_variants() { - assert_eq!( - HardwareTransport::from_str_loose("probe"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("probe-rs"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("swd"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("jtag"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("jlink"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("j-link"), - HardwareTransport::Probe - ); - } - - #[test] - fn transport_parse_none_and_unknown() { - assert_eq!( - HardwareTransport::from_str_loose("none"), - HardwareTransport::None - ); - assert_eq!( - HardwareTransport::from_str_loose(""), - HardwareTransport::None - ); - assert_eq!( - HardwareTransport::from_str_loose("foobar"), - HardwareTransport::None - ); - assert_eq!( - HardwareTransport::from_str_loose("bluetooth"), - HardwareTransport::None - ); - } - - #[test] - fn transport_default_is_none() { - assert_eq!(HardwareTransport::default(), HardwareTransport::None); - } - - #[test] - fn transport_display() { - assert_eq!(format!("{}", HardwareTransport::Native), "native"); - assert_eq!(format!("{}", HardwareTransport::Serial), "serial"); - assert_eq!(format!("{}", HardwareTransport::Probe), "probe"); - assert_eq!(format!("{}", HardwareTransport::None), "none"); - } - - // ── HardwareTransport serde ──────────────────────────────── - - #[test] - fn transport_serde_roundtrip() { - let json = serde_json::to_string(&HardwareTransport::Native).unwrap(); - assert_eq!(json, "\"native\""); - let parsed: HardwareTransport = serde_json::from_str("\"serial\"").unwrap(); - assert_eq!(parsed, HardwareTransport::Serial); - let parsed2: HardwareTransport = serde_json::from_str("\"probe\"").unwrap(); - assert_eq!(parsed2, HardwareTransport::Probe); - let parsed3: HardwareTransport = serde_json::from_str("\"none\"").unwrap(); - assert_eq!(parsed3, HardwareTransport::None); - } - - // ── HardwareConfig defaults ──────────────────────────────── - - #[test] - fn config_default_values() { - let cfg = HardwareConfig::default(); - assert!(!cfg.enabled); - assert_eq!(cfg.transport, "none"); - assert_eq!(cfg.baud_rate, 115_200); - assert!(cfg.serial_port.is_none()); - assert!(!cfg.workspace_datasheets); - assert!(cfg.discovered_board.is_none()); - assert!(cfg.probe_target.is_none()); - assert!(cfg.allowed_pins.is_empty()); - assert_eq!(cfg.max_pwm_frequency_hz, 50_000); - } - - #[test] - fn config_transport_mode_maps_correctly() { - let mut cfg = HardwareConfig::default(); - assert_eq!(cfg.transport_mode(), HardwareTransport::None); - - cfg.transport = "native".into(); - assert_eq!(cfg.transport_mode(), HardwareTransport::Native); - - cfg.transport = "serial".into(); - assert_eq!(cfg.transport_mode(), HardwareTransport::Serial); - - cfg.transport = "probe".into(); - assert_eq!(cfg.transport_mode(), HardwareTransport::Probe); - - cfg.transport = "UART".into(); - assert_eq!(cfg.transport_mode(), HardwareTransport::Serial); - } - - // ── HardwareConfig::is_pin_allowed ───────────────────────── - - #[test] - fn pin_allowed_empty_allowlist_permits_all() { - let cfg = HardwareConfig::default(); - assert!(cfg.is_pin_allowed(0)); - assert!(cfg.is_pin_allowed(13)); - assert!(cfg.is_pin_allowed(255)); - } - - #[test] - fn pin_allowed_nonempty_allowlist_restricts() { - let cfg = HardwareConfig { - allowed_pins: vec![2, 13, 27], - ..HardwareConfig::default() - }; - assert!(cfg.is_pin_allowed(2)); - assert!(cfg.is_pin_allowed(13)); - assert!(cfg.is_pin_allowed(27)); - assert!(!cfg.is_pin_allowed(0)); - assert!(!cfg.is_pin_allowed(14)); - assert!(!cfg.is_pin_allowed(255)); - } - - #[test] - fn pin_allowed_single_pin_allowlist() { - let cfg = HardwareConfig { - allowed_pins: vec![13], - ..HardwareConfig::default() - }; - assert!(cfg.is_pin_allowed(13)); - assert!(!cfg.is_pin_allowed(12)); - assert!(!cfg.is_pin_allowed(14)); - } - - // ── HardwareConfig::validate ─────────────────────────────── - - #[test] - fn validate_disabled_always_ok() { - let cfg = HardwareConfig::default(); - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_disabled_ignores_bad_values() { - // Even with invalid values, disabled config should pass - let cfg = HardwareConfig { - enabled: false, - transport: "serial".into(), - serial_port: None, // Would fail if enabled - baud_rate: 0, // Would fail if enabled - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_serial_requires_port() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: None, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("serial_port")); - } - - #[test] - fn validate_serial_with_port_ok() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_probe_requires_target() { - let cfg = HardwareConfig { - enabled: true, - transport: "probe".into(), - probe_target: None, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("probe_target")); - } - - #[test] - fn validate_probe_with_target_ok() { - let cfg = HardwareConfig { - enabled: true, - transport: "probe".into(), - probe_target: Some("STM32F411CEUx".into()), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_native_ok_without_extras() { - let cfg = HardwareConfig { - enabled: true, - transport: "native".into(), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_none_transport_enabled_ok() { - let cfg = HardwareConfig { - enabled: true, - transport: "none".into(), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_baud_rate_zero_fails() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 0, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("baud_rate")); - } - - #[test] - fn validate_baud_rate_too_high_fails() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 5_000_000, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("safety limit")); - } - - #[test] - fn validate_baud_rate_boundary_ok() { - // Exactly at the limit - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 4_000_000, - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_baud_rate_common_values_ok() { - for baud in [ - 9600, 19200, 38400, 57600, 115_200, 230_400, 460_800, 921_600, - ] { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: baud, - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok(), "baud rate {baud} should be valid"); - } - } - - #[test] - fn validate_pwm_frequency_zero_fails() { - let cfg = HardwareConfig { - enabled: true, - transport: "native".into(), - max_pwm_frequency_hz: 0, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("max_pwm_frequency_hz")); - } - - // ── HardwareConfig serde ─────────────────────────────────── - - #[test] - fn config_serde_roundtrip_toml() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 9600, - workspace_datasheets: true, - discovered_board: Some("Arduino Uno".into()), - probe_target: None, - allowed_pins: vec![2, 13], - max_pwm_frequency_hz: 25_000, - }; - - let toml_str = toml::to_string_pretty(&cfg).unwrap(); - let parsed: HardwareConfig = toml::from_str(&toml_str).unwrap(); - - assert_eq!(parsed.enabled, cfg.enabled); - assert_eq!(parsed.transport, cfg.transport); - assert_eq!(parsed.serial_port, cfg.serial_port); - assert_eq!(parsed.baud_rate, cfg.baud_rate); - assert_eq!(parsed.workspace_datasheets, cfg.workspace_datasheets); - assert_eq!(parsed.discovered_board, cfg.discovered_board); - assert_eq!(parsed.allowed_pins, cfg.allowed_pins); - assert_eq!(parsed.max_pwm_frequency_hz, cfg.max_pwm_frequency_hz); - } - - #[test] - fn config_serde_minimal_toml() { - // Deserializing an empty TOML section should produce defaults - let toml_str = "enabled = false\n"; - let parsed: HardwareConfig = toml::from_str(toml_str).unwrap(); - assert!(!parsed.enabled); - assert_eq!(parsed.transport, "none"); - assert_eq!(parsed.baud_rate, 115_200); - } - - #[test] - fn config_serde_json_roundtrip() { - let cfg = HardwareConfig { - enabled: true, - transport: "probe".into(), - serial_port: None, - baud_rate: 115_200, - workspace_datasheets: false, - discovered_board: None, - probe_target: Some("nRF52840_xxAA".into()), - allowed_pins: vec![], - max_pwm_frequency_hz: 50_000, - }; - - let json = serde_json::to_string(&cfg).unwrap(); - let parsed: HardwareConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.probe_target, cfg.probe_target); - assert_eq!(parsed.transport, "probe"); - } - - // ── NoopHal ──────────────────────────────────────────────── - - #[test] - fn noop_hal_gpio_read_fails() { - let hal = NoopHal; - let err = hal.gpio_read(13).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - assert!(err.to_string().contains("13")); - } - - #[test] - fn noop_hal_gpio_write_fails() { - let hal = NoopHal; - let err = hal.gpio_write(5, true).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - } - - #[test] - fn noop_hal_memory_read_fails() { - let hal = NoopHal; - let err = hal.memory_read(0x2000_0000, 4).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - assert!(err.to_string().contains("0x20000000")); - } - - #[test] - fn noop_hal_firmware_upload_fails() { - let hal = NoopHal; - let err = hal - .firmware_upload(Path::new("/tmp/firmware.bin")) - .unwrap_err(); - assert!(err.to_string().contains("not enabled")); - assert!(err.to_string().contains("firmware.bin")); - } - - #[test] - fn noop_hal_describe() { - let hal = NoopHal; - let desc = hal.describe(); - assert!(desc.contains("software-only")); - } - - #[test] - fn noop_hal_pwm_set_fails() { - let hal = NoopHal; - let err = hal.pwm_set(9, 50.0).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - } - - #[test] - fn noop_hal_analog_read_fails() { - let hal = NoopHal; - let err = hal.analog_read(0).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - } - - // ── create_hal factory ───────────────────────────────────── - - #[test] - fn create_hal_disabled_returns_noop() { - let cfg = HardwareConfig::default(); - let hal = create_hal(&cfg).unwrap(); - assert!(hal.describe().contains("software-only")); - } - - #[test] - fn create_hal_none_transport_returns_noop() { - let cfg = HardwareConfig { - enabled: true, - transport: "none".into(), - ..HardwareConfig::default() - }; - let hal = create_hal(&cfg).unwrap(); - assert!(hal.describe().contains("software-only")); - } - - #[test] - fn create_hal_serial_without_port_fails_validation() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: None, - ..HardwareConfig::default() - }; - assert!(create_hal(&cfg).is_err()); - } - - #[test] - fn create_hal_invalid_baud_fails_validation() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 0, - ..HardwareConfig::default() - }; - assert!(create_hal(&cfg).is_err()); - } - - // ── Discovery helpers ────────────────────────────────────── - - #[test] - fn classify_serial_arduino() { - let path = Path::new("/dev/tty.usbmodem14201"); - assert!(classify_serial_device(path).contains("Arduino")); - } - - #[test] - fn classify_serial_ftdi() { - let path = Path::new("/dev/tty.usbserial-1234"); - assert!(classify_serial_device(path).contains("FTDI")); - } - - #[test] - fn classify_serial_ch340() { - let path = Path::new("/dev/tty.wchusbserial1420"); - assert!(classify_serial_device(path).contains("CH340")); - } - - #[test] - fn classify_serial_ttyacm() { - let path = Path::new("/dev/ttyACM0"); - assert!(classify_serial_device(path).contains("CDC")); - } - - #[test] - fn classify_serial_ttyusb() { - let path = Path::new("/dev/ttyUSB0"); - assert!(classify_serial_device(path).contains("USB-Serial")); - } - - #[test] - fn classify_serial_unknown() { - let path = Path::new("/dev/ttyXYZ99"); - assert!(classify_serial_device(path).contains("Unknown")); - } - - // ── Serial device path patterns ──────────────────────────── - - #[test] - fn serial_paths_macos_patterns() { - if cfg!(target_os = "macos") { - let patterns = serial_device_paths(); - assert!(patterns.iter().any(|p| p.contains("usbmodem"))); - assert!(patterns.iter().any(|p| p.contains("usbserial"))); - assert!(patterns.iter().any(|p| p.contains("wchusbserial"))); - } - } - - #[test] - fn serial_paths_linux_patterns() { - if cfg!(target_os = "linux") { - let patterns = serial_device_paths(); - assert!(patterns.iter().any(|p| p.contains("ttyUSB"))); - assert!(patterns.iter().any(|p| p.contains("ttyACM"))); - } - } - - // ── Wizard helpers ───────────────────────────────────────── - - #[test] - fn recommended_default_no_devices() { - let devices: Vec = vec![]; - assert_eq!(recommended_wizard_default(&devices), 3); // Software only - } - - #[test] - fn recommended_default_native_found() { - let devices = vec![DiscoveredDevice { - name: "Raspberry Pi (Native GPIO)".into(), - transport: HardwareTransport::Native, - device_path: Some("/dev/gpiomem".into()), - detail: None, - }]; - assert_eq!(recommended_wizard_default(&devices), 0); // Native - } - - #[test] - fn recommended_default_serial_found() { - let devices = vec![DiscoveredDevice { - name: "Arduino (USB Serial)".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }]; - assert_eq!(recommended_wizard_default(&devices), 1); // Tethered - } - - #[test] - fn recommended_default_probe_found() { - let devices = vec![DiscoveredDevice { - name: "ST-Link (SWD)".into(), - transport: HardwareTransport::Probe, - device_path: None, - detail: None, - }]; - assert_eq!(recommended_wizard_default(&devices), 2); // Probe - } - - #[test] - fn recommended_default_native_priority_over_serial() { - // When both native and serial are found, native wins - let devices = vec![ - DiscoveredDevice { - name: "Arduino".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }, - DiscoveredDevice { - name: "RPi GPIO".into(), - transport: HardwareTransport::Native, - device_path: Some("/dev/gpiomem".into()), - detail: None, - }, - ]; - assert_eq!(recommended_wizard_default(&devices), 0); // Native wins - } - - #[test] - fn config_from_wizard_native() { - let devices = vec![DiscoveredDevice { - name: "Raspberry Pi 4 (Native GPIO)".into(), - transport: HardwareTransport::Native, - device_path: Some("/dev/gpiomem".into()), - detail: Some("Raspberry Pi 4 Model B Rev 1.5".into()), - }]; - - let cfg = config_from_wizard_choice(0, &devices); - assert!(cfg.enabled); - assert_eq!(cfg.transport, "native"); - assert_eq!( - cfg.discovered_board.as_deref(), - Some("Raspberry Pi 4 Model B Rev 1.5") - ); - } - - #[test] - fn config_from_wizard_serial() { - let devices = vec![DiscoveredDevice { - name: "Arduino Uno (USB Serial)".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }]; - - let cfg = config_from_wizard_choice(1, &devices); - assert!(cfg.enabled); - assert_eq!(cfg.transport, "serial"); - assert_eq!(cfg.serial_port.as_deref(), Some("/dev/ttyUSB0")); - } - - #[test] - fn config_from_wizard_probe() { - let devices = vec![DiscoveredDevice { - name: "ST-Link (SWD)".into(), - transport: HardwareTransport::Probe, - device_path: Some("/dev/stlinkv2".into()), - detail: None, - }]; - - let cfg = config_from_wizard_choice(2, &devices); - assert!(cfg.enabled); - assert_eq!(cfg.transport, "probe"); - } - - #[test] - fn config_from_wizard_software_only() { - let devices: Vec = vec![]; - let cfg = config_from_wizard_choice(3, &devices); - assert!(!cfg.enabled); - assert_eq!(cfg.transport, "none"); - } - - #[test] - fn config_from_wizard_serial_no_serial_device_found() { - // User picks serial but no serial device was discovered - let devices = vec![DiscoveredDevice { - name: "RPi GPIO".into(), - transport: HardwareTransport::Native, - device_path: Some("/dev/gpiomem".into()), - detail: None, - }]; - - let cfg = config_from_wizard_choice(1, &devices); - assert!(cfg.enabled); - assert_eq!(cfg.transport, "serial"); - assert!(cfg.serial_port.is_none()); // Will need manual config later - } - - #[test] - fn config_from_wizard_out_of_bounds_defaults_to_software() { - let devices: Vec = vec![]; - let cfg = config_from_wizard_choice(99, &devices); - assert!(!cfg.enabled); - } - - // ── Discovery function runs without panicking ────────────── - - #[test] - fn discover_hardware_does_not_panic() { - // Should never panic regardless of the platform - let devices = discover_hardware(); - // We can't assert what's found (platform-dependent) but it should not crash - assert!(devices.len() < 100); // Sanity check - } - - // ── DiscoveredDevice equality ────────────────────────────── - - #[test] - fn discovered_device_equality() { - let d1 = DiscoveredDevice { - name: "Arduino".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }; - let d2 = d1.clone(); - assert_eq!(d1, d2); - } - - #[test] - fn discovered_device_inequality() { - let d1 = DiscoveredDevice { - name: "Arduino".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }; - let d2 = DiscoveredDevice { - name: "ESP32".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB1".into()), - detail: None, - }; - assert_ne!(d1, d2); - } - - // ── Edge cases ───────────────────────────────────────────── - - #[test] - fn config_with_all_pins_in_allowlist() { - let cfg = HardwareConfig { - allowed_pins: (0..=255).collect(), - ..HardwareConfig::default() - }; - // Every pin should be allowed - for pin in 0..=255u8 { - assert!(cfg.is_pin_allowed(pin)); - } - } - - #[test] - fn config_transport_unknown_string() { - let cfg = HardwareConfig { - transport: "quantum_bus".into(), - ..HardwareConfig::default() - }; - assert_eq!(cfg.transport_mode(), HardwareTransport::None); - } - - #[test] - fn config_transport_empty_string() { - let cfg = HardwareConfig { - transport: String::new(), - ..HardwareConfig::default() - }; - assert_eq!(cfg.transport_mode(), HardwareTransport::None); - } - - #[test] - fn validate_serial_empty_port_string_treated_as_set() { - // An empty string is still Some(""), which passes the None check - // but the serial backend would fail at open time — that's acceptable - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some(String::new()), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_multiple_errors_first_wins() { - // Serial with no port AND zero baud — the port error should surface first - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: None, - baud_rate: 0, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("serial_port")); } + println!(); + println!("Info read via USB (SWD) — no firmware on target needed."); + Ok(()) } diff --git a/src/hardware/registry.rs b/src/hardware/registry.rs new file mode 100644 index 0000000..aac15f2 --- /dev/null +++ b/src/hardware/registry.rs @@ -0,0 +1,102 @@ +//! Board registry — maps USB VID/PID to known board names and architectures. + +/// Information about a known board. +#[derive(Debug, Clone)] +pub struct BoardInfo { + pub vid: u16, + pub pid: u16, + pub name: &'static str, + pub architecture: Option<&'static str>, +} + +/// Known USB VID/PID to board mappings. +/// VID 0x0483 = STMicroelectronics, 0x2341 = Arduino, 0x10c4 = Silicon Labs. +const KNOWN_BOARDS: &[BoardInfo] = &[ + BoardInfo { + vid: 0x0483, + pid: 0x374b, + name: "nucleo-f401re", + architecture: Some("ARM Cortex-M4"), + }, + BoardInfo { + vid: 0x0483, + pid: 0x3748, + name: "nucleo-f411re", + architecture: Some("ARM Cortex-M4"), + }, + BoardInfo { + vid: 0x2341, + pid: 0x0043, + name: "arduino-uno", + architecture: Some("AVR ATmega328P"), + }, + BoardInfo { + vid: 0x2341, + pid: 0x0078, + name: "arduino-uno", + architecture: Some("Arduino Uno Q / ATmega328P"), + }, + BoardInfo { + vid: 0x2341, + pid: 0x0042, + name: "arduino-mega", + architecture: Some("AVR ATmega2560"), + }, + BoardInfo { + vid: 0x10c4, + pid: 0xea60, + name: "cp2102", + architecture: Some("USB-UART bridge"), + }, + BoardInfo { + vid: 0x10c4, + pid: 0xea70, + name: "cp2102n", + architecture: Some("USB-UART bridge"), + }, + // ESP32 dev boards often use CH340 USB-UART + BoardInfo { + vid: 0x1a86, + pid: 0x7523, + name: "esp32", + architecture: Some("ESP32 (CH340)"), + }, + BoardInfo { + vid: 0x1a86, + pid: 0x55d4, + name: "esp32", + architecture: Some("ESP32 (CH340)"), + }, +]; + +/// Look up a board by VID and PID. +pub fn lookup_board(vid: u16, pid: u16) -> Option<&'static BoardInfo> { + KNOWN_BOARDS.iter().find(|b| b.vid == vid && b.pid == pid) +} + +/// Return all known board entries. +pub fn known_boards() -> &'static [BoardInfo] { + KNOWN_BOARDS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookup_nucleo_f401re() { + let b = lookup_board(0x0483, 0x374b).unwrap(); + assert_eq!(b.name, "nucleo-f401re"); + assert_eq!(b.architecture, Some("ARM Cortex-M4")); + } + + #[test] + fn lookup_unknown_returns_none() { + assert!(lookup_board(0x0000, 0x0000).is_none()); + } + + #[test] + fn known_boards_not_empty() { + assert!(!known_boards().is_empty()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 588ada3..cfde7a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,7 +55,9 @@ pub mod memory; pub mod migration; pub mod observability; pub mod onboard; +pub mod peripherals; pub mod providers; +pub mod rag; pub mod runtime; pub mod security; pub mod service; @@ -182,74 +184,48 @@ pub enum IntegrationCommands { }, } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn service_commands_serde_roundtrip() { - let command = ServiceCommands::Status; - let json = serde_json::to_string(&command).unwrap(); - let parsed: ServiceCommands = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, ServiceCommands::Status); - } - - #[test] - fn channel_commands_struct_variants_roundtrip() { - let add = ChannelCommands::Add { - channel_type: "telegram".into(), - config: "{}".into(), - }; - let remove = ChannelCommands::Remove { - name: "main".into(), - }; - - let add_json = serde_json::to_string(&add).unwrap(); - let remove_json = serde_json::to_string(&remove).unwrap(); - - let parsed_add: ChannelCommands = serde_json::from_str(&add_json).unwrap(); - let parsed_remove: ChannelCommands = serde_json::from_str(&remove_json).unwrap(); - - assert_eq!(parsed_add, add); - assert_eq!(parsed_remove, remove); - } - - #[test] - fn commands_with_payloads_roundtrip() { - let skill = SkillCommands::Install { - source: "https://example.com/skill".into(), - }; - let migrate = MigrateCommands::Openclaw { - source: Some(std::path::PathBuf::from("/tmp/openclaw")), - dry_run: true, - }; - let cron = CronCommands::Add { - expression: "*/5 * * * *".into(), - command: "echo hi".into(), - }; - let integration = IntegrationCommands::Info { - name: "Telegram".into(), - }; - - assert_eq!( - serde_json::from_str::(&serde_json::to_string(&skill).unwrap()).unwrap(), - skill - ); - assert_eq!( - serde_json::from_str::(&serde_json::to_string(&migrate).unwrap()) - .unwrap(), - migrate - ); - assert_eq!( - serde_json::from_str::(&serde_json::to_string(&cron).unwrap()).unwrap(), - cron - ); - assert_eq!( - serde_json::from_str::( - &serde_json::to_string(&integration).unwrap() - ) - .unwrap(), - integration - ); - } +/// Hardware discovery subcommands +#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum HardwareCommands { + /// Enumerate USB devices (VID/PID) and show known boards + Discover, + /// Introspect a device by path (e.g. /dev/ttyACM0) + Introspect { + /// Serial or device path + path: String, + }, + /// Get chip info via USB (probe-rs over ST-Link). No firmware needed on target. + Info { + /// Chip name (e.g. STM32F401RETx). Default: STM32F401RETx for Nucleo-F401RE + #[arg(long, default_value = "STM32F401RETx")] + chip: String, + }, +} + +/// Peripheral (hardware) management subcommands +#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum PeripheralCommands { + /// List configured peripherals + List, + /// Add a peripheral (board path, e.g. nucleo-f401re /dev/ttyACM0) + Add { + /// Board type (nucleo-f401re, rpi-gpio, esp32) + board: String, + /// Path for serial transport (/dev/ttyACM0) or "native" for local GPIO + path: String, + }, + /// Flash ZeroClaw firmware to Arduino (creates .ino, installs arduino-cli if needed, uploads) + Flash { + /// Serial port (e.g. /dev/cu.usbmodem12345). If omitted, uses first arduino-uno from config. + #[arg(short, long)] + port: Option, + }, + /// Setup Arduino Uno Q Bridge app (deploy GPIO bridge for agent control) + SetupUnoQ { + /// Uno Q IP (e.g. 192.168.0.48). If omitted, assumes running ON the Uno Q. + #[arg(long)] + host: Option, + }, + /// Flash ZeroClaw firmware to Nucleo-F401RE (builds + probe-rs run) + FlashNucleo, } diff --git a/src/main.rs b/src/main.rs index 478ce41..b12bc06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,9 @@ use tracing_subscriber::FmtSubscriber; mod agent; mod channels; +mod rag { + pub use zeroclaw::rag::*; +} mod config; mod cron; mod daemon; @@ -53,6 +56,7 @@ mod memory; mod migration; mod observability; mod onboard; +mod peripherals; mod providers; mod runtime; mod security; @@ -65,6 +69,9 @@ mod util; use config::Config; +// Re-export so binary's hardware/peripherals modules can use crate::HardwareCommands etc. +pub use zeroclaw::{HardwareCommands, PeripheralCommands}; + /// `ZeroClaw` - Zero overhead. Zero compromise. 100% Rust. #[derive(Parser, Debug)] #[command(name = "zeroclaw")] @@ -133,9 +140,9 @@ enum Commands { #[arg(short, long, default_value = "0.7")] temperature: f64, - /// Print user-facing progress lines via observer (`>` send, `<` receive/complete). + /// Attach a peripheral (board:path, e.g. nucleo-f401re:/dev/ttyACM0) #[arg(long)] - verbose: bool, + peripheral: Vec, }, /// Start the gateway server (webhooks, websockets) @@ -207,6 +214,18 @@ enum Commands { #[command(subcommand)] migrate_command: MigrateCommands, }, + + /// Discover and introspect USB hardware + Hardware { + #[command(subcommand)] + hardware_command: zeroclaw::HardwareCommands, + }, + + /// Manage hardware peripherals (STM32, RPi GPIO, etc.) + Peripheral { + #[command(subcommand)] + peripheral_command: zeroclaw::PeripheralCommands, + }, } #[derive(Subcommand, Debug)] @@ -380,8 +399,8 @@ async fn main() -> Result<()> { provider, model, temperature, - verbose, - } => agent::run(config, message, provider, model, temperature, verbose).await, + peripheral, + } => agent::run(config, message, provider, model, temperature, peripheral).await, Commands::Gateway { port, host } => { if port == 0 { @@ -466,6 +485,17 @@ async fn main() -> Result<()> { } ); } + println!(); + println!("Peripherals:"); + println!( + " Enabled: {}", + if config.peripherals.enabled { + "yes" + } else { + "no" + } + ); + println!(" Boards: {}", config.peripherals.boards.len()); Ok(()) } @@ -499,6 +529,14 @@ async fn main() -> Result<()> { Commands::Migrate { migrate_command } => { migration::handle_command(migrate_command, &config).await } + + Commands::Hardware { hardware_command } => { + hardware::handle_command(hardware_command.clone(), &config) + } + + Commands::Peripheral { peripheral_command } => { + peripherals::handle_command(peripheral_command.clone(), &config) + } } } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 77dbe3b..13ed3a8 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -125,10 +125,11 @@ pub fn run_wizard() -> Result { browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), - cost: crate::config::schema::CostConfig::default(), - hardware: hardware_config, + cost: crate::config::CostConfig::default(), + peripherals: crate::config::PeripheralsConfig::default(), + agent: crate::config::AgentConfig::default(), agents: std::collections::HashMap::new(), - security: crate::config::SecurityConfig::default(), + hardware: hardware_config, }; println!( @@ -328,10 +329,11 @@ pub fn run_quick_setup( browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), - cost: crate::config::schema::CostConfig::default(), - hardware: HardwareConfig::default(), + cost: crate::config::CostConfig::default(), + peripherals: crate::config::PeripheralsConfig::default(), + agent: crate::config::AgentConfig::default(), agents: std::collections::HashMap::new(), - security: crate::config::SecurityConfig::default(), + hardware: crate::config::HardwareConfig::default(), }; config.save()?; @@ -2328,18 +2330,27 @@ fn setup_channels() -> Result { continue; } - // Test connection + // Test connection (run entirely in separate thread — reqwest::blocking Response + // must be used and dropped there to avoid "Cannot drop a runtime" panic) print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - let url = format!("https://api.telegram.org/bot{token}/getMe"); - match client.get(&url).send() { - Ok(resp) if resp.status().is_success() => { - let data: serde_json::Value = resp.json().unwrap_or_default(); - let bot_name = data - .get("result") - .and_then(|r| r.get("username")) - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); + let token_clone = token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let url = format!("https://api.telegram.org/bot{token_clone}/getMe"); + let resp = client.get(&url).send()?; + let ok = resp.status().is_success(); + let data: serde_json::Value = resp.json().unwrap_or_default(); + let bot_name = data + .get("result") + .and_then(|r| r.get("username")) + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + Ok::<_, reqwest::Error>((ok, bot_name)) + }) + .join(); + match thread_result { + Ok(Ok((true, bot_name))) => { println!( "\r {} Connected as @{bot_name} ", style("✅").green().bold() @@ -2412,20 +2423,27 @@ fn setup_channels() -> Result { continue; } - // Test connection + // Test connection (run entirely in separate thread — Response must be used/dropped there) print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - match client - .get("https://discord.com/api/v10/users/@me") - .header("Authorization", format!("Bot {token}")) - .send() - { - Ok(resp) if resp.status().is_success() => { - let data: serde_json::Value = resp.json().unwrap_or_default(); - let bot_name = data - .get("username") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); + let token_clone = token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let resp = client + .get("https://discord.com/api/v10/users/@me") + .header("Authorization", format!("Bot {token_clone}")) + .send()?; + let ok = resp.status().is_success(); + let data: serde_json::Value = resp.json().unwrap_or_default(); + let bot_name = data + .get("username") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + Ok::<_, reqwest::Error>((ok, bot_name)) + }) + .join(); + match thread_result { + Ok(Ok((true, bot_name))) => { println!( "\r {} Connected as {bot_name} ", style("✅").green().bold() @@ -2504,37 +2522,44 @@ fn setup_channels() -> Result { continue; } - // Test connection + // Test connection (run entirely in separate thread — Response must be used/dropped there) print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - match client - .get("https://slack.com/api/auth.test") - .bearer_auth(&token) - .send() - { - Ok(resp) if resp.status().is_success() => { - let data: serde_json::Value = resp.json().unwrap_or_default(); - let ok = data - .get("ok") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let team = data - .get("team") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); - if ok { - println!( - "\r {} Connected to workspace: {team} ", - style("✅").green().bold() - ); - } else { - let err = data - .get("error") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown error"); - println!("\r {} Slack error: {err}", style("❌").red().bold()); - continue; - } + let token_clone = token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let resp = client + .get("https://slack.com/api/auth.test") + .bearer_auth(&token_clone) + .send()?; + let ok = resp.status().is_success(); + let data: serde_json::Value = resp.json().unwrap_or_default(); + let api_ok = data + .get("ok") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let team = data + .get("team") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + let err = data + .get("error") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown error") + .to_string(); + Ok::<_, reqwest::Error>((ok, api_ok, team, err)) + }) + .join(); + match thread_result { + Ok(Ok((true, true, team, _))) => { + println!( + "\r {} Connected to workspace: {team} ", + style("✅").green().bold() + ); + } + Ok(Ok((true, false, _, err))) => { + println!("\r {} Slack error: {err}", style("❌").red().bold()); + continue; } _ => { println!( @@ -2673,21 +2698,29 @@ fn setup_channels() -> Result { continue; } - // Test connection + // Test connection (run entirely in separate thread — Response must be used/dropped there) let hs = homeserver.trim_end_matches('/'); print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - match client - .get(format!("{hs}/_matrix/client/v3/account/whoami")) - .header("Authorization", format!("Bearer {access_token}")) - .send() - { - Ok(resp) if resp.status().is_success() => { - let data: serde_json::Value = resp.json().unwrap_or_default(); - let user_id = data - .get("user_id") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); + let hs_owned = hs.to_string(); + let access_token_clone = access_token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let resp = client + .get(format!("{hs_owned}/_matrix/client/v3/account/whoami")) + .header("Authorization", format!("Bearer {access_token_clone}")) + .send()?; + let ok = resp.status().is_success(); + let data: serde_json::Value = resp.json().unwrap_or_default(); + let user_id = data + .get("user_id") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + Ok::<_, reqwest::Error>((ok, user_id)) + }) + .join(); + match thread_result { + Ok(Ok((true, user_id))) => { println!( "\r {} Connected as {user_id} ", style("✅").green().bold() @@ -2761,19 +2794,28 @@ fn setup_channels() -> Result { .default("zeroclaw-whatsapp-verify".into()) .interact_text()?; - // Test connection + // Test connection (run entirely in separate thread — Response must be used/dropped there) print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - let url = format!( - "https://graph.facebook.com/v18.0/{}", - phone_number_id.trim() - ); - match client - .get(&url) - .header("Authorization", format!("Bearer {}", access_token.trim())) - .send() - { - Ok(resp) if resp.status().is_success() => { + let phone_number_id_clone = phone_number_id.clone(); + let access_token_clone = access_token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let url = format!( + "https://graph.facebook.com/v18.0/{}", + phone_number_id_clone.trim() + ); + let resp = client + .get(&url) + .header( + "Authorization", + format!("Bearer {}", access_token_clone.trim()), + ) + .send()?; + Ok::<_, reqwest::Error>(resp.status().is_success()) + }) + .join(); + match thread_result { + Ok(Ok(true)) => { println!( "\r {} Connected to WhatsApp API ", style("✅").green().bold() diff --git a/src/peripherals/arduino_flash.rs b/src/peripherals/arduino_flash.rs new file mode 100644 index 0000000..8aaf287 --- /dev/null +++ b/src/peripherals/arduino_flash.rs @@ -0,0 +1,144 @@ +//! Flash ZeroClaw Arduino firmware via arduino-cli. +//! +//! Ensures arduino-cli is available (installs via brew on macOS if missing), +//! installs the AVR core, compiles and uploads the base firmware. + +use anyhow::{Context, Result}; +use std::process::Command; + +/// ZeroClaw Arduino Uno base firmware (capabilities, gpio_read, gpio_write). +const FIRMWARE_INO: &str = include_str!("../../firmware/zeroclaw-arduino/zeroclaw-arduino.ino"); + +const FQBN: &str = "arduino:avr:uno"; +const SKETCH_NAME: &str = "zeroclaw-arduino"; + +/// Check if arduino-cli is available. +pub fn arduino_cli_available() -> bool { + Command::new("arduino-cli") + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Try to install arduino-cli. Returns Ok(()) if installed or already present. +pub fn ensure_arduino_cli() -> Result<()> { + if arduino_cli_available() { + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + println!("arduino-cli not found. Installing via Homebrew..."); + let status = Command::new("brew") + .args(["install", "arduino-cli"]) + .status() + .context("Failed to run brew install")?; + if !status.success() { + anyhow::bail!("brew install arduino-cli failed. Install manually: https://arduino.github.io/arduino-cli/"); + } + println!("arduino-cli installed."); + } + + #[cfg(target_os = "linux")] + { + println!("arduino-cli not found. Run the install script:"); + println!(" curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh"); + println!(); + println!("Or install via package manager (e.g. apt install arduino-cli on Debian/Ubuntu)."); + anyhow::bail!("arduino-cli not installed. Install it and try again."); + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + println!("arduino-cli not found. Install it: https://arduino.github.io/arduino-cli/"); + anyhow::bail!("arduino-cli not installed."); + } + + if !arduino_cli_available() { + anyhow::bail!("arduino-cli still not found after install. Ensure it's in PATH."); + } + Ok(()) +} + +/// Ensure arduino:avr core is installed. +fn ensure_avr_core() -> Result<()> { + let out = Command::new("arduino-cli") + .args(["core", "list"]) + .output() + .context("arduino-cli core list failed")?; + let stdout = String::from_utf8_lossy(&out.stdout); + if stdout.contains("arduino:avr") { + return Ok(()); + } + + println!("Installing Arduino AVR core..."); + let status = Command::new("arduino-cli") + .args(["core", "install", "arduino:avr"]) + .status() + .context("arduino-cli core install failed")?; + if !status.success() { + anyhow::bail!("Failed to install arduino:avr core"); + } + println!("AVR core installed."); + Ok(()) +} + +/// Flash ZeroClaw firmware to Arduino at the given port. +pub fn flash_arduino_firmware(port: &str) -> Result<()> { + ensure_arduino_cli()?; + ensure_avr_core()?; + + let temp_dir = std::env::temp_dir().join(format!("zeroclaw_flash_{}", uuid::Uuid::new_v4())); + let sketch_dir = temp_dir.join(SKETCH_NAME); + let ino_path = sketch_dir.join(format!("{}.ino", SKETCH_NAME)); + + std::fs::create_dir_all(&sketch_dir).context("Failed to create sketch dir")?; + std::fs::write(&ino_path, FIRMWARE_INO).context("Failed to write firmware")?; + + let sketch_path = sketch_dir.to_string_lossy(); + + // Compile + println!("Compiling ZeroClaw Arduino firmware..."); + let compile = Command::new("arduino-cli") + .args(["compile", "--fqbn", FQBN, &*sketch_path]) + .output() + .context("arduino-cli compile failed")?; + + if !compile.status.success() { + let stderr = String::from_utf8_lossy(&compile.stderr); + let _ = std::fs::remove_dir_all(&temp_dir); + anyhow::bail!("Compile failed:\n{}", stderr); + } + + // Upload + println!("Uploading to {}...", port); + let upload = Command::new("arduino-cli") + .args(["upload", "-p", port, "--fqbn", FQBN, &*sketch_path]) + .output() + .context("arduino-cli upload failed")?; + + let _ = std::fs::remove_dir_all(&temp_dir); + + if !upload.status.success() { + let stderr = String::from_utf8_lossy(&upload.stderr); + anyhow::bail!("Upload failed:\n{}\n\nEnsure the board is connected and the port is correct (e.g. /dev/cu.usbmodem* on macOS).", stderr); + } + + println!("ZeroClaw firmware flashed successfully."); + println!("The Arduino now supports: capabilities, gpio_read, gpio_write."); + Ok(()) +} + +/// Resolve port from config or path. Returns the path to use for flashing. +pub fn resolve_port(config: &crate::config::Config, path_override: Option<&str>) -> Option { + if let Some(p) = path_override { + return Some(p.to_string()); + } + config + .peripherals + .boards + .iter() + .find(|b| b.board == "arduino-uno" && b.transport == "serial") + .and_then(|b| b.path.clone()) +} diff --git a/src/peripherals/arduino_upload.rs b/src/peripherals/arduino_upload.rs new file mode 100644 index 0000000..e11b19f --- /dev/null +++ b/src/peripherals/arduino_upload.rs @@ -0,0 +1,161 @@ +//! Arduino upload tool — agent generates code, uploads via arduino-cli. +//! +//! When user says "make a heart on the LED grid", the agent generates Arduino +//! sketch code and calls this tool. ZeroClaw compiles and uploads it — no +//! manual IDE or file editing. + +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::process::Command; + +/// Tool: upload Arduino sketch (agent-generated code) to the board. +pub struct ArduinoUploadTool { + /// Serial port path (e.g. /dev/cu.usbmodem33000283452) + pub port: String, +} + +impl ArduinoUploadTool { + pub fn new(port: String) -> Self { + Self { port } + } +} + +#[async_trait] +impl Tool for ArduinoUploadTool { + fn name(&self) -> &str { + "arduino_upload" + } + + fn description(&self) -> &str { + "Generate Arduino sketch code and upload it to the connected Arduino. Use when: user asks to 'make a heart', 'blink LED', or run any custom pattern on Arduino. You MUST write the full .ino sketch code (setup + loop). Arduino Uno: pin 13 = built-in LED. Saves to temp dir, runs arduino-cli compile and upload. Requires arduino-cli installed." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Full Arduino sketch code (complete .ino file content)" + } + }, + "required": ["code"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let code = args + .get("code") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'code' parameter"))?; + + if code.trim().is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Code cannot be empty".into()), + }); + } + + // Check arduino-cli exists + if Command::new("arduino-cli").arg("version").output().is_err() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "arduino-cli not found. Install it: https://arduino.github.io/arduino-cli/" + .into(), + ), + }); + } + + let sketch_name = "zeroclaw_sketch"; + let temp_dir = std::env::temp_dir().join(format!("zeroclaw_{}", uuid::Uuid::new_v4())); + let sketch_dir = temp_dir.join(sketch_name); + let ino_path = sketch_dir.join(format!("{}.ino", sketch_name)); + + if let Err(e) = std::fs::create_dir_all(&sketch_dir) { + return Ok(ToolResult { + success: false, + output: format!("Failed to create sketch dir: {}", e), + error: Some(e.to_string()), + }); + } + + if let Err(e) = std::fs::write(&ino_path, code) { + let _ = std::fs::remove_dir_all(&temp_dir); + return Ok(ToolResult { + success: false, + output: format!("Failed to write sketch: {}", e), + error: Some(e.to_string()), + }); + } + + let sketch_path = sketch_dir.to_string_lossy(); + let fqbn = "arduino:avr:uno"; + + // Compile + let compile = Command::new("arduino-cli") + .args(["compile", "--fqbn", fqbn, &sketch_path]) + .output(); + + let compile_output = match compile { + Ok(o) => o, + Err(e) => { + let _ = std::fs::remove_dir_all(&temp_dir); + return Ok(ToolResult { + success: false, + output: format!("arduino-cli compile failed: {}", e), + error: Some(e.to_string()), + }); + } + }; + + if !compile_output.status.success() { + let stderr = String::from_utf8_lossy(&compile_output.stderr); + let _ = std::fs::remove_dir_all(&temp_dir); + return Ok(ToolResult { + success: false, + output: format!("Compile failed:\n{}", stderr), + error: Some("Arduino compile error".into()), + }); + } + + // Upload + let upload = Command::new("arduino-cli") + .args(["upload", "-p", &self.port, "--fqbn", fqbn, &sketch_path]) + .output(); + + let upload_output = match upload { + Ok(o) => o, + Err(e) => { + let _ = std::fs::remove_dir_all(&temp_dir); + return Ok(ToolResult { + success: false, + output: format!("arduino-cli upload failed: {}", e), + error: Some(e.to_string()), + }); + } + }; + + let _ = std::fs::remove_dir_all(&temp_dir); + + if !upload_output.status.success() { + let stderr = String::from_utf8_lossy(&upload_output.stderr); + return Ok(ToolResult { + success: false, + output: format!("Upload failed:\n{}", stderr), + error: Some("Arduino upload error".into()), + }); + } + + Ok(ToolResult { + success: true, + output: + "Sketch compiled and uploaded successfully. The Arduino is now running your code." + .into(), + error: None, + }) + } +} diff --git a/src/peripherals/capabilities_tool.rs b/src/peripherals/capabilities_tool.rs new file mode 100644 index 0000000..c3fca4f --- /dev/null +++ b/src/peripherals/capabilities_tool.rs @@ -0,0 +1,99 @@ +//! Hardware capabilities tool — Phase C: query device for reported GPIO pins. + +use super::serial::SerialTransport; +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +/// Tool: query device capabilities (GPIO pins, LED pin) from firmware. +pub struct HardwareCapabilitiesTool { + /// (board_name, transport) for each serial board. + boards: Vec<(String, Arc)>, +} + +impl HardwareCapabilitiesTool { + pub(crate) fn new(boards: Vec<(String, Arc)>) -> Self { + Self { boards } + } +} + +#[async_trait] +impl Tool for HardwareCapabilitiesTool { + fn name(&self) -> &str { + "hardware_capabilities" + } + + fn description(&self) -> &str { + "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "board": { + "type": "string", + "description": "Optional board name. If omitted, queries all." + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let filter = args.get("board").and_then(|v| v.as_str()); + let mut outputs = Vec::new(); + + for (board_name, transport) in &self.boards { + if let Some(b) = filter { + if b != board_name { + continue; + } + } + match transport.capabilities().await { + Ok(result) => { + let output = if result.success { + if let Ok(parsed) = + serde_json::from_str::(&result.output) + { + format!( + "{}: gpio {:?}, led_pin {:?}", + board_name, + parsed.get("gpio").unwrap_or(&json!([])), + parsed.get("led_pin").unwrap_or(&json!(null)) + ) + } else { + format!("{}: {}", board_name, result.output) + } + } else { + format!( + "{}: {}", + board_name, + result.error.as_deref().unwrap_or("unknown") + ) + }; + outputs.push(output); + } + Err(e) => { + outputs.push(format!("{}: error - {}", board_name, e)); + } + } + } + + let output = if outputs.is_empty() { + if filter.is_some() { + "No matching board or capabilities not supported.".to_string() + } else { + "No serial boards configured or capabilities not supported.".to_string() + } + } else { + outputs.join("\n") + }; + + Ok(ToolResult { + success: !outputs.is_empty(), + output, + error: None, + }) + } +} diff --git a/src/peripherals/mod.rs b/src/peripherals/mod.rs new file mode 100644 index 0000000..6084cab --- /dev/null +++ b/src/peripherals/mod.rs @@ -0,0 +1,231 @@ +//! Hardware peripherals — STM32, RPi GPIO, etc. +//! +//! Peripherals extend the agent with physical capabilities. See +//! `docs/hardware-peripherals-design.md` for the full design. + +pub mod traits; + +#[cfg(feature = "hardware")] +pub mod serial; + +#[cfg(feature = "hardware")] +pub mod arduino_flash; +#[cfg(feature = "hardware")] +pub mod arduino_upload; +#[cfg(feature = "hardware")] +pub mod capabilities_tool; +#[cfg(feature = "hardware")] +pub mod nucleo_flash; +#[cfg(feature = "hardware")] +pub mod uno_q_bridge; +#[cfg(feature = "hardware")] +pub mod uno_q_setup; + +#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))] +pub mod rpi; + +pub use traits::Peripheral; + +use crate::config::{Config, PeripheralBoardConfig, PeripheralsConfig}; +use crate::tools::{HardwareMemoryMapTool, Tool}; +use anyhow::Result; + +/// List configured boards from config (no connection yet). +pub fn list_configured_boards(config: &PeripheralsConfig) -> Vec<&PeripheralBoardConfig> { + if !config.enabled { + return Vec::new(); + } + config.boards.iter().collect() +} + +/// Handle `zeroclaw peripheral` subcommands. +#[allow(clippy::module_name_repetitions)] +pub fn handle_command(cmd: crate::PeripheralCommands, config: &Config) -> Result<()> { + match cmd { + crate::PeripheralCommands::List => { + let boards = list_configured_boards(&config.peripherals); + if boards.is_empty() { + println!("No peripherals configured."); + println!(); + println!("Add one with: zeroclaw peripheral add "); + println!(" Example: zeroclaw peripheral add nucleo-f401re /dev/ttyACM0"); + println!(); + println!("Or add to config.toml:"); + println!(" [peripherals]"); + println!(" enabled = true"); + println!(); + println!(" [[peripherals.boards]]"); + println!(" board = \"nucleo-f401re\""); + println!(" transport = \"serial\""); + println!(" path = \"/dev/ttyACM0\""); + } else { + println!("Configured peripherals:"); + for b in boards { + let path = b.path.as_deref().unwrap_or("(native)"); + println!(" {} {} {}", b.board, b.transport, path); + } + } + } + crate::PeripheralCommands::Add { board, path } => { + let transport = if path == "native" { "native" } else { "serial" }; + let path_opt = if path == "native" { + None + } else { + Some(path.clone()) + }; + + let mut cfg = crate::config::Config::load_or_init()?; + cfg.peripherals.enabled = true; + + if cfg + .peripherals + .boards + .iter() + .any(|b| b.board == board && b.path.as_deref() == path_opt.as_deref()) + { + println!("Board {} at {:?} already configured.", board, path_opt); + return Ok(()); + } + + cfg.peripherals.boards.push(PeripheralBoardConfig { + board: board.clone(), + transport: transport.to_string(), + path: path_opt, + baud: 115200, + }); + cfg.save()?; + println!("Added {} at {}. Restart daemon to apply.", board, path); + } + #[cfg(feature = "hardware")] + crate::PeripheralCommands::Flash { port } => { + let port_str = arduino_flash::resolve_port(config, port.as_deref()) + .or_else(|| port.clone()) + .ok_or_else(|| anyhow::anyhow!( + "No port specified. Use --port /dev/cu.usbmodem* or add arduino-uno to config.toml" + ))?; + arduino_flash::flash_arduino_firmware(&port_str)?; + } + #[cfg(not(feature = "hardware"))] + crate::PeripheralCommands::Flash { .. } => { + println!("Arduino flash requires the 'hardware' feature."); + println!("Build with: cargo build --features hardware"); + } + #[cfg(feature = "hardware")] + crate::PeripheralCommands::SetupUnoQ { host } => { + uno_q_setup::setup_uno_q_bridge(host.as_deref())?; + } + #[cfg(not(feature = "hardware"))] + crate::PeripheralCommands::SetupUnoQ { .. } => { + println!("Uno Q setup requires the 'hardware' feature."); + println!("Build with: cargo build --features hardware"); + } + #[cfg(feature = "hardware")] + crate::PeripheralCommands::FlashNucleo => { + nucleo_flash::flash_nucleo_firmware()?; + } + #[cfg(not(feature = "hardware"))] + crate::PeripheralCommands::FlashNucleo => { + println!("Nucleo flash requires the 'hardware' feature."); + println!("Build with: cargo build --features hardware"); + } + } + Ok(()) +} + +/// Create and connect peripherals from config, returning their tools. +/// Returns empty vec if peripherals disabled or hardware feature off. +#[cfg(feature = "hardware")] +pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result>> { + if !config.enabled || config.boards.is_empty() { + return Ok(Vec::new()); + } + + let mut tools: Vec> = Vec::new(); + let mut serial_transports: Vec<(String, std::sync::Arc)> = Vec::new(); + + for board in &config.boards { + // Arduino Uno Q: Bridge transport (socket to local Bridge app) + if board.transport == "bridge" && (board.board == "arduino-uno-q" || board.board == "uno-q") + { + tools.push(Box::new(uno_q_bridge::UnoQGpioReadTool)); + tools.push(Box::new(uno_q_bridge::UnoQGpioWriteTool)); + tracing::info!(board = %board.board, "Uno Q Bridge GPIO tools added"); + continue; + } + + // Native transport: RPi GPIO (Linux only) + #[cfg(all(feature = "peripheral-rpi", target_os = "linux"))] + if board.transport == "native" + && (board.board == "rpi-gpio" || board.board == "raspberry-pi") + { + match rpi::RpiGpioPeripheral::connect_from_config(board).await { + Ok(peripheral) => { + tools.extend(peripheral.tools()); + tracing::info!(board = %board.board, "RPi GPIO peripheral connected"); + } + Err(e) => { + tracing::warn!("Failed to connect RPi GPIO {}: {}", board.board, e); + } + } + continue; + } + + // Serial transport (STM32, ESP32, Arduino, etc.) + if board.transport != "serial" { + continue; + } + if board.path.is_none() { + tracing::warn!("Skipping serial board {}: no path", board.board); + continue; + } + + match serial::SerialPeripheral::connect(board).await { + Ok(peripheral) => { + let mut p = peripheral; + if p.connect().await.is_err() { + tracing::warn!("Peripheral {} connect warning (continuing)", p.name()); + } + serial_transports.push((board.board.clone(), p.transport())); + tools.extend(p.tools()); + if board.board == "arduino-uno" { + if let Some(ref path) = board.path { + tools.push(Box::new(arduino_upload::ArduinoUploadTool::new( + path.clone(), + ))); + tracing::info!("Arduino upload tool added (port: {})", path); + } + } + tracing::info!(board = %board.board, "Serial peripheral connected"); + } + Err(e) => { + tracing::warn!("Failed to connect {}: {}", board.board, e); + } + } + } + + // Phase B: Add hardware tools when any boards configured + if !tools.is_empty() { + let board_names: Vec = config.boards.iter().map(|b| b.board.clone()).collect(); + tools.push(Box::new(HardwareMemoryMapTool::new(board_names.clone()))); + tools.push(Box::new(crate::tools::HardwareBoardInfoTool::new( + board_names.clone(), + ))); + tools.push(Box::new(crate::tools::HardwareMemoryReadTool::new( + board_names, + ))); + } + + // Phase C: Add hardware_capabilities tool when any serial boards + if !serial_transports.is_empty() { + tools.push(Box::new(capabilities_tool::HardwareCapabilitiesTool::new( + serial_transports, + ))); + } + + Ok(tools) +} + +#[cfg(not(feature = "hardware"))] +pub async fn create_peripheral_tools(_config: &PeripheralsConfig) -> Result>> { + Ok(Vec::new()) +} diff --git a/src/peripherals/nucleo_flash.rs b/src/peripherals/nucleo_flash.rs new file mode 100644 index 0000000..5558872 --- /dev/null +++ b/src/peripherals/nucleo_flash.rs @@ -0,0 +1,83 @@ +//! Flash ZeroClaw Nucleo-F401RE firmware via probe-rs. +//! +//! Builds the Embassy firmware and flashes via ST-Link (built into Nucleo). +//! Requires: cargo install probe-rs-tools --locked + +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::process::Command; + +const CHIP: &str = "STM32F401RETx"; +const TARGET: &str = "thumbv7em-none-eabihf"; + +/// Check if probe-rs CLI is available (from probe-rs-tools). +pub fn probe_rs_available() -> bool { + Command::new("probe-rs") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Flash ZeroClaw Nucleo firmware. Builds from firmware/zeroclaw-nucleo. +pub fn flash_nucleo_firmware() -> Result<()> { + if !probe_rs_available() { + anyhow::bail!( + "probe-rs not found. Install it:\n cargo install probe-rs-tools --locked\n\n\ + Or: curl -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh\n\n\ + Connect Nucleo via USB (ST-Link). Then run this command again." + ); + } + + // CARGO_MANIFEST_DIR = repo root (zeroclaw's Cargo.toml) + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let firmware_dir = repo_root.join("firmware").join("zeroclaw-nucleo"); + if !firmware_dir.join("Cargo.toml").exists() { + anyhow::bail!( + "Nucleo firmware not found at {}. Run from zeroclaw repo root.", + firmware_dir.display() + ); + } + + println!("Building ZeroClaw Nucleo firmware..."); + let build = Command::new("cargo") + .args(["build", "--release", "--target", TARGET]) + .current_dir(&firmware_dir) + .output() + .context("cargo build failed")?; + + if !build.status.success() { + let stderr = String::from_utf8_lossy(&build.stderr); + anyhow::bail!("Build failed:\n{}", stderr); + } + + let elf_path = firmware_dir + .join("target") + .join(TARGET) + .join("release") + .join("zeroclaw-nucleo"); + + if !elf_path.exists() { + anyhow::bail!("Built binary not found at {}", elf_path.display()); + } + + println!("Flashing to Nucleo-F401RE (connect via USB)..."); + let flash = Command::new("probe-rs") + .args(["run", "--chip", CHIP, elf_path.to_str().unwrap()]) + .output() + .context("probe-rs run failed")?; + + if !flash.status.success() { + let stderr = String::from_utf8_lossy(&flash.stderr); + anyhow::bail!( + "Flash failed:\n{}\n\n\ + Ensure Nucleo is connected via USB. The ST-Link is built into the board.", + stderr + ); + } + + println!("ZeroClaw Nucleo firmware flashed successfully."); + println!("The Nucleo now supports: ping, capabilities, gpio_read, gpio_write."); + println!("Add to config.toml: board = \"nucleo-f401re\", transport = \"serial\", path = \"/dev/ttyACM0\""); + Ok(()) +} diff --git a/src/peripherals/rpi.rs b/src/peripherals/rpi.rs new file mode 100644 index 0000000..6cea075 --- /dev/null +++ b/src/peripherals/rpi.rs @@ -0,0 +1,173 @@ +//! Raspberry Pi GPIO peripheral — native rppal access. +//! +//! Only compiled when `peripheral-rpi` feature is enabled and target is Linux. +//! Uses BCM pin numbering (e.g. GPIO 17, 27). + +use crate::config::PeripheralBoardConfig; +use crate::peripherals::traits::Peripheral; +use crate::tools::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +/// RPi GPIO peripheral — direct access via rppal. +pub struct RpiGpioPeripheral { + board: PeripheralBoardConfig, +} + +impl RpiGpioPeripheral { + /// Create a new RPi GPIO peripheral from config. + pub fn new(board: PeripheralBoardConfig) -> Self { + Self { board } + } + + /// Attempt to connect (init rppal). Returns Ok if GPIO is available. + pub async fn connect_from_config(board: &PeripheralBoardConfig) -> anyhow::Result { + let mut peripheral = Self::new(board.clone()); + peripheral.connect().await?; + Ok(peripheral) + } +} + +#[async_trait] +impl Peripheral for RpiGpioPeripheral { + fn name(&self) -> &str { + &self.board.board + } + + fn board_type(&self) -> &str { + "rpi-gpio" + } + + async fn connect(&mut self) -> anyhow::Result<()> { + // Verify GPIO is accessible by doing a no-op init + let result = tokio::task::spawn_blocking(|| rppal::gpio::Gpio::new()).await??; + drop(result); + Ok(()) + } + + async fn disconnect(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn health_check(&self) -> bool { + tokio::task::spawn_blocking(|| rppal::gpio::Gpio::new().is_ok()) + .await + .unwrap_or(false) + } + + fn tools(&self) -> Vec> { + vec![Box::new(RpiGpioReadTool), Box::new(RpiGpioWriteTool)] + } +} + +/// Tool: read GPIO pin value (BCM numbering). +struct RpiGpioReadTool; + +#[async_trait] +impl Tool for RpiGpioReadTool { + fn name(&self) -> &str { + "gpio_read" + } + + fn description(&self) -> &str { + "Read the value (0 or 1) of a GPIO pin on Raspberry Pi. Uses BCM pin numbers (e.g. 17, 27)." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "BCM GPIO pin number (e.g. 17, 27)" + } + }, + "required": ["pin"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + let pin_u8 = pin as u8; + + let value = tokio::task::spawn_blocking(move || { + let gpio = rppal::gpio::Gpio::new()?; + let pin = gpio.get(pin_u8)?.into_input(); + Ok::<_, anyhow::Error>(match pin.read() { + rppal::gpio::Level::Low => 0, + rppal::gpio::Level::High => 1, + }) + }) + .await??; + + Ok(ToolResult { + success: true, + output: format!("pin {} = {}", pin, value), + error: None, + }) + } +} + +/// Tool: write GPIO pin value (BCM numbering). +struct RpiGpioWriteTool; + +#[async_trait] +impl Tool for RpiGpioWriteTool { + fn name(&self) -> &str { + "gpio_write" + } + + fn description(&self) -> &str { + "Set a GPIO pin high (1) or low (0) on Raspberry Pi. Uses BCM pin numbers." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "BCM GPIO pin number" + }, + "value": { + "type": "integer", + "description": "0 for low, 1 for high" + } + }, + "required": ["pin", "value"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + let value = args + .get("value") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?; + let pin_u8 = pin as u8; + let level = match value { + 0 => rppal::gpio::Level::Low, + _ => rppal::gpio::Level::High, + }; + + tokio::task::spawn_blocking(move || { + let gpio = rppal::gpio::Gpio::new()?; + let mut pin = gpio.get(pin_u8)?.into_output(); + pin.write(level); + Ok::<_, anyhow::Error>(()) + }) + .await??; + + Ok(ToolResult { + success: true, + output: format!("pin {} = {}", pin, value), + error: None, + }) + } +} diff --git a/src/peripherals/serial.rs b/src/peripherals/serial.rs new file mode 100644 index 0000000..ab40d71 --- /dev/null +++ b/src/peripherals/serial.rs @@ -0,0 +1,274 @@ +//! Serial peripheral — STM32 and similar boards over USB CDC/serial. +//! +//! Protocol: newline-delimited JSON. +//! Request: {"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}} +//! Response: {"id":"1","ok":true,"result":"done"} + +use super::traits::Peripheral; +use crate::config::PeripheralBoardConfig; +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::Mutex; +use tokio_serial::{SerialPortBuilderExt, SerialStream}; + +/// Allowed serial path patterns (security: deny arbitrary paths). +const ALLOWED_PATH_PREFIXES: &[&str] = &[ + "/dev/ttyACM", + "/dev/ttyUSB", + "/dev/tty.usbmodem", + "/dev/cu.usbmodem", + "/dev/tty.usbserial", + "/dev/cu.usbserial", // Arduino Uno (FTDI), clones + "COM", // Windows +]; + +fn is_path_allowed(path: &str) -> bool { + ALLOWED_PATH_PREFIXES.iter().any(|p| path.starts_with(p)) +} + +/// JSON request/response over serial. +async fn send_request(port: &mut SerialStream, cmd: &str, args: Value) -> anyhow::Result { + static ID: AtomicU64 = AtomicU64::new(0); + let id = ID.fetch_add(1, Ordering::Relaxed); + let id_str = id.to_string(); + + let req = json!({ + "id": id_str, + "cmd": cmd, + "args": args + }); + let line = format!("{}\n", req); + + port.write_all(line.as_bytes()).await?; + port.flush().await?; + + let mut buf = Vec::new(); + let mut b = [0u8; 1]; + while port.read_exact(&mut b).await.is_ok() { + if b[0] == b'\n' { + break; + } + buf.push(b[0]); + } + let line_str = String::from_utf8_lossy(&buf); + let resp: Value = serde_json::from_str(line_str.trim())?; + let resp_id = resp["id"].as_str().unwrap_or(""); + if resp_id != id_str { + anyhow::bail!("Response id mismatch: expected {}, got {}", id_str, resp_id); + } + Ok(resp) +} + +/// Shared serial transport for tools. Pub(crate) for capabilities tool. +pub(crate) struct SerialTransport { + port: Mutex, +} + +/// Timeout for serial request/response (seconds). +const SERIAL_TIMEOUT_SECS: u64 = 5; + +impl SerialTransport { + async fn request(&self, cmd: &str, args: Value) -> anyhow::Result { + let mut port = self.port.lock().await; + let resp = tokio::time::timeout( + std::time::Duration::from_secs(SERIAL_TIMEOUT_SECS), + send_request(&mut *port, cmd, args), + ) + .await + .map_err(|_| { + anyhow::anyhow!("Serial request timed out after {}s", SERIAL_TIMEOUT_SECS) + })??; + + let ok = resp["ok"].as_bool().unwrap_or(false); + let result = resp["result"] + .as_str() + .map(String::from) + .unwrap_or_else(|| resp["result"].to_string()); + let error = resp["error"].as_str().map(String::from); + + Ok(ToolResult { + success: ok, + output: result, + error, + }) + } + + /// Phase C: fetch capabilities from device (gpio pins, led_pin). + pub async fn capabilities(&self) -> anyhow::Result { + self.request("capabilities", json!({})).await + } +} + +/// Serial peripheral for STM32, Arduino, etc. over USB CDC. +pub struct SerialPeripheral { + name: String, + board_type: String, + transport: Arc, +} + +impl SerialPeripheral { + /// Create and connect to a serial peripheral. + pub async fn connect(config: &PeripheralBoardConfig) -> anyhow::Result { + let path = config + .path + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Serial peripheral requires path"))?; + + if !is_path_allowed(path) { + anyhow::bail!( + "Serial path not allowed: {}. Allowed: /dev/ttyACM*, /dev/ttyUSB*, /dev/tty.usbmodem*, /dev/cu.usbmodem*", + path + ); + } + + let port = tokio_serial::new(path, config.baud) + .open_native_async() + .map_err(|e| anyhow::anyhow!("Failed to open {}: {}", path, e))?; + + let name = format!("{}-{}", config.board, path.replace('/', "_")); + let transport = Arc::new(SerialTransport { + port: Mutex::new(port), + }); + + Ok(Self { + name: name.clone(), + board_type: config.board.clone(), + transport, + }) + } +} + +#[async_trait] +impl Peripheral for SerialPeripheral { + fn name(&self) -> &str { + &self.name + } + + fn board_type(&self) -> &str { + &self.board_type + } + + async fn connect(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn disconnect(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn health_check(&self) -> bool { + self.transport + .request("ping", json!({})) + .await + .map(|r| r.success) + .unwrap_or(false) + } + + fn tools(&self) -> Vec> { + vec![ + Box::new(GpioReadTool { + transport: self.transport.clone(), + }), + Box::new(GpioWriteTool { + transport: self.transport.clone(), + }), + ] + } +} + +impl SerialPeripheral { + /// Expose transport for capabilities tool (Phase C). + pub(crate) fn transport(&self) -> Arc { + self.transport.clone() + } +} + +/// Tool: read GPIO pin value. +struct GpioReadTool { + transport: Arc, +} + +#[async_trait] +impl Tool for GpioReadTool { + fn name(&self) -> &str { + "gpio_read" + } + + fn description(&self) -> &str { + "Read the value (0 or 1) of a GPIO pin on a connected peripheral (e.g. STM32 Nucleo)" + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "GPIO pin number (e.g. 13 for LED on Nucleo)" + } + }, + "required": ["pin"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + self.transport + .request("gpio_read", json!({ "pin": pin })) + .await + } +} + +/// Tool: write GPIO pin value. +struct GpioWriteTool { + transport: Arc, +} + +#[async_trait] +impl Tool for GpioWriteTool { + fn name(&self) -> &str { + "gpio_write" + } + + fn description(&self) -> &str { + "Set a GPIO pin high (1) or low (0) on a connected peripheral (e.g. turn on/off LED)" + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "GPIO pin number" + }, + "value": { + "type": "integer", + "description": "0 for low, 1 for high" + } + }, + "required": ["pin", "value"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + let value = args + .get("value") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?; + self.transport + .request("gpio_write", json!({ "pin": pin, "value": value })) + .await + } +} diff --git a/src/peripherals/traits.rs b/src/peripherals/traits.rs new file mode 100644 index 0000000..6081d1d --- /dev/null +++ b/src/peripherals/traits.rs @@ -0,0 +1,33 @@ +//! Peripheral trait — hardware boards (STM32, RPi GPIO) that expose tools. +//! +//! Peripherals are the agent's "arms and legs": remote devices that run minimal +//! firmware and expose capabilities (GPIO, sensors, actuators) as tools. + +use async_trait::async_trait; + +use crate::tools::Tool; + +/// A hardware peripheral that exposes capabilities as tools. +/// +/// Implement this for boards like Nucleo-F401RE (serial), RPi GPIO (native), etc. +/// When connected, the peripheral's tools are merged into the agent's tool registry. +#[async_trait] +pub trait Peripheral: Send + Sync { + /// Human-readable peripheral name (e.g. "nucleo-f401re-0") + fn name(&self) -> &str; + + /// Board type identifier (e.g. "nucleo-f401re", "rpi-gpio") + fn board_type(&self) -> &str; + + /// Connect to the peripheral (open serial, init GPIO, etc.) + async fn connect(&mut self) -> anyhow::Result<()>; + + /// Disconnect and release resources + async fn disconnect(&mut self) -> anyhow::Result<()>; + + /// Check if the peripheral is reachable and responsive + async fn health_check(&self) -> bool; + + /// Tools this peripheral provides (e.g. gpio_read, gpio_write, sensor_read) + fn tools(&self) -> Vec>; +} diff --git a/src/peripherals/uno_q_bridge.rs b/src/peripherals/uno_q_bridge.rs new file mode 100644 index 0000000..a621831 --- /dev/null +++ b/src/peripherals/uno_q_bridge.rs @@ -0,0 +1,151 @@ +//! Arduino Uno Q Bridge — GPIO via socket to Bridge app. +//! +//! When ZeroClaw runs on Uno Q, the Bridge app (Python + MCU) exposes +//! digitalWrite/digitalRead over a local socket. These tools connect to it. + +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +const BRIDGE_HOST: &str = "127.0.0.1"; +const BRIDGE_PORT: u16 = 9999; + +async fn bridge_request(cmd: &str, args: &[String]) -> anyhow::Result { + let addr = format!("{}:{}", BRIDGE_HOST, BRIDGE_PORT); + let mut stream = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&addr)) + .await + .map_err(|_| anyhow::anyhow!("Bridge connection timed out"))??; + + let msg = format!("{} {}\n", cmd, args.join(" ")); + stream.write_all(msg.as_bytes()).await?; + + let mut buf = vec![0u8; 64]; + let n = tokio::time::timeout(Duration::from_secs(3), stream.read(&mut buf)) + .await + .map_err(|_| anyhow::anyhow!("Bridge response timed out"))??; + let resp = String::from_utf8_lossy(&buf[..n]).trim().to_string(); + Ok(resp) +} + +/// Tool: read GPIO pin via Uno Q Bridge. +pub struct UnoQGpioReadTool; + +#[async_trait] +impl Tool for UnoQGpioReadTool { + fn name(&self) -> &str { + "gpio_read" + } + + fn description(&self) -> &str { + "Read GPIO pin value (0 or 1) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "GPIO pin number (e.g. 13 for LED)" + } + }, + "required": ["pin"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + match bridge_request("gpio_read", &[pin.to_string()]).await { + Ok(resp) => { + if resp.starts_with("error:") { + Ok(ToolResult { + success: false, + output: resp.clone(), + error: Some(resp), + }) + } else { + Ok(ToolResult { + success: true, + output: resp, + error: None, + }) + } + } + Err(e) => Ok(ToolResult { + success: false, + output: format!("Bridge error: {}", e), + error: Some(e.to_string()), + }), + } + } +} + +/// Tool: write GPIO pin via Uno Q Bridge. +pub struct UnoQGpioWriteTool; + +#[async_trait] +impl Tool for UnoQGpioWriteTool { + fn name(&self) -> &str { + "gpio_write" + } + + fn description(&self) -> &str { + "Set GPIO pin high (1) or low (0) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "GPIO pin number" + }, + "value": { + "type": "integer", + "description": "0 for low, 1 for high" + } + }, + "required": ["pin", "value"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + let value = args + .get("value") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?; + match bridge_request("gpio_write", &[pin.to_string(), value.to_string()]).await { + Ok(resp) => { + if resp.starts_with("error:") { + Ok(ToolResult { + success: false, + output: resp.clone(), + error: Some(resp), + }) + } else { + Ok(ToolResult { + success: true, + output: "done".into(), + error: None, + }) + } + } + Err(e) => Ok(ToolResult { + success: false, + output: format!("Bridge error: {}", e), + error: Some(e.to_string()), + }), + } + } +} diff --git a/src/peripherals/uno_q_setup.rs b/src/peripherals/uno_q_setup.rs new file mode 100644 index 0000000..3b7d114 --- /dev/null +++ b/src/peripherals/uno_q_setup.rs @@ -0,0 +1,143 @@ +//! Deploy ZeroClaw Bridge app to Arduino Uno Q. + +use anyhow::{Context, Result}; +use std::process::Command; + +const BRIDGE_APP_NAME: &str = "zeroclaw-uno-q-bridge"; + +/// Deploy the Bridge app. If host is Some, scp from repo and ssh to start. +/// If host is None, assume we're ON the Uno Q — use embedded files and start. +pub fn setup_uno_q_bridge(host: Option<&str>) -> Result<()> { + let bridge_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("firmware") + .join("zeroclaw-uno-q-bridge"); + + if let Some(h) = host { + if bridge_dir.exists() { + deploy_remote(h, &bridge_dir)?; + } else { + anyhow::bail!( + "Bridge app not found at {}. Run from zeroclaw repo root.", + bridge_dir.display() + ); + } + } else { + deploy_local(if bridge_dir.exists() { + Some(&bridge_dir) + } else { + None + })?; + } + Ok(()) +} + +fn deploy_remote(host: &str, bridge_dir: &std::path::Path) -> Result<()> { + let ssh_target = if host.contains('@') { + host.to_string() + } else { + format!("arduino@{}", host) + }; + + println!("Copying Bridge app to {}...", host); + let status = Command::new("ssh") + .args([&ssh_target, "mkdir", "-p", "~/ArduinoApps"]) + .status() + .context("ssh mkdir failed")?; + if !status.success() { + anyhow::bail!("Failed to create ArduinoApps dir on Uno Q"); + } + + let status = Command::new("scp") + .args([ + "-r", + bridge_dir.to_str().unwrap(), + &format!("{}:~/ArduinoApps/", ssh_target), + ]) + .status() + .context("scp failed")?; + if !status.success() { + anyhow::bail!("Failed to copy Bridge app"); + } + + println!("Starting Bridge app on Uno Q..."); + let status = Command::new("ssh") + .args([ + &ssh_target, + "arduino-app-cli", + "app", + "start", + &format!("~/ArduinoApps/zeroclaw-uno-q-bridge"), + ]) + .status() + .context("arduino-app-cli start failed")?; + if !status.success() { + anyhow::bail!("Failed to start Bridge app. Ensure arduino-app-cli is installed on Uno Q."); + } + + println!("ZeroClaw Bridge app started. Add to config.toml:"); + println!(" [[peripherals.boards]]"); + println!(" board = \"arduino-uno-q\""); + println!(" transport = \"bridge\""); + Ok(()) +} + +fn deploy_local(bridge_dir: Option<&std::path::Path>) -> Result<()> { + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/arduino".into()); + let apps_dir = std::path::Path::new(&home).join("ArduinoApps"); + let dest_dir = apps_dir.join(BRIDGE_APP_NAME); + + std::fs::create_dir_all(&dest_dir).context("create dest dir")?; + + if let Some(src) = bridge_dir { + println!("Copying Bridge app from repo..."); + copy_dir(src, &dest_dir)?; + } else { + println!("Writing embedded Bridge app..."); + write_embedded_bridge(&dest_dir)?; + } + + println!("Starting Bridge app..."); + let status = Command::new("arduino-app-cli") + .args(["app", "start", dest_dir.to_str().unwrap()]) + .status() + .context("arduino-app-cli start failed")?; + if !status.success() { + anyhow::bail!("Failed to start Bridge app. Ensure arduino-app-cli is installed on Uno Q."); + } + + println!("ZeroClaw Bridge app started."); + Ok(()) +} + +fn write_embedded_bridge(dest: &std::path::Path) -> Result<()> { + let app_yaml = include_str!("../../firmware/zeroclaw-uno-q-bridge/app.yaml"); + let sketch_ino = include_str!("../../firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino"); + let sketch_yaml = include_str!("../../firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml"); + let main_py = include_str!("../../firmware/zeroclaw-uno-q-bridge/python/main.py"); + let requirements = include_str!("../../firmware/zeroclaw-uno-q-bridge/python/requirements.txt"); + + std::fs::write(dest.join("app.yaml"), app_yaml)?; + std::fs::create_dir_all(dest.join("sketch"))?; + std::fs::write(dest.join("sketch").join("sketch.ino"), sketch_ino)?; + std::fs::write(dest.join("sketch").join("sketch.yaml"), sketch_yaml)?; + std::fs::create_dir_all(dest.join("python"))?; + std::fs::write(dest.join("python").join("main.py"), main_py)?; + std::fs::write(dest.join("python").join("requirements.txt"), requirements)?; + Ok(()) +} + +fn copy_dir(src: &std::path::Path, dst: &std::path::Path) -> Result<()> { + for entry in std::fs::read_dir(src)? { + let e = entry?; + let name = e.file_name(); + let src_path = src.join(&name); + let dst_path = dst.join(&name); + if e.file_type()?.is_dir() { + std::fs::create_dir_all(&dst_path)?; + copy_dir(&src_path, &dst_path)?; + } else { + std::fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} diff --git a/src/providers/compatible.rs b/src/providers/compatible.rs index 4c59992..e9e39e1 100644 --- a/src/providers/compatible.rs +++ b/src/providers/compatible.rs @@ -15,6 +15,9 @@ pub struct OpenAiCompatibleProvider { pub(crate) base_url: String, pub(crate) api_key: Option, pub(crate) auth_header: AuthStyle, + /// When false, do not fall back to /v1/responses on chat completions 404. + /// GLM/Zhipu does not support the responses API. + supports_responses_fallback: bool, client: Client, } @@ -36,6 +39,29 @@ impl OpenAiCompatibleProvider { base_url: base_url.trim_end_matches('/').to_string(), api_key: api_key.map(ToString::to_string), auth_header: auth_style, + supports_responses_fallback: true, + client: Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + /// Same as `new` but skips the /v1/responses fallback on 404. + /// Use for providers (e.g. GLM) that only support chat completions. + pub fn new_no_responses_fallback( + name: &str, + base_url: &str, + api_key: Option<&str>, + auth_style: AuthStyle, + ) -> Self { + Self { + name: name.to_string(), + base_url: base_url.trim_end_matches('/').to_string(), + api_key: api_key.map(ToString::to_string), + auth_header: auth_style, + supports_responses_fallback: false, client: Client::builder() .timeout(std::time::Duration::from_secs(120)) .connect_timeout(std::time::Duration::from_secs(10)) @@ -112,6 +138,8 @@ struct ChatRequest { model: String, messages: Vec, temperature: f64, + #[serde(skip_serializing_if = "Option::is_none")] + stream: Option, } #[derive(Debug, Serialize)] @@ -348,6 +376,7 @@ impl Provider for OpenAiCompatibleProvider { model: model.to_string(), messages, temperature, + stream: Some(false), }; let url = self.chat_completions_url(); @@ -362,7 +391,7 @@ impl Provider for OpenAiCompatibleProvider { let error = response.text().await?; let sanitized = super::sanitize_api_error(&error); - if status == reqwest::StatusCode::NOT_FOUND { + if status == reqwest::StatusCode::NOT_FOUND && self.supports_responses_fallback { return self .chat_via_responses(api_key, system_prompt, message, model) .await @@ -413,6 +442,7 @@ impl Provider for OpenAiCompatibleProvider { model: model.to_string(), messages: api_messages, temperature, + stream: Some(false), }; let url = self.chat_completions_url(); @@ -425,7 +455,7 @@ impl Provider for OpenAiCompatibleProvider { let status = response.status(); // Mirror chat_with_system: 404 may mean this provider uses the Responses API - if status == reqwest::StatusCode::NOT_FOUND { + if status == reqwest::StatusCode::NOT_FOUND && self.supports_responses_fallback { // Extract system prompt and last user message for responses fallback let system = messages.iter().find(|m| m.role == "system"); let last_user = messages.iter().rfind(|m| m.role == "user"); @@ -517,7 +547,8 @@ mod tests { content: "hello".to_string(), }, ], - temperature: 0.7, + temperature: 0.4, + stream: Some(false), }; let json = serde_json::to_string(&req).unwrap(); assert!(json.contains("llama-3.3-70b")); diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 1808499..ca4eaa4 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -217,8 +217,8 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result Ok(Box::new(OpenAiCompatibleProvider::new( "Z.AI", "https://api.z.ai/api/coding/paas/v4", key, AuthStyle::Bearer, ))), - "glm" | "zhipu" => Ok(Box::new(OpenAiCompatibleProvider::new( - "GLM", "https://open.bigmodel.cn/api/paas/v4", key, AuthStyle::Bearer, + "glm" | "zhipu" => Ok(Box::new(OpenAiCompatibleProvider::new_no_responses_fallback( + "GLM", "https://api.z.ai/api/paas/v4", key, AuthStyle::Bearer, ))), "minimax" => Ok(Box::new(OpenAiCompatibleProvider::new( "MiniMax", diff --git a/src/rag/mod.rs b/src/rag/mod.rs new file mode 100644 index 0000000..cc98c5a --- /dev/null +++ b/src/rag/mod.rs @@ -0,0 +1,397 @@ +//! RAG pipeline for hardware datasheet retrieval. +//! +//! Supports: +//! - Markdown and text datasheets (always) +//! - PDF ingestion (with `rag-pdf` feature) +//! - Pin/alias tables (e.g. `red_led: 13`) for explicit lookup +//! - Keyword retrieval (default) or semantic search via embeddings (optional) + +use crate::memory::chunker; +use std::collections::HashMap; +use std::path::Path; + +/// A chunk of datasheet content with board metadata. +#[derive(Debug, Clone)] +pub struct DatasheetChunk { + /// Board this chunk applies to (e.g. "nucleo-f401re", "rpi-gpio"), or None for generic. + pub board: Option, + /// Source file path (for debugging). + pub source: String, + /// Chunk content. + pub content: String, +} + +/// Pin alias: human-readable name → pin number (e.g. "red_led" → 13). +pub type PinAliases = HashMap; + +/// Parse pin aliases from markdown. Looks for: +/// - `## Pin Aliases` section with `alias: pin` lines +/// - Markdown table `| alias | pin |` +fn parse_pin_aliases(content: &str) -> PinAliases { + let mut aliases = PinAliases::new(); + let content_lower = content.to_lowercase(); + + // Find ## Pin Aliases section + let section_markers = ["## pin aliases", "## pin alias", "## pins"]; + let mut in_section = false; + let mut section_start = 0; + + for marker in section_markers { + if let Some(pos) = content_lower.find(marker) { + in_section = true; + section_start = pos + marker.len(); + break; + } + } + + if !in_section { + return aliases; + } + + let rest = &content[section_start..]; + let section_end = rest + .find("\n## ") + .map(|i| section_start + i) + .unwrap_or(content.len()); + let section = &content[section_start..section_end]; + + // Parse "alias: pin" or "alias = pin" lines + for line in section.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + // Table row: | red_led | 13 | (skip header | alias | pin | and separator |---|) + if line.starts_with('|') { + let parts: Vec<&str> = line.split('|').map(|s| s.trim()).collect(); + if parts.len() >= 3 { + let alias = parts[1].trim().to_lowercase().replace(' ', "_"); + let pin_str = parts[2].trim(); + // Skip header row and separator (|---|) + if alias.eq("alias") + || alias.eq("pin") + || pin_str.eq("pin") + || alias.contains("---") + || pin_str.contains("---") + { + continue; + } + if let Ok(pin) = pin_str.parse::() { + if !alias.is_empty() { + aliases.insert(alias, pin); + } + } + } + continue; + } + // Key: value + if let Some((k, v)) = line.split_once(':').or_else(|| line.split_once('=')) { + let alias = k.trim().to_lowercase().replace(' ', "_"); + if let Ok(pin) = v.trim().parse::() { + if !alias.is_empty() { + aliases.insert(alias, pin); + } + } + } + } + + aliases +} + +fn collect_md_txt_paths(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_md_txt_paths(&path, out); + } else if path.is_file() { + let ext = path.extension().and_then(|e| e.to_str()); + if ext == Some("md") || ext == Some("txt") { + out.push(path); + } + } + } +} + +#[cfg(feature = "rag-pdf")] +fn collect_pdf_paths(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_pdf_paths(&path, out); + } else if path.is_file() { + if path.extension().and_then(|e| e.to_str()) == Some("pdf") { + out.push(path); + } + } + } +} + +#[cfg(feature = "rag-pdf")] +fn extract_pdf_text(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + pdf_extract::extract_text_from_mem(&bytes).ok() +} + +/// Hardware RAG index — loads and retrieves datasheet chunks. +pub struct HardwareRag { + chunks: Vec, + /// Per-board pin aliases (board -> alias -> pin). + pin_aliases: HashMap, +} + +impl HardwareRag { + /// Load datasheets from a directory. Expects .md, .txt, and optionally .pdf (with rag-pdf). + /// Filename (without extension) is used as board tag. + /// Supports `## Pin Aliases` section for explicit alias→pin mapping. + pub fn load(workspace_dir: &Path, datasheet_dir: &str) -> anyhow::Result { + let base = workspace_dir.join(datasheet_dir); + if !base.exists() || !base.is_dir() { + return Ok(Self { + chunks: Vec::new(), + pin_aliases: HashMap::new(), + }); + } + + let mut paths: Vec = Vec::new(); + collect_md_txt_paths(&base, &mut paths); + #[cfg(feature = "rag-pdf")] + collect_pdf_paths(&base, &mut paths); + + let mut chunks = Vec::new(); + let mut pin_aliases: HashMap = HashMap::new(); + let max_tokens = 512; + + for path in paths { + let content = if path.extension().and_then(|e| e.to_str()) == Some("pdf") { + #[cfg(feature = "rag-pdf")] + { + extract_pdf_text(&path).unwrap_or_default() + } + #[cfg(not(feature = "rag-pdf"))] + { + String::new() + } + } else { + std::fs::read_to_string(&path).unwrap_or_default() + }; + + if content.trim().is_empty() { + continue; + } + + let board = infer_board_from_path(&path, &base); + let source = path + .strip_prefix(workspace_dir) + .unwrap_or(&path) + .display() + .to_string(); + + // Parse pin aliases from full content + let aliases = parse_pin_aliases(&content); + if let Some(ref b) = board { + if !aliases.is_empty() { + pin_aliases.insert(b.clone(), aliases); + } + } + + for chunk in chunker::chunk_markdown(&content, max_tokens) { + chunks.push(DatasheetChunk { + board: board.clone(), + source: source.clone(), + content: chunk.content, + }); + } + } + + Ok(Self { + chunks, + pin_aliases, + }) + } + + /// Get pin aliases for a board (e.g. "red_led" -> 13). + pub fn pin_aliases_for_board(&self, board: &str) -> Option<&PinAliases> { + self.pin_aliases.get(board) + } + + /// Build pin-alias context for query. When user says "red led", inject "red_led: 13" for matching boards. + pub fn pin_alias_context(&self, query: &str, boards: &[String]) -> String { + let query_lower = query.to_lowercase(); + let query_words: Vec<&str> = query_lower + .split_whitespace() + .filter(|w| w.len() > 1) + .collect(); + + let mut lines = Vec::new(); + for board in boards { + if let Some(aliases) = self.pin_aliases.get(board) { + for (alias, pin) in aliases { + let alias_words: Vec<&str> = alias.split('_').collect(); + let matches = query_words + .iter() + .any(|qw| alias_words.iter().any(|aw| *aw == *qw)) + || query_lower.contains(&alias.replace('_', " ")); + if matches { + lines.push(format!("{board}: {alias} = pin {pin}")); + } + } + } + } + if lines.is_empty() { + return String::new(); + } + format!("[Pin aliases for query]\n{}\n\n", lines.join("\n")) + } + + /// Retrieve chunks relevant to the query and boards. + /// Uses keyword matching and board filter. Pin-alias context is built separately via `pin_alias_context`. + pub fn retrieve(&self, query: &str, boards: &[String], limit: usize) -> Vec<&DatasheetChunk> { + if self.chunks.is_empty() || limit == 0 { + return Vec::new(); + } + + let query_lower = query.to_lowercase(); + let query_terms: Vec<&str> = query_lower + .split_whitespace() + .filter(|w| w.len() > 2) + .collect(); + + let mut scored: Vec<(&DatasheetChunk, f32)> = Vec::new(); + for chunk in &self.chunks { + let content_lower = chunk.content.to_lowercase(); + let mut score = 0.0f32; + + for term in &query_terms { + if content_lower.contains(term) { + score += 1.0; + } + } + + if score > 0.0 { + let board_match = chunk.board.as_ref().map_or(false, |b| boards.contains(b)); + if board_match { + score += 2.0; + } + scored.push((chunk, score)); + } + } + + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + scored.truncate(limit); + scored.into_iter().map(|(c, _)| c).collect() + } + + /// Number of indexed chunks. + pub fn len(&self) -> usize { + self.chunks.len() + } + + /// True if no chunks are indexed. + pub fn is_empty(&self) -> bool { + self.chunks.is_empty() + } +} + +/// Infer board tag from file path. `nucleo-f401re.md` → Some("nucleo-f401re"). +fn infer_board_from_path(path: &Path, base: &Path) -> Option { + let rel = path.strip_prefix(base).ok()?; + let stem = path.file_stem()?.to_str()?; + + if stem == "generic" || stem.starts_with("generic_") { + return None; + } + if rel.parent().and_then(|p| p.to_str()) == Some("_generic") { + return None; + } + + Some(stem.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_pin_aliases_key_value() { + let md = r#"## Pin Aliases +red_led: 13 +builtin_led: 13 +user_led: 5"#; + let a = parse_pin_aliases(md); + assert_eq!(a.get("red_led"), Some(&13)); + assert_eq!(a.get("builtin_led"), Some(&13)); + assert_eq!(a.get("user_led"), Some(&5)); + } + + #[test] + fn parse_pin_aliases_table() { + let md = r#"## Pin Aliases +| alias | pin | +|-------|-----| +| red_led | 13 | +| builtin_led | 13 |"#; + let a = parse_pin_aliases(md); + assert_eq!(a.get("red_led"), Some(&13)); + assert_eq!(a.get("builtin_led"), Some(&13)); + } + + #[test] + fn parse_pin_aliases_empty() { + let a = parse_pin_aliases("No aliases here"); + assert!(a.is_empty()); + } + + #[test] + fn infer_board_from_path_nucleo() { + let base = std::path::Path::new("/base"); + let path = std::path::Path::new("/base/nucleo-f401re.md"); + assert_eq!( + infer_board_from_path(path, base), + Some("nucleo-f401re".into()) + ); + } + + #[test] + fn infer_board_generic_none() { + let base = std::path::Path::new("/base"); + let path = std::path::Path::new("/base/generic.md"); + assert_eq!(infer_board_from_path(path, base), None); + } + + #[test] + fn hardware_rag_load_and_retrieve() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().join("datasheets"); + std::fs::create_dir_all(&base).unwrap(); + let content = r#"# Test Board +## Pin Aliases +red_led: 13 +## GPIO +Pin 13: LED +"#; + std::fs::write(base.join("test-board.md"), content).unwrap(); + + let rag = HardwareRag::load(tmp.path(), "datasheets").unwrap(); + assert!(!rag.is_empty()); + let boards = vec!["test-board".to_string()]; + let chunks = rag.retrieve("led", &boards, 5); + assert!(!chunks.is_empty()); + let ctx = rag.pin_alias_context("red led", &boards); + assert!(ctx.contains("13")); + } + + #[test] + fn hardware_rag_load_empty_dir() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().join("empty_ds"); + std::fs::create_dir_all(&base).unwrap(); + let rag = HardwareRag::load(tmp.path(), "empty_ds").unwrap(); + assert!(rag.is_empty()); + } +} diff --git a/src/tools/hardware_board_info.rs b/src/tools/hardware_board_info.rs new file mode 100644 index 0000000..f7af262 --- /dev/null +++ b/src/tools/hardware_board_info.rs @@ -0,0 +1,205 @@ +//! Hardware board info tool — returns chip name, architecture, memory map for Telegram/agent. +//! +//! Use when user asks "what board do I have?", "board info", "connected hardware", etc. +//! Uses probe-rs for Nucleo when available; otherwise static datasheet info. + +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +/// Static board info (datasheets). Used when probe-rs is unavailable. +const BOARD_INFO: &[(&str, &str, &str)] = &[ + ( + "nucleo-f401re", + "STM32F401RET6", + "ARM Cortex-M4, 84 MHz. Flash: 512 KB, RAM: 128 KB. User LED on PA5 (pin 13).", + ), + ( + "nucleo-f411re", + "STM32F411RET6", + "ARM Cortex-M4, 100 MHz. Flash: 512 KB, RAM: 128 KB. User LED on PA5 (pin 13).", + ), + ( + "arduino-uno", + "ATmega328P", + "8-bit AVR, 16 MHz. Flash: 16 KB, SRAM: 2 KB. Built-in LED on pin 13.", + ), + ( + "arduino-uno-q", + "STM32U585 + Qualcomm", + "Dual-core: STM32 (MCU) + Linux (aarch64). GPIO via Bridge app on port 9999.", + ), + ( + "esp32", + "ESP32", + "Dual-core Xtensa LX6, 240 MHz. Flash: 4 MB typical. Built-in LED on GPIO 2.", + ), + ( + "rpi-gpio", + "Raspberry Pi", + "ARM Linux. Native GPIO via sysfs/rppal. No fixed LED pin.", + ), +]; + +/// Tool: return full board info (chip, architecture, memory map) for agent/Telegram. +pub struct HardwareBoardInfoTool { + boards: Vec, +} + +impl HardwareBoardInfoTool { + pub fn new(boards: Vec) -> Self { + Self { boards } + } + + fn static_info_for_board(&self, board: &str) -> Option { + BOARD_INFO + .iter() + .find(|(b, _, _)| *b == board) + .map(|(_, chip, desc)| { + format!( + "**Board:** {}\n**Chip:** {}\n**Description:** {}", + board, chip, desc + ) + }) + } +} + +#[async_trait] +impl Tool for HardwareBoardInfoTool { + fn name(&self) -> &str { + "hardware_board_info" + } + + fn description(&self) -> &str { + "Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', 'what hardware', or 'memory map'." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "board": { + "type": "string", + "description": "Optional board name (e.g. nucleo-f401re). If omitted, returns info for first configured board." + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let board = args + .get("board") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| self.boards.first().cloned()); + + let board = board.as_deref().unwrap_or("unknown"); + + if self.boards.is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "No peripherals configured. Add boards to config.toml [peripherals.boards]." + .into(), + ), + }); + } + + let mut output = String::new(); + + #[cfg(feature = "probe")] + if board == "nucleo-f401re" || board == "nucleo-f411re" { + let chip = if board == "nucleo-f411re" { + "STM32F411RETx" + } else { + "STM32F401RETx" + }; + match probe_board_info(chip) { + Ok(info) => { + return Ok(ToolResult { + success: true, + output: info, + error: None, + }); + } + Err(e) => { + output.push_str(&format!( + "probe-rs attach failed: {}. Using static info.\n\n", + e + )); + } + } + } + + if let Some(info) = self.static_info_for_board(board) { + output.push_str(&info); + if let Some(mem) = memory_map_static(board) { + output.push_str(&format!("\n\n**Memory map:**\n{}", mem)); + } + } else { + output.push_str(&format!( + "Board '{}' configured. No static info available.", + board + )); + } + + Ok(ToolResult { + success: true, + output, + error: None, + }) + } +} + +#[cfg(feature = "probe")] +fn probe_board_info(chip: &str) -> anyhow::Result { + use probe_rs::config::MemoryRegion; + use probe_rs::{Session, SessionConfig}; + + let session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let target = session.target(); + let arch = session.architecture(); + + let mut out = format!( + "**Board:** {}\n**Chip:** {}\n**Architecture:** {:?}\n\n**Memory map:**\n", + chip, target.name, arch + ); + for region in target.memory_map.iter() { + match region { + MemoryRegion::Ram(ram) => { + let (start, end) = (ram.range.start, ram.range.end); + out.push_str(&format!( + "RAM: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, + end, + (end - start) / 1024 + )); + } + MemoryRegion::Nvm(flash) => { + let (start, end) = (flash.range.start, flash.range.end); + out.push_str(&format!( + "Flash: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, + end, + (end - start) / 1024 + )); + } + _ => {} + } + } + out.push_str("\n(Info read via USB/SWD — no firmware on target needed.)"); + Ok(out) +} + +fn memory_map_static(board: &str) -> Option<&'static str> { + match board { + "nucleo-f401re" | "nucleo-f411re" => Some( + "Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)", + ), + "arduino-uno" => Some("Flash: 16 KB, SRAM: 2 KB, EEPROM: 1 KB"), + "esp32" => Some("Flash: 4 MB, IRAM/DRAM per ESP-IDF layout"), + _ => None, + } +} diff --git a/src/tools/hardware_memory_map.rs b/src/tools/hardware_memory_map.rs new file mode 100644 index 0000000..bdb4f96 --- /dev/null +++ b/src/tools/hardware_memory_map.rs @@ -0,0 +1,205 @@ +//! Hardware memory map tool — returns flash/RAM address ranges for connected boards. +//! +//! Phase B: When user asks "what are the upper and lower memory addresses?", this tool +//! returns the memory map. Uses probe-rs for Nucleo/STM32 when available; otherwise +//! returns static maps from datasheets. + +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +/// Known memory maps (from datasheets). Used when probe-rs is unavailable. +const MEMORY_MAPS: &[(&str, &str)] = &[ + ( + "nucleo-f401re", + "Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)\nSTM32F401RET6, ARM Cortex-M4", + ), + ( + "nucleo-f411re", + "Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)\nSTM32F411RET6, ARM Cortex-M4", + ), + ( + "arduino-uno", + "Flash: 0x0000 - 0x3FFF (16 KB, ATmega328P)\nSRAM: 0x0100 - 0x08FF (2 KB)\nEEPROM: 0x0000 - 0x03FF (1 KB)", + ), + ( + "arduino-mega", + "Flash: 0x0000 - 0x3FFFF (256 KB, ATmega2560)\nSRAM: 0x0200 - 0x21FF (8 KB)\nEEPROM: 0x0000 - 0x0FFF (4 KB)", + ), + ( + "esp32", + "Flash: 0x3F40_0000 - 0x3F7F_FFFF (4 MB typical)\nIRAM: 0x4000_0000 - 0x4005_FFFF\nDRAM: 0x3FFB_0000 - 0x3FFF_FFFF", + ), +]; + +/// Tool: report hardware memory map for connected boards. +pub struct HardwareMemoryMapTool { + boards: Vec, +} + +impl HardwareMemoryMapTool { + pub fn new(boards: Vec) -> Self { + Self { boards } + } + + fn static_map_for_board(&self, board: &str) -> Option<&'static str> { + MEMORY_MAPS + .iter() + .find(|(b, _)| *b == board) + .map(|(_, m)| *m) + } +} + +#[async_trait] +impl Tool for HardwareMemoryMapTool { + fn name(&self) -> &str { + "hardware_memory_map" + } + + fn description(&self) -> &str { + "Return the memory map (flash and RAM address ranges) for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', 'address space', or 'readable addresses'. Returns flash/RAM ranges from datasheets." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "board": { + "type": "string", + "description": "Optional board name (e.g. nucleo-f401re, arduino-uno). If omitted, returns map for first configured board." + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let board = args + .get("board") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| self.boards.first().cloned()); + + let board = board.as_deref().unwrap_or("unknown"); + + if self.boards.is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "No peripherals configured. Add boards to config.toml [peripherals.boards]." + .into(), + ), + }); + } + + let mut output = String::new(); + + #[cfg(feature = "probe")] + let probe_ok = { + if board == "nucleo-f401re" || board == "nucleo-f411re" { + let chip = if board == "nucleo-f411re" { + "STM32F411RETx" + } else { + "STM32F401RETx" + }; + match probe_rs_memory_map(chip) { + Ok(probe_msg) => { + output.push_str(&format!("**{}** (via probe-rs):\n{}\n", board, probe_msg)); + true + } + Err(e) => { + output.push_str(&format!("Probe-rs failed: {}. ", e)); + false + } + } + } else { + false + } + }; + + #[cfg(not(feature = "probe"))] + let probe_ok = false; + + if !probe_ok { + if let Some(map) = self.static_map_for_board(board) { + output.push_str(&format!("**{}** (from datasheet):\n{}", board, map)); + } else { + let known: Vec<&str> = MEMORY_MAPS.iter().map(|(b, _)| *b).collect(); + output.push_str(&format!( + "No memory map for board '{}'. Known boards: {}", + board, + known.join(", ") + )); + } + } + + Ok(ToolResult { + success: true, + output, + error: None, + }) + } +} + +#[cfg(feature = "probe")] +fn probe_rs_memory_map(chip: &str) -> anyhow::Result { + use probe_rs::config::MemoryRegion; + use probe_rs::{Session, SessionConfig}; + + let session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("probe-rs attach failed: {}", e))?; + + let target = session.target(); + let mut out = String::new(); + + for region in target.memory_map.iter() { + match region { + MemoryRegion::Ram(ram) => { + let start = ram.range.start; + let end = ram.range.end; + let size_kb = (end - start) / 1024; + out.push_str(&format!( + "RAM: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, end, size_kb + )); + } + MemoryRegion::Nvm(flash) => { + let start = flash.range.start; + let end = flash.range.end; + let size_kb = (end - start) / 1024; + out.push_str(&format!( + "Flash: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, end, size_kb + )); + } + _ => {} + } + } + + if out.is_empty() { + out = "Could not read memory regions from probe.".to_string(); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn static_map_nucleo() { + let tool = HardwareMemoryMapTool::new(vec!["nucleo-f401re".into()]); + assert!(tool.static_map_for_board("nucleo-f401re").is_some()); + assert!(tool + .static_map_for_board("nucleo-f401re") + .unwrap() + .contains("Flash")); + } + + #[test] + fn static_map_arduino() { + let tool = HardwareMemoryMapTool::new(vec!["arduino-uno".into()]); + assert!(tool.static_map_for_board("arduino-uno").is_some()); + } +} diff --git a/src/tools/hardware_memory_read.rs b/src/tools/hardware_memory_read.rs new file mode 100644 index 0000000..4cc42d5 --- /dev/null +++ b/src/tools/hardware_memory_read.rs @@ -0,0 +1,181 @@ +//! Hardware memory read tool — read actual memory/register values from Nucleo via probe-rs. +//! +//! Use when user asks to "read register values", "read memory at address", "dump lower memory", etc. +//! Requires probe feature and Nucleo connected via USB. + +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +/// RAM base for Nucleo-F401RE (STM32F401) +const NUCLEO_RAM_BASE: u64 = 0x2000_0000; + +/// Tool: read memory at address from connected Nucleo via probe-rs. +pub struct HardwareMemoryReadTool { + boards: Vec, +} + +impl HardwareMemoryReadTool { + pub fn new(boards: Vec) -> Self { + Self { boards } + } + + fn chip_for_board(board: &str) -> Option<&'static str> { + match board { + "nucleo-f401re" => Some("STM32F401RETx"), + "nucleo-f411re" => Some("STM32F411RETx"), + _ => None, + } + } +} + +#[async_trait] +impl Tool for HardwareMemoryReadTool { + fn name(&self) -> &str { + "hardware_memory_read" + } + + fn description(&self) -> &str { + "Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory at address', 'dump memory', 'lower memory 0-126', or 'give address and value'. Returns hex dump. Requires Nucleo connected via USB and probe feature. Params: address (hex, e.g. 0x20000000 for RAM start), length (bytes, default 128)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Memory address in hex (e.g. 0x20000000 for RAM start). Default: 0x20000000 (RAM base)." + }, + "length": { + "type": "integer", + "description": "Number of bytes to read (default 128, max 256)." + }, + "board": { + "type": "string", + "description": "Board name (nucleo-f401re). Optional if only one configured." + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + if self.boards.is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "No peripherals configured. Add nucleo-f401re to config.toml [peripherals.boards]." + .into(), + ), + }); + } + + let board = args + .get("board") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| self.boards.first().cloned()) + .unwrap_or_else(|| "nucleo-f401re".into()); + + let chip = Self::chip_for_board(&board); + if chip.is_none() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Memory read only supports nucleo-f401re, nucleo-f411re. Got: {}", + board + )), + }); + } + + let address_str = args + .get("address") + .and_then(|v| v.as_str()) + .unwrap_or("0x20000000"); + let address = parse_hex_address(address_str).unwrap_or(NUCLEO_RAM_BASE); + + let length = args.get("length").and_then(|v| v.as_u64()).unwrap_or(128) as usize; + let length = length.min(256).max(1); + + #[cfg(feature = "probe")] + { + match probe_read_memory(chip.unwrap(), address, length) { + Ok(output) => { + return Ok(ToolResult { + success: true, + output, + error: None, + }); + } + Err(e) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "probe-rs read failed: {}. Ensure Nucleo is connected via USB and built with --features probe.", + e + )), + }); + } + } + } + + #[cfg(not(feature = "probe"))] + { + Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "Memory read requires probe feature. Build with: cargo build --features hardware,probe" + .into(), + ), + }) + } + } +} + +fn parse_hex_address(s: &str) -> Option { + let s = s.trim().trim_start_matches("0x").trim_start_matches("0X"); + u64::from_str_radix(s, 16).ok() +} + +#[cfg(feature = "probe")] +fn probe_read_memory(chip: &str, address: u64, length: usize) -> anyhow::Result { + use probe_rs::MemoryInterface; + use probe_rs::Session; + use probe_rs::SessionConfig; + + let mut session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + let mut core = session.core(0)?; + let mut buf = vec![0u8; length]; + core.read_8(address, &mut buf) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + // Format as hex dump: address | bytes (16 per line) + let mut out = format!("Memory read from 0x{:08X} ({} bytes):\n\n", address, length); + const COLS: usize = 16; + for (i, chunk) in buf.chunks(COLS).enumerate() { + let addr = address + (i * COLS) as u64; + let hex: String = chunk + .iter() + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" "); + let ascii: String = chunk + .iter() + .map(|&b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect(); + out.push_str(&format!("0x{:08X} {:48} {}\n", addr, hex, ascii)); + } + Ok(out) +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index d239c5e..0a7a2bf 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -5,6 +5,9 @@ pub mod delegate; pub mod file_read; pub mod file_write; pub mod git_operations; +pub mod hardware_board_info; +pub mod hardware_memory_map; +pub mod hardware_memory_read; pub mod http_request; pub mod image_info; pub mod memory_forget; @@ -22,6 +25,9 @@ pub use delegate::DelegateTool; pub use file_read::FileReadTool; pub use file_write::FileWriteTool; pub use git_operations::GitOperationsTool; +pub use hardware_board_info::HardwareBoardInfoTool; +pub use hardware_memory_map::HardwareMemoryMapTool; +pub use hardware_memory_read::HardwareMemoryReadTool; pub use http_request::HttpRequestTool; pub use image_info::ImageInfoTool; pub use memory_forget::MemoryForgetTool; From 02decd309f90c92e5cee46ddc552ce8d2ef97edd Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 17 Feb 2026 00:41:48 +0800 Subject: [PATCH 23/32] fix(security): tighten SSRF IP classification for docs ranges --- src/tools/http_request.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index d5fa716..450bde5 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -404,7 +404,7 @@ fn is_private_or_local_host(host: &str) -> bool { /// Returns true if the IPv4 address is not globally routable. fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { - let [a, b, _, _] = v4.octets(); + let [a, b, c, _] = v4.octets(); v4.is_loopback() // 127.0.0.0/8 || v4.is_private() // 10/8, 172.16/12, 192.168/16 || v4.is_link_local() // 169.254.0.0/16 @@ -413,7 +413,7 @@ fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { || v4.is_multicast() // 224.0.0.0/4 || (a == 100 && (64..=127).contains(&b)) // Shared address space (RFC 6598) || a >= 240 // Reserved (240.0.0.0/4, except broadcast) - || (a == 192 && b == 0) // Documentation/IETF (192.0.0.0/24, 192.0.2.0/24) + || (a == 192 && b == 0 && (c == 0 || c == 2)) // IETF assignments + TEST-NET-1 || (a == 198 && b == 51) // Documentation (198.51.100.0/24) || (a == 203 && b == 0) // Documentation (203.0.113.0/24) || (a == 198 && (18..=19).contains(&b)) // Benchmarking (198.18.0.0/15) @@ -427,6 +427,7 @@ fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { || v6.is_multicast() // ff00::/8 || (segs[0] & 0xfe00) == 0xfc00 // Unique-local (fc00::/7) || (segs[0] & 0xffc0) == 0xfe80 // Link-local (fe80::/10) + || (segs[0] == 0x2001 && segs[1] == 0x0db8) // Documentation (2001:db8::/32) || v6.to_ipv4_mapped().is_some_and(|v4| is_non_global_v4(v4)) } @@ -628,16 +629,13 @@ mod tests { assert!(!is_private_or_local_host("93.184.216.34")); } + #[test] + fn blocks_ipv6_documentation_range() { + assert!(is_private_or_local_host("2001:db8::1")); + } + #[test] fn allows_public_ipv6() { - assert!( - !is_private_or_local_host("2001:db8::1") - .to_string() - .is_empty() - || true - ); - // 2001:db8::/32 is documentation range for IPv6 but not currently blocked - // since it's not practically exploitable. Public IPv6 addresses pass: assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e")); } From 6bb9bc47c02254ca8c057c8ce291aeac5615aabd Mon Sep 17 00:00:00 2001 From: reidliu41 Date: Tue, 17 Feb 2026 00:42:53 +0800 Subject: [PATCH 24/32] feat(provider): add Qwen/DashScope provider with multi-region support - Add Alibaba Qwen as an OpenAI-compatible provider via DashScope API - Support three regional endpoints: China (Beijing), Singapore, and US (Virginia) - All regions share a single `DASHSCOPE_API_KEY` environment variable | Config Value | Region | Base URL | |---|---|---| | `qwen` / `dashscope` | China (Beijing) | `dashscope.aliyuncs.com/compatible-mode/v1` | | `qwen-intl` / `dashscope-intl` | Singapore | `dashscope-intl.aliyuncs.com/compatible-mode/v1` | | `qwen-us` / `dashscope-us` | US (Virginia) | `dashscope-us.aliyuncs.com/compatible-mode/v1` | --- src/providers/mod.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/providers/mod.rs b/src/providers/mod.rs index b342675..d411fed 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -123,6 +123,9 @@ fn resolve_api_key(name: &str, api_key: Option<&str>) -> Option { "glm" | "zhipu" => vec!["GLM_API_KEY"], "minimax" => vec!["MINIMAX_API_KEY"], "qianfan" | "baidu" => vec!["QIANFAN_API_KEY"], + "qwen" | "dashscope" | "qwen-intl" | "dashscope-intl" | "qwen-us" | "dashscope-us" => { + vec!["DASHSCOPE_API_KEY"] + } "zai" | "z.ai" => vec!["ZAI_API_KEY"], "synthetic" => vec!["SYNTHETIC_API_KEY"], "opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"], @@ -235,6 +238,15 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result Ok(Box::new(OpenAiCompatibleProvider::new( "Qianfan", "https://aip.baidubce.com", key, AuthStyle::Bearer, ))), + "qwen" | "dashscope" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Qwen", "https://dashscope.aliyuncs.com/compatible-mode/v1", key, AuthStyle::Bearer, + ))), + "qwen-intl" | "dashscope-intl" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", key, AuthStyle::Bearer, + ))), + "qwen-us" | "dashscope-us" => Ok(Box::new(OpenAiCompatibleProvider::new( + "Qwen", "https://dashscope-us.aliyuncs.com/compatible-mode/v1", key, AuthStyle::Bearer, + ))), // ── Extended ecosystem (community favorites) ───────── "groq" => Ok(Box::new(OpenAiCompatibleProvider::new( @@ -521,6 +533,16 @@ mod tests { assert!(create_provider("baidu", Some("key")).is_ok()); } + #[test] + fn factory_qwen() { + assert!(create_provider("qwen", Some("key")).is_ok()); + assert!(create_provider("dashscope", Some("key")).is_ok()); + assert!(create_provider("qwen-intl", Some("key")).is_ok()); + assert!(create_provider("dashscope-intl", Some("key")).is_ok()); + assert!(create_provider("qwen-us", Some("key")).is_ok()); + assert!(create_provider("dashscope-us", Some("key")).is_ok()); + } + // ── Extended ecosystem ─────────────────────────────────── #[test] @@ -749,6 +771,9 @@ mod tests { "minimax", "bedrock", "qianfan", + "qwen", + "qwen-intl", + "qwen-us", "groq", "mistral", "xai", From 44ef48f3c68399cfe03db944c8d55e0bc48c391a Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 17 Feb 2026 00:46:01 +0800 Subject: [PATCH 25/32] docs(agents): add superseded-PR title/body template --- AGENTS.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index cfbacfc..2670878 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -314,6 +314,37 @@ When a PR supersedes another contributor's PR and carries forward substantive co - In the PR body, list superseded PR links and briefly state what was incorporated from each. - If no actual code/design was incorporated (only inspiration), do not use `Co-authored-by`; give credit in PR notes instead. +### 9.3 Superseded-PR PR Template (Recommended) + +When superseding multiple PRs, use a consistent title/body structure to reduce reviewer ambiguity. + +- Recommended title format: `feat(): unify and supersede #, # [and #]` +- If this is docs/chore/meta only, keep the same supersede suffix and use the appropriate conventional-commit type. +- In the PR body, include the following template (fill placeholders, remove non-applicable lines): + +```md +## Supersedes +- # by @ +- # by @ +- # by @ + +## Integrated Scope +- From #: +- From #: +- From #: + +## Attribution +- Co-authored-by trailers added for materially incorporated contributors: Yes/No +- If No, explain why (for example: no direct code/design carry-over) + +## Non-goals +- + +## Risk and Rollback +- Risk:

+- Rollback: +``` + Reference docs: - `CONTRIBUTING.md` From 882defef12cd8c5dabd40dca42f9b9b53fa25b5a Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:49:21 +0100 Subject: [PATCH 26/32] security(browser): harden SSRF blocking and block file:// URLs - Block file:// URLs which bypassed all SSRF and domain-allowlist controls, enabling arbitrary local file exfiltration via browser - Harden is_private_host() to match http_request.rs coverage: multicast, broadcast, reserved (240/4), shared address space (100.64/10), documentation IPs, benchmarking IPs - Add .localhost subdomain and .local mDNS TLD blocking - Extract is_non_global_v4() and is_non_global_v6() helpers Closes #361 Co-Authored-By: Claude Opus 4.6 --- src/tools/browser.rs | 103 +++++++++++++++++++++++++++---------------- 1 file changed, 66 insertions(+), 37 deletions(-) diff --git a/src/tools/browser.rs b/src/tools/browser.rs index c6a0ba9..d138f09 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -393,9 +393,10 @@ impl BrowserTool { anyhow::bail!("URL cannot be empty"); } - // Allow file:// URLs for local testing + // Block file:// URLs — browser file access bypasses all SSRF and + // domain-allowlist controls and can exfiltrate arbitrary local files. if url.starts_with("file://") { - return Ok(()); + anyhow::bail!("file:// URLs are not allowed in browser automation"); } if !url.starts_with("https://") && !url.starts_with("http://") { @@ -1966,49 +1967,63 @@ fn is_private_host(host: &str) -> bool { .and_then(|h| h.strip_suffix(']')) .unwrap_or(host); - if bare == "localhost" { + if bare == "localhost" || bare.ends_with(".localhost") { + return true; + } + + // .local TLD (mDNS) + if bare + .rsplit('.') + .next() + .is_some_and(|label| label == "local") + { return true; } // Parse as IP address to catch all representations (decimal, hex, octal, mapped) if let Ok(ip) = bare.parse::() { return match ip { - std::net::IpAddr::V4(v4) => { - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || v4.is_unspecified() - || v4.is_broadcast() - } - std::net::IpAddr::V6(v6) => { - let segs = v6.segments(); - v6.is_loopback() - || v6.is_unspecified() - // Unique-local (fc00::/7) — IPv6 equivalent of RFC 1918 - || (segs[0] & 0xfe00) == 0xfc00 - // Link-local (fe80::/10) - || (segs[0] & 0xffc0) == 0xfe80 - // IPv4-mapped addresses (::ffff:127.0.0.1) - || v6.to_ipv4_mapped().is_some_and(|v4| { - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || v4.is_unspecified() - || v4.is_broadcast() - }) - } + std::net::IpAddr::V4(v4) => is_non_global_v4(v4), + std::net::IpAddr::V6(v6) => is_non_global_v6(v6), }; } - // Fallback string patterns for hostnames that look like IPs but don't parse - // (e.g., partial addresses used in DNS names). - let string_patterns = [ - "127.", "10.", "192.168.", "0.0.0.0", "172.16.", "172.17.", "172.18.", "172.19.", - "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", "172.26.", "172.27.", - "172.28.", "172.29.", "172.30.", "172.31.", - ]; + false +} - string_patterns.iter().any(|p| bare.starts_with(p)) +/// Returns `true` for any IPv4 address that is not globally routable. +fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { + let [a, b, _, _] = v4.octets(); + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_unspecified() + || v4.is_broadcast() + || v4.is_multicast() + // Shared address space (100.64/10) + || (a == 100 && (64..=127).contains(&b)) + // Reserved (240.0.0.0/4) + || a >= 240 + // Documentation (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) + || (a == 192 && b == 0) + || (a == 198 && b == 51) + || (a == 203 && b == 0) + // Benchmarking (198.18.0.0/15) + || (a == 198 && (18..=19).contains(&b)) +} + +/// Returns `true` for any IPv6 address that is not globally routable. +fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { + let segs = v6.segments(); + v6.is_loopback() + || v6.is_unspecified() + || v6.is_multicast() + // Unique-local (fc00::/7) — IPv6 equivalent of RFC 1918 + || (segs[0] & 0xfe00) == 0xfc00 + // Link-local (fe80::/10) + || (segs[0] & 0xffc0) == 0xfe80 + // IPv4-mapped addresses + || v6.to_ipv4_mapped().is_some_and(|v4| is_non_global_v4(v4)) } fn host_matches_allowlist(host: &str, allowed: &[String]) -> bool { @@ -2070,6 +2085,8 @@ mod tests { #[test] fn is_private_host_detects_local() { assert!(is_private_host("localhost")); + assert!(is_private_host("app.localhost")); + assert!(is_private_host("printer.local")); assert!(is_private_host("127.0.0.1")); assert!(is_private_host("192.168.1.1")); assert!(is_private_host("10.0.0.1")); @@ -2077,6 +2094,18 @@ mod tests { assert!(!is_private_host("google.com")); } + #[test] + fn is_private_host_blocks_multicast_and_reserved() { + assert!(is_private_host("224.0.0.1")); // multicast + assert!(is_private_host("255.255.255.255")); // broadcast + assert!(is_private_host("100.64.0.1")); // shared address space + assert!(is_private_host("240.0.0.1")); // reserved + assert!(is_private_host("192.0.2.1")); // documentation + assert!(is_private_host("198.51.100.1")); // documentation + assert!(is_private_host("203.0.113.1")); // documentation + assert!(is_private_host("198.18.0.1")); // benchmarking + } + #[test] fn is_private_host_catches_ipv6() { assert!(is_private_host("::1")); @@ -2303,8 +2332,8 @@ mod tests { // Invalid - not https assert!(tool.validate_url("ftp://example.com").is_err()); - // File URLs allowed - assert!(tool.validate_url("file:///tmp/test.html").is_ok()); + // file:// URLs blocked (local file exfiltration risk) + assert!(tool.validate_url("file:///tmp/test.html").is_err()); } #[test] From dbff1b40b1228d1bd44e24158a81b408734d6f80 Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 17 Feb 2026 01:00:39 +0800 Subject: [PATCH 27/32] docs(agents): add superseded-PR commit message template --- AGENTS.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 2670878..8ed3a4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -345,6 +345,33 @@ When superseding multiple PRs, use a consistent title/body structure to reduce r - Rollback: ``` +### 9.4 Superseded-PR Commit Template (Recommended) + +When a commit unifies or supersedes prior PR work, use a deterministic commit message layout so attribution is machine-parsed and reviewer-friendly. + +- Keep one blank line between message sections, and exactly one blank line before trailer lines. +- Keep each trailer on its own line; do not wrap, indent, or encode as escaped `\n` text. +- Add one `Co-authored-by` trailer per materially incorporated contributor, using GitHub-recognized email. +- If no direct code/design is carried over, omit `Co-authored-by` and explain attribution in the PR body instead. + +```text +feat(): unify and supersede #, # [and #] + + + +Supersedes: +- # by @ +- # by @ +- # by @ + +Integrated scope: +- : from # +- : from # + +Co-authored-by: +Co-authored-by: +``` + Reference docs: - `CONTRIBUTING.md` From b341fdb36892fb7e1f3cb3bf4e51d622553b2e3b Mon Sep 17 00:00:00 2001 From: mai1015 Date: Mon, 16 Feb 2026 00:40:43 -0500 Subject: [PATCH 28/32] feat: add agent structure and improve tooling for provider --- src/agent/agent.rs | 701 ++++++++++++++++++++++++++++++++++++ src/agent/dispatcher.rs | 312 ++++++++++++++++ src/agent/loop_.rs | 22 +- src/agent/memory_loader.rs | 118 ++++++ src/agent/mod.rs | 21 ++ src/agent/prompt.rs | 304 ++++++++++++++++ src/channels/mod.rs | 36 +- src/config/schema.rs | 67 ++++ src/gateway/mod.rs | 272 +++----------- src/onboard/wizard.rs | 2 + src/providers/anthropic.rs | 324 +++++++++++++++-- src/providers/compatible.rs | 155 ++++---- src/providers/gemini.rs | 5 +- src/providers/mod.rs | 5 +- src/providers/ollama.rs | 8 +- src/providers/openai.rs | 239 +++++++++++- src/providers/openrouter.rs | 238 +++++++++++- src/providers/reliable.rs | 42 +-- src/providers/router.rs | 54 ++- src/providers/traits.rs | 76 +++- src/tools/delegate.rs | 9 +- 21 files changed, 2567 insertions(+), 443 deletions(-) create mode 100644 src/agent/agent.rs create mode 100644 src/agent/dispatcher.rs create mode 100644 src/agent/memory_loader.rs create mode 100644 src/agent/prompt.rs diff --git a/src/agent/agent.rs b/src/agent/agent.rs new file mode 100644 index 0000000..8f9331e --- /dev/null +++ b/src/agent/agent.rs @@ -0,0 +1,701 @@ +use crate::agent::dispatcher::{ + NativeToolDispatcher, ParsedToolCall, ToolDispatcher, ToolExecutionResult, XmlToolDispatcher, +}; +use crate::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader}; +use crate::agent::prompt::{PromptContext, SystemPromptBuilder}; +use crate::config::Config; +use crate::memory::{self, Memory, MemoryCategory}; +use crate::observability::{self, Observer, ObserverEvent}; +use crate::providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider}; +use crate::runtime; +use crate::security::SecurityPolicy; +use crate::tools::{self, Tool, ToolSpec}; +use crate::util::truncate_with_ellipsis; +use anyhow::Result; +use std::io::Write as IoWrite; +use std::sync::Arc; +use std::time::Instant; + +pub struct Agent { + provider: Box, + tools: Vec>, + tool_specs: Vec, + memory: Arc, + observer: Arc, + prompt_builder: SystemPromptBuilder, + tool_dispatcher: Box, + memory_loader: Box, + config: crate::config::AgentConfig, + model_name: String, + temperature: f64, + workspace_dir: std::path::PathBuf, + identity_config: crate::config::IdentityConfig, + skills: Vec, + auto_save: bool, + history: Vec, +} + +pub struct AgentBuilder { + provider: Option>, + tools: Option>>, + memory: Option>, + observer: Option>, + prompt_builder: Option, + tool_dispatcher: Option>, + memory_loader: Option>, + config: Option, + model_name: Option, + temperature: Option, + workspace_dir: Option, + identity_config: Option, + skills: Option>, + auto_save: Option, +} + +impl AgentBuilder { + pub fn new() -> Self { + Self { + provider: None, + tools: None, + memory: None, + observer: None, + prompt_builder: None, + tool_dispatcher: None, + memory_loader: None, + config: None, + model_name: None, + temperature: None, + workspace_dir: None, + identity_config: None, + skills: None, + auto_save: None, + } + } + + pub fn provider(mut self, provider: Box) -> Self { + self.provider = Some(provider); + self + } + + pub fn tools(mut self, tools: Vec>) -> Self { + self.tools = Some(tools); + self + } + + pub fn memory(mut self, memory: Arc) -> Self { + self.memory = Some(memory); + self + } + + pub fn observer(mut self, observer: Arc) -> Self { + self.observer = Some(observer); + self + } + + pub fn prompt_builder(mut self, prompt_builder: SystemPromptBuilder) -> Self { + self.prompt_builder = Some(prompt_builder); + self + } + + pub fn tool_dispatcher(mut self, tool_dispatcher: Box) -> Self { + self.tool_dispatcher = Some(tool_dispatcher); + self + } + + pub fn memory_loader(mut self, memory_loader: Box) -> Self { + self.memory_loader = Some(memory_loader); + self + } + + pub fn config(mut self, config: crate::config::AgentConfig) -> Self { + self.config = Some(config); + self + } + + pub fn model_name(mut self, model_name: String) -> Self { + self.model_name = Some(model_name); + self + } + + pub fn temperature(mut self, temperature: f64) -> Self { + self.temperature = Some(temperature); + self + } + + pub fn workspace_dir(mut self, workspace_dir: std::path::PathBuf) -> Self { + self.workspace_dir = Some(workspace_dir); + self + } + + pub fn identity_config(mut self, identity_config: crate::config::IdentityConfig) -> Self { + self.identity_config = Some(identity_config); + self + } + + pub fn skills(mut self, skills: Vec) -> Self { + self.skills = Some(skills); + self + } + + pub fn auto_save(mut self, auto_save: bool) -> Self { + self.auto_save = Some(auto_save); + self + } + + pub fn build(self) -> Result { + let tools = self + .tools + .ok_or_else(|| anyhow::anyhow!("tools are required"))?; + let tool_specs = tools.iter().map(|tool| tool.spec()).collect(); + + Ok(Agent { + provider: self + .provider + .ok_or_else(|| anyhow::anyhow!("provider is required"))?, + tools, + tool_specs, + memory: self + .memory + .ok_or_else(|| anyhow::anyhow!("memory is required"))?, + observer: self + .observer + .ok_or_else(|| anyhow::anyhow!("observer is required"))?, + prompt_builder: self + .prompt_builder + .unwrap_or_else(SystemPromptBuilder::with_defaults), + tool_dispatcher: self + .tool_dispatcher + .ok_or_else(|| anyhow::anyhow!("tool_dispatcher is required"))?, + memory_loader: self + .memory_loader + .unwrap_or_else(|| Box::new(DefaultMemoryLoader::default())), + config: self.config.unwrap_or_default(), + model_name: self + .model_name + .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()), + temperature: self.temperature.unwrap_or(0.7), + workspace_dir: self + .workspace_dir + .unwrap_or_else(|| std::path::PathBuf::from(".")), + identity_config: self.identity_config.unwrap_or_default(), + skills: self.skills.unwrap_or_default(), + auto_save: self.auto_save.unwrap_or(false), + history: Vec::new(), + }) + } +} + +impl Agent { + pub fn builder() -> AgentBuilder { + AgentBuilder::new() + } + + pub fn history(&self) -> &[ConversationMessage] { + &self.history + } + + pub fn clear_history(&mut self) { + self.history.clear(); + } + + pub fn from_config(config: &Config) -> Result { + let observer: Arc = + Arc::from(observability::create_observer(&config.observability)); + let runtime: Arc = + Arc::from(runtime::create_runtime(&config.runtime)?); + let security = Arc::new(SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + )); + + let memory: Arc = Arc::from(memory::create_memory( + &config.memory, + &config.workspace_dir, + config.api_key.as_deref(), + )?); + + let composio_key = if config.composio.enabled { + config.composio.api_key.as_deref() + } else { + None + }; + + let tools = tools::all_tools_with_runtime( + &security, + runtime, + memory.clone(), + composio_key, + &config.browser, + &config.http_request, + &config.workspace_dir, + &config.agents, + config.api_key.as_deref(), + ); + + let provider_name = config.default_provider.as_deref().unwrap_or("openrouter"); + + let model_name = config + .default_model + .as_deref() + .unwrap_or("anthropic/claude-sonnet-4-20250514") + .to_string(); + + let provider: Box = providers::create_routed_provider( + provider_name, + config.api_key.as_deref(), + &config.reliability, + &config.model_routes, + &model_name, + )?; + + let dispatcher_choice = config.agent.tool_dispatcher.as_str(); + let tool_dispatcher: Box = match dispatcher_choice { + "native" => Box::new(NativeToolDispatcher), + "xml" => Box::new(XmlToolDispatcher), + _ if provider.supports_native_tools() => Box::new(NativeToolDispatcher), + _ => Box::new(XmlToolDispatcher), + }; + + Agent::builder() + .provider(provider) + .tools(tools) + .memory(memory) + .observer(observer) + .tool_dispatcher(tool_dispatcher) + .memory_loader(Box::new(DefaultMemoryLoader::default())) + .prompt_builder(SystemPromptBuilder::with_defaults()) + .config(config.agent.clone()) + .model_name(model_name) + .temperature(config.default_temperature) + .workspace_dir(config.workspace_dir.clone()) + .identity_config(config.identity.clone()) + .skills(crate::skills::load_skills(&config.workspace_dir)) + .auto_save(config.memory.auto_save) + .build() + } + + fn trim_history(&mut self) { + let max = self.config.max_history_messages; + if self.history.len() <= max { + return; + } + + let mut system_messages = Vec::new(); + let mut other_messages = Vec::new(); + + for msg in self.history.drain(..) { + match &msg { + ConversationMessage::Chat(chat) if chat.role == "system" => { + system_messages.push(msg) + } + _ => other_messages.push(msg), + } + } + + if other_messages.len() > max { + let drop_count = other_messages.len() - max; + other_messages.drain(0..drop_count); + } + + self.history = system_messages; + self.history.extend(other_messages); + } + + fn build_system_prompt(&self) -> Result { + let instructions = self.tool_dispatcher.prompt_instructions(&self.tools); + let ctx = PromptContext { + workspace_dir: &self.workspace_dir, + model_name: &self.model_name, + tools: &self.tools, + skills: &self.skills, + identity_config: Some(&self.identity_config), + dispatcher_instructions: &instructions, + }; + self.prompt_builder.build(&ctx) + } + + async fn execute_tool_call(&self, call: &ParsedToolCall) -> ToolExecutionResult { + let start = Instant::now(); + + let result = if let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) { + match tool.execute(call.arguments.clone()).await { + Ok(r) => { + self.observer.record_event(&ObserverEvent::ToolCall { + tool: call.name.clone(), + duration: start.elapsed(), + success: r.success, + }); + if r.success { + r.output + } else { + format!("Error: {}", r.error.unwrap_or(r.output)) + } + } + Err(e) => { + self.observer.record_event(&ObserverEvent::ToolCall { + tool: call.name.clone(), + duration: start.elapsed(), + success: false, + }); + format!("Error executing {}: {e}", call.name) + } + } + } else { + format!("Unknown tool: {}", call.name) + }; + + ToolExecutionResult { + name: call.name.clone(), + output: result, + success: true, + tool_call_id: call.tool_call_id.clone(), + } + } + + async fn execute_tools(&self, calls: &[ParsedToolCall]) -> Vec { + if !self.config.parallel_tools { + let mut results = Vec::with_capacity(calls.len()); + for call in calls { + results.push(self.execute_tool_call(call).await); + } + return results; + } + + let mut results = Vec::with_capacity(calls.len()); + for call in calls { + results.push(self.execute_tool_call(call).await); + } + results + } + + pub async fn turn(&mut self, user_message: &str) -> Result { + if self.history.is_empty() { + let system_prompt = self.build_system_prompt()?; + self.history + .push(ConversationMessage::Chat(ChatMessage::system( + system_prompt, + ))); + } + + if self.auto_save { + let _ = self + .memory + .store("user_msg", user_message, MemoryCategory::Conversation) + .await; + } + + let context = self + .memory_loader + .load_context(self.memory.as_ref(), user_message) + .await + .unwrap_or_default(); + + let enriched = if context.is_empty() { + user_message.to_string() + } else { + format!("{context}{user_message}") + }; + + self.history + .push(ConversationMessage::Chat(ChatMessage::user(enriched))); + + for _ in 0..self.config.max_tool_iterations { + let messages = self.tool_dispatcher.to_provider_messages(&self.history); + let response = match self + .provider + .chat( + ChatRequest { + messages: &messages, + tools: if self.tool_dispatcher.should_send_tool_specs() { + Some(&self.tool_specs) + } else { + None + }, + }, + &self.model_name, + self.temperature, + ) + .await + { + Ok(resp) => resp, + Err(err) => return Err(err), + }; + + let (text, calls) = self.tool_dispatcher.parse_response(&response); + if calls.is_empty() { + let final_text = if text.is_empty() { + response.text.unwrap_or_default() + } else { + text + }; + + self.history + .push(ConversationMessage::Chat(ChatMessage::assistant( + final_text.clone(), + ))); + self.trim_history(); + + if self.auto_save { + let summary = truncate_with_ellipsis(&final_text, 100); + let _ = self + .memory + .store("assistant_resp", &summary, MemoryCategory::Daily) + .await; + } + + return Ok(final_text); + } + + if !text.is_empty() { + self.history + .push(ConversationMessage::Chat(ChatMessage::assistant( + text.clone(), + ))); + print!("{text}"); + let _ = std::io::stdout().flush(); + } + + self.history.push(ConversationMessage::AssistantToolCalls { + text: response.text.clone(), + tool_calls: response.tool_calls.clone(), + }); + + let results = self.execute_tools(&calls).await; + let formatted = self.tool_dispatcher.format_results(&results); + self.history.push(formatted); + self.trim_history(); + } + + anyhow::bail!( + "Agent exceeded maximum tool iterations ({})", + self.config.max_tool_iterations + ) + } + + pub async fn run_single(&mut self, message: &str) -> Result { + self.turn(message).await + } + + pub async fn run_interactive(&mut self) -> Result<()> { + println!("🦀 ZeroClaw Interactive Mode"); + println!("Type /quit to exit.\n"); + + let (tx, mut rx) = tokio::sync::mpsc::channel(32); + let cli = crate::channels::CliChannel::new(); + + let listen_handle = tokio::spawn(async move { + let _ = crate::channels::Channel::listen(&cli, tx).await; + }); + + while let Some(msg) = rx.recv().await { + let response = match self.turn(&msg.content).await { + Ok(resp) => resp, + Err(e) => { + eprintln!("\nError: {e}\n"); + continue; + } + }; + println!("\n{response}\n"); + } + + listen_handle.abort(); + Ok(()) + } +} + +pub async fn run( + config: Config, + message: Option, + provider_override: Option, + model_override: Option, + temperature: f64, +) -> Result<()> { + let start = Instant::now(); + + let mut effective_config = config; + if let Some(p) = provider_override { + effective_config.default_provider = Some(p); + } + if let Some(m) = model_override { + effective_config.default_model = Some(m); + } + effective_config.default_temperature = temperature; + + let mut agent = Agent::from_config(&effective_config)?; + + let provider_name = effective_config + .default_provider + .as_deref() + .unwrap_or("openrouter") + .to_string(); + let model_name = effective_config + .default_model + .as_deref() + .unwrap_or("anthropic/claude-sonnet-4-20250514") + .to_string(); + + agent.observer.record_event(&ObserverEvent::AgentStart { + provider: provider_name, + model: model_name, + }); + + if let Some(msg) = message { + let response = agent.run_single(&msg).await?; + println!("{response}"); + } else { + agent.run_interactive().await?; + } + + agent.observer.record_event(&ObserverEvent::AgentEnd { + duration: start.elapsed(), + tokens_used: None, + }); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use std::sync::Mutex; + + struct MockProvider { + responses: Mutex>, + } + + #[async_trait] + impl Provider for MockProvider { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> Result { + Ok("ok".into()) + } + + async fn chat( + &self, + _request: ChatRequest<'_>, + _model: &str, + _temperature: f64, + ) -> Result { + let mut guard = self.responses.lock().unwrap(); + if guard.is_empty() { + return Ok(crate::providers::ChatResponse { + text: Some("done".into()), + tool_calls: vec![], + }); + } + Ok(guard.remove(0)) + } + } + + struct MockTool; + + #[async_trait] + impl Tool for MockTool { + fn name(&self) -> &str { + "echo" + } + + fn description(&self) -> &str { + "echo" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + + async fn execute(&self, _args: serde_json::Value) -> Result { + Ok(crate::tools::ToolResult { + success: true, + output: "tool-out".into(), + error: None, + }) + } + } + + #[tokio::test] + async fn turn_without_tools_returns_text() { + let provider = Box::new(MockProvider { + responses: Mutex::new(vec![crate::providers::ChatResponse { + text: Some("hello".into()), + tool_calls: vec![], + }]), + }); + + let memory_cfg = crate::config::MemoryConfig { + backend: "none".into(), + ..crate::config::MemoryConfig::default() + }; + let mem: Arc = Arc::from( + crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None).unwrap(), + ); + + let observer: Arc = Arc::from(crate::observability::NoopObserver {}); + let mut agent = Agent::builder() + .provider(provider) + .tools(vec![Box::new(MockTool)]) + .memory(mem) + .observer(observer) + .tool_dispatcher(Box::new(XmlToolDispatcher)) + .workspace_dir(std::path::PathBuf::from("/tmp")) + .build() + .unwrap(); + + let response = agent.turn("hi").await.unwrap(); + assert_eq!(response, "hello"); + } + + #[tokio::test] + async fn turn_with_native_dispatcher_handles_tool_results_variant() { + let provider = Box::new(MockProvider { + responses: Mutex::new(vec![ + crate::providers::ChatResponse { + text: Some("".into()), + tool_calls: vec![crate::providers::ToolCall { + id: "tc1".into(), + name: "echo".into(), + arguments: "{}".into(), + }], + }, + crate::providers::ChatResponse { + text: Some("done".into()), + tool_calls: vec![], + }, + ]), + }); + + let memory_cfg = crate::config::MemoryConfig { + backend: "none".into(), + ..crate::config::MemoryConfig::default() + }; + let mem: Arc = Arc::from( + crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None).unwrap(), + ); + + let observer: Arc = Arc::from(crate::observability::NoopObserver {}); + let mut agent = Agent::builder() + .provider(provider) + .tools(vec![Box::new(MockTool)]) + .memory(mem) + .observer(observer) + .tool_dispatcher(Box::new(NativeToolDispatcher)) + .workspace_dir(std::path::PathBuf::from("/tmp")) + .build() + .unwrap(); + + let response = agent.turn("hi").await.unwrap(); + assert_eq!(response, "done"); + assert!(matches!( + agent + .history() + .iter() + .find(|msg| matches!(msg, ConversationMessage::ToolResults(_))), + Some(_) + )); + } +} diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs new file mode 100644 index 0000000..673ec8c --- /dev/null +++ b/src/agent/dispatcher.rs @@ -0,0 +1,312 @@ +use crate::providers::{ChatMessage, ChatResponse, ConversationMessage, ToolResultMessage}; +use crate::tools::{Tool, ToolSpec}; +use serde_json::Value; +use std::fmt::Write; + +#[derive(Debug, Clone)] +pub struct ParsedToolCall { + pub name: String, + pub arguments: Value, + pub tool_call_id: Option, +} + +#[derive(Debug, Clone)] +pub struct ToolExecutionResult { + pub name: String, + pub output: String, + pub success: bool, + pub tool_call_id: Option, +} + +pub trait ToolDispatcher: Send + Sync { + fn parse_response(&self, response: &ChatResponse) -> (String, Vec); + fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage; + fn prompt_instructions(&self, tools: &[Box]) -> String; + fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec; + fn should_send_tool_specs(&self) -> bool; +} + +#[derive(Default)] +pub struct XmlToolDispatcher; + +impl XmlToolDispatcher { + fn parse_xml_tool_calls(response: &str) -> (String, Vec) { + let mut text_parts = Vec::new(); + let mut calls = Vec::new(); + let mut remaining = response; + + while let Some(start) = remaining.find("") { + let before = &remaining[..start]; + if !before.trim().is_empty() { + text_parts.push(before.trim().to_string()); + } + + if let Some(end) = remaining[start..].find("") { + let inner = &remaining[start + 11..start + end]; + match serde_json::from_str::(inner.trim()) { + Ok(parsed) => { + let name = parsed + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + if name.is_empty() { + remaining = &remaining[start + end + 12..]; + continue; + } + let arguments = parsed + .get("arguments") + .cloned() + .unwrap_or_else(|| Value::Object(serde_json::Map::new())); + calls.push(ParsedToolCall { + name, + arguments, + tool_call_id: None, + }); + } + Err(e) => { + tracing::warn!("Malformed JSON: {e}"); + } + } + remaining = &remaining[start + end + 12..]; + } else { + break; + } + } + + if !remaining.trim().is_empty() { + text_parts.push(remaining.trim().to_string()); + } + + (text_parts.join("\n"), calls) + } + + pub fn tool_specs(tools: &[Box]) -> Vec { + tools.iter().map(|tool| tool.spec()).collect() + } +} + +impl ToolDispatcher for XmlToolDispatcher { + fn parse_response(&self, response: &ChatResponse) -> (String, Vec) { + let text = response.text_or_empty(); + Self::parse_xml_tool_calls(text) + } + + fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage { + let mut content = String::new(); + for result in results { + let status = if result.success { "ok" } else { "error" }; + let _ = writeln!( + content, + "\n{}\n", + result.name, status, result.output + ); + } + ConversationMessage::Chat(ChatMessage::user(format!("[Tool results]\n{content}"))) + } + + fn prompt_instructions(&self, tools: &[Box]) -> String { + let mut instructions = String::new(); + instructions.push_str("## Tool Use Protocol\n\n"); + instructions + .push_str("To use a tool, wrap a JSON object in tags:\n\n"); + instructions.push_str( + "```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n\n```\n\n", + ); + instructions.push_str("### Available Tools\n\n"); + + for tool in tools { + let _ = writeln!( + instructions, + "- **{}**: {}\n Parameters: `{}`", + tool.name(), + tool.description(), + tool.parameters_schema() + ); + } + + instructions + } + + fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec { + history + .iter() + .flat_map(|msg| match msg { + ConversationMessage::Chat(chat) => vec![chat.clone()], + ConversationMessage::AssistantToolCalls { text, .. } => { + vec![ChatMessage::assistant(text.clone().unwrap_or_default())] + } + ConversationMessage::ToolResults(results) => { + let mut content = String::new(); + for result in results { + let _ = writeln!( + content, + "\n{}\n", + result.tool_call_id, result.content + ); + } + vec![ChatMessage::user(format!("[Tool results]\n{content}"))] + } + }) + .collect() + } + + fn should_send_tool_specs(&self) -> bool { + false + } +} + +pub struct NativeToolDispatcher; + +impl ToolDispatcher for NativeToolDispatcher { + fn parse_response(&self, response: &ChatResponse) -> (String, Vec) { + let text = response.text.clone().unwrap_or_default(); + let calls = response + .tool_calls + .iter() + .map(|tc| ParsedToolCall { + name: tc.name.clone(), + arguments: serde_json::from_str(&tc.arguments) + .unwrap_or_else(|_| Value::Object(serde_json::Map::new())), + tool_call_id: Some(tc.id.clone()), + }) + .collect(); + (text, calls) + } + + fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage { + let messages = results + .iter() + .map(|result| ToolResultMessage { + tool_call_id: result + .tool_call_id + .clone() + .unwrap_or_else(|| "unknown".to_string()), + content: result.output.clone(), + }) + .collect(); + ConversationMessage::ToolResults(messages) + } + + fn prompt_instructions(&self, _tools: &[Box]) -> String { + String::new() + } + + fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec { + history + .iter() + .flat_map(|msg| match msg { + ConversationMessage::Chat(chat) => vec![chat.clone()], + ConversationMessage::AssistantToolCalls { text, tool_calls } => { + let payload = serde_json::json!({ + "content": text, + "tool_calls": tool_calls, + }); + vec![ChatMessage::assistant(payload.to_string())] + } + ConversationMessage::ToolResults(results) => results + .iter() + .map(|result| { + ChatMessage::tool( + serde_json::json!({ + "tool_call_id": result.tool_call_id, + "content": result.content, + }) + .to_string(), + ) + }) + .collect(), + }) + .collect() + } + + fn should_send_tool_specs(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xml_dispatcher_parses_tool_calls() { + let response = ChatResponse { + text: Some( + "Checking\n{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}" + .into(), + ), + tool_calls: vec![], + }; + let dispatcher = XmlToolDispatcher; + let (_, calls) = dispatcher.parse_response(&response); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "shell"); + } + + #[test] + fn native_dispatcher_roundtrip() { + let response = ChatResponse { + text: Some("ok".into()), + tool_calls: vec![crate::providers::ToolCall { + id: "tc1".into(), + name: "file_read".into(), + arguments: "{\"path\":\"a.txt\"}".into(), + }], + }; + let dispatcher = NativeToolDispatcher; + let (_, calls) = dispatcher.parse_response(&response); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].tool_call_id.as_deref(), Some("tc1")); + + let msg = dispatcher.format_results(&[ToolExecutionResult { + name: "file_read".into(), + output: "hello".into(), + success: true, + tool_call_id: Some("tc1".into()), + }]); + match msg { + ConversationMessage::ToolResults(results) => { + assert_eq!(results.len(), 1); + assert_eq!(results[0].tool_call_id, "tc1"); + } + _ => panic!("expected tool results"), + } + } + + #[test] + fn xml_format_results_contains_tool_result_tags() { + let dispatcher = XmlToolDispatcher; + let msg = dispatcher.format_results(&[ToolExecutionResult { + name: "shell".into(), + output: "ok".into(), + success: true, + tool_call_id: None, + }]); + let rendered = match msg { + ConversationMessage::Chat(chat) => chat.content, + _ => String::new(), + }; + assert!(rendered.contains(" { + assert_eq!(results.len(), 1); + assert_eq!(results[0].tool_call_id, "tc-1"); + } + _ => panic!("expected ToolResults variant"), + } + } +} diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index e7421ad..1888866 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -8,11 +8,10 @@ use crate::tools::{self, Tool}; use crate::util::truncate_with_ellipsis; use anyhow::Result; use std::fmt::Write; -use std::io::Write as IoWrite; +use std::io::Write as _; use std::sync::Arc; use std::time::Instant; use uuid::Uuid; - /// Maximum agentic tool-use iterations per user message to prevent runaway loops. const MAX_TOOL_ITERATIONS: usize = 10; @@ -113,7 +112,6 @@ async fn auto_compact_history( let summary_raw = provider .chat_with_system(Some(summarizer_system), &summarizer_user, model, 0.2) .await - .map(|resp| resp.text_or_empty().to_string()) .unwrap_or_else(|_| { // Fallback to deterministic local truncation when summarization fails. truncate_with_ellipsis(&transcript, COMPACTION_MAX_SUMMARY_CHARS) @@ -482,21 +480,11 @@ pub(crate) async fn run_tool_call_loop( } }; - let response_text = response.text.unwrap_or_default(); + let response_text = response; let mut assistant_history_content = response_text.clone(); - let mut parsed_text = response_text.clone(); - let mut tool_calls = parse_structured_tool_calls(&response.tool_calls); - - if !response.tool_calls.is_empty() { - assistant_history_content = - build_assistant_history_with_tool_calls(&response_text, &response.tool_calls); - } - - if tool_calls.is_empty() { - let (fallback_text, fallback_calls) = parse_tool_calls(&response_text); - parsed_text = fallback_text; - tool_calls = fallback_calls; - } + let (parsed_text, tool_calls) = parse_tool_calls(&response_text); + let mut parsed_text = parsed_text; + let mut tool_calls = tool_calls; if tool_calls.is_empty() { // No tool calls — this is the final response diff --git a/src/agent/memory_loader.rs b/src/agent/memory_loader.rs new file mode 100644 index 0000000..f5733ec --- /dev/null +++ b/src/agent/memory_loader.rs @@ -0,0 +1,118 @@ +use crate::memory::Memory; +use async_trait::async_trait; +use std::fmt::Write; + +#[async_trait] +pub trait MemoryLoader: Send + Sync { + async fn load_context(&self, memory: &dyn Memory, user_message: &str) + -> anyhow::Result; +} + +pub struct DefaultMemoryLoader { + limit: usize, +} + +impl Default for DefaultMemoryLoader { + fn default() -> Self { + Self { limit: 5 } + } +} + +impl DefaultMemoryLoader { + pub fn new(limit: usize) -> Self { + Self { + limit: limit.max(1), + } + } +} + +#[async_trait] +impl MemoryLoader for DefaultMemoryLoader { + async fn load_context( + &self, + memory: &dyn Memory, + user_message: &str, + ) -> anyhow::Result { + let entries = memory.recall(user_message, self.limit).await?; + if entries.is_empty() { + return Ok(String::new()); + } + + let mut context = String::from("[Memory context]\n"); + for entry in entries { + let _ = writeln!(context, "- {}: {}", entry.key, entry.content); + } + context.push('\n'); + Ok(context) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::{Memory, MemoryCategory, MemoryEntry}; + + struct MockMemory; + + #[async_trait] + impl Memory for MockMemory { + async fn store( + &self, + _key: &str, + _content: &str, + _category: MemoryCategory, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn recall(&self, _query: &str, limit: usize) -> anyhow::Result> { + if limit == 0 { + return Ok(vec![]); + } + Ok(vec![MemoryEntry { + id: "1".into(), + key: "k".into(), + content: "v".into(), + category: MemoryCategory::Conversation, + timestamp: "now".into(), + session_id: None, + score: None, + }]) + } + + async fn get(&self, _key: &str) -> anyhow::Result> { + Ok(None) + } + + async fn list( + &self, + _category: Option<&MemoryCategory>, + ) -> anyhow::Result> { + Ok(vec![]) + } + + async fn forget(&self, _key: &str) -> anyhow::Result { + Ok(true) + } + + async fn count(&self) -> anyhow::Result { + Ok(0) + } + + async fn health_check(&self) -> bool { + true + } + + fn name(&self) -> &str { + "mock" + } + } + + #[tokio::test] + async fn default_loader_formats_context() { + let loader = DefaultMemoryLoader::default(); + let context = loader.load_context(&MockMemory, "hello").await.unwrap(); + assert!(context.contains("[Memory context]")); + assert!(context.contains("- k: v")); + } +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index e3d7d16..63bf3f8 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,3 +1,24 @@ +pub mod agent; +pub mod dispatcher; pub mod loop_; +pub mod memory_loader; +pub mod prompt; +#[allow(unused_imports)] +pub use agent::{Agent, AgentBuilder}; pub use loop_::{process_message, run}; + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_reexport_exists(_value: F) {} + + #[test] + fn run_function_is_reexported() { + assert_reexport_exists(run); + assert_reexport_exists(process_message); + assert_reexport_exists(loop_::run); + assert_reexport_exists(loop_::process_message); + } +} diff --git a/src/agent/prompt.rs b/src/agent/prompt.rs new file mode 100644 index 0000000..bdc426f --- /dev/null +++ b/src/agent/prompt.rs @@ -0,0 +1,304 @@ +use crate::config::IdentityConfig; +use crate::identity; +use crate::skills::Skill; +use crate::tools::Tool; +use anyhow::Result; +use chrono::Local; +use std::fmt::Write; +use std::path::Path; + +const BOOTSTRAP_MAX_CHARS: usize = 20_000; + +pub struct PromptContext<'a> { + pub workspace_dir: &'a Path, + pub model_name: &'a str, + pub tools: &'a [Box], + pub skills: &'a [Skill], + pub identity_config: Option<&'a IdentityConfig>, + pub dispatcher_instructions: &'a str, +} + +pub trait PromptSection: Send + Sync { + fn name(&self) -> &str; + fn build(&self, ctx: &PromptContext<'_>) -> Result; +} + +#[derive(Default)] +pub struct SystemPromptBuilder { + sections: Vec>, +} + +impl SystemPromptBuilder { + pub fn with_defaults() -> Self { + Self { + sections: vec![ + Box::new(IdentitySection), + Box::new(ToolsSection), + Box::new(SafetySection), + Box::new(SkillsSection), + Box::new(WorkspaceSection), + Box::new(DateTimeSection), + Box::new(RuntimeSection), + ], + } + } + + pub fn add_section(mut self, section: Box) -> Self { + self.sections.push(section); + self + } + + pub fn build(&self, ctx: &PromptContext<'_>) -> Result { + let mut output = String::new(); + for section in &self.sections { + let part = section.build(ctx)?; + if part.trim().is_empty() { + continue; + } + output.push_str(part.trim_end()); + output.push_str("\n\n"); + } + Ok(output) + } +} + +pub struct IdentitySection; +pub struct ToolsSection; +pub struct SafetySection; +pub struct SkillsSection; +pub struct WorkspaceSection; +pub struct RuntimeSection; +pub struct DateTimeSection; + +impl PromptSection for IdentitySection { + fn name(&self) -> &str { + "identity" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let mut prompt = String::from("## Project Context\n\n"); + if let Some(config) = ctx.identity_config { + if identity::is_aieos_configured(config) { + if let Ok(Some(aieos)) = identity::load_aieos_identity(config, ctx.workspace_dir) { + let rendered = identity::aieos_to_system_prompt(&aieos); + if !rendered.is_empty() { + prompt.push_str(&rendered); + return Ok(prompt); + } + } + } + } + + prompt.push_str( + "The following workspace files define your identity, behavior, and context.\n\n", + ); + for file in [ + "AGENTS.md", + "SOUL.md", + "TOOLS.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + "BOOTSTRAP.md", + "MEMORY.md", + ] { + inject_workspace_file(&mut prompt, ctx.workspace_dir, file); + } + + Ok(prompt) + } +} + +impl PromptSection for ToolsSection { + fn name(&self) -> &str { + "tools" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let mut out = String::from("## Tools\n\n"); + for tool in ctx.tools { + let _ = writeln!( + out, + "- **{}**: {}\n Parameters: `{}`", + tool.name(), + tool.description(), + tool.parameters_schema() + ); + } + if !ctx.dispatcher_instructions.is_empty() { + out.push('\n'); + out.push_str(ctx.dispatcher_instructions); + } + Ok(out) + } +} + +impl PromptSection for SafetySection { + fn name(&self) -> &str { + "safety" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> Result { + Ok("## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.".into()) + } +} + +impl PromptSection for SkillsSection { + fn name(&self) -> &str { + "skills" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + if ctx.skills.is_empty() { + return Ok(String::new()); + } + + let mut prompt = String::from("## Available Skills\n\n\n"); + for skill in ctx.skills { + let location = skill.location.clone().unwrap_or_else(|| { + ctx.workspace_dir + .join("skills") + .join(&skill.name) + .join("SKILL.md") + }); + let _ = writeln!( + prompt, + " \n {}\n {}\n {}\n ", + skill.name, + skill.description, + location.display() + ); + } + prompt.push_str(""); + Ok(prompt) + } +} + +impl PromptSection for WorkspaceSection { + fn name(&self) -> &str { + "workspace" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + Ok(format!( + "## Workspace\n\nWorking directory: `{}`", + ctx.workspace_dir.display() + )) + } +} + +impl PromptSection for RuntimeSection { + fn name(&self) -> &str { + "runtime" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let host = + hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string()); + Ok(format!( + "## Runtime\n\nHost: {host} | OS: {} | Model: {}", + std::env::consts::OS, + ctx.model_name + )) + } +} + +impl PromptSection for DateTimeSection { + fn name(&self) -> &str { + "datetime" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> Result { + let now = Local::now(); + Ok(format!( + "## Current Date & Time\n\nTimezone: {}", + now.format("%Z") + )) + } +} + +fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str) { + let path = workspace_dir.join(filename); + match std::fs::read_to_string(&path) { + Ok(content) => { + let trimmed = content.trim(); + if trimmed.is_empty() { + return; + } + let _ = writeln!(prompt, "### {filename}\n"); + let truncated = if trimmed.chars().count() > BOOTSTRAP_MAX_CHARS { + trimmed + .char_indices() + .nth(BOOTSTRAP_MAX_CHARS) + .map(|(idx, _)| &trimmed[..idx]) + .unwrap_or(trimmed) + } else { + trimmed + }; + prompt.push_str(truncated); + if truncated.len() < trimmed.len() { + let _ = writeln!( + prompt, + "\n\n[... truncated at {BOOTSTRAP_MAX_CHARS} chars — use `read` for full file]\n" + ); + } else { + prompt.push_str("\n\n"); + } + } + Err(_) => { + let _ = writeln!(prompt, "### {filename}\n\n[File not found: {filename}]\n"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::traits::Tool; + use async_trait::async_trait; + + struct TestTool; + + #[async_trait] + impl Tool for TestTool { + fn name(&self) -> &str { + "test_tool" + } + + fn description(&self) -> &str { + "tool desc" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + + async fn execute( + &self, + _args: serde_json::Value, + ) -> anyhow::Result { + Ok(crate::tools::ToolResult { + success: true, + output: "ok".into(), + error: None, + }) + } + } + + #[test] + fn prompt_builder_assembles_sections() { + let tools: Vec> = vec![Box::new(TestTool)]; + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + tools: &tools, + skills: &[], + identity_config: None, + dispatcher_instructions: "instr", + }; + let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap(); + assert!(prompt.contains("## Tools")); + assert!(prompt.contains("test_tool")); + assert!(prompt.contains("instr")); + } +} diff --git a/src/channels/mod.rs b/src/channels/mod.rs index a3d8281..3c96f19 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -765,18 +765,16 @@ pub async fn start_channels(config: Config) -> Result<()> { &config.autonomy, &config.workspace_dir, )); - let model = config .default_model .clone() - .unwrap_or_else(|| "anthropic/claude-sonnet-4".into()); + .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); let temperature = config.default_temperature; let mem: Arc = Arc::from(memory::create_memory( &config.memory, &config.workspace_dir, config.api_key.as_deref(), )?); - let (composio_key, composio_entity_id) = if config.composio.enabled { ( config.composio.api_key.as_deref(), @@ -785,6 +783,8 @@ pub async fn start_channels(config: Config) -> Result<()> { } else { (None, None) }; + // Build system prompt from workspace identity files + skills + let workspace = config.workspace_dir.clone(); let tools_registry = Arc::new(tools::all_tools_with_runtime( &security, runtime, @@ -793,14 +793,12 @@ pub async fn start_channels(config: Config) -> Result<()> { composio_entity_id, &config.browser, &config.http_request, - &config.workspace_dir, + &workspace, &config.agents, config.api_key.as_deref(), &config, )); - // Build system prompt from workspace identity files + skills - let workspace = config.workspace_dir.clone(); let skills = crate::skills::load_skills(&workspace); // Collect tool descriptions for the prompt @@ -1112,23 +1110,19 @@ mod tests { message: &str, _model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { tokio::time::sleep(self.delay).await; - Ok(ChatResponse::with_text(format!("echo: {message}"))) + Ok(format!("echo: {message}")) } } struct ToolCallingProvider; - fn tool_call_payload() -> ChatResponse { - ChatResponse { - text: Some(String::new()), - tool_calls: vec![ToolCall { - id: "call_1".into(), - name: "mock_price".into(), - arguments: r#"{"symbol":"BTC"}"#.into(), - }], - } + fn tool_call_payload() -> String { + r#" +{"name":"mock_price","arguments":{"symbol":"BTC"}} +"# + .to_string() } #[async_trait::async_trait] @@ -1139,7 +1133,7 @@ mod tests { _message: &str, _model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { Ok(tool_call_payload()) } @@ -1148,14 +1142,12 @@ mod tests { messages: &[ChatMessage], _model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let has_tool_results = messages .iter() .any(|msg| msg.role == "user" && msg.content.contains("[Tool results]")); if has_tool_results { - Ok(ChatResponse::with_text( - "BTC is currently around $65,000 based on latest tool output.", - )) + Ok("BTC is currently around $65,000 based on latest tool output.".to_string()) } else { Ok(tool_call_payload()) } diff --git a/src/config/schema.rs b/src/config/schema.rs index f615d13..5183b81 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -37,6 +37,9 @@ pub struct Config { #[serde(default)] pub scheduler: SchedulerConfig, + #[serde(default)] + pub agent: AgentConfig, + /// Model routing rules — route `hint:` to specific provider+model combos. #[serde(default)] pub model_routes: Vec, @@ -209,6 +212,41 @@ impl Default for HardwareConfig { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentConfig { + #[serde(default = "default_agent_max_tool_iterations")] + pub max_tool_iterations: usize, + #[serde(default = "default_agent_max_history_messages")] + pub max_history_messages: usize, + #[serde(default)] + pub parallel_tools: bool, + #[serde(default = "default_agent_tool_dispatcher")] + pub tool_dispatcher: String, +} + +fn default_agent_max_tool_iterations() -> usize { + 10 +} + +fn default_agent_max_history_messages() -> usize { + 50 +} + +fn default_agent_tool_dispatcher() -> String { + "auto".into() +} + +impl Default for AgentConfig { + fn default() -> Self { + Self { + max_tool_iterations: default_agent_max_tool_iterations(), + max_history_messages: default_agent_max_history_messages(), + parallel_tools: false, + tool_dispatcher: default_agent_tool_dispatcher(), + } + } +} + // ── Identity (AIEOS / OpenClaw format) ────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1507,6 +1545,7 @@ impl Default for Config { runtime: RuntimeConfig::default(), reliability: ReliabilityConfig::default(), scheduler: SchedulerConfig::default(), + agent: AgentConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), @@ -1873,6 +1912,7 @@ mod tests { secrets: SecretsConfig::default(), browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), + agent: AgentConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), peripherals: PeripheralsConfig::default(), @@ -1922,6 +1962,32 @@ default_temperature = 0.7 assert_eq!(parsed.memory.conversation_retention_days, 30); } + #[test] + fn agent_config_defaults() { + let cfg = AgentConfig::default(); + assert_eq!(cfg.max_tool_iterations, 10); + assert_eq!(cfg.max_history_messages, 50); + assert!(!cfg.parallel_tools); + assert_eq!(cfg.tool_dispatcher, "auto"); + } + + #[test] + fn agent_config_deserializes() { + let raw = r#" +default_temperature = 0.7 +[agent] +max_tool_iterations = 20 +max_history_messages = 80 +parallel_tools = true +tool_dispatcher = "xml" +"#; + let parsed: Config = toml::from_str(raw).unwrap(); + assert_eq!(parsed.agent.max_tool_iterations, 20); + assert_eq!(parsed.agent.max_history_messages, 80); + assert!(parsed.agent.parallel_tools); + assert_eq!(parsed.agent.tool_dispatcher, "xml"); + } + #[test] fn config_save_and_load_tmpdir() { let dir = std::env::temp_dir().join("zeroclaw_test_config"); @@ -1951,6 +2017,7 @@ default_temperature = 0.7 secrets: SecretsConfig::default(), browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), + agent: AgentConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), peripherals: PeripheralsConfig::default(), diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index f9f5b6e..580fe4b 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -10,14 +10,8 @@ use crate::channels::{Channel, WhatsAppChannel}; use crate::config::Config; use crate::memory::{self, Memory, MemoryCategory}; -use crate::observability::{self, Observer}; -use crate::providers::{self, ChatMessage, Provider}; -use crate::runtime; -use crate::security::{ - pairing::{constant_time_eq, is_public_bind, PairingGuard}, - SecurityPolicy, -}; -use crate::tools::{self, Tool}; +use crate::providers::{self, Provider}; +use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard}; use crate::util::truncate_with_ellipsis; use anyhow::Result; use axum::{ @@ -51,35 +45,6 @@ fn whatsapp_memory_key(msg: &crate::channels::traits::ChannelMessage) -> String format!("whatsapp_{}_{}", msg.sender, msg.id) } -fn normalize_gateway_reply(reply: String) -> String { - if reply.trim().is_empty() { - return "Model returned an empty response.".to_string(); - } - - reply -} - -async fn gateway_agent_reply(state: &AppState, message: &str) -> Result { - let mut history = vec![ - ChatMessage::system(state.system_prompt.as_str()), - ChatMessage::user(message), - ]; - - let reply = crate::agent::loop_::run_tool_call_loop( - state.provider.as_ref(), - &mut history, - state.tools_registry.as_ref(), - state.observer.as_ref(), - "gateway", - &state.model, - state.temperature, - true, // silent — gateway responses go over HTTP - ) - .await?; - - Ok(normalize_gateway_reply(reply)) -} - /// How often the rate limiter sweeps stale IP entries from its map. const RATE_LIMITER_SWEEP_INTERVAL_SECS: u64 = 300; // 5 minutes @@ -207,9 +172,6 @@ fn client_key_from_headers(headers: &HeaderMap) -> String { #[derive(Clone)] pub struct AppState { pub provider: Arc, - pub observer: Arc, - pub tools_registry: Arc>>, - pub system_prompt: Arc, pub model: String, pub temperature: f64, pub mem: Arc, @@ -256,55 +218,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { &config.workspace_dir, config.api_key.as_deref(), )?); - let observer: Arc = - Arc::from(observability::create_observer(&config.observability)); - let runtime: Arc = - Arc::from(runtime::create_runtime(&config.runtime)?); - let security = Arc::new(SecurityPolicy::from_config( - &config.autonomy, - &config.workspace_dir, - )); - let (composio_key, composio_entity_id) = if config.composio.enabled { - ( - config.composio.api_key.as_deref(), - Some(config.composio.entity_id.as_str()), - ) - } else { - (None, None) - }; - - let tools_registry = Arc::new(tools::all_tools_with_runtime( - &security, - runtime, - Arc::clone(&mem), - composio_key, - composio_entity_id, - &config.browser, - &config.http_request, - &config.workspace_dir, - &config.agents, - config.api_key.as_deref(), - &config, - )); - let skills = crate::skills::load_skills(&config.workspace_dir); - let tool_descs: Vec<(&str, &str)> = tools_registry - .iter() - .map(|tool| (tool.name(), tool.description())) - .collect(); - - let mut system_prompt = crate::channels::build_system_prompt( - &config.workspace_dir, - &model, - &tool_descs, - &skills, - Some(&config.identity), - None, // bootstrap_max_chars — no compact context for gateway - ); - system_prompt.push_str(&crate::agent::loop_::build_tool_instructions( - tools_registry.as_ref(), - )); - let system_prompt = Arc::new(system_prompt); // Extract webhook secret for authentication let webhook_secret: Option> = config @@ -408,9 +322,6 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { // Build shared state let state = AppState { provider, - observer, - tools_registry, - system_prompt, model, temperature, mem, @@ -594,9 +505,13 @@ async fn handle_webhook( .await; } - match gateway_agent_reply(&state, message).await { - Ok(reply) => { - let body = serde_json::json!({"response": reply, "model": state.model}); + match state + .provider + .simple_chat(message, &state.model, state.temperature) + .await + { + Ok(response) => { + let body = serde_json::json!({"response": response, "model": state.model}); (StatusCode::OK, Json(body)) } Err(e) => { @@ -744,10 +659,14 @@ async fn handle_whatsapp_message( } // Call the LLM - match gateway_agent_reply(&state, &msg.content).await { - Ok(reply) => { + match state + .provider + .simple_chat(&msg.content, &state.model, state.temperature) + .await + { + Ok(response) => { // Send reply via WhatsApp - if let Err(e) = wa.send(&reply, &msg.sender).await { + if let Err(e) = wa.send(&response, &msg.sender).await { tracing::error!("Failed to send WhatsApp reply: {e}"); } } @@ -966,9 +885,9 @@ mod tests { _message: &str, _model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { self.calls.fetch_add(1, Ordering::SeqCst); - Ok(crate::providers::ChatResponse::with_text("ok")) + Ok("ok".into()) } } @@ -1029,36 +948,25 @@ mod tests { } } - fn test_app_state( - provider: Arc, - memory: Arc, - auto_save: bool, - ) -> AppState { - AppState { - provider, - observer: Arc::new(crate::observability::NoopObserver), - tools_registry: Arc::new(Vec::new()), - system_prompt: Arc::new("test-system-prompt".into()), - model: "test-model".into(), - temperature: 0.0, - mem: memory, - auto_save, - webhook_secret: None, - pairing: Arc::new(PairingGuard::new(false, &[])), - rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)), - idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))), - whatsapp: None, - whatsapp_app_secret: None, - } - } - #[tokio::test] async fn webhook_idempotency_skips_duplicate_provider_calls() { let provider_impl = Arc::new(MockProvider::default()); let provider: Arc = provider_impl.clone(); let memory: Arc = Arc::new(MockMemory); - let state = test_app_state(provider, memory, false); + let state = AppState { + provider, + model: "test-model".into(), + temperature: 0.0, + mem: memory, + auto_save: false, + webhook_secret: None, + pairing: Arc::new(PairingGuard::new(false, &[])), + rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)), + idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))), + whatsapp: None, + whatsapp_app_secret: None, + }; let mut headers = HeaderMap::new(); headers.insert("X-Idempotency-Key", HeaderValue::from_static("abc-123")); @@ -1094,7 +1002,19 @@ mod tests { let tracking_impl = Arc::new(TrackingMemory::default()); let memory: Arc = tracking_impl.clone(); - let state = test_app_state(provider, memory, true); + let state = AppState { + provider, + model: "test-model".into(), + temperature: 0.0, + mem: memory, + auto_save: true, + webhook_secret: None, + pairing: Arc::new(PairingGuard::new(false, &[])), + rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)), + idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))), + whatsapp: None, + whatsapp_app_secret: None, + }; let headers = HeaderMap::new(); @@ -1126,110 +1046,6 @@ mod tests { assert_eq!(provider_impl.calls.load(Ordering::SeqCst), 2); } - #[derive(Default)] - struct StructuredToolCallProvider { - calls: AtomicUsize, - } - - #[async_trait] - impl Provider for StructuredToolCallProvider { - async fn chat_with_system( - &self, - _system_prompt: Option<&str>, - _message: &str, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - let turn = self.calls.fetch_add(1, Ordering::SeqCst); - - if turn == 0 { - return Ok(crate::providers::ChatResponse { - text: Some("Running tool...".into()), - tool_calls: vec![crate::providers::ToolCall { - id: "call_1".into(), - name: "mock_tool".into(), - arguments: r#"{"query":"gateway"}"#.into(), - }], - }); - } - - Ok(crate::providers::ChatResponse::with_text( - "Gateway tool result ready.", - )) - } - } - - struct MockTool { - calls: Arc, - } - - #[async_trait] - impl Tool for MockTool { - fn name(&self) -> &str { - "mock_tool" - } - - fn description(&self) -> &str { - "Mock tool for gateway tests" - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "query": {"type": "string"} - }, - "required": ["query"] - }) - } - - async fn execute( - &self, - args: serde_json::Value, - ) -> anyhow::Result { - self.calls.fetch_add(1, Ordering::SeqCst); - assert_eq!(args["query"], "gateway"); - - Ok(crate::tools::ToolResult { - success: true, - output: "ok".into(), - error: None, - }) - } - } - - #[tokio::test] - async fn webhook_executes_structured_tool_calls() { - let provider_impl = Arc::new(StructuredToolCallProvider::default()); - let provider: Arc = provider_impl.clone(); - let memory: Arc = Arc::new(MockMemory); - - let tool_calls = Arc::new(AtomicUsize::new(0)); - let tools: Vec> = vec![Box::new(MockTool { - calls: Arc::clone(&tool_calls), - })]; - - let mut state = test_app_state(provider, memory, false); - state.tools_registry = Arc::new(tools); - - let response = handle_webhook( - State(state), - HeaderMap::new(), - Ok(Json(WebhookBody { - message: "please use tool".into(), - })), - ) - .await - .into_response(); - - assert_eq!(response.status(), StatusCode::OK); - let payload = response.into_body().collect().await.unwrap().to_bytes(); - let parsed: serde_json::Value = serde_json::from_slice(&payload).unwrap(); - assert_eq!(parsed["response"], "Gateway tool result ready."); - assert_eq!(tool_calls.load(Ordering::SeqCst), 1); - assert_eq!(provider_impl.calls.load(Ordering::SeqCst), 2); - } - // ══════════════════════════════════════════════════════════ // WhatsApp Signature Verification Tests (CWE-345 Prevention) // ══════════════════════════════════════════════════════════ diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 13ed3a8..2deee91 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -114,6 +114,7 @@ pub fn run_wizard() -> Result { runtime: RuntimeConfig::default(), reliability: crate::config::ReliabilityConfig::default(), scheduler: crate::config::schema::SchedulerConfig::default(), + agent: crate::config::schema::AgentConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config, @@ -318,6 +319,7 @@ pub fn run_quick_setup( runtime: RuntimeConfig::default(), reliability: crate::config::ReliabilityConfig::default(), scheduler: crate::config::schema::SchedulerConfig::default(), + agent: crate::config::schema::AgentConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index c3c7870..56efeb8 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -1,4 +1,8 @@ -use crate::providers::traits::{ChatResponse as ProviderChatResponse, Provider}; +use crate::providers::traits::{ + ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse, + Provider, ToolCall as ProviderToolCall, +}; +use crate::tools::ToolSpec; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -26,13 +30,76 @@ struct Message { } #[derive(Debug, Deserialize)] -struct ApiChatResponse { +struct ChatResponse { content: Vec, } #[derive(Debug, Deserialize)] struct ContentBlock { - text: String, + #[serde(rename = "type")] + kind: String, + #[serde(default)] + text: Option, +} + +#[derive(Debug, Serialize)] +struct NativeChatRequest { + model: String, + max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + system: Option, + messages: Vec, + temperature: f64, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, +} + +#[derive(Debug, Serialize)] +struct NativeMessage { + role: String, + content: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type")] +enum NativeContentOut { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "tool_use")] + ToolUse { + id: String, + name: String, + input: serde_json::Value, + }, + #[serde(rename = "tool_result")] + ToolResult { tool_use_id: String, content: String }, +} + +#[derive(Debug, Serialize)] +struct NativeToolSpec { + name: String, + description: String, + input_schema: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct NativeChatResponse { + #[serde(default)] + content: Vec, +} + +#[derive(Debug, Deserialize)] +struct NativeContentIn { + #[serde(rename = "type")] + kind: String, + #[serde(default)] + text: Option, + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + input: Option, } impl AnthropicProvider { @@ -62,6 +129,186 @@ impl AnthropicProvider { fn is_setup_token(token: &str) -> bool { token.starts_with("sk-ant-oat01-") } + + fn apply_auth( + &self, + request: reqwest::RequestBuilder, + credential: &str, + ) -> reqwest::RequestBuilder { + if Self::is_setup_token(credential) { + request.header("Authorization", format!("Bearer {credential}")) + } else { + request.header("x-api-key", credential) + } + } + + fn convert_tools(tools: Option<&[ToolSpec]>) -> Option> { + let items = tools?; + if items.is_empty() { + return None; + } + Some( + items + .iter() + .map(|tool| NativeToolSpec { + name: tool.name.clone(), + description: tool.description.clone(), + input_schema: tool.parameters.clone(), + }) + .collect(), + ) + } + + fn parse_assistant_tool_call_message(content: &str) -> Option> { + let value = serde_json::from_str::(content).ok()?; + let tool_calls = value + .get("tool_calls") + .and_then(|v| serde_json::from_value::>(v.clone()).ok())?; + + let mut blocks = Vec::new(); + if let Some(text) = value + .get("content") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|t| !t.is_empty()) + { + blocks.push(NativeContentOut::Text { + text: text.to_string(), + }); + } + for call in tool_calls { + let input = serde_json::from_str::(&call.arguments) + .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())); + blocks.push(NativeContentOut::ToolUse { + id: call.id, + name: call.name, + input, + }); + } + Some(blocks) + } + + fn parse_tool_result_message(content: &str) -> Option { + let value = serde_json::from_str::(content).ok()?; + let tool_use_id = value + .get("tool_call_id") + .and_then(serde_json::Value::as_str)? + .to_string(); + let result = value + .get("content") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(); + Some(NativeMessage { + role: "user".to_string(), + content: vec![NativeContentOut::ToolResult { + tool_use_id, + content: result, + }], + }) + } + + fn convert_messages(messages: &[ChatMessage]) -> (Option, Vec) { + let mut system_prompt = None; + let mut native_messages = Vec::new(); + + for msg in messages { + match msg.role.as_str() { + "system" => { + if system_prompt.is_none() { + system_prompt = Some(msg.content.clone()); + } + } + "assistant" => { + if let Some(blocks) = Self::parse_assistant_tool_call_message(&msg.content) { + native_messages.push(NativeMessage { + role: "assistant".to_string(), + content: blocks, + }); + } else { + native_messages.push(NativeMessage { + role: "assistant".to_string(), + content: vec![NativeContentOut::Text { + text: msg.content.clone(), + }], + }); + } + } + "tool" => { + if let Some(tool_result) = Self::parse_tool_result_message(&msg.content) { + native_messages.push(tool_result); + } else { + native_messages.push(NativeMessage { + role: "user".to_string(), + content: vec![NativeContentOut::Text { + text: msg.content.clone(), + }], + }); + } + } + _ => { + native_messages.push(NativeMessage { + role: "user".to_string(), + content: vec![NativeContentOut::Text { + text: msg.content.clone(), + }], + }); + } + } + } + + (system_prompt, native_messages) + } + + fn parse_text_response(response: ChatResponse) -> anyhow::Result { + response + .content + .into_iter() + .find(|c| c.kind == "text") + .and_then(|c| c.text) + .ok_or_else(|| anyhow::anyhow!("No response from Anthropic")) + } + + fn parse_native_response(response: NativeChatResponse) -> ProviderChatResponse { + let mut text_parts = Vec::new(); + let mut tool_calls = Vec::new(); + + for block in response.content { + match block.kind.as_str() { + "text" => { + if let Some(text) = block.text.map(|t| t.trim().to_string()) { + if !text.is_empty() { + text_parts.push(text); + } + } + } + "tool_use" => { + let name = block.name.unwrap_or_default(); + if name.is_empty() { + continue; + } + let arguments = block + .input + .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); + tool_calls.push(ProviderToolCall { + id: block.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + name, + arguments: arguments.to_string(), + }); + } + _ => {} + } + } + + ProviderChatResponse { + text: if text_parts.is_empty() { + None + } else { + Some(text_parts.join("\n")) + }, + tool_calls, + } + } } #[async_trait] @@ -72,7 +319,7 @@ impl Provider for AnthropicProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let credential = self.credential.as_ref().ok_or_else(|| { anyhow::anyhow!( "Anthropic credentials not set. Set ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN (setup-token)." @@ -97,11 +344,7 @@ impl Provider for AnthropicProvider { .header("content-type", "application/json") .json(&request); - if Self::is_setup_token(credential) { - request = request.header("Authorization", format!("Bearer {credential}")); - } else { - request = request.header("x-api-key", credential); - } + request = self.apply_auth(request, credential); let response = request.send().await?; @@ -109,14 +352,50 @@ impl Provider for AnthropicProvider { return Err(super::api_error("Anthropic", response).await); } - let chat_response: ApiChatResponse = response.json().await?; + let chat_response: ChatResponse = response.json().await?; + Self::parse_text_response(chat_response) + } - chat_response - .content - .into_iter() - .next() - .map(|c| ProviderChatResponse::with_text(c.text)) - .ok_or_else(|| anyhow::anyhow!("No response from Anthropic")) + async fn chat( + &self, + request: ProviderChatRequest<'_>, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let credential = self.credential.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Anthropic credentials not set. Set ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN (setup-token)." + ) + })?; + + let (system_prompt, messages) = Self::convert_messages(request.messages); + let native_request = NativeChatRequest { + model: model.to_string(), + max_tokens: 4096, + system: system_prompt, + messages, + temperature, + tools: Self::convert_tools(request.tools), + }; + + let req = self + .client + .post(format!("{}/v1/messages", self.base_url)) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&native_request); + + let response = self.apply_auth(req, credential).send().await?; + if !response.status().is_success() { + return Err(super::api_error("Anthropic", response).await); + } + + let native_response: NativeChatResponse = response.json().await?; + Ok(Self::parse_native_response(native_response)) + } + + fn supports_native_tools(&self) -> bool { + true } } @@ -241,15 +520,16 @@ mod tests { #[test] fn chat_response_deserializes() { let json = r#"{"content":[{"type":"text","text":"Hello there!"}]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.content.len(), 1); - assert_eq!(resp.content[0].text, "Hello there!"); + assert_eq!(resp.content[0].kind, "text"); + assert_eq!(resp.content[0].text.as_deref(), Some("Hello there!")); } #[test] fn chat_response_empty_content() { let json = r#"{"content":[]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert!(resp.content.is_empty()); } @@ -257,10 +537,10 @@ mod tests { fn chat_response_multiple_blocks() { let json = r#"{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.content.len(), 2); - assert_eq!(resp.content[0].text, "First"); - assert_eq!(resp.content[1].text, "Second"); + assert_eq!(resp.content[0].text.as_deref(), Some("First")); + assert_eq!(resp.content[1].text.as_deref(), Some("Second")); } #[test] diff --git a/src/providers/compatible.rs b/src/providers/compatible.rs index e9e39e1..a9942f0 100644 --- a/src/providers/compatible.rs +++ b/src/providers/compatible.rs @@ -2,7 +2,10 @@ //! Most LLM APIs follow the same `/v1/chat/completions` format. //! This module provides a single implementation that works for all of them. -use crate::providers::traits::{ChatMessage, ChatResponse, Provider, ToolCall}; +use crate::providers::traits::{ + ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse, + Provider, ToolCall as ProviderToolCall, +}; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -163,12 +166,11 @@ struct ResponseMessage { #[serde(default)] content: Option, #[serde(default)] - tool_calls: Option>, + tool_calls: Option>, } #[derive(Debug, Deserialize, Serialize)] -struct ApiToolCall { - id: Option, +struct ToolCall { #[serde(rename = "type")] kind: Option, function: Option, @@ -254,44 +256,6 @@ fn extract_responses_text(response: ResponsesResponse) -> Option { None } -fn map_response_message(message: ResponseMessage) -> ChatResponse { - let text = first_nonempty(message.content.as_deref()); - let tool_calls = message - .tool_calls - .unwrap_or_default() - .into_iter() - .enumerate() - .filter_map(|(index, call)| map_api_tool_call(call, index)) - .collect(); - - ChatResponse { text, tool_calls } -} - -fn map_api_tool_call(call: ApiToolCall, index: usize) -> Option { - if call.kind.as_deref().is_some_and(|kind| kind != "function") { - return None; - } - - let function = call.function?; - let name = function - .name - .and_then(|value| first_nonempty(Some(value.as_str())))?; - let arguments = function - .arguments - .and_then(|value| first_nonempty(Some(value.as_str()))) - .unwrap_or_else(|| "{}".to_string()); - let id = call - .id - .and_then(|value| first_nonempty(Some(value.as_str()))) - .unwrap_or_else(|| format!("call_{}", index + 1)); - - Some(ToolCall { - id, - name, - arguments, - }) -} - impl OpenAiCompatibleProvider { fn apply_auth_header( &self, @@ -311,7 +275,7 @@ impl OpenAiCompatibleProvider { system_prompt: Option<&str>, message: &str, model: &str, - ) -> anyhow::Result { + ) -> anyhow::Result { let request = ResponsesRequest { model: model.to_string(), input: vec![ResponsesInput { @@ -337,7 +301,6 @@ impl OpenAiCompatibleProvider { let responses: ResponsesResponse = response.json().await?; extract_responses_text(responses) - .map(ChatResponse::with_text) .ok_or_else(|| anyhow::anyhow!("No response from {} Responses API", self.name)) } } @@ -350,7 +313,7 @@ impl Provider for OpenAiCompatibleProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let api_key = self.api_key.as_ref().ok_or_else(|| { anyhow::anyhow!( "{} API key not set. Run `zeroclaw onboard` or set the appropriate env var.", @@ -408,13 +371,27 @@ impl Provider for OpenAiCompatibleProvider { let chat_response: ApiChatResponse = response.json().await?; - let choice = chat_response + chat_response .choices .into_iter() .next() - .ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))?; - - Ok(map_response_message(choice.message)) + .map(|c| { + // If tool_calls are present, serialize the full message as JSON + // so parse_tool_calls can handle the OpenAI-style format + if c.message.tool_calls.is_some() + && c.message + .tool_calls + .as_ref() + .map_or(false, |t| !t.is_empty()) + { + serde_json::to_string(&c.message) + .unwrap_or_else(|_| c.message.content.unwrap_or_default()) + } else { + // No tool calls, return content as-is + c.message.content.unwrap_or_default() + } + }) + .ok_or_else(|| anyhow::anyhow!("No response from {}", self.name)) } async fn chat_with_history( @@ -422,7 +399,7 @@ impl Provider for OpenAiCompatibleProvider { messages: &[ChatMessage], model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let api_key = self.api_key.as_ref().ok_or_else(|| { anyhow::anyhow!( "{} API key not set. Run `zeroclaw onboard` or set the appropriate env var.", @@ -482,13 +459,71 @@ impl Provider for OpenAiCompatibleProvider { let chat_response: ApiChatResponse = response.json().await?; - let choice = chat_response + chat_response .choices .into_iter() .next() - .ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))?; + .map(|c| { + // If tool_calls are present, serialize the full message as JSON + // so parse_tool_calls can handle the OpenAI-style format + if c.message.tool_calls.is_some() + && c.message + .tool_calls + .as_ref() + .map_or(false, |t| !t.is_empty()) + { + serde_json::to_string(&c.message) + .unwrap_or_else(|_| c.message.content.unwrap_or_default()) + } else { + // No tool calls, return content as-is + c.message.content.unwrap_or_default() + } + }) + .ok_or_else(|| anyhow::anyhow!("No response from {}", self.name)) + } - Ok(map_response_message(choice.message)) + async fn chat( + &self, + request: ProviderChatRequest<'_>, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let text = self + .chat_with_history(request.messages, model, temperature) + .await?; + + // Backward compatible path: chat_with_history may serialize tool_calls JSON into content. + if let Ok(message) = serde_json::from_str::(&text) { + let tool_calls = message + .tool_calls + .unwrap_or_default() + .into_iter() + .filter_map(|tc| { + let function = tc.function?; + let name = function.name?; + let arguments = function.arguments.unwrap_or_else(|| "{}".to_string()); + Some(ProviderToolCall { + id: uuid::Uuid::new_v4().to_string(), + name, + arguments, + }) + }) + .collect::>(); + + return Ok(ProviderChatResponse { + text: message.content, + tool_calls, + }); + } + + Ok(ProviderChatResponse { + text: Some(text), + tool_calls: vec![], + }) + } + + fn supports_native_tools(&self) -> bool { + true } } @@ -573,20 +608,6 @@ mod tests { assert!(resp.choices.is_empty()); } - #[test] - fn response_with_tool_calls_maps_structured_data() { - let json = r#"{"choices":[{"message":{"content":"Running checks","tool_calls":[{"id":"call_1","type":"function","function":{"name":"shell","arguments":"{\"command\":\"pwd\"}"}}]}}]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); - let choice = resp.choices.into_iter().next().unwrap(); - - let mapped = map_response_message(choice.message); - assert_eq!(mapped.text.as_deref(), Some("Running checks")); - assert_eq!(mapped.tool_calls.len(), 1); - assert_eq!(mapped.tool_calls[0].id, "call_1"); - assert_eq!(mapped.tool_calls[0].name, "shell"); - assert_eq!(mapped.tool_calls[0].arguments, r#"{"command":"pwd"}"#); - } - #[test] fn x_api_key_auth_style() { let p = OpenAiCompatibleProvider::new( diff --git a/src/providers/gemini.rs b/src/providers/gemini.rs index 189daf0..a988224 100644 --- a/src/providers/gemini.rs +++ b/src/providers/gemini.rs @@ -3,7 +3,7 @@ //! - Gemini CLI OAuth tokens (reuse existing ~/.gemini/ authentication) //! - Google Cloud ADC (`GOOGLE_APPLICATION_CREDENTIALS`) -use crate::providers::traits::{ChatResponse, Provider}; +use crate::providers::traits::Provider; use async_trait::async_trait; use directories::UserDirs; use reqwest::Client; @@ -260,7 +260,7 @@ impl Provider for GeminiProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let auth = self.auth.as_ref().ok_or_else(|| { anyhow::anyhow!( "Gemini API key not found. Options:\n\ @@ -319,7 +319,6 @@ impl Provider for GeminiProvider { .and_then(|c| c.into_iter().next()) .and_then(|c| c.content.parts.into_iter().next()) .and_then(|p| p.text) - .map(ChatResponse::with_text) .ok_or_else(|| anyhow::anyhow!("No response from Gemini")) } } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 713afe4..1ddaddc 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -9,7 +9,10 @@ pub mod router; pub mod traits; #[allow(unused_imports)] -pub use traits::{ChatMessage, ChatResponse, Provider, ToolCall}; +pub use traits::{ + ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ToolCall, + ToolResultMessage, +}; use compatible::{AuthStyle, OpenAiCompatibleProvider}; use reliable::ReliableProvider; diff --git a/src/providers/ollama.rs b/src/providers/ollama.rs index 481d0bf..8ecfb5a 100644 --- a/src/providers/ollama.rs +++ b/src/providers/ollama.rs @@ -1,4 +1,4 @@ -use crate::providers::traits::{ChatResponse as ProviderChatResponse, Provider}; +use crate::providers::traits::Provider; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -61,7 +61,7 @@ impl Provider for OllamaProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let mut messages = Vec::new(); if let Some(sys) = system_prompt { @@ -93,9 +93,7 @@ impl Provider for OllamaProvider { } let chat_response: ApiChatResponse = response.json().await?; - Ok(ProviderChatResponse::with_text( - chat_response.message.content, - )) + Ok(chat_response.message.content) } } diff --git a/src/providers/openai.rs b/src/providers/openai.rs index 6b8bbe5..ef67678 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -1,4 +1,8 @@ -use crate::providers::traits::{ChatResponse, Provider}; +use crate::providers::traits::{ + ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse, + Provider, ToolCall as ProviderToolCall, +}; +use crate::tools::ToolSpec; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -22,7 +26,7 @@ struct Message { } #[derive(Debug, Deserialize)] -struct ApiChatResponse { +struct ChatResponse { choices: Vec, } @@ -36,6 +40,75 @@ struct ResponseMessage { content: String, } +#[derive(Debug, Serialize)] +struct NativeChatRequest { + model: String, + messages: Vec, + temperature: f64, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + tool_choice: Option, +} + +#[derive(Debug, Serialize)] +struct NativeMessage { + role: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>, +} + +#[derive(Debug, Serialize)] +struct NativeToolSpec { + #[serde(rename = "type")] + kind: String, + function: NativeToolFunctionSpec, +} + +#[derive(Debug, Serialize)] +struct NativeToolFunctionSpec { + name: String, + description: String, + parameters: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize)] +struct NativeToolCall { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + kind: Option, + function: NativeFunctionCall, +} + +#[derive(Debug, Serialize, Deserialize)] +struct NativeFunctionCall { + name: String, + arguments: String, +} + +#[derive(Debug, Deserialize)] +struct NativeChatResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct NativeChoice { + message: NativeResponseMessage, +} + +#[derive(Debug, Deserialize)] +struct NativeResponseMessage { + #[serde(default)] + content: Option, + #[serde(default)] + tool_calls: Option>, +} + impl OpenAiProvider { pub fn new(api_key: Option<&str>) -> Self { Self { @@ -47,6 +120,107 @@ impl OpenAiProvider { .unwrap_or_else(|_| Client::new()), } } + + fn convert_tools(tools: Option<&[ToolSpec]>) -> Option> { + tools.map(|items| { + items + .iter() + .map(|tool| NativeToolSpec { + kind: "function".to_string(), + function: NativeToolFunctionSpec { + name: tool.name.clone(), + description: tool.description.clone(), + parameters: tool.parameters.clone(), + }, + }) + .collect() + }) + } + + fn convert_messages(messages: &[ChatMessage]) -> Vec { + messages + .iter() + .map(|m| { + if m.role == "assistant" { + if let Ok(value) = serde_json::from_str::(&m.content) { + if let Some(tool_calls_value) = value.get("tool_calls") { + if let Ok(parsed_calls) = + serde_json::from_value::>( + tool_calls_value.clone(), + ) + { + let tool_calls = parsed_calls + .into_iter() + .map(|tc| NativeToolCall { + id: Some(tc.id), + kind: Some("function".to_string()), + function: NativeFunctionCall { + name: tc.name, + arguments: tc.arguments, + }, + }) + .collect::>(); + let content = value + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + return NativeMessage { + role: "assistant".to_string(), + content, + tool_call_id: None, + tool_calls: Some(tool_calls), + }; + } + } + } + } + + if m.role == "tool" { + if let Ok(value) = serde_json::from_str::(&m.content) { + let tool_call_id = value + .get("tool_call_id") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + let content = value + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + return NativeMessage { + role: "tool".to_string(), + content, + tool_call_id, + tool_calls: None, + }; + } + } + + NativeMessage { + role: m.role.clone(), + content: Some(m.content.clone()), + tool_call_id: None, + tool_calls: None, + } + }) + .collect() + } + + fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse { + let tool_calls = message + .tool_calls + .unwrap_or_default() + .into_iter() + .map(|tc| ProviderToolCall { + id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + name: tc.function.name, + arguments: tc.function.arguments, + }) + .collect::>(); + + ProviderChatResponse { + text: message.content, + tool_calls, + } + } } #[async_trait] @@ -57,7 +231,7 @@ impl Provider for OpenAiProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let api_key = self.api_key.as_ref().ok_or_else(|| { anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.") })?; @@ -94,15 +268,60 @@ impl Provider for OpenAiProvider { return Err(super::api_error("OpenAI", response).await); } - let chat_response: ApiChatResponse = response.json().await?; + let chat_response: ChatResponse = response.json().await?; chat_response .choices .into_iter() .next() - .map(|c| ChatResponse::with_text(c.message.content)) + .map(|c| c.message.content) .ok_or_else(|| anyhow::anyhow!("No response from OpenAI")) } + + async fn chat( + &self, + request: ProviderChatRequest<'_>, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let api_key = self.api_key.as_ref().ok_or_else(|| { + anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.") + })?; + + let tools = Self::convert_tools(request.tools); + let native_request = NativeChatRequest { + model: model.to_string(), + messages: Self::convert_messages(request.messages), + temperature, + tool_choice: tools.as_ref().map(|_| "auto".to_string()), + tools, + }; + + let response = self + .client + .post("https://api.openai.com/v1/chat/completions") + .header("Authorization", format!("Bearer {api_key}")) + .json(&native_request) + .send() + .await?; + + if !response.status().is_success() { + return Err(super::api_error("OpenAI", response).await); + } + + let native_response: NativeChatResponse = response.json().await?; + let message = native_response + .choices + .into_iter() + .next() + .map(|c| c.message) + .ok_or_else(|| anyhow::anyhow!("No response from OpenAI"))?; + Ok(Self::parse_native_response(message)) + } + + fn supports_native_tools(&self) -> bool { + true + } } #[cfg(test)] @@ -184,7 +403,7 @@ mod tests { #[test] fn response_deserializes_single_choice() { let json = r#"{"choices":[{"message":{"content":"Hi!"}}]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.choices.len(), 1); assert_eq!(resp.choices[0].message.content, "Hi!"); } @@ -192,14 +411,14 @@ mod tests { #[test] fn response_deserializes_empty_choices() { let json = r#"{"choices":[]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert!(resp.choices.is_empty()); } #[test] fn response_deserializes_multiple_choices() { let json = r#"{"choices":[{"message":{"content":"A"}},{"message":{"content":"B"}}]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.choices.len(), 2); assert_eq!(resp.choices[0].message.content, "A"); } @@ -207,7 +426,7 @@ mod tests { #[test] fn response_with_unicode() { let json = r#"{"choices":[{"message":{"content":"こんにちは 🦀"}}]}"#; - let resp: ApiChatResponse = serde_json::from_str(json).unwrap(); + let resp: ChatResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.choices[0].message.content, "こんにちは 🦀"); } @@ -215,7 +434,7 @@ mod tests { fn response_with_long_content() { let long = "x".repeat(100_000); let json = format!(r#"{{"choices":[{{"message":{{"content":"{long}"}}}}]}}"#); - let resp: ApiChatResponse = serde_json::from_str(&json).unwrap(); + let resp: ChatResponse = serde_json::from_str(&json).unwrap(); assert_eq!(resp.choices[0].message.content.len(), 100_000); } } diff --git a/src/providers/openrouter.rs b/src/providers/openrouter.rs index 287dd88..5363651 100644 --- a/src/providers/openrouter.rs +++ b/src/providers/openrouter.rs @@ -1,4 +1,8 @@ -use crate::providers::traits::{ChatMessage, ChatResponse, Provider}; +use crate::providers::traits::{ + ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse, + Provider, ToolCall as ProviderToolCall, +}; +use crate::tools::ToolSpec; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -36,6 +40,75 @@ struct ResponseMessage { content: String, } +#[derive(Debug, Serialize)] +struct NativeChatRequest { + model: String, + messages: Vec, + temperature: f64, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + tool_choice: Option, +} + +#[derive(Debug, Serialize)] +struct NativeMessage { + role: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>, +} + +#[derive(Debug, Serialize)] +struct NativeToolSpec { + #[serde(rename = "type")] + kind: String, + function: NativeToolFunctionSpec, +} + +#[derive(Debug, Serialize)] +struct NativeToolFunctionSpec { + name: String, + description: String, + parameters: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize)] +struct NativeToolCall { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + kind: Option, + function: NativeFunctionCall, +} + +#[derive(Debug, Serialize, Deserialize)] +struct NativeFunctionCall { + name: String, + arguments: String, +} + +#[derive(Debug, Deserialize)] +struct NativeChatResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct NativeChoice { + message: NativeResponseMessage, +} + +#[derive(Debug, Deserialize)] +struct NativeResponseMessage { + #[serde(default)] + content: Option, + #[serde(default)] + tool_calls: Option>, +} + impl OpenRouterProvider { pub fn new(api_key: Option<&str>) -> Self { Self { @@ -47,6 +120,111 @@ impl OpenRouterProvider { .unwrap_or_else(|_| Client::new()), } } + + fn convert_tools(tools: Option<&[ToolSpec]>) -> Option> { + let items = tools?; + if items.is_empty() { + return None; + } + Some( + items + .iter() + .map(|tool| NativeToolSpec { + kind: "function".to_string(), + function: NativeToolFunctionSpec { + name: tool.name.clone(), + description: tool.description.clone(), + parameters: tool.parameters.clone(), + }, + }) + .collect(), + ) + } + + fn convert_messages(messages: &[ChatMessage]) -> Vec { + messages + .iter() + .map(|m| { + if m.role == "assistant" { + if let Ok(value) = serde_json::from_str::(&m.content) { + if let Some(tool_calls_value) = value.get("tool_calls") { + if let Ok(parsed_calls) = + serde_json::from_value::>( + tool_calls_value.clone(), + ) + { + let tool_calls = parsed_calls + .into_iter() + .map(|tc| NativeToolCall { + id: Some(tc.id), + kind: Some("function".to_string()), + function: NativeFunctionCall { + name: tc.name, + arguments: tc.arguments, + }, + }) + .collect::>(); + let content = value + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + return NativeMessage { + role: "assistant".to_string(), + content, + tool_call_id: None, + tool_calls: Some(tool_calls), + }; + } + } + } + } + + if m.role == "tool" { + if let Ok(value) = serde_json::from_str::(&m.content) { + let tool_call_id = value + .get("tool_call_id") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + let content = value + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + return NativeMessage { + role: "tool".to_string(), + content, + tool_call_id, + tool_calls: None, + }; + } + } + + NativeMessage { + role: m.role.clone(), + content: Some(m.content.clone()), + tool_call_id: None, + tool_calls: None, + } + }) + .collect() + } + + fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse { + let tool_calls = message + .tool_calls + .unwrap_or_default() + .into_iter() + .map(|tc| ProviderToolCall { + id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + name: tc.function.name, + arguments: tc.function.arguments, + }) + .collect::>(); + + ProviderChatResponse { + text: message.content, + tool_calls, + } + } } #[async_trait] @@ -71,7 +249,7 @@ impl Provider for OpenRouterProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let api_key = self.api_key.as_ref() .ok_or_else(|| anyhow::anyhow!("OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var."))?; @@ -118,7 +296,7 @@ impl Provider for OpenRouterProvider { .choices .into_iter() .next() - .map(|c| ChatResponse::with_text(c.message.content)) + .map(|c| c.message.content) .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter")) } @@ -127,7 +305,7 @@ impl Provider for OpenRouterProvider { messages: &[ChatMessage], model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let api_key = self.api_key.as_ref() .ok_or_else(|| anyhow::anyhow!("OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var."))?; @@ -168,9 +346,59 @@ impl Provider for OpenRouterProvider { .choices .into_iter() .next() - .map(|c| ChatResponse::with_text(c.message.content)) + .map(|c| c.message.content) .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter")) } + + async fn chat( + &self, + request: ProviderChatRequest<'_>, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let api_key = self.api_key.as_ref().ok_or_else(|| anyhow::anyhow!( + "OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var." + ))?; + + let tools = Self::convert_tools(request.tools); + let native_request = NativeChatRequest { + model: model.to_string(), + messages: Self::convert_messages(request.messages), + temperature, + tool_choice: tools.as_ref().map(|_| "auto".to_string()), + tools, + }; + + let response = self + .client + .post("https://openrouter.ai/api/v1/chat/completions") + .header("Authorization", format!("Bearer {api_key}")) + .header( + "HTTP-Referer", + "https://github.com/theonlyhennygod/zeroclaw", + ) + .header("X-Title", "ZeroClaw") + .json(&native_request) + .send() + .await?; + + if !response.status().is_success() { + return Err(super::api_error("OpenRouter", response).await); + } + + let native_response: NativeChatResponse = response.json().await?; + let message = native_response + .choices + .into_iter() + .next() + .map(|c| c.message) + .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))?; + Ok(Self::parse_native_response(message)) + } + + fn supports_native_tools(&self) -> bool { + true + } } #[cfg(test)] diff --git a/src/providers/reliable.rs b/src/providers/reliable.rs index 3494a41..9782ec4 100644 --- a/src/providers/reliable.rs +++ b/src/providers/reliable.rs @@ -1,4 +1,4 @@ -use super::traits::{ChatMessage, ChatResponse}; +use super::traits::ChatMessage; use super::Provider; use async_trait::async_trait; use std::collections::HashMap; @@ -156,7 +156,7 @@ impl Provider for ReliableProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let models = self.model_chain(model); let mut failures = Vec::new(); @@ -254,7 +254,7 @@ impl Provider for ReliableProvider { messages: &[ChatMessage], model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let models = self.model_chain(model); let mut failures = Vec::new(); @@ -359,12 +359,12 @@ mod tests { _message: &str, _model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if attempt <= self.fail_until_attempt { anyhow::bail!(self.error); } - Ok(ChatResponse::with_text(self.response)) + Ok(self.response.to_string()) } async fn chat_with_history( @@ -372,12 +372,12 @@ mod tests { _messages: &[ChatMessage], _model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if attempt <= self.fail_until_attempt { anyhow::bail!(self.error); } - Ok(ChatResponse::with_text(self.response)) + Ok(self.response.to_string()) } } @@ -397,13 +397,13 @@ mod tests { _message: &str, model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { self.calls.fetch_add(1, Ordering::SeqCst); self.models_seen.lock().unwrap().push(model.to_string()); if self.fail_models.contains(&model) { anyhow::bail!("500 model {} unavailable", model); } - Ok(ChatResponse::with_text(self.response)) + Ok(self.response.to_string()) } } @@ -426,8 +426,8 @@ mod tests { 1, ); - let result = provider.chat("hello", "test", 0.0).await.unwrap(); - assert_eq!(result.text_or_empty(), "ok"); + let result = provider.simple_chat("hello", "test", 0.0).await.unwrap(); + assert_eq!(result, "ok"); assert_eq!(calls.load(Ordering::SeqCst), 1); } @@ -448,8 +448,8 @@ mod tests { 1, ); - let result = provider.chat("hello", "test", 0.0).await.unwrap(); - assert_eq!(result.text_or_empty(), "recovered"); + let result = provider.simple_chat("hello", "test", 0.0).await.unwrap(); + assert_eq!(result, "recovered"); assert_eq!(calls.load(Ordering::SeqCst), 2); } @@ -483,8 +483,8 @@ mod tests { 1, ); - let result = provider.chat("hello", "test", 0.0).await.unwrap(); - assert_eq!(result.text_or_empty(), "from fallback"); + let result = provider.simple_chat("hello", "test", 0.0).await.unwrap(); + assert_eq!(result, "from fallback"); assert_eq!(primary_calls.load(Ordering::SeqCst), 2); assert_eq!(fallback_calls.load(Ordering::SeqCst), 1); } @@ -517,7 +517,7 @@ mod tests { ); let err = provider - .chat("hello", "test", 0.0) + .simple_chat("hello", "test", 0.0) .await .expect_err("all providers should fail"); let msg = err.to_string(); @@ -572,8 +572,8 @@ mod tests { 1, ); - let result = provider.chat("hello", "test", 0.0).await.unwrap(); - assert_eq!(result.text_or_empty(), "from fallback"); + let result = provider.simple_chat("hello", "test", 0.0).await.unwrap(); + assert_eq!(result, "from fallback"); // Primary should have been called only once (no retries) assert_eq!(primary_calls.load(Ordering::SeqCst), 1); assert_eq!(fallback_calls.load(Ordering::SeqCst), 1); @@ -601,7 +601,7 @@ mod tests { .chat_with_history(&messages, "test", 0.0) .await .unwrap(); - assert_eq!(result.text_or_empty(), "history ok"); + assert_eq!(result, "history ok"); assert_eq!(calls.load(Ordering::SeqCst), 2); } @@ -640,7 +640,7 @@ mod tests { .chat_with_history(&messages, "test", 0.0) .await .unwrap(); - assert_eq!(result.text_or_empty(), "fallback ok"); + assert_eq!(result, "fallback ok"); assert_eq!(primary_calls.load(Ordering::SeqCst), 2); assert_eq!(fallback_calls.load(Ordering::SeqCst), 1); } @@ -827,7 +827,7 @@ mod tests { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { self.as_ref() .chat_with_system(system_prompt, message, model, temperature) .await diff --git a/src/providers/router.rs b/src/providers/router.rs index eb3101f..ccbdffb 100644 --- a/src/providers/router.rs +++ b/src/providers/router.rs @@ -1,4 +1,4 @@ -use super::traits::{ChatMessage, ChatResponse}; +use super::traits::{ChatMessage, ChatRequest, ChatResponse}; use super::Provider; use async_trait::async_trait; use std::collections::HashMap; @@ -98,7 +98,7 @@ impl Provider for RouterProvider { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let (provider_idx, resolved_model) = self.resolve(model); let (provider_name, provider) = &self.providers[provider_idx]; @@ -118,7 +118,7 @@ impl Provider for RouterProvider { messages: &[ChatMessage], model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let (provider_idx, resolved_model) = self.resolve(model); let (_, provider) = &self.providers[provider_idx]; provider @@ -126,6 +126,24 @@ impl Provider for RouterProvider { .await } + async fn chat( + &self, + request: ChatRequest<'_>, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let (provider_idx, resolved_model) = self.resolve(model); + let (_, provider) = &self.providers[provider_idx]; + provider.chat(request, &resolved_model, temperature).await + } + + fn supports_native_tools(&self) -> bool { + self.providers + .get(self.default_index) + .map(|(_, p)| p.supports_native_tools()) + .unwrap_or(false) + } + async fn warmup(&self) -> anyhow::Result<()> { for (name, provider) in &self.providers { tracing::info!(provider = name, "Warming up routed provider"); @@ -175,10 +193,10 @@ mod tests { _message: &str, model: &str, _temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { self.calls.fetch_add(1, Ordering::SeqCst); *self.last_model.lock().unwrap() = model.to_string(); - Ok(ChatResponse::with_text(self.response)) + Ok(self.response.to_string()) } } @@ -229,7 +247,7 @@ mod tests { message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { self.as_ref() .chat_with_system(system_prompt, message, model, temperature) .await @@ -246,8 +264,11 @@ mod tests { ], ); - let result = router.chat("hello", "hint:reasoning", 0.5).await.unwrap(); - assert_eq!(result.text_or_empty(), "smart-response"); + let result = router + .simple_chat("hello", "hint:reasoning", 0.5) + .await + .unwrap(); + assert_eq!(result, "smart-response"); assert_eq!(mocks[1].call_count(), 1); assert_eq!(mocks[1].last_model(), "claude-opus"); assert_eq!(mocks[0].call_count(), 0); @@ -260,8 +281,8 @@ mod tests { vec![("fast", "fast", "llama-3-70b")], ); - let result = router.chat("hello", "hint:fast", 0.5).await.unwrap(); - assert_eq!(result.text_or_empty(), "fast-response"); + let result = router.simple_chat("hello", "hint:fast", 0.5).await.unwrap(); + assert_eq!(result, "fast-response"); assert_eq!(mocks[0].call_count(), 1); assert_eq!(mocks[0].last_model(), "llama-3-70b"); } @@ -273,8 +294,11 @@ mod tests { vec![], ); - let result = router.chat("hello", "hint:nonexistent", 0.5).await.unwrap(); - assert_eq!(result.text_or_empty(), "default-response"); + let result = router + .simple_chat("hello", "hint:nonexistent", 0.5) + .await + .unwrap(); + assert_eq!(result, "default-response"); assert_eq!(mocks[0].call_count(), 1); // Falls back to default with the hint as model name assert_eq!(mocks[0].last_model(), "hint:nonexistent"); @@ -291,10 +315,10 @@ mod tests { ); let result = router - .chat("hello", "anthropic/claude-sonnet-4-20250514", 0.5) + .simple_chat("hello", "anthropic/claude-sonnet-4-20250514", 0.5) .await .unwrap(); - assert_eq!(result.text_or_empty(), "primary-response"); + assert_eq!(result, "primary-response"); assert_eq!(mocks[0].call_count(), 1); assert_eq!(mocks[0].last_model(), "anthropic/claude-sonnet-4-20250514"); } @@ -355,7 +379,7 @@ mod tests { .chat_with_system(Some("system"), "hello", "model", 0.5) .await .unwrap(); - assert_eq!(result.text_or_empty(), "response"); + assert_eq!(result, "response"); assert_eq!(mock.call_count(), 1); } } diff --git a/src/providers/traits.rs b/src/providers/traits.rs index d1f8dd1..fdbd5cc 100644 --- a/src/providers/traits.rs +++ b/src/providers/traits.rs @@ -1,3 +1,4 @@ +use crate::tools::ToolSpec; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -29,6 +30,13 @@ impl ChatMessage { content: content.into(), } } + + pub fn tool(content: impl Into) -> Self { + Self { + role: "tool".into(), + content: content.into(), + } + } } /// A tool call requested by the LLM. @@ -49,14 +57,6 @@ pub struct ChatResponse { } impl ChatResponse { - /// Convenience: construct a plain text response with no tool calls. - pub fn with_text(text: impl Into) -> Self { - Self { - text: Some(text.into()), - tool_calls: vec![], - } - } - /// True when the LLM wants to invoke at least one tool. pub fn has_tool_calls(&self) -> bool { !self.tool_calls.is_empty() @@ -68,6 +68,13 @@ impl ChatResponse { } } +/// Request payload for provider chat calls. +#[derive(Debug, Clone, Copy)] +pub struct ChatRequest<'a> { + pub messages: &'a [ChatMessage], + pub tools: Option<&'a [ToolSpec]>, +} + /// A tool result to feed back to the LLM. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolResultMessage { @@ -77,7 +84,7 @@ pub struct ToolResultMessage { /// A message in a multi-turn conversation, including tool interactions. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] +#[serde(tag = "type", content = "data")] pub enum ConversationMessage { /// Regular chat message (system, user, assistant). Chat(ChatMessage), @@ -86,29 +93,34 @@ pub enum ConversationMessage { text: Option, tool_calls: Vec, }, - /// Result of a tool execution, fed back to the LLM. - ToolResult(ToolResultMessage), + /// Results of tool executions, fed back to the LLM. + ToolResults(Vec), } #[async_trait] pub trait Provider: Send + Sync { - async fn chat( + /// Simple one-shot chat (single user message, no explicit system prompt). + /// + /// This is the preferred API for non-agentic direct interactions. + async fn simple_chat( &self, message: &str, model: &str, temperature: f64, - ) -> anyhow::Result { - self.chat_with_system(None, message, model, temperature) - .await + ) -> anyhow::Result { + self.chat_with_system(None, message, model, temperature).await } + /// One-shot chat with optional system prompt. + /// + /// Kept for compatibility and advanced one-shot prompting. async fn chat_with_system( &self, system_prompt: Option<&str>, message: &str, model: &str, temperature: f64, - ) -> anyhow::Result; + ) -> anyhow::Result; /// Multi-turn conversation. Default implementation extracts the last user /// message and delegates to `chat_with_system`. @@ -117,7 +129,7 @@ pub trait Provider: Send + Sync { messages: &[ChatMessage], model: &str, temperature: f64, - ) -> anyhow::Result { + ) -> anyhow::Result { let system = messages .iter() .find(|m| m.role == "system") @@ -131,6 +143,27 @@ pub trait Provider: Send + Sync { .await } + /// Structured chat API for agent loop callers. + async fn chat( + &self, + request: ChatRequest<'_>, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let text = self + .chat_with_history(request.messages, model, temperature) + .await?; + Ok(ChatResponse { + text: Some(text), + tool_calls: Vec::new(), + }) + } + + /// Whether provider supports native tool calls over API. + fn supports_native_tools(&self) -> bool { + false + } + /// Warm up the HTTP connection pool (TLS handshake, DNS, HTTP/2 setup). /// Default implementation is a no-op; providers with HTTP clients should override. async fn warmup(&self) -> anyhow::Result<()> { @@ -153,6 +186,9 @@ mod tests { let asst = ChatMessage::assistant("Hi there"); assert_eq!(asst.role, "assistant"); + + let tool = ChatMessage::tool("{}"); + assert_eq!(tool.role, "tool"); } #[test] @@ -194,11 +230,11 @@ mod tests { let json = serde_json::to_string(&chat).unwrap(); assert!(json.contains("\"type\":\"Chat\"")); - let tool_result = ConversationMessage::ToolResult(ToolResultMessage { + let tool_result = ConversationMessage::ToolResults(vec![ToolResultMessage { tool_call_id: "1".into(), content: "done".into(), - }); + }]); let json = serde_json::to_string(&tool_result).unwrap(); - assert!(json.contains("\"type\":\"ToolResult\"")); + assert!(json.contains("\"type\":\"ToolResults\"")); } } diff --git a/src/tools/delegate.rs b/src/tools/delegate.rs index f205a58..7f30b64 100644 --- a/src/tools/delegate.rs +++ b/src/tools/delegate.rs @@ -221,14 +221,9 @@ impl Tool for DelegateTool { match result { Ok(response) => { - let has_tool_calls = response.has_tool_calls(); - let mut rendered = response.text.unwrap_or_default(); + let mut rendered = response; if rendered.trim().is_empty() { - if has_tool_calls { - rendered = "[Tool-only response; no text content]".to_string(); - } else { - rendered = "[Empty response]".to_string(); - } + rendered = "[Empty response]".to_string(); } Ok(ToolResult { From dc5e14d7d2e88b9ef9c0d8c6d23b2d8847f910f3 Mon Sep 17 00:00:00 2001 From: mai1015 Date: Mon, 16 Feb 2026 03:35:03 -0500 Subject: [PATCH 29/32] refactor: improve code formatting and structure across multiple files --- src/agent/agent.rs | 15 ++++++--------- src/agent/mod.rs | 1 + src/providers/anthropic.rs | 5 ++++- src/providers/openrouter.rs | 6 ++++-- src/providers/traits.rs | 3 ++- src/tools/mod.rs | 10 +++++++--- 6 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/agent/agent.rs b/src/agent/agent.rs index 8f9331e..ce150d0 100644 --- a/src/agent/agent.rs +++ b/src/agent/agent.rs @@ -286,7 +286,7 @@ impl Agent { for msg in self.history.drain(..) { match &msg { ConversationMessage::Chat(chat) if chat.role == "system" => { - system_messages.push(msg) + system_messages.push(msg); } _ => other_messages.push(msg), } @@ -655,7 +655,7 @@ mod tests { let provider = Box::new(MockProvider { responses: Mutex::new(vec![ crate::providers::ChatResponse { - text: Some("".into()), + text: Some(String::new()), tool_calls: vec![crate::providers::ToolCall { id: "tc1".into(), name: "echo".into(), @@ -690,12 +690,9 @@ mod tests { let response = agent.turn("hi").await.unwrap(); assert_eq!(response, "done"); - assert!(matches!( - agent - .history() - .iter() - .find(|msg| matches!(msg, ConversationMessage::ToolResults(_))), - Some(_) - )); + assert!(agent + .history() + .iter() + .any(|msg| matches!(msg, ConversationMessage::ToolResults(_)))); } } diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 63bf3f8..89406ef 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,3 +1,4 @@ +#[allow(clippy::module_inception)] pub mod agent; pub mod dispatcher; pub mod loop_; diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index 56efeb8..fb940e9 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -72,7 +72,10 @@ enum NativeContentOut { input: serde_json::Value, }, #[serde(rename = "tool_result")] - ToolResult { tool_use_id: String, content: String }, + ToolResult { + tool_use_id: String, + content: String, + }, } #[derive(Debug, Serialize)] diff --git a/src/providers/openrouter.rs b/src/providers/openrouter.rs index 5363651..3a02e2d 100644 --- a/src/providers/openrouter.rs +++ b/src/providers/openrouter.rs @@ -356,9 +356,11 @@ impl Provider for OpenRouterProvider { model: &str, temperature: f64, ) -> anyhow::Result { - let api_key = self.api_key.as_ref().ok_or_else(|| anyhow::anyhow!( + let api_key = self.api_key.as_ref().ok_or_else(|| { + anyhow::anyhow!( "OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var." - ))?; + ) + })?; let tools = Self::convert_tools(request.tools); let native_request = NativeChatRequest { diff --git a/src/providers/traits.rs b/src/providers/traits.rs index fdbd5cc..2117e57 100644 --- a/src/providers/traits.rs +++ b/src/providers/traits.rs @@ -108,7 +108,8 @@ pub trait Provider: Send + Sync { model: &str, temperature: f64, ) -> anyhow::Result { - self.chat_with_system(None, message, model, temperature).await + self.chat_with_system(None, message, model, temperature) + .await } /// One-shot chat with optional system prompt. diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 0a7a2bf..67c05a3 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -74,7 +74,7 @@ pub fn all_tools( browser_config: &crate::config::BrowserConfig, http_config: &crate::config::HttpRequestConfig, workspace_dir: &std::path::Path, - agents: &HashMap, + agents: &HashMap, fallback_api_key: Option<&str>, config: &crate::config::Config, ) -> Vec> { @@ -104,7 +104,7 @@ pub fn all_tools_with_runtime( browser_config: &crate::config::BrowserConfig, http_config: &crate::config::HttpRequestConfig, workspace_dir: &std::path::Path, - agents: &HashMap, + agents: &HashMap, fallback_api_key: Option<&str>, config: &crate::config::Config, ) -> Vec> { @@ -170,8 +170,12 @@ pub fn all_tools_with_runtime( // Add delegation tool when agents are configured if !agents.is_empty() { + let delegate_agents: HashMap = agents + .iter() + .map(|(name, cfg)| (name.clone(), cfg.clone())) + .collect(); tools.push(Box::new(DelegateTool::new( - agents.clone(), + delegate_agents, fallback_api_key.map(String::from), ))); } From b2dd3582a4ca3278f0c1344e284ab42ada10d9ae Mon Sep 17 00:00:00 2001 From: Chummy Date: Mon, 16 Feb 2026 23:41:48 +0800 Subject: [PATCH 30/32] fix(ci): align reliable tests with simple_chat contract --- src/providers/reliable.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/providers/reliable.rs b/src/providers/reliable.rs index 9782ec4..41a0a1a 100644 --- a/src/providers/reliable.rs +++ b/src/providers/reliable.rs @@ -670,8 +670,11 @@ mod tests { ) .with_model_fallbacks(fallbacks); - let result = provider.chat("hello", "claude-opus", 0.0).await.unwrap(); - assert_eq!(result.text_or_empty(), "ok from sonnet"); + let result = provider + .simple_chat("hello", "claude-opus", 0.0) + .await + .unwrap(); + assert_eq!(result, "ok from sonnet"); let seen = mock.models_seen.lock().unwrap(); assert_eq!(seen.len(), 2); @@ -703,7 +706,7 @@ mod tests { .with_model_fallbacks(fallbacks); let err = provider - .chat("hello", "model-a", 0.0) + .simple_chat("hello", "model-a", 0.0) .await .expect_err("all models should fail"); assert!(err.to_string().contains("All providers/models failed")); @@ -729,8 +732,8 @@ mod tests { 1, ); // No model_fallbacks set — should work exactly as before - let result = provider.chat("hello", "test", 0.0).await.unwrap(); - assert_eq!(result.text_or_empty(), "ok"); + let result = provider.simple_chat("hello", "test", 0.0).await.unwrap(); + assert_eq!(result, "ok"); assert_eq!(calls.load(Ordering::SeqCst), 1); } From 413ecfd1433548720a3774ae767d0fb1d223e135 Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 17 Feb 2026 00:28:28 +0800 Subject: [PATCH 31/32] fix(rebase): resolve main drift and restore CI contracts --- src/agent/agent.rs | 7 +++++++ src/gateway/mod.rs | 1 - src/tools/mod.rs | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/agent/agent.rs b/src/agent/agent.rs index ce150d0..45b4d54 100644 --- a/src/agent/agent.rs +++ b/src/agent/agent.rs @@ -219,17 +219,24 @@ impl Agent { } else { None }; + let composio_entity_id = if config.composio.enabled { + Some(config.composio.entity_id.as_str()) + } else { + None + }; let tools = tools::all_tools_with_runtime( &security, runtime, memory.clone(), composio_key, + composio_entity_id, &config.browser, &config.http_request, &config.workspace_dir, &config.agents, config.api_key.as_deref(), + config, ); let provider_name = config.default_provider.as_deref().unwrap_or("openrouter"); diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 580fe4b..9c97fe6 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -219,7 +219,6 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { config.api_key.as_deref(), )?); - // Extract webhook secret for authentication let webhook_secret: Option> = config .channels_config diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 67c05a3..fcf8fa5 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -74,7 +74,7 @@ pub fn all_tools( browser_config: &crate::config::BrowserConfig, http_config: &crate::config::HttpRequestConfig, workspace_dir: &std::path::Path, - agents: &HashMap, + agents: &HashMap, fallback_api_key: Option<&str>, config: &crate::config::Config, ) -> Vec> { @@ -104,7 +104,7 @@ pub fn all_tools_with_runtime( browser_config: &crate::config::BrowserConfig, http_config: &crate::config::HttpRequestConfig, workspace_dir: &std::path::Path, - agents: &HashMap, + agents: &HashMap, fallback_api_key: Option<&str>, config: &crate::config::Config, ) -> Vec> { From e005b6d9e4bfb7cb31d6912bc907a4da6f9691c0 Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 17 Feb 2026 00:56:06 +0800 Subject: [PATCH 32/32] fix(rebase): unify agent config and remove duplicate fields --- src/config/schema.rs | 31 +++++++------------------------ src/onboard/wizard.rs | 2 -- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/src/config/schema.rs b/src/config/schema.rs index 5183b81..4f8056d 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -80,10 +80,6 @@ pub struct Config { #[serde(default)] pub peripherals: PeripheralsConfig, - /// Agent context limits — use compact for smaller models (e.g. 13B with 4k–8k context). - #[serde(default)] - pub agent: AgentConfig, - /// Delegate agent configurations for multi-agent workflows. #[serde(default)] pub agents: HashMap, @@ -93,23 +89,6 @@ pub struct Config { pub hardware: HardwareConfig, } -// ── Agent (context limits for smaller models) ──────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentConfig { - /// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models. - #[serde(default)] - pub compact_context: bool, -} - -impl Default for AgentConfig { - fn default() -> Self { - Self { - compact_context: false, - } - } -} - // ── Delegate Agents ────────────────────────────────────────────── /// Configuration for a delegate sub-agent used by the `delegate` tool. @@ -214,6 +193,9 @@ impl Default for HardwareConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentConfig { + /// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models. + #[serde(default)] + pub compact_context: bool, #[serde(default = "default_agent_max_tool_iterations")] pub max_tool_iterations: usize, #[serde(default = "default_agent_max_history_messages")] @@ -239,6 +221,7 @@ fn default_agent_tool_dispatcher() -> String { impl Default for AgentConfig { fn default() -> Self { Self { + compact_context: false, max_tool_iterations: default_agent_max_tool_iterations(), max_history_messages: default_agent_max_history_messages(), parallel_tools: false, @@ -1559,7 +1542,6 @@ impl Default for Config { identity: IdentityConfig::default(), cost: CostConfig::default(), peripherals: PeripheralsConfig::default(), - agent: AgentConfig::default(), agents: HashMap::new(), hardware: HardwareConfig::default(), } @@ -1916,7 +1898,6 @@ mod tests { identity: IdentityConfig::default(), cost: CostConfig::default(), peripherals: PeripheralsConfig::default(), - agent: AgentConfig::default(), agents: HashMap::new(), hardware: HardwareConfig::default(), }; @@ -1965,6 +1946,7 @@ default_temperature = 0.7 #[test] fn agent_config_defaults() { let cfg = AgentConfig::default(); + assert!(!cfg.compact_context); assert_eq!(cfg.max_tool_iterations, 10); assert_eq!(cfg.max_history_messages, 50); assert!(!cfg.parallel_tools); @@ -1976,12 +1958,14 @@ default_temperature = 0.7 let raw = r#" default_temperature = 0.7 [agent] +compact_context = true max_tool_iterations = 20 max_history_messages = 80 parallel_tools = true tool_dispatcher = "xml" "#; let parsed: Config = toml::from_str(raw).unwrap(); + assert!(parsed.agent.compact_context); assert_eq!(parsed.agent.max_tool_iterations, 20); assert_eq!(parsed.agent.max_history_messages, 80); assert!(parsed.agent.parallel_tools); @@ -2021,7 +2005,6 @@ tool_dispatcher = "xml" identity: IdentityConfig::default(), cost: CostConfig::default(), peripherals: PeripheralsConfig::default(), - agent: AgentConfig::default(), agents: HashMap::new(), hardware: HardwareConfig::default(), }; diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 2deee91..b8b3c58 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -128,7 +128,6 @@ pub fn run_wizard() -> Result { identity: crate::config::IdentityConfig::default(), cost: crate::config::CostConfig::default(), peripherals: crate::config::PeripheralsConfig::default(), - agent: crate::config::AgentConfig::default(), agents: std::collections::HashMap::new(), hardware: hardware_config, }; @@ -333,7 +332,6 @@ pub fn run_quick_setup( identity: crate::config::IdentityConfig::default(), cost: crate::config::CostConfig::default(), peripherals: crate::config::PeripheralsConfig::default(), - agent: crate::config::AgentConfig::default(), agents: std::collections::HashMap::new(), hardware: crate::config::HardwareConfig::default(), };