feat: initial release — ZeroClaw v0.1.0

- 22 AI providers (OpenRouter, Anthropic, OpenAI, Mistral, etc.)
- 7 channels (CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook)
- 5-step onboarding wizard with Project Context personalization
- OpenClaw-aligned system prompt (SOUL.md, IDENTITY.md, USER.md, AGENTS.md, etc.)
- SQLite memory backend with auto-save
- Skills system with on-demand loading
- Security: autonomy levels, command allowlists, cost limits
- 532 tests passing, 0 clippy warnings
This commit is contained in:
argenis de la rosa 2026-02-13 12:19:14 -05:00
commit 05cb353f7f
71 changed files with 15757 additions and 0 deletions

119
src/observability/log.rs Normal file
View file

@ -0,0 +1,119 @@
use super::traits::{Observer, ObserverEvent, ObserverMetric};
use tracing::info;
/// Log-based observer — uses tracing, zero external deps
pub struct LogObserver;
impl LogObserver {
pub fn new() -> Self {
Self
}
}
impl Observer for LogObserver {
fn record_event(&self, event: &ObserverEvent) {
match event {
ObserverEvent::AgentStart { provider, model } => {
info!(provider = %provider, model = %model, "agent.start");
}
ObserverEvent::AgentEnd {
duration,
tokens_used,
} => {
let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
info!(duration_ms = ms, tokens = ?tokens_used, "agent.end");
}
ObserverEvent::ToolCall {
tool,
duration,
success,
} => {
let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
info!(tool = %tool, duration_ms = ms, success = success, "tool.call");
}
ObserverEvent::ChannelMessage { channel, direction } => {
info!(channel = %channel, direction = %direction, "channel.message");
}
ObserverEvent::HeartbeatTick => {
info!("heartbeat.tick");
}
ObserverEvent::Error { component, message } => {
info!(component = %component, error = %message, "error");
}
}
}
fn record_metric(&self, metric: &ObserverMetric) {
match metric {
ObserverMetric::RequestLatency(d) => {
let ms = u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
info!(latency_ms = ms, "metric.request_latency");
}
ObserverMetric::TokensUsed(t) => {
info!(tokens = t, "metric.tokens_used");
}
ObserverMetric::ActiveSessions(s) => {
info!(sessions = s, "metric.active_sessions");
}
ObserverMetric::QueueDepth(d) => {
info!(depth = d, "metric.queue_depth");
}
}
}
fn name(&self) -> &str {
"log"
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn log_observer_name() {
assert_eq!(LogObserver::new().name(), "log");
}
#[test]
fn log_observer_all_events_no_panic() {
let obs = LogObserver::new();
obs.record_event(&ObserverEvent::AgentStart {
provider: "openrouter".into(),
model: "claude-sonnet".into(),
});
obs.record_event(&ObserverEvent::AgentEnd {
duration: Duration::from_millis(500),
tokens_used: Some(100),
});
obs.record_event(&ObserverEvent::AgentEnd {
duration: Duration::ZERO,
tokens_used: None,
});
obs.record_event(&ObserverEvent::ToolCall {
tool: "shell".into(),
duration: Duration::from_millis(10),
success: false,
});
obs.record_event(&ObserverEvent::ChannelMessage {
channel: "telegram".into(),
direction: "outbound".into(),
});
obs.record_event(&ObserverEvent::HeartbeatTick);
obs.record_event(&ObserverEvent::Error {
component: "provider".into(),
message: "timeout".into(),
});
}
#[test]
fn log_observer_all_metrics_no_panic() {
let obs = LogObserver::new();
obs.record_metric(&ObserverMetric::RequestLatency(Duration::from_secs(2)));
obs.record_metric(&ObserverMetric::TokensUsed(0));
obs.record_metric(&ObserverMetric::TokensUsed(u64::MAX));
obs.record_metric(&ObserverMetric::ActiveSessions(1));
obs.record_metric(&ObserverMetric::QueueDepth(999));
}
}

76
src/observability/mod.rs Normal file
View file

@ -0,0 +1,76 @@
pub mod log;
pub mod multi;
pub mod noop;
pub mod traits;
pub use self::log::LogObserver;
pub use noop::NoopObserver;
pub use traits::{Observer, ObserverEvent};
use crate::config::ObservabilityConfig;
/// Factory: create the right observer from config
pub fn create_observer(config: &ObservabilityConfig) -> Box<dyn Observer> {
match config.backend.as_str() {
"log" => Box::new(LogObserver::new()),
"none" | "noop" => Box::new(NoopObserver),
_ => {
tracing::warn!(
"Unknown observability backend '{}', falling back to noop",
config.backend
);
Box::new(NoopObserver)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn factory_none_returns_noop() {
let cfg = ObservabilityConfig {
backend: "none".into(),
};
assert_eq!(create_observer(&cfg).name(), "noop");
}
#[test]
fn factory_noop_returns_noop() {
let cfg = ObservabilityConfig {
backend: "noop".into(),
};
assert_eq!(create_observer(&cfg).name(), "noop");
}
#[test]
fn factory_log_returns_log() {
let cfg = ObservabilityConfig {
backend: "log".into(),
};
assert_eq!(create_observer(&cfg).name(), "log");
}
#[test]
fn factory_unknown_falls_back_to_noop() {
let cfg = ObservabilityConfig {
backend: "prometheus".into(),
};
assert_eq!(create_observer(&cfg).name(), "noop");
}
#[test]
fn factory_empty_string_falls_back_to_noop() {
let cfg = ObservabilityConfig { backend: "".into() };
assert_eq!(create_observer(&cfg).name(), "noop");
}
#[test]
fn factory_garbage_falls_back_to_noop() {
let cfg = ObservabilityConfig {
backend: "xyzzy_garbage_123".into(),
};
assert_eq!(create_observer(&cfg).name(), "noop");
}
}

154
src/observability/multi.rs Normal file
View file

@ -0,0 +1,154 @@
use super::traits::{Observer, ObserverEvent, ObserverMetric};
/// Combine multiple observers — fan-out events to all backends
pub struct MultiObserver {
observers: Vec<Box<dyn Observer>>,
}
impl MultiObserver {
pub fn new(observers: Vec<Box<dyn Observer>>) -> Self {
Self { observers }
}
}
impl Observer for MultiObserver {
fn record_event(&self, event: &ObserverEvent) {
for obs in &self.observers {
obs.record_event(event);
}
}
fn record_metric(&self, metric: &ObserverMetric) {
for obs in &self.observers {
obs.record_metric(metric);
}
}
fn flush(&self) {
for obs in &self.observers {
obs.flush();
}
}
fn name(&self) -> &str {
"multi"
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
/// Test observer that counts calls
struct CountingObserver {
event_count: Arc<AtomicUsize>,
metric_count: Arc<AtomicUsize>,
flush_count: Arc<AtomicUsize>,
}
impl CountingObserver {
fn new(
event_count: Arc<AtomicUsize>,
metric_count: Arc<AtomicUsize>,
flush_count: Arc<AtomicUsize>,
) -> Self {
Self {
event_count,
metric_count,
flush_count,
}
}
}
impl Observer for CountingObserver {
fn record_event(&self, _event: &ObserverEvent) {
self.event_count.fetch_add(1, Ordering::SeqCst);
}
fn record_metric(&self, _metric: &ObserverMetric) {
self.metric_count.fetch_add(1, Ordering::SeqCst);
}
fn flush(&self) {
self.flush_count.fetch_add(1, Ordering::SeqCst);
}
fn name(&self) -> &str {
"counting"
}
}
#[test]
fn multi_name() {
let m = MultiObserver::new(vec![]);
assert_eq!(m.name(), "multi");
}
#[test]
fn multi_empty_no_panic() {
let m = MultiObserver::new(vec![]);
m.record_event(&ObserverEvent::HeartbeatTick);
m.record_metric(&ObserverMetric::TokensUsed(10));
m.flush();
}
#[test]
fn multi_fans_out_events() {
let ec1 = Arc::new(AtomicUsize::new(0));
let mc1 = Arc::new(AtomicUsize::new(0));
let fc1 = Arc::new(AtomicUsize::new(0));
let ec2 = Arc::new(AtomicUsize::new(0));
let mc2 = Arc::new(AtomicUsize::new(0));
let fc2 = Arc::new(AtomicUsize::new(0));
let m = MultiObserver::new(vec![
Box::new(CountingObserver::new(ec1.clone(), mc1.clone(), fc1.clone())),
Box::new(CountingObserver::new(ec2.clone(), mc2.clone(), fc2.clone())),
]);
m.record_event(&ObserverEvent::HeartbeatTick);
m.record_event(&ObserverEvent::HeartbeatTick);
m.record_event(&ObserverEvent::HeartbeatTick);
assert_eq!(ec1.load(Ordering::SeqCst), 3);
assert_eq!(ec2.load(Ordering::SeqCst), 3);
}
#[test]
fn multi_fans_out_metrics() {
let ec1 = Arc::new(AtomicUsize::new(0));
let mc1 = Arc::new(AtomicUsize::new(0));
let fc1 = Arc::new(AtomicUsize::new(0));
let ec2 = Arc::new(AtomicUsize::new(0));
let mc2 = Arc::new(AtomicUsize::new(0));
let fc2 = Arc::new(AtomicUsize::new(0));
let m = MultiObserver::new(vec![
Box::new(CountingObserver::new(ec1.clone(), mc1.clone(), fc1.clone())),
Box::new(CountingObserver::new(ec2.clone(), mc2.clone(), fc2.clone())),
]);
m.record_metric(&ObserverMetric::TokensUsed(100));
m.record_metric(&ObserverMetric::RequestLatency(Duration::from_millis(5)));
assert_eq!(mc1.load(Ordering::SeqCst), 2);
assert_eq!(mc2.load(Ordering::SeqCst), 2);
}
#[test]
fn multi_fans_out_flush() {
let ec = Arc::new(AtomicUsize::new(0));
let mc = Arc::new(AtomicUsize::new(0));
let fc1 = Arc::new(AtomicUsize::new(0));
let fc2 = Arc::new(AtomicUsize::new(0));
let m = MultiObserver::new(vec![
Box::new(CountingObserver::new(ec.clone(), mc.clone(), fc1.clone())),
Box::new(CountingObserver::new(ec.clone(), mc.clone(), fc2.clone())),
]);
m.flush();
assert_eq!(fc1.load(Ordering::SeqCst), 1);
assert_eq!(fc2.load(Ordering::SeqCst), 1);
}
}

72
src/observability/noop.rs Normal file
View file

@ -0,0 +1,72 @@
use super::traits::{Observer, ObserverEvent, ObserverMetric};
/// Zero-overhead observer — all methods compile to nothing
pub struct NoopObserver;
impl Observer for NoopObserver {
#[inline(always)]
fn record_event(&self, _event: &ObserverEvent) {}
#[inline(always)]
fn record_metric(&self, _metric: &ObserverMetric) {}
fn name(&self) -> &str {
"noop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn noop_name() {
assert_eq!(NoopObserver.name(), "noop");
}
#[test]
fn noop_record_event_does_not_panic() {
let obs = NoopObserver;
obs.record_event(&ObserverEvent::HeartbeatTick);
obs.record_event(&ObserverEvent::AgentStart {
provider: "test".into(),
model: "test".into(),
});
obs.record_event(&ObserverEvent::AgentEnd {
duration: Duration::from_millis(100),
tokens_used: Some(42),
});
obs.record_event(&ObserverEvent::AgentEnd {
duration: Duration::ZERO,
tokens_used: None,
});
obs.record_event(&ObserverEvent::ToolCall {
tool: "shell".into(),
duration: Duration::from_secs(1),
success: true,
});
obs.record_event(&ObserverEvent::ChannelMessage {
channel: "cli".into(),
direction: "inbound".into(),
});
obs.record_event(&ObserverEvent::Error {
component: "test".into(),
message: "boom".into(),
});
}
#[test]
fn noop_record_metric_does_not_panic() {
let obs = NoopObserver;
obs.record_metric(&ObserverMetric::RequestLatency(Duration::from_millis(50)));
obs.record_metric(&ObserverMetric::TokensUsed(1000));
obs.record_metric(&ObserverMetric::ActiveSessions(5));
obs.record_metric(&ObserverMetric::QueueDepth(0));
}
#[test]
fn noop_flush_does_not_panic() {
NoopObserver.flush();
}
}

View file

@ -0,0 +1,52 @@
use std::time::Duration;
/// Events the observer can record
#[derive(Debug, Clone)]
pub enum ObserverEvent {
AgentStart {
provider: String,
model: String,
},
AgentEnd {
duration: Duration,
tokens_used: Option<u64>,
},
ToolCall {
tool: String,
duration: Duration,
success: bool,
},
ChannelMessage {
channel: String,
direction: String,
},
HeartbeatTick,
Error {
component: String,
message: String,
},
}
/// Numeric metrics
#[derive(Debug, Clone)]
pub enum ObserverMetric {
RequestLatency(Duration),
TokensUsed(u64),
ActiveSessions(u64),
QueueDepth(u64),
}
/// Core observability trait — implement for any backend
pub trait Observer: Send + Sync {
/// Record a discrete event
fn record_event(&self, event: &ObserverEvent);
/// Record a numeric metric
fn record_metric(&self, metric: &ObserverMetric);
/// Flush any buffered data (no-op for most backends)
fn flush(&self) {}
/// Human-readable name of this observer
fn name(&self) -> &str;
}