nabu-core 0.1.1

Core storage, indexing, and search for nabu: append-only JSONL capture and a rebuildable SQLite FTS5 index for coding-agent history.
Documentation
//! Event envelope, dedupe parts, and the canonical event enums.

use crate::{sanitize_session_id, Error, Result, SCHEMA_VERSION};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fmt;
use std::str::FromStr;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Tool {
    Codex,
    Claude,
    Opencode,
}

impl Tool {
    pub fn as_str(self) -> &'static str {
        match self {
            Tool::Codex => "codex",
            Tool::Claude => "claude",
            Tool::Opencode => "opencode",
        }
    }

    pub const fn all() -> [Tool; 3] {
        [Tool::Codex, Tool::Claude, Tool::Opencode]
    }
}

impl fmt::Display for Tool {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for Tool {
    type Err = Error;

    fn from_str(value: &str) -> Result<Self> {
        match value {
            "codex" => Ok(Tool::Codex),
            "claude" => Ok(Tool::Claude),
            "opencode" => Ok(Tool::Opencode),
            _ => Err(Error::Validation(format!("unsupported tool: {value}"))),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Source {
    Hook,
    EventStream,
    TranscriptTail,
    SdkSessionStore,
    Backfill,
    ExecJson,
    AppServer,
}

impl Source {
    pub fn as_str(self) -> &'static str {
        match self {
            Source::Hook => "hook",
            Source::EventStream => "event_stream",
            Source::TranscriptTail => "transcript_tail",
            Source::SdkSessionStore => "sdk_session_store",
            Source::Backfill => "backfill",
            Source::ExecJson => "exec_json",
            Source::AppServer => "app_server",
        }
    }
}

impl FromStr for Source {
    type Err = Error;

    fn from_str(value: &str) -> Result<Self> {
        match value {
            "hook" => Ok(Source::Hook),
            "event_stream" => Ok(Source::EventStream),
            "transcript_tail" => Ok(Source::TranscriptTail),
            "sdk_session_store" => Ok(Source::SdkSessionStore),
            "backfill" => Ok(Source::Backfill),
            "exec_json" => Ok(Source::ExecJson),
            "app_server" => Ok(Source::AppServer),
            _ => Err(Error::Validation(format!("unsupported source: {value}"))),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CanonicalType {
    #[serde(rename = "session.started")]
    SessionStarted,
    #[serde(rename = "session.resumed")]
    SessionResumed,
    #[serde(rename = "session.ended")]
    SessionEnded,
    #[serde(rename = "user.message")]
    UserMessage,
    #[serde(rename = "assistant.delta")]
    AssistantDelta,
    #[serde(rename = "assistant.message")]
    AssistantMessage,
    #[serde(rename = "tool.call")]
    ToolCall,
    #[serde(rename = "tool.result")]
    ToolResult,
    #[serde(rename = "permission.requested")]
    PermissionRequested,
    #[serde(rename = "permission.replied")]
    PermissionReplied,
    #[serde(rename = "file.changed")]
    FileChanged,
    #[serde(rename = "compaction.before")]
    CompactionBefore,
    #[serde(rename = "compaction.after")]
    CompactionAfter,
    #[serde(rename = "source.discontinuity")]
    SourceDiscontinuity,
    #[serde(rename = "error")]
    Error,
}

impl CanonicalType {
    pub fn as_str(self) -> &'static str {
        match self {
            CanonicalType::SessionStarted => "session.started",
            CanonicalType::SessionResumed => "session.resumed",
            CanonicalType::SessionEnded => "session.ended",
            CanonicalType::UserMessage => "user.message",
            CanonicalType::AssistantDelta => "assistant.delta",
            CanonicalType::AssistantMessage => "assistant.message",
            CanonicalType::ToolCall => "tool.call",
            CanonicalType::ToolResult => "tool.result",
            CanonicalType::PermissionRequested => "permission.requested",
            CanonicalType::PermissionReplied => "permission.replied",
            CanonicalType::FileChanged => "file.changed",
            CanonicalType::CompactionBefore => "compaction.before",
            CanonicalType::CompactionAfter => "compaction.after",
            CanonicalType::SourceDiscontinuity => "source.discontinuity",
            CanonicalType::Error => "error",
        }
    }
}

impl FromStr for CanonicalType {
    type Err = Error;

    fn from_str(value: &str) -> Result<Self> {
        match value {
            "session.started" => Ok(CanonicalType::SessionStarted),
            "session.resumed" => Ok(CanonicalType::SessionResumed),
            "session.ended" => Ok(CanonicalType::SessionEnded),
            "user.message" => Ok(CanonicalType::UserMessage),
            "assistant.delta" => Ok(CanonicalType::AssistantDelta),
            "assistant.message" => Ok(CanonicalType::AssistantMessage),
            "tool.call" => Ok(CanonicalType::ToolCall),
            "tool.result" => Ok(CanonicalType::ToolResult),
            "permission.requested" => Ok(CanonicalType::PermissionRequested),
            "permission.replied" => Ok(CanonicalType::PermissionReplied),
            "file.changed" => Ok(CanonicalType::FileChanged),
            "compaction.before" => Ok(CanonicalType::CompactionBefore),
            "compaction.after" => Ok(CanonicalType::CompactionAfter),
            "source.discontinuity" => Ok(CanonicalType::SourceDiscontinuity),
            "error" => Ok(CanonicalType::Error),
            _ => Err(Error::Validation(format!(
                "unsupported canonical_type: {value}"
            ))),
        }
    }
}

/// Classification of canonical types that hand over an entire phase in a
/// single one-line event. These summary-bearing events let a reader (or an
/// agent triaging search/list output) skip a full session read, so they are
/// surfaced as a first-class, typed flag on search hits and session events.
///
/// The taxonomy keys off [`CanonicalType`] only — there is no string matching
/// on payloads. Ordinary conversational and tool events classify as `None`
/// (see [`CanonicalType::summary_kind`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SummaryKind {
    /// `session.started` — the session-open handover (project, cwd, task framing).
    SessionStart,
    /// `session.ended` — the session-close summary (outcome, usage rollup).
    SessionEnd,
    /// `compaction.after` — the post-compaction recap of prior context.
    Compaction,
}

impl SummaryKind {
    pub fn as_str(self) -> &'static str {
        match self {
            SummaryKind::SessionStart => "session_start",
            SummaryKind::SessionEnd => "session_end",
            SummaryKind::Compaction => "compaction",
        }
    }
}

impl CanonicalType {
    /// Returns the [`SummaryKind`] for canonical types that carry a one-line
    /// phase handover, or `None` for ordinary events.
    ///
    /// Note: the GitHub issue (#1, item 6) referred to an `away_summary` event
    /// type; no such canonical type exists in this codebase. The summary-bearing
    /// canonical types that do exist are `session.started`, `session.ended`, and
    /// `compaction.after`.
    pub fn summary_kind(self) -> Option<SummaryKind> {
        match self {
            CanonicalType::SessionStarted => Some(SummaryKind::SessionStart),
            CanonicalType::SessionEnded => Some(SummaryKind::SessionEnd),
            CanonicalType::CompactionAfter => Some(SummaryKind::Compaction),
            CanonicalType::SessionResumed
            | CanonicalType::UserMessage
            | CanonicalType::AssistantDelta
            | CanonicalType::AssistantMessage
            | CanonicalType::ToolCall
            | CanonicalType::ToolResult
            | CanonicalType::PermissionRequested
            | CanonicalType::PermissionReplied
            | CanonicalType::FileChanged
            | CanonicalType::CompactionBefore
            | CanonicalType::SourceDiscontinuity
            | CanonicalType::Error => None,
        }
    }
}

/// Resolves the [`SummaryKind`] for a stored canonical-type string. Unknown or
/// unparsable canonical types are treated as non-summary (`None`), keeping read
/// paths total rather than failing a whole page on one unexpected row.
pub fn summary_kind_for_canonical_str(canonical_type: &str) -> Option<SummaryKind> {
    CanonicalType::from_str(canonical_type)
        .ok()
        .and_then(CanonicalType::summary_kind)
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EventEnvelope {
    pub schema_version: u32,
    pub captured_at: String,
    pub tool: Tool,
    pub tool_version: Option<String>,
    pub session_id: String,
    pub filename_session_id: String,
    pub turn_id: Option<String>,
    pub message_id: Option<String>,
    pub project_root: Option<String>,
    pub cwd: Option<String>,
    pub source: Source,
    pub source_event_type: String,
    pub canonical_type: CanonicalType,
    pub source_event_id: Option<String>,
    pub dedupe_key: String,
    pub sequence: Option<i64>,
    pub raw_file: Option<String>,
    pub raw_offset: Option<i64>,
    pub payload: Value,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub payload_ref: Option<String>,
}

impl EventEnvelope {
    pub fn validate(&self) -> Result<()> {
        if self.schema_version != SCHEMA_VERSION {
            return Err(Error::Validation(format!(
                "schema_version must be {SCHEMA_VERSION}"
            )));
        }
        if self.session_id.is_empty() {
            return Err(Error::Validation(
                "session_id must not be empty".to_string(),
            ));
        }
        if self.filename_session_id != sanitize_session_id(&self.session_id) {
            return Err(Error::Validation(
                "filename_session_id must match sanitized session_id".to_string(),
            ));
        }
        if self.source_event_type.is_empty() {
            return Err(Error::Validation(
                "source_event_type must not be empty".to_string(),
            ));
        }
        if !self.dedupe_key.starts_with("sha256:") {
            return Err(Error::Validation(
                "dedupe_key must start with sha256:".to_string(),
            ));
        }
        Ok(())
    }
}

pub struct DedupeParts<'a> {
    pub tool: Tool,
    pub session_id: &'a str,
    pub canonical_type: CanonicalType,
    pub source_event_id: Option<&'a str>,
    pub sequence: Option<i64>,
    pub payload: &'a Value,
}