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