Skip to main content

recall_echo/transcript/
gemini.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Gemini CLI chat sessions.
6//!
7//! # Why this exists
8//!
9//! Gemini ships `gemini hooks migrate --from-claude`, which copies Claude
10//! Code's hook commands into Gemini's own settings. A recall-echo user who runs
11//! it gets `recall-echo archive-session` wired into Gemini's `SessionEnd` —
12//! where it is handed a **JSON document**, not the JSON *Lines* transcript
13//! [`crate::jsonl`] parses. Every line of that document fails to parse, the
14//! conversation comes out empty, and the session is silently not archived.
15//!
16//! So this module reads the shape Gemini writes, and archival sniffs the file
17//! rather than assuming its own. A migrated hook then works instead of quietly
18//! doing nothing.
19//!
20//! # This shape is unverified
21//!
22//! It was read off Gemini's type declarations, not off a file produced by a
23//! real session: one document, `{sessionId, projectHash, startTime,
24//! lastUpdated, messages[], summary?}`, with `messages[].type` in
25//! `user | gemini | info | error | warning`, a `content` of Gemini's
26//! `PartListUnion` (a string, a part, or a list of parts), and `toolCalls[]`
27//! and `thoughts[]` on model messages.
28//!
29//! Everything here is therefore written to *decline* rather than guess: a
30//! document without a `messages` array is not recognised, a message of an
31//! unknown type is dropped, and content in an unexpected shape reads as empty.
32//! The cost of being wrong is a session that is not archived — never a session
33//! archived as something it was not.
34//!
35//! There is no discovery half to this adapter for the same reason. Sweeping
36//! unverified transcripts into memory unattended is a different risk from
37//! parsing one a hook explicitly handed us, and it can be added the day
38//! someone confirms the format against a real file.
39//!
40//! # What is dropped, and why
41//!
42//! `thoughts[]` is the model's private reasoning. Like Grok's `reasoning` and
43//! Codex's `developer` role, it is not something the model *asserted*, so it
44//! must not enter the graph as self-authored evidence — see the contract in
45//! [`crate::transcript`]. `info`, `error` and `warning` messages are the
46//! harness talking to the user; they are not turns at all.
47
48use serde_json::Value;
49
50use crate::conversation::{Conversation, ConversationEntry};
51
52use super::content_text;
53
54/// Message types that carry what one of the two parties said.
55const HUMAN_TURN: &str = "user";
56const MODEL_TURN: &str = "gemini";
57
58/// Whether a JSON document looks like a Gemini chat session.
59///
60/// The `messages` array is the load-bearing field — without it there is
61/// nothing to read — so it is also the marker. `sessionId` is corroborating
62/// but not required: a session file is recognised by what it has to offer.
63#[must_use]
64pub fn is_session_document(document: &Value) -> bool {
65    document.get("messages").is_some_and(Value::is_array)
66}
67
68/// Read a Gemini chat session into the universal conversation format.
69///
70/// `session_id` is the identity the archive is recorded under. The document's
71/// own `sessionId` is used when the caller has none — a hook payload that named
72/// no session is still a session.
73#[must_use]
74pub fn parse_session(document: &Value, session_id: &str) -> Option<Conversation> {
75    if !is_session_document(document) {
76        return None;
77    }
78
79    let session_id = if session_id.is_empty() {
80        document
81            .get("sessionId")
82            .and_then(Value::as_str)
83            .unwrap_or("gemini-session")
84    } else {
85        session_id
86    };
87
88    let mut conv = Conversation::new(session_id);
89    conv.first_timestamp = timestamp(document, "startTime");
90    conv.last_timestamp = timestamp(document, "lastUpdated");
91
92    for message in document["messages"].as_array().into_iter().flatten() {
93        append_message(&mut conv, message);
94    }
95
96    Some(conv)
97}
98
99/// Fold one message into the conversation, or drop it.
100fn append_message(conv: &mut Conversation, message: &Value) {
101    let text = message
102        .get("content")
103        .map(content_text)
104        .unwrap_or_default()
105        .trim()
106        .to_string();
107
108    match message.get("type").and_then(Value::as_str).unwrap_or("") {
109        HUMAN_TURN if !text.is_empty() => {
110            conv.user_message_count += 1;
111            conv.entries.push(ConversationEntry::UserMessage(text));
112        }
113        MODEL_TURN => {
114            if !text.is_empty() {
115                conv.assistant_message_count += 1;
116                conv.entries.push(ConversationEntry::AssistantText(text));
117            }
118            append_tool_calls(conv, message);
119        }
120        // `info`, `error`, `warning`, an empty turn, or a type this build has
121        // never heard of: harness text, not a turn.
122        _ => {}
123    }
124}
125
126/// Record what the model did, without pretending to know each tool's schema.
127fn append_tool_calls(conv: &mut Conversation, message: &Value) {
128    for call in message
129        .get("toolCalls")
130        .and_then(Value::as_array)
131        .into_iter()
132        .flatten()
133    {
134        let name = call
135            .get("name")
136            .and_then(Value::as_str)
137            .unwrap_or("unknown")
138            .to_string();
139        let input_summary = call
140            .get("args")
141            .map(|args| crate::conversation::truncate(&args.to_string(), 200))
142            .unwrap_or_default();
143        conv.entries.push(ConversationEntry::ToolUse {
144            name,
145            input_summary,
146        });
147    }
148}
149
150fn timestamp(document: &Value, field: &str) -> Option<String> {
151    document
152        .get(field)
153        .and_then(Value::as_str)
154        .filter(|value| !value.is_empty())
155        .map(str::to_string)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn session() -> Value {
163        serde_json::json!({
164            "sessionId": "sess-1",
165            "projectHash": "abc123",
166            "startTime": "2026-08-06T10:00:00Z",
167            "lastUpdated": "2026-08-06T10:30:00Z",
168            "messages": [
169                {"type": "user", "content": "Where does recall-echo live?"},
170                {
171                    "type": "gemini",
172                    "content": [{"text": "Under /opt/recall-echo."}],
173                    "thoughts": [{"subject": "plan", "description": "check the path first"}],
174                    "toolCalls": [{"name": "read_file", "args": {"path": "/opt/recall-echo"}}],
175                },
176                {"type": "info", "content": "Model switched to gemini-2.5-pro."},
177                {"type": "error", "content": "Quota exceeded."},
178            ],
179        })
180    }
181
182    #[test]
183    fn a_session_document_is_recognised_by_its_messages() {
184        assert!(is_session_document(&session()));
185        assert!(!is_session_document(&serde_json::json!({"sessionId": "s"})));
186        assert!(!is_session_document(&serde_json::json!({"messages": "no"})));
187    }
188
189    #[test]
190    fn both_parties_turns_survive_and_nothing_else_does() {
191        let conv = parse_session(&session(), "hook-session").expect("recognised");
192
193        assert_eq!(conv.session_id, "hook-session");
194        assert_eq!(conv.user_message_count, 1);
195        assert_eq!(conv.assistant_message_count, 1);
196        assert_eq!(
197            conv.first_timestamp.as_deref(),
198            Some("2026-08-06T10:00:00Z")
199        );
200        assert_eq!(conv.last_timestamp.as_deref(), Some("2026-08-06T10:30:00Z"));
201
202        let markdown = crate::conversation::conversation_to_markdown(&conv, 1);
203        assert!(markdown.contains("Where does recall-echo live?"));
204        assert!(markdown.contains("Under /opt/recall-echo."));
205        assert!(markdown.contains("read_file"));
206        assert!(
207            !markdown.contains("Model switched"),
208            "harness notices are not turns: {markdown}"
209        );
210        assert!(!markdown.contains("Quota exceeded"), "{markdown}");
211    }
212
213    /// The provenance contract: private reasoning is not something the model
214    /// asserted, so it must never reach the graph as self-authored evidence.
215    #[test]
216    fn thoughts_never_become_a_turn() {
217        let conv = parse_session(&session(), "s").expect("recognised");
218        for entry in &conv.entries {
219            if let ConversationEntry::AssistantText(text) = entry {
220                assert!(!text.contains("check the path first"), "{text}");
221            }
222        }
223    }
224
225    /// `PartListUnion` is a string, a part, or a list of them — all three from
226    /// the same CLI, so all three are read.
227    #[test]
228    fn every_shape_of_content_reads_the_same() {
229        let document = serde_json::json!({"messages": [
230            {"type": "user", "content": "bare string"},
231            {"type": "user", "content": {"text": "one part"}},
232            {"type": "user", "content": [{"text": "two "}, {"text": "parts"}]},
233        ]});
234        let conv = parse_session(&document, "s").expect("recognised");
235        let said: Vec<&str> = conv
236            .entries
237            .iter()
238            .filter_map(|entry| match entry {
239                ConversationEntry::UserMessage(text) => Some(text.as_str()),
240                _ => None,
241            })
242            .collect();
243        assert_eq!(said, ["bare string", "one part", "two parts"]);
244    }
245
246    /// The shape is unverified, so anything unexpected is declined rather than
247    /// guessed at.
248    #[test]
249    fn an_unreadable_message_is_dropped_not_invented() {
250        let document = serde_json::json!({"messages": [
251            {"type": "user"},
252            {"type": "user", "content": 42},
253            {"type": "unheard-of", "content": "something new"},
254            {"type": "gemini", "content": ""},
255        ]});
256        let conv = parse_session(&document, "s").expect("recognised");
257        assert_eq!(conv.user_message_count, 0);
258        assert_eq!(conv.assistant_message_count, 0);
259        assert!(conv.entries.is_empty(), "{:?}", conv.entries);
260    }
261
262    #[test]
263    fn a_document_that_is_not_a_session_is_not_parsed() {
264        let document = serde_json::json!({"type": "user", "message": {"role": "user"}});
265        assert!(parse_session(&document, "s").is_none());
266    }
267
268    /// A payload that named no session still archives, under the session the
269    /// document names itself.
270    #[test]
271    fn the_document_names_the_session_when_the_caller_cannot() {
272        let conv = parse_session(&session(), "").expect("recognised");
273        assert_eq!(conv.session_id, "sess-1");
274    }
275}