1use super::{Context, TaskStore};
2use chrono::{Duration, Utc};
3use serde::{Deserialize, Serialize};
4use sqlx::Row;
5
6const CONTROL_MESSAGE_COLS: &str = "id, session_id, role, channel, channel_thread, \
13 content, summary, model, agent, input_tokens, output_tokens, tokens_used, cost_usd, created_at";
14
15#[derive(Debug, Clone)]
17pub struct ControlMessage {
18 pub id: i64,
19 pub session_id: String,
20 pub role: String,
21 pub channel: String,
22 pub channel_thread: Option<String>,
23 pub content: String,
24 pub summary: Option<String>,
25 pub model: Option<String>,
26 pub agent: Option<String>,
27 pub input_tokens: Option<i64>,
28 pub output_tokens: Option<i64>,
29 pub tokens_used: Option<i64>,
30 pub cost_usd: Option<f64>,
31 pub created_at: String,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct MemoryEntry {
37 pub attempt: u32,
39 pub agent: String,
41 pub model: Option<String>,
43 pub learnings: Vec<String>,
45 pub error: Option<String>,
47 pub files_modified: Vec<String>,
49 pub approach: String,
51 pub timestamp: String,
53}
54
55impl TaskStore {
56 pub const DEFAULT_SESSION: &'static str = "default";
57
58 #[expect(
60 clippy::too_many_arguments,
61 reason = "one argument per column of the message row"
62 )]
63 pub async fn insert_control_message(
64 &self,
65 session_id: &str,
66 role: &str,
67 channel: &str,
68 channel_thread: Option<&str>,
69 content: &str,
70 summary: Option<&str>,
71 model: Option<&str>,
72 agent: Option<&str>,
73 input_tokens: Option<i64>,
74 output_tokens: Option<i64>,
75 tokens_used: Option<i64>,
76 cost_usd: Option<f64>,
77 ) -> anyhow::Result<i64> {
78 let row = sqlx::query(
79 "INSERT INTO control_messages (session_id, role, channel, channel_thread, content, summary, model, agent, input_tokens, output_tokens, tokens_used, cost_usd)
80 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
81 )
82 .bind(session_id)
83 .bind(role)
84 .bind(channel)
85 .bind(channel_thread)
86 .bind(content)
87 .bind(summary)
88 .bind(model)
89 .bind(agent)
90 .bind(input_tokens)
91 .bind(output_tokens)
92 .bind(tokens_used)
93 .bind(cost_usd)
94 .fetch_one(&self.pool)
95 .await?;
96 Ok(row.try_get("id")?)
97 }
98
99 pub async fn list_control_messages(
105 &self,
106 session_id: &str,
107 since: Option<&str>,
108 limit: i64,
109 ) -> anyhow::Result<Vec<ControlMessage>> {
110 let rows = if let Some(ts) = since {
112 sqlx::query(sqlx::AssertSqlSafe(format!(
113 "SELECT {CONTROL_MESSAGE_COLS} FROM (
114 SELECT {CONTROL_MESSAGE_COLS} FROM control_messages
115 WHERE session_id = ? AND created_at >= ?
116 ORDER BY created_at DESC, id DESC LIMIT ?
117 ) ORDER BY created_at ASC, id ASC"
118 )))
119 .bind(session_id)
120 .bind(ts)
121 .bind(limit)
122 .fetch_all(&self.pool)
123 .await?
124 } else {
125 sqlx::query(sqlx::AssertSqlSafe(format!(
126 "SELECT {CONTROL_MESSAGE_COLS} FROM (
127 SELECT {CONTROL_MESSAGE_COLS} FROM control_messages
128 WHERE session_id = ?
129 ORDER BY created_at DESC, id DESC LIMIT ?
130 ) ORDER BY created_at ASC, id ASC"
131 )))
132 .bind(session_id)
133 .bind(limit)
134 .fetch_all(&self.pool)
135 .await?
136 };
137 rows.iter()
138 .map(|r| {
139 Self::row_to_control_message(r).with_context(|| {
140 format!(
141 "parsing control_message row id={:?} session_id={:?}",
142 r.try_get::<i64, _>("id").ok(),
143 r.try_get::<String, _>("session_id").ok()
144 )
145 })
146 })
147 .collect::<Result<Vec<_>, _>>()
148 }
149
150 pub async fn search_control_messages(
156 &self,
157 session_id: &str,
158 query: &str,
159 since: Option<&str>,
160 limit: i64,
161 ) -> anyhow::Result<Vec<ControlMessage>> {
162 let escaped = query
164 .replace('\\', "\\\\")
165 .replace('%', "\\%")
166 .replace('_', "\\_");
167 let pattern = format!("%{escaped}%");
168 let rows = if let Some(ts) = since {
169 sqlx::query(sqlx::AssertSqlSafe(format!(
170 "SELECT {CONTROL_MESSAGE_COLS} FROM control_messages \
171 WHERE session_id = ? AND content LIKE ? ESCAPE '\\' AND created_at >= ? \
172 ORDER BY created_at DESC LIMIT ?"
173 )))
174 .bind(session_id)
175 .bind(&pattern)
176 .bind(ts)
177 .bind(limit)
178 .fetch_all(&self.pool)
179 .await?
180 } else {
181 sqlx::query(sqlx::AssertSqlSafe(format!(
182 "SELECT {CONTROL_MESSAGE_COLS} FROM control_messages \
183 WHERE session_id = ? AND content LIKE ? ESCAPE '\\' \
184 ORDER BY created_at DESC LIMIT ?"
185 )))
186 .bind(session_id)
187 .bind(&pattern)
188 .bind(limit)
189 .fetch_all(&self.pool)
190 .await?
191 };
192 rows.iter()
193 .map(|r| {
194 Self::row_to_control_message(r).with_context(|| {
195 format!(
196 "parsing control_message row id={:?} session_id={:?}",
197 r.try_get::<i64, _>("id").ok(),
198 r.try_get::<String, _>("session_id").ok()
199 )
200 })
201 })
202 .collect::<Result<Vec<_>, _>>()
203 }
204
205 pub async fn control_recent_summaries(
207 &self,
208 session_id: &str,
209 limit: i64,
210 ) -> anyhow::Result<Vec<String>> {
211 let rows = sqlx::query(
213 "SELECT summary FROM (
214 SELECT summary, created_at, id FROM control_messages
215 WHERE session_id = ? AND summary IS NOT NULL
216 ORDER BY created_at DESC, id DESC LIMIT ?
217 ) ORDER BY created_at ASC, id ASC",
218 )
219 .bind(session_id)
220 .bind(limit)
221 .fetch_all(&self.pool)
222 .await?;
223 rows.iter()
224 .map(|r| r.try_get::<String, _>("summary"))
225 .collect::<Result<Vec<_>, _>>()
226 .map_err(Into::into)
227 }
228
229 fn row_to_control_message(
230 row: &sqlx::sqlite::SqliteRow,
231 ) -> Result<ControlMessage, sqlx::Error> {
232 use sqlx::Row;
233 Ok(ControlMessage {
234 id: row.try_get("id")?,
235 session_id: row.try_get("session_id")?,
236 role: row.try_get("role")?,
237 channel: row.try_get("channel")?,
238 channel_thread: row.try_get::<Option<String>, _>("channel_thread")?,
239 content: row.try_get("content")?,
240 summary: row.try_get::<Option<String>, _>("summary")?,
241 model: row.try_get::<Option<String>, _>("model")?,
242 agent: row.try_get::<Option<String>, _>("agent")?,
243 input_tokens: row.try_get::<Option<i64>, _>("input_tokens")?,
244 output_tokens: row.try_get::<Option<i64>, _>("output_tokens")?,
245 tokens_used: row.try_get::<Option<i64>, _>("tokens_used")?,
246 cost_usd: row.try_get::<Option<f64>, _>("cost_usd")?,
247 created_at: row.try_get("created_at")?,
248 })
249 }
250}
251
252#[derive(Debug, Clone, Default)]
254pub struct ChatCostSummary {
255 pub total_messages: i64,
257 pub assistant_messages: i64,
259 pub total_tokens: i64,
261 pub total_input_tokens: i64,
263 pub total_output_tokens: i64,
265 pub total_cost_usd: f64,
267 pub by_model: Vec<(String, i64, i64, f64)>,
269 pub primary_model: Option<String>,
271 pub primary_agent: Option<String>,
273}
274
275impl TaskStore {
276 pub async fn get_session_cost_summary(
281 &self,
282 session_id: &str,
283 ) -> anyhow::Result<ChatCostSummary> {
284 let row = sqlx::query(
286 "SELECT
287 COUNT(*) as total_messages,
288 COUNT(CASE WHEN role = 'assistant' THEN 1 END) as assistant_messages,
289 COALESCE(SUM(tokens_used), 0) as total_tokens,
290 COALESCE(SUM(input_tokens), 0) as total_input_tokens,
291 COALESCE(SUM(output_tokens), 0) as total_output_tokens,
292 COALESCE(SUM(cost_usd), 0.0) as total_cost_usd
293 FROM control_messages
294 WHERE session_id = ?",
295 )
296 .bind(session_id)
297 .fetch_one(&self.pool)
298 .await?;
299
300 let total_messages: i64 = row.try_get("total_messages")?;
301 let assistant_messages: i64 = row.try_get("assistant_messages")?;
302 let total_tokens: i64 = row.try_get("total_tokens")?;
303 let total_input_tokens: i64 = row.try_get("total_input_tokens")?;
304 let total_output_tokens: i64 = row.try_get("total_output_tokens")?;
305 let total_cost_usd: f64 = row.try_get("total_cost_usd")?;
306
307 let model_rows = sqlx::query(
309 "SELECT
310 COALESCE(model, 'unknown') as model_name,
311 COUNT(*) as message_count,
312 COALESCE(SUM(tokens_used), 0) as tokens,
313 COALESCE(SUM(cost_usd), 0.0) as cost
314 FROM control_messages
315 WHERE session_id = ? AND role = 'assistant'
316 GROUP BY model
317 ORDER BY message_count DESC",
318 )
319 .bind(session_id)
320 .fetch_all(&self.pool)
321 .await?;
322
323 let mut by_model = Vec::new();
324 for row in model_rows {
325 let model: String = row.try_get("model_name")?;
326 let count: i64 = row.try_get("message_count")?;
327 let tokens: i64 = row.try_get("tokens")?;
328 let cost: f64 = row.try_get("cost")?;
329 by_model.push((model, count, tokens, cost));
330 }
331
332 let primary_model = by_model.first().map(|(m, _, _, _)| m.clone());
334
335 let agent_row = sqlx::query(
337 "SELECT COALESCE(agent, 'unknown') as agent_name
338 FROM control_messages
339 WHERE session_id = ? AND role = 'assistant' AND agent IS NOT NULL
340 GROUP BY agent
341 ORDER BY COUNT(*) DESC
342 LIMIT 1",
343 )
344 .bind(session_id)
345 .fetch_optional(&self.pool)
346 .await?;
347
348 let primary_agent = agent_row.and_then(|r| r.try_get::<String, _>("agent_name").ok());
349
350 Ok(ChatCostSummary {
351 total_messages,
352 assistant_messages,
353 total_tokens,
354 total_input_tokens,
355 total_output_tokens,
356 total_cost_usd,
357 by_model,
358 primary_model,
359 primary_agent,
360 })
361 }
362}
363
364pub fn parse_since_duration(s: &str) -> anyhow::Result<String> {
377 let s = s.trim();
378 let (num_str, unit) = if let Some(n) = s.strip_suffix('d') {
379 (n, 'd')
380 } else if let Some(n) = s.strip_suffix('h') {
381 (n, 'h')
382 } else if let Some(n) = s.strip_suffix('m') {
383 (n, 'm')
384 } else {
385 anyhow::bail!(
386 "unrecognised --since format {s:?}; expected a number followed by d, h, or m (e.g. 7d, 24h, 30m)"
387 );
388 };
389
390 let n: u64 = num_str.trim().parse().map_err(|_| {
391 anyhow::anyhow!(
392 "invalid number in --since {s:?}; expected a positive integer followed by d, h, or m"
393 )
394 })?;
395 if n == 0 {
396 anyhow::bail!("--since value must be greater than zero");
397 }
398
399 let secs: i64 = match unit {
400 'd' => n.checked_mul(86_400),
401 'h' => n.checked_mul(3_600),
402 'm' => n.checked_mul(60),
403 _ => anyhow::bail!("unrecognized duration unit '{unit}' (expected d, h, or m)"),
404 }
405 .ok_or_else(|| anyhow::anyhow!("--since value is too large"))?
406 .try_into()
407 .map_err(|_| anyhow::anyhow!("--since value is too large"))?;
408
409 Ok((Utc::now() - Duration::seconds(secs)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 async fn store_with_message() -> (TaskStore, i64) {
418 let store = TaskStore::open_memory().await.unwrap();
419 let id = store
420 .insert_control_message(
421 "sess1",
422 "user",
423 "cli",
424 None,
425 "hello world",
426 Some("a summary"),
427 Some("sonnet"),
428 Some("claude"),
429 Some(10),
430 Some(20),
431 Some(30),
432 Some(0.001),
433 )
434 .await
435 .unwrap();
436 (store, id)
437 }
438
439 #[tokio::test]
441 async fn row_to_control_message_decodes_required_fields() {
442 let (store, _) = store_with_message().await;
443 let msgs = store
444 .list_control_messages("sess1", None, 10)
445 .await
446 .unwrap();
447 assert_eq!(msgs.len(), 1);
448 let m = &msgs[0];
449 assert_eq!(m.session_id, "sess1");
450 assert_eq!(m.role, "user");
451 assert_eq!(m.channel, "cli");
452 assert_eq!(m.content, "hello world");
453 assert_ne!(m.created_at, "");
454 }
455
456 #[tokio::test]
458 async fn row_to_control_message_decodes_optional_fields() {
459 let (store, _) = store_with_message().await;
460 let msgs = store
461 .list_control_messages("sess1", None, 10)
462 .await
463 .unwrap();
464 let m = &msgs[0];
465 assert_eq!(m.summary, Some("a summary".to_string()));
466 assert_eq!(m.model, Some("sonnet".to_string()));
467 assert_eq!(m.agent, Some("claude".to_string()));
468 assert_eq!(m.input_tokens, Some(10));
469 assert_eq!(m.output_tokens, Some(20));
470 assert_eq!(m.tokens_used, Some(30));
471 assert_eq!(m.cost_usd, Some(0.001));
472 assert_eq!(m.channel_thread, None);
473 }
474
475 #[tokio::test]
477 async fn row_to_control_message_null_optional_fields_are_none() {
478 let store = TaskStore::open_memory().await.unwrap();
479 store
480 .insert_control_message(
481 "s",
482 "assistant",
483 "cli",
484 None,
485 "hi",
486 None,
487 None,
488 None,
489 None,
490 None,
491 None,
492 None,
493 )
494 .await
495 .unwrap();
496 let msgs = store.list_control_messages("s", None, 10).await.unwrap();
497 assert_eq!(msgs.len(), 1);
498 let m = &msgs[0];
499 assert!(m.summary.is_none());
500 assert!(m.model.is_none());
501 assert!(m.agent.is_none());
502 assert!(m.input_tokens.is_none());
503 assert!(m.output_tokens.is_none());
504 assert!(m.tokens_used.is_none());
505 assert!(m.cost_usd.is_none());
506 assert!(m.channel_thread.is_none());
507 }
508
509 #[tokio::test]
516 async fn row_to_control_message_missing_column_propagates_error() {
517 let store = TaskStore::open_memory().await.unwrap();
518 let rows = sqlx::query(
520 "SELECT 1 as id, 'sess' as session_id, 'cli' as channel, \
521 NULL as channel_thread, 'content' as content, NULL as summary, \
522 NULL as model, NULL as agent, NULL as input_tokens, NULL as output_tokens, \
523 NULL as tokens_used, NULL as cost_usd, '2024-01-01T00:00:00Z' as created_at",
524 )
525 .fetch_all(&store.pool)
526 .await
527 .unwrap();
528 assert_eq!(rows.len(), 1);
529 let result = TaskStore::row_to_control_message(&rows[0]);
530 assert!(
531 result.is_err(),
532 "expected Err when required column `role` is missing, got Ok"
533 );
534 }
535}