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    SystemNote,
70}
71
72impl FrameKind {
73    pub fn as_str(self) -> &'static str {
74        match self {
75            Self::UserMsg => "user_msg",
76            Self::AgentReply => "agent_reply",
77            Self::InternalThought => "internal_thought",
78            Self::ToolCall => "tool_call",
79            Self::SystemNote => "system_note",
80        }
81    }
82
83    pub fn parse(value: &str) -> Option<Self> {
84        match value.trim().to_ascii_lowercase().as_str() {
85            "user_msg" | "user" => Some(Self::UserMsg),
86            "agent_reply" | "assistant" | "reply" => Some(Self::AgentReply),
87            "internal_thought" | "thought" | "thinking" | "reasoning" => {
88                Some(Self::InternalThought)
89            }
90            "tool_call" | "tool" | "tool_result" | "function_call" => Some(Self::ToolCall),
91            "system_note" | "system" | "note" | "notification" | "error" => Some(Self::SystemNote),
92            _ => None,
93        }
94    }
95}
96
97impl fmt::Display for FrameKind {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.write_str(self.as_str())
100    }
101}
102
103/// Neutral metadata for conversation JSON consumers.
104///
105/// This is descriptive only: it does not decide indexing policy, filtering, or
106/// chunking behavior.
107#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
108#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
109#[serde(rename_all = "snake_case")]
110pub enum MessageKind {
111    #[default]
112    Conversation,
113    WorkflowPrompt,
114    ContinuationSummary,
115    CollapseStub,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
119#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
120#[serde(rename_all = "snake_case")]
121pub enum CollapseStubKind {
122    SkillRef,
123    DedupRef,
124}
125
126/// Unified timeline entry from any AI agent source.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct TimelineEntry {
129    pub timestamp: DateTime<Utc>,
130    pub agent: String,
131    pub session_id: String,
132    pub role: String,
133    pub message: String,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub frame_kind: Option<FrameKind>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub branch: Option<String>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub cwd: Option<String>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub timestamp_source: Option<String>,
142}
143
144/// Denoised conversation message — the canonical projection of a TimelineEntry
145/// containing only user/assistant messages with repo-centric identity.
146///
147/// This is the primary unit for "recover the conversation" workflows.
148/// Tool calls, tool results, reasoning/thoughts, system noise, and artifact
149/// payloads are excluded. Artifact paths may appear as references only.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct ConversationMessage {
152    pub timestamp: DateTime<Utc>,
153    pub agent: String,
154    pub session_id: String,
155    /// Only "user" or "assistant" — reasoning and system roles are excluded.
156    pub role: String,
157    /// Raw, untrimmed, untruncated message body.
158    pub message: String,
159    /// Canonical project/repo identity (derived from cwd + project filter).
160    pub repo_project: String,
161    /// Secondary provenance: source working directory path.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub source_path: Option<String>,
164    /// Git branch at time of message (when available).
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub branch: Option<String>,
167    /// Neutral descriptive metadata for JSON consumers.
168    #[serde(default)]
169    pub message_kind: MessageKind,
170    /// Present only when `message_kind` is `collapse_stub`.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub collapse_stub_kind: Option<CollapseStubKind>,
173}
174
175/// Configuration for extraction.
176#[derive(Debug, Clone)]
177pub struct ExtractionConfig {
178    pub project_filter: Vec<String>,
179    pub cutoff: DateTime<Utc>,
180    pub include_assistant: bool,
181    pub watermark: Option<DateTime<Utc>>,
182}
183
184/// Info about an available source directory/file.
185#[derive(Debug, Clone, Serialize)]
186pub struct SourceInfo {
187    pub agent: String,
188    pub path: PathBuf,
189    pub sessions: usize,
190    pub size_bytes: u64,
191    pub protected_by_git: bool,
192    pub protection_backend: String,
193    pub protection_root: Option<PathBuf>,
194    pub git_remote_count: usize,
195    pub git_remotes: Vec<String>,
196    pub protection_warning: Option<String>,
197}
198
199/// Explicit trust tier for a repo identity signal.
200///
201/// Not all evidence for "which repo is this?" is equal. A git remote URL
202/// is canonical truth; a directory layout is a strong hint; a hex hash is
203/// opaque noise. This enum makes the distinction machine-readable so the
204/// store can decide whether to assert identity or route to fallback.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
206pub enum SourceTier {
207    /// Git remote URL or explicit GitHub/GitLab link in message text.
208    /// The strongest signal — the repo literally named itself.
209    Primary,
210    /// Local git repo discovered on disk (via `.git/` traversal + known layout),
211    /// or a projectHash resolved through a trustworthy local mapping file.
212    Secondary,
213    /// Known directory layout (e.g. `~/hosted/<org>/<repo>`) without a `.git/`
214    /// directory or remote confirmation. Plausible but not proven.
215    Fallback,
216    /// Hex hash, opaque identifier, or source that is explicitly not a
217    /// conversation (e.g. `.pb` protobuf, step-output). Must never assert
218    /// repo identity on its own.
219    Opaque,
220}
221
222impl SourceTier {
223    /// Whether this tier is strong enough to assert repo identity for
224    /// canonical store placement (under `store/<org>/<repo>/`).
225    pub fn is_assertable(self) -> bool {
226        matches!(self, Self::Primary | Self::Secondary)
227    }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Hash)]
231pub struct RepoIdentity {
232    pub organization: String,
233    pub repository: String,
234}
235
236impl RepoIdentity {
237    pub fn slug(&self) -> String {
238        format!("{}/{}", self.organization, self.repository)
239    }
240}
241
242#[derive(Debug, Clone)]
243pub struct SemanticSegment {
244    pub repo: Option<RepoIdentity>,
245    /// The trust tier of the strongest signal that produced `repo`.
246    /// `None` when `repo` is `None`.
247    pub source_tier: Option<SourceTier>,
248    pub kind: Kind,
249    pub agent: String,
250    pub session_id: String,
251    pub entries: Vec<TimelineEntry>,
252}
253
254impl SemanticSegment {
255    pub fn project_label(&self) -> String {
256        self.repo
257            .as_ref()
258            .map(RepoIdentity::slug)
259            .unwrap_or_else(|| "non-repository-contexts".to_string())
260    }
261
262    /// Whether the repo identity is strong enough for canonical store placement.
263    /// Returns `false` for `None` repo or Fallback/Opaque tiers.
264    pub fn has_assertable_identity(&self) -> bool {
265        self.source_tier.is_some_and(SourceTier::is_assertable)
266    }
267}