sivtr-core 0.2.4

Core library for sivtr terminal output and AI coding session processing across local agent providers
Documentation
use anyhow::Result;
use serde_json::Value;
use std::path::{Path, PathBuf};

use crate::ai::{
    list_recent_jsonl_sessions, parse_jsonl_meta, parse_jsonl_session, pretty_json_string,
    push_block, AgentBlockKind, AgentProvider, AgentSession, AgentSessionInfo, AgentSessionMeta,
    AgentSessionProvider,
};

const PROVIDER_NAME: &str = "Hermes";

#[derive(Debug, Clone, Copy, Default)]
pub struct HermesProvider;

impl AgentSessionProvider for HermesProvider {
    fn provider(&self) -> AgentProvider {
        AgentProvider::Hermes
    }

    fn list_recent_sessions(&self, cwd: Option<&Path>) -> Result<Vec<AgentSessionInfo>> {
        list_recent_jsonl_sessions(&hermes_sessions_dir(), cwd, parse_session_meta)
    }

    fn parse_session_file(&self, path: &Path) -> Result<AgentSession> {
        let mut session = parse_jsonl_session(path, PROVIDER_NAME, apply_event)?;
        if session.id.is_none() {
            session.id = session_id_from_path(path);
        }
        Ok(session)
    }
}

pub fn hermes_home() -> PathBuf {
    if let Ok(path) = std::env::var("HERMES_HOME") {
        return PathBuf::from(path);
    }

    if cfg!(windows) {
        if let Some(local_data) = dirs::data_local_dir() {
            return local_data.join("hermes");
        }
    }

    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".hermes")
}

pub fn hermes_sessions_dir() -> PathBuf {
    hermes_home().join("sessions")
}

fn parse_session_meta(path: &Path) -> Result<AgentSessionMeta> {
    let id = session_id_from_path(path);
    let mut meta = parse_jsonl_meta(path, PROVIDER_NAME, 1, |_, _| {})?;
    meta.id = id;
    Ok(meta)
}

fn session_id_from_path(path: &Path) -> Option<String> {
    path.file_stem()
        .and_then(|name| name.to_str())
        .map(str::to_string)
}

fn apply_event(session: &mut AgentSession, value: &Value) {
    match value.get("role").and_then(Value::as_str) {
        Some("session_meta") => {}
        Some("user") => push_user_message(session, value),
        Some("assistant") => push_assistant_message(session, value),
        Some("tool") => push_tool_output(session, value),
        _ => {}
    }
}

fn push_user_message(session: &mut AgentSession, value: &Value) {
    let timestamp = extract_timestamp(value);
    let content = value
        .get("content")
        .and_then(Value::as_str)
        .unwrap_or_default();
    push_block(session, AgentBlockKind::User, timestamp, None, content);
}

fn push_assistant_message(session: &mut AgentSession, value: &Value) {
    let timestamp = extract_timestamp(value);

    let content = value
        .get("content")
        .and_then(Value::as_str)
        .unwrap_or_default();
    if !content.is_empty() {
        push_block(
            session,
            AgentBlockKind::Assistant,
            timestamp.clone(),
            None,
            content,
        );
    }

    if let Some(Value::Array(tool_calls)) = value.get("tool_calls") {
        for tool_call in tool_calls {
            let function = tool_call.get("function").unwrap_or(&Value::Null);
            let name = function
                .get("name")
                .and_then(Value::as_str)
                .map(str::to_string);
            let arguments = function
                .get("arguments")
                .and_then(Value::as_str)
                .map(pretty_json_string)
                .unwrap_or_default();
            push_block(
                session,
                AgentBlockKind::ToolCall,
                timestamp.clone(),
                name,
                arguments,
            );
        }
    }
}

fn push_tool_output(session: &mut AgentSession, value: &Value) {
    let timestamp = extract_timestamp(value);
    let content = value
        .get("content")
        .and_then(Value::as_str)
        .unwrap_or_default();
    push_block(
        session,
        AgentBlockKind::ToolOutput,
        timestamp,
        None,
        content,
    );
}

fn extract_timestamp(value: &Value) -> Option<String> {
    value
        .get("timestamp")
        .and_then(Value::as_str)
        .map(str::to_string)
}

#[cfg(test)]
mod tests {
    use super::HermesProvider;
    use crate::ai::{AgentBlockKind, AgentSessionProvider};

    #[test]
    fn parses_hermes_messages_and_tools() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("20260426_134409_4f3e2502.jsonl");
        std::fs::write(
            &path,
            r#"{"role":"session_meta","model":"gpt-5.4-mini","platform":"cli","timestamp":"2026-04-26T13:47:42Z"}
{"role":"user","content":"hello","timestamp":"2026-04-26T13:47:43Z"}
{"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"terminal","arguments":"{\"command\":\"echo hi\"}"}}],"timestamp":"2026-04-26T13:47:44Z"}
{"role":"tool","content":"{\"output\":\"hi\",\"exit_code\":0}","tool_call_id":"call_1","timestamp":"2026-04-26T13:47:45Z"}
{"role":"assistant","content":"done","timestamp":"2026-04-26T13:47:46Z"}
"#,
        )
        .unwrap();

        let session = HermesProvider.parse_session_file(&path).unwrap();

        assert_eq!(session.id.as_deref(), Some("20260426_134409_4f3e2502"));
        assert_eq!(session.blocks.len(), 4);
        assert_eq!(session.blocks[0].kind, AgentBlockKind::User);
        assert_eq!(session.blocks[0].text, "hello");
        assert_eq!(session.blocks[1].kind, AgentBlockKind::ToolCall);
        assert_eq!(session.blocks[1].label.as_deref(), Some("terminal"));
        assert!(session.blocks[1].text.contains("echo hi"));
        assert_eq!(session.blocks[2].kind, AgentBlockKind::ToolOutput);
        assert!(session.blocks[2].text.contains("hi"));
        assert_eq!(session.blocks[3].kind, AgentBlockKind::Assistant);
        assert_eq!(session.blocks[3].text, "done");
    }

    #[test]
    fn skips_reasoning_and_empty_content() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("session.jsonl");
        std::fs::write(
            &path,
            r#"{"role":"session_meta","model":"gpt-5.4-mini","timestamp":"2026-04-26T00:00:00Z"}
{"role":"user","content":"test","timestamp":"2026-04-26T00:00:01Z"}
{"role":"assistant","content":"answer","reasoning":"hidden thinking","codex_reasoning_items":[{"type":"reasoning","encrypted_content":"xxx"}],"timestamp":"2026-04-26T00:00:02Z"}
"#,
        )
        .unwrap();

        let session = HermesProvider.parse_session_file(&path).unwrap();

        assert_eq!(session.blocks.len(), 2);
        assert_eq!(session.blocks[0].kind, AgentBlockKind::User);
        assert_eq!(session.blocks[0].text, "test");
        assert_eq!(session.blocks[1].kind, AgentBlockKind::Assistant);
        assert_eq!(session.blocks[1].text, "answer");
    }

    #[test]
    fn handles_multiple_tool_calls_in_one_message() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("session.jsonl");
        std::fs::write(
            &path,
            r#"{"role":"session_meta","timestamp":"2026-04-26T00:00:00Z"}
{"role":"user","content":"do both","timestamp":"2026-04-26T00:00:01Z"}
{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"a.rs\"}"}},{"id":"c2","type":"function","function":{"name":"terminal","arguments":"{\"command\":\"ls\"}"}}],"timestamp":"2026-04-26T00:00:02Z"}
{"role":"tool","content":"file content","tool_call_id":"c1","timestamp":"2026-04-26T00:00:03Z"}
{"role":"tool","content":"dir listing","tool_call_id":"c2","timestamp":"2026-04-26T00:00:04Z"}
{"role":"assistant","content":"all done","timestamp":"2026-04-26T00:00:05Z"}
"#,
        )
        .unwrap();

        let session = HermesProvider.parse_session_file(&path).unwrap();

        assert_eq!(session.blocks.len(), 6);
        assert_eq!(session.blocks[0].kind, AgentBlockKind::User);
        assert_eq!(session.blocks[1].kind, AgentBlockKind::ToolCall);
        assert_eq!(session.blocks[1].label.as_deref(), Some("read_file"));
        assert_eq!(session.blocks[2].kind, AgentBlockKind::ToolCall);
        assert_eq!(session.blocks[2].label.as_deref(), Some("terminal"));
        assert_eq!(session.blocks[3].kind, AgentBlockKind::ToolOutput);
        assert_eq!(session.blocks[4].kind, AgentBlockKind::ToolOutput);
        assert_eq!(session.blocks[5].kind, AgentBlockKind::Assistant);
        assert_eq!(session.blocks[5].text, "all done");
    }
}