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::{
    extract_content_text, list_recent_jsonl_sessions, parse_jsonl_meta, parse_jsonl_session,
    pretty_json_value, push_block, AgentBlockKind, AgentProvider, AgentSession, AgentSessionInfo,
    AgentSessionMeta, AgentSessionProvider,
};

const PROVIDER_NAME: &str = "Pi";

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

impl AgentSessionProvider for PiProvider {
    fn provider(&self) -> AgentProvider {
        AgentProvider::Pi
    }

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

    fn parse_session_file(&self, path: &Path) -> Result<AgentSession> {
        parse_jsonl_session(path, PROVIDER_NAME, apply_event)
    }
}

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

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

pub fn pi_sessions_dir() -> PathBuf {
    pi_home().join("agent").join("sessions")
}

fn parse_session_meta(path: &Path) -> Result<AgentSessionMeta> {
    parse_jsonl_meta(path, PROVIDER_NAME, 1, update_meta)
}

fn update_meta(meta: &mut AgentSessionMeta, value: &Value) {
    if value.get("type").and_then(Value::as_str) != Some("session") {
        return;
    }

    meta.id = value.get("id").and_then(Value::as_str).map(str::to_string);
    if let Some(cwd) = value.get("cwd").and_then(Value::as_str) {
        meta.add_cwd(cwd);
    }
}

fn apply_event(session: &mut AgentSession, value: &Value) {
    match value.get("type").and_then(Value::as_str) {
        Some("session") => update_session_meta(session, value),
        Some("message") => apply_message(session, value),
        Some("model_change" | "thinking_level_change" | "compaction") => {}
        _ => {}
    }
}

fn update_session_meta(session: &mut AgentSession, value: &Value) {
    if session.id.is_none() {
        session.id = value.get("id").and_then(Value::as_str).map(str::to_string);
    }
    if session.cwd.is_none() {
        session.cwd = value.get("cwd").and_then(Value::as_str).map(str::to_string);
    }
}

fn apply_message(session: &mut AgentSession, value: &Value) {
    let timestamp = value
        .get("timestamp")
        .and_then(Value::as_str)
        .map(str::to_string);
    let message = value.get("message").unwrap_or(&Value::Null);

    match message.get("role").and_then(Value::as_str) {
        Some("user") => push_text_items(
            session,
            AgentBlockKind::User,
            timestamp,
            message.get("content").unwrap_or(&Value::Null),
        ),
        Some("assistant") => push_assistant_items(
            session,
            timestamp,
            message.get("content").unwrap_or(&Value::Null),
        ),
        Some("toolResult") => push_text_items(
            session,
            AgentBlockKind::ToolOutput,
            timestamp,
            message.get("content").unwrap_or(&Value::Null),
        ),
        _ => {}
    }
}

fn push_assistant_items(session: &mut AgentSession, timestamp: Option<String>, content: &Value) {
    match content {
        Value::Array(items) => {
            for item in items {
                match item.get("type").and_then(Value::as_str) {
                    Some("text") => push_block(
                        session,
                        AgentBlockKind::Assistant,
                        timestamp.clone(),
                        None,
                        extract_content_text(item),
                    ),
                    Some("toolCall") => push_block(
                        session,
                        AgentBlockKind::ToolCall,
                        timestamp.clone(),
                        item.get("name").and_then(Value::as_str).map(str::to_string),
                        item.get("arguments")
                            .map(pretty_json_value)
                            .unwrap_or_default(),
                    ),
                    Some("thinking") => {}
                    _ => {}
                }
            }
        }
        Value::String(text) => push_block(
            session,
            AgentBlockKind::Assistant,
            timestamp,
            None,
            text.clone(),
        ),
        _ => {}
    }
}

fn push_text_items(
    session: &mut AgentSession,
    kind: AgentBlockKind,
    timestamp: Option<String>,
    content: &Value,
) {
    push_block(
        session,
        kind,
        timestamp,
        None,
        extract_content_text(content),
    );
}

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

    #[test]
    fn parses_pi_messages_and_tools() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("session.jsonl");
        std::fs::write(
            &path,
            r#"{"type":"session","version":3,"id":"pi-session","timestamp":"2026-05-21T00:00:00Z","cwd":"D:\\sivtr"}
{"type":"message","timestamp":"2026-05-21T00:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}
{"type":"message","timestamp":"2026-05-21T00:00:02Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"hidden"},{"type":"toolCall","name":"bash","arguments":{"command":"echo hi"}}]}}
{"type":"message","timestamp":"2026-05-21T00:00:03Z","message":{"role":"toolResult","content":[{"type":"text","text":"hi"}]}}
{"type":"message","timestamp":"2026-05-21T00:00:04Z","message":{"role":"assistant","content":[{"type":"text","text":"done"}]}}
{"type":"compaction","summary":"summary should not become a block"}
"#,
        )
        .unwrap();

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

        assert_eq!(session.id.as_deref(), Some("pi-session"));
        assert_eq!(session.cwd.as_deref(), Some("D:\\sivtr"));
        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("bash"));
        assert!(session.blocks[1].text.contains("echo hi"));
        assert_eq!(session.blocks[2].kind, AgentBlockKind::ToolOutput);
        assert_eq!(session.blocks[2].text, "hi");
        assert_eq!(session.blocks[3].kind, AgentBlockKind::Assistant);
        assert_eq!(session.blocks[3].text, "done");
    }
}