Skip to main content

retro_core/
models.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Deserializer, Serialize};
3
4/// Deserialize a String that may be null — converts null to empty string.
5fn null_to_empty<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
6    Option::<String>::deserialize(d).map(|o| o.unwrap_or_default())
7}
8
9// ── Pattern types ──
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(rename_all = "snake_case")]
13pub enum PatternType {
14    RepetitiveInstruction,
15    RecurringMistake,
16    WorkflowPattern,
17    StaleContext,
18    RedundantContext,
19}
20
21impl std::fmt::Display for PatternType {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            Self::RepetitiveInstruction => write!(f, "repetitive_instruction"),
25            Self::RecurringMistake => write!(f, "recurring_mistake"),
26            Self::WorkflowPattern => write!(f, "workflow_pattern"),
27            Self::StaleContext => write!(f, "stale_context"),
28            Self::RedundantContext => write!(f, "redundant_context"),
29        }
30    }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "snake_case")]
35pub enum PatternStatus {
36    Discovered,
37    Active,
38    Archived,
39    Dismissed,
40}
41
42impl std::fmt::Display for PatternStatus {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Discovered => write!(f, "discovered"),
46            Self::Active => write!(f, "active"),
47            Self::Archived => write!(f, "archived"),
48            Self::Dismissed => write!(f, "dismissed"),
49        }
50    }
51}
52
53impl PatternStatus {
54    pub fn from_str(s: &str) -> Self {
55        match s {
56            "discovered" => Self::Discovered,
57            "active" => Self::Active,
58            "archived" => Self::Archived,
59            "dismissed" => Self::Dismissed,
60            _ => Self::Discovered,
61        }
62    }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66#[serde(rename_all = "snake_case")]
67pub enum SuggestedTarget {
68    Skill,
69    ClaudeMd,
70    GlobalAgent,
71    DbOnly,
72}
73
74impl std::fmt::Display for SuggestedTarget {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            Self::Skill => write!(f, "skill"),
78            Self::ClaudeMd => write!(f, "claude_md"),
79            Self::GlobalAgent => write!(f, "global_agent"),
80            Self::DbOnly => write!(f, "db_only"),
81        }
82    }
83}
84
85impl SuggestedTarget {
86    pub fn from_str(s: &str) -> Self {
87        match s {
88            "skill" => Self::Skill,
89            "claude_md" => Self::ClaudeMd,
90            "global_agent" => Self::GlobalAgent,
91            "db_only" => Self::DbOnly,
92            _ => Self::DbOnly,
93        }
94    }
95}
96
97impl PatternType {
98    pub fn from_str(s: &str) -> Self {
99        match s {
100            "repetitive_instruction" => Self::RepetitiveInstruction,
101            "recurring_mistake" => Self::RecurringMistake,
102            "workflow_pattern" => Self::WorkflowPattern,
103            "stale_context" => Self::StaleContext,
104            "redundant_context" => Self::RedundantContext,
105            _ => Self::WorkflowPattern,
106        }
107    }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Pattern {
112    pub id: String,
113    pub pattern_type: PatternType,
114    pub description: String,
115    pub confidence: f64,
116    pub times_seen: i64,
117    pub first_seen: DateTime<Utc>,
118    pub last_seen: DateTime<Utc>,
119    pub last_projected: Option<DateTime<Utc>>,
120    pub status: PatternStatus,
121    pub source_sessions: Vec<String>,
122    pub related_files: Vec<String>,
123    pub suggested_content: String,
124    pub suggested_target: SuggestedTarget,
125    pub project: Option<String>,
126    pub generation_failed: bool,
127}
128
129// ── Session JSONL types ──
130
131/// Top-level entry in a session JSONL file.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(tag = "type")]
134pub enum SessionEntry {
135    #[serde(rename = "user")]
136    User(UserEntry),
137    #[serde(rename = "assistant")]
138    Assistant(AssistantEntry),
139    #[serde(rename = "summary")]
140    Summary(SummaryEntry),
141    #[serde(rename = "file-history-snapshot")]
142    FileHistorySnapshot(serde_json::Value),
143    #[serde(rename = "progress")]
144    Progress(serde_json::Value),
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct UserEntry {
150    pub uuid: String,
151    #[serde(default)]
152    pub parent_uuid: Option<String>,
153    #[serde(default)]
154    pub session_id: Option<String>,
155    #[serde(default)]
156    pub cwd: Option<String>,
157    #[serde(default)]
158    pub version: Option<String>,
159    #[serde(default)]
160    pub git_branch: Option<String>,
161    #[serde(default)]
162    pub timestamp: Option<String>,
163    pub message: UserMessage,
164    #[serde(default)]
165    pub is_sidechain: bool,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct UserMessage {
170    pub role: String,
171    pub content: MessageContent,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(untagged)]
176pub enum MessageContent {
177    Text(String),
178    Blocks(Vec<ContentBlock>),
179    /// Catch-all for newer content formats we don't handle yet.
180    Other(serde_json::Value),
181}
182
183impl MessageContent {
184    /// Extract the user-facing text from the message content.
185    pub fn as_text(&self) -> String {
186        match self {
187            MessageContent::Text(s) => s.clone(),
188            MessageContent::Blocks(blocks) => {
189                let mut parts = Vec::new();
190                for block in blocks {
191                    match block {
192                        ContentBlock::Text { text } => parts.push(text.clone()),
193                        ContentBlock::ToolResult { content, .. } => {
194                            if let Some(c) = content {
195                                parts.push(c.as_text());
196                            }
197                        }
198                        _ => {}
199                    }
200                }
201                parts.join("\n")
202            }
203            MessageContent::Other(_) => String::new(),
204        }
205    }
206
207    /// Returns true if this is a tool_result message (not a user prompt).
208    pub fn is_tool_result(&self) -> bool {
209        matches!(self, MessageContent::Blocks(blocks) if blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. })))
210    }
211
212    /// Returns true if this content is an unknown format we can't parse.
213    pub fn is_unknown(&self) -> bool {
214        matches!(self, MessageContent::Other(_))
215    }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(tag = "type")]
220pub enum ContentBlock {
221    #[serde(rename = "text")]
222    Text { text: String },
223    #[serde(rename = "thinking")]
224    Thinking {
225        thinking: String,
226        #[serde(default)]
227        signature: Option<String>,
228    },
229    #[serde(rename = "tool_use")]
230    ToolUse {
231        id: String,
232        name: String,
233        #[serde(default)]
234        input: serde_json::Value,
235    },
236    #[serde(rename = "tool_result")]
237    ToolResult {
238        tool_use_id: String,
239        #[serde(default)]
240        content: Option<ToolResultContent>,
241    },
242    /// Catch-all for new block types from future Claude versions.
243    #[serde(other)]
244    Unknown,
245}
246
247/// Tool result content can be a string or an array of content blocks.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249#[serde(untagged)]
250pub enum ToolResultContent {
251    Text(String),
252    Blocks(Vec<serde_json::Value>),
253}
254
255impl ToolResultContent {
256    pub fn as_text(&self) -> String {
257        match self {
258            Self::Text(s) => s.clone(),
259            Self::Blocks(blocks) => {
260                blocks
261                    .iter()
262                    .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
263                    .collect::<Vec<_>>()
264                    .join("\n")
265            }
266        }
267    }
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct AssistantEntry {
273    pub uuid: String,
274    #[serde(default)]
275    pub parent_uuid: Option<String>,
276    #[serde(default)]
277    pub session_id: Option<String>,
278    #[serde(default)]
279    pub cwd: Option<String>,
280    #[serde(default)]
281    pub version: Option<String>,
282    #[serde(default)]
283    pub git_branch: Option<String>,
284    #[serde(default)]
285    pub timestamp: Option<String>,
286    pub message: AssistantMessage,
287    #[serde(default)]
288    pub is_sidechain: bool,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct AssistantMessage {
293    pub role: String,
294    #[serde(default)]
295    pub model: Option<String>,
296    #[serde(default)]
297    pub content: Vec<ContentBlock>,
298    #[serde(default)]
299    pub stop_reason: Option<String>,
300    #[serde(default)]
301    pub usage: Option<Usage>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct Usage {
306    #[serde(default)]
307    pub input_tokens: u64,
308    #[serde(default)]
309    pub output_tokens: u64,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct SummaryEntry {
315    #[serde(default)]
316    pub uuid: String,
317    #[serde(default)]
318    pub parent_uuid: Option<String>,
319    #[serde(default)]
320    pub session_id: Option<String>,
321    #[serde(default)]
322    pub timestamp: Option<String>,
323    #[serde(default)]
324    pub summary: Option<String>,
325    /// Summaries may also carry a message field.
326    #[serde(default)]
327    pub message: Option<serde_json::Value>,
328}
329
330// ── Parsed session (for analysis) ──
331
332/// A parsed and processed session ready for analysis.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct Session {
335    pub session_id: String,
336    pub project: String,
337    pub session_path: String,
338    pub user_messages: Vec<ParsedUserMessage>,
339    pub assistant_messages: Vec<ParsedAssistantMessage>,
340    pub summaries: Vec<String>,
341    pub tools_used: Vec<String>,
342    pub errors: Vec<String>,
343    pub metadata: SessionMetadata,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct ParsedUserMessage {
348    pub text: String,
349    pub timestamp: Option<String>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct ParsedAssistantMessage {
354    pub text: String,
355    pub thinking_summary: Option<String>,
356    pub tools: Vec<String>,
357    pub timestamp: Option<String>,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct SessionMetadata {
362    pub cwd: Option<String>,
363    pub version: Option<String>,
364    pub git_branch: Option<String>,
365    pub model: Option<String>,
366}
367
368// ── History entry ──
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase")]
372pub struct HistoryEntry {
373    #[serde(default)]
374    pub display: Option<String>,
375    #[serde(default)]
376    pub timestamp: Option<u64>,
377    #[serde(default)]
378    pub project: Option<String>,
379    #[serde(default)]
380    pub session_id: Option<String>,
381}
382
383// ── Context snapshot ──
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct PluginSkillSummary {
387    pub plugin_name: String,
388    pub skill_name: String,
389    pub description: String,
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct ContextSnapshot {
394    pub claude_md: Option<String>,
395    pub skills: Vec<SkillFile>,
396    pub memory_md: Option<String>,
397    pub global_agents: Vec<AgentFile>,
398    #[serde(default)]
399    pub plugin_skills: Vec<PluginSkillSummary>,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct SkillFile {
404    pub path: String,
405    pub content: String,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct AgentFile {
410    pub path: String,
411    pub content: String,
412}
413
414// ── Ingestion tracking ──
415
416#[derive(Debug, Clone)]
417pub struct IngestedSession {
418    pub session_id: String,
419    pub project: String,
420    pub session_path: String,
421    pub file_size: u64,
422    pub file_mtime: String,
423    pub ingested_at: DateTime<Utc>,
424}
425
426// ── Analysis types ──
427
428/// AI response: either a new pattern or an update to an existing one.
429#[derive(Debug, Clone, Serialize, Deserialize)]
430#[serde(tag = "action")]
431pub enum PatternUpdate {
432    #[serde(rename = "new")]
433    New(NewPattern),
434    #[serde(rename = "update")]
435    Update(UpdateExisting),
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct NewPattern {
440    pub pattern_type: PatternType,
441    #[serde(deserialize_with = "null_to_empty")]
442    pub description: String,
443    pub confidence: f64,
444    #[serde(default)]
445    pub source_sessions: Vec<String>,
446    #[serde(default)]
447    pub related_files: Vec<String>,
448    #[serde(default, deserialize_with = "null_to_empty")]
449    pub suggested_content: String,
450    pub suggested_target: SuggestedTarget,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct UpdateExisting {
455    #[serde(deserialize_with = "null_to_empty")]
456    pub existing_id: String,
457    #[serde(default)]
458    pub new_sessions: Vec<String>,
459    pub new_confidence: f64,
460}
461
462/// Top-level AI response wrapper.
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct AnalysisResponse {
465    #[serde(default)]
466    pub reasoning: String,
467    pub patterns: Vec<PatternUpdate>,
468}
469
470/// Claude CLI --output-format json wrapper.
471#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct ClaudeCliOutput {
473    #[serde(default)]
474    pub result: Option<String>,
475    #[serde(default)]
476    pub is_error: bool,
477    #[serde(default)]
478    pub duration_ms: u64,
479    #[serde(default)]
480    pub num_turns: u64,
481    #[serde(default)]
482    pub stop_reason: Option<String>,
483    #[serde(default)]
484    pub session_id: Option<String>,
485    #[serde(default)]
486    pub usage: Option<CliUsage>,
487    /// When `--json-schema` is used, the structured output appears here
488    /// as a parsed JSON value rather than in `result`.
489    #[serde(default)]
490    pub structured_output: Option<serde_json::Value>,
491}
492
493/// Token usage from Claude CLI output (nested inside `usage` field).
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct CliUsage {
496    #[serde(default)]
497    pub input_tokens: u64,
498    #[serde(default)]
499    pub output_tokens: u64,
500    #[serde(default)]
501    pub cache_creation_input_tokens: u64,
502    #[serde(default)]
503    pub cache_read_input_tokens: u64,
504}
505
506impl ClaudeCliOutput {
507    /// Total input tokens (direct + cache creation + cache read).
508    pub fn total_input_tokens(&self) -> u64 {
509        self.usage.as_ref().map_or(0, |u| {
510            u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens
511        })
512    }
513
514    /// Total output tokens.
515    pub fn total_output_tokens(&self) -> u64 {
516        self.usage.as_ref().map_or(0, |u| u.output_tokens)
517    }
518}
519
520/// Audit log entry.
521#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct AuditEntry {
523    pub timestamp: DateTime<Utc>,
524    pub action: String,
525    pub details: serde_json::Value,
526}
527
528/// Per-batch analysis detail for diagnostics.
529#[derive(Debug, Clone)]
530pub struct BatchDetail {
531    pub batch_index: usize,
532    pub session_count: usize,
533    pub session_ids: Vec<String>,
534    pub prompt_chars: usize,
535    pub input_tokens: u64,
536    pub output_tokens: u64,
537    pub new_patterns: usize,
538    pub updated_patterns: usize,
539    pub reasoning: String,
540    pub ai_response_preview: String,
541}
542
543/// Result of an analysis run.
544#[derive(Debug, Clone)]
545pub struct AnalyzeResult {
546    pub sessions_analyzed: usize,
547    pub new_patterns: usize,
548    pub updated_patterns: usize,
549    pub total_patterns: usize,
550    pub input_tokens: u64,
551    pub output_tokens: u64,
552    pub batch_details: Vec<BatchDetail>,
553}
554
555/// Compact session format for serialization to AI prompts.
556#[derive(Debug, Clone, Serialize)]
557pub struct CompactSession {
558    pub session_id: String,
559    pub project: String,
560    pub user_messages: Vec<CompactUserMessage>,
561    pub tools_used: Vec<String>,
562    pub errors: Vec<String>,
563    pub thinking_highlights: Vec<String>,
564    pub summaries: Vec<String>,
565}
566
567#[derive(Debug, Clone, Serialize)]
568pub struct CompactUserMessage {
569    pub text: String,
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub timestamp: Option<String>,
572}
573
574/// Compact pattern format for inclusion in AI prompts.
575#[derive(Debug, Clone, Serialize)]
576pub struct CompactPattern {
577    pub id: String,
578    pub pattern_type: String,
579    pub description: String,
580    pub confidence: f64,
581    pub times_seen: i64,
582    pub suggested_target: String,
583}
584
585// ── Projection types ──
586
587/// A projection record — tracks what was generated and where it was applied.
588#[derive(Debug, Clone, Serialize, Deserialize)]
589pub struct Projection {
590    pub id: String,
591    pub pattern_id: String,
592    pub target_type: String,
593    pub target_path: String,
594    pub content: String,
595    pub applied_at: DateTime<Utc>,
596    pub pr_url: Option<String>,
597    pub status: ProjectionStatus,
598}
599
600/// A generated skill draft (output of AI skill generation).
601#[derive(Debug, Clone)]
602pub struct SkillDraft {
603    pub name: String,
604    pub content: String,
605    pub pattern_id: String,
606}
607
608/// Skill validation result from AI.
609#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct SkillValidation {
611    pub valid: bool,
612    #[serde(default)]
613    pub feedback: String,
614}
615
616/// A generated global agent draft.
617#[derive(Debug, Clone)]
618pub struct AgentDraft {
619    pub name: String,
620    pub content: String,
621    pub pattern_id: String,
622}
623
624/// A planned action for `retro apply`.
625#[derive(Debug, Clone)]
626pub struct ApplyAction {
627    pub pattern_id: String,
628    pub pattern_description: String,
629    pub target_type: SuggestedTarget,
630    pub target_path: String,
631    pub content: String,
632    pub track: ApplyTrack,
633}
634
635/// Status of a projection in the review queue.
636#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
637#[serde(rename_all = "snake_case")]
638pub enum ProjectionStatus {
639    PendingReview,
640    Applied,
641    Dismissed,
642}
643
644impl std::fmt::Display for ProjectionStatus {
645    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
646        match self {
647            Self::PendingReview => write!(f, "pending_review"),
648            Self::Applied => write!(f, "applied"),
649            Self::Dismissed => write!(f, "dismissed"),
650        }
651    }
652}
653
654impl ProjectionStatus {
655    pub fn from_str(s: &str) -> Option<Self> {
656        match s {
657            "pending_review" => Some(Self::PendingReview),
658            "applied" => Some(Self::Applied),
659            "dismissed" => Some(Self::Dismissed),
660            _ => None,
661        }
662    }
663}
664
665/// Whether a change is auto-applied (personal) or needs a PR (shared).
666#[derive(Debug, Clone, PartialEq)]
667pub enum ApplyTrack {
668    /// Auto-apply: global agents
669    Personal,
670    /// Needs PR (Phase 4): skills, CLAUDE.md rules
671    Shared,
672}
673
674impl std::fmt::Display for ApplyTrack {
675    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676        match self {
677            Self::Personal => write!(f, "personal"),
678            Self::Shared => write!(f, "shared"),
679        }
680    }
681}
682
683/// The full apply plan — all actions to take.
684#[derive(Debug, Clone)]
685pub struct ApplyPlan {
686    pub actions: Vec<ApplyAction>,
687}
688
689impl ApplyPlan {
690    pub fn is_empty(&self) -> bool {
691        self.actions.is_empty()
692    }
693
694    pub fn personal_actions(&self) -> Vec<&ApplyAction> {
695        self.actions.iter().filter(|a| a.track == ApplyTrack::Personal).collect()
696    }
697
698    pub fn shared_actions(&self) -> Vec<&ApplyAction> {
699        self.actions.iter().filter(|a| a.track == ApplyTrack::Shared).collect()
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706
707    #[test]
708    fn test_projection_status_display() {
709        assert_eq!(ProjectionStatus::PendingReview.to_string(), "pending_review");
710        assert_eq!(ProjectionStatus::Applied.to_string(), "applied");
711        assert_eq!(ProjectionStatus::Dismissed.to_string(), "dismissed");
712    }
713
714    #[test]
715    fn test_projection_status_from_str() {
716        assert_eq!(ProjectionStatus::from_str("pending_review"), Some(ProjectionStatus::PendingReview));
717        assert_eq!(ProjectionStatus::from_str("applied"), Some(ProjectionStatus::Applied));
718        assert_eq!(ProjectionStatus::from_str("dismissed"), Some(ProjectionStatus::Dismissed));
719        assert_eq!(ProjectionStatus::from_str("unknown"), None);
720    }
721}