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 let cwd = cwd.filter(|c| !c.is_empty()).or_else(|| {
95 model_config
96 .as_deref()
97 .and_then(|raw| serde_json::from_str::<Value>(raw).ok())
98 .and_then(|v| v.get("cwd").and_then(Value::as_str).map(str::to_string))
99 .filter(|c| !c.is_empty())
100 });
101 meta.cwd = cwd.map(PathBuf::from);
102 meta.system_prompt = system_prompt;
103 if let Some(title) = title.filter(|t| !t.is_empty()) {
104 meta.lineage.insert("session_name".to_string(), title);
105 }
106 if let Some(hermes_source) = source.filter(|s| !s.is_empty()) {
107 meta.lineage
108 .insert("hermes_source".to_string(), hermes_source);
109 }
110 if let Some(parent) = parent_id.as_deref() {
111 meta.lineage
112 .insert("hermes_parent_session_id".to_string(), parent.to_string());
113 meta.lineage.insert(
114 "hermes_lineage_kind".to_string(),
115 hermes_lineage_kind(&conn, parent, model_config.as_deref(), started_at).to_string(),
116 );
117 }
118
119 hermes_capture_nouns(&conn, &id, &mut meta);
120
121 let mut raw: Vec<String> = vec![serde_json::json!({
122 "hermes_session": {
123 "id": id,
124 "cwd": meta.cwd,
125 "parent_session_id": parent_id,
126 "started_at": started_at,
127 }
128 })
129 .to_string()];
130 let mut messages: Vec<ChatMessage> = Vec::new();
131 let mut statement = conn
132 .prepare(
133 "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",
134 )
135 .map_err(|e| crate::Error::Other(format!("Hermes messages query failed: {e}")))?;
136 let rows = statement
137 .query_map([&id], |row| {
138 Ok((
139 row.get::<_, i64>(0)?,
140 row.get::<_, Option<String>>(1)?,
141 row.get::<_, Option<String>>(2)?,
142 row.get::<_, Option<String>>(3)?,
143 row.get::<_, Option<String>>(4)?,
144 row.get::<_, Option<String>>(5)?,
145 row.get::<_, Option<f64>>(6)?,
146 row.get::<_, Option<String>>(7)?,
147 row.get::<_, Option<i64>>(8)?,
148 row.get::<_, Option<i64>>(9)?,
149 ))
150 })
151 .map_err(|e| crate::Error::Other(format!("Hermes messages scan failed: {e}")))?;
152 for row in rows {
153 let (
154 row_id,
155 role,
156 content,
157 tool_call_id,
158 tool_calls,
159 tool_name,
160 timestamp,
161 reasoning_content,
162 active,
163 compacted,
164 ) = row.map_err(|e| crate::Error::Other(format!("Hermes message row failed: {e}")))?;
165 raw.push(
166 serde_json::json!({
167 "hermes_message": {
168 "id": row_id,
169 "role": role,
170 "content": content,
171 "tool_call_id": tool_call_id,
172 "tool_calls": tool_calls,
173 "tool_name": tool_name,
174 "timestamp": timestamp,
175 "active": active,
176 "compacted": compacted,
177 }
178 })
179 .to_string(),
180 );
181 if active != Some(1) {
183 continue;
184 }
185 let stamp = |message: &mut ChatMessage| {
186 message
187 .metadata
188 .insert("hermes_message_id".to_string(), row_id.to_string());
189 if let Some(ts) = timestamp {
190 message.metadata.insert(
191 "timestamp".to_string(),
192 crate::sidecar::ms_to_rfc3339((ts * 1000.0) as i64),
193 );
194 }
195 if compacted == Some(1) {
196 message
197 .metadata
198 .insert("compacted_out".to_string(), "true".to_string());
199 }
200 };
201 match role.as_deref() {
202 Some("user") => {
203 let mut message = ChatMessage::user(content.unwrap_or_default());
204 stamp(&mut message);
205 messages.push(message);
206 }
207 Some("assistant") => {
208 let mut message = ChatMessage::assistant(content.unwrap_or_default());
209 if let Some(calls_json) = tool_calls.as_deref() {
210 if let Ok(calls) = serde_json::from_str::<Vec<Value>>(calls_json) {
211 let parsed: Vec<ToolCall> = calls
212 .iter()
213 .filter_map(|call| {
214 Some(ToolCall {
215 id: call.get("id")?.as_str()?.to_string(),
216 kind: call
217 .get("type")
218 .and_then(Value::as_str)
219 .unwrap_or("function")
220 .to_string(),
221 function: FunctionCall {
222 name: call
223 .get("function")?
224 .get("name")?
225 .as_str()?
226 .to_string(),
227 arguments: call
228 .get("function")?
229 .get("arguments")
230 .and_then(Value::as_str)
231 .unwrap_or("{}")
232 .to_string(),
233 },
234 })
235 })
236 .collect();
237 if !parsed.is_empty() {
238 message.tool_calls = Some(parsed);
239 }
240 }
241 }
242 if let Some(reasoning) = reasoning_content.filter(|r| !r.is_empty()) {
243 message
244 .metadata
245 .insert("reasoning_content".to_string(), reasoning);
246 }
247 stamp(&mut message);
248 messages.push(message);
249 }
250 Some("tool") => {
251 let mut message = ChatMessage::tool_result(
252 tool_call_id.as_deref().unwrap_or(""),
253 tool_name.as_deref().unwrap_or("tool"),
254 content.unwrap_or_default(),
255 );
256 stamp(&mut message);
257 messages.push(message);
258 }
259 _ => {}
261 }
262 }
263 drop(statement);
264 ensure_tool_results_paired(&mut messages);
265 let imported_message_count = Some(messages.len());
266 Ok(Session {
267 meta,
268 messages,
269 subagents: Vec::new(),
270 raw,
271 raw_trailing_newline: true,
272 imported_message_count,
273 raw_is_verbatim: false,
276 parse_error_lines: 0,
277 load_residue: Vec::new(),
278 })
279 }
280}
281
282pub(super) fn hermes_sqlite_fingerprint(conn: &Connection) -> bool {
294 let has = |table: &str| -> bool {
295 conn.query_row(
296 "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
297 [table],
298 |_| Ok(()),
299 )
300 .is_ok()
301 };
302 has("sessions") && has("messages") && has("schema_version") && !has("schema_meta")
303}
304
305fn hermes_sqlite_open(db_path: &Path) -> Result<Connection> {
309 if !db_path.is_file() {
310 return Err(crate::Error::Other(format!(
311 "Hermes SQLite store not found at {} — expected a `state.db` file",
312 db_path.display()
313 )));
314 }
315 let conn = Connection::open_with_flags(
316 db_path,
317 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
318 )
319 .map_err(|e| {
320 crate::Error::Other(format!(
321 "{} does not look like a valid Hermes SQLite database: {e}",
322 db_path.display()
323 ))
324 })?;
325 if !hermes_sqlite_fingerprint(&conn) {
326 return Err(crate::Error::Other(format!(
327 "{} is SQLite but not a Hermes state.db (missing sessions/messages/schema_version, or it carries OpenClaw's schema_meta)",
328 db_path.display()
329 )));
330 }
331 Ok(conn)
332}
333
334pub(crate) fn hermes_capture_nouns(conn: &Connection, id: &str, meta: &mut SessionMeta) {
340 let mut row = HermesSessionRow {
341 id: id.to_string(),
342 source: meta.lineage.get("hermes_source").cloned(),
343 lineage_kind: meta.lineage.get("hermes_lineage_kind").cloned(),
344 ..Default::default()
345 };
346 type Row = (
347 Option<String>,
348 Option<String>,
349 Option<String>,
350 Option<String>,
351 Option<String>,
352 Option<String>,
353 Option<String>,
354 Option<String>,
355 Option<String>,
356 );
357 let extended: Option<Row> = conn
358 .query_row(
359 "SELECT session_key, chat_id, chat_type, thread_id, user_id, profile_name, \
360 handoff_state, handoff_platform, handoff_error FROM sessions WHERE id = ?1",
361 [id],
362 |row| {
363 Ok((
364 row.get(0)?,
365 row.get(1)?,
366 row.get(2)?,
367 row.get(3)?,
368 row.get(4)?,
369 row.get(5)?,
370 row.get(6)?,
371 row.get(7)?,
372 row.get(8)?,
373 ))
374 },
375 )
376 .ok();
377 let extended_read = extended.is_some();
378 if let Some((
379 key,
380 chat_id,
381 chat_type,
382 thread_id,
383 user_id,
384 profile,
385 h_state,
386 h_platform,
387 h_error,
388 )) = extended
389 {
390 row.session_key = key;
391 row.chat_id = chat_id;
392 row.chat_type = chat_type;
393 row.thread_id = thread_id;
394 row.user_id = user_id;
395 row.profile_name = profile;
396 row.handoff_state = h_state;
397 row.handoff_platform = h_platform;
398 row.handoff_error = h_error;
399 }
400 let binding = Binding::from_hermes_row(&row, None);
401 meta.trigger = Some(binding.trigger);
402 meta.recurrence = binding.recurrence.clone();
403 if !extended_read {
404 return;
405 }
406 let nouns = binding.nouns();
407 meta.surface = nouns.surface;
408 if let Some(p) = nouns.profile {
409 meta.profile = Some(p);
410 }
411 meta.cross_surface = nouns.cross_surface;
412}
413
414pub(crate) fn hermes_lineage_kind(
423 conn: &Connection,
424 parent_id: &str,
425 model_config: Option<&str>,
426 started_at: Option<f64>,
427) -> &'static str {
428 let marker = |key: &str| -> bool {
429 model_config
430 .and_then(|raw| serde_json::from_str::<Value>(raw).ok())
431 .map(|config| config.get(key).map(|v| !v.is_null()).unwrap_or(false))
432 .unwrap_or(false)
433 };
434 if marker("_delegate_from") {
435 return "delegate";
436 }
437 if marker("_branched_from") {
438 return "branch";
439 }
440 let parent: Option<(Option<String>, Option<f64>)> = conn
441 .query_row(
442 "SELECT end_reason, ended_at FROM sessions WHERE id = ?1",
443 [parent_id],
444 |row| Ok((row.get(0)?, row.get(1)?)),
445 )
446 .ok();
447 if let Some((end_reason, ended_at)) = parent {
448 match end_reason.as_deref() {
449 Some("compression") => return "compaction",
450 Some("branched") => {
451 let started = started_at.unwrap_or(f64::MAX);
452 let ended = ended_at.unwrap_or(f64::MAX);
453 if started >= ended {
454 return "branch";
455 }
456 }
457 _ => {}
458 }
459 }
460 "unknown"
461}