Skip to main content

aicx_parser/
timeline.rs

1//! Shared timeline and segmentation data types.
2//!
3//! Vibecrafted with AI Agents by VetCoders (c)2026 VetCoders
4
5use chrono::{DateTime, Utc};
6#[cfg(feature = "json-schema")]
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::fmt;
10use std::path::PathBuf;
11
12/// Canonical kind for a session segment in the store.
13///
14/// Kind determines the subdirectory under `<project>/<date>/` and is part
15/// of the canonical store path. Classification is conservative: when in
16/// doubt, segments fall through to `Other`.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Kind {
20    Conversations,
21    Plans,
22    Reports,
23    #[default]
24    Other,
25}
26
27impl Kind {
28    /// Directory name used in the canonical store layout.
29    pub fn dir_name(self) -> &'static str {
30        match self {
31            Self::Conversations => "conversations",
32            Self::Plans => "plans",
33            Self::Reports => "reports",
34            Self::Other => "other",
35        }
36    }
37
38    /// Parse from a string (case-insensitive, accepts both singular and plural).
39    pub fn parse(s: &str) -> Option<Self> {
40        match s.to_ascii_lowercase().as_str() {
41            "conversations" | "conversation" => Some(Self::Conversations),
42            "plans" | "plan" => Some(Self::Plans),
43            "reports" | "report" => Some(Self::Reports),
44            "other" => Some(Self::Other),
45            _ => None,
46        }
47    }
48}
49
50impl fmt::Display for Kind {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        f.write_str(self.dir_name())
53    }
54}
55
56/// Canonical stream/frame classification for a timeline entry or stored chunk.
57///
58/// This axis is intentionally orthogonal to `role`: source formats drift in how
59/// they spell assistant reasoning or tool payloads, but downstream retrieval
60/// needs one stable vocabulary for "which channel is this?".
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
63#[serde(rename_all = "snake_case")]
64pub enum FrameKind {
65    UserMsg,
66    AgentReply,
67    InternalThought,
68    ToolCall,
69}
70
71impl FrameKind {
72    pub fn as_str(self) -> &'static str {
73        match self {
74            Self::UserMsg => "user_msg",
75            Self::AgentReply => "agent_reply",
76            Self::InternalThought => "internal_thought",
77            Self::ToolCall => "tool_call",
78        }
79    }
80
81    pub fn parse(value: &str) -> Option<Self> {
82        match value.trim().to_ascii_lowercase().as_str() {
83            "user_msg" | "user" => Some(Self::UserMsg),
84            "agent_reply" | "assistant" | "reply" => Some(Self::AgentReply),
85            "internal_thought" | "thought" | "thinking" | "reasoning" => {
86                Some(Self::InternalThought)
87            }
88            "tool_call" | "tool" | "tool_result" | "function_call" => Some(Self::ToolCall),
89            _ => None,
90        }
91    }
92}
93
94impl fmt::Display for FrameKind {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.write_str(self.as_str())
97    }
98}
99
100/// Unified timeline entry from any AI agent source.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct TimelineEntry {
103    pub timestamp: DateTime<Utc>,
104    pub agent: String,
105    pub session_id: String,
106    pub role: String,
107    pub message: String,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub frame_kind: Option<FrameKind>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub branch: Option<String>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub cwd: Option<String>,
114}
115
116/// Denoised conversation message — the canonical projection of a TimelineEntry
117/// containing only user/assistant messages with repo-centric identity.
118///
119/// This is the primary unit for "recover the conversation" workflows.
120/// Tool calls, tool results, reasoning/thoughts, system noise, and artifact
121/// payloads are excluded. Artifact paths may appear as references only.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ConversationMessage {
124    pub timestamp: DateTime<Utc>,
125    pub agent: String,
126    pub session_id: String,
127    /// Only "user" or "assistant" — reasoning and system roles are excluded.
128    pub role: String,
129    /// Raw, untrimmed, untruncated message body.
130    pub message: String,
131    /// Canonical project/repo identity (derived from cwd + project filter).
132    pub repo_project: String,
133    /// Secondary provenance: source working directory path.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub source_path: Option<String>,
136    /// Git branch at time of message (when available).
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub branch: Option<String>,
139}
140
141/// Configuration for extraction.
142#[derive(Debug, Clone)]
143pub struct ExtractionConfig {
144    pub project_filter: Vec<String>,
145    pub cutoff: DateTime<Utc>,
146    pub include_assistant: bool,
147    pub watermark: Option<DateTime<Utc>>,
148}
149
150/// Info about an available source directory/file.
151#[derive(Debug, Clone, Serialize)]
152pub struct SourceInfo {
153    pub agent: String,
154    pub path: PathBuf,
155    pub sessions: usize,
156    pub size_bytes: u64,
157}
158
159/// Explicit trust tier for a repo identity signal.
160///
161/// Not all evidence for "which repo is this?" is equal. A git remote URL
162/// is canonical truth; a directory layout is a strong hint; a hex hash is
163/// opaque noise. This enum makes the distinction machine-readable so the
164/// store can decide whether to assert identity or route to fallback.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
166pub enum SourceTier {
167    /// Git remote URL or explicit GitHub/GitLab link in message text.
168    /// The strongest signal — the repo literally named itself.
169    Primary,
170    /// Local git repo discovered on disk (via `.git/` traversal + known layout),
171    /// or a projectHash resolved through a trustworthy local mapping file.
172    Secondary,
173    /// Known directory layout (e.g. `~/hosted/<org>/<repo>`) without a `.git/`
174    /// directory or remote confirmation. Plausible but not proven.
175    Fallback,
176    /// Hex hash, opaque identifier, or source that is explicitly not a
177    /// conversation (e.g. `.pb` protobuf, step-output). Must never assert
178    /// repo identity on its own.
179    Opaque,
180}
181
182impl SourceTier {
183    /// Whether this tier is strong enough to assert repo identity for
184    /// canonical store placement (under `store/<org>/<repo>/`).
185    pub fn is_assertable(self) -> bool {
186        matches!(self, Self::Primary | Self::Secondary)
187    }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Hash)]
191pub struct RepoIdentity {
192    pub organization: String,
193    pub repository: String,
194}
195
196impl RepoIdentity {
197    pub fn slug(&self) -> String {
198        format!("{}/{}", self.organization, self.repository)
199    }
200}
201
202#[derive(Debug, Clone)]
203pub struct SemanticSegment {
204    pub repo: Option<RepoIdentity>,
205    /// The trust tier of the strongest signal that produced `repo`.
206    /// `None` when `repo` is `None`.
207    pub source_tier: Option<SourceTier>,
208    pub kind: Kind,
209    pub agent: String,
210    pub session_id: String,
211    pub entries: Vec<TimelineEntry>,
212}
213
214impl SemanticSegment {
215    pub fn project_label(&self) -> String {
216        self.repo
217            .as_ref()
218            .map(RepoIdentity::slug)
219            .unwrap_or_else(|| "non-repository-contexts".to_string())
220    }
221
222    /// Whether the repo identity is strong enough for canonical store placement.
223    /// Returns `false` for `None` repo or Fallback/Opaque tiers.
224    pub fn has_assertable_identity(&self) -> bool {
225        self.source_tier.is_some_and(SourceTier::is_assertable)
226    }
227}