Skip to main content

supercode_interchange/session/
hermes.rs

1//! Hermes session codec: SQLite loader and orchestration-noun helpers.
2
3use 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    /// Load an OpenCode session from a real SQLite store (PARITY-3): opens
12    /// `db_path`, resolves `session_id` (explicit, or — when `None` — the
13    /// most-recently-updated top-level session, see
14    /// `opencode_sqlite_primary_session_id`) and reconstructs the SAME
15    /// envelope form [`Self::from_opencode_str`] already parses for the
16    /// JSON-tree surfaces — so canonicalization (§2.1 mapping, compaction
17    /// discipline, S1 tool-output masking, …) is shared code, not
18    /// reimplemented here. See `docs/interop/opencode-pi-spec.md` §1.2/S9c
19    /// for the envelope-construction rules this follows (all-columns rule,
20    /// raw `revert` column carried verbatim).
21    /// Load one session from a Hermes `state.db` SQLite store (UNI-15,
22    /// read-only rescue tier; hermes 0.19.0 / SCHEMA_VERSION=22 pin).
23    ///
24    /// - `session_id: None` selects the most recently started session.
25    /// - Replayed messages follow hermes's OWN resume rule
26    ///   (`get_messages_as_conversation`): `active = 1`, `ORDER BY id`.
27    ///   Inactive rows are NOT replayed but survive in `raw` (rescue).
28    /// - Assistant `tool_calls` JSON (OpenAI shape) and `tool` rows
29    ///   (`tool_call_id` + `tool_name` + JSON content) map to canonical tool
30    ///   calls/results; reasoning fields and the `compacted` flag land in
31    ///   message metadata.
32    /// - Lineage: `parent_session_id` plus the tri-semantic classification
33    ///   (`branch | compaction | delegate`, else `unknown`) recorded in
34    ///   `meta.lineage` as `hermes_parent_session_id` /
35    ///   `hermes_lineage_kind`.
36    /// - `raw` is a SYNTHESIZED one-JSON-object-per-row reconstruction (a
37    ///   binary store has no verbatim line form); `raw_is_verbatim = false`.
38    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        // ACP-created sessions (hermes ≤ 0.21) recorded their cwd only inside
92        // model_config; the column stayed NULL and the session fell out of
93        // every workspace scope. Read the column first, then the JSON.
94        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            // Hermes's own replay rule: only active rows reach the model.
182            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                // Open union: system/unknown roles survive in raw only.
260                _ => {}
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            // Synthesized from SQL rows — a binary store has no verbatim
274            // line-oriented form (mirrors `from_opencode_sqlite`).
275            raw_is_verbatim: false,
276            parse_error_lines: 0,
277            load_residue: Vec::new(),
278        })
279    }
280}
281
282/// Open `db_path` read-only and confirm it carries the expected V1 schema
283/// (a `session` table) — the shared entry point for every SQLite read below,
284/// so every caller gets the SAME clear diagnostics (PARITY-3 AC03): missing
285/// path, not-a-database, and wrong/unsupported schema are each named
286/// distinctly rather than surfacing later as "zero sessions" or a generic
287/// parse failure.
288/// True when this open SQLite connection is a Hermes `state.db` (UNI-15
289/// fingerprint, verified against the 0.19.0 / SCHEMA_VERSION=22 store):
290/// `sessions` + `messages` + `schema_version` tables present, and OpenClaw's
291/// `schema_meta` table ABSENT (the shared-probe disambiguation rule from the
292/// integration design).
293pub(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
305/// Open a Hermes `state.db` strictly read-only. Never call any write API on
306/// this connection: the store is a live, shared, WAL, single-writer database
307/// owned by the running Hermes install (UNI-15 dev/03).
308fn 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
334/// ORCH-3: derive trigger / surface / profile / recurrence / cross-surface
335/// from a Hermes session row — through the one [`Binding`] decoder. The
336/// gateway columns are optional in older schemas, so a failed extended read
337/// still classifies the trigger from `source` (already in `meta.lineage`) and
338/// leaves the rest `None`.
339pub(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
414/// Classify a Hermes child session's relationship to `parent_session_id`
415/// (UNI-15's tri-semantic lineage, verified against hermes 0.19.0's OWN SQL
416/// in `hermes_state.py`): the stable JSON markers live in the
417/// `model_config` column (`$._branched_from` / `$._delegate_from`);
418/// compaction children have a parent with `end_reason = 'compression'`; the
419/// legacy branch heuristic is parent `end_reason = 'branched'` with the
420/// child started at/after the parent's end. Anything else is honestly
421/// `unknown`, never guessed.
422pub(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}