use crate::{
AgentConfig, AgentEvent, AgentId, AgentStep, DaemonConfig, McpServerConfig, model::HistoryEntry,
};
use anyhow::Result;
use crabllm_core::Usage;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub trait Storage: Send + Sync + 'static {
fn list_skills(&self) -> Result<Vec<Skill>>;
fn load_skill(&self, name: &str) -> Result<Option<Skill>>;
fn create_session(&self, agent: &str, created_by: &str) -> Result<SessionHandle>;
fn find_latest_session(&self, agent: &str, created_by: &str) -> Result<Option<SessionHandle>>;
fn load_session(&self, handle: &SessionHandle) -> Result<Option<SessionSnapshot>>;
fn list_sessions(&self) -> Result<Vec<SessionSummary>>;
fn append_session_messages(
&self,
handle: &SessionHandle,
entries: &[HistoryEntry],
) -> Result<()>;
fn append_session_events(&self, handle: &SessionHandle, events: &[EventLine]) -> Result<()>;
fn append_session_compact(&self, handle: &SessionHandle, archive_name: &str) -> Result<()>;
fn update_session_meta(&self, handle: &SessionHandle, meta: &ConversationMeta) -> Result<()>;
fn delete_session(&self, handle: &SessionHandle) -> Result<bool>;
fn list_agents(&self) -> Result<Vec<AgentConfig>>;
fn load_agent(&self, id: &AgentId) -> Result<Option<AgentConfig>>;
fn load_agent_by_name(&self, name: &str) -> Result<Option<AgentConfig>>;
fn upsert_agent(&self, config: &AgentConfig, prompt: &str) -> Result<()>;
fn delete_agent(&self, id: &AgentId) -> Result<bool>;
fn rename_agent(&self, id: &AgentId, new_name: &str) -> Result<bool>;
fn load_config(&self) -> Result<DaemonConfig>;
fn save_config(&self, config: &DaemonConfig) -> Result<()>;
fn scaffold(&self, default_model: &str) -> Result<()>;
fn list_mcps(&self) -> Result<BTreeMap<String, McpServerConfig>>;
fn load_mcp(&self, name: &str) -> Result<Option<McpServerConfig>>;
fn upsert_mcp(&self, config: &McpServerConfig) -> Result<()>;
fn delete_mcp(&self, name: &str) -> Result<bool>;
}
pub fn validate_table_name(kind: &str, name: &str) -> Result<()> {
if name.is_empty() {
anyhow::bail!("{kind}: name must not be empty");
}
if name
.chars()
.any(|c| matches!(c, '.' | '[' | ']' | '"') || c.is_control())
{
anyhow::bail!(
"{kind}: name '{name}' must not contain '.', '[', ']', '\"', or control chars"
);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SessionHandle(String);
impl SessionHandle {
pub fn new(slug: impl Into<String>) -> Self {
Self(slug.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
pub struct SessionSnapshot {
pub meta: ConversationMeta,
pub history: Vec<HistoryEntry>,
pub archive: Option<String>,
}
pub struct SessionSummary {
pub handle: SessionHandle,
pub meta: ConversationMeta,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationMeta {
pub agent: String,
pub created_by: String,
pub created_at: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub uptime_secs: u64,
#[serde(default)]
pub topic: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum EventLine {
ToolStart {
calls: Vec<ToolCallTrace>,
ts: String,
},
ToolResult {
call_id: String,
duration_ms: u64,
ts: String,
},
Done {
model: String,
iterations: usize,
stop_reason: String,
usage: Usage,
ts: String,
},
UserSteered { content: String, ts: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallTrace {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub arguments: String,
}
impl EventLine {
pub fn from_agent_event(event: &AgentEvent) -> Option<Self> {
let ts = chrono::Utc::now().to_rfc3339();
match event {
AgentEvent::ToolCallsStart(calls) => Some(Self::ToolStart {
calls: calls
.iter()
.map(|c| ToolCallTrace {
id: c.id.clone(),
name: c.function.name.to_string(),
arguments: c.function.arguments.clone(),
})
.collect(),
ts,
}),
AgentEvent::ToolResult {
call_id,
duration_ms,
..
} => Some(Self::ToolResult {
call_id: call_id.clone(),
duration_ms: *duration_ms,
ts,
}),
AgentEvent::Done(resp) => Some(Self::Done {
model: resp.model.clone(),
iterations: resp.iterations,
stop_reason: resp.stop_reason.to_string(),
usage: sum_step_usage(&resp.steps),
ts,
}),
AgentEvent::UserSteered { content } => Some(Self::UserSteered {
content: content.clone(),
ts,
}),
_ => None,
}
}
}
fn sum_step_usage(steps: &[AgentStep]) -> Usage {
steps.iter().fold(Usage::default(), |mut acc, step| {
let u = &step.usage;
acc.prompt_tokens += u.prompt_tokens;
acc.completion_tokens += u.completion_tokens;
acc.total_tokens += u.total_tokens;
if let Some(v) = u.prompt_cache_hit_tokens {
*acc.prompt_cache_hit_tokens.get_or_insert(0) += v;
}
if let Some(v) = u.prompt_cache_miss_tokens {
*acc.prompt_cache_miss_tokens.get_or_insert(0) += v;
}
acc
})
}
pub fn sender_slug(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub description: String,
pub license: Option<String>,
pub compatibility: Option<String>,
pub metadata: BTreeMap<String, String>,
pub allowed_tools: Vec<String>,
pub body: String,
}