Skip to main content

supercode_interchange/session/
goose.rs

1//! Goose session codec: loaders, writers and native-record helpers.
2
3use super::*;
4
5impl Session {
6    /// Load a Goose session-export JSON document from disk.
7    pub fn from_goose(path: impl AsRef<Path>) -> Result<Session> {
8        Self::from_goose_str(&std::fs::read_to_string(path.as_ref())?)
9    }
10
11    /// Parse Goose's official native import/export document.
12    ///
13    /// Goose's durable store is SQLite, but its own
14    /// `_goose/unstable/session/export` and `/session/import` boundary is one
15    /// JSON object containing a `conversation` array. Unknown native content
16    /// blocks are retained on the first canonical message in a namespaced
17    /// portability envelope; unchanged same-format exports replay the exact
18    /// source bytes.
19    pub fn from_goose_str(json: &str) -> Result<Session> {
20        let document: Value = serde_json::from_str(json).map_err(crate::Error::Decode)?;
21        let object = document.as_object().ok_or_else(|| {
22            Error::InvalidSession("Goose session export must be a JSON object".to_string())
23        })?;
24        let conversation = object
25            .get("conversation")
26            .and_then(Value::as_array)
27            .ok_or_else(|| {
28                Error::InvalidSession(
29                    "Goose session export must contain a conversation array".to_string(),
30                )
31            })?;
32
33        let mut meta = SessionMeta::new(SessionSource::Goose);
34        meta.session_id = object.get("id").and_then(Value::as_str).map(str::to_string);
35        meta.cwd = object
36            .get("working_dir")
37            .or_else(|| object.get("workingDir"))
38            .and_then(Value::as_str)
39            .map(PathBuf::from);
40        meta.model = object
41            .get("model_config")
42            .or_else(|| object.get("modelConfig"))
43            .and_then(|model| model.get("model_name").or_else(|| model.get("modelName")))
44            .and_then(Value::as_str)
45            .map(str::to_string);
46        for (source, target) in [
47            ("name", "session_name"),
48            ("created_at", "created_at"),
49            ("updated_at", "updated_at"),
50            ("session_type", "goose_session_type"),
51            ("goose_mode", "goose_mode"),
52            ("provider_name", "goose_provider_name"),
53            ("parent_session_id", "parent_session_id"),
54        ] {
55            if let Some(value) = object.get(source) {
56                meta.lineage.insert(
57                    target.to_string(),
58                    value
59                        .as_str()
60                        .map(str::to_string)
61                        .unwrap_or_else(|| value.to_string()),
62                );
63            }
64        }
65        let mut header = document.clone();
66        if let Some(header) = header.as_object_mut() {
67            header.remove("conversation");
68        }
69        meta.goose_header = Some(header.clone());
70
71        let mut messages = Vec::new();
72        for (native_index, native) in conversation.iter().enumerate() {
73            let before = messages.len();
74            normalize_goose_message(native, native_index, &mut messages);
75            if let Some(first) = messages.get_mut(before) {
76                first
77                    .metadata
78                    .insert("goose_native_message".to_string(), native.to_string());
79                first
80                    .metadata
81                    .insert("goose_native_index".to_string(), native_index.to_string());
82                if native_index == 0 {
83                    first
84                        .metadata
85                        .insert("goose_session_header".to_string(), header.to_string());
86                }
87                restore_grok_message_extension(native, first);
88            }
89            for message in messages.iter_mut().skip(before + 1) {
90                message
91                    .metadata
92                    .insert("goose_native_index".to_string(), native_index.to_string());
93            }
94        }
95        ensure_tool_results_paired(&mut messages);
96
97        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(json);
98        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
99        let imported_message_count = Some(messages.len());
100        Ok(Session {
101            meta,
102            messages,
103            subagents: Vec::new(),
104            raw,
105            raw_trailing_newline,
106            imported_message_count,
107            raw_is_verbatim: true,
108            parse_error_lines: 0,
109            load_residue: Vec::new(),
110        })
111    }
112
113    /// Load one Goose session directly from its native SQLite store.
114    ///
115    /// The selector is Goose's stable `sessions.id`. The reconstructed JSON
116    /// uses Goose's own public export shape, so the ordinary Goose codec is
117    /// the single normalization boundary for both files and the live store.
118    pub fn from_goose_sqlite(db_path: &Path, session_id: &str) -> Result<Session> {
119        Self::from_goose_sqlite_with_limit(db_path, session_id, None)
120    }
121
122    /// Bounded Goose store read for transcript UI surfaces. The inner query
123    /// selects only the newest native rows; the outer query restores their
124    /// chronological order. Export/continue callers deliberately use the
125    /// unbounded public loader above.
126    #[doc(hidden)]
127    pub fn from_goose_sqlite_display(
128        db_path: &Path,
129        session_id: &str,
130        message_limit: usize,
131    ) -> Result<Session> {
132        Self::from_goose_sqlite_with_limit(db_path, session_id, Some(message_limit.max(1)))
133    }
134
135    fn from_goose_sqlite_with_limit(
136        db_path: &Path,
137        session_id: &str,
138        message_limit: Option<usize>,
139    ) -> Result<Session> {
140        let connection = Connection::open_with_flags(
141            db_path,
142            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
143        )
144        .map_err(|error| Error::Other(format!("failed to open Goose SQLite store: {error}")))?;
145        let mut statement = connection
146            .prepare(
147                "SELECT id, name, working_dir, created_at, updated_at, session_type, \
148                    extension_data, goose_mode, provider_name, model_config_json \
149             FROM sessions WHERE id = ?1",
150            )
151            .map_err(|error| Error::Other(format!("failed to query Goose sessions: {error}")))?;
152        let mut document = statement
153            .query_row([session_id], |row| {
154                let extension_data: Option<String> = row.get(6)?;
155                let model_config: Option<String> = row.get(9)?;
156                Ok(serde_json::json!({
157                    "id": row.get::<_, String>(0)?,
158                    "working_dir": row.get::<_, String>(2)?,
159                    "name": row.get::<_, String>(1)?,
160                    "user_set_name": false,
161                    "session_type": row.get::<_, String>(5)?,
162                    "created_at": row.get::<_, String>(3)?,
163                    "updated_at": row.get::<_, String>(4)?,
164                    "extension_data": extension_data
165                        .as_deref()
166                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
167                        .unwrap_or_else(|| serde_json::json!({})),
168                    "usage": {},
169                    "accumulated_usage": {},
170                    "accumulated_cost": Value::Null,
171                    "schedule_id": Value::Null,
172                    "recipe": Value::Null,
173                    "user_recipe_values": Value::Null,
174                    "conversation": [],
175                    "message_count": 0,
176                    "last_message_at": Value::Null,
177                    "provider_name": row.get::<_, Option<String>>(8)?,
178                    "model_config": model_config
179                        .as_deref()
180                        .and_then(|value| serde_json::from_str::<Value>(value).ok()),
181                    "goose_mode": row.get::<_, String>(7)?,
182                    "archived_at": Value::Null,
183                    "project_id": Value::Null,
184                    "parent_session_id": Value::Null,
185                    "last_message_snippet": Value::Null,
186                }))
187            })
188            .map_err(|error| Error::Other(format!("failed to load Goose session: {error}")))?;
189
190        let message_query = message_limit.map_or_else(
191            || {
192                "SELECT message_id, role, content_json, created_timestamp, metadata_json \
193                 FROM messages WHERE session_id = ?1 ORDER BY created_timestamp, id"
194                    .to_string()
195            },
196            |limit| {
197                format!(
198                    "SELECT message_id, role, content_json, created_timestamp, metadata_json \
199                     FROM (SELECT id AS native_row_id, message_id, role, content_json, \
200                                  created_timestamp, metadata_json \
201                           FROM messages WHERE session_id = ?1 \
202                           ORDER BY created_timestamp DESC, id DESC LIMIT {limit}) \
203                     ORDER BY created_timestamp, native_row_id"
204                )
205            },
206        );
207        let mut message_statement = connection
208            .prepare(&message_query)
209            .map_err(|error| Error::Other(format!("failed to query Goose messages: {error}")))?;
210        let rows = message_statement
211            .query_map([session_id], |row| {
212                let content: String = row.get(2)?;
213                let metadata: Option<String> = row.get(4)?;
214                Ok(serde_json::json!({
215                    "id": row.get::<_, Option<String>>(0)?,
216                    "role": row.get::<_, String>(1)?,
217                    "created": row.get::<_, i64>(3)?,
218                    "content": serde_json::from_str::<Value>(&content)
219                        .unwrap_or_else(|_| Value::Array(Vec::new())),
220                    "metadata": metadata
221                        .as_deref()
222                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
223                        .unwrap_or_else(|| serde_json::json!({
224                            "userVisible": true,
225                            "agentVisible": true
226                        })),
227                }))
228            })
229            .map_err(|error| Error::Other(format!("failed to load Goose messages: {error}")))?;
230        let conversation = rows
231            .collect::<std::result::Result<Vec<_>, _>>()
232            .map_err(|error| Error::Other(format!("failed to decode Goose messages: {error}")))?;
233        document["message_count"] = Value::from(conversation.len());
234        document["conversation"] = Value::Array(conversation);
235        let json = serde_json::to_string_pretty(&document).map_err(crate::Error::Decode)?;
236        let mut session = Self::from_goose_str(&json)?;
237        // SQLite was reconstructed through values, not captured byte-for-byte.
238        session.raw_is_verbatim = false;
239        Ok(session)
240    }
241}
242
243fn normalize_goose_message(native: &Value, native_index: usize, out: &mut Vec<ChatMessage>) {
244    let role = match native.get("role").and_then(Value::as_str) {
245        Some("assistant") => Role::Assistant,
246        _ => Role::User,
247    };
248    let created = native.get("created").and_then(Value::as_i64);
249    let native_id = native.get("id").and_then(Value::as_str);
250    let mut text = Vec::new();
251    let mut content_parts = Vec::new();
252    let mut tool_calls = Vec::new();
253    let mut tool_results = Vec::new();
254
255    for (block_index, block) in native
256        .get("content")
257        .and_then(Value::as_array)
258        .into_iter()
259        .flatten()
260        .enumerate()
261    {
262        match block.get("type").and_then(Value::as_str) {
263            Some("text") => {
264                if let Some(value) = block.get("text").and_then(Value::as_str) {
265                    text.push(value.to_string());
266                    content_parts.push(serde_json::json!({"type": "text", "text": value}));
267                }
268            }
269            Some("image") => {
270                let data = block
271                    .get("data")
272                    .and_then(Value::as_str)
273                    .unwrap_or_default();
274                let media_type = block
275                    .get("mimeType")
276                    .or_else(|| block.get("mime_type"))
277                    .and_then(Value::as_str)
278                    .unwrap_or("application/octet-stream");
279                content_parts.push(serde_json::json!({
280                    "type": "image_url",
281                    "image_url": {"url": format!("data:{media_type};base64,{data}")},
282                }));
283            }
284            Some("toolRequest" | "frontendToolRequest") => {
285                let id = block
286                    .get("id")
287                    .and_then(Value::as_str)
288                    .map(str::to_string)
289                    .unwrap_or_else(|| format!("goose-{native_index}-{block_index}"));
290                let call = block
291                    .get("toolCall")
292                    .and_then(|call| {
293                        (call.get("status").and_then(Value::as_str) == Some("success"))
294                            .then(|| call.get("value"))
295                            .flatten()
296                    })
297                    .or_else(|| block.get("toolCall"));
298                let Some(call) = call else { continue };
299                let name = call.get("name").and_then(Value::as_str).unwrap_or("tool");
300                let arguments = call
301                    .get("arguments")
302                    .map(value_to_arg_string)
303                    .unwrap_or_else(|| "{}".to_string());
304                tool_calls.push(function_call(&id, name, arguments));
305            }
306            Some("toolResponse") => tool_results.push(block.clone()),
307            _ => {}
308        }
309    }
310
311    if !text.is_empty() || !content_parts.is_empty() || !tool_calls.is_empty() {
312        let has_non_text = content_parts
313            .iter()
314            .any(|part| part.get("type").and_then(Value::as_str) != Some("text"));
315        let mut message = ChatMessage {
316            role,
317            content: (!text.is_empty()).then(|| text.join("\n")),
318            content_parts: has_non_text.then_some(content_parts),
319            tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
320            tool_call_id: None,
321            name: None,
322            metadata: Default::default(),
323        };
324        capture_goose_message_metadata(native, created, native_id, &mut message);
325        out.push(message);
326    }
327
328    for (result_index, block) in tool_results.into_iter().enumerate() {
329        let id = block
330            .get("id")
331            .and_then(Value::as_str)
332            .map(str::to_string)
333            .unwrap_or_else(|| format!("goose-{native_index}-result-{result_index}"));
334        let result = block.get("toolResult").unwrap_or(&Value::Null);
335        let status_error = result.get("status").and_then(Value::as_str) == Some("error");
336        let value = result.get("value").unwrap_or(result);
337        let is_error = status_error || value.get("isError").and_then(Value::as_bool) == Some(true);
338        let output = if status_error {
339            result
340                .get("error")
341                .and_then(Value::as_str)
342                .unwrap_or("Goose tool call failed")
343                .to_string()
344        } else {
345            value
346                .get("content")
347                .and_then(Value::as_array)
348                .map(|content| {
349                    content
350                        .iter()
351                        .filter_map(|part| {
352                            part.get("text")
353                                .and_then(Value::as_str)
354                                .map(str::to_string)
355                                .or_else(|| Some(part.to_string()))
356                        })
357                        .collect::<Vec<_>>()
358                        .join("\n")
359                })
360                .unwrap_or_else(|| value.to_string())
361        };
362        let mut message = tool_message(&id, output);
363        if is_error {
364            crate::mark_tool_error(&mut message);
365        }
366        capture_goose_message_metadata(native, created, native_id, &mut message);
367        out.push(message);
368    }
369}
370
371fn capture_goose_message_metadata(
372    native: &Value,
373    created: Option<i64>,
374    native_id: Option<&str>,
375    message: &mut ChatMessage,
376) {
377    if let Some(created) = created {
378        message
379            .metadata
380            .insert("goose_created".to_string(), created.to_string());
381    }
382    if let Some(native_id) = native_id {
383        message
384            .metadata
385            .insert("goose_message_id".to_string(), native_id.to_string());
386    }
387    if let Some(metadata) = native.get("metadata") {
388        message
389            .metadata
390            .insert("goose_metadata".to_string(), metadata.to_string());
391    }
392}
393
394impl Session {
395    // ---- Goose writers ----------------------------------------------
396
397    pub(super) fn to_goose_json(&self) -> String {
398        if self.meta.source == SessionSource::Goose
399            && !self.raw.is_empty()
400            && self.imported_message_count == Some(self.messages.len())
401        {
402            return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
403        }
404        self.synthesized_goose_document(None, &self.messages)
405    }
406
407    pub(super) fn to_goose_json_spliced(&self, session_id: Option<&str>) -> String {
408        let message_prefix_len = self
409            .imported_message_count
410            .unwrap_or(self.messages.len())
411            .min(self.messages.len());
412        if self.meta.source == SessionSource::Goose && !self.raw.is_empty() {
413            if session_id.is_none() && message_prefix_len == self.messages.len() {
414                return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
415            }
416            let source = join_lines_verbatim(&self.raw, self.raw_trailing_newline);
417            if let Ok(mut document) = serde_json::from_str::<Value>(&source) {
418                if let Some(session_id) = session_id {
419                    document["id"] = Value::String(session_id.to_string());
420                }
421                let appended = self.goose_conversation(&self.messages[message_prefix_len..]);
422                if let Some(conversation) = document
423                    .get_mut("conversation")
424                    .and_then(Value::as_array_mut)
425                {
426                    conversation.extend(appended);
427                    document["message_count"] = Value::from(conversation.len());
428                }
429                return serde_json::to_string_pretty(&document).unwrap_or_else(|_| {
430                    self.synthesized_goose_document(session_id, &self.messages)
431                });
432            }
433        }
434        self.synthesized_goose_document(session_id, &self.messages)
435    }
436
437    fn synthesized_goose_document(
438        &self,
439        session_id: Option<&str>,
440        messages: &[ChatMessage],
441    ) -> String {
442        let mut document = self
443            .meta
444            .goose_header
445            .clone()
446            .or_else(|| {
447                self.messages.iter().find_map(|message| {
448                    message
449                        .metadata
450                        .get("goose_session_header")
451                        .and_then(|value| serde_json::from_str(value).ok())
452                })
453            })
454            .unwrap_or_else(|| {
455                serde_json::json!({
456                    "id": "supercode-goose-session",
457                    "working_dir": self.cwd_string(),
458                    "name": "supercode export",
459                    "user_set_name": false,
460                    "session_type": "user",
461                    "created_at": SYNTH_TS,
462                    "updated_at": SYNTH_TS,
463                    "extension_data": {},
464                    "usage": {},
465                    "accumulated_usage": {},
466                    "accumulated_cost": Value::Null,
467                    "schedule_id": Value::Null,
468                    "recipe": Value::Null,
469                    "user_recipe_values": Value::Null,
470                    "message_count": 0,
471                    "last_message_at": Value::Null,
472                    "provider_name": Value::Null,
473                    "model_config": Value::Null,
474                    "goose_mode": "auto",
475                    "archived_at": Value::Null,
476                    "project_id": Value::Null,
477                    "parent_session_id": Value::Null,
478                    "last_message_snippet": Value::Null,
479                })
480            });
481        document["id"] = Value::String(
482            session_id
483                .map(str::to_string)
484                .or_else(|| self.meta.session_id.clone())
485                .unwrap_or_else(|| "supercode-goose-session".to_string()),
486        );
487        document["working_dir"] = Value::String(self.cwd_string());
488        let conversation = self.goose_conversation(messages);
489        document["message_count"] = Value::from(conversation.len());
490        document["conversation"] = Value::Array(conversation);
491        serde_json::to_string_pretty(&document).unwrap_or_else(|_| "{}".to_string())
492    }
493
494    fn goose_conversation(&self, messages: &[ChatMessage]) -> Vec<Value> {
495        let mut out = Vec::new();
496        let mut last_native_index: Option<String> = None;
497        let mut tool_names = HashMap::<String, String>::new();
498        for (index, message) in messages.iter().enumerate() {
499            if is_replay_excluded(message) {
500                continue;
501            }
502            if let Some(native_index) = message.metadata.get("goose_native_index") {
503                if last_native_index.as_ref() == Some(native_index) {
504                    continue;
505                }
506                last_native_index = Some(native_index.clone());
507                if let Some(native) = message
508                    .metadata
509                    .get("goose_native_message")
510                    .and_then(|value| serde_json::from_str::<Value>(value).ok())
511                {
512                    out.push(native);
513                    continue;
514                }
515            } else {
516                last_native_index = None;
517            }
518
519            for call in message.tool_calls() {
520                tool_names.insert(call.id.clone(), call.function.name.clone());
521            }
522            let created = message
523                .metadata
524                .get("goose_created")
525                .and_then(|value| value.parse::<i64>().ok())
526                .unwrap_or(SYNTH_TS_MS / 1000 + index as i64);
527            let role = match message.role {
528                Role::Assistant => "assistant",
529                _ => "user",
530            };
531            let mut content = Vec::new();
532            // A Goose tool response carries its output inside
533            // `toolResult.value.content`; duplicating it as a sibling text
534            // block makes the loader normalize one Tool message twice.
535            if message.role != Role::Tool {
536                if let Some(text) = &message.content {
537                    let text = if message.role == Role::System {
538                        format!("[System] {text}")
539                    } else {
540                        text.clone()
541                    };
542                    content.push(serde_json::json!({"type": "text", "text": text}));
543                }
544                if let Some(parts) = &message.content_parts {
545                    for part in parts {
546                        if let Some(text) = part.get("text").and_then(Value::as_str) {
547                            if message.content.is_none() {
548                                content.push(serde_json::json!({"type": "text", "text": text}));
549                            }
550                        }
551                        let Some(url) = part
552                            .get("image_url")
553                            .and_then(|image| image.get("url"))
554                            .and_then(Value::as_str)
555                        else {
556                            continue;
557                        };
558                        let Some(data) = url.strip_prefix("data:") else {
559                            continue;
560                        };
561                        let Some((media_type, data)) = data.split_once(";base64,") else {
562                            continue;
563                        };
564                        content.push(serde_json::json!({
565                            "type": "image",
566                            "data": data,
567                            "mimeType": media_type,
568                        }));
569                    }
570                }
571            }
572            for call in message.tool_calls() {
573                let arguments = serde_json::from_str::<Value>(&call.function.arguments)
574                    .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
575                content.push(serde_json::json!({
576                    "type": "toolRequest",
577                    "id": call.id,
578                    "toolCall": {
579                        "status": "success",
580                        "value": {"name": call.function.name, "arguments": arguments}
581                    }
582                }));
583            }
584            if message.role == Role::Tool {
585                let id = message.tool_call_id.clone().unwrap_or_default();
586                let output = message.content.clone().unwrap_or_else(|| {
587                    message
588                        .content_parts
589                        .as_ref()
590                        .map(|parts| Value::Array(parts.clone()).to_string())
591                        .unwrap_or_default()
592                });
593                let tool_result = if crate::is_tool_error(message) {
594                    serde_json::json!({"status": "error", "error": output})
595                } else {
596                    serde_json::json!({
597                        "status": "success",
598                        "value": {
599                            "content": [{"type": "text", "text": output}],
600                            "isError": false
601                        }
602                    })
603                };
604                content.push(serde_json::json!({
605                    "type": "toolResponse",
606                    "id": id,
607                    "toolResult": tool_result,
608                    "metadata": {
609                        "toolName": message.name.as_ref()
610                            .or_else(|| tool_names.get(&id))
611                    }
612                }));
613            }
614            if content.is_empty() {
615                continue;
616            }
617            let metadata = message
618                .metadata
619                .get("goose_metadata")
620                .and_then(|value| serde_json::from_str::<Value>(value).ok())
621                .unwrap_or_else(|| {
622                    serde_json::json!({
623                        "userVisible": true,
624                        "agentVisible": true
625                    })
626                });
627            let mut native = serde_json::json!({
628                "id": message.metadata.get("goose_message_id")
629                    .cloned().unwrap_or_else(|| format!("supercode-goose-{index}")),
630                "role": role,
631                "created": created,
632                "content": content,
633                "metadata": metadata,
634            });
635            // Goose tolerates unknown top-level fields on a conversation
636            // message. Always carry the canonical envelope when Goose is
637            // the TARGET so metadata absent from Goose's stock schema can
638            // make a later Goose -> source round trip without residue.
639            set_grok_target_message_extension(&mut native, message);
640            out.push(native);
641        }
642        out
643    }
644}