Skip to main content

pond/adapter/
hermes.rs

1//! Hermes Agent adapter (github.com/NousResearch/hermes-agent).
2//!
3//! Hermes (Python) persists everything in ONE SQLite database per profile - no
4//! JSONL, no per-session files. The default home is `~/.hermes` (env override
5//! `$HERMES_HOME`, which may point anywhere - Docker installs use paths like
6//! `/opt/data`). The default profile's DB is `<home>/state.db`; named profiles
7//! live at `<home>/profiles/<name>/state.db`, each an independent DB. The
8//! adapter enumerates both. Hermes sanctions cross-process reads via a
9//! `mode=ro` URI, which is exactly what [`sqlite::open_db`] does.
10//!
11//! Identity (plan 3.1): pond `session_id` = `sessions.id` verbatim; message id =
12//! `<session_id>:<messages.id>`. `messages.id` is SQLite `AUTOINCREMENT`, so it
13//! is never reused - a deleted id never comes back carrying different content,
14//! which makes additive sync safe even though hermes rewrites history in place
15//! (compaction, rewind, `/retry` delete+reinsert). Rewrites appear to pond as
16//! NEW rows (higher ids stamped with the current time); pond keeps the
17//! superseded rows as history - a superset of the source, not a mirror. The
18//! `active`/`compacted`/`observed` flags are snapshots at ingest time recorded
19//! in per-message options; pond does not track later flag flips.
20//!
21//! `project` = `session_key` when present, else `<source>:<chat_id>`, else
22//! `cwd`, else `source` (`source` is NOT NULL, so a value always resolves) -
23//! every component a verbatim source field routed through the seam.
24//! `source_agent` = `hermes`, `hermes/cron` (for `source='cron'`), or
25//! `hermes/subagent` (delegate/spawn children). Compression-fork and branch
26//! children stay plain `hermes` (they ARE the conversation continuing).
27//!
28//! Lineage (plan 3.1): `parent_session_id` verbatim, plus a `relation` tag in
29//! `options.hermes` derived from the parent's `end_reason` and the child's
30//! `model_config._branched_from` marker - `branch`, `compaction_successor`, or
31//! `spawn` (hermes's own un-conflated edge kinds, `hermes_state.py` lines
32//! 74-103). `parent_message_id` stays `None` (hermes tracks no cut point).
33//!
34//! Content encoding (`_decode_content`, `hermes_state.py` ~5567): `content` is a
35//! plain string OR a JSON payload prefixed with the sentinel `"\x00json:"` (a NUL
36//! byte then `json:`, illegal in normal text so it cannot collide). Stripped and
37//! parsed it recovers a multimodal part list of text and image_url items; a
38//! decode failure falls back to the raw string, matching hermes.
39//!
40//! Ordering: messages are read `ORDER BY id`. `timestamp` is non-monotonic by
41//! design (every hermes read path orders by id); pond re-sorts canonically by
42//! `(timestamp, id)` and keeps the source `id` in `options.source.id` so the
43//! append order survives. The freshness watermark is `MAX(timestamp)` over the
44//! session's messages (read once per DB via one grouped query), never a
45//! `versions()`-style scan.
46//!
47//! Documented non-ingest (per-adapter contract): `kanban.db`, the legacy
48//! `sessions/sessions.json` routing index, `cron/`, `checkpoints/` (a git object
49//! store of file snapshots), `logs/`, the memory plugins (mem0 / hindsight /
50//! redis), the FTS shadow tables inside `state.db`, and every JSONL side-channel
51//! that DUPLICATES conversation content on disk (`moa-traces/*.jsonl`,
52//! `trajectory_samples.jsonl`, `failed_trajectories.jsonl`, the trace-upload
53//! `sessions/<id>.jsonl`, and `hermes sessions export` output) - `state.db` is
54//! canonical.
55//!
56//! Restore (plan 3.3): hermes has no file-era format to target, so `serialize`
57//! emits idiomatic Foreign NDJSON of the reconstructed `sessions` + `messages`
58//! rows (the sanctioned fallback); native is not offered and the CLI is told so
59//! via `actual_fidelity: Foreign`.
60
61use std::collections::HashMap;
62use std::path::{Path, PathBuf};
63
64use async_stream::stream;
65use chrono::{DateTime, Utc};
66use rusqlite::{Connection, OptionalExtension};
67use serde_json::{Map, Value, json};
68use tokio::sync::mpsc;
69
70use crate::{
71    sessions::{IngestEvent, MessageWithParts, SessionWithMessages},
72    wire::{FileData, Message, Part, PartKind, Provenance, ProviderOptions, Session},
73};
74
75use super::{
76    Adapter, AdapterError, AdapterFactory, AdapterYield, AdapterYieldStream, DiscoverFuture, Env,
77    RestoreFidelity, RestoredFile, SkipOracle, SkipReason, by_timestamp_then_id,
78    extract::{Extracted, extract_compact_repr, extract_raw_record, extract_str, json_or_string},
79    extracted_text, is_session_fresh, jsonl_bytes, part_id, part_ordinal, raw_record,
80    source_options,
81    sqlite::{self, CHANNEL_CAP, ColKind, columns_sql, emit, row_to_json},
82    validate_path_id,
83};
84
85const NAME: &str = "hermes";
86
87/// Multimodal content sentinel: a NUL byte + `json:` prefixes a JSON payload
88/// (`_CONTENT_JSON_PREFIX`, `hermes_state.py` ~5530). The NUL is illegal in
89/// normal text, so this never collides with real user content.
90const CONTENT_JSON_PREFIX: &str = "\u{0}json:";
91
92/// The `sessions` columns pond mirrors verbatim into `options.hermes`, in SELECT
93/// order. This ONE list drives both the SELECT and the row->JSON decode, so
94/// tracking hermes's schema is a one-line change (the openclaw precedent). The
95/// billing/cost/handoff columns are intentionally omitted - they are operational
96/// bookkeeping, not conversation provenance.
97const SESSION_COLUMNS: &[(&str, ColKind)] = &[
98    ("id", ColKind::Str),
99    ("source", ColKind::Str),
100    ("user_id", ColKind::Str),
101    ("session_key", ColKind::Str),
102    ("chat_id", ColKind::Str),
103    ("chat_type", ColKind::Str),
104    ("thread_id", ColKind::Str),
105    ("display_name", ColKind::Str),
106    ("origin_json", ColKind::Str),
107    ("model", ColKind::Str),
108    ("model_config", ColKind::Str),
109    ("system_prompt", ColKind::Str),
110    ("parent_session_id", ColKind::Str),
111    ("started_at", ColKind::Real),
112    ("ended_at", ColKind::Real),
113    ("end_reason", ColKind::Str),
114    ("message_count", ColKind::Int),
115    ("tool_call_count", ColKind::Int),
116    ("input_tokens", ColKind::Int),
117    ("output_tokens", ColKind::Int),
118    ("cache_read_tokens", ColKind::Int),
119    ("cache_write_tokens", ColKind::Int),
120    ("reasoning_tokens", ColKind::Int),
121    ("cwd", ColKind::Str),
122    ("git_branch", ColKind::Str),
123    ("git_repo_root", ColKind::Str),
124    ("title", ColKind::Str),
125    ("profile_name", ColKind::Str),
126    ("rewind_count", ColKind::Int),
127    ("archived", ColKind::Int),
128];
129
130/// The `messages` columns pond reads. The whole row is the per-message
131/// `raw_record` (native-restore fidelity); the metadata columns also mirror into
132/// `options.hermes` (spec 6.5 rule 2). One list, both purposes.
133const MESSAGE_COLUMNS: &[(&str, ColKind)] = &[
134    ("id", ColKind::Int),
135    ("session_id", ColKind::Str),
136    ("role", ColKind::Str),
137    ("content", ColKind::Str),
138    ("tool_call_id", ColKind::Str),
139    ("tool_calls", ColKind::Str),
140    ("tool_name", ColKind::Str),
141    ("effect_disposition", ColKind::Str),
142    ("timestamp", ColKind::Real),
143    ("token_count", ColKind::Int),
144    ("finish_reason", ColKind::Str),
145    ("reasoning", ColKind::Str),
146    ("reasoning_content", ColKind::Str),
147    ("reasoning_details", ColKind::Str),
148    ("codex_reasoning_items", ColKind::Str),
149    ("codex_message_items", ColKind::Str),
150    ("platform_message_id", ColKind::Str),
151    ("observed", ColKind::Int),
152    ("active", ColKind::Int),
153    ("compacted", ColKind::Int),
154    ("api_content", ColKind::Str),
155];
156
157/// Per-message metadata columns lifted into `options.hermes` (spec 6.5 rule 2 -
158/// real turn data that is not a canonical field). The JSON-text ones are parsed
159/// so they land as structure, not an escaped string.
160const MESSAGE_META_COLUMNS: &[&str] = &[
161    "token_count",
162    "finish_reason",
163    "platform_message_id",
164    "observed",
165    "active",
166    "compacted",
167    "effect_disposition",
168    "tool_call_id",
169    "tool_name",
170    "api_content",
171    "reasoning_details",
172    "codex_reasoning_items",
173    "codex_message_items",
174];
175
176const JSON_TEXT_META: &[&str] = &[
177    "reasoning_details",
178    "codex_reasoning_items",
179    "codex_message_items",
180];
181
182/// Stateless factory: opens [`HermesAdapter`] instances and probes for the
183/// canonical `~/.hermes` (or `$HERMES_HOME`) home holding a `state.db`.
184pub struct HermesFactory;
185
186impl AdapterFactory for HermesFactory {
187    fn name(&self) -> &'static str {
188        NAME
189    }
190
191    fn open(&self, config: Value) -> Result<Box<dyn Adapter>, AdapterError> {
192        Ok(Box::new(HermesAdapter::from_config(config)?))
193    }
194
195    fn probe_default(&self, env: &Env) -> Option<Value> {
196        // `$HERMES_HOME` wins (it may point anywhere - Docker installs live
197        // outside `~`), then `~/.hermes`. A home only qualifies when it actually
198        // holds a `state.db`, so an empty dir never masquerades as a source.
199        let override_dir = std::env::var_os("HERMES_HOME").map(PathBuf::from);
200        resolve_home(&env.home, override_dir.as_deref()).map(|home| json!({ "path": home }))
201    }
202
203    fn serialize(
204        &self,
205        session: &SessionWithMessages,
206        fidelity: RestoreFidelity,
207    ) -> Result<Vec<RestoredFile>, AdapterError> {
208        serialize_session(session, fidelity)
209    }
210}
211
212/// Resolve the hermes home for auto-discovery: the first of `override_dir` then
213/// `~/.hermes` that exists and contains a `state.db`.
214fn resolve_home(home: &Path, override_dir: Option<&Path>) -> Option<PathBuf> {
215    let candidates = [
216        override_dir.map(Path::to_path_buf),
217        Some(home.join(".hermes")),
218    ];
219    candidates
220        .into_iter()
221        .flatten()
222        .find(|root| root.join("state.db").is_file())
223}
224
225/// Configured hermes reader, rooted at the home dir (holding `state.db` and
226/// optional `profiles/<name>/state.db`).
227#[derive(Debug, Clone)]
228pub struct HermesAdapter {
229    root: PathBuf,
230}
231
232impl HermesAdapter {
233    pub fn new(root: impl Into<PathBuf>) -> Self {
234        Self { root: root.into() }
235    }
236
237    pub fn from_config(config: Value) -> Result<Self, AdapterError> {
238        Ok(Self {
239            root: super::config_path(NAME, config)?,
240        })
241    }
242}
243
244impl Adapter for HermesAdapter {
245    fn discover(&self) -> DiscoverFuture<'_> {
246        let adapter = self.clone();
247        Box::pin(async move {
248            tokio::task::spawn_blocking(move || {
249                Ok(enumerate_and_peek(&adapter, false).entries.len())
250            })
251            .await
252            .map_err(join_error)?
253        })
254    }
255
256    fn events_with<'a>(&'a self, oracle: &'a dyn SkipOracle) -> AdapterYieldStream<'a> {
257        let adapter = self.clone();
258        Box::pin(stream! {
259            let peek = !oracle.is_empty();
260            let enum_adapter = adapter.clone();
261            let enumerated = tokio::task::spawn_blocking(move || enumerate_and_peek(&enum_adapter, peek)).await;
262            let Enumerated { entries, errors } = match enumerated {
263                Ok(enumerated) => enumerated,
264                Err(join) => { yield Err(join_error(join)); return; }
265            };
266            // Per-DB enumeration failures surface as visible errors; the run
267            // continues with survivors (spec.md#adapter-integrity-no-silent-drops).
268            for error in errors {
269                yield Err(error);
270            }
271
272            let mut survivors = Vec::with_capacity(entries.len());
273            for entry in entries {
274                if is_session_fresh(oracle, &entry.session_id, entry.source_ts) {
275                    yield Ok(AdapterYield::Skipped {
276                        session_id: Some(entry.session_id),
277                        project: None,
278                        reason: SkipReason::Fresh,
279                    });
280                    continue;
281                }
282                survivors.push((entry.db_path, entry.session_id));
283            }
284
285            let (tx, mut rx) = mpsc::channel(CHANNEL_CAP);
286            let handle = tokio::task::spawn_blocking(move || read_survivors(survivors, &tx));
287            while let Some(item) = rx.recv().await {
288                yield item;
289            }
290            if let Err(join) = handle.await {
291                yield Err(join_error(join));
292            }
293        })
294    }
295}
296
297// -- Enumeration ------------------------------------------------------------
298
299struct HeadEntry {
300    db_path: PathBuf,
301    session_id: String,
302    source_ts: Option<i64>,
303}
304
305struct Enumerated {
306    entries: Vec<HeadEntry>,
307    errors: Vec<AdapterError>,
308}
309
310/// The `state.db` databases under the home: the default profile at
311/// `<home>/state.db` plus every `<home>/profiles/<name>/state.db`.
312fn list_dbs(root: &Path) -> Vec<PathBuf> {
313    let mut dbs = Vec::new();
314    let default = root.join("state.db");
315    if default.is_file() {
316        dbs.push(default);
317    }
318    if let Ok(read) = std::fs::read_dir(root.join("profiles")) {
319        let mut profile_dbs: Vec<PathBuf> = read
320            .flatten()
321            .filter(|entry| entry.file_type().map(|t| t.is_dir()).unwrap_or(false))
322            .map(|entry| entry.path().join("state.db"))
323            .filter(|db| db.is_file())
324            .collect();
325        profile_dbs.sort();
326        dbs.extend(profile_dbs);
327    }
328    dbs
329}
330
331fn enumerate_and_peek(adapter: &HermesAdapter, peek: bool) -> Enumerated {
332    let mut entries = Vec::new();
333    let mut errors = Vec::new();
334
335    for db_path in list_dbs(&adapter.root) {
336        let conn = match open_db(&db_path) {
337            Ok(conn) => conn,
338            Err(error) => {
339                tracing::warn!(path = %db_path.display(), %error, "hermes: opening state.db failed");
340                errors.push(error);
341                continue;
342            }
343        };
344        let session_ids = match list_session_ids(&conn, &db_path) {
345            Ok(ids) => ids,
346            Err(error) => {
347                tracing::warn!(path = %db_path.display(), %error, "hermes: listing sessions failed");
348                errors.push(error);
349                continue;
350            }
351        };
352        // One grouped read yields every session's watermark (indexed via
353        // idx_messages_session); never a per-session scan.
354        let watermarks = if peek {
355            session_watermarks(&conn).unwrap_or_default()
356        } else {
357            HashMap::new()
358        };
359        for session_id in session_ids {
360            let source_ts = watermarks.get(&session_id).copied();
361            entries.push(HeadEntry {
362                db_path: db_path.clone(),
363                session_id,
364                source_ts,
365            });
366        }
367    }
368
369    Enumerated { entries, errors }
370}
371
372fn list_session_ids(conn: &Connection, db_path: &Path) -> Result<Vec<String>, AdapterError> {
373    let mut stmt = conn
374        .prepare("SELECT id FROM sessions ORDER BY id")
375        .map_err(|error| db_error(db_path, "prepare session list", &error))?;
376    let rows = stmt
377        .query_map([], |row| row.get::<_, String>(0))
378        .map_err(|error| db_error(db_path, "query session list", &error))?;
379    rows.collect::<rusqlite::Result<Vec<_>>>()
380        .map_err(|error| db_error(db_path, "read session id", &error))
381}
382
383/// One grouped query for every session's `MAX(timestamp)` (micros). `timestamp`
384/// is `REAL` epoch seconds; the peek is the sole freshness signal.
385fn session_watermarks(conn: &Connection) -> Option<HashMap<String, i64>> {
386    let mut stmt = conn
387        .prepare("SELECT session_id, MAX(timestamp) FROM messages GROUP BY session_id")
388        .ok()?;
389    let rows = stmt
390        .query_map([], |row| {
391            Ok((row.get::<_, String>(0)?, row.get::<_, Option<f64>>(1)?))
392        })
393        .ok()?;
394    let mut map = HashMap::new();
395    for row in rows.flatten() {
396        if let (session_id, Some(secs)) = row {
397            map.insert(session_id, secs_to_micros(secs));
398        }
399    }
400    Some(map)
401}
402
403// -- Reading ----------------------------------------------------------------
404
405fn read_survivors(
406    survivors: Vec<(PathBuf, String)>,
407    tx: &mpsc::Sender<Result<AdapterYield, AdapterError>>,
408) {
409    let mut conns: HashMap<PathBuf, Connection> = HashMap::new();
410    for (db_path, session_id) in survivors {
411        let keep = match connection(&mut conns, &db_path) {
412            Ok(conn) => read_session(conn, &db_path, &session_id, tx),
413            Err(error) => tx.blocking_send(Err(error)).is_ok(),
414        };
415        if !keep {
416            return;
417        }
418    }
419}
420
421fn read_session(
422    conn: &Connection,
423    db_path: &Path,
424    session_id: &str,
425    tx: &mpsc::Sender<Result<AdapterYield, AdapterError>>,
426) -> bool {
427    // A hostile `sessions.id` becomes a restore filename (`serialize_session`);
428    // it fails here at ingest, typed and attributed, like opencode's DB ids.
429    if let Err(error) = validate_path_id(
430        NAME,
431        "session id",
432        session_id,
433        format!("{}#{session_id}", db_path.display()),
434    ) {
435        return tx.blocking_send(Err(error)).is_ok();
436    }
437
438    let row = match fetch_session_row(conn, session_id) {
439        Ok(Some(row)) => row,
440        Ok(None) => {
441            let error = AdapterError::schema(
442                NAME,
443                session_id.to_owned(),
444                "session row vanished between enumeration and read",
445            );
446            return tx.blocking_send(Err(error)).is_ok();
447        }
448        Err(error) => return tx.blocking_send(Err(error)).is_ok(),
449    };
450
451    // `started_at` is NOT NULL in the source schema; a session missing it is
452    // corrupt and fails visibly rather than earning a synthesized wall-clock
453    // `created_at` (spec.md#model-no-synthesis).
454    let Some(created_at) = row
455        .get("started_at")
456        .and_then(Value::as_f64)
457        .and_then(secs_to_dt)
458    else {
459        let error = AdapterError::schema(
460            NAME,
461            session_id.to_owned(),
462            "session has no parseable started_at timestamp",
463        );
464        return tx.blocking_send(Err(error)).is_ok();
465    };
466
467    let (relation, source_agent) = classify(conn, &row);
468    let session = build_session(session_id, &row, relation, source_agent, created_at);
469    emit!(tx, Ok(AdapterYield::Event(IngestEvent::Session(session))));
470
471    let messages = match fetch_messages(conn, session_id) {
472        Ok(messages) => messages,
473        Err(error) => return tx.blocking_send(Err(error)).is_ok(),
474    };
475    for message_row in messages {
476        match message_events(session_id, &message_row) {
477            Ok(events) => {
478                for event in events {
479                    emit!(tx, Ok(AdapterYield::Event(event)));
480                }
481            }
482            Err(error) => emit!(tx, Err(error)),
483        }
484    }
485    true
486}
487
488fn fetch_session_row(conn: &Connection, session_id: &str) -> Result<Option<Value>, AdapterError> {
489    let sql = format!(
490        "SELECT {} FROM sessions WHERE id = ?1",
491        columns_sql(SESSION_COLUMNS)
492    );
493    let mut stmt = conn
494        .prepare_cached(&sql)
495        .map_err(|error| db_error(Path::new("sessions"), "prepare session row", &error))?;
496    stmt.query_row([session_id], |row| row_to_json(row, SESSION_COLUMNS))
497        .optional()
498        .map_err(|error| db_error(Path::new("sessions"), "query session row", &error))
499}
500
501fn fetch_messages(conn: &Connection, session_id: &str) -> Result<Vec<Value>, AdapterError> {
502    let sql = format!(
503        "SELECT {} FROM messages WHERE session_id = ?1 ORDER BY id",
504        columns_sql(MESSAGE_COLUMNS)
505    );
506    let mut stmt = conn
507        .prepare_cached(&sql)
508        .map_err(|error| db_error(Path::new("messages"), "prepare messages", &error))?;
509    let rows = stmt
510        .query_map([session_id], |row| row_to_json(row, MESSAGE_COLUMNS))
511        .map_err(|error| db_error(Path::new("messages"), "query messages", &error))?;
512    rows.collect::<rusqlite::Result<Vec<_>>>()
513        .map_err(|error| db_error(Path::new("messages"), "read message row", &error))
514}
515
516/// The parent's `end_reason`, used to classify a child's lineage. Absent parent
517/// row / column -> `None` (degrades to a `spawn` classification).
518fn parent_end_reason(conn: &Connection, parent_id: &str) -> Option<String> {
519    let mut stmt = conn
520        .prepare_cached("SELECT end_reason FROM sessions WHERE id = ?1")
521        .ok()?;
522    stmt.query_row([parent_id], |row| row.get::<_, Option<String>>(0))
523        .ok()
524        .flatten()
525}
526
527// -- Session construction ---------------------------------------------------
528
529#[derive(Clone, Copy)]
530enum Relation {
531    Compaction,
532    Spawn,
533    Branch,
534}
535
536impl Relation {
537    fn tag(self) -> &'static str {
538        match self {
539            Self::Compaction => "compaction_successor",
540            Self::Spawn => "spawn",
541            Self::Branch => "branch",
542        }
543    }
544}
545
546/// Derive the lineage relation + `source_agent` from the session row and its
547/// parent (`hermes_state.py` lines 74-103): a `_branched_from` marker or a
548/// parent that ended `end_reason='branched'` is a `/branch`; a parent that ended
549/// `end_reason='compression'` is a compaction successor; any other parented row
550/// is a delegate/subagent spawn. `source='cron'` overrides to `hermes/cron`;
551/// only spawns become `hermes/subagent` - branch and compaction children ARE the
552/// conversation continuing, so they stay plain `hermes`.
553fn classify(conn: &Connection, row: &Value) -> (Option<Relation>, String) {
554    let source = row.get("source").and_then(Value::as_str);
555    let relation = row
556        .get("parent_session_id")
557        .and_then(Value::as_str)
558        .map(|parent_id| {
559            let parent_end = parent_end_reason(conn, parent_id);
560            let branched_marker = model_config_has(row, "_branched_from");
561            if branched_marker || parent_end.as_deref() == Some("branched") {
562                Relation::Branch
563            } else if parent_end.as_deref() == Some("compression") {
564                Relation::Compaction
565            } else {
566                Relation::Spawn
567            }
568        });
569    let source_agent = if source == Some("cron") {
570        format!("{NAME}/cron")
571    } else if matches!(relation, Some(Relation::Spawn)) {
572        format!("{NAME}/subagent")
573    } else {
574        NAME.to_owned()
575    };
576    (relation, source_agent)
577}
578
579/// Whether the session's `model_config` JSON carries `key` as a non-null value.
580fn model_config_has(row: &Value, key: &str) -> bool {
581    row.get("model_config")
582        .and_then(Value::as_str)
583        .and_then(|text| serde_json::from_str::<Value>(text).ok())
584        .and_then(|cfg| cfg.get(key).cloned())
585        .is_some_and(|value| !value.is_null())
586}
587
588fn build_session(
589    session_id: &str,
590    row: &Value,
591    relation: Option<Relation>,
592    source_agent: String,
593    created_at: DateTime<Utc>,
594) -> Session {
595    let project = session_project(row, session_id);
596    let parent_session_id = row
597        .get("parent_session_id")
598        .and_then(Value::as_str)
599        .map(ToOwned::to_owned);
600
601    let mut hermes = row.as_object().cloned().unwrap_or_default();
602    if let Some(relation) = relation {
603        hermes.insert("relation".to_owned(), json!(relation.tag()));
604    }
605
606    let mut options = source_options(NAME, row);
607    options.insert("hermes".to_owned(), Value::Object(hermes));
608
609    Session {
610        id: session_id.to_owned(),
611        parent_session_id,
612        parent_message_id: None,
613        source_agent,
614        created_at,
615        project,
616        options,
617    }
618}
619
620/// `project` = `session_key`, else `<source>:<chat_id>`, else `cwd`, else
621/// `source` - every candidate a verbatim source field routed through the seam
622/// (spec.md#model-project-non-empty, spec.md#model-no-synthesis). `source` is
623/// NOT NULL, so the final fallback always resolves; the compact-repr tail is
624/// dead and only keeps the value total.
625fn session_project(row: &Value, session_id: &str) -> Extracted<String> {
626    if let Some(key) = extract_str(row, "session_key") {
627        return key;
628    }
629    if let (Some(source), Some(chat_id)) = (
630        row.get("source").and_then(Value::as_str),
631        row.get("chat_id").and_then(Value::as_str),
632    ) {
633        let composite = format!("{source}:{chat_id}");
634        if let Some(project) = extract_str(&json!({ "project": composite }), "project") {
635            return project;
636        }
637    }
638    extract_str(row, "cwd")
639        .or_else(|| extract_str(row, "source"))
640        .unwrap_or_else(|| extract_compact_repr(&Value::String(session_id.to_owned())))
641}
642
643// -- Message -> events ------------------------------------------------------
644
645fn message_events(session_id: &str, row: &Value) -> Result<Vec<IngestEvent>, AdapterError> {
646    // `id` and `timestamp` are both NOT NULL in the source schema; a row missing
647    // either is corrupt, so it fails visibly rather than being dropped or given a
648    // synthesized wall-clock timestamp (spec.md#model-no-synthesis,
649    // spec.md#adapter-integrity-no-silent-drops).
650    let Some(message_id_int) = row.get("id").and_then(Value::as_i64) else {
651        return Err(AdapterError::schema(
652            NAME,
653            session_id.to_owned(),
654            "message row has no integer id",
655        ));
656    };
657    let message_id = format!("{session_id}:{message_id_int}");
658    let Some(timestamp) = row
659        .get("timestamp")
660        .and_then(Value::as_f64)
661        .and_then(secs_to_dt)
662    else {
663        return Err(AdapterError::schema(
664            NAME,
665            message_id,
666            "message row has no parseable timestamp",
667        ));
668    };
669    let options = message_options(row, message_id_int);
670    let role = row.get("role").and_then(Value::as_str);
671    let content = row.get("content").and_then(Value::as_str);
672
673    let mut parts = Vec::new();
674    let mut ordinal = 0usize;
675
676    let message = match role {
677        Some("user") => {
678            if let Some(content) = content {
679                content_parts(
680                    session_id,
681                    &message_id,
682                    &mut ordinal,
683                    content,
684                    Provenance::Conversational,
685                    &mut parts,
686                );
687            }
688            Message::User {
689                id: message_id.clone(),
690                session_id: session_id.to_owned(),
691                timestamp,
692                options,
693            }
694        }
695        Some("assistant") => {
696            if let Some(content) = content {
697                content_parts(
698                    session_id,
699                    &message_id,
700                    &mut ordinal,
701                    content,
702                    Provenance::Conversational,
703                    &mut parts,
704                );
705            }
706            for key in ["reasoning", "reasoning_content"] {
707                if let Some(text) = extract_str(row, key) {
708                    parts.push(reasoning_part(session_id, &message_id, ordinal, text));
709                    ordinal += 1;
710                }
711            }
712            match tool_calls(row) {
713                ToolCalls::Parsed(calls) => {
714                    for call in calls {
715                        parts.push(tool_call_part(session_id, &message_id, ordinal, &call));
716                        ordinal += 1;
717                    }
718                }
719                ToolCalls::Corrupt(raw) => {
720                    let text = extract_str(&json!({ "content": raw }), "content");
721                    parts.push(text_part(
722                        session_id,
723                        &message_id,
724                        ordinal,
725                        text,
726                        Provenance::Conversational,
727                    ));
728                }
729            }
730            Message::Assistant {
731                id: message_id.clone(),
732                session_id: session_id.to_owned(),
733                timestamp,
734                options,
735            }
736        }
737        Some("tool") => {
738            parts.push(tool_result_part(session_id, &message_id, row, content));
739            Message::Tool {
740                id: message_id.clone(),
741                session_id: session_id.to_owned(),
742                timestamp,
743                options,
744            }
745        }
746        // `system` and any unknown role -> System carrier: the content survives
747        // as text, the whole row in options; nothing is dropped
748        // (spec.md#adapter-integrity-no-silent-drops).
749        _ => Message::System {
750            id: message_id.clone(),
751            session_id: session_id.to_owned(),
752            timestamp,
753            content: content.and_then(decoded_text),
754            options,
755        },
756    };
757
758    let mut events = Vec::with_capacity(parts.len() + 1);
759    events.push(IngestEvent::Message(message));
760    events.extend(parts.into_iter().map(IngestEvent::Part));
761    Ok(events)
762}
763
764/// Decode a `content` string into conversational parts. A sentinel-prefixed JSON
765/// array is a multimodal part list; any other JSON is preserved as one compact
766/// Text part; a plain string is one Text part.
767fn content_parts(
768    session_id: &str,
769    message_id: &str,
770    ordinal: &mut usize,
771    raw: &str,
772    provenance: Provenance,
773    parts: &mut Vec<Part>,
774) {
775    match decode_content(raw) {
776        Decoded::Text(text) => {
777            parts.push(text_part(
778                session_id,
779                message_id,
780                *ordinal,
781                extract_str(&json!({ "text": text }), "text"),
782                provenance,
783            ));
784            *ordinal += 1;
785        }
786        Decoded::Json(Value::Array(items)) => {
787            for item in items {
788                parts.push(multimodal_part(
789                    session_id, message_id, *ordinal, &item, provenance,
790                ));
791                *ordinal += 1;
792            }
793        }
794        Decoded::Json(other) => {
795            parts.push(text_part(
796                session_id,
797                message_id,
798                *ordinal,
799                Some(extract_compact_repr(&other)),
800                provenance,
801            ));
802            *ordinal += 1;
803        }
804    }
805}
806
807enum Decoded {
808    Json(Value),
809    Text(String),
810}
811
812fn decode_content(raw: &str) -> Decoded {
813    match raw.strip_prefix(CONTENT_JSON_PREFIX) {
814        // A decode failure falls back to the raw string (matching hermes's own
815        // `_decode_content`), so a corrupt payload is preserved, not lost.
816        Some(rest) => match serde_json::from_str::<Value>(rest) {
817            Ok(value) => Decoded::Json(value),
818            Err(_) => Decoded::Text(raw.to_owned()),
819        },
820        None => Decoded::Text(raw.to_owned()),
821    }
822}
823
824/// Decode a `content` string to a single canonical text value (System carriers,
825/// which have no Part list). Structured JSON collapses to its compact repr.
826fn decoded_text(raw: &str) -> Option<Extracted<String>> {
827    let text = match decode_content(raw) {
828        Decoded::Text(text) => text,
829        Decoded::Json(value) => value.to_string(),
830    };
831    extract_str(&json!({ "content": text }), "content")
832}
833
834fn multimodal_part(
835    session_id: &str,
836    message_id: &str,
837    ordinal: usize,
838    item: &Value,
839    provenance: Provenance,
840) -> Part {
841    match item.get("type").and_then(Value::as_str) {
842        Some("text") => text_part(
843            session_id,
844            message_id,
845            ordinal,
846            extract_str(item, "text"),
847            provenance,
848        ),
849        Some("image_url") => {
850            let url = item
851                .get("image_url")
852                .and_then(|inner| inner.get("url"))
853                .and_then(Value::as_str);
854            match url {
855                Some(url) => Part {
856                    session_id: session_id.to_owned(),
857                    id: part_id(message_id, ordinal),
858                    message_id: message_id.to_owned(),
859                    ordinal: part_ordinal(ordinal),
860                    provenance,
861                    options: ProviderOptions::new(),
862                    kind: PartKind::File {
863                        media_type: None,
864                        file_name: None,
865                        data: FileData::Url(url.to_owned()),
866                    },
867                },
868                None => text_part(
869                    session_id,
870                    message_id,
871                    ordinal,
872                    Some(extract_compact_repr(item)),
873                    provenance,
874                ),
875            }
876        }
877        _ => text_part(
878            session_id,
879            message_id,
880            ordinal,
881            Some(extract_compact_repr(item)),
882            provenance,
883        ),
884    }
885}
886
887/// The assistant `tool_calls` column: parsed OpenAI tool-call objects, or the
888/// raw payload preserved on a decode failure (matching [`decode_content`]'s
889/// preserve-corrupt-as-text policy, never a silent drop -
890/// spec.md#adapter-integrity-no-silent-drops). `Ok(vec![])` means no column.
891enum ToolCalls {
892    Parsed(Vec<Value>),
893    Corrupt(String),
894}
895
896/// Parse the assistant `tool_calls` column into OpenAI tool-call objects. Old
897/// rows double-encode it as a JSON string (fixed upstream in #68856), so a
898/// string result is parsed once more.
899fn tool_calls(row: &Value) -> ToolCalls {
900    let Some(raw) = row.get("tool_calls").and_then(Value::as_str) else {
901        return ToolCalls::Parsed(Vec::new());
902    };
903    let mut value: Value = match serde_json::from_str(raw) {
904        Ok(value) => value,
905        Err(_) => return ToolCalls::Corrupt(raw.to_owned()),
906    };
907    if let Value::String(inner) = &value
908        && let Ok(parsed) = serde_json::from_str::<Value>(inner)
909    {
910        value = parsed;
911    }
912    match value.as_array() {
913        Some(calls) => ToolCalls::Parsed(calls.clone()),
914        None => ToolCalls::Corrupt(raw.to_owned()),
915    }
916}
917
918fn tool_call_part(session_id: &str, message_id: &str, ordinal: usize, call: &Value) -> Part {
919    let function = call.get("function");
920    let name = function.and_then(|f| extract_str(f, "name"));
921    let params = function
922        .and_then(|f| f.get("arguments"))
923        .map(|arguments| match arguments {
924            Value::String(text) => json_or_string(text),
925            other => other.clone(),
926        })
927        .unwrap_or(Value::Null);
928    Part {
929        session_id: session_id.to_owned(),
930        id: part_id(message_id, ordinal),
931        message_id: message_id.to_owned(),
932        ordinal: part_ordinal(ordinal),
933        provenance: Provenance::Conversational,
934        options: ProviderOptions::new(),
935        kind: PartKind::ToolCall {
936            call_id: extract_str(call, "id"),
937            name,
938            params,
939            provider_executed: false,
940        },
941    }
942}
943
944fn tool_result_part(
945    session_id: &str,
946    message_id: &str,
947    row: &Value,
948    content: Option<&str>,
949) -> Part {
950    let result = match content.map(decode_content) {
951        Some(Decoded::Json(value)) => value,
952        Some(Decoded::Text(text)) => Value::String(text),
953        None => Value::Null,
954    };
955    Part {
956        session_id: session_id.to_owned(),
957        id: part_id(message_id, 0),
958        message_id: message_id.to_owned(),
959        ordinal: 0,
960        // spec.md#model-part-provenance: tool output is runtime-produced.
961        provenance: Provenance::Injected,
962        options: ProviderOptions::new(),
963        kind: PartKind::ToolResult {
964            call_id: extract_str(row, "tool_call_id"),
965            name: extract_str(row, "tool_name"),
966            is_failure: false,
967            result,
968        },
969    }
970}
971
972fn reasoning_part(
973    session_id: &str,
974    message_id: &str,
975    ordinal: usize,
976    text: Extracted<String>,
977) -> Part {
978    Part {
979        session_id: session_id.to_owned(),
980        id: part_id(message_id, ordinal),
981        message_id: message_id.to_owned(),
982        ordinal: part_ordinal(ordinal),
983        provenance: Provenance::Conversational,
984        options: ProviderOptions::new(),
985        kind: PartKind::Reasoning { text: Some(text) },
986    }
987}
988
989fn text_part(
990    session_id: &str,
991    message_id: &str,
992    ordinal: usize,
993    text: Option<Extracted<String>>,
994    provenance: Provenance,
995) -> Part {
996    Part {
997        session_id: session_id.to_owned(),
998        id: part_id(message_id, ordinal),
999        message_id: message_id.to_owned(),
1000        ordinal: part_ordinal(ordinal),
1001        provenance,
1002        options: ProviderOptions::new(),
1003        kind: PartKind::Text { text },
1004    }
1005}
1006
1007fn message_options(row: &Value, message_id_int: i64) -> ProviderOptions {
1008    let mut hermes = Map::new();
1009    for key in MESSAGE_META_COLUMNS {
1010        let Some(value) = row.get(*key).filter(|v| !v.is_null()) else {
1011            continue;
1012        };
1013        let stored = if JSON_TEXT_META.contains(key) {
1014            value.as_str().map_or_else(|| value.clone(), json_or_string)
1015        } else {
1016            value.clone()
1017        };
1018        hermes.insert((*key).to_owned(), stored);
1019    }
1020    let mut options = ProviderOptions::new();
1021    if !hermes.is_empty() {
1022        options.insert("hermes".to_owned(), Value::Object(hermes));
1023    }
1024    options.insert(
1025        "source".to_owned(),
1026        json!({
1027            "adapter": NAME,
1028            "id": message_id_int,
1029            "raw_record": extract_raw_record(row),
1030        }),
1031    );
1032    options
1033}
1034
1035// -- Serialize (Foreign NDJSON; plan 3.3) -----------------------------------
1036
1037/// Hermes has no file-era format to target, so native restore is impossible;
1038/// `serialize` always emits Foreign NDJSON of the reconstructed `sessions` +
1039/// `messages` rows (the sanctioned fallback) and reports `Foreign` so the CLI
1040/// warns rather than silently degrading.
1041fn serialize_session(
1042    session: &SessionWithMessages,
1043    _fidelity: RestoreFidelity,
1044) -> Result<Vec<RestoredFile>, AdapterError> {
1045    let session_row = session
1046        .session
1047        .options
1048        .get("source")
1049        .and_then(|source| source.get("raw_record"))
1050        .cloned()
1051        .or_else(|| session.session.options.get("hermes").cloned())
1052        .unwrap_or(Value::Null);
1053    let mut records = vec![json!({ "table": "sessions", "row": session_row })];
1054
1055    let mut messages: Vec<&MessageWithParts> = session.messages.iter().collect();
1056    messages.sort_by(|left, right| {
1057        message_source_id(left)
1058            .cmp(&message_source_id(right))
1059            .then_with(|| by_timestamp_then_id(left, right))
1060    });
1061    for message in messages {
1062        let row = raw_record(message.message.options()).unwrap_or_else(|| reconstruct_row(message));
1063        records.push(json!({ "table": "messages", "row": row }));
1064    }
1065
1066    Ok(vec![RestoredFile::new(
1067        format!("{}.jsonl", session.session.id),
1068        jsonl_bytes(NAME, &records)?,
1069        RestoreFidelity::Foreign,
1070    )])
1071}
1072
1073/// Source `messages.id` (an integer), the faithful append order. `timestamp` is
1074/// non-monotonic and the message id STRING sorts lexicographically wrong
1075/// (`s:10` < `s:2`), so restore must order by this integer.
1076fn message_source_id(message: &MessageWithParts) -> i64 {
1077    message
1078        .message
1079        .options()
1080        .get("source")
1081        .and_then(|source| source.get("id"))
1082        .and_then(Value::as_i64)
1083        .unwrap_or(i64::MAX)
1084}
1085
1086/// Minimal message row when a stored `raw_record` is absent (defensive; ingest
1087/// always records one).
1088fn reconstruct_row(message: &MessageWithParts) -> Value {
1089    let text = message.parts.iter().find_map(|part| match &part.kind {
1090        PartKind::Text { text } => Some(extracted_text(text).to_owned()),
1091        _ => None,
1092    });
1093    json!({
1094        "role": message.message.role().as_str(),
1095        "content": text,
1096        "timestamp": message.message.timestamp().timestamp() as f64,
1097    })
1098}
1099
1100// -- Small helpers ----------------------------------------------------------
1101
1102/// Unix epoch seconds (hermes `REAL`) -> micros.
1103fn secs_to_micros(secs: f64) -> i64 {
1104    (secs * 1_000_000.0).round() as i64
1105}
1106
1107fn secs_to_dt(secs: f64) -> Option<DateTime<Utc>> {
1108    DateTime::from_timestamp_micros(secs_to_micros(secs))
1109}
1110
1111fn open_db(path: &Path) -> Result<Connection, AdapterError> {
1112    sqlite::open_db(NAME, path)
1113}
1114
1115fn connection<'a>(
1116    conns: &'a mut HashMap<PathBuf, Connection>,
1117    path: &Path,
1118) -> Result<&'a Connection, AdapterError> {
1119    sqlite::connection(NAME, conns, path)
1120}
1121
1122fn db_error(path: &Path, op: &str, error: &rusqlite::Error) -> AdapterError {
1123    sqlite::db_error(NAME, path, op, error)
1124}
1125
1126fn join_error(join: tokio::task::JoinError) -> AdapterError {
1127    sqlite::join_error(NAME, join)
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132    #![allow(clippy::expect_used, clippy::unwrap_used)]
1133    use super::*;
1134    use tempfile::TempDir;
1135
1136    /// The subset of hermes's `SCHEMA_SQL` pond reads, copied verbatim from
1137    /// `hermes_state.py` (SCHEMA_VERSION 23). Tests build DBs from this so the
1138    /// column shapes match the real source.
1139    const HERMES_SCHEMA: &str = "
1140        CREATE TABLE schema_version (version INTEGER NOT NULL);
1141        CREATE TABLE sessions (
1142            id TEXT PRIMARY KEY,
1143            source TEXT NOT NULL,
1144            user_id TEXT,
1145            session_key TEXT,
1146            chat_id TEXT,
1147            chat_type TEXT,
1148            thread_id TEXT,
1149            display_name TEXT,
1150            origin_json TEXT,
1151            expiry_finalized INTEGER DEFAULT 0,
1152            model TEXT,
1153            model_config TEXT,
1154            system_prompt TEXT,
1155            parent_session_id TEXT,
1156            started_at REAL NOT NULL,
1157            ended_at REAL,
1158            end_reason TEXT,
1159            message_count INTEGER DEFAULT 0,
1160            tool_call_count INTEGER DEFAULT 0,
1161            input_tokens INTEGER DEFAULT 0,
1162            output_tokens INTEGER DEFAULT 0,
1163            cache_read_tokens INTEGER DEFAULT 0,
1164            cache_write_tokens INTEGER DEFAULT 0,
1165            reasoning_tokens INTEGER DEFAULT 0,
1166            cwd TEXT,
1167            git_branch TEXT,
1168            git_repo_root TEXT,
1169            title TEXT,
1170            profile_name TEXT,
1171            rewind_count INTEGER NOT NULL DEFAULT 0,
1172            archived INTEGER NOT NULL DEFAULT 0,
1173            FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
1174        );
1175        CREATE TABLE messages (
1176            id INTEGER PRIMARY KEY AUTOINCREMENT,
1177            session_id TEXT NOT NULL REFERENCES sessions(id),
1178            role TEXT NOT NULL,
1179            content TEXT,
1180            tool_call_id TEXT,
1181            tool_calls TEXT,
1182            tool_name TEXT,
1183            effect_disposition TEXT,
1184            timestamp REAL NOT NULL,
1185            token_count INTEGER,
1186            finish_reason TEXT,
1187            reasoning TEXT,
1188            reasoning_content TEXT,
1189            reasoning_details TEXT,
1190            codex_reasoning_items TEXT,
1191            codex_message_items TEXT,
1192            platform_message_id TEXT,
1193            observed INTEGER DEFAULT 0,
1194            active INTEGER NOT NULL DEFAULT 1,
1195            compacted INTEGER NOT NULL DEFAULT 0,
1196            api_content TEXT
1197        );
1198        CREATE INDEX idx_messages_session ON messages(session_id, timestamp);
1199    ";
1200
1201    fn create_db(path: &Path) -> Connection {
1202        let conn = Connection::open(path).unwrap();
1203        conn.execute_batch(HERMES_SCHEMA).unwrap();
1204        conn.execute("INSERT INTO schema_version (version) VALUES (23)", [])
1205            .unwrap();
1206        conn
1207    }
1208
1209    /// A convenience session insert with only the columns a test cares about.
1210    fn insert_session(conn: &Connection, id: &str, source: &str, started_at: f64) {
1211        conn.execute(
1212            "INSERT INTO sessions (id, source, started_at) VALUES (?1, ?2, ?3)",
1213            rusqlite::params![id, source, started_at],
1214        )
1215        .unwrap();
1216    }
1217
1218    fn events(root: &Path) -> Vec<IngestEvent> {
1219        let adapter = HermesAdapter::new(root);
1220        let (tx, mut rx) = mpsc::channel(1024);
1221        let enumerated = enumerate_and_peek(&adapter, false);
1222        let survivors: Vec<(PathBuf, String)> = enumerated
1223            .entries
1224            .into_iter()
1225            .map(|entry| (entry.db_path, entry.session_id))
1226            .collect();
1227        std::thread::scope(|scope| {
1228            scope.spawn(move || read_survivors(survivors, &tx));
1229            let mut out = Vec::new();
1230            while let Some(item) = rx.blocking_recv() {
1231                match item.unwrap() {
1232                    AdapterYield::Event(event) => out.push(event),
1233                    other => panic!("unexpected non-event yield: {other:?}"),
1234                }
1235            }
1236            out
1237        })
1238    }
1239
1240    fn only<T>(mut items: Vec<T>, predicate: impl Fn(&T) -> bool) -> T {
1241        let position = items.iter().position(predicate).expect("match present");
1242        items.swap_remove(position)
1243    }
1244
1245    #[test]
1246    fn probe_default_requires_a_home_holding_state_db() {
1247        if std::env::var_os("HERMES_HOME").is_some() {
1248            return; // developer env overrides the probe.
1249        }
1250        let temp = TempDir::new().unwrap();
1251        let env = Env::with_home(temp.path());
1252        assert!(
1253            HermesFactory.probe_default(&env).is_none(),
1254            "an empty home is not a source",
1255        );
1256
1257        let home = temp.path().join(".hermes");
1258        std::fs::create_dir_all(&home).unwrap();
1259        assert!(
1260            HermesFactory.probe_default(&env).is_none(),
1261            "a home without state.db is not a source",
1262        );
1263
1264        create_db(&home.join("state.db"));
1265        let probe = HermesFactory.probe_default(&env);
1266        let got = probe
1267            .as_ref()
1268            .and_then(|value| value.get("path"))
1269            .and_then(Value::as_str);
1270        assert_eq!(
1271            got,
1272            home.to_str(),
1273            "probe returns the home holding state.db"
1274        );
1275    }
1276
1277    #[test]
1278    fn probe_default_honors_hermes_home_override() {
1279        // The override arm is exercised directly (resolve_home) to avoid touching
1280        // the process env under parallel tests.
1281        let temp = TempDir::new().unwrap();
1282        let custom = temp.path().join("opt-data");
1283        std::fs::create_dir_all(&custom).unwrap();
1284        let home = temp.path().join(".hermes");
1285        std::fs::create_dir_all(&home).unwrap();
1286        create_db(&home.join("state.db"));
1287
1288        // Override present but empty -> falls through to ~/.hermes.
1289        assert_eq!(
1290            resolve_home(temp.path(), Some(&custom)),
1291            Some(home.clone()),
1292            "an override without state.db does not win",
1293        );
1294        // Override holding a state.db wins over ~/.hermes.
1295        create_db(&custom.join("state.db"));
1296        assert_eq!(resolve_home(temp.path(), Some(&custom)), Some(custom));
1297    }
1298
1299    #[test]
1300    fn enumerates_default_and_profile_dbs() {
1301        let temp = TempDir::new().unwrap();
1302        let root = temp.path();
1303        let default = create_db(&root.join("state.db"));
1304        insert_session(&default, "s-default", "cli", 1_700_000_000.0);
1305
1306        let profile_dir = root.join("profiles").join("coder");
1307        std::fs::create_dir_all(&profile_dir).unwrap();
1308        let profile = create_db(&profile_dir.join("state.db"));
1309        insert_session(&profile, "s-profile", "tui", 1_700_000_100.0);
1310
1311        let dbs = list_dbs(root);
1312        assert_eq!(dbs.len(), 2, "default + one profile DB enumerated");
1313
1314        let adapter = HermesAdapter::new(root);
1315        let enumerated = enumerate_and_peek(&adapter, false);
1316        let ids: Vec<&str> = enumerated
1317            .entries
1318            .iter()
1319            .map(|entry| entry.session_id.as_str())
1320            .collect();
1321        assert!(ids.contains(&"s-default"));
1322        assert!(ids.contains(&"s-profile"));
1323    }
1324
1325    #[test]
1326    fn user_and_assistant_messages_map_parts_roles_and_tool_calls() {
1327        let temp = TempDir::new().unwrap();
1328        let db_path = temp.path().join("state.db");
1329        let conn = create_db(&db_path);
1330        insert_session(&conn, "s1", "telegram", 1_700_000_000.0);
1331        conn.execute(
1332            "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','user','hello there', 1700000001.0)",
1333            [],
1334        )
1335        .unwrap();
1336        conn.execute(
1337            "INSERT INTO messages (session_id, role, content, tool_calls, timestamp, reasoning) \
1338             VALUES ('s1','assistant','sure', ?1, 1700000002.0, 'let me think')",
1339            [r#"[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]"#],
1340        )
1341        .unwrap();
1342        conn.execute(
1343            "INSERT INTO messages (session_id, role, content, tool_call_id, tool_name, timestamp) \
1344             VALUES ('s1','tool','result-body','call_1','lookup', 1700000003.0)",
1345            [],
1346        )
1347        .unwrap();
1348        drop(conn);
1349
1350        let all = events(temp.path());
1351        let messages: Vec<&Message> = all
1352            .iter()
1353            .filter_map(|event| match event {
1354                IngestEvent::Message(message) => Some(message),
1355                _ => None,
1356            })
1357            .collect();
1358        assert_eq!(messages.len(), 3);
1359        assert!(matches!(messages[0], Message::User { .. }));
1360        assert!(matches!(messages[1], Message::Assistant { .. }));
1361        assert!(matches!(messages[2], Message::Tool { .. }));
1362        assert_eq!(messages[0].id(), "s1:1", "message id = <session>:<rowid>");
1363
1364        let parts: Vec<&Part> = all
1365            .iter()
1366            .filter_map(|event| match event {
1367                IngestEvent::Part(part) => Some(part),
1368                _ => None,
1369            })
1370            .collect();
1371        // assistant: text + reasoning + tool_call.
1372        let tool_call = parts
1373            .iter()
1374            .find(|part| matches!(part.kind, PartKind::ToolCall { .. }))
1375            .expect("assistant tool_call part");
1376        match &tool_call.kind {
1377            PartKind::ToolCall {
1378                call_id,
1379                name,
1380                params,
1381                ..
1382            } => {
1383                assert_eq!(extracted_text(call_id), "call_1");
1384                assert_eq!(extracted_text(name), "lookup");
1385                assert_eq!(params, &json!({ "q": "x" }));
1386            }
1387            _ => unreachable!(),
1388        }
1389        assert!(
1390            parts
1391                .iter()
1392                .any(|part| matches!(part.kind, PartKind::Reasoning { .. })),
1393            "reasoning column becomes a Reasoning part",
1394        );
1395        let tool_result = parts
1396            .iter()
1397            .find(|part| matches!(part.kind, PartKind::ToolResult { .. }))
1398            .expect("tool role -> ToolResult part");
1399        assert_eq!(
1400            tool_result.provenance,
1401            Provenance::Injected,
1402            "tool output is injected, not conversational",
1403        );
1404    }
1405
1406    #[test]
1407    fn multimodal_sentinel_content_decodes_to_parts() {
1408        let temp = TempDir::new().unwrap();
1409        let db_path = temp.path().join("state.db");
1410        let conn = create_db(&db_path);
1411        insert_session(&conn, "s1", "discord", 1_700_000_000.0);
1412        let payload = format!(
1413            "{CONTENT_JSON_PREFIX}{}",
1414            json!([
1415                {"type": "text", "text": "look at this"},
1416                {"type": "image_url", "image_url": {"url": "https://example.com/a.png"}}
1417            ])
1418        );
1419        conn.execute(
1420            "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','user',?1,1700000001.0)",
1421            [payload],
1422        )
1423        .unwrap();
1424        drop(conn);
1425
1426        let all = events(temp.path());
1427        let parts: Vec<&Part> = all
1428            .iter()
1429            .filter_map(|event| match event {
1430                IngestEvent::Part(part) => Some(part),
1431                _ => None,
1432            })
1433            .collect();
1434        assert_eq!(parts.len(), 2, "text + image parts");
1435        assert!(matches!(parts[0].kind, PartKind::Text { .. }));
1436        match &parts[1].kind {
1437            PartKind::File { data, .. } => {
1438                assert_eq!(data, &FileData::Url("https://example.com/a.png".to_owned()));
1439            }
1440            _ => panic!("second multimodal part is a File"),
1441        }
1442    }
1443
1444    #[test]
1445    fn decode_content_falls_back_to_raw_on_bad_json() {
1446        // A sentinel with a corrupt payload is preserved as the raw string, not
1447        // lost - matching hermes's own `_decode_content`.
1448        let raw = format!("{CONTENT_JSON_PREFIX}{{not valid json");
1449        match decode_content(&raw) {
1450            Decoded::Text(text) => assert_eq!(text, raw),
1451            Decoded::Json(_) => panic!("corrupt payload must not parse"),
1452        }
1453    }
1454
1455    #[test]
1456    fn watermark_is_max_timestamp_micros() {
1457        let temp = TempDir::new().unwrap();
1458        let db_path = temp.path().join("state.db");
1459        let conn = create_db(&db_path);
1460        insert_session(&conn, "s1", "cli", 1_700_000_000.0);
1461        for (row, ts) in [("a", 1_700_000_010.5), ("b", 1_700_000_005.0)] {
1462            conn.execute(
1463                "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','user',?1,?2)",
1464                rusqlite::params![row, ts],
1465            )
1466            .unwrap();
1467        }
1468        let watermarks = session_watermarks(&conn).unwrap();
1469        assert_eq!(
1470            watermarks.get("s1").copied(),
1471            Some(secs_to_micros(1_700_000_010.5)),
1472            "watermark is MAX(timestamp), non-monotonic order notwithstanding",
1473        );
1474    }
1475
1476    #[test]
1477    fn lineage_relation_and_source_agent_cover_all_kinds() {
1478        let temp = TempDir::new().unwrap();
1479        let db_path = temp.path().join("state.db");
1480        let conn = create_db(&db_path);
1481        // Parents with distinct end_reasons.
1482        conn.execute(
1483            "INSERT INTO sessions (id, source, started_at, end_reason) VALUES ('p-comp','telegram',1.0,'compression')",
1484            [],
1485        ).unwrap();
1486        conn.execute(
1487            "INSERT INTO sessions (id, source, started_at, end_reason) VALUES ('p-branch','telegram',1.0,'branched')",
1488            [],
1489        ).unwrap();
1490        conn.execute(
1491            "INSERT INTO sessions (id, source, started_at, end_reason) VALUES ('p-spawn','telegram',1.0,'agent_close')",
1492            [],
1493        ).unwrap();
1494        // Children.
1495        conn.execute(
1496            "INSERT INTO sessions (id, source, started_at, parent_session_id) VALUES ('c-comp','telegram',2.0,'p-comp')",
1497            [],
1498        ).unwrap();
1499        conn.execute(
1500            "INSERT INTO sessions (id, source, started_at, parent_session_id) VALUES ('c-branch','telegram',2.0,'p-branch')",
1501            [],
1502        ).unwrap();
1503        conn.execute(
1504            "INSERT INTO sessions (id, source, started_at, parent_session_id) VALUES ('c-spawn','telegram',2.0,'p-spawn')",
1505            [],
1506        ).unwrap();
1507        // A cron session and a marker-based branch (parent not 'branched').
1508        conn.execute(
1509            "INSERT INTO sessions (id, source, started_at) VALUES ('c-cron','cron',2.0)",
1510            [],
1511        )
1512        .unwrap();
1513        conn.execute(
1514            "INSERT INTO sessions (id, source, started_at, parent_session_id, model_config) \
1515             VALUES ('c-marker','telegram',2.0,'p-spawn','{\"_branched_from\":\"p-spawn\"}')",
1516            [],
1517        )
1518        .unwrap();
1519
1520        let check = |id: &str| {
1521            let row = fetch_session_row(&conn, id).unwrap().unwrap();
1522            let (relation, agent) = classify(&conn, &row);
1523            (relation.map(Relation::tag), agent)
1524        };
1525        assert_eq!(
1526            check("c-comp"),
1527            (Some("compaction_successor"), "hermes".to_owned())
1528        );
1529        assert_eq!(check("c-branch"), (Some("branch"), "hermes".to_owned()));
1530        assert_eq!(
1531            check("c-spawn"),
1532            (Some("spawn"), "hermes/subagent".to_owned())
1533        );
1534        assert_eq!(check("c-cron"), (None, "hermes/cron".to_owned()));
1535        assert_eq!(
1536            check("c-marker"),
1537            (Some("branch"), "hermes".to_owned()),
1538            "the _branched_from marker classifies a branch even when the parent did not end 'branched'",
1539        );
1540    }
1541
1542    #[test]
1543    fn project_prefers_session_key_then_composite_then_source() {
1544        let with_key =
1545            json!({ "source": "telegram", "session_key": "tg:42:main", "chat_id": "42" });
1546        assert_eq!(&*session_project(&with_key, "s"), "tg:42:main");
1547
1548        let composite = json!({ "source": "discord", "chat_id": "99" });
1549        assert_eq!(&*session_project(&composite, "s"), "discord:99");
1550
1551        let source_only = json!({ "source": "cli" });
1552        assert_eq!(&*session_project(&source_only, "s"), "cli");
1553    }
1554
1555    #[test]
1556    fn rewrite_reinsert_appears_as_new_rows_old_survive() {
1557        // Hermes /retry deletes then re-inserts; AUTOINCREMENT never reuses ids,
1558        // so the new content lands at higher ids and pond keeps both (additive).
1559        let temp = TempDir::new().unwrap();
1560        let db_path = temp.path().join("state.db");
1561        let conn = create_db(&db_path);
1562        insert_session(&conn, "s1", "cli", 1_700_000_000.0);
1563        conn.execute(
1564            "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','user','first',1700000001.0)",
1565            [],
1566        )
1567        .unwrap();
1568        // Simulate a delete+reinsert rewrite: delete row 1, insert a fresh one.
1569        conn.execute("DELETE FROM messages WHERE id = 1", [])
1570            .unwrap();
1571        conn.execute(
1572            "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','user','second',1700000009.0)",
1573            [],
1574        )
1575        .unwrap();
1576        let new_id: i64 = conn
1577            .query_row("SELECT MAX(id) FROM messages", [], |row| row.get(0))
1578            .unwrap();
1579        assert_eq!(new_id, 2, "AUTOINCREMENT does not reuse the deleted id 1");
1580        drop(conn);
1581
1582        let message = only(
1583            events(temp.path())
1584                .into_iter()
1585                .filter_map(|event| match event {
1586                    IngestEvent::Message(message) => Some(message),
1587                    _ => None,
1588                })
1589                .collect(),
1590            |m| matches!(m, Message::User { .. }),
1591        );
1592        assert_eq!(
1593            message.id(),
1594            "s1:2",
1595            "the surviving row carries the fresh id"
1596        );
1597    }
1598
1599    #[test]
1600    fn foreign_serialize_emits_session_plus_message_rows() {
1601        let temp = TempDir::new().unwrap();
1602        let db_path = temp.path().join("state.db");
1603        let conn = create_db(&db_path);
1604        insert_session(&conn, "s1", "cli", 1_700_000_000.0);
1605        conn.execute(
1606            "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','user','q',1700000001.0)",
1607            [],
1608        )
1609        .unwrap();
1610        conn.execute(
1611            "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','assistant','a',1700000002.0)",
1612            [],
1613        )
1614        .unwrap();
1615        drop(conn);
1616
1617        // Rebuild a SessionWithMessages from the emitted events.
1618        let all = events(temp.path());
1619        let mut session = None;
1620        let mut messages = Vec::new();
1621        let mut parts: Vec<Part> = Vec::new();
1622        for event in all {
1623            match event {
1624                IngestEvent::Session(s) => session = Some(s),
1625                IngestEvent::Message(m) => messages.push(m),
1626                IngestEvent::Part(p) => parts.push(p),
1627            }
1628        }
1629        let session = session.unwrap();
1630        let with_parts: Vec<MessageWithParts> = messages
1631            .into_iter()
1632            .map(|message| {
1633                let owned: Vec<Part> = parts
1634                    .iter()
1635                    .filter(|part| part.message_id == message.id())
1636                    .cloned()
1637                    .collect();
1638                MessageWithParts {
1639                    parts: owned,
1640                    message,
1641                }
1642            })
1643            .collect();
1644        let swm = SessionWithMessages {
1645            session,
1646            messages: with_parts,
1647        };
1648
1649        let files = serialize_session(&swm, RestoreFidelity::Native).unwrap();
1650        assert_eq!(files.len(), 1);
1651        assert_eq!(
1652            files[0].actual_fidelity,
1653            RestoreFidelity::Foreign,
1654            "native is impossible for hermes; the CLI is told it downgraded",
1655        );
1656        let text = std::str::from_utf8(&files[0].bytes).unwrap();
1657        let lines: Vec<&str> = text.lines().collect();
1658        assert_eq!(lines.len(), 3, "one session row + two message rows");
1659        let first: Value = serde_json::from_str(lines[0]).unwrap();
1660        assert_eq!(first.get("table").and_then(Value::as_str), Some("sessions"));
1661        let second: Value = serde_json::from_str(lines[1]).unwrap();
1662        assert_eq!(
1663            second.get("table").and_then(Value::as_str),
1664            Some("messages")
1665        );
1666        assert_eq!(
1667            second.pointer("/row/content").and_then(Value::as_str),
1668            Some("q"),
1669            "messages restore in source id order",
1670        );
1671    }
1672}