1use super::*;
4
5pub use crate::ontology::{
6 hermes_cron_job_id, hermes_trigger_for_source, parse_hermes_session_key,
7};
8use crate::ontology::{Binding, HermesSessionRow};
9
10impl Session {
11 pub fn from_hermes_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
39 let conn = hermes_sqlite_open(db_path)?;
40 let id: String = match session_id {
41 Some(id) => id.to_string(),
42 None => conn
43 .query_row(
44 "SELECT id FROM sessions ORDER BY started_at DESC LIMIT 1",
45 [],
46 |row| row.get(0),
47 )
48 .map_err(|_| {
49 crate::Error::Other(format!(
50 "{} contains no Hermes sessions",
51 db_path.display()
52 ))
53 })?,
54 };
55 let (source, model, cwd, system_prompt, title, parent_id, model_config, started_at): (
56 Option<String>,
57 Option<String>,
58 Option<String>,
59 Option<String>,
60 Option<String>,
61 Option<String>,
62 Option<String>,
63 Option<f64>,
64 ) = conn
65 .query_row(
66 "SELECT source, model, cwd, system_prompt, title, parent_session_id, model_config, started_at FROM sessions WHERE id = ?1",
67 [&id],
68 |row| {
69 Ok((
70 row.get(0)?,
71 row.get(1)?,
72 row.get(2)?,
73 row.get(3)?,
74 row.get(4)?,
75 row.get(5)?,
76 row.get(6)?,
77 row.get(7)?,
78 ))
79 },
80 )
81 .map_err(|_| {
82 crate::Error::Other(format!(
83 "Hermes session `{id}` not found in {}",
84 db_path.display()
85 ))
86 })?;
87
88 let mut meta = SessionMeta::new(SessionSource::Hermes);
89 meta.session_id = Some(id.clone());
90 meta.model = model;
91 meta.cwd = cwd.map(PathBuf::from);
92 meta.system_prompt = system_prompt;
93 if let Some(title) = title.filter(|t| !t.is_empty()) {
94 meta.lineage.insert("session_name".to_string(), title);
95 }
96 if let Some(hermes_source) = source.filter(|s| !s.is_empty()) {
97 meta.lineage
98 .insert("hermes_source".to_string(), hermes_source);
99 }
100 if let Some(parent) = parent_id.as_deref() {
101 meta.lineage
102 .insert("hermes_parent_session_id".to_string(), parent.to_string());
103 meta.lineage.insert(
104 "hermes_lineage_kind".to_string(),
105 hermes_lineage_kind(&conn, parent, model_config.as_deref(), started_at).to_string(),
106 );
107 }
108
109 hermes_capture_nouns(&conn, &id, &mut meta);
110
111 let mut raw: Vec<String> = vec![serde_json::json!({
112 "hermes_session": {
113 "id": id,
114 "cwd": meta.cwd,
115 "parent_session_id": parent_id,
116 "started_at": started_at,
117 }
118 })
119 .to_string()];
120 let mut messages: Vec<ChatMessage> = Vec::new();
121 let mut statement = conn
122 .prepare(
123 "SELECT id, role, content, tool_call_id, tool_calls, tool_name, timestamp, reasoning_content, active, compacted FROM messages WHERE session_id = ?1 ORDER BY id",
124 )
125 .map_err(|e| crate::Error::Other(format!("Hermes messages query failed: {e}")))?;
126 let rows = statement
127 .query_map([&id], |row| {
128 Ok((
129 row.get::<_, i64>(0)?,
130 row.get::<_, Option<String>>(1)?,
131 row.get::<_, Option<String>>(2)?,
132 row.get::<_, Option<String>>(3)?,
133 row.get::<_, Option<String>>(4)?,
134 row.get::<_, Option<String>>(5)?,
135 row.get::<_, Option<f64>>(6)?,
136 row.get::<_, Option<String>>(7)?,
137 row.get::<_, Option<i64>>(8)?,
138 row.get::<_, Option<i64>>(9)?,
139 ))
140 })
141 .map_err(|e| crate::Error::Other(format!("Hermes messages scan failed: {e}")))?;
142 for row in rows {
143 let (
144 row_id,
145 role,
146 content,
147 tool_call_id,
148 tool_calls,
149 tool_name,
150 timestamp,
151 reasoning_content,
152 active,
153 compacted,
154 ) = row.map_err(|e| crate::Error::Other(format!("Hermes message row failed: {e}")))?;
155 raw.push(
156 serde_json::json!({
157 "hermes_message": {
158 "id": row_id,
159 "role": role,
160 "content": content,
161 "tool_call_id": tool_call_id,
162 "tool_calls": tool_calls,
163 "tool_name": tool_name,
164 "timestamp": timestamp,
165 "active": active,
166 "compacted": compacted,
167 }
168 })
169 .to_string(),
170 );
171 if active != Some(1) {
173 continue;
174 }
175 let stamp = |message: &mut ChatMessage| {
176 message
177 .metadata
178 .insert("hermes_message_id".to_string(), row_id.to_string());
179 if let Some(ts) = timestamp {
180 message.metadata.insert(
181 "timestamp".to_string(),
182 crate::sidecar::ms_to_rfc3339((ts * 1000.0) as i64),
183 );
184 }
185 if compacted == Some(1) {
186 message
187 .metadata
188 .insert("compacted_out".to_string(), "true".to_string());
189 }
190 };
191 match role.as_deref() {
192 Some("user") => {
193 let mut message = ChatMessage::user(content.unwrap_or_default());
194 stamp(&mut message);
195 messages.push(message);
196 }
197 Some("assistant") => {
198 let mut message = ChatMessage::assistant(content.unwrap_or_default());
199 if let Some(calls_json) = tool_calls.as_deref() {
200 if let Ok(calls) = serde_json::from_str::<Vec<Value>>(calls_json) {
201 let parsed: Vec<ToolCall> = calls
202 .iter()
203 .filter_map(|call| {
204 Some(ToolCall {
205 id: call.get("id")?.as_str()?.to_string(),
206 kind: call
207 .get("type")
208 .and_then(Value::as_str)
209 .unwrap_or("function")
210 .to_string(),
211 function: FunctionCall {
212 name: call
213 .get("function")?
214 .get("name")?
215 .as_str()?
216 .to_string(),
217 arguments: call
218 .get("function")?
219 .get("arguments")
220 .and_then(Value::as_str)
221 .unwrap_or("{}")
222 .to_string(),
223 },
224 })
225 })
226 .collect();
227 if !parsed.is_empty() {
228 message.tool_calls = Some(parsed);
229 }
230 }
231 }
232 if let Some(reasoning) = reasoning_content.filter(|r| !r.is_empty()) {
233 message
234 .metadata
235 .insert("reasoning_content".to_string(), reasoning);
236 }
237 stamp(&mut message);
238 messages.push(message);
239 }
240 Some("tool") => {
241 let mut message = ChatMessage::tool_result(
242 tool_call_id.as_deref().unwrap_or(""),
243 tool_name.as_deref().unwrap_or("tool"),
244 content.unwrap_or_default(),
245 );
246 stamp(&mut message);
247 messages.push(message);
248 }
249 _ => {}
251 }
252 }
253 drop(statement);
254 ensure_tool_results_paired(&mut messages);
255 let imported_message_count = Some(messages.len());
256 Ok(Session {
257 meta,
258 messages,
259 subagents: Vec::new(),
260 raw,
261 raw_trailing_newline: true,
262 imported_message_count,
263 raw_is_verbatim: false,
266 parse_error_lines: 0,
267 load_residue: Vec::new(),
268 })
269 }
270}
271
272pub(super) fn hermes_sqlite_fingerprint(conn: &Connection) -> bool {
284 let has = |table: &str| -> bool {
285 conn.query_row(
286 "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
287 [table],
288 |_| Ok(()),
289 )
290 .is_ok()
291 };
292 has("sessions") && has("messages") && has("schema_version") && !has("schema_meta")
293}
294
295fn hermes_sqlite_open(db_path: &Path) -> Result<Connection> {
299 if !db_path.is_file() {
300 return Err(crate::Error::Other(format!(
301 "Hermes SQLite store not found at {} — expected a `state.db` file",
302 db_path.display()
303 )));
304 }
305 let conn = Connection::open_with_flags(
306 db_path,
307 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
308 )
309 .map_err(|e| {
310 crate::Error::Other(format!(
311 "{} does not look like a valid Hermes SQLite database: {e}",
312 db_path.display()
313 ))
314 })?;
315 if !hermes_sqlite_fingerprint(&conn) {
316 return Err(crate::Error::Other(format!(
317 "{} is SQLite but not a Hermes state.db (missing sessions/messages/schema_version, or it carries OpenClaw's schema_meta)",
318 db_path.display()
319 )));
320 }
321 Ok(conn)
322}
323
324pub(crate) fn hermes_capture_nouns(conn: &Connection, id: &str, meta: &mut SessionMeta) {
330 let mut row = HermesSessionRow {
331 id: id.to_string(),
332 source: meta.lineage.get("hermes_source").cloned(),
333 lineage_kind: meta.lineage.get("hermes_lineage_kind").cloned(),
334 ..Default::default()
335 };
336 type Row = (
337 Option<String>,
338 Option<String>,
339 Option<String>,
340 Option<String>,
341 Option<String>,
342 Option<String>,
343 Option<String>,
344 Option<String>,
345 Option<String>,
346 );
347 let extended: Option<Row> = conn
348 .query_row(
349 "SELECT session_key, chat_id, chat_type, thread_id, user_id, profile_name, \
350 handoff_state, handoff_platform, handoff_error FROM sessions WHERE id = ?1",
351 [id],
352 |row| {
353 Ok((
354 row.get(0)?,
355 row.get(1)?,
356 row.get(2)?,
357 row.get(3)?,
358 row.get(4)?,
359 row.get(5)?,
360 row.get(6)?,
361 row.get(7)?,
362 row.get(8)?,
363 ))
364 },
365 )
366 .ok();
367 let extended_read = extended.is_some();
368 if let Some((
369 key,
370 chat_id,
371 chat_type,
372 thread_id,
373 user_id,
374 profile,
375 h_state,
376 h_platform,
377 h_error,
378 )) = extended
379 {
380 row.session_key = key;
381 row.chat_id = chat_id;
382 row.chat_type = chat_type;
383 row.thread_id = thread_id;
384 row.user_id = user_id;
385 row.profile_name = profile;
386 row.handoff_state = h_state;
387 row.handoff_platform = h_platform;
388 row.handoff_error = h_error;
389 }
390 let binding = Binding::from_hermes_row(&row, None);
391 meta.trigger = Some(binding.trigger);
392 meta.recurrence = binding.recurrence.clone();
393 if !extended_read {
394 return;
395 }
396 let nouns = binding.nouns();
397 meta.surface = nouns.surface;
398 if let Some(p) = nouns.profile {
399 meta.profile = Some(p);
400 }
401 meta.cross_surface = nouns.cross_surface;
402}
403
404pub(crate) fn hermes_lineage_kind(
413 conn: &Connection,
414 parent_id: &str,
415 model_config: Option<&str>,
416 started_at: Option<f64>,
417) -> &'static str {
418 let marker = |key: &str| -> bool {
419 model_config
420 .and_then(|raw| serde_json::from_str::<Value>(raw).ok())
421 .map(|config| config.get(key).map(|v| !v.is_null()).unwrap_or(false))
422 .unwrap_or(false)
423 };
424 if marker("_delegate_from") {
425 return "delegate";
426 }
427 if marker("_branched_from") {
428 return "branch";
429 }
430 let parent: Option<(Option<String>, Option<f64>)> = conn
431 .query_row(
432 "SELECT end_reason, ended_at FROM sessions WHERE id = ?1",
433 [parent_id],
434 |row| Ok((row.get(0)?, row.get(1)?)),
435 )
436 .ok();
437 if let Some((end_reason, ended_at)) = parent {
438 match end_reason.as_deref() {
439 Some("compression") => return "compaction",
440 Some("branched") => {
441 let started = started_at.unwrap_or(f64::MAX);
442 let ended = ended_at.unwrap_or(f64::MAX);
443 if started >= ended {
444 return "branch";
445 }
446 }
447 _ => {}
448 }
449 }
450 "unknown"
451}