style: cargo fmt — fix all formatting for CI
Ran cargo fmt across entire codebase to pass CI's cargo fmt --check. No logic changes, only whitespace/formatting.
This commit is contained in:
parent
a5887ad2dc
commit
bc31e4389b
24 changed files with 613 additions and 242 deletions
|
|
@ -46,8 +46,10 @@ pub async fn run(
|
|||
));
|
||||
|
||||
// ── Memory (the brain) ────────────────────────────────────────
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(memory::create_memory(&config.memory, &config.workspace_dir)?);
|
||||
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
|
||||
&config.memory,
|
||||
&config.workspace_dir,
|
||||
)?);
|
||||
tracing::info!(backend = mem.name(), "Memory initialized");
|
||||
|
||||
// ── Tools (including memory tools) ────────────────────────────
|
||||
|
|
|
|||
|
|
@ -39,8 +39,7 @@ impl DiscordChannel {
|
|||
}
|
||||
}
|
||||
|
||||
const BASE64_ALPHABET: &[u8] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const BASE64_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/// Minimal base64 decode (no extra dep) — only needs to decode the user ID portion
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
|
|
@ -155,8 +154,7 @@ impl Channel for DiscordChannel {
|
|||
let (hb_tx, mut hb_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
let hb_interval = heartbeat_interval;
|
||||
tokio::spawn(async move {
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_millis(hb_interval));
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(hb_interval));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if hb_tx.send(()).await.is_err() {
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ impl IMessageChannel {
|
|||
if self.allowed_contacts.iter().any(|u| u == "*") {
|
||||
return true;
|
||||
}
|
||||
self.allowed_contacts.iter().any(|u| {
|
||||
u.eq_ignore_ascii_case(sender)
|
||||
})
|
||||
self.allowed_contacts
|
||||
.iter()
|
||||
.any(|u| u.eq_ignore_ascii_case(sender))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,10 +93,7 @@ impl MatrixChannel {
|
|||
}
|
||||
|
||||
async fn get_my_user_id(&self) -> anyhow::Result<String> {
|
||||
let url = format!(
|
||||
"{}/_matrix/client/v3/account/whoami",
|
||||
self.homeserver
|
||||
);
|
||||
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
|
|
@ -250,10 +247,7 @@ impl Channel for MatrixChannel {
|
|||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
let url = format!(
|
||||
"{}/_matrix/client/v3/account/whoami",
|
||||
self.homeserver
|
||||
);
|
||||
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
|
||||
let Ok(resp) = self
|
||||
.client
|
||||
.get(&url)
|
||||
|
|
@ -413,8 +407,14 @@ mod tests {
|
|||
let room = resp.rooms.join.get("!room:matrix.org").unwrap();
|
||||
assert_eq!(room.timeline.events.len(), 1);
|
||||
assert_eq!(room.timeline.events[0].sender, "@user:matrix.org");
|
||||
assert_eq!(room.timeline.events[0].content.body.as_deref(), Some("Hello!"));
|
||||
assert_eq!(room.timeline.events[0].content.msgtype.as_deref(), Some("m.text"));
|
||||
assert_eq!(
|
||||
room.timeline.events[0].content.body.as_deref(),
|
||||
Some("Hello!")
|
||||
);
|
||||
assert_eq!(
|
||||
room.timeline.events[0].content.msgtype.as_deref(),
|
||||
Some("m.text")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -75,8 +75,15 @@ pub fn build_system_prompt(
|
|||
for skill in skills {
|
||||
let _ = writeln!(prompt, " <skill>");
|
||||
let _ = writeln!(prompt, " <name>{}</name>", skill.name);
|
||||
let _ = writeln!(prompt, " <description>{}</description>", skill.description);
|
||||
let location = workspace_dir.join("skills").join(&skill.name).join("SKILL.md");
|
||||
let _ = writeln!(
|
||||
prompt,
|
||||
" <description>{}</description>",
|
||||
skill.description
|
||||
);
|
||||
let location = workspace_dir
|
||||
.join("skills")
|
||||
.join(&skill.name)
|
||||
.join("SKILL.md");
|
||||
let _ = writeln!(prompt, " <location>{}</location>", location.display());
|
||||
let _ = writeln!(prompt, " </skill>");
|
||||
}
|
||||
|
|
@ -84,11 +91,16 @@ pub fn build_system_prompt(
|
|||
}
|
||||
|
||||
// ── 4. Workspace ────────────────────────────────────────────
|
||||
let _ = writeln!(prompt, "## Workspace\n\nWorking directory: `{}`\n", workspace_dir.display());
|
||||
let _ = writeln!(
|
||||
prompt,
|
||||
"## Workspace\n\nWorking directory: `{}`\n",
|
||||
workspace_dir.display()
|
||||
);
|
||||
|
||||
// ── 5. Bootstrap files (injected into context) ──────────────
|
||||
prompt.push_str("## Project Context\n\n");
|
||||
prompt.push_str("The following workspace files define your identity, behavior, and context.\n\n");
|
||||
prompt
|
||||
.push_str("The following workspace files define your identity, behavior, and context.\n\n");
|
||||
|
||||
let bootstrap_files = [
|
||||
"AGENTS.md",
|
||||
|
|
@ -118,8 +130,8 @@ pub fn build_system_prompt(
|
|||
let _ = writeln!(prompt, "## Current Date & Time\n\nTimezone: {tz}\n");
|
||||
|
||||
// ── 7. Runtime ──────────────────────────────────────────────
|
||||
let host = hostname::get()
|
||||
.map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
|
||||
let host =
|
||||
hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
|
||||
let _ = writeln!(
|
||||
prompt,
|
||||
"## Runtime\n\nHost: {host} | OS: {} | Model: {model_name}\n",
|
||||
|
|
@ -180,10 +192,7 @@ pub fn handle_command(command: super::ChannelCommands, config: &Config) -> Resul
|
|||
("iMessage", config.channels_config.imessage.is_some()),
|
||||
("Matrix", config.channels_config.matrix.is_some()),
|
||||
] {
|
||||
println!(
|
||||
" {} {name}",
|
||||
if configured { "✅" } else { "❌" }
|
||||
);
|
||||
println!(" {} {name}", if configured { "✅" } else { "❌" });
|
||||
}
|
||||
println!("\nTo start channels: zeroclaw channel start");
|
||||
println!("To configure: zeroclaw onboard");
|
||||
|
|
@ -193,7 +202,9 @@ pub fn handle_command(command: super::ChannelCommands, config: &Config) -> Resul
|
|||
channel_type,
|
||||
config: _,
|
||||
} => {
|
||||
anyhow::bail!("Channel type '{channel_type}' — use `zeroclaw onboard` to configure channels");
|
||||
anyhow::bail!(
|
||||
"Channel type '{channel_type}' — use `zeroclaw onboard` to configure channels"
|
||||
);
|
||||
}
|
||||
super::ChannelCommands::Remove { name } => {
|
||||
anyhow::bail!("Remove channel '{name}' — edit ~/.zeroclaw/config.toml directly");
|
||||
|
|
@ -213,8 +224,10 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
.clone()
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
|
||||
let temperature = config.default_temperature;
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(memory::create_memory(&config.memory, &config.workspace_dir)?);
|
||||
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
|
||||
&config.memory,
|
||||
&config.workspace_dir,
|
||||
)?);
|
||||
|
||||
// Build system prompt from workspace identity files + skills
|
||||
let workspace = config.workspace_dir.clone();
|
||||
|
|
@ -233,7 +246,14 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
let system_prompt = build_system_prompt(&workspace, &model, &tool_descs, &skills);
|
||||
|
||||
if !skills.is_empty() {
|
||||
println!(" 🧩 Skills: {}", skills.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(", "));
|
||||
println!(
|
||||
" 🧩 Skills: {}",
|
||||
skills
|
||||
.iter()
|
||||
.map(|s| s.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
// Collect active channels
|
||||
|
|
@ -263,9 +283,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
}
|
||||
|
||||
if let Some(ref im) = config.channels_config.imessage {
|
||||
channels.push(Arc::new(IMessageChannel::new(
|
||||
im.allowed_contacts.clone(),
|
||||
)));
|
||||
channels.push(Arc::new(IMessageChannel::new(im.allowed_contacts.clone())));
|
||||
}
|
||||
|
||||
if let Some(ref mx) = config.channels_config.matrix {
|
||||
|
|
@ -284,8 +302,19 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
|
||||
println!("🦀 ZeroClaw Channel Server");
|
||||
println!(" 🤖 Model: {model}");
|
||||
println!(" 🧠 Memory: {} (auto-save: {})", config.memory.backend, if config.memory.auto_save { "on" } else { "off" });
|
||||
println!(" 📡 Channels: {}", channels.iter().map(|c| c.name()).collect::<Vec<_>>().join(", "));
|
||||
println!(
|
||||
" 🧠 Memory: {} (auto-save: {})",
|
||||
config.memory.backend,
|
||||
if config.memory.auto_save { "on" } else { "off" }
|
||||
);
|
||||
println!(
|
||||
" 📡 Channels: {}",
|
||||
channels
|
||||
.iter()
|
||||
.map(|c| c.name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
println!();
|
||||
println!(" Listening for messages... (Ctrl+C to stop)");
|
||||
println!();
|
||||
|
|
@ -331,7 +360,10 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
}
|
||||
|
||||
// Call the LLM with system prompt (identity + soul + tools)
|
||||
match provider.chat_with_system(Some(&system_prompt), &msg.content, &model, temperature).await {
|
||||
match provider
|
||||
.chat_with_system(Some(&system_prompt), &msg.content, &model, temperature)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
println!(
|
||||
" 🤖 Reply: {}",
|
||||
|
|
@ -355,9 +387,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
eprintln!(" ❌ LLM error: {e}");
|
||||
for ch in &channels {
|
||||
if ch.name() == msg.channel {
|
||||
let _ = ch
|
||||
.send(&format!("⚠️ Error: {e}"), &msg.sender)
|
||||
.await;
|
||||
let _ = ch.send(&format!("⚠️ Error: {e}"), &msg.sender).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -384,9 +414,17 @@ mod tests {
|
|||
std::fs::write(tmp.path().join("SOUL.md"), "# Soul\nBe helpful.").unwrap();
|
||||
std::fs::write(tmp.path().join("IDENTITY.md"), "# Identity\nName: ZeroClaw").unwrap();
|
||||
std::fs::write(tmp.path().join("USER.md"), "# User\nName: Test User").unwrap();
|
||||
std::fs::write(tmp.path().join("AGENTS.md"), "# Agents\nFollow instructions.").unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("AGENTS.md"),
|
||||
"# Agents\nFollow instructions.",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(tmp.path().join("TOOLS.md"), "# Tools\nUse shell carefully.").unwrap();
|
||||
std::fs::write(tmp.path().join("HEARTBEAT.md"), "# Heartbeat\nCheck status.").unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("HEARTBEAT.md"),
|
||||
"# Heartbeat\nCheck status.",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(tmp.path().join("MEMORY.md"), "# Memory\nUser likes Rust.").unwrap();
|
||||
tmp
|
||||
}
|
||||
|
|
@ -401,15 +439,24 @@ mod tests {
|
|||
assert!(prompt.contains("## Tools"), "missing Tools section");
|
||||
assert!(prompt.contains("## Safety"), "missing Safety section");
|
||||
assert!(prompt.contains("## Workspace"), "missing Workspace section");
|
||||
assert!(prompt.contains("## Project Context"), "missing Project Context");
|
||||
assert!(prompt.contains("## Current Date & Time"), "missing Date/Time");
|
||||
assert!(
|
||||
prompt.contains("## Project Context"),
|
||||
"missing Project Context"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("## Current Date & Time"),
|
||||
"missing Date/Time"
|
||||
);
|
||||
assert!(prompt.contains("## Runtime"), "missing Runtime section");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_injects_tools() {
|
||||
let ws = make_workspace();
|
||||
let tools = vec![("shell", "Run commands"), ("memory_recall", "Search memory")];
|
||||
let tools = vec![
|
||||
("shell", "Run commands"),
|
||||
("memory_recall", "Search memory"),
|
||||
];
|
||||
let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[]);
|
||||
|
||||
assert!(prompt.contains("**shell**"));
|
||||
|
|
@ -435,7 +482,10 @@ mod tests {
|
|||
assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header");
|
||||
assert!(prompt.contains("Be helpful"), "missing SOUL content");
|
||||
assert!(prompt.contains("### IDENTITY.md"), "missing IDENTITY.md");
|
||||
assert!(prompt.contains("Name: ZeroClaw"), "missing IDENTITY content");
|
||||
assert!(
|
||||
prompt.contains("Name: ZeroClaw"),
|
||||
"missing IDENTITY content"
|
||||
);
|
||||
assert!(prompt.contains("### USER.md"), "missing USER.md");
|
||||
assert!(prompt.contains("### AGENTS.md"), "missing AGENTS.md");
|
||||
assert!(prompt.contains("### TOOLS.md"), "missing TOOLS.md");
|
||||
|
|
@ -460,12 +510,18 @@ mod tests {
|
|||
let ws = make_workspace();
|
||||
// No BOOTSTRAP.md — should not appear
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[]);
|
||||
assert!(!prompt.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should not appear when missing");
|
||||
assert!(
|
||||
!prompt.contains("### BOOTSTRAP.md"),
|
||||
"BOOTSTRAP.md should not appear when missing"
|
||||
);
|
||||
|
||||
// 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", &[], &[]);
|
||||
assert!(prompt2.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should appear when present");
|
||||
assert!(
|
||||
prompt2.contains("### BOOTSTRAP.md"),
|
||||
"BOOTSTRAP.md should appear when present"
|
||||
);
|
||||
assert!(prompt2.contains("First run"));
|
||||
}
|
||||
|
||||
|
|
@ -475,13 +531,23 @@ mod tests {
|
|||
let memory_dir = ws.path().join("memory");
|
||||
std::fs::create_dir_all(&memory_dir).unwrap();
|
||||
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||||
std::fs::write(memory_dir.join(format!("{today}.md")), "# Daily\nSome note.").unwrap();
|
||||
std::fs::write(
|
||||
memory_dir.join(format!("{today}.md")),
|
||||
"# Daily\nSome note.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[]);
|
||||
|
||||
// Daily notes should NOT be in the system prompt (on-demand via tools)
|
||||
assert!(!prompt.contains("Daily Notes"), "daily notes should not be auto-injected");
|
||||
assert!(!prompt.contains("Some note"), "daily content should not be in prompt");
|
||||
assert!(
|
||||
!prompt.contains("Daily Notes"),
|
||||
"daily notes should not be auto-injected"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("Some note"),
|
||||
"daily content should not be in prompt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -513,7 +579,10 @@ mod tests {
|
|||
assert!(prompt.contains("<name>code-review</name>"));
|
||||
assert!(prompt.contains("<description>Review code for bugs</description>"));
|
||||
assert!(prompt.contains("SKILL.md</location>"));
|
||||
assert!(prompt.contains("loaded on demand"), "should mention on-demand loading");
|
||||
assert!(
|
||||
prompt.contains("loaded on demand"),
|
||||
"should mention on-demand loading"
|
||||
);
|
||||
// Full prompt content should NOT be dumped
|
||||
assert!(!prompt.contains("Long prompt content that should NOT appear"));
|
||||
}
|
||||
|
|
@ -527,8 +596,14 @@ mod tests {
|
|||
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[]);
|
||||
|
||||
assert!(prompt.contains("truncated at"), "large files should be truncated");
|
||||
assert!(!prompt.contains(&big_content), "full content should not appear");
|
||||
assert!(
|
||||
prompt.contains("truncated at"),
|
||||
"large files should be truncated"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains(&big_content),
|
||||
"full content should not appear"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -539,7 +614,10 @@ mod tests {
|
|||
let prompt = build_system_prompt(ws.path(), "model", &[], &[]);
|
||||
|
||||
// Empty file should not produce a header
|
||||
assert!(!prompt.contains("### TOOLS.md"), "empty files should be skipped");
|
||||
assert!(
|
||||
!prompt.contains("### TOOLS.md"),
|
||||
"empty files should be skipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -84,10 +84,7 @@ impl Channel for SlackChannel {
|
|||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
|
||||
let mut params = vec![
|
||||
("channel", channel_id.clone()),
|
||||
("limit", "10".to_string()),
|
||||
];
|
||||
let mut params = vec![("channel", channel_id.clone()), ("limit", "10".to_string())];
|
||||
if !last_ts.is_empty() {
|
||||
params.push(("oldest", last_ts.clone()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,9 @@ impl Channel for TelegramChannel {
|
|||
.unwrap_or("unknown");
|
||||
|
||||
if !self.is_user_allowed(username) {
|
||||
tracing::warn!("Telegram: ignoring message from unauthorized user: {username}");
|
||||
tracing::warn!(
|
||||
"Telegram: ignoring message from unauthorized user: {username}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -570,10 +570,7 @@ default_temperature = 0.7
|
|||
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
|
||||
assert!(parsed.imessage.is_some());
|
||||
assert!(parsed.matrix.is_some());
|
||||
assert_eq!(
|
||||
parsed.imessage.unwrap().allowed_contacts,
|
||||
vec!["+1"]
|
||||
);
|
||||
assert_eq!(parsed.imessage.unwrap().allowed_contacts, vec!["+1"]);
|
||||
assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,10 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
|||
.clone()
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
|
||||
let temperature = config.default_temperature;
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(memory::create_memory(&config.memory, &config.workspace_dir)?);
|
||||
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
|
||||
&config.memory,
|
||||
&config.workspace_dir,
|
||||
)?);
|
||||
|
||||
// Extract webhook secret for authentication
|
||||
let webhook_secret: Option<Arc<str>> = config
|
||||
|
|
@ -39,7 +41,9 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
|||
if webhook_secret.is_some() {
|
||||
println!(" 🔒 Webhook authentication: ENABLED (X-Webhook-Secret header required)");
|
||||
} else {
|
||||
println!(" ⚠️ Webhook authentication: DISABLED (set [channels.webhook] secret to enable)");
|
||||
println!(
|
||||
" ⚠️ Webhook authentication: DISABLED (set [channels.webhook] secret to enable)"
|
||||
);
|
||||
}
|
||||
println!(" Press Ctrl+C to stop.\n");
|
||||
|
||||
|
|
@ -64,7 +68,19 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
|||
|
||||
if let [method, path, ..] = parts.as_slice() {
|
||||
tracing::info!("{peer} → {method} {path}");
|
||||
handle_request(&mut stream, method, path, &request, &provider, &model, temperature, &mem, auto_save, secret.as_ref()).await;
|
||||
handle_request(
|
||||
&mut stream,
|
||||
method,
|
||||
path,
|
||||
&request,
|
||||
&provider,
|
||||
&model,
|
||||
temperature,
|
||||
&mem,
|
||||
auto_save,
|
||||
secret.as_ref(),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
let _ = send_response(&mut stream, 400, "Bad Request").await;
|
||||
}
|
||||
|
|
@ -116,14 +132,25 @@ async fn handle_request(
|
|||
match header_val {
|
||||
Some(val) if val == secret.as_ref() => {}
|
||||
_ => {
|
||||
tracing::warn!("Webhook: rejected request — invalid or missing X-Webhook-Secret");
|
||||
tracing::warn!(
|
||||
"Webhook: rejected request — invalid or missing X-Webhook-Secret"
|
||||
);
|
||||
let err = serde_json::json!({"error": "Unauthorized — invalid or missing X-Webhook-Secret header"});
|
||||
let _ = send_json(stream, 401, &err).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
handle_webhook(stream, request, provider, model, temperature, mem, auto_save).await;
|
||||
handle_webhook(
|
||||
stream,
|
||||
request,
|
||||
provider,
|
||||
model,
|
||||
temperature,
|
||||
mem,
|
||||
auto_save,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
_ => {
|
||||
|
|
@ -206,7 +233,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn extract_header_finds_value() {
|
||||
let req = "POST /webhook HTTP/1.1\r\nHost: localhost\r\nX-Webhook-Secret: my-secret\r\n\r\n{}";
|
||||
let req =
|
||||
"POST /webhook HTTP/1.1\r\nHost: localhost\r\nX-Webhook-Secret: my-secret\r\n\r\n{}";
|
||||
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("my-secret"));
|
||||
}
|
||||
|
||||
|
|
@ -244,13 +272,19 @@ mod tests {
|
|||
fn extract_header_colon_in_value() {
|
||||
let req = "POST /webhook HTTP/1.1\r\nAuthorization: Bearer sk-abc:123\r\n\r\n{}";
|
||||
// split_once on ':' means only the first colon splits key/value
|
||||
assert_eq!(extract_header(req, "Authorization"), Some("Bearer sk-abc:123"));
|
||||
assert_eq!(
|
||||
extract_header(req, "Authorization"),
|
||||
Some("Bearer sk-abc:123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_header_different_header() {
|
||||
let req = "POST /webhook HTTP/1.1\r\nContent-Type: application/json\r\nX-Webhook-Secret: mysecret\r\n\r\n{}";
|
||||
assert_eq!(extract_header(req, "Content-Type"), Some("application/json"));
|
||||
assert_eq!(
|
||||
extract_header(req, "Content-Type"),
|
||||
Some("application/json")
|
||||
);
|
||||
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("mysecret"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -130,7 +130,9 @@ fn list_integrations(config: &Config, filter_category: Option<&str>) -> Result<(
|
|||
|
||||
let total = available + active + coming;
|
||||
println!();
|
||||
println!(" {total} integrations: {active} active, {available} available, {coming} coming soon");
|
||||
println!(
|
||||
" {total} integrations: {active} active, {available} available, {coming} coming soon"
|
||||
);
|
||||
println!();
|
||||
println!(" Configure: zeroclaw onboard");
|
||||
println!(" Details: zeroclaw integrations info <name>");
|
||||
|
|
@ -144,9 +146,7 @@ fn show_integration_info(config: &Config, name: &str) -> Result<()> {
|
|||
let name_lower = name.to_lowercase();
|
||||
|
||||
let Some(entry) = entries.iter().find(|e| e.name.to_lowercase() == name_lower) else {
|
||||
anyhow::bail!(
|
||||
"Unknown integration: {name}. Run `zeroclaw integrations list` to see all."
|
||||
);
|
||||
anyhow::bail!("Unknown integration: {name}. Run `zeroclaw integrations list` to see all.");
|
||||
};
|
||||
|
||||
let status = (entry.status_fn)(config);
|
||||
|
|
@ -157,7 +157,12 @@ fn show_integration_info(config: &Config, name: &str) -> Result<()> {
|
|||
};
|
||||
|
||||
println!();
|
||||
println!(" {} {} — {}", icon, console::style(entry.name).white().bold(), entry.description);
|
||||
println!(
|
||||
" {} {} — {}",
|
||||
icon,
|
||||
console::style(entry.name).white().bold(),
|
||||
entry.description
|
||||
);
|
||||
println!(" Category: {}", entry.category.label());
|
||||
println!(" Status: {label}");
|
||||
println!();
|
||||
|
|
|
|||
|
|
@ -161,7 +161,10 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
|
|||
description: "Gemini 2.5 Pro/Flash",
|
||||
category: IntegrationCategory::AiModel,
|
||||
status_fn: |c| {
|
||||
if c.default_model.as_deref().is_some_and(|m| m.starts_with("google/")) {
|
||||
if c.default_model
|
||||
.as_deref()
|
||||
.is_some_and(|m| m.starts_with("google/"))
|
||||
{
|
||||
IntegrationStatus::Active
|
||||
} else {
|
||||
IntegrationStatus::Available
|
||||
|
|
@ -173,7 +176,10 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
|
|||
description: "DeepSeek V3 & R1",
|
||||
category: IntegrationCategory::AiModel,
|
||||
status_fn: |c| {
|
||||
if c.default_model.as_deref().is_some_and(|m| m.starts_with("deepseek/")) {
|
||||
if c.default_model
|
||||
.as_deref()
|
||||
.is_some_and(|m| m.starts_with("deepseek/"))
|
||||
{
|
||||
IntegrationStatus::Active
|
||||
} else {
|
||||
IntegrationStatus::Available
|
||||
|
|
@ -185,7 +191,10 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
|
|||
description: "Grok 3 & 4",
|
||||
category: IntegrationCategory::AiModel,
|
||||
status_fn: |c| {
|
||||
if c.default_model.as_deref().is_some_and(|m| m.starts_with("x-ai/")) {
|
||||
if c.default_model
|
||||
.as_deref()
|
||||
.is_some_and(|m| m.starts_with("x-ai/"))
|
||||
{
|
||||
IntegrationStatus::Active
|
||||
} else {
|
||||
IntegrationStatus::Available
|
||||
|
|
@ -197,7 +206,10 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
|
|||
description: "Mistral Large & Codestral",
|
||||
category: IntegrationCategory::AiModel,
|
||||
status_fn: |c| {
|
||||
if c.default_model.as_deref().is_some_and(|m| m.starts_with("mistral")) {
|
||||
if c.default_model
|
||||
.as_deref()
|
||||
.is_some_and(|m| m.starts_with("mistral"))
|
||||
{
|
||||
IntegrationStatus::Active
|
||||
} else {
|
||||
IntegrationStatus::Available
|
||||
|
|
@ -655,15 +667,17 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::schema::{ChannelsConfig, IMessageConfig, MatrixConfig, TelegramConfig};
|
||||
use crate::config::Config;
|
||||
use crate::config::schema::{
|
||||
ChannelsConfig, IMessageConfig, MatrixConfig, TelegramConfig,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn registry_has_entries() {
|
||||
let entries = all_integrations();
|
||||
assert!(entries.len() >= 50, "Expected 50+ integrations, got {}", entries.len());
|
||||
assert!(
|
||||
entries.len() >= 50,
|
||||
"Expected 50+ integrations, got {}",
|
||||
entries.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -727,7 +741,10 @@ mod tests {
|
|||
let config = Config::default();
|
||||
let entries = all_integrations();
|
||||
let tg = entries.iter().find(|e| e.name == "Telegram").unwrap();
|
||||
assert!(matches!((tg.status_fn)(&config), IntegrationStatus::Available));
|
||||
assert!(matches!(
|
||||
(tg.status_fn)(&config),
|
||||
IntegrationStatus::Available
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -746,7 +763,10 @@ mod tests {
|
|||
let config = Config::default();
|
||||
let entries = all_integrations();
|
||||
let im = entries.iter().find(|e| e.name == "iMessage").unwrap();
|
||||
assert!(matches!((im.status_fn)(&config), IntegrationStatus::Available));
|
||||
assert!(matches!(
|
||||
(im.status_fn)(&config),
|
||||
IntegrationStatus::Available
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -768,7 +788,10 @@ mod tests {
|
|||
let config = Config::default();
|
||||
let entries = all_integrations();
|
||||
let mx = entries.iter().find(|e| e.name == "Matrix").unwrap();
|
||||
assert!(matches!((mx.status_fn)(&config), IntegrationStatus::Available));
|
||||
assert!(matches!(
|
||||
(mx.status_fn)(&config),
|
||||
IntegrationStatus::Available
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -813,9 +836,21 @@ mod tests {
|
|||
#[test]
|
||||
fn category_counts_reasonable() {
|
||||
let entries = all_integrations();
|
||||
let chat_count = entries.iter().filter(|e| e.category == IntegrationCategory::Chat).count();
|
||||
let ai_count = entries.iter().filter(|e| e.category == IntegrationCategory::AiModel).count();
|
||||
assert!(chat_count >= 5, "Expected 5+ chat integrations, got {chat_count}");
|
||||
assert!(ai_count >= 5, "Expected 5+ AI model integrations, got {ai_count}");
|
||||
let chat_count = entries
|
||||
.iter()
|
||||
.filter(|e| e.category == IntegrationCategory::Chat)
|
||||
.count();
|
||||
let ai_count = entries
|
||||
.iter()
|
||||
.filter(|e| e.category == IntegrationCategory::AiModel)
|
||||
.count();
|
||||
assert!(
|
||||
chat_count >= 5,
|
||||
"Expected 5+ chat integrations, got {chat_count}"
|
||||
);
|
||||
assert!(
|
||||
ai_count >= 5,
|
||||
"Expected 5+ AI model integrations, got {ai_count}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ mod config;
|
|||
mod cron;
|
||||
mod gateway;
|
||||
mod heartbeat;
|
||||
mod integrations;
|
||||
mod memory;
|
||||
mod observability;
|
||||
mod onboard;
|
||||
mod providers;
|
||||
mod runtime;
|
||||
mod security;
|
||||
mod integrations;
|
||||
mod skills;
|
||||
mod tools;
|
||||
|
||||
|
|
@ -298,7 +298,11 @@ async fn main() -> Result<()> {
|
|||
] {
|
||||
println!(
|
||||
" {name:9} {}",
|
||||
if configured { "✅ configured" } else { "❌ not configured" }
|
||||
if configured {
|
||||
"✅ configured"
|
||||
} else {
|
||||
"❌ not configured"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,7 +222,10 @@ fn setup_provider() -> Result<(String, String, String)> {
|
|||
|
||||
let providers: Vec<(&str, &str)> = match tier_idx {
|
||||
0 => vec![
|
||||
("openrouter", "OpenRouter — 200+ models, 1 API key (recommended)"),
|
||||
(
|
||||
"openrouter",
|
||||
"OpenRouter — 200+ models, 1 API key (recommended)",
|
||||
),
|
||||
("venice", "Venice AI — privacy-first (Llama, Opus)"),
|
||||
("anthropic", "Anthropic — Claude Sonnet & Opus (direct)"),
|
||||
("openai", "OpenAI — GPT-4o, o1, GPT-5 (direct)"),
|
||||
|
|
@ -251,9 +254,7 @@ fn setup_provider() -> Result<(String, String, String)> {
|
|||
("opencode", "OpenCode Zen — code-focused AI"),
|
||||
("cohere", "Cohere — Command R+ & embeddings"),
|
||||
],
|
||||
_ => vec![
|
||||
("ollama", "Ollama — local models (Llama, Mistral, Phi)"),
|
||||
],
|
||||
_ => vec![("ollama", "Ollama — local models (Llama, Mistral, Phi)")],
|
||||
};
|
||||
|
||||
let provider_labels: Vec<&str> = providers.iter().map(|(_, label)| *label).collect();
|
||||
|
|
@ -321,18 +322,36 @@ fn setup_provider() -> Result<(String, String, String)> {
|
|||
// ── Model selection ──
|
||||
let models: Vec<(&str, &str)> = match provider_name {
|
||||
"openrouter" => vec![
|
||||
("anthropic/claude-sonnet-4-20250514", "Claude Sonnet 4 (balanced, recommended)"),
|
||||
("anthropic/claude-3.5-sonnet", "Claude 3.5 Sonnet (fast, affordable)"),
|
||||
(
|
||||
"anthropic/claude-sonnet-4-20250514",
|
||||
"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)"),
|
||||
(
|
||||
"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-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)"),
|
||||
(
|
||||
"claude-3-5-haiku-20241022",
|
||||
"Claude 3.5 Haiku (fastest, cheapest)",
|
||||
),
|
||||
],
|
||||
"openai" => vec![
|
||||
("gpt-4o", "GPT-4o (flagship)"),
|
||||
|
|
@ -345,7 +364,10 @@ fn setup_provider() -> Result<(String, String, String)> {
|
|||
("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.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)"),
|
||||
],
|
||||
|
|
@ -367,12 +389,24 @@ fn setup_provider() -> Result<(String, String, String)> {
|
|||
("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"),
|
||||
(
|
||||
"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"),
|
||||
(
|
||||
"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![
|
||||
|
|
@ -397,9 +431,7 @@ fn setup_provider() -> Result<(String, String, String)> {
|
|||
("codellama", "Code Llama"),
|
||||
("phi3", "Phi-3 (small, fast)"),
|
||||
],
|
||||
_ => vec![
|
||||
("default", "Default model"),
|
||||
],
|
||||
_ => vec![("default", "Default model")],
|
||||
};
|
||||
|
||||
let model_labels: Vec<&str> = models.iter().map(|(_, label)| *label).collect();
|
||||
|
|
@ -518,7 +550,9 @@ fn setup_project_context() -> Result<ProjectContext> {
|
|||
0 => "Be direct and concise. Skip pleasantries. Get to the point.".to_string(),
|
||||
1 => "Be friendly and casual. Warm but efficient.".to_string(),
|
||||
2 => "Be technical and detailed. Thorough explanations, code-first.".to_string(),
|
||||
_ => "Adapt to the situation. Be concise when needed, thorough when it matters.".to_string(),
|
||||
_ => {
|
||||
"Adapt to the situation. Be concise when needed, thorough when it matters.".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
|
|
@ -560,27 +594,51 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
let options = vec![
|
||||
format!(
|
||||
"Telegram {}",
|
||||
if config.telegram.is_some() { "✅ connected" } else { "— connect your bot" }
|
||||
if config.telegram.is_some() {
|
||||
"✅ connected"
|
||||
} else {
|
||||
"— connect your bot"
|
||||
}
|
||||
),
|
||||
format!(
|
||||
"Discord {}",
|
||||
if config.discord.is_some() { "✅ connected" } else { "— connect your bot" }
|
||||
if config.discord.is_some() {
|
||||
"✅ connected"
|
||||
} else {
|
||||
"— connect your bot"
|
||||
}
|
||||
),
|
||||
format!(
|
||||
"Slack {}",
|
||||
if config.slack.is_some() { "✅ connected" } else { "— connect your bot" }
|
||||
if config.slack.is_some() {
|
||||
"✅ connected"
|
||||
} else {
|
||||
"— connect your bot"
|
||||
}
|
||||
),
|
||||
format!(
|
||||
"iMessage {}",
|
||||
if config.imessage.is_some() { "✅ configured" } else { "— macOS only" }
|
||||
if config.imessage.is_some() {
|
||||
"✅ configured"
|
||||
} else {
|
||||
"— macOS only"
|
||||
}
|
||||
),
|
||||
format!(
|
||||
"Matrix {}",
|
||||
if config.matrix.is_some() { "✅ connected" } else { "— self-hosted chat" }
|
||||
if config.matrix.is_some() {
|
||||
"✅ connected"
|
||||
} else {
|
||||
"— self-hosted chat"
|
||||
}
|
||||
),
|
||||
format!(
|
||||
"Webhook {}",
|
||||
if config.webhook.is_some() { "✅ configured" } else { "— HTTP endpoint" }
|
||||
if config.webhook.is_some() {
|
||||
"✅ configured"
|
||||
} else {
|
||||
"— HTTP endpoint"
|
||||
}
|
||||
),
|
||||
"Done — finish setup".to_string(),
|
||||
];
|
||||
|
|
@ -670,9 +728,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
print_bullet("4. Invite bot to your server with messages permission");
|
||||
println!();
|
||||
|
||||
let token: String = Input::new()
|
||||
.with_prompt(" Bot token")
|
||||
.interact_text()?;
|
||||
let token: String = Input::new().with_prompt(" Bot token").interact_text()?;
|
||||
|
||||
if token.trim().is_empty() {
|
||||
println!(" {} Skipped", style("→").dim());
|
||||
|
|
@ -750,7 +806,10 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
{
|
||||
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 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)
|
||||
|
|
@ -761,11 +820,11 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
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()
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -790,8 +849,16 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
|
||||
config.slack = Some(SlackConfig {
|
||||
bot_token: token,
|
||||
app_token: if app_token.is_empty() { None } else { Some(app_token) },
|
||||
channel_id: if channel.is_empty() { None } else { Some(channel) },
|
||||
app_token: if app_token.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(app_token)
|
||||
},
|
||||
channel_id: if channel.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(channel)
|
||||
},
|
||||
allowed_users: vec![],
|
||||
});
|
||||
}
|
||||
|
|
@ -813,7 +880,9 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
}
|
||||
|
||||
print_bullet("ZeroClaw reads your iMessage database and replies via AppleScript.");
|
||||
print_bullet("You need to grant Full Disk Access to your terminal in System Settings.");
|
||||
print_bullet(
|
||||
"You need to grant Full Disk Access to your terminal in System Settings.",
|
||||
);
|
||||
println!();
|
||||
|
||||
let contacts_str: String = Input::new()
|
||||
|
|
@ -824,7 +893,10 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
let allowed_contacts = if contacts_str.trim() == "*" {
|
||||
vec!["*".into()]
|
||||
} else {
|
||||
contacts_str.split(',').map(|s| s.trim().to_string()).collect()
|
||||
contacts_str
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.collect()
|
||||
};
|
||||
|
||||
config.imessage = Some(IMessageConfig { allowed_contacts });
|
||||
|
|
@ -855,9 +927,8 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
continue;
|
||||
}
|
||||
|
||||
let access_token: String = Input::new()
|
||||
.with_prompt(" Access token")
|
||||
.interact_text()?;
|
||||
let access_token: String =
|
||||
Input::new().with_prompt(" Access token").interact_text()?;
|
||||
|
||||
if access_token.trim().is_empty() {
|
||||
println!(" {} Skipped — token required", style("→").dim());
|
||||
|
|
@ -936,7 +1007,11 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
|
||||
config.webhook = Some(WebhookConfig {
|
||||
port: port.parse().unwrap_or(8080),
|
||||
secret: if secret.is_empty() { None } else { Some(secret) },
|
||||
secret: if secret.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(secret)
|
||||
},
|
||||
});
|
||||
println!(
|
||||
" {} Webhook on port {}",
|
||||
|
|
@ -1330,9 +1405,7 @@ fn print_summary(config: &Config) {
|
|||
let mut step = 1u8;
|
||||
|
||||
if config.api_key.is_none() {
|
||||
let env_var = provider_env_var(
|
||||
config.default_provider.as_deref().unwrap_or("openrouter"),
|
||||
);
|
||||
let env_var = provider_env_var(config.default_provider.as_deref().unwrap_or("openrouter"));
|
||||
println!(
|
||||
" {} Set your API key:",
|
||||
style(format!("{step}.")).cyan().bold()
|
||||
|
|
@ -1352,10 +1425,7 @@ fn print_summary(config: &Config) {
|
|||
style(format!("{step}.")).cyan().bold(),
|
||||
style("Launch your channels").white().bold()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
style("zeroclaw channel start").yellow()
|
||||
);
|
||||
println!(" {}", style("zeroclaw channel start").yellow());
|
||||
println!();
|
||||
step += 1;
|
||||
}
|
||||
|
|
@ -1440,10 +1510,7 @@ mod tests {
|
|||
scaffold_workspace(tmp.path(), &ctx).unwrap();
|
||||
|
||||
for dir in &["sessions", "memory", "state", "cron", "skills"] {
|
||||
assert!(
|
||||
tmp.path().join(dir).is_dir(),
|
||||
"missing subdirectory: {dir}"
|
||||
);
|
||||
assert!(tmp.path().join(dir).is_dir(), "missing subdirectory: {dir}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1459,7 +1526,10 @@ mod tests {
|
|||
scaffold_workspace(tmp.path(), &ctx).unwrap();
|
||||
|
||||
let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap();
|
||||
assert!(user_md.contains("**Name:** Alice"), "USER.md should contain user name");
|
||||
assert!(
|
||||
user_md.contains("**Name:** Alice"),
|
||||
"USER.md should contain user name"
|
||||
);
|
||||
|
||||
let bootstrap = fs::read_to_string(tmp.path().join("BOOTSTRAP.md")).unwrap();
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -53,9 +53,7 @@ impl Provider for AnthropicProvider {
|
|||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Anthropic API key not set. Set ANTHROPIC_API_KEY or edit config.toml."
|
||||
)
|
||||
anyhow::anyhow!("Anthropic API key not set. Set ANTHROPIC_API_KEY or edit config.toml.")
|
||||
})?;
|
||||
|
||||
let request = ChatRequest {
|
||||
|
|
@ -122,10 +120,15 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn chat_fails_without_key() {
|
||||
let p = AnthropicProvider::new(None);
|
||||
let result = p.chat_with_system(None, "hello", "claude-3-opus", 0.7).await;
|
||||
let result = p
|
||||
.chat_with_system(None, "hello", "claude-3-opus", 0.7)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("API key not set"), "Expected key error, got: {err}");
|
||||
assert!(
|
||||
err.contains("API key not set"),
|
||||
"Expected key error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -150,7 +153,10 @@ mod tests {
|
|||
temperature: 0.7,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(!json.contains("system"), "system field should be skipped when None");
|
||||
assert!(
|
||||
!json.contains("system"),
|
||||
"system field should be skipped when None"
|
||||
);
|
||||
assert!(json.contains("claude-3-opus"));
|
||||
assert!(json.contains("hello"));
|
||||
}
|
||||
|
|
@ -188,7 +194,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn chat_response_multiple_blocks() {
|
||||
let json = r#"{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}"#;
|
||||
let json =
|
||||
r#"{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}"#;
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.content.len(), 2);
|
||||
assert_eq!(resp.content[0].text, "First");
|
||||
|
|
|
|||
|
|
@ -170,9 +170,14 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn chat_fails_without_key() {
|
||||
let p = make_provider("Venice", "https://api.venice.ai", None);
|
||||
let result = p.chat_with_system(None, "hello", "llama-3.3-70b", 0.7).await;
|
||||
let result = p
|
||||
.chat_with_system(None, "hello", "llama-3.3-70b", 0.7)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Venice API key not set"));
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Venice API key not set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -180,8 +185,14 @@ mod tests {
|
|||
let req = ChatRequest {
|
||||
model: "llama-3.3-70b".to_string(),
|
||||
messages: vec![
|
||||
Message { role: "system".to_string(), content: "You are ZeroClaw".to_string() },
|
||||
Message { role: "user".to_string(), content: "hello".to_string() },
|
||||
Message {
|
||||
role: "system".to_string(),
|
||||
content: "You are ZeroClaw".to_string(),
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: "hello".to_string(),
|
||||
},
|
||||
],
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
|
@ -208,7 +219,10 @@ mod tests {
|
|||
#[test]
|
||||
fn x_api_key_auth_style() {
|
||||
let p = OpenAiCompatibleProvider::new(
|
||||
"moonshot", "https://api.moonshot.cn", Some("ms-key"), AuthStyle::XApiKey,
|
||||
"moonshot",
|
||||
"https://api.moonshot.cn",
|
||||
Some("ms-key"),
|
||||
AuthStyle::XApiKey,
|
||||
);
|
||||
assert!(matches!(p.auth_header, AuthStyle::XApiKey));
|
||||
}
|
||||
|
|
@ -216,7 +230,10 @@ mod tests {
|
|||
#[test]
|
||||
fn custom_auth_style() {
|
||||
let p = OpenAiCompatibleProvider::new(
|
||||
"custom", "https://api.example.com", Some("key"), AuthStyle::Custom("X-Custom-Key".into()),
|
||||
"custom",
|
||||
"https://api.example.com",
|
||||
Some("key"),
|
||||
AuthStyle::Custom("X-Custom-Key".into()),
|
||||
);
|
||||
assert!(matches!(p.auth_header, AuthStyle::Custom(_)));
|
||||
}
|
||||
|
|
@ -238,7 +255,8 @@ mod tests {
|
|||
assert!(result.is_err(), "{} should fail without key", p.name);
|
||||
assert!(
|
||||
result.unwrap_err().to_string().contains("API key not set"),
|
||||
"{} error should mention key", p.name
|
||||
"{} error should mention key",
|
||||
p.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,11 +250,29 @@ mod tests {
|
|||
#[test]
|
||||
fn factory_all_providers_create_successfully() {
|
||||
let providers = [
|
||||
"openrouter", "anthropic", "openai", "ollama",
|
||||
"venice", "vercel", "cloudflare", "moonshot", "synthetic",
|
||||
"opencode", "zai", "glm", "minimax", "bedrock", "qianfan",
|
||||
"groq", "mistral", "xai", "deepseek", "together",
|
||||
"fireworks", "perplexity", "cohere",
|
||||
"openrouter",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"ollama",
|
||||
"venice",
|
||||
"vercel",
|
||||
"cloudflare",
|
||||
"moonshot",
|
||||
"synthetic",
|
||||
"opencode",
|
||||
"zai",
|
||||
"glm",
|
||||
"minimax",
|
||||
"bedrock",
|
||||
"qianfan",
|
||||
"groq",
|
||||
"mistral",
|
||||
"xai",
|
||||
"deepseek",
|
||||
"together",
|
||||
"fireworks",
|
||||
"perplexity",
|
||||
"cohere",
|
||||
];
|
||||
for name in providers {
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -85,7 +85,9 @@ impl Provider for OllamaProvider {
|
|||
|
||||
if !response.status().is_success() {
|
||||
let error = response.text().await?;
|
||||
anyhow::bail!("Ollama error: {error}. Is Ollama running? (brew install ollama && ollama serve)");
|
||||
anyhow::bail!(
|
||||
"Ollama error: {error}. Is Ollama running? (brew install ollama && ollama serve)"
|
||||
);
|
||||
}
|
||||
|
||||
let chat_response: ChatResponse = response.json().await?;
|
||||
|
|
@ -126,8 +128,14 @@ mod tests {
|
|||
let req = ChatRequest {
|
||||
model: "llama3".to_string(),
|
||||
messages: vec![
|
||||
Message { role: "system".to_string(), content: "You are ZeroClaw".to_string() },
|
||||
Message { role: "user".to_string(), content: "hello".to_string() },
|
||||
Message {
|
||||
role: "system".to_string(),
|
||||
content: "You are ZeroClaw".to_string(),
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: "hello".to_string(),
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
options: Options { temperature: 0.7 },
|
||||
|
|
@ -143,9 +151,10 @@ mod tests {
|
|||
fn request_serializes_without_system() {
|
||||
let req = ChatRequest {
|
||||
model: "mistral".to_string(),
|
||||
messages: vec![
|
||||
Message { role: "user".to_string(), content: "test".to_string() },
|
||||
],
|
||||
messages: vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: "test".to_string(),
|
||||
}],
|
||||
stream: false,
|
||||
options: Options { temperature: 0.0 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -146,8 +146,14 @@ mod tests {
|
|||
let req = ChatRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
messages: vec![
|
||||
Message { role: "system".to_string(), content: "You are ZeroClaw".to_string() },
|
||||
Message { role: "user".to_string(), content: "hello".to_string() },
|
||||
Message {
|
||||
role: "system".to_string(),
|
||||
content: "You are ZeroClaw".to_string(),
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: "hello".to_string(),
|
||||
},
|
||||
],
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
|
@ -161,9 +167,10 @@ mod tests {
|
|||
fn request_serializes_without_system() {
|
||||
let req = ChatRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
messages: vec![
|
||||
Message { role: "user".to_string(), content: "hello".to_string() },
|
||||
],
|
||||
messages: vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: "hello".to_string(),
|
||||
}],
|
||||
temperature: 0.0,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
|
|
|
|||
|
|
@ -2,12 +2,7 @@ use async_trait::async_trait;
|
|||
|
||||
#[async_trait]
|
||||
pub trait Provider: Send + Sync {
|
||||
async fn chat(
|
||||
&self,
|
||||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
async fn chat(&self, message: &str, model: &str, temperature: f64) -> anyhow::Result<String> {
|
||||
self.chat_with_system(None, message, model, temperature)
|
||||
.await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,8 +37,13 @@ impl ActionTracker {
|
|||
|
||||
/// Record an action and return the current count within the window.
|
||||
pub fn record(&self) -> usize {
|
||||
let mut actions = self.actions.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let cutoff = Instant::now().checked_sub(std::time::Duration::from_secs(3600)).unwrap_or_else(Instant::now);
|
||||
let mut actions = self
|
||||
.actions
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let cutoff = Instant::now()
|
||||
.checked_sub(std::time::Duration::from_secs(3600))
|
||||
.unwrap_or_else(Instant::now);
|
||||
actions.retain(|t| *t > cutoff);
|
||||
actions.push(Instant::now());
|
||||
actions.len()
|
||||
|
|
@ -46,8 +51,13 @@ impl ActionTracker {
|
|||
|
||||
/// Count of actions in the current window without recording.
|
||||
pub fn count(&self) -> usize {
|
||||
let mut actions = self.actions.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let cutoff = Instant::now().checked_sub(std::time::Duration::from_secs(3600)).unwrap_or_else(Instant::now);
|
||||
let mut actions = self
|
||||
.actions
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let cutoff = Instant::now()
|
||||
.checked_sub(std::time::Duration::from_secs(3600))
|
||||
.unwrap_or_else(Instant::now);
|
||||
actions.retain(|t| *t > cutoff);
|
||||
actions.len()
|
||||
}
|
||||
|
|
@ -55,7 +65,10 @@ impl ActionTracker {
|
|||
|
||||
impl Clone for ActionTracker {
|
||||
fn clone(&self) -> Self {
|
||||
let actions = self.actions.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let actions = self
|
||||
.actions
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
Self {
|
||||
actions: Mutex::new(actions.clone()),
|
||||
}
|
||||
|
|
@ -582,7 +595,7 @@ mod tests {
|
|||
max_actions_per_hour: 1,
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
assert!(p.record_action()); // 1 — exactly at limit
|
||||
assert!(p.record_action()); // 1 — exactly at limit
|
||||
assert!(!p.record_action()); // 2 — over
|
||||
assert!(!p.record_action()); // 3 — still over
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,7 +158,11 @@ pub fn skills_to_prompt(skills: &[Skill]) -> String {
|
|||
if !skill.tools.is_empty() {
|
||||
prompt.push_str("Tools:\n");
|
||||
for tool in &skill.tools {
|
||||
let _ = writeln!(prompt, "- **{}**: {} ({})", tool.name, tool.description, tool.kind);
|
||||
let _ = writeln!(
|
||||
prompt,
|
||||
"- **{}**: {} ({})",
|
||||
tool.name, tool.description, tool.kind
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -242,14 +246,16 @@ pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Re
|
|||
if !skill.tools.is_empty() {
|
||||
println!(
|
||||
" Tools: {}",
|
||||
skill.tools.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ")
|
||||
skill
|
||||
.tools
|
||||
.iter()
|
||||
.map(|t| t.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
if !skill.tags.is_empty() {
|
||||
println!(
|
||||
" Tags: {}",
|
||||
skill.tags.join(", ")
|
||||
);
|
||||
println!(" Tags: {}", skill.tags.join(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -270,7 +276,10 @@ pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Re
|
|||
.output()?;
|
||||
|
||||
if output.status.success() {
|
||||
println!(" {} Skill installed successfully!", console::style("✓").green().bold());
|
||||
println!(
|
||||
" {} Skill installed successfully!",
|
||||
console::style("✓").green().bold()
|
||||
);
|
||||
println!(" Restart `zeroclaw channel start` to activate.");
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
|
@ -293,7 +302,11 @@ pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Re
|
|||
anyhow::bail!("Symlink not supported on this platform. Copy the skill directory manually.");
|
||||
}
|
||||
|
||||
println!(" {} Skill linked: {}", console::style("✓").green().bold(), dest.display());
|
||||
println!(
|
||||
" {} Skill linked: {}",
|
||||
console::style("✓").green().bold(),
|
||||
dest.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -305,7 +318,11 @@ pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Re
|
|||
}
|
||||
|
||||
std::fs::remove_dir_all(&skill_path)?;
|
||||
println!(" {} Skill '{}' removed.", console::style("✓").green().bold(), name);
|
||||
println!(
|
||||
" {} Skill '{}' removed.",
|
||||
console::style("✓").green().bold(),
|
||||
name
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ impl Tool for MemoryRecallTool {
|
|||
Ok(entries) => {
|
||||
let mut output = format!("Found {} memories:\n", entries.len());
|
||||
for entry in &entries {
|
||||
let score = entry.score.map_or_else(String::new, |s| format!(" [{s:.0}%]"));
|
||||
let score = entry
|
||||
.score
|
||||
.map_or_else(String::new, |s| format!(" [{s:.0}%]"));
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"- [{}] {}: {}{score}",
|
||||
|
|
@ -102,10 +104,7 @@ mod tests {
|
|||
async fn recall_empty() {
|
||||
let (_tmp, mem) = seeded_mem();
|
||||
let tool = MemoryRecallTool::new(mem);
|
||||
let result = tool
|
||||
.execute(json!({"query": "anything"}))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = tool.execute(json!({"query": "anything"})).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("No memories found"));
|
||||
}
|
||||
|
|
@ -131,9 +130,13 @@ mod tests {
|
|||
async fn recall_respects_limit() {
|
||||
let (_tmp, mem) = seeded_mem();
|
||||
for i in 0..10 {
|
||||
mem.store(&format!("k{i}"), &format!("Rust fact {i}"), MemoryCategory::Core)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store(
|
||||
&format!("k{i}"),
|
||||
&format!("Rust fact {i}"),
|
||||
MemoryCategory::Core,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let tool = MemoryRecallTool::new(mem);
|
||||
|
|
|
|||
|
|
@ -32,10 +32,7 @@ pub fn default_tools(security: Arc<SecurityPolicy>) -> Vec<Box<dyn Tool>> {
|
|||
}
|
||||
|
||||
/// Create full tool registry including memory tools
|
||||
pub fn all_tools(
|
||||
security: Arc<SecurityPolicy>,
|
||||
memory: Arc<dyn Memory>,
|
||||
) -> Vec<Box<dyn Tool>> {
|
||||
pub fn all_tools(security: Arc<SecurityPolicy>, memory: Arc<dyn Memory>) -> Vec<Box<dyn Tool>> {
|
||||
vec![
|
||||
Box::new(ShellTool::new(security.clone())),
|
||||
Box::new(FileReadTool::new(security.clone())),
|
||||
|
|
@ -51,8 +48,10 @@ pub async fn handle_command(command: super::ToolCommands, config: Config) -> Res
|
|||
workspace_dir: config.workspace_dir.clone(),
|
||||
..SecurityPolicy::default()
|
||||
});
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::memory::create_memory(&config.memory, &config.workspace_dir)?);
|
||||
let mem: Arc<dyn Memory> = Arc::from(crate::memory::create_memory(
|
||||
&config.memory,
|
||||
&config.workspace_dir,
|
||||
)?);
|
||||
let tools_list = all_tools(security, mem);
|
||||
|
||||
match command {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue