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:
argenis de la rosa 2026-02-13 16:03:50 -05:00
parent a5887ad2dc
commit bc31e4389b
24 changed files with 613 additions and 242 deletions

View file

@ -46,8 +46,10 @@ pub async fn run(
)); ));
// ── Memory (the brain) ──────────────────────────────────────── // ── Memory (the brain) ────────────────────────────────────────
let mem: Arc<dyn Memory> = let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
Arc::from(memory::create_memory(&config.memory, &config.workspace_dir)?); &config.memory,
&config.workspace_dir,
)?);
tracing::info!(backend = mem.name(), "Memory initialized"); tracing::info!(backend = mem.name(), "Memory initialized");
// ── Tools (including memory tools) ──────────────────────────── // ── Tools (including memory tools) ────────────────────────────

View file

@ -39,8 +39,7 @@ impl DiscordChannel {
} }
} }
const BASE64_ALPHABET: &[u8] = const BASE64_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Minimal base64 decode (no extra dep) — only needs to decode the user ID portion /// Minimal base64 decode (no extra dep) — only needs to decode the user ID portion
#[allow(clippy::cast_possible_truncation)] #[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_tx, mut hb_rx) = tokio::sync::mpsc::channel::<()>(1);
let hb_interval = heartbeat_interval; let hb_interval = heartbeat_interval;
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = let mut interval = tokio::time::interval(std::time::Duration::from_millis(hb_interval));
tokio::time::interval(std::time::Duration::from_millis(hb_interval));
loop { loop {
interval.tick().await; interval.tick().await;
if hb_tx.send(()).await.is_err() { if hb_tx.send(()).await.is_err() {

View file

@ -23,9 +23,9 @@ impl IMessageChannel {
if self.allowed_contacts.iter().any(|u| u == "*") { if self.allowed_contacts.iter().any(|u| u == "*") {
return true; return true;
} }
self.allowed_contacts.iter().any(|u| { self.allowed_contacts
u.eq_ignore_ascii_case(sender) .iter()
}) .any(|u| u.eq_ignore_ascii_case(sender))
} }
} }

View file

@ -93,10 +93,7 @@ impl MatrixChannel {
} }
async fn get_my_user_id(&self) -> anyhow::Result<String> { async fn get_my_user_id(&self) -> anyhow::Result<String> {
let url = format!( let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
"{}/_matrix/client/v3/account/whoami",
self.homeserver
);
let resp = self let resp = self
.client .client
.get(&url) .get(&url)
@ -250,10 +247,7 @@ impl Channel for MatrixChannel {
} }
async fn health_check(&self) -> bool { async fn health_check(&self) -> bool {
let url = format!( let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
"{}/_matrix/client/v3/account/whoami",
self.homeserver
);
let Ok(resp) = self let Ok(resp) = self
.client .client
.get(&url) .get(&url)
@ -413,8 +407,14 @@ mod tests {
let room = resp.rooms.join.get("!room:matrix.org").unwrap(); let room = resp.rooms.join.get("!room:matrix.org").unwrap();
assert_eq!(room.timeline.events.len(), 1); assert_eq!(room.timeline.events.len(), 1);
assert_eq!(room.timeline.events[0].sender, "@user:matrix.org"); assert_eq!(room.timeline.events[0].sender, "@user:matrix.org");
assert_eq!(room.timeline.events[0].content.body.as_deref(), Some("Hello!")); assert_eq!(
assert_eq!(room.timeline.events[0].content.msgtype.as_deref(), Some("m.text")); room.timeline.events[0].content.body.as_deref(),
Some("Hello!")
);
assert_eq!(
room.timeline.events[0].content.msgtype.as_deref(),
Some("m.text")
);
} }
#[test] #[test]

View file

@ -75,8 +75,15 @@ pub fn build_system_prompt(
for skill in skills { for skill in skills {
let _ = writeln!(prompt, " <skill>"); let _ = writeln!(prompt, " <skill>");
let _ = writeln!(prompt, " <name>{}</name>", skill.name); let _ = writeln!(prompt, " <name>{}</name>", skill.name);
let _ = writeln!(prompt, " <description>{}</description>", skill.description); let _ = writeln!(
let location = workspace_dir.join("skills").join(&skill.name).join("SKILL.md"); 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, " <location>{}</location>", location.display());
let _ = writeln!(prompt, " </skill>"); let _ = writeln!(prompt, " </skill>");
} }
@ -84,11 +91,16 @@ pub fn build_system_prompt(
} }
// ── 4. Workspace ──────────────────────────────────────────── // ── 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) ────────────── // ── 5. Bootstrap files (injected into context) ──────────────
prompt.push_str("## Project Context\n\n"); 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 = [ let bootstrap_files = [
"AGENTS.md", "AGENTS.md",
@ -118,8 +130,8 @@ pub fn build_system_prompt(
let _ = writeln!(prompt, "## Current Date & Time\n\nTimezone: {tz}\n"); let _ = writeln!(prompt, "## Current Date & Time\n\nTimezone: {tz}\n");
// ── 7. Runtime ────────────────────────────────────────────── // ── 7. Runtime ──────────────────────────────────────────────
let host = hostname::get() let host =
.map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string()); hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
let _ = writeln!( let _ = writeln!(
prompt, prompt,
"## Runtime\n\nHost: {host} | OS: {} | Model: {model_name}\n", "## 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()), ("iMessage", config.channels_config.imessage.is_some()),
("Matrix", config.channels_config.matrix.is_some()), ("Matrix", config.channels_config.matrix.is_some()),
] { ] {
println!( println!(" {} {name}", if configured { "" } else { "" });
" {} {name}",
if configured { "" } else { "" }
);
} }
println!("\nTo start channels: zeroclaw channel start"); println!("\nTo start channels: zeroclaw channel start");
println!("To configure: zeroclaw onboard"); println!("To configure: zeroclaw onboard");
@ -193,7 +202,9 @@ pub fn handle_command(command: super::ChannelCommands, config: &Config) -> Resul
channel_type, channel_type,
config: _, 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 } => { super::ChannelCommands::Remove { name } => {
anyhow::bail!("Remove channel '{name}' — edit ~/.zeroclaw/config.toml directly"); anyhow::bail!("Remove channel '{name}' — edit ~/.zeroclaw/config.toml directly");
@ -213,8 +224,10 @@ pub async fn start_channels(config: Config) -> Result<()> {
.clone() .clone()
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
let temperature = config.default_temperature; let temperature = config.default_temperature;
let mem: Arc<dyn Memory> = let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
Arc::from(memory::create_memory(&config.memory, &config.workspace_dir)?); &config.memory,
&config.workspace_dir,
)?);
// Build system prompt from workspace identity files + skills // Build system prompt from workspace identity files + skills
let workspace = config.workspace_dir.clone(); 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); let system_prompt = build_system_prompt(&workspace, &model, &tool_descs, &skills);
if !skills.is_empty() { 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 // Collect active channels
@ -263,9 +283,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
} }
if let Some(ref im) = config.channels_config.imessage { if let Some(ref im) = config.channels_config.imessage {
channels.push(Arc::new(IMessageChannel::new( channels.push(Arc::new(IMessageChannel::new(im.allowed_contacts.clone())));
im.allowed_contacts.clone(),
)));
} }
if let Some(ref mx) = config.channels_config.matrix { 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!("🦀 ZeroClaw Channel Server");
println!(" 🤖 Model: {model}"); println!(" 🤖 Model: {model}");
println!(" 🧠 Memory: {} (auto-save: {})", config.memory.backend, if config.memory.auto_save { "on" } else { "off" }); println!(
println!(" 📡 Channels: {}", channels.iter().map(|c| c.name()).collect::<Vec<_>>().join(", ")); " 🧠 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!();
println!(" Listening for messages... (Ctrl+C to stop)"); println!(" Listening for messages... (Ctrl+C to stop)");
println!(); println!();
@ -331,7 +360,10 @@ pub async fn start_channels(config: Config) -> Result<()> {
} }
// Call the LLM with system prompt (identity + soul + tools) // 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) => { Ok(response) => {
println!( println!(
" 🤖 Reply: {}", " 🤖 Reply: {}",
@ -355,9 +387,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
eprintln!(" ❌ LLM error: {e}"); eprintln!(" ❌ LLM error: {e}");
for ch in &channels { for ch in &channels {
if ch.name() == msg.channel { if ch.name() == msg.channel {
let _ = ch let _ = ch.send(&format!("⚠️ Error: {e}"), &msg.sender).await;
.send(&format!("⚠️ Error: {e}"), &msg.sender)
.await;
break; 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("SOUL.md"), "# Soul\nBe helpful.").unwrap();
std::fs::write(tmp.path().join("IDENTITY.md"), "# Identity\nName: ZeroClaw").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("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("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(); std::fs::write(tmp.path().join("MEMORY.md"), "# Memory\nUser likes Rust.").unwrap();
tmp tmp
} }
@ -401,15 +439,24 @@ mod tests {
assert!(prompt.contains("## Tools"), "missing Tools section"); assert!(prompt.contains("## Tools"), "missing Tools section");
assert!(prompt.contains("## Safety"), "missing Safety section"); assert!(prompt.contains("## Safety"), "missing Safety section");
assert!(prompt.contains("## Workspace"), "missing Workspace section"); assert!(prompt.contains("## Workspace"), "missing Workspace section");
assert!(prompt.contains("## Project Context"), "missing Project Context"); assert!(
assert!(prompt.contains("## Current Date & Time"), "missing Date/Time"); prompt.contains("## Project Context"),
"missing Project Context"
);
assert!(
prompt.contains("## Current Date & Time"),
"missing Date/Time"
);
assert!(prompt.contains("## Runtime"), "missing Runtime section"); assert!(prompt.contains("## Runtime"), "missing Runtime section");
} }
#[test] #[test]
fn prompt_injects_tools() { fn prompt_injects_tools() {
let ws = make_workspace(); 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, &[]); let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[]);
assert!(prompt.contains("**shell**")); assert!(prompt.contains("**shell**"));
@ -435,7 +482,10 @@ mod tests {
assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header"); assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header");
assert!(prompt.contains("Be helpful"), "missing SOUL content"); assert!(prompt.contains("Be helpful"), "missing SOUL content");
assert!(prompt.contains("### IDENTITY.md"), "missing IDENTITY.md"); 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("### USER.md"), "missing USER.md");
assert!(prompt.contains("### AGENTS.md"), "missing AGENTS.md"); assert!(prompt.contains("### AGENTS.md"), "missing AGENTS.md");
assert!(prompt.contains("### TOOLS.md"), "missing TOOLS.md"); assert!(prompt.contains("### TOOLS.md"), "missing TOOLS.md");
@ -460,12 +510,18 @@ mod tests {
let ws = make_workspace(); let ws = make_workspace();
// No BOOTSTRAP.md — should not appear // No BOOTSTRAP.md — should not appear
let prompt = build_system_prompt(ws.path(), "model", &[], &[]); 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 // Create BOOTSTRAP.md — should appear
std::fs::write(ws.path().join("BOOTSTRAP.md"), "# Bootstrap\nFirst run.").unwrap(); std::fs::write(ws.path().join("BOOTSTRAP.md"), "# Bootstrap\nFirst run.").unwrap();
let prompt2 = build_system_prompt(ws.path(), "model", &[], &[]); 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")); assert!(prompt2.contains("First run"));
} }
@ -475,13 +531,23 @@ mod tests {
let memory_dir = ws.path().join("memory"); let memory_dir = ws.path().join("memory");
std::fs::create_dir_all(&memory_dir).unwrap(); std::fs::create_dir_all(&memory_dir).unwrap();
let today = chrono::Local::now().format("%Y-%m-%d").to_string(); 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", &[], &[]); let prompt = build_system_prompt(ws.path(), "model", &[], &[]);
// Daily notes should NOT be in the system prompt (on-demand via tools) // 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!(
assert!(!prompt.contains("Some note"), "daily content should not be in prompt"); !prompt.contains("Daily Notes"),
"daily notes should not be auto-injected"
);
assert!(
!prompt.contains("Some note"),
"daily content should not be in prompt"
);
} }
#[test] #[test]
@ -513,7 +579,10 @@ mod tests {
assert!(prompt.contains("<name>code-review</name>")); assert!(prompt.contains("<name>code-review</name>"));
assert!(prompt.contains("<description>Review code for bugs</description>")); assert!(prompt.contains("<description>Review code for bugs</description>"));
assert!(prompt.contains("SKILL.md</location>")); 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 // Full prompt content should NOT be dumped
assert!(!prompt.contains("Long prompt content that should NOT appear")); assert!(!prompt.contains("Long prompt content that should NOT appear"));
} }
@ -527,8 +596,14 @@ mod tests {
let prompt = build_system_prompt(ws.path(), "model", &[], &[]); let prompt = build_system_prompt(ws.path(), "model", &[], &[]);
assert!(prompt.contains("truncated at"), "large files should be truncated"); assert!(
assert!(!prompt.contains(&big_content), "full content should not appear"); prompt.contains("truncated at"),
"large files should be truncated"
);
assert!(
!prompt.contains(&big_content),
"full content should not appear"
);
} }
#[test] #[test]
@ -539,7 +614,10 @@ mod tests {
let prompt = build_system_prompt(ws.path(), "model", &[], &[]); let prompt = build_system_prompt(ws.path(), "model", &[], &[]);
// Empty file should not produce a header // 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] #[test]

View file

@ -84,10 +84,7 @@ impl Channel for SlackChannel {
loop { loop {
tokio::time::sleep(std::time::Duration::from_secs(3)).await; tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let mut params = vec![ let mut params = vec![("channel", channel_id.clone()), ("limit", "10".to_string())];
("channel", channel_id.clone()),
("limit", "10".to_string()),
];
if !last_ts.is_empty() { if !last_ts.is_empty() {
params.push(("oldest", last_ts.clone())); params.push(("oldest", last_ts.clone()));
} }

View file

@ -102,7 +102,9 @@ impl Channel for TelegramChannel {
.unwrap_or("unknown"); .unwrap_or("unknown");
if !self.is_user_allowed(username) { 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; continue;
} }

View file

@ -570,10 +570,7 @@ default_temperature = 0.7
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap(); let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.imessage.is_some()); assert!(parsed.imessage.is_some());
assert!(parsed.matrix.is_some()); assert!(parsed.matrix.is_some());
assert_eq!( assert_eq!(parsed.imessage.unwrap().allowed_contacts, vec!["+1"]);
parsed.imessage.unwrap().allowed_contacts,
vec!["+1"]
);
assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org"); assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org");
} }

View file

@ -22,8 +22,10 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
.clone() .clone()
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
let temperature = config.default_temperature; let temperature = config.default_temperature;
let mem: Arc<dyn Memory> = let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
Arc::from(memory::create_memory(&config.memory, &config.workspace_dir)?); &config.memory,
&config.workspace_dir,
)?);
// Extract webhook secret for authentication // Extract webhook secret for authentication
let webhook_secret: Option<Arc<str>> = config 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() { if webhook_secret.is_some() {
println!(" 🔒 Webhook authentication: ENABLED (X-Webhook-Secret header required)"); println!(" 🔒 Webhook authentication: ENABLED (X-Webhook-Secret header required)");
} else { } 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"); 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() { if let [method, path, ..] = parts.as_slice() {
tracing::info!("{peer} → {method} {path}"); 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 { } else {
let _ = send_response(&mut stream, 400, "Bad Request").await; let _ = send_response(&mut stream, 400, "Bad Request").await;
} }
@ -116,14 +132,25 @@ async fn handle_request(
match header_val { match header_val {
Some(val) if val == secret.as_ref() => {} 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 err = serde_json::json!({"error": "Unauthorized — invalid or missing X-Webhook-Secret header"});
let _ = send_json(stream, 401, &err).await; let _ = send_json(stream, 401, &err).await;
return; 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] #[test]
fn extract_header_finds_value() { 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")); assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("my-secret"));
} }
@ -244,13 +272,19 @@ mod tests {
fn extract_header_colon_in_value() { fn extract_header_colon_in_value() {
let req = "POST /webhook HTTP/1.1\r\nAuthorization: Bearer sk-abc:123\r\n\r\n{}"; 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 // 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] #[test]
fn extract_header_different_header() { 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{}"; 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")); assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("mysecret"));
} }

View file

@ -130,7 +130,9 @@ fn list_integrations(config: &Config, filter_category: Option<&str>) -> Result<(
let total = available + active + coming; let total = available + active + coming;
println!(); println!();
println!(" {total} integrations: {active} active, {available} available, {coming} coming soon"); println!(
" {total} integrations: {active} active, {available} available, {coming} coming soon"
);
println!(); println!();
println!(" Configure: zeroclaw onboard"); println!(" Configure: zeroclaw onboard");
println!(" Details: zeroclaw integrations info <name>"); 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 name_lower = name.to_lowercase();
let Some(entry) = entries.iter().find(|e| e.name.to_lowercase() == name_lower) else { let Some(entry) = entries.iter().find(|e| e.name.to_lowercase() == name_lower) else {
anyhow::bail!( anyhow::bail!("Unknown integration: {name}. Run `zeroclaw integrations list` to see all.");
"Unknown integration: {name}. Run `zeroclaw integrations list` to see all."
);
}; };
let status = (entry.status_fn)(config); let status = (entry.status_fn)(config);
@ -157,7 +157,12 @@ fn show_integration_info(config: &Config, name: &str) -> Result<()> {
}; };
println!(); 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!(" Category: {}", entry.category.label());
println!(" Status: {label}"); println!(" Status: {label}");
println!(); println!();

View file

@ -161,7 +161,10 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
description: "Gemini 2.5 Pro/Flash", description: "Gemini 2.5 Pro/Flash",
category: IntegrationCategory::AiModel, category: IntegrationCategory::AiModel,
status_fn: |c| { 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 IntegrationStatus::Active
} else { } else {
IntegrationStatus::Available IntegrationStatus::Available
@ -173,7 +176,10 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
description: "DeepSeek V3 & R1", description: "DeepSeek V3 & R1",
category: IntegrationCategory::AiModel, category: IntegrationCategory::AiModel,
status_fn: |c| { 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 IntegrationStatus::Active
} else { } else {
IntegrationStatus::Available IntegrationStatus::Available
@ -185,7 +191,10 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
description: "Grok 3 & 4", description: "Grok 3 & 4",
category: IntegrationCategory::AiModel, category: IntegrationCategory::AiModel,
status_fn: |c| { 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 IntegrationStatus::Active
} else { } else {
IntegrationStatus::Available IntegrationStatus::Available
@ -197,7 +206,10 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
description: "Mistral Large & Codestral", description: "Mistral Large & Codestral",
category: IntegrationCategory::AiModel, category: IntegrationCategory::AiModel,
status_fn: |c| { 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 IntegrationStatus::Active
} else { } else {
IntegrationStatus::Available IntegrationStatus::Available
@ -655,15 +667,17 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::schema::{ChannelsConfig, IMessageConfig, MatrixConfig, TelegramConfig};
use crate::config::Config; use crate::config::Config;
use crate::config::schema::{
ChannelsConfig, IMessageConfig, MatrixConfig, TelegramConfig,
};
#[test] #[test]
fn registry_has_entries() { fn registry_has_entries() {
let entries = all_integrations(); 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] #[test]
@ -727,7 +741,10 @@ mod tests {
let config = Config::default(); let config = Config::default();
let entries = all_integrations(); let entries = all_integrations();
let tg = entries.iter().find(|e| e.name == "Telegram").unwrap(); 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] #[test]
@ -746,7 +763,10 @@ mod tests {
let config = Config::default(); let config = Config::default();
let entries = all_integrations(); let entries = all_integrations();
let im = entries.iter().find(|e| e.name == "iMessage").unwrap(); 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] #[test]
@ -768,7 +788,10 @@ mod tests {
let config = Config::default(); let config = Config::default();
let entries = all_integrations(); let entries = all_integrations();
let mx = entries.iter().find(|e| e.name == "Matrix").unwrap(); 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] #[test]
@ -813,9 +836,21 @@ mod tests {
#[test] #[test]
fn category_counts_reasonable() { fn category_counts_reasonable() {
let entries = all_integrations(); let entries = all_integrations();
let chat_count = entries.iter().filter(|e| e.category == IntegrationCategory::Chat).count(); let chat_count = entries
let ai_count = entries.iter().filter(|e| e.category == IntegrationCategory::AiModel).count(); .iter()
assert!(chat_count >= 5, "Expected 5+ chat integrations, got {chat_count}"); .filter(|e| e.category == IntegrationCategory::Chat)
assert!(ai_count >= 5, "Expected 5+ AI model integrations, got {ai_count}"); .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}"
);
} }
} }

View file

@ -19,13 +19,13 @@ mod config;
mod cron; mod cron;
mod gateway; mod gateway;
mod heartbeat; mod heartbeat;
mod integrations;
mod memory; mod memory;
mod observability; mod observability;
mod onboard; mod onboard;
mod providers; mod providers;
mod runtime; mod runtime;
mod security; mod security;
mod integrations;
mod skills; mod skills;
mod tools; mod tools;
@ -298,7 +298,11 @@ async fn main() -> Result<()> {
] { ] {
println!( println!(
" {name:9} {}", " {name:9} {}",
if configured { "✅ configured" } else { "❌ not configured" } if configured {
"✅ configured"
} else {
"❌ not configured"
}
); );
} }
} }

View file

@ -222,7 +222,10 @@ fn setup_provider() -> Result<(String, String, String)> {
let providers: Vec<(&str, &str)> = match tier_idx { let providers: Vec<(&str, &str)> = match tier_idx {
0 => vec![ 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)"), ("venice", "Venice AI — privacy-first (Llama, Opus)"),
("anthropic", "Anthropic — Claude Sonnet & Opus (direct)"), ("anthropic", "Anthropic — Claude Sonnet & Opus (direct)"),
("openai", "OpenAI — GPT-4o, o1, GPT-5 (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"), ("opencode", "OpenCode Zen — code-focused AI"),
("cohere", "Cohere — Command R+ & embeddings"), ("cohere", "Cohere — Command R+ & embeddings"),
], ],
_ => vec![ _ => vec![("ollama", "Ollama — local models (Llama, Mistral, Phi)")],
("ollama", "Ollama — local models (Llama, Mistral, Phi)"),
],
}; };
let provider_labels: Vec<&str> = providers.iter().map(|(_, label)| *label).collect(); let provider_labels: Vec<&str> = providers.iter().map(|(_, label)| *label).collect();
@ -321,18 +322,36 @@ fn setup_provider() -> Result<(String, String, String)> {
// ── Model selection ── // ── Model selection ──
let models: Vec<(&str, &str)> = match provider_name { let models: Vec<(&str, &str)> = match provider_name {
"openrouter" => vec![ "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", "GPT-4o (OpenAI flagship)"),
("openai/gpt-4o-mini", "GPT-4o Mini (fast, cheap)"), ("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)"), ("deepseek/deepseek-chat", "DeepSeek Chat (affordable)"),
], ],
"anthropic" => vec![ "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-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![ "openai" => vec![
("gpt-4o", "GPT-4o (flagship)"), ("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)"), ("llama-3.1-405b", "Llama 3.1 405B (largest open source)"),
], ],
"groq" => vec![ "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)"), ("llama-3.1-8b-instant", "Llama 3.1 8B (instant)"),
("mixtral-8x7b-32768", "Mixtral 8x7B (32K context)"), ("mixtral-8x7b-32768", "Mixtral 8x7B (32K context)"),
], ],
@ -367,12 +389,24 @@ fn setup_provider() -> Result<(String, String, String)> {
("sonar", "Sonar (search, fast)"), ("sonar", "Sonar (search, fast)"),
], ],
"fireworks" => vec![ "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![ "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"), ("mistralai/Mixtral-8x22B-Instruct-v0.1", "Mixtral 8x22B"),
], ],
"cohere" => vec![ "cohere" => vec![
@ -397,9 +431,7 @@ fn setup_provider() -> Result<(String, String, String)> {
("codellama", "Code Llama"), ("codellama", "Code Llama"),
("phi3", "Phi-3 (small, fast)"), ("phi3", "Phi-3 (small, fast)"),
], ],
_ => vec![ _ => vec![("default", "Default model")],
("default", "Default model"),
],
}; };
let model_labels: Vec<&str> = models.iter().map(|(_, label)| *label).collect(); 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(), 0 => "Be direct and concise. Skip pleasantries. Get to the point.".to_string(),
1 => "Be friendly and casual. Warm but efficient.".to_string(), 1 => "Be friendly and casual. Warm but efficient.".to_string(),
2 => "Be technical and detailed. Thorough explanations, code-first.".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!( println!(
@ -560,27 +594,51 @@ fn setup_channels() -> Result<ChannelsConfig> {
let options = vec![ let options = vec![
format!( format!(
"Telegram {}", "Telegram {}",
if config.telegram.is_some() { "✅ connected" } else { "— connect your bot" } if config.telegram.is_some() {
"✅ connected"
} else {
"— connect your bot"
}
), ),
format!( format!(
"Discord {}", "Discord {}",
if config.discord.is_some() { "✅ connected" } else { "— connect your bot" } if config.discord.is_some() {
"✅ connected"
} else {
"— connect your bot"
}
), ),
format!( format!(
"Slack {}", "Slack {}",
if config.slack.is_some() { "✅ connected" } else { "— connect your bot" } if config.slack.is_some() {
"✅ connected"
} else {
"— connect your bot"
}
), ),
format!( format!(
"iMessage {}", "iMessage {}",
if config.imessage.is_some() { "✅ configured" } else { "— macOS only" } if config.imessage.is_some() {
"✅ configured"
} else {
"— macOS only"
}
), ),
format!( format!(
"Matrix {}", "Matrix {}",
if config.matrix.is_some() { "✅ connected" } else { "— self-hosted chat" } if config.matrix.is_some() {
"✅ connected"
} else {
"— self-hosted chat"
}
), ),
format!( format!(
"Webhook {}", "Webhook {}",
if config.webhook.is_some() { "✅ configured" } else { "— HTTP endpoint" } if config.webhook.is_some() {
"✅ configured"
} else {
"— HTTP endpoint"
}
), ),
"Done — finish setup".to_string(), "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"); print_bullet("4. Invite bot to your server with messages permission");
println!(); println!();
let token: String = Input::new() let token: String = Input::new().with_prompt(" Bot token").interact_text()?;
.with_prompt(" Bot token")
.interact_text()?;
if token.trim().is_empty() { if token.trim().is_empty() {
println!(" {} Skipped", style("").dim()); println!(" {} Skipped", style("").dim());
@ -750,7 +806,10 @@ fn setup_channels() -> Result<ChannelsConfig> {
{ {
Ok(resp) if resp.status().is_success() => { Ok(resp) if resp.status().is_success() => {
let data: serde_json::Value = resp.json().unwrap_or_default(); 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 let team = data
.get("team") .get("team")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
@ -761,11 +820,11 @@ fn setup_channels() -> Result<ChannelsConfig> {
style("").green().bold() style("").green().bold()
); );
} else { } else {
let err = data.get("error").and_then(serde_json::Value::as_str).unwrap_or("unknown error"); let err = data
println!( .get("error")
"\r {} Slack error: {err}", .and_then(serde_json::Value::as_str)
style("").red().bold() .unwrap_or("unknown error");
); println!("\r {} Slack error: {err}", style("").red().bold());
continue; continue;
} }
} }
@ -790,8 +849,16 @@ fn setup_channels() -> Result<ChannelsConfig> {
config.slack = Some(SlackConfig { config.slack = Some(SlackConfig {
bot_token: token, bot_token: token,
app_token: if app_token.is_empty() { None } else { Some(app_token) }, app_token: if app_token.is_empty() {
channel_id: if channel.is_empty() { None } else { Some(channel) }, None
} else {
Some(app_token)
},
channel_id: if channel.is_empty() {
None
} else {
Some(channel)
},
allowed_users: vec![], 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("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!(); println!();
let contacts_str: String = Input::new() let contacts_str: String = Input::new()
@ -824,7 +893,10 @@ fn setup_channels() -> Result<ChannelsConfig> {
let allowed_contacts = if contacts_str.trim() == "*" { let allowed_contacts = if contacts_str.trim() == "*" {
vec!["*".into()] vec!["*".into()]
} else { } 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 }); config.imessage = Some(IMessageConfig { allowed_contacts });
@ -855,9 +927,8 @@ fn setup_channels() -> Result<ChannelsConfig> {
continue; continue;
} }
let access_token: String = Input::new() let access_token: String =
.with_prompt(" Access token") Input::new().with_prompt(" Access token").interact_text()?;
.interact_text()?;
if access_token.trim().is_empty() { if access_token.trim().is_empty() {
println!(" {} Skipped — token required", style("").dim()); println!(" {} Skipped — token required", style("").dim());
@ -936,7 +1007,11 @@ fn setup_channels() -> Result<ChannelsConfig> {
config.webhook = Some(WebhookConfig { config.webhook = Some(WebhookConfig {
port: port.parse().unwrap_or(8080), 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!( println!(
" {} Webhook on port {}", " {} Webhook on port {}",
@ -1330,9 +1405,7 @@ fn print_summary(config: &Config) {
let mut step = 1u8; let mut step = 1u8;
if config.api_key.is_none() { if config.api_key.is_none() {
let env_var = provider_env_var( let env_var = provider_env_var(config.default_provider.as_deref().unwrap_or("openrouter"));
config.default_provider.as_deref().unwrap_or("openrouter"),
);
println!( println!(
" {} Set your API key:", " {} Set your API key:",
style(format!("{step}.")).cyan().bold() style(format!("{step}.")).cyan().bold()
@ -1352,10 +1425,7 @@ fn print_summary(config: &Config) {
style(format!("{step}.")).cyan().bold(), style(format!("{step}.")).cyan().bold(),
style("Launch your channels").white().bold() style("Launch your channels").white().bold()
); );
println!( println!(" {}", style("zeroclaw channel start").yellow());
" {}",
style("zeroclaw channel start").yellow()
);
println!(); println!();
step += 1; step += 1;
} }
@ -1440,10 +1510,7 @@ mod tests {
scaffold_workspace(tmp.path(), &ctx).unwrap(); scaffold_workspace(tmp.path(), &ctx).unwrap();
for dir in &["sessions", "memory", "state", "cron", "skills"] { for dir in &["sessions", "memory", "state", "cron", "skills"] {
assert!( assert!(tmp.path().join(dir).is_dir(), "missing subdirectory: {dir}");
tmp.path().join(dir).is_dir(),
"missing subdirectory: {dir}"
);
} }
} }
@ -1459,7 +1526,10 @@ mod tests {
scaffold_workspace(tmp.path(), &ctx).unwrap(); scaffold_workspace(tmp.path(), &ctx).unwrap();
let user_md = fs::read_to_string(tmp.path().join("USER.md")).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(); let bootstrap = fs::read_to_string(tmp.path().join("BOOTSTRAP.md")).unwrap();
assert!( assert!(

View file

@ -53,9 +53,7 @@ impl Provider for AnthropicProvider {
temperature: f64, temperature: f64,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
let api_key = self.api_key.as_ref().ok_or_else(|| { let api_key = self.api_key.as_ref().ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!("Anthropic API key not set. Set ANTHROPIC_API_KEY or edit config.toml.")
"Anthropic API key not set. Set ANTHROPIC_API_KEY or edit config.toml."
)
})?; })?;
let request = ChatRequest { let request = ChatRequest {
@ -122,10 +120,15 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn chat_fails_without_key() { async fn chat_fails_without_key() {
let p = AnthropicProvider::new(None); 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()); assert!(result.is_err());
let err = result.unwrap_err().to_string(); 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] #[tokio::test]
@ -150,7 +153,10 @@ mod tests {
temperature: 0.7, temperature: 0.7,
}; };
let json = serde_json::to_string(&req).unwrap(); 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("claude-3-opus"));
assert!(json.contains("hello")); assert!(json.contains("hello"));
} }
@ -188,7 +194,8 @@ mod tests {
#[test] #[test]
fn chat_response_multiple_blocks() { 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(); let resp: ChatResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.content.len(), 2); assert_eq!(resp.content.len(), 2);
assert_eq!(resp.content[0].text, "First"); assert_eq!(resp.content[0].text, "First");

View file

@ -170,9 +170,14 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn chat_fails_without_key() { async fn chat_fails_without_key() {
let p = make_provider("Venice", "https://api.venice.ai", None); 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.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] #[test]
@ -180,8 +185,14 @@ mod tests {
let req = ChatRequest { let req = ChatRequest {
model: "llama-3.3-70b".to_string(), model: "llama-3.3-70b".to_string(),
messages: vec![ messages: vec![
Message { role: "system".to_string(), content: "You are ZeroClaw".to_string() }, Message {
Message { role: "user".to_string(), content: "hello".to_string() }, role: "system".to_string(),
content: "You are ZeroClaw".to_string(),
},
Message {
role: "user".to_string(),
content: "hello".to_string(),
},
], ],
temperature: 0.7, temperature: 0.7,
}; };
@ -208,7 +219,10 @@ mod tests {
#[test] #[test]
fn x_api_key_auth_style() { fn x_api_key_auth_style() {
let p = OpenAiCompatibleProvider::new( 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)); assert!(matches!(p.auth_header, AuthStyle::XApiKey));
} }
@ -216,7 +230,10 @@ mod tests {
#[test] #[test]
fn custom_auth_style() { fn custom_auth_style() {
let p = OpenAiCompatibleProvider::new( 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(_))); 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.is_err(), "{} should fail without key", p.name);
assert!( assert!(
result.unwrap_err().to_string().contains("API key not set"), result.unwrap_err().to_string().contains("API key not set"),
"{} error should mention key", p.name "{} error should mention key",
p.name
); );
} }
} }

View file

@ -250,11 +250,29 @@ mod tests {
#[test] #[test]
fn factory_all_providers_create_successfully() { fn factory_all_providers_create_successfully() {
let providers = [ let providers = [
"openrouter", "anthropic", "openai", "ollama", "openrouter",
"venice", "vercel", "cloudflare", "moonshot", "synthetic", "anthropic",
"opencode", "zai", "glm", "minimax", "bedrock", "qianfan", "openai",
"groq", "mistral", "xai", "deepseek", "together", "ollama",
"fireworks", "perplexity", "cohere", "venice",
"vercel",
"cloudflare",
"moonshot",
"synthetic",
"opencode",
"zai",
"glm",
"minimax",
"bedrock",
"qianfan",
"groq",
"mistral",
"xai",
"deepseek",
"together",
"fireworks",
"perplexity",
"cohere",
]; ];
for name in providers { for name in providers {
assert!( assert!(

View file

@ -85,7 +85,9 @@ impl Provider for OllamaProvider {
if !response.status().is_success() { if !response.status().is_success() {
let error = response.text().await?; 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?; let chat_response: ChatResponse = response.json().await?;
@ -126,8 +128,14 @@ mod tests {
let req = ChatRequest { let req = ChatRequest {
model: "llama3".to_string(), model: "llama3".to_string(),
messages: vec![ messages: vec![
Message { role: "system".to_string(), content: "You are ZeroClaw".to_string() }, Message {
Message { role: "user".to_string(), content: "hello".to_string() }, role: "system".to_string(),
content: "You are ZeroClaw".to_string(),
},
Message {
role: "user".to_string(),
content: "hello".to_string(),
},
], ],
stream: false, stream: false,
options: Options { temperature: 0.7 }, options: Options { temperature: 0.7 },
@ -143,9 +151,10 @@ mod tests {
fn request_serializes_without_system() { fn request_serializes_without_system() {
let req = ChatRequest { let req = ChatRequest {
model: "mistral".to_string(), model: "mistral".to_string(),
messages: vec![ messages: vec![Message {
Message { role: "user".to_string(), content: "test".to_string() }, role: "user".to_string(),
], content: "test".to_string(),
}],
stream: false, stream: false,
options: Options { temperature: 0.0 }, options: Options { temperature: 0.0 },
}; };

View file

@ -146,8 +146,14 @@ mod tests {
let req = ChatRequest { let req = ChatRequest {
model: "gpt-4o".to_string(), model: "gpt-4o".to_string(),
messages: vec![ messages: vec![
Message { role: "system".to_string(), content: "You are ZeroClaw".to_string() }, Message {
Message { role: "user".to_string(), content: "hello".to_string() }, role: "system".to_string(),
content: "You are ZeroClaw".to_string(),
},
Message {
role: "user".to_string(),
content: "hello".to_string(),
},
], ],
temperature: 0.7, temperature: 0.7,
}; };
@ -161,9 +167,10 @@ mod tests {
fn request_serializes_without_system() { fn request_serializes_without_system() {
let req = ChatRequest { let req = ChatRequest {
model: "gpt-4o".to_string(), model: "gpt-4o".to_string(),
messages: vec![ messages: vec![Message {
Message { role: "user".to_string(), content: "hello".to_string() }, role: "user".to_string(),
], content: "hello".to_string(),
}],
temperature: 0.0, temperature: 0.0,
}; };
let json = serde_json::to_string(&req).unwrap(); let json = serde_json::to_string(&req).unwrap();

View file

@ -2,12 +2,7 @@ use async_trait::async_trait;
#[async_trait] #[async_trait]
pub trait Provider: Send + Sync { pub trait Provider: Send + Sync {
async fn chat( async fn chat(&self, message: &str, model: &str, temperature: f64) -> anyhow::Result<String> {
&self,
message: &str,
model: &str,
temperature: f64,
) -> anyhow::Result<String> {
self.chat_with_system(None, message, model, temperature) self.chat_with_system(None, message, model, temperature)
.await .await
} }

View file

@ -37,8 +37,13 @@ impl ActionTracker {
/// Record an action and return the current count within the window. /// Record an action and return the current count within the window.
pub fn record(&self) -> usize { pub fn record(&self) -> usize {
let mut actions = self.actions.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let mut actions = self
let cutoff = Instant::now().checked_sub(std::time::Duration::from_secs(3600)).unwrap_or_else(Instant::now); .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.retain(|t| *t > cutoff);
actions.push(Instant::now()); actions.push(Instant::now());
actions.len() actions.len()
@ -46,8 +51,13 @@ impl ActionTracker {
/// Count of actions in the current window without recording. /// Count of actions in the current window without recording.
pub fn count(&self) -> usize { pub fn count(&self) -> usize {
let mut actions = self.actions.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let mut actions = self
let cutoff = Instant::now().checked_sub(std::time::Duration::from_secs(3600)).unwrap_or_else(Instant::now); .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.retain(|t| *t > cutoff);
actions.len() actions.len()
} }
@ -55,7 +65,10 @@ impl ActionTracker {
impl Clone for ActionTracker { impl Clone for ActionTracker {
fn clone(&self) -> Self { 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 { Self {
actions: Mutex::new(actions.clone()), actions: Mutex::new(actions.clone()),
} }
@ -582,7 +595,7 @@ mod tests {
max_actions_per_hour: 1, max_actions_per_hour: 1,
..SecurityPolicy::default() ..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()); // 2 — over
assert!(!p.record_action()); // 3 — still over assert!(!p.record_action()); // 3 — still over
} }

View file

@ -158,7 +158,11 @@ pub fn skills_to_prompt(skills: &[Skill]) -> String {
if !skill.tools.is_empty() { if !skill.tools.is_empty() {
prompt.push_str("Tools:\n"); prompt.push_str("Tools:\n");
for tool in &skill.tools { 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() { if !skill.tools.is_empty() {
println!( println!(
" Tools: {}", " 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() { if !skill.tags.is_empty() {
println!( println!(" Tags: {}", skill.tags.join(", "));
" Tags: {}",
skill.tags.join(", ")
);
} }
} }
} }
@ -270,7 +276,10 @@ pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Re
.output()?; .output()?;
if output.status.success() { 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."); println!(" Restart `zeroclaw channel start` to activate.");
} else { } else {
let stderr = String::from_utf8_lossy(&output.stderr); 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."); 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(()) Ok(())
@ -305,7 +318,11 @@ pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Re
} }
std::fs::remove_dir_all(&skill_path)?; std::fs::remove_dir_all(&skill_path)?;
println!(" {} Skill '{}' removed.", console::style("").green().bold(), name); println!(
" {} Skill '{}' removed.",
console::style("").green().bold(),
name
);
Ok(()) Ok(())
} }
} }

View file

@ -64,7 +64,9 @@ impl Tool for MemoryRecallTool {
Ok(entries) => { Ok(entries) => {
let mut output = format!("Found {} memories:\n", entries.len()); let mut output = format!("Found {} memories:\n", entries.len());
for entry in &entries { 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!( let _ = writeln!(
output, output,
"- [{}] {}: {}{score}", "- [{}] {}: {}{score}",
@ -102,10 +104,7 @@ mod tests {
async fn recall_empty() { async fn recall_empty() {
let (_tmp, mem) = seeded_mem(); let (_tmp, mem) = seeded_mem();
let tool = MemoryRecallTool::new(mem); let tool = MemoryRecallTool::new(mem);
let result = tool let result = tool.execute(json!({"query": "anything"})).await.unwrap();
.execute(json!({"query": "anything"}))
.await
.unwrap();
assert!(result.success); assert!(result.success);
assert!(result.output.contains("No memories found")); assert!(result.output.contains("No memories found"));
} }
@ -131,9 +130,13 @@ mod tests {
async fn recall_respects_limit() { async fn recall_respects_limit() {
let (_tmp, mem) = seeded_mem(); let (_tmp, mem) = seeded_mem();
for i in 0..10 { for i in 0..10 {
mem.store(&format!("k{i}"), &format!("Rust fact {i}"), MemoryCategory::Core) mem.store(
.await &format!("k{i}"),
.unwrap(); &format!("Rust fact {i}"),
MemoryCategory::Core,
)
.await
.unwrap();
} }
let tool = MemoryRecallTool::new(mem); let tool = MemoryRecallTool::new(mem);

View file

@ -32,10 +32,7 @@ pub fn default_tools(security: Arc<SecurityPolicy>) -> Vec<Box<dyn Tool>> {
} }
/// Create full tool registry including memory tools /// Create full tool registry including memory tools
pub fn all_tools( pub fn all_tools(security: Arc<SecurityPolicy>, memory: Arc<dyn Memory>) -> Vec<Box<dyn Tool>> {
security: Arc<SecurityPolicy>,
memory: Arc<dyn Memory>,
) -> Vec<Box<dyn Tool>> {
vec![ vec![
Box::new(ShellTool::new(security.clone())), Box::new(ShellTool::new(security.clone())),
Box::new(FileReadTool::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(), workspace_dir: config.workspace_dir.clone(),
..SecurityPolicy::default() ..SecurityPolicy::default()
}); });
let mem: Arc<dyn Memory> = let mem: Arc<dyn Memory> = Arc::from(crate::memory::create_memory(
Arc::from(crate::memory::create_memory(&config.memory, &config.workspace_dir)?); &config.memory,
&config.workspace_dir,
)?);
let tools_list = all_tools(security, mem); let tools_list = all_tools(security, mem);
match command { match command {

View file

@ -6,9 +6,7 @@ use std::time::Instant;
use tempfile::TempDir; use tempfile::TempDir;
// We test both backends through the public memory module // We test both backends through the public memory module
use zeroclaw::memory::{ use zeroclaw::memory::{markdown::MarkdownMemory, sqlite::SqliteMemory, Memory, MemoryCategory};
markdown::MarkdownMemory, sqlite::SqliteMemory, Memory, MemoryCategory,
};
// ── Helpers ──────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────
@ -80,16 +78,52 @@ async fn compare_recall_quality() {
// Seed both with identical data // Seed both with identical data
let entries = vec![ let entries = vec![
("lang_pref", "User prefers Rust over Python", MemoryCategory::Core), (
("editor", "Uses VS Code with rust-analyzer", MemoryCategory::Core), "lang_pref",
"User prefers Rust over Python",
MemoryCategory::Core,
),
(
"editor",
"Uses VS Code with rust-analyzer",
MemoryCategory::Core,
),
("tz", "Timezone is EST, works 9-5", MemoryCategory::Core), ("tz", "Timezone is EST, works 9-5", MemoryCategory::Core),
("proj1", "Working on ZeroClaw AI assistant", MemoryCategory::Daily), (
("proj2", "Previous project was a web scraper in Python", MemoryCategory::Daily), "proj1",
("deploy", "Deploys to Hetzner VPS via Docker", MemoryCategory::Core), "Working on ZeroClaw AI assistant",
("model", "Prefers Claude Sonnet for coding tasks", MemoryCategory::Core), MemoryCategory::Daily,
("style", "Likes concise responses, no fluff", MemoryCategory::Core), ),
("rust_note", "Rust's ownership model prevents memory bugs", MemoryCategory::Daily), (
("perf", "Cares about binary size and startup time", MemoryCategory::Core), "proj2",
"Previous project was a web scraper in Python",
MemoryCategory::Daily,
),
(
"deploy",
"Deploys to Hetzner VPS via Docker",
MemoryCategory::Core,
),
(
"model",
"Prefers Claude Sonnet for coding tasks",
MemoryCategory::Core,
),
(
"style",
"Likes concise responses, no fluff",
MemoryCategory::Core,
),
(
"rust_note",
"Rust's ownership model prevents memory bugs",
MemoryCategory::Daily,
),
(
"perf",
"Cares about binary size and startup time",
MemoryCategory::Core,
),
]; ];
for (key, content, cat) in &entries { for (key, content, cat) in &entries {
@ -270,8 +304,10 @@ async fn compare_upsert() {
println!("\n============================================================"); println!("\n============================================================");
println!("UPSERT (store same key twice):"); println!("UPSERT (store same key twice):");
println!(" SQLite: count={sq_count}, latest=\"{}\"", println!(
sq_entry.as_ref().map_or("none", |e| &e.content)); " SQLite: count={sq_count}, latest=\"{}\"",
sq_entry.as_ref().map_or("none", |e| &e.content)
);
println!(" Markdown: count={md_count} (append-only, both entries kept)"); println!(" Markdown: count={md_count} (append-only, both entries kept)");
println!(" Can still find latest: {}", !md_results.is_empty()); println!(" Can still find latest: {}", !md_results.is_empty());
@ -311,7 +347,11 @@ async fn compare_forget() {
); );
println!( println!(
" Markdown: {} (append-only by design)", " Markdown: {} (append-only by design)",
if md_forgot { "✅ Deleted" } else { "⚠️ Cannot delete (audit trail)" }, if md_forgot {
"✅ Deleted"
} else {
"⚠️ Cannot delete (audit trail)"
},
); );
// SQLite can delete // SQLite can delete
@ -332,14 +372,28 @@ async fn compare_category_filter() {
let md = markdown_backend(tmp_md.path()); let md = markdown_backend(tmp_md.path());
// Mix of categories // Mix of categories
sq.store("a", "core fact 1", MemoryCategory::Core).await.unwrap(); sq.store("a", "core fact 1", MemoryCategory::Core)
sq.store("b", "core fact 2", MemoryCategory::Core).await.unwrap(); .await
sq.store("c", "daily note", MemoryCategory::Daily).await.unwrap(); .unwrap();
sq.store("d", "convo msg", MemoryCategory::Conversation).await.unwrap(); sq.store("b", "core fact 2", MemoryCategory::Core)
.await
.unwrap();
sq.store("c", "daily note", MemoryCategory::Daily)
.await
.unwrap();
sq.store("d", "convo msg", MemoryCategory::Conversation)
.await
.unwrap();
md.store("a", "core fact 1", MemoryCategory::Core).await.unwrap(); md.store("a", "core fact 1", MemoryCategory::Core)
md.store("b", "core fact 2", MemoryCategory::Core).await.unwrap(); .await
md.store("c", "daily note", MemoryCategory::Daily).await.unwrap(); .unwrap();
md.store("b", "core fact 2", MemoryCategory::Core)
.await
.unwrap();
md.store("c", "daily note", MemoryCategory::Daily)
.await
.unwrap();
let sq_core = sq.list(Some(&MemoryCategory::Core)).await.unwrap(); let sq_core = sq.list(Some(&MemoryCategory::Core)).await.unwrap();
let sq_daily = sq.list(Some(&MemoryCategory::Daily)).await.unwrap(); let sq_daily = sq.list(Some(&MemoryCategory::Daily)).await.unwrap();
@ -352,10 +406,19 @@ async fn compare_category_filter() {
println!("\n============================================================"); println!("\n============================================================");
println!("CATEGORY FILTERING:"); println!("CATEGORY FILTERING:");
println!(" SQLite: core={}, daily={}, conv={}, all={}", println!(
sq_core.len(), sq_daily.len(), sq_conv.len(), sq_all.len()); " SQLite: core={}, daily={}, conv={}, all={}",
println!(" Markdown: core={}, daily={}, all={}", sq_core.len(),
md_core.len(), md_daily.len(), md_all.len()); sq_daily.len(),
sq_conv.len(),
sq_all.len()
);
println!(
" Markdown: core={}, daily={}, all={}",
md_core.len(),
md_daily.len(),
md_all.len()
);
// SQLite: precise category filtering via SQL WHERE // SQLite: precise category filtering via SQL WHERE
assert_eq!(sq_core.len(), 2); assert_eq!(sq_core.len(), 2);