Skip to main content

txcript/harness/
hermes.rs

1//! Hermes Agent sessions: `~/.hermes/state.db` and `hermes sessions export`.
2//!
3//! Hermes stores session metadata and ordered message rows in `SQLite`. Its JSONL
4//! exporter emits one JSON object per session, with the message rows nested in
5//! a `messages` array; that object is this harness's portable text form. The
6//! codec reads active user, assistant, and tool rows, preserving reasoning,
7//! function calls, results, stop reasons, and metadata through [`Common`].
8//!
9//! Native JSON is retained as an opaque [`Value`], so unknown session columns,
10//! message columns, and bookkeeping roles survive text round trips. Hermes has
11//! no public import command, so the store is intentionally read-only; generated
12//! Hermes text can be exported or converted, but is not inserted into a live
13//! `state.db`. Multiple same-kind text/reasoning blocks are joined with newlines,
14//! and images have no native Hermes message-row slot.
15
16use chrono::{DateTime, Utc};
17use serde_json::{Map, Value, json};
18use uuid::Uuid;
19
20#[cfg(feature = "hermes")]
21use rusqlite::{Connection, OpenFlags};
22#[cfg(feature = "hermes")]
23use std::collections::HashMap;
24#[cfg(feature = "hermes")]
25use std::path::Path;
26use std::path::PathBuf;
27
28use crate::common::{Block, ImageSource, Message, Meta, Role, StopReason, Tool, ToolOutput};
29use crate::error::{Error, Result};
30use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript};
31
32/// The Hermes Agent harness marker.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Hermes;
35
36impl Harness for Hermes {
37    const NAME: &'static str = "hermes";
38    type Body = Value;
39}
40
41impl TextCodec for Hermes {
42    fn from_text(text: &str) -> Result<Transcript<Self>> {
43        let body: Value = serde_json::from_str(text)?;
44        let meta = meta_from_export(&body);
45        Ok(Transcript::new(meta, body))
46    }
47
48    fn to_text(transcript: &Transcript<Self>) -> Result<String> {
49        Ok(serde_json::to_string_pretty(&transcript.body)?)
50    }
51}
52
53impl Codec for Hermes {
54    fn to_common(transcript: &Transcript<Self>) -> Result<Transcript<Common>> {
55        Ok(Transcript::new(
56            transcript.meta.clone(),
57            messages_from_export(&transcript.body, &transcript.meta),
58        ))
59    }
60
61    fn from_common(transcript: &Transcript<Common>) -> Result<Transcript<Self>> {
62        let body = export_from_messages(&transcript.meta, &transcript.body);
63        let mut meta = transcript.meta.clone();
64        if meta.id.is_empty() {
65            meta.id = body
66                .get("id")
67                .and_then(Value::as_str)
68                .unwrap_or_default()
69                .to_string();
70        }
71        Ok(Transcript::new(meta, body))
72    }
73}
74
75/// Read-only access to Hermes Agent's canonical `state.db` session store.
76#[derive(Debug, Clone)]
77pub struct HermesStore {
78    pub db_path: PathBuf,
79}
80
81impl HermesStore {
82    pub fn new(path: impl Into<PathBuf>) -> Self {
83        Self {
84            db_path: path.into(),
85        }
86    }
87
88    /// Resolve `$HERMES_HOME/state.db`, falling back to `~/.hermes/state.db`.
89    #[must_use]
90    pub fn default_root() -> Option<Self> {
91        std::env::var_os("HERMES_HOME")
92            .filter(|value| !value.is_empty())
93            .map(PathBuf::from)
94            .or_else(|| super::home_dir().map(|home| home.join(".hermes")))
95            .map(|root| Self::new(root.join("state.db")))
96    }
97}
98
99#[cfg(feature = "hermes")]
100impl Store for HermesStore {
101    type H = Hermes;
102    type Ref = String;
103
104    fn discover(&self) -> Result<Vec<Discovered<String>>> {
105        if !self.db_path.is_file() {
106            return Ok(Vec::new());
107        }
108        let conn = open_read_only(&self.db_path)?;
109        let rows = query_rows(&conn, "SELECT * FROM sessions", &[])?;
110        Ok(rows
111            .into_iter()
112            .filter(|row| row.get("archived").and_then(Value::as_i64) != Some(1))
113            .filter_map(|row| {
114                let body = Value::Object(row);
115                let meta = meta_from_export(&body);
116                (!meta.id.is_empty()).then(|| Discovered {
117                    reference: meta.id.clone(),
118                    meta,
119                })
120            })
121            .collect())
122    }
123
124    fn load(&self, reference: &String) -> Result<Transcript<Hermes>> {
125        let conn = open_read_only(&self.db_path)?;
126        let mut sessions = query_rows(
127            &conn,
128            "SELECT * FROM sessions WHERE id = ?1",
129            &[reference.as_str()],
130        )?;
131        let mut session = sessions.pop().ok_or_else(|| Error::Malformed {
132            harness: Hermes::NAME,
133            detail: format!(
134                "session `{reference}` not found in {}",
135                self.db_path.display()
136            ),
137        })?;
138        let mut messages = query_rows(
139            &conn,
140            "SELECT * FROM messages WHERE session_id = ?1 AND active = 1 ORDER BY id",
141            &[reference.as_str()],
142        )?;
143        for row in &mut messages {
144            normalize_export_message(row);
145        }
146        session.insert(
147            "messages".to_string(),
148            Value::Array(messages.into_iter().map(Value::Object).collect()),
149        );
150        let body = Value::Object(session);
151        Ok(Transcript::new(meta_from_export(&body), body))
152    }
153
154    fn save(&self, _transcript: &Transcript<Hermes>) -> Result<Saved<String>> {
155        Err(read_only_error())
156    }
157
158    fn delete(&self, _reference: &String) -> Result<()> {
159        Err(read_only_error())
160    }
161
162    fn fingerprints(&self, refs: &[String]) -> Result<HashMap<String, String>> {
163        let mut output = HashMap::with_capacity(refs.len());
164        for reference in refs {
165            let transcript = self.load(reference)?;
166            output.insert(reference.clone(), stable_hash(&transcript.body));
167        }
168        Ok(output)
169    }
170}
171
172#[cfg(not(feature = "hermes"))]
173impl Store for HermesStore {
174    type H = Hermes;
175    type Ref = String;
176
177    fn discover(&self) -> Result<Vec<Discovered<String>>> {
178        Ok(Vec::new())
179    }
180
181    fn load(&self, _reference: &String) -> Result<Transcript<Hermes>> {
182        Err(sqlite_unavailable())
183    }
184
185    fn save(&self, _transcript: &Transcript<Hermes>) -> Result<Saved<String>> {
186        Err(sqlite_unavailable())
187    }
188
189    fn delete(&self, _reference: &String) -> Result<()> {
190        Err(sqlite_unavailable())
191    }
192}
193
194#[cfg(feature = "hermes")]
195fn open_read_only(path: &Path) -> Result<Connection> {
196    Connection::open_with_flags(
197        path,
198        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
199    )
200    .map_err(sqlite_error)
201}
202
203#[cfg(feature = "hermes")]
204fn query_rows(conn: &Connection, sql: &str, args: &[&str]) -> Result<Vec<Map<String, Value>>> {
205    let mut statement = conn.prepare(sql).map_err(sqlite_error)?;
206    let names: Vec<String> = statement
207        .column_names()
208        .into_iter()
209        .map(String::from)
210        .collect();
211    let mut rows = statement
212        .query(rusqlite::params_from_iter(args.iter()))
213        .map_err(sqlite_error)?;
214    let mut output = Vec::new();
215    while let Some(row) = rows.next().map_err(sqlite_error)? {
216        let mut object = Map::with_capacity(names.len());
217        for (index, name) in names.iter().enumerate() {
218            let value = row.get_ref(index).map_err(sqlite_error)?;
219            object.insert(name.clone(), sqlite_value(value));
220        }
221        output.push(object);
222    }
223    Ok(output)
224}
225
226#[cfg(feature = "hermes")]
227fn sqlite_value(value: rusqlite::types::ValueRef<'_>) -> Value {
228    match value {
229        rusqlite::types::ValueRef::Null => Value::Null,
230        rusqlite::types::ValueRef::Integer(number) => json!(number),
231        rusqlite::types::ValueRef::Real(number) => json!(number),
232        rusqlite::types::ValueRef::Text(bytes) => {
233            Value::String(String::from_utf8_lossy(bytes).into_owned())
234        }
235        rusqlite::types::ValueRef::Blob(bytes) => json!({"$blob_hex": hex(bytes)}),
236    }
237}
238
239#[cfg(feature = "hermes")]
240fn hex(bytes: &[u8]) -> String {
241    const DIGITS: &[u8; 16] = b"0123456789abcdef";
242    let mut output = String::with_capacity(bytes.len().saturating_mul(2));
243    for byte in bytes {
244        output.push(char::from(DIGITS[usize::from(byte >> 4)]));
245        output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
246    }
247    output
248}
249
250#[cfg(feature = "hermes")]
251fn normalize_export_message(row: &mut Map<String, Value>) {
252    // `HermesState.get_messages` decodes sentinel-prefixed structured content
253    // and tool_calls, while leaving ordinary JSON-looking tool-result strings
254    // as text. Match that public export contract rather than exposing SQLite's
255    // storage representation.
256    if let Some(value) = row.get_mut("content")
257        && let Value::String(content) = value
258        && let Some(encoded) = content.strip_prefix("\0json:")
259        && let Ok(decoded) = serde_json::from_str(encoded)
260    {
261        *value = decoded;
262    }
263    if let Some(value) = row.get_mut("tool_calls")
264        && let Value::String(text) = value
265        && !text.is_empty()
266    {
267        *value = serde_json::from_str(text).unwrap_or_else(|_| json!([]));
268    }
269}
270
271#[cfg(feature = "hermes")]
272#[allow(clippy::needless_pass_by_value)]
273fn sqlite_error(error: rusqlite::Error) -> Error {
274    Error::Malformed {
275        harness: Hermes::NAME,
276        detail: error.to_string(),
277    }
278}
279
280#[cfg(feature = "hermes")]
281fn read_only_error() -> Error {
282    Error::Unconvertible {
283        harness: Hermes::NAME,
284        detail:
285            "Hermes state.db support is read-only; use `hermes sessions export` for portable output"
286                .to_string(),
287    }
288}
289
290#[cfg(not(feature = "hermes"))]
291fn sqlite_unavailable() -> Error {
292    Error::Unconvertible {
293        harness: Hermes::NAME,
294        detail: "Hermes store support requires the `hermes` feature for SQLite".to_string(),
295    }
296}
297
298fn meta_from_export(body: &Value) -> Meta {
299    let string = |key: &str| {
300        body.get(key)
301            .and_then(Value::as_str)
302            .filter(|s| !s.is_empty())
303            .map(String::from)
304    };
305    Meta {
306        id: string("id").unwrap_or_default(),
307        timestamp: body
308            .get("started_at")
309            .and_then(Value::as_f64)
310            .and_then(timestamp_from_seconds)
311            .unwrap_or(DateTime::<Utc>::UNIX_EPOCH),
312        cwd: string("cwd"),
313        git_branch: string("git_branch"),
314        title: string("title"),
315        cli_version: string("cli_version"),
316        model: string("model"),
317    }
318}
319
320#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
321fn timestamp_from_seconds(seconds: f64) -> Option<DateTime<Utc>> {
322    if !seconds.is_finite() {
323        return None;
324    }
325    let whole = seconds.floor();
326    let nanos = ((seconds - whole) * 1_000_000_000.0).round();
327    let secs = whole as i64;
328    let nanos = nanos.clamp(0.0, 999_999_999.0) as u32;
329    DateTime::from_timestamp(secs, nanos)
330}
331
332fn message_timestamp(row: &Value, fallback: DateTime<Utc>) -> DateTime<Utc> {
333    row.get("timestamp")
334        .and_then(Value::as_f64)
335        .and_then(timestamp_from_seconds)
336        .unwrap_or(fallback)
337}
338
339fn messages_from_export(body: &Value, meta: &Meta) -> Vec<Message> {
340    body.get("messages")
341        .and_then(Value::as_array)
342        .into_iter()
343        .flatten()
344        .filter(|row| row.get("active").and_then(Value::as_i64) != Some(0))
345        .filter_map(|row| message_from_row(row, meta))
346        .collect()
347}
348
349fn message_from_row(row: &Value, meta: &Meta) -> Option<Message> {
350    let timestamp = message_timestamp(row, meta.timestamp);
351    match row.get("role").and_then(Value::as_str) {
352        Some("user") => {
353            let content = content_blocks(row.get("content"));
354            (!content.is_empty()).then_some(Message {
355                role: Role::User,
356                content,
357                timestamp,
358                model: None,
359                stop_reason: None,
360                usage: None,
361            })
362        }
363        Some("assistant") => assistant_message(row, meta, timestamp),
364        Some("tool") => tool_result_message(row, timestamp),
365        // `session_meta`, system, and future bookkeeping rows remain in the
366        // native body but carry no conversational turn.
367        _ => None,
368    }
369}
370
371fn assistant_message(row: &Value, meta: &Meta, timestamp: DateTime<Utc>) -> Option<Message> {
372    let mut content = Vec::new();
373    let reasoning = reasoning_text(row).filter(|s| !s.trim().is_empty());
374    let encrypted = reasoning_encrypted(row);
375    if reasoning.is_some() || encrypted.is_some() {
376        content.push(Block::Thinking {
377            text: reasoning.unwrap_or_default(),
378            signature: None,
379            encrypted,
380        });
381    }
382    content.extend(content_blocks(row.get("content")));
383    if let Some(calls) = tool_calls(row) {
384        content.extend(calls);
385    }
386    if content.is_empty() {
387        return None;
388    }
389    Some(Message {
390        role: Role::Assistant,
391        content,
392        timestamp,
393        model: meta.model.clone(),
394        stop_reason: row
395            .get("finish_reason")
396            .and_then(Value::as_str)
397            .map(parse_finish_reason),
398        usage: None,
399    })
400}
401
402fn content_blocks(value: Option<&Value>) -> Vec<Block> {
403    match value {
404        None | Some(Value::Null) => Vec::new(),
405        Some(Value::String(text)) if text.trim().is_empty() => Vec::new(),
406        Some(Value::String(text)) => vec![Block::Text { text: text.clone() }],
407        Some(Value::Array(parts)) => parts.iter().filter_map(content_part).collect(),
408        Some(value) => content_part(value).into_iter().collect(),
409    }
410}
411
412fn content_part(part: &Value) -> Option<Block> {
413    match part.get("type").and_then(Value::as_str) {
414        Some("text") => part
415            .get("text")
416            .and_then(Value::as_str)
417            .map(|text| Block::Text {
418                text: text.to_string(),
419            }),
420        Some("image_url") => part
421            .pointer("/image_url/url")
422            .and_then(Value::as_str)
423            .and_then(data_url_image)
424            .or(Some(Block::Text {
425                text: value_text(part),
426            })),
427        _ => Some(Block::Text {
428            text: value_text(part),
429        }),
430    }
431}
432
433fn data_url_image(url: &str) -> Option<Block> {
434    let value = url.strip_prefix("data:")?;
435    let (media_type, data) = value.split_once(";base64,")?;
436    Some(Block::Image {
437        source: ImageSource {
438            source_type: "base64".to_string(),
439            media_type: media_type.to_string(),
440            data: data.to_string(),
441        },
442    })
443}
444
445fn reasoning_text(row: &Value) -> Option<String> {
446    ["reasoning_content", "reasoning"]
447        .into_iter()
448        .find_map(|key| text_content(row.get(key)).filter(|s| !s.is_empty()))
449}
450
451fn reasoning_encrypted(row: &Value) -> Option<String> {
452    ["codex_reasoning_items", "reasoning_details"]
453        .into_iter()
454        .find_map(|key| row.get(key).filter(|v| !v.is_null()))
455        .map(value_text)
456}
457
458fn tool_calls(row: &Value) -> Option<Vec<Block>> {
459    let value = row.get("tool_calls")?;
460    let parsed;
461    let calls = if let Some(array) = value.as_array() {
462        array
463    } else {
464        let text = value.as_str()?;
465        parsed = serde_json::from_str::<Value>(text).ok()?;
466        parsed.as_array()?
467    };
468    Some(calls.iter().filter_map(tool_call_block).collect())
469}
470
471fn tool_call_block(call: &Value) -> Option<Block> {
472    let function = call.get("function")?;
473    let name = function.get("name").and_then(Value::as_str)?;
474    let input = match function.get("arguments") {
475        Some(Value::String(text)) => {
476            serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone()))
477        }
478        Some(value) => value.clone(),
479        None => Value::Object(Map::new()),
480    };
481    let id = call
482        .get("id")
483        .or_else(|| call.get("call_id"))
484        .and_then(Value::as_str)
485        .unwrap_or_default()
486        .to_string();
487    let (canonical_name, canonical_input) = normalize_tool(name, input);
488    Some(Block::ToolUse {
489        id,
490        tool: Tool::from_canonical(&canonical_name, canonical_input),
491    })
492}
493
494fn normalize_tool(name: &str, input: Value) -> (String, Value) {
495    let Some(mut object) = input.as_object().cloned() else {
496        return (name.to_string(), input);
497    };
498    match name {
499        "read_file" => {
500            rename_key(&mut object, "path", "file_path");
501            ("Read".to_string(), Value::Object(object))
502        }
503        "write_file" => {
504            rename_key(&mut object, "path", "file_path");
505            ("Write".to_string(), Value::Object(object))
506        }
507        "patch" if object.get("mode").and_then(Value::as_str) == Some("replace") => {
508            object.remove("mode");
509            rename_key(&mut object, "path", "file_path");
510            ("Edit".to_string(), Value::Object(object))
511        }
512        "terminal" => {
513            rename_key(&mut object, "background", "run_in_background");
514            if let Some(timeout) = object.get_mut("timeout")
515                && let Some(seconds) = timeout.as_u64()
516            {
517                *timeout = json!(seconds.saturating_mul(1000));
518                rename_key(&mut object, "timeout", "timeout_ms");
519            }
520            ("Bash".to_string(), Value::Object(object))
521        }
522        _ => (name.to_string(), Value::Object(object)),
523    }
524}
525
526fn rename_key(object: &mut Map<String, Value>, from: &str, to: &str) {
527    if let Some(value) = object.remove(from) {
528        object.insert(to.to_string(), value);
529    }
530}
531
532fn tool_result_message(row: &Value, timestamp: DateTime<Utc>) -> Option<Message> {
533    let tool_use_id = row.get("tool_call_id").and_then(Value::as_str)?.to_string();
534    let content = row
535        .get("content")
536        .cloned()
537        .map_or_else(|| ToolOutput::Text(String::new()), tool_output);
538    Some(Message {
539        role: Role::User,
540        content: vec![Block::ToolResult {
541            tool_use_id,
542            is_error: tool_result_is_error(row, &content),
543            content,
544        }],
545        timestamp,
546        model: None,
547        stop_reason: None,
548        usage: None,
549    })
550}
551
552fn tool_output(value: Value) -> ToolOutput {
553    match value {
554        Value::String(text) => serde_json::from_str::<Value>(&text)
555            .ok()
556            .filter(|v| v.is_object() || v.is_array())
557            .map_or(ToolOutput::Text(text), ToolOutput::Json),
558        other => ToolOutput::Json(other),
559    }
560}
561
562fn tool_result_is_error(row: &Value, output: &ToolOutput) -> bool {
563    if row.get("effect_disposition").and_then(Value::as_str) == Some("denied") {
564        return true;
565    }
566    match output {
567        ToolOutput::Json(value) => {
568            value.get("success").and_then(Value::as_bool) == Some(false)
569                || value
570                    .get("exit_code")
571                    .and_then(Value::as_i64)
572                    .is_some_and(|code| code != 0)
573                || value.get("error").is_some_and(|error| !error.is_null())
574        }
575        ToolOutput::Text(_) => false,
576    }
577}
578
579fn text_content(value: Option<&Value>) -> Option<String> {
580    match value? {
581        Value::Null => None,
582        Value::String(text) => Some(text.clone()),
583        other => Some(value_text(other)),
584    }
585}
586
587fn value_text(value: &Value) -> String {
588    value
589        .as_str()
590        .map_or_else(|| value.to_string(), String::from)
591}
592
593fn parse_finish_reason(reason: &str) -> StopReason {
594    match reason {
595        "stop" | "end_turn" => StopReason::EndTurn,
596        "tool_calls" | "tool_use" => StopReason::ToolUse,
597        "length" | "max_tokens" => StopReason::MaxTokens,
598        "stop_sequence" => StopReason::StopSequence,
599        "aborted" | "cancelled" => StopReason::Aborted,
600        "error" => StopReason::Error,
601        other => StopReason::Other(other.to_string()),
602    }
603}
604
605fn finish_reason(reason: Option<&StopReason>, has_tools: bool) -> &'static str {
606    match reason {
607        Some(StopReason::MaxTokens) => "length",
608        Some(StopReason::StopSequence) => "stop_sequence",
609        Some(StopReason::Aborted) => "aborted",
610        Some(StopReason::Error) => "error",
611        Some(StopReason::EndTurn | StopReason::Other(_)) | None if !has_tools => "stop",
612        Some(StopReason::ToolUse | StopReason::EndTurn | StopReason::Other(_)) | None => {
613            "tool_calls"
614        }
615    }
616}
617
618fn stable_hash<T: serde::Serialize>(value: &T) -> String {
619    let bytes = serde_json::to_vec(value).unwrap_or_default();
620    Uuid::new_v5(&Uuid::NAMESPACE_URL, &bytes)
621        .simple()
622        .to_string()
623}
624
625fn export_from_messages(meta: &Meta, messages: &[Message]) -> Value {
626    let session_id = if meta.id.is_empty() {
627        format!(
628            "{}_{}",
629            meta.timestamp.format("%Y%m%d_%H%M%S"),
630            stable_hash(&(meta, messages))
631        )
632    } else {
633        meta.id.clone()
634    };
635    let rows = rows_from_messages(&session_id, messages);
636
637    json!({
638        "id": session_id,
639        "source": "txcript",
640        "model": meta.model,
641        "started_at": timestamp_seconds(meta.timestamp),
642        "cwd": meta.cwd,
643        "git_branch": meta.git_branch,
644        "title": meta.title,
645        "message_count": rows.len(),
646        "tool_call_count": rows.iter()
647            .filter(|row| row.get("role").and_then(Value::as_str) == Some("assistant"))
648            .filter_map(|row| row.get("tool_calls").and_then(Value::as_array))
649            .map(Vec::len)
650            .sum::<usize>(),
651        "messages": rows
652    })
653}
654
655fn rows_from_messages(session_id: &str, messages: &[Message]) -> Vec<Value> {
656    let mut rows = Vec::new();
657    let mut next_id = 1_u64;
658    for message in messages {
659        match message.role {
660            Role::User => {
661                for block in &message.content {
662                    if let Block::ToolResult {
663                        tool_use_id,
664                        content,
665                        is_error,
666                    } = block
667                    {
668                        rows.push(json!({
669                            "id": next_id,
670                            "session_id": session_id,
671                            "role": "tool",
672                            "content": output_value(content),
673                            "tool_call_id": tool_use_id,
674                            "effect_disposition": if *is_error { "denied" } else { "allowed" },
675                            "timestamp": timestamp_seconds(message.timestamp),
676                            "observed": 0,
677                            "active": 1,
678                            "compacted": 0
679                        }));
680                        next_id += 1;
681                    }
682                }
683                let native = native_content(&message.content);
684                if !native.is_null() {
685                    rows.push(base_row(
686                        next_id,
687                        session_id,
688                        "user",
689                        &native,
690                        message.timestamp,
691                    ));
692                    next_id += 1;
693                }
694            }
695            Role::Assistant => {
696                let mut reasoning = Vec::new();
697                let mut encrypted = None;
698                let mut calls = Vec::new();
699                for block in &message.content {
700                    match block {
701                        Block::Thinking {
702                            text: value,
703                            encrypted: value_encrypted,
704                            ..
705                        } => {
706                            reasoning.push(value.clone());
707                            encrypted = encrypted.or_else(|| value_encrypted.clone());
708                        }
709                        Block::ToolUse { id, tool } => calls.push(tool_call_value(id, tool)),
710                        Block::Text { .. } | Block::Image { .. } | Block::ToolResult { .. } => {}
711                    }
712                }
713                let has_tools = !calls.is_empty();
714                let native = native_content(&message.content);
715                let mut row =
716                    base_row(next_id, session_id, "assistant", &native, message.timestamp);
717                if let Some(object) = row.as_object_mut() {
718                    if !reasoning.is_empty() {
719                        object.insert("reasoning_content".into(), json!(reasoning.join("\n")));
720                    }
721                    if let Some(value) = encrypted {
722                        object.insert("codex_reasoning_items".into(), json!(value));
723                    }
724                    if has_tools {
725                        object.insert("tool_calls".into(), Value::Array(calls));
726                    }
727                    object.insert(
728                        "finish_reason".into(),
729                        json!(finish_reason(message.stop_reason.as_ref(), has_tools)),
730                    );
731                }
732                rows.push(row);
733                next_id += 1;
734            }
735        }
736    }
737    rows
738}
739
740fn native_content(blocks: &[Block]) -> Value {
741    let has_image = blocks
742        .iter()
743        .any(|block| matches!(block, Block::Image { .. }));
744    if !has_image {
745        let text = blocks
746            .iter()
747            .filter_map(|block| match block {
748                Block::Text { text } => Some(text.as_str()),
749                _ => None,
750            })
751            .collect::<Vec<_>>()
752            .join("\n");
753        return if text.is_empty() {
754            Value::Null
755        } else {
756            Value::String(text)
757        };
758    }
759    Value::Array(
760        blocks
761            .iter()
762            .filter_map(|block| match block {
763                Block::Text { text } => Some(json!({"type": "text", "text": text})),
764                Block::Image { source } => Some(json!({
765                    "type": "image_url",
766                    "image_url": {
767                        "url": format!("data:{};base64,{}", source.media_type, source.data)
768                    }
769                })),
770                _ => None,
771            })
772            .collect(),
773    )
774}
775
776fn base_row(
777    id: u64,
778    session_id: &str,
779    role: &str,
780    content: &Value,
781    timestamp: DateTime<Utc>,
782) -> Value {
783    json!({
784        "id": id,
785        "session_id": session_id,
786        "role": role,
787        "content": content,
788        "timestamp": timestamp_seconds(timestamp),
789        "observed": 0,
790        "active": 1,
791        "compacted": 0
792    })
793}
794
795#[allow(clippy::cast_precision_loss)]
796fn timestamp_seconds(timestamp: DateTime<Utc>) -> f64 {
797    timestamp.timestamp() as f64 + f64::from(timestamp.timestamp_subsec_nanos()) / 1_000_000_000.0
798}
799
800fn tool_call_value(id: &str, tool: &Tool) -> Value {
801    let (name, input) = denormalize_tool(tool);
802    json!({
803        "id": id,
804        "call_id": id,
805        "type": "function",
806        "function": {
807            "name": name,
808            "arguments": input.to_string()
809        }
810    })
811}
812
813fn denormalize_tool(tool: &Tool) -> (String, Value) {
814    match tool {
815        Tool::Read { .. } => {
816            let (_, mut input) = tool.to_canonical();
817            if let Some(object) = input.as_object_mut() {
818                rename_key(object, "file_path", "path");
819            }
820            ("read_file".to_string(), input)
821        }
822        Tool::Write { .. } => {
823            let (_, mut input) = tool.to_canonical();
824            if let Some(object) = input.as_object_mut() {
825                rename_key(object, "file_path", "path");
826            }
827            ("write_file".to_string(), input)
828        }
829        Tool::Edit { .. } => {
830            let (_, mut input) = tool.to_canonical();
831            if let Some(object) = input.as_object_mut() {
832                rename_key(object, "file_path", "path");
833                object.insert("mode".into(), json!("replace"));
834            }
835            ("patch".to_string(), input)
836        }
837        Tool::Bash { .. } => {
838            let (_, mut input) = tool.to_canonical();
839            if let Some(object) = input.as_object_mut() {
840                rename_key(object, "run_in_background", "background");
841                if let Some(milliseconds) = object.remove("timeout_ms") {
842                    let seconds = milliseconds
843                        .as_u64()
844                        .map(|ms| ms / 1000)
845                        .unwrap_or_default();
846                    object.insert("timeout".into(), json!(seconds));
847                }
848            }
849            ("terminal".to_string(), input)
850        }
851        // Commands keep their canonical slash-prefixed name; no Hermes tool
852        // name may collide with it, so the round trip is the identity.
853        Tool::MultiEdit { .. } | Tool::Raw { .. } | Tool::Command { .. } => tool.to_canonical(),
854    }
855}
856
857fn output_value(output: &ToolOutput) -> Value {
858    match output {
859        ToolOutput::Text(text) => Value::String(text.clone()),
860        ToolOutput::Json(value) => value.clone(),
861    }
862}