Skip to main content

scud/db/
transcripts.rs

1//! Transcript database operations - insert and query Claude Code transcripts.
2
3use anyhow::Result;
4use rusqlite::{params, Connection};
5
6use crate::commands::swarm::transcript::{MessageContent, Transcript};
7
8#[derive(Debug)]
9pub struct TranscriptSearchResult {
10    pub session_id: String,
11    pub task_id: Option<String>,
12    pub timestamp: String,
13    pub role: String,
14    pub content_preview: String,
15}
16
17#[derive(Debug)]
18pub struct TranscriptStats {
19    pub total_messages: i64,
20    pub total_tool_calls: i64,
21    pub total_sessions: i64,
22}
23
24/// Insert a full transcript (messages, tool calls, tool results) into the database.
25pub fn insert_transcript(
26    conn: &Connection,
27    transcript: &Transcript,
28    scud_session_id: Option<&str>,
29    task_id: Option<&str>,
30) -> Result<()> {
31    let tx = conn.unchecked_transaction()?;
32
33    for msg in &transcript.messages {
34        let content = match &msg.content {
35            MessageContent::Text(t) => t.clone(),
36            MessageContent::Structured(s) => serde_json::to_string(s)?,
37        };
38
39        // Extract model and token counts from structured content
40        let (model, input_tokens, output_tokens) = match &msg.content {
41            MessageContent::Structured(s) => (
42                s.model.clone(),
43                s.usage.as_ref().and_then(|u| u.input_tokens).map(|t| t as i64),
44                s.usage.as_ref().and_then(|u| u.output_tokens).map(|t| t as i64),
45            ),
46            _ => (None, None, None),
47        };
48
49        tx.execute(
50            "INSERT INTO transcript_messages
51             (claude_session_id, scud_session_id, task_id, timestamp, uuid, parent_uuid,
52              role, content, model, input_tokens, output_tokens)
53             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
54            params![
55                transcript.session_id,
56                scud_session_id,
57                task_id,
58                msg.timestamp.to_rfc3339(),
59                msg.uuid,
60                msg.parent_uuid,
61                msg.role,
62                content,
63                model,
64                input_tokens,
65                output_tokens,
66            ],
67        )?;
68        let message_id = tx.last_insert_rowid();
69
70        // Insert tool calls that belong to this message (matched by timestamp)
71        for tool_call in &transcript.tool_calls {
72            if tool_call.timestamp == msg.timestamp {
73                tx.execute(
74                    "INSERT INTO tool_calls
75                     (message_id, claude_session_id, timestamp, tool_id, tool_name, input_json)
76                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
77                    params![
78                        message_id,
79                        transcript.session_id,
80                        tool_call.timestamp.to_rfc3339(),
81                        tool_call.id,
82                        tool_call.name,
83                        tool_call.input.to_string(),
84                    ],
85                )?;
86            }
87        }
88
89        // Insert tool results that belong to this message
90        for tool_result in &transcript.tool_results {
91            if tool_result.timestamp == msg.timestamp {
92                tx.execute(
93                    "INSERT INTO tool_results
94                     (message_id, claude_session_id, timestamp, tool_use_id, content, is_error)
95                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
96                    params![
97                        message_id,
98                        transcript.session_id,
99                        tool_result.timestamp.to_rfc3339(),
100                        tool_result.tool_use_id,
101                        tool_result.content,
102                        tool_result.is_error as i32,
103                    ],
104                )?;
105            }
106        }
107    }
108
109    tx.commit()?;
110    Ok(())
111}
112
113pub fn search_transcripts(
114    conn: &Connection,
115    query: &str,
116) -> Result<Vec<TranscriptSearchResult>> {
117    let mut stmt = conn.prepare(
118        "SELECT tm.claude_session_id, tm.task_id, tm.timestamp, tm.role,
119                substr(tm.content, 1, 200) as content_preview
120         FROM transcript_messages tm
121         WHERE tm.content LIKE ?1
122         ORDER BY tm.timestamp DESC
123         LIMIT 100",
124    )?;
125
126    let pattern = format!("%{}%", query);
127    let results = stmt.query_map(params![pattern], |row| {
128        Ok(TranscriptSearchResult {
129            session_id: row.get(0)?,
130            task_id: row.get(1)?,
131            timestamp: row.get(2)?,
132            role: row.get(3)?,
133            content_preview: row.get(4)?,
134        })
135    })?;
136
137    results.collect::<Result<Vec<_>, _>>().map_err(Into::into)
138}
139
140pub fn get_transcript_stats(conn: &Connection) -> Result<TranscriptStats> {
141    let total_messages: i64 =
142        conn.query_row("SELECT COUNT(*) FROM transcript_messages", [], |r| r.get(0))?;
143    let total_tool_calls: i64 =
144        conn.query_row("SELECT COUNT(*) FROM tool_calls", [], |r| r.get(0))?;
145    let total_sessions: i64 = conn.query_row(
146        "SELECT COUNT(DISTINCT claude_session_id) FROM transcript_messages",
147        [],
148        |r| r.get(0),
149    )?;
150
151    Ok(TranscriptStats {
152        total_messages,
153        total_tool_calls,
154        total_sessions,
155    })
156}
157
158/// List transcript sessions with basic info
159pub fn list_transcript_sessions(conn: &Connection) -> Result<Vec<TranscriptSessionInfo>> {
160    let mut stmt = conn.prepare(
161        "SELECT claude_session_id,
162                COUNT(*) as msg_count,
163                MIN(timestamp) as first_msg,
164                MAX(timestamp) as last_msg
165         FROM transcript_messages
166         GROUP BY claude_session_id
167         ORDER BY first_msg DESC
168         LIMIT 50",
169    )?;
170
171    let results = stmt.query_map([], |row| {
172        Ok(TranscriptSessionInfo {
173            session_id: row.get(0)?,
174            message_count: row.get(1)?,
175            first_message: row.get(2)?,
176            last_message: row.get(3)?,
177        })
178    })?;
179
180    results.collect::<Result<Vec<_>, _>>().map_err(Into::into)
181}
182
183#[derive(Debug)]
184pub struct TranscriptSessionInfo {
185    pub session_id: String,
186    pub message_count: i64,
187    pub first_message: String,
188    pub last_message: String,
189}