1use 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#[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#[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 #[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 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 _ => 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 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}