koda_core/persistence.rs
1//! Persistence trait — the storage contract for koda.
2//!
3//! Types and trait definition for the storage layer. The engine
4//! depends on this trait, not the concrete SQLite implementation.
5//!
6//! The default implementation is `Database` in `db.rs`.
7//!
8//! ## Design (DESIGN.md)
9//!
10//! - **Database Backend: SQLite + Persistence Trait (P3)**: This trait
11//! exists because P3 says the storage backend will change. But there's
12//! only one real implementation because P1 says we don't need a second
13//! one yet. When P1 and P3 conflict: P1 wins on timing, P3 wins on
14//! architecture.
15
16use anyhow::Result;
17use std::path::Path;
18
19/// Message roles in the conversation.
20#[derive(Debug, Clone, PartialEq)]
21#[allow(dead_code)]
22pub enum Role {
23 /// System prompt.
24 System,
25 /// User message.
26 User,
27 /// Assistant (LLM) response.
28 Assistant,
29 /// Tool result.
30 Tool,
31}
32
33impl Role {
34 /// String representation for database storage.
35 ///
36 /// # Examples
37 ///
38 /// ```
39 /// use koda_core::persistence::Role;
40 ///
41 /// assert_eq!(Role::User.as_str(), "user");
42 /// assert_eq!(Role::Assistant.as_str(), "assistant");
43 /// ```
44 pub fn as_str(&self) -> &'static str {
45 match self {
46 Self::System => "system",
47 Self::User => "user",
48 Self::Assistant => "assistant",
49 Self::Tool => "tool",
50 }
51 }
52}
53
54impl std::fmt::Display for Role {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 write!(f, "{}", self.as_str())
57 }
58}
59
60impl std::str::FromStr for Role {
61 type Err = String;
62 fn from_str(s: &str) -> Result<Self, Self::Err> {
63 match s {
64 "system" => Ok(Self::System),
65 "user" => Ok(Self::User),
66 "assistant" => Ok(Self::Assistant),
67 "tool" => Ok(Self::Tool),
68 other => Err(format!("unknown role: {other}")),
69 }
70 }
71}
72
73/// A stored message row.
74#[derive(Debug, Clone)]
75#[allow(dead_code)]
76pub struct Message {
77 /// Database row ID.
78 pub id: i64,
79 /// Session this message belongs to.
80 pub session_id: String,
81 /// Message role (system, user, assistant, tool).
82 pub role: Role,
83 /// Text content (may be a summary for Bash results).
84 pub content: Option<String>,
85 /// Full untruncated output (only set for Bash tool results).
86 pub full_content: Option<String>,
87 /// Serialized tool calls JSON.
88 pub tool_calls: Option<String>,
89 /// ID of the tool call this responds to.
90 pub tool_call_id: Option<String>,
91 /// Input tokens for this message.
92 pub prompt_tokens: Option<i64>,
93 /// Output tokens for this message.
94 pub completion_tokens: Option<i64>,
95 /// Cached input tokens.
96 pub cache_read_tokens: Option<i64>,
97 /// Tokens written to cache.
98 pub cache_creation_tokens: Option<i64>,
99 /// Reasoning/thinking tokens.
100 pub thinking_tokens: Option<i64>,
101 /// Full thinking/reasoning text produced by Claude extended thinking.
102 ///
103 /// `None` for non-Claude models, or when thinking was disabled.
104 /// Persisted so the content can be re-rendered on session resume.
105 pub thinking_content: Option<String>,
106 /// ISO 8601 creation timestamp.
107 pub created_at: Option<String>,
108}
109
110/// Detected interruption state for a resumed session.
111///
112/// Returned by [`detect_interruption`](crate::db::queries::detect_interruption)
113/// after inspecting the tail of the message history.
114///
115/// ## Design decision: banner, not auto-resume
116///
117/// Claude Code auto-continues interrupted turns (re-sends the prompt or
118/// injects "Continue from where you left off"). Koda deliberately shows a
119/// banner and lets the user decide, for three reasons:
120///
121/// 1. **Safety** — auto-resuming a destructive tool call (e.g. `rm -rf`)
122/// after a VPN drop is surprising. The user should see the state first.
123/// 2. **Stale context** — the user may have fixed the issue manually while
124/// Koda was disconnected. Auto-resume wastes tokens re-doing work.
125/// 3. **Cost** — resuming to *check history* shouldn't burn an API call.
126///
127/// A single "type `continue` or rephrase" banner is near-zero friction
128/// (one word + Enter) and handles all three cases.
129#[derive(Debug, Clone, PartialEq)]
130pub enum InterruptionKind {
131 /// The user's prompt was never answered (last message is `Role::User`).
132 /// Contains a preview of the unanswered prompt.
133 Prompt(String),
134 /// A tool finished but the assistant never processed the result
135 /// (last message is `Role::Tool`).
136 Tool,
137}
138
139/// Token usage totals for a session.
140#[derive(Debug, Clone, Default)]
141pub struct SessionUsage {
142 /// Total input tokens.
143 pub prompt_tokens: i64,
144 /// Total output tokens.
145 pub completion_tokens: i64,
146 /// Total cached input tokens.
147 pub cache_read_tokens: i64,
148 /// Total tokens written to cache.
149 pub cache_creation_tokens: i64,
150 /// Total reasoning/thinking tokens.
151 pub thinking_tokens: i64,
152 /// Number of API calls made.
153 pub api_calls: i64,
154}
155
156/// Summary info for a stored session.
157#[derive(Debug, Clone)]
158pub struct SessionInfo {
159 /// Session identifier.
160 pub id: String,
161 /// Agent name for this session.
162 pub agent_name: String,
163 /// ISO 8601 creation timestamp.
164 pub created_at: String,
165 /// Total messages in the session.
166 pub message_count: i64,
167 /// Cumulative token count.
168 pub total_tokens: i64,
169 /// Auto-generated title from first user message.
170 pub title: Option<String>,
171 /// Last active approval mode (for restore on resume).
172 pub mode: Option<String>,
173}
174
175/// Stats about compacted (archived) messages in the database.
176#[derive(Debug, Clone, Default)]
177pub struct CompactedStats {
178 /// Number of compacted messages.
179 pub message_count: i64,
180 /// Number of sessions with compacted messages.
181 pub session_count: i64,
182 /// Approximate size in bytes of compacted message content.
183 pub size_bytes: i64,
184 /// ISO 8601 timestamp of the oldest compacted message.
185 pub oldest: Option<String>,
186}
187
188/// Core storage contract for sessions, messages, and metadata.
189#[async_trait::async_trait]
190pub trait Persistence: Send + Sync {
191 // ── Sessions ──
192
193 /// Create a new session, returning its unique ID.
194 async fn create_session(&self, agent_name: &str, project_root: &Path) -> Result<String>;
195 /// List recent sessions for the given project root.
196 async fn list_sessions(&self, limit: i64, project_root: &Path) -> Result<Vec<SessionInfo>>;
197 /// Delete a session by ID. Returns `true` if it existed.
198 async fn delete_session(&self, session_id: &str) -> Result<bool>;
199 /// Set the auto-generated title for a session.
200 async fn set_session_title(&self, session_id: &str, title: &str) -> Result<()>;
201 /// Persist the current approval mode for a session (restored on resume).
202 async fn set_session_mode(&self, session_id: &str, mode: &str) -> Result<()>;
203 /// Get the stored approval mode for a session.
204 async fn get_session_mode(&self, session_id: &str) -> Result<Option<String>>;
205 /// Seconds elapsed since the session was last accessed (`last_accessed_at`).
206 /// Returns `None` if the column is NULL (session never had a context load).
207 async fn get_session_idle_secs(&self, session_id: &str) -> Result<Option<i64>>;
208
209 // ── Messages ──
210
211 /// Insert a message into a session.
212 async fn insert_message(
213 &self,
214 session_id: &str,
215 role: &Role,
216 content: Option<&str>,
217 tool_calls: Option<&str>,
218 tool_call_id: Option<&str>,
219 usage: Option<&crate::providers::TokenUsage>,
220 ) -> Result<i64>;
221
222 /// Insert a message with an explicit agent name (for sub-agent tracking).
223 #[allow(clippy::too_many_arguments)]
224 async fn insert_message_with_agent(
225 &self,
226 session_id: &str,
227 role: &Role,
228 content: Option<&str>,
229 tool_calls: Option<&str>,
230 tool_call_id: Option<&str>,
231 usage: Option<&crate::providers::TokenUsage>,
232 agent_name: Option<&str>,
233 ) -> Result<i64>;
234
235 /// Insert a tool message with full (untruncated) output stored separately.
236 ///
237 /// `content` holds the model-facing summary; `full_content` holds the
238 /// complete output for later retrieval via RecallContext.
239 #[allow(clippy::too_many_arguments)]
240 async fn insert_tool_message_with_full(
241 &self,
242 session_id: &str,
243 content: &str,
244 tool_call_id: &str,
245 full_content: &str,
246 ) -> Result<i64>;
247
248 /// Load active (non-compacted) conversation context for a session.
249 async fn load_context(&self, session_id: &str) -> Result<Vec<Message>>;
250 /// Load all messages in a session (no token limit).
251 async fn load_all_messages(&self, session_id: &str) -> Result<Vec<Message>>;
252 /// Recent user messages across all sessions (for startup hints).
253 async fn recent_user_messages(&self, limit: i64) -> Result<Vec<String>>;
254 /// Last assistant message in a session.
255 async fn last_assistant_message(&self, session_id: &str) -> Result<String>;
256 /// Last user message in a session.
257 async fn last_user_message(&self, session_id: &str) -> Result<String>;
258 /// Check if the session has unresolved tool calls.
259 async fn has_pending_tool_calls(&self, session_id: &str) -> Result<bool>;
260
261 /// Mark an assistant message as fully delivered.
262 ///
263 /// Sets `completed_at = CURRENT_TIMESTAMP`. Only called after a legitimate
264 /// `StreamChunk::Done` — not after user cancellation or a network error.
265 /// A `NULL` `completed_at` means the message is in-progress or was interrupted.
266 async fn mark_message_complete(&self, message_id: i64) -> Result<()>;
267
268 /// Atomically copy a slice of pre-loaded messages into a session.
269 ///
270 /// Used by `fork` sub-agent dispatch (#1022 B20). Pre-fix, the fork
271 /// path looped over `Persistence::insert_message` row-by-row — each
272 /// iteration was 2 queries (INSERT + UPDATE last_accessed_at) plus,
273 /// for assistant rows, a separate `mark_message_complete` (1 more
274 /// query). For an N-message parent history that's ~3N round-trips
275 /// to SQLite, each its own commit, on the synchronous fork hot path.
276 ///
277 /// This method:
278 /// - Wraps every INSERT in a single transaction (one fsync at COMMIT
279 /// instead of N).
280 /// - Sets `completed_at = datetime('now')` inline at INSERT time for
281 /// `Role::Assistant` rows, eliminating the separate
282 /// `mark_message_complete` round-trip per row.
283 /// - Updates `last_accessed_at` on the destination session exactly
284 /// once at the end (not per-row).
285 ///
286 /// Token usage stats are intentionally **not** copied: fork creates a
287 /// new conversation perspective and cost-attributing parent tokens
288 /// to the child would double-count session usage. Same policy as the
289 /// pre-fix loop, which passed `usage = None` for every copied row.
290 async fn copy_messages_into_session(
291 &self,
292 dst_session: &str,
293 messages: &[Message],
294 ) -> Result<()>;
295
296 /// Persist thinking/reasoning text for an assistant message.
297 ///
298 /// Called only for Claude with extended thinking enabled. All other
299 /// providers leave `thinking_content` NULL.
300 async fn update_message_thinking_content(&self, message_id: i64, content: &str) -> Result<()>;
301
302 // ── Token usage ──
303
304 /// Token usage totals for a session.
305 async fn session_token_usage(&self, session_id: &str) -> Result<SessionUsage>;
306 /// Token usage broken down by agent name.
307 async fn session_usage_by_agent(&self, session_id: &str)
308 -> Result<Vec<(String, SessionUsage)>>;
309
310 // ── Compaction ──
311
312 /// Compact old messages into a summary, preserving the last N messages.
313 async fn compact_session(
314 &self,
315 session_id: &str,
316 summary: &str,
317 preserve_count: usize,
318 ) -> Result<usize>;
319
320 // ── Microcompact ──
321
322 /// Replace message content for the given IDs with a stub string.
323 /// Used by microcompact to clear old tool results without full compaction.
324 async fn clear_message_content(&self, message_ids: &[i64], stub: &str) -> Result<()>;
325
326 // ── Purge ──
327
328 /// Stats about compacted (archived) messages across all sessions.
329 async fn compacted_stats(&self) -> Result<CompactedStats>;
330 /// Permanently delete compacted messages older than `min_age_days`.
331 /// Returns the number of messages deleted.
332 async fn purge_compacted(&self, min_age_days: u32) -> Result<usize>;
333
334 // ── Metadata ──
335
336 /// Get a session metadata value by key.
337 async fn get_metadata(&self, session_id: &str, key: &str) -> Result<Option<String>>;
338 /// Set a session metadata value.
339 async fn set_metadata(&self, session_id: &str, key: &str, value: &str) -> Result<()>;
340 /// Get the TODO list for a session.
341 async fn get_todo(&self, session_id: &str) -> Result<Option<String>>;
342 /// Set the TODO list for a session.
343 async fn set_todo(&self, session_id: &str, content: &str) -> Result<()>;
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 // ── Role::as_str ──────────────────────────────────────────────────────
351
352 #[test]
353 fn test_role_as_str_all_variants() {
354 assert_eq!(Role::System.as_str(), "system");
355 assert_eq!(Role::User.as_str(), "user");
356 assert_eq!(Role::Assistant.as_str(), "assistant");
357 assert_eq!(Role::Tool.as_str(), "tool");
358 }
359
360 // ── Role FromStr ──────────────────────────────────────────────────────
361
362 #[test]
363 fn test_role_from_str_round_trips() {
364 for (s, expected) in [
365 ("system", Role::System),
366 ("user", Role::User),
367 ("assistant", Role::Assistant),
368 ("tool", Role::Tool),
369 ] {
370 let parsed: Role = s.parse().expect(s);
371 assert_eq!(parsed.as_str(), expected.as_str());
372 }
373 }
374
375 #[test]
376 fn test_role_from_str_unknown_returns_error() {
377 let result: Result<Role, _> = "unknown".parse();
378 assert!(result.is_err());
379 assert!(result.unwrap_err().contains("unknown role"));
380 }
381
382 // ── Role Display ──────────────────────────────────────────────────────
383
384 #[test]
385 fn test_role_display_matches_as_str() {
386 for role in [Role::System, Role::User, Role::Assistant, Role::Tool] {
387 assert_eq!(role.to_string(), role.as_str());
388 }
389 }
390}