1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Deserializer, Serialize};
3
4fn 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#[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#[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 Other(serde_json::Value),
181}
182
183impl MessageContent {
184 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 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 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 #[serde(other)]
244 Unknown,
245}
246
247#[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 #[serde(default)]
327 pub message: Option<serde_json::Value>,
328}
329
330#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct AnalysisResponse {
465 #[serde(default)]
466 pub reasoning: String,
467 pub patterns: Vec<PatternUpdate>,
468 #[serde(default)]
469 pub claude_md_edits: Vec<ClaudeMdEdit>,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
475#[serde(rename_all = "snake_case")]
476pub enum ClaudeMdEditType {
477 Add,
478 Remove,
479 Reword,
480 Move,
481}
482
483impl std::fmt::Display for ClaudeMdEditType {
484 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485 match self {
486 Self::Add => write!(f, "add"),
487 Self::Remove => write!(f, "remove"),
488 Self::Reword => write!(f, "reword"),
489 Self::Move => write!(f, "move"),
490 }
491 }
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct ClaudeMdEdit {
497 pub edit_type: ClaudeMdEditType,
498 #[serde(default)]
499 pub original_text: String,
500 #[serde(default)]
501 pub suggested_content: Option<String>,
502 #[serde(default)]
503 pub target_section: Option<String>,
504 #[serde(default)]
505 pub reasoning: String,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct ClaudeCliOutput {
511 #[serde(default)]
512 pub result: Option<String>,
513 #[serde(default)]
514 pub is_error: bool,
515 #[serde(default)]
516 pub duration_ms: u64,
517 #[serde(default)]
518 pub num_turns: u64,
519 #[serde(default)]
520 pub stop_reason: Option<String>,
521 #[serde(default)]
522 pub session_id: Option<String>,
523 #[serde(default)]
524 pub usage: Option<CliUsage>,
525 #[serde(default)]
528 pub structured_output: Option<serde_json::Value>,
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize)]
533pub struct CliUsage {
534 #[serde(default)]
535 pub input_tokens: u64,
536 #[serde(default)]
537 pub output_tokens: u64,
538 #[serde(default)]
539 pub cache_creation_input_tokens: u64,
540 #[serde(default)]
541 pub cache_read_input_tokens: u64,
542}
543
544impl ClaudeCliOutput {
545 pub fn total_input_tokens(&self) -> u64 {
547 self.usage.as_ref().map_or(0, |u| {
548 u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens
549 })
550 }
551
552 pub fn total_output_tokens(&self) -> u64 {
554 self.usage.as_ref().map_or(0, |u| u.output_tokens)
555 }
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct AuditEntry {
561 pub timestamp: DateTime<Utc>,
562 pub action: String,
563 pub details: serde_json::Value,
564}
565
566#[derive(Debug, Clone)]
568pub struct BatchDetail {
569 pub batch_index: usize,
570 pub session_count: usize,
571 pub session_ids: Vec<String>,
572 pub prompt_chars: usize,
573 pub input_tokens: u64,
574 pub output_tokens: u64,
575 pub new_patterns: usize,
576 pub updated_patterns: usize,
577 pub reasoning: String,
578 pub ai_response_preview: String,
579}
580
581#[derive(Debug, Clone)]
583pub struct AnalyzeResult {
584 pub sessions_analyzed: usize,
585 pub new_patterns: usize,
586 pub updated_patterns: usize,
587 pub total_patterns: usize,
588 pub input_tokens: u64,
589 pub output_tokens: u64,
590 pub batch_details: Vec<BatchDetail>,
591}
592
593#[derive(Debug, Clone, Serialize)]
595pub struct CompactSession {
596 pub session_id: String,
597 pub project: String,
598 pub user_messages: Vec<CompactUserMessage>,
599 pub tools_used: Vec<String>,
600 pub errors: Vec<String>,
601 pub thinking_highlights: Vec<String>,
602 pub summaries: Vec<String>,
603}
604
605#[derive(Debug, Clone, Serialize)]
606pub struct CompactUserMessage {
607 pub text: String,
608 #[serde(skip_serializing_if = "Option::is_none")]
609 pub timestamp: Option<String>,
610}
611
612#[derive(Debug, Clone, Serialize)]
614pub struct CompactPattern {
615 pub id: String,
616 pub pattern_type: String,
617 pub description: String,
618 pub confidence: f64,
619 pub times_seen: i64,
620 pub suggested_target: String,
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize)]
627pub struct Projection {
628 pub id: String,
629 pub pattern_id: String,
630 pub target_type: String,
631 pub target_path: String,
632 pub content: String,
633 pub applied_at: DateTime<Utc>,
634 pub pr_url: Option<String>,
635 pub status: ProjectionStatus,
636}
637
638#[derive(Debug, Clone)]
640pub struct SkillDraft {
641 pub name: String,
642 pub content: String,
643 pub pattern_id: String,
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize)]
648pub struct SkillValidation {
649 pub valid: bool,
650 #[serde(default)]
651 pub feedback: String,
652}
653
654#[derive(Debug, Clone)]
656pub struct AgentDraft {
657 pub name: String,
658 pub content: String,
659 pub pattern_id: String,
660}
661
662#[derive(Debug, Clone)]
664pub struct ApplyAction {
665 pub pattern_id: String,
666 pub pattern_description: String,
667 pub target_type: SuggestedTarget,
668 pub target_path: String,
669 pub content: String,
670 pub track: ApplyTrack,
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
675#[serde(rename_all = "snake_case")]
676pub enum ProjectionStatus {
677 PendingReview,
678 Applied,
679 Dismissed,
680}
681
682impl std::fmt::Display for ProjectionStatus {
683 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
684 match self {
685 Self::PendingReview => write!(f, "pending_review"),
686 Self::Applied => write!(f, "applied"),
687 Self::Dismissed => write!(f, "dismissed"),
688 }
689 }
690}
691
692impl ProjectionStatus {
693 pub fn from_str(s: &str) -> Option<Self> {
694 match s {
695 "pending_review" => Some(Self::PendingReview),
696 "applied" => Some(Self::Applied),
697 "dismissed" => Some(Self::Dismissed),
698 _ => None,
699 }
700 }
701}
702
703#[derive(Debug, Clone, PartialEq)]
705pub enum ApplyTrack {
706 Personal,
708 Shared,
710}
711
712impl std::fmt::Display for ApplyTrack {
713 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
714 match self {
715 Self::Personal => write!(f, "personal"),
716 Self::Shared => write!(f, "shared"),
717 }
718 }
719}
720
721#[derive(Debug, Clone)]
723pub struct ApplyPlan {
724 pub actions: Vec<ApplyAction>,
725}
726
727impl ApplyPlan {
728 pub fn is_empty(&self) -> bool {
729 self.actions.is_empty()
730 }
731
732 pub fn personal_actions(&self) -> Vec<&ApplyAction> {
733 self.actions.iter().filter(|a| a.track == ApplyTrack::Personal).collect()
734 }
735
736 pub fn shared_actions(&self) -> Vec<&ApplyAction> {
737 self.actions.iter().filter(|a| a.track == ApplyTrack::Shared).collect()
738 }
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 #[test]
746 fn test_projection_status_display() {
747 assert_eq!(ProjectionStatus::PendingReview.to_string(), "pending_review");
748 assert_eq!(ProjectionStatus::Applied.to_string(), "applied");
749 assert_eq!(ProjectionStatus::Dismissed.to_string(), "dismissed");
750 }
751
752 #[test]
753 fn test_projection_status_from_str() {
754 assert_eq!(ProjectionStatus::from_str("pending_review"), Some(ProjectionStatus::PendingReview));
755 assert_eq!(ProjectionStatus::from_str("applied"), Some(ProjectionStatus::Applied));
756 assert_eq!(ProjectionStatus::from_str("dismissed"), Some(ProjectionStatus::Dismissed));
757 assert_eq!(ProjectionStatus::from_str("unknown"), None);
758 }
759
760 #[test]
761 fn test_claude_md_edit_type_serde() {
762 let edit = ClaudeMdEdit {
763 edit_type: ClaudeMdEditType::Reword,
764 original_text: "No async".to_string(),
765 suggested_content: Some("Sync only — no tokio, no async".to_string()),
766 target_section: None,
767 reasoning: "Too terse".to_string(),
768 };
769 let json = serde_json::to_string(&edit).unwrap();
770 let parsed: ClaudeMdEdit = serde_json::from_str(&json).unwrap();
771 assert_eq!(parsed.edit_type, ClaudeMdEditType::Reword);
772 assert_eq!(parsed.original_text, "No async");
773 assert_eq!(parsed.suggested_content.unwrap(), "Sync only — no tokio, no async");
774 }
775
776 #[test]
777 fn test_claude_md_edit_type_display() {
778 assert_eq!(ClaudeMdEditType::Add.to_string(), "add");
779 assert_eq!(ClaudeMdEditType::Remove.to_string(), "remove");
780 assert_eq!(ClaudeMdEditType::Reword.to_string(), "reword");
781 assert_eq!(ClaudeMdEditType::Move.to_string(), "move");
782 }
783
784 #[test]
785 fn test_analysis_response_with_edits() {
786 let json = r#"{
787 "reasoning": "test",
788 "patterns": [],
789 "claude_md_edits": [
790 {
791 "edit_type": "remove",
792 "original_text": "stale rule",
793 "reasoning": "no longer relevant"
794 }
795 ]
796 }"#;
797 let resp: AnalysisResponse = serde_json::from_str(json).unwrap();
798 assert_eq!(resp.claude_md_edits.len(), 1);
799 assert_eq!(resp.claude_md_edits[0].edit_type, ClaudeMdEditType::Remove);
800 }
801
802 #[test]
803 fn test_analysis_response_without_edits() {
804 let json = r#"{"reasoning": "test", "patterns": []}"#;
805 let resp: AnalysisResponse = serde_json::from_str(json).unwrap();
806 assert!(resp.claude_md_edits.is_empty());
807 }
808}