use super::*;
pub use crate::ontology::{
hermes_cron_job_id, hermes_trigger_for_source, parse_hermes_session_key,
};
use crate::ontology::{Binding, HermesSessionRow};
impl Session {
pub fn from_hermes_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
let conn = hermes_sqlite_open(db_path)?;
let id: String = match session_id {
Some(id) => id.to_string(),
None => conn
.query_row(
"SELECT id FROM sessions ORDER BY started_at DESC LIMIT 1",
[],
|row| row.get(0),
)
.map_err(|_| {
crate::Error::Other(format!(
"{} contains no Hermes sessions",
db_path.display()
))
})?,
};
let (source, model, cwd, system_prompt, title, parent_id, model_config, started_at): (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<f64>,
) = conn
.query_row(
"SELECT source, model, cwd, system_prompt, title, parent_session_id, model_config, started_at FROM sessions WHERE id = ?1",
[&id],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
))
},
)
.map_err(|_| {
crate::Error::Other(format!(
"Hermes session `{id}` not found in {}",
db_path.display()
))
})?;
let mut meta = SessionMeta::new(SessionSource::Hermes);
meta.session_id = Some(id.clone());
meta.model = model;
let cwd = cwd.filter(|c| !c.is_empty()).or_else(|| {
model_config
.as_deref()
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
.and_then(|v| v.get("cwd").and_then(Value::as_str).map(str::to_string))
.filter(|c| !c.is_empty())
});
meta.cwd = cwd.map(PathBuf::from);
meta.system_prompt = system_prompt;
if let Some(title) = title.filter(|t| !t.is_empty()) {
meta.lineage.insert("session_name".to_string(), title);
}
if let Some(hermes_source) = source.filter(|s| !s.is_empty()) {
meta.lineage
.insert("hermes_source".to_string(), hermes_source);
}
if let Some(parent) = parent_id.as_deref() {
meta.lineage
.insert("hermes_parent_session_id".to_string(), parent.to_string());
meta.lineage.insert(
"hermes_lineage_kind".to_string(),
hermes_lineage_kind(&conn, parent, model_config.as_deref(), started_at).to_string(),
);
}
hermes_capture_nouns(&conn, &id, &mut meta);
let mut raw: Vec<String> = vec![serde_json::json!({
"hermes_session": {
"id": id,
"cwd": meta.cwd,
"parent_session_id": parent_id,
"started_at": started_at,
}
})
.to_string()];
let mut messages: Vec<ChatMessage> = Vec::new();
let mut statement = conn
.prepare(
"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",
)
.map_err(|e| crate::Error::Other(format!("Hermes messages query failed: {e}")))?;
let rows = statement
.query_map([&id], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<String>>(4)?,
row.get::<_, Option<String>>(5)?,
row.get::<_, Option<f64>>(6)?,
row.get::<_, Option<String>>(7)?,
row.get::<_, Option<i64>>(8)?,
row.get::<_, Option<i64>>(9)?,
))
})
.map_err(|e| crate::Error::Other(format!("Hermes messages scan failed: {e}")))?;
for row in rows {
let (
row_id,
role,
content,
tool_call_id,
tool_calls,
tool_name,
timestamp,
reasoning_content,
active,
compacted,
) = row.map_err(|e| crate::Error::Other(format!("Hermes message row failed: {e}")))?;
raw.push(
serde_json::json!({
"hermes_message": {
"id": row_id,
"role": role,
"content": content,
"tool_call_id": tool_call_id,
"tool_calls": tool_calls,
"tool_name": tool_name,
"timestamp": timestamp,
"active": active,
"compacted": compacted,
}
})
.to_string(),
);
if active != Some(1) {
continue;
}
let stamp = |message: &mut ChatMessage| {
message
.metadata
.insert("hermes_message_id".to_string(), row_id.to_string());
if let Some(ts) = timestamp {
message.metadata.insert(
"timestamp".to_string(),
crate::sidecar::ms_to_rfc3339((ts * 1000.0) as i64),
);
}
if compacted == Some(1) {
message
.metadata
.insert("compacted_out".to_string(), "true".to_string());
}
};
match role.as_deref() {
Some("user") => {
let mut message = ChatMessage::user(content.unwrap_or_default());
stamp(&mut message);
messages.push(message);
}
Some("assistant") => {
let mut message = ChatMessage::assistant(content.unwrap_or_default());
if let Some(calls_json) = tool_calls.as_deref() {
if let Ok(calls) = serde_json::from_str::<Vec<Value>>(calls_json) {
let parsed: Vec<ToolCall> = calls
.iter()
.filter_map(|call| {
Some(ToolCall {
id: call.get("id")?.as_str()?.to_string(),
kind: call
.get("type")
.and_then(Value::as_str)
.unwrap_or("function")
.to_string(),
function: FunctionCall {
name: call
.get("function")?
.get("name")?
.as_str()?
.to_string(),
arguments: call
.get("function")?
.get("arguments")
.and_then(Value::as_str)
.unwrap_or("{}")
.to_string(),
},
})
})
.collect();
if !parsed.is_empty() {
message.tool_calls = Some(parsed);
}
}
}
if let Some(reasoning) = reasoning_content.filter(|r| !r.is_empty()) {
message
.metadata
.insert("reasoning_content".to_string(), reasoning);
}
stamp(&mut message);
messages.push(message);
}
Some("tool") => {
let mut message = ChatMessage::tool_result(
tool_call_id.as_deref().unwrap_or(""),
tool_name.as_deref().unwrap_or("tool"),
content.unwrap_or_default(),
);
stamp(&mut message);
messages.push(message);
}
_ => {}
}
}
drop(statement);
ensure_tool_results_paired(&mut messages);
let imported_message_count = Some(messages.len());
Ok(Session {
meta,
messages,
subagents: Vec::new(),
raw,
raw_trailing_newline: true,
imported_message_count,
raw_is_verbatim: false,
parse_error_lines: 0,
load_residue: Vec::new(),
})
}
}
pub(super) fn hermes_sqlite_fingerprint(conn: &Connection) -> bool {
let has = |table: &str| -> bool {
conn.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
[table],
|_| Ok(()),
)
.is_ok()
};
has("sessions") && has("messages") && has("schema_version") && !has("schema_meta")
}
fn hermes_sqlite_open(db_path: &Path) -> Result<Connection> {
if !db_path.is_file() {
return Err(crate::Error::Other(format!(
"Hermes SQLite store not found at {} — expected a `state.db` file",
db_path.display()
)));
}
let conn = Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| {
crate::Error::Other(format!(
"{} does not look like a valid Hermes SQLite database: {e}",
db_path.display()
))
})?;
if !hermes_sqlite_fingerprint(&conn) {
return Err(crate::Error::Other(format!(
"{} is SQLite but not a Hermes state.db (missing sessions/messages/schema_version, or it carries OpenClaw's schema_meta)",
db_path.display()
)));
}
Ok(conn)
}
pub(crate) fn hermes_capture_nouns(conn: &Connection, id: &str, meta: &mut SessionMeta) {
let mut row = HermesSessionRow {
id: id.to_string(),
source: meta.lineage.get("hermes_source").cloned(),
lineage_kind: meta.lineage.get("hermes_lineage_kind").cloned(),
..Default::default()
};
type Row = (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
);
let extended: Option<Row> = conn
.query_row(
"SELECT session_key, chat_id, chat_type, thread_id, user_id, profile_name, \
handoff_state, handoff_platform, handoff_error FROM sessions WHERE id = ?1",
[id],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
row.get(8)?,
))
},
)
.ok();
let extended_read = extended.is_some();
if let Some((
key,
chat_id,
chat_type,
thread_id,
user_id,
profile,
h_state,
h_platform,
h_error,
)) = extended
{
row.session_key = key;
row.chat_id = chat_id;
row.chat_type = chat_type;
row.thread_id = thread_id;
row.user_id = user_id;
row.profile_name = profile;
row.handoff_state = h_state;
row.handoff_platform = h_platform;
row.handoff_error = h_error;
}
let binding = Binding::from_hermes_row(&row, None);
meta.trigger = Some(binding.trigger);
meta.recurrence = binding.recurrence.clone();
if !extended_read {
return;
}
let nouns = binding.nouns();
meta.surface = nouns.surface;
if let Some(p) = nouns.profile {
meta.profile = Some(p);
}
meta.cross_surface = nouns.cross_surface;
}
pub(crate) fn hermes_lineage_kind(
conn: &Connection,
parent_id: &str,
model_config: Option<&str>,
started_at: Option<f64>,
) -> &'static str {
let marker = |key: &str| -> bool {
model_config
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
.map(|config| config.get(key).map(|v| !v.is_null()).unwrap_or(false))
.unwrap_or(false)
};
if marker("_delegate_from") {
return "delegate";
}
if marker("_branched_from") {
return "branch";
}
let parent: Option<(Option<String>, Option<f64>)> = conn
.query_row(
"SELECT end_reason, ended_at FROM sessions WHERE id = ?1",
[parent_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
if let Some((end_reason, ended_at)) = parent {
match end_reason.as_deref() {
Some("compression") => return "compaction",
Some("branched") => {
let started = started_at.unwrap_or(f64::MAX);
let ended = ended_at.unwrap_or(f64::MAX);
if started >= ended {
return "branch";
}
}
_ => {}
}
}
"unknown"
}