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}