Skip to main content

vct_core/models/
gemini.rs

1//! Serde models for the Google Gemini CLI chat-log JSONL format.
2//!
3//! The first line of each chat file is a [`GeminiSession`] meta record; the
4//! remaining lines are individual events the parser handles as plain `Value`s,
5//! materializing only assistant turns into [`GeminiMessage`]. The `content`
6//! field is polymorphic and handled by the `deserialize_content` helper.
7
8use serde::{Deserialize, Deserializer, Serialize};
9use serde_json::Value;
10
11/// Top-level session metadata line of a Gemini CLI chat log.
12///
13/// Current Gemini CLI writes chats as JSONL event streams under
14/// `~/.gemini/tmp/<project>/chats/session-*.jsonl`. The very first line is a
15/// pure session-meta record:
16///
17/// ```json
18/// {"sessionId":"...","projectHash":"...","startTime":"...","lastUpdated":"...","kind":"main"}
19/// ```
20///
21/// Subsequent lines are individual assistant / user / info events that the
22/// parser handles one-by-one as plain `Value`s (see
23/// `parse_gemini_events`), so this struct only needs to capture the
24/// identifiers found on that opening meta line. Legacy single-object
25/// exports (`chats/<session>.json` with an inline `messages` array) are no
26/// longer supported — the filesystem filter ignores `.json` entirely.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct GeminiSession {
30    /// Session identifier.
31    pub session_id: String,
32    /// Hash identifying the project the session belongs to.
33    #[serde(default)]
34    pub project_hash: String,
35    /// ISO-8601 session start time.
36    #[serde(default)]
37    pub start_time: String,
38    /// ISO-8601 timestamp of the last update to the session.
39    #[serde(default)]
40    pub last_updated: String,
41    /// Present on JSONL session-meta records (e.g. `"main"`), but not
42    /// required — older CLI builds occasionally omit it.
43    #[serde(default)]
44    pub kind: Option<String>,
45}
46
47/// Single message within a Gemini session
48///
49/// Used both for the legacy `messages[]` entries and for JSONL events whose
50/// `type == "gemini"`. Non-assistant events (`"user"`, `"info"`, `$set`
51/// meta-updates, …) are filtered out by the analyzer before reaching this
52/// type, so fields such as `content` and `model` can stay absent without
53/// breaking deserialisation.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct GeminiMessage {
57    /// Message identifier.
58    #[serde(default)]
59    pub id: String,
60    /// ISO-8601 timestamp string for the message.
61    #[serde(default)]
62    pub timestamp: String,
63    /// Message type discriminator (e.g. `gemini`, `user`, `info`).
64    #[serde(rename = "type", default)]
65    pub message_type: String,
66    /// Flattened message text (string or joined `{text}` array blocks).
67    #[serde(default, deserialize_with = "deserialize_content")]
68    pub content: String,
69    /// Reasoning steps recorded for the turn.
70    #[serde(default)]
71    pub thoughts: Vec<GeminiThought>,
72    /// Token-usage breakdown for the turn, when reported.
73    pub tokens: Option<GeminiTokens>,
74    /// Model that produced the turn, when reported.
75    pub model: Option<String>,
76    /// Raw tool-call entries for the turn.
77    #[serde(default)]
78    pub tool_calls: Vec<Value>,
79}
80
81/// Flattens a polymorphic message `content` field into a single `String`.
82///
83/// Content may be a string, an array of `{text: "..."}` objects, null, or
84/// missing entirely. Arrays are joined with newlines, `null` becomes an empty
85/// string, and any other JSON value is stringified.
86///
87/// # Errors
88///
89/// Returns a deserialization error if the underlying tokens are not valid JSON
90/// for [`Value`].
91fn deserialize_content<'de, D>(deserializer: D) -> Result<String, D::Error>
92where
93    D: Deserializer<'de>,
94{
95    let value = Value::deserialize(deserializer)?;
96    match value {
97        Value::String(s) => Ok(s),
98        Value::Array(arr) => {
99            let texts: Vec<&str> = arr
100                .iter()
101                .filter_map(|item| item.get("text").and_then(|t| t.as_str()))
102                .collect();
103            Ok(texts.join("\n"))
104        }
105        Value::Null => Ok(String::new()),
106        _ => Ok(value.to_string()),
107    }
108}
109
110/// A single reasoning step captured during Gemini's thought process.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct GeminiThought {
113    /// Short subject line for the reasoning step.
114    #[serde(default)]
115    pub subject: String,
116    /// Detailed description of the reasoning step.
117    #[serde(default)]
118    pub description: String,
119    /// ISO-8601 timestamp string for the reasoning step.
120    #[serde(default)]
121    pub timestamp: String,
122}
123
124/// Token-usage breakdown for a single Gemini message.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct GeminiTokens {
127    /// Input (prompt) tokens.
128    #[serde(default)]
129    pub input: i64,
130    /// Output (response) tokens.
131    #[serde(default)]
132    pub output: i64,
133    /// Tokens served from cache.
134    #[serde(default)]
135    pub cached: i64,
136    /// Tokens spent on reasoning / thoughts.
137    #[serde(default)]
138    pub thoughts: i64,
139    /// Tokens attributed to tool use.
140    #[serde(default)]
141    pub tool: i64,
142    /// Total token count for the message.
143    #[serde(default)]
144    pub total: i64,
145}