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) ────────────────────────────────────────
|
// ── 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) ────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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!();
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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!(
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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!(
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue