Skip to main content

crabtalk_core/
storage.rs

1//! Persistence trait and domain types.
2//!
3//! [`Storage`] is the unified persistence backend — one trait, one
4//! implementation per backend. Memory lives in its own `crabtalk-memory`
5//! crate and is not part of this trait.
6
7use crate::{
8    AgentConfig, AgentEvent, AgentId, AgentStep, DaemonConfig, McpServerConfig, model::HistoryEntry,
9};
10use anyhow::Result;
11use crabllm_core::Usage;
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14
15// ── Storage trait ───────────────────────────────────────────────────
16
17/// Unified persistence backend.
18///
19/// All read/write operations for skills, sessions, and agents live
20/// here. Implementations own their encoding and storage layout — the
21/// trait speaks domain types only.
22pub trait Storage: Send + Sync + 'static {
23    // ── Skills (read-only — skills are discovered from disk, not
24    //    created through the runtime) ───────────────────────────────
25
26    /// List all available skills.
27    fn list_skills(&self) -> Result<Vec<Skill>>;
28
29    /// Load a skill by name. Returns `None` if not found.
30    fn load_skill(&self, name: &str) -> Result<Option<Skill>>;
31
32    // ── Sessions ───────────────────────────────────────────────────
33
34    /// Create a new session. Returns an opaque handle.
35    fn create_session(&self, agent: &str, created_by: &str) -> Result<SessionHandle>;
36
37    /// Find the latest session for an (agent, created_by) identity.
38    fn find_latest_session(&self, agent: &str, created_by: &str) -> Result<Option<SessionHandle>>;
39
40    /// Load a session's meta and working-context history.
41    fn load_session(&self, handle: &SessionHandle) -> Result<Option<SessionSnapshot>>;
42
43    /// List all sessions.
44    fn list_sessions(&self) -> Result<Vec<SessionSummary>>;
45
46    /// Append history entries to a session.
47    fn append_session_messages(
48        &self,
49        handle: &SessionHandle,
50        entries: &[HistoryEntry],
51    ) -> Result<()>;
52
53    /// Append trace event entries.
54    fn append_session_events(&self, handle: &SessionHandle, events: &[EventLine]) -> Result<()>;
55
56    /// Append a compact marker (archive boundary). `archive_name`
57    /// references the `Archive`-kind entry in `memory` where the
58    /// summary content actually lives. The marker only carries the
59    /// pointer — session storage never sees the summary text.
60    fn append_session_compact(&self, handle: &SessionHandle, archive_name: &str) -> Result<()>;
61
62    /// Overwrite session metadata.
63    fn update_session_meta(&self, handle: &SessionHandle, meta: &ConversationMeta) -> Result<()>;
64
65    /// Delete a session entirely.
66    fn delete_session(&self, handle: &SessionHandle) -> Result<bool>;
67
68    // ── Agents ─────────────────────────────────────────────────────
69
70    /// List all persisted agent configs (with prompts loaded).
71    fn list_agents(&self) -> Result<Vec<AgentConfig>>;
72
73    /// Load a single agent by ULID.
74    fn load_agent(&self, id: &AgentId) -> Result<Option<AgentConfig>>;
75
76    /// Load a single agent by name.
77    fn load_agent_by_name(&self, name: &str) -> Result<Option<AgentConfig>>;
78
79    /// Create or replace an agent config and prompt. `config.id` and
80    /// `config.name` must both be set — implementations bail otherwise
81    /// (otherwise the prompt becomes an orphan, unreachable by name or
82    /// by listing).
83    fn upsert_agent(&self, config: &AgentConfig, prompt: &str) -> Result<()>;
84
85    /// Delete an agent by ULID. Returns `true` if it existed.
86    fn delete_agent(&self, id: &AgentId) -> Result<bool>;
87
88    /// Rename an agent. The ULID stays stable.
89    fn rename_agent(&self, id: &AgentId, new_name: &str) -> Result<bool>;
90
91    // ── Config ──────────────────────────────────────────────────────
92
93    /// Load the daemon configuration (`config.toml`).
94    fn load_config(&self) -> Result<DaemonConfig>;
95
96    /// Overwrite the daemon configuration.
97    fn save_config(&self, config: &DaemonConfig) -> Result<()>;
98
99    /// Create the initial config directory structure and seed the
100    /// default `crab` agent if no agent is stored yet.
101    ///
102    /// `default_model` is the model assigned to the seeded crab agent.
103    /// Callers pick it from the configured providers; an empty string
104    /// here would produce an unusable agent, so callers must ensure a
105    /// provider is configured first.
106    fn scaffold(&self, default_model: &str) -> Result<()>;
107
108    // ── MCP servers ────────────────────────────────────────────────
109
110    /// List all persisted MCP server configs, keyed by name.
111    fn list_mcps(&self) -> Result<BTreeMap<String, McpServerConfig>>;
112
113    /// Load a single MCP server by name.
114    fn load_mcp(&self, name: &str) -> Result<Option<McpServerConfig>>;
115
116    /// Create or replace an MCP server config. Keyed by `config.name`.
117    fn upsert_mcp(&self, config: &McpServerConfig) -> Result<()>;
118
119    /// Delete an MCP server by name. `true` if it existed.
120    fn delete_mcp(&self, name: &str) -> Result<bool>;
121}
122
123/// Reject names that won't survive serialization as a TOML table key.
124/// Used by MCP and agent CRUD to keep `local/settings.toml` from
125/// silently aliasing entries (e.g. `mcp."foo.bar"` round-trips today
126/// but a hand-edit dropping the quotes would corrupt the file).
127pub fn validate_table_name(kind: &str, name: &str) -> Result<()> {
128    if name.is_empty() {
129        anyhow::bail!("{kind}: name must not be empty");
130    }
131    if name
132        .chars()
133        .any(|c| matches!(c, '.' | '[' | ']' | '"') || c.is_control())
134    {
135        anyhow::bail!(
136            "{kind}: name '{name}' must not contain '.', '[', ']', '\"', or control chars"
137        );
138    }
139    Ok(())
140}
141
142// ── Sessions ────────────────────────────────────────────────────────
143
144/// Opaque handle identifying a persisted session. Created by the repo
145/// on `create`, returned by `find_latest`. Callers pass it back to
146/// append/load methods without interpreting the inner value.
147#[derive(Debug, Clone, PartialEq, Eq, Hash)]
148pub struct SessionHandle(String);
149
150impl SessionHandle {
151    /// Construct a handle from a repo-assigned identifier.
152    pub fn new(slug: impl Into<String>) -> Self {
153        Self(slug.into())
154    }
155
156    /// The raw identifier.
157    pub fn as_str(&self) -> &str {
158        &self.0
159    }
160}
161
162/// Snapshot returned by [`Storage::load_session`] — meta +
163/// working-context history, already replayed past the last compact
164/// marker.
165pub struct SessionSnapshot {
166    pub meta: ConversationMeta,
167    pub history: Vec<HistoryEntry>,
168    /// Name of the `Archive`-kind memory entry whose content represents
169    /// the compacted prefix of this session, if any. Callers that want
170    /// the full resumed context resolve this against `memory` and
171    /// prepend the entry's content to `history`.
172    pub archive: Option<String>,
173}
174
175/// Summary returned by [`Storage::list_sessions`] for enumeration.
176pub struct SessionSummary {
177    pub handle: SessionHandle,
178    pub meta: ConversationMeta,
179}
180
181/// Conversation metadata persisted alongside the session.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ConversationMeta {
184    pub agent: String,
185    pub created_by: String,
186    pub created_at: String,
187    #[serde(default)]
188    pub title: String,
189    #[serde(default)]
190    pub uptime_secs: u64,
191    /// Topic this conversation belongs to, if any. `None` means the
192    /// conversation is a tmp chat and should not have been persisted —
193    /// only topic-bound conversations reach the Storage layer.
194    #[serde(default)]
195    pub topic: Option<String>,
196}
197
198/// A trace entry persisted alongside messages.
199///
200/// Captures the *how* of agent execution (which tools ran, how long
201/// they took, why the agent stopped, what it cost) — information that
202/// doesn't fit in the message stream itself but is invaluable for
203/// debugging.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(tag = "event", rename_all = "snake_case")]
206pub enum EventLine {
207    /// One round of tool calls dispatched by the model.
208    ToolStart {
209        calls: Vec<ToolCallTrace>,
210        ts: String,
211    },
212    /// A single tool call completed.
213    ToolResult {
214        call_id: String,
215        duration_ms: u64,
216        ts: String,
217    },
218    /// Agent run finished — final metadata and token usage.
219    Done {
220        model: String,
221        iterations: usize,
222        stop_reason: String,
223        usage: Usage,
224        ts: String,
225    },
226    /// User steered the agent mid-stream.
227    UserSteered { content: String, ts: String },
228}
229
230/// Compact tool call info for [`EventLine::ToolStart`].
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct ToolCallTrace {
233    pub id: String,
234    pub name: String,
235    #[serde(default, skip_serializing_if = "String::is_empty")]
236    pub arguments: String,
237}
238
239impl EventLine {
240    /// Build a trace entry from an [`AgentEvent`]. Returns `None` for
241    /// events that don't carry useful trace information.
242    pub fn from_agent_event(event: &AgentEvent) -> Option<Self> {
243        let ts = chrono::Utc::now().to_rfc3339();
244        match event {
245            AgentEvent::ToolCallsStart(calls) => Some(Self::ToolStart {
246                calls: calls
247                    .iter()
248                    .map(|c| ToolCallTrace {
249                        id: c.id.clone(),
250                        name: c.function.name.to_string(),
251                        arguments: c.function.arguments.clone(),
252                    })
253                    .collect(),
254                ts,
255            }),
256            AgentEvent::ToolResult {
257                call_id,
258                duration_ms,
259                ..
260            } => Some(Self::ToolResult {
261                call_id: call_id.clone(),
262                duration_ms: *duration_ms,
263                ts,
264            }),
265            AgentEvent::Done(resp) => Some(Self::Done {
266                model: resp.model.clone(),
267                iterations: resp.iterations,
268                stop_reason: resp.stop_reason.to_string(),
269                usage: sum_step_usage(&resp.steps),
270                ts,
271            }),
272            AgentEvent::UserSteered { content } => Some(Self::UserSteered {
273                content: content.clone(),
274                ts,
275            }),
276            _ => None,
277        }
278    }
279}
280
281/// Sum token usage across all steps.
282fn sum_step_usage(steps: &[AgentStep]) -> Usage {
283    steps.iter().fold(Usage::default(), |mut acc, step| {
284        let u = &step.usage;
285        acc.prompt_tokens += u.prompt_tokens;
286        acc.completion_tokens += u.completion_tokens;
287        acc.total_tokens += u.total_tokens;
288        if let Some(v) = u.prompt_cache_hit_tokens {
289            *acc.prompt_cache_hit_tokens.get_or_insert(0) += v;
290        }
291        if let Some(v) = u.prompt_cache_miss_tokens {
292            *acc.prompt_cache_miss_tokens.get_or_insert(0) += v;
293        }
294        acc
295    })
296}
297
298/// Sanitize a string into a filesystem-safe slug for session naming.
299pub fn sender_slug(s: &str) -> String {
300    s.chars()
301        .map(|c| {
302            if c.is_alphanumeric() || c == '-' {
303                c.to_ascii_lowercase()
304            } else {
305                '-'
306            }
307        })
308        .collect::<String>()
309        .split('-')
310        .filter(|s| !s.is_empty())
311        .collect::<Vec<_>>()
312        .join("-")
313}
314
315// ── Skills ──────────────────────────────────────────────────────────
316
317/// A named unit of agent behavior (agentskills.io format).
318#[derive(Debug, Clone)]
319pub struct Skill {
320    pub name: String,
321    pub description: String,
322    pub license: Option<String>,
323    pub compatibility: Option<String>,
324    pub metadata: BTreeMap<String, String>,
325    pub allowed_tools: Vec<String>,
326    pub body: String,
327}