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        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            // Hermes's own replay rule: only active rows reach the model.
172            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                // Open union: system/unknown roles survive in raw only.
250                _ => {}
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            // Synthesized from SQL rows — a binary store has no verbatim
264            // line-oriented form (mirrors `from_opencode_sqlite`).
265            raw_is_verbatim: false,
266            parse_error_lines: 0,
267            load_residue: Vec::new(),
268        })
269    }
270}
271
272/// Open `db_path` read-only and confirm it carries the expected V1 schema
273/// (a `session` table) — the shared entry point for every SQLite read below,
274/// so every caller gets the SAME clear diagnostics (PARITY-3 AC03): missing
275/// path, not-a-database, and wrong/unsupported schema are each named
276/// distinctly rather than surfacing later as "zero sessions" or a generic
277/// parse failure.
278/// True when this open SQLite connection is a Hermes `state.db` (UNI-15
279/// fingerprint, verified against the 0.19.0 / SCHEMA_VERSION=22 store):
280/// `sessions` + `messages` + `schema_version` tables present, and OpenClaw's
281/// `schema_meta` table ABSENT (the shared-probe disambiguation rule from the
282/// integration design).
283pub(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
295/// Open a Hermes `state.db` strictly read-only. Never call any write API on
296/// this connection: the store is a live, shared, WAL, single-writer database
297/// owned by the running Hermes install (UNI-15 dev/03).
298fn 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
324/// ORCH-3: derive trigger / surface / profile / recurrence / cross-surface
325/// from a Hermes session row — through the one [`Binding`] decoder. The
326/// gateway columns are optional in older schemas, so a failed extended read
327/// still classifies the trigger from `source` (already in `meta.lineage`) and
328/// leaves the rest `None`.
329pub(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
404/// Classify a Hermes child session's relationship to `parent_session_id`
405/// (UNI-15's tri-semantic lineage, verified against hermes 0.19.0's OWN SQL
406/// in `hermes_state.py`): the stable JSON markers live in the
407/// `model_config` column (`$._branched_from` / `$._delegate_from`);
408/// compaction children have a parent with `end_reason = 'compression'`; the
409/// legacy branch heuristic is parent `end_reason = 'branched'` with the
410/// child started at/after the parent's end. Anything else is honestly
411/// `unknown`, never guessed.
412pub(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}