Skip to main content

mur_common/
conversation.rs

1//! Shared conversation archive types (used by mur-core, mur-commander).
2//!
3//! JSONL row format version 1. See
4//! `docs/superpowers/specs/2026-04-19-mur-conversations-design.md` §4.1.
5//!
6//! # Schema versioning
7//!
8//! Every `Message` carries `v: u32` (current: [`CONVERSATION_SCHEMA_VERSION`]).
9//! This is intentional — schema evolution for a durable archive must be explicit.
10//!
11//! ## When to bump
12//! Bump `CONVERSATION_SCHEMA_VERSION` ONLY when:
13//!   1. A required field is renamed or removed, OR
14//!   2. A field's semantic meaning changes (e.g., `ts` interpretation shifts
15//!      from Utc to local), OR
16//!   3. `Content` gains a variant that older deserializers cannot safely
17//!      ignore.
18//!
19//! Adding a new optional field with `#[serde(default)]` does NOT require a bump.
20//!
21//! ## How to bump
22//!   1. Add a schema migrator in `mur-core/src/conversations/migrate_schema.rs`
23//!      that takes any JSON row and rewrites to the new schema.
24//!   2. Wire it into the store's append/read paths so older lines in the same
25//!      file still deserialize (via serde `untagged` or custom visitor).
26//!   3. Keep the previous version's deserializer functional for at least one
27//!      minor release.
28//!   4. Bump `CONVERSATION_SCHEMA_VERSION`.
29//!
30//! ## Backward reads
31//! `mur-core::conversations::store::read_day` must always migrate legacy rows
32//! before constructing a `Message` so older on-disk JSONL still works.
33
34use chrono::{DateTime, Utc};
35use serde::{Deserialize, Serialize};
36
37/// Current conversations JSONL schema version. Bump only per the rules in this
38/// module's top-level doc comment.
39pub const CONVERSATION_SCHEMA_VERSION: u32 = 1;
40
41/// One line in `~/.mur/conversations/raw/<date>/*.jsonl`.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Message {
44    /// Schema version; breaking changes bump this.
45    pub v: u32,
46    pub ts: DateTime<Utc>,
47    pub src: Source,
48    /// Conversation id; scopes messages within a source.
49    pub conv: String,
50    pub role: Role,
51    pub content: Content,
52    #[serde(default)]
53    pub meta: serde_json::Value,
54    /// Named-Abstraction references into patterns/ (Freedman 2026).
55    #[serde(default)]
56    pub refs: Vec<String>,
57}
58
59#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
60#[serde(rename_all = "kebab-case")]
61pub enum Source {
62    ClaudeCode,
63    Cursor,
64    Gemini,
65    Aider,
66    Slack,
67    Telegram,
68    Discord,
69    CommanderEngine,
70}
71
72impl Source {
73    /// Canonical short prefix used in filename `<src>_<id>.jsonl`.
74    pub fn file_prefix(&self) -> &'static str {
75        match self {
76            Source::ClaudeCode => "cc",
77            Source::Cursor => "cursor",
78            Source::Gemini => "gemini",
79            Source::Aider => "aider",
80            Source::Slack => "slack",
81            Source::Telegram => "telegram",
82            Source::Discord => "discord",
83            Source::CommanderEngine => "commander",
84        }
85    }
86
87    /// Inverse of `file_prefix()`. Returns None on unknown prefix.
88    /// Case-sensitive by design — prefixes are a closed set.
89    pub fn from_prefix(s: &str) -> Option<Self> {
90        match s {
91            "cc" => Some(Source::ClaudeCode),
92            "cursor" => Some(Source::Cursor),
93            "gemini" => Some(Source::Gemini),
94            "aider" => Some(Source::Aider),
95            "slack" => Some(Source::Slack),
96            "telegram" => Some(Source::Telegram),
97            "discord" => Some(Source::Discord),
98            "commander" => Some(Source::CommanderEngine),
99            _ => None,
100        }
101    }
102}
103
104#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "lowercase")]
106pub enum Role {
107    User,
108    Assistant,
109    System,
110    Tool,
111}
112
113/// Message body. `ToolRef` and `ImageRef` are content-addressed pointers
114/// (spec §4.3 pointer substitution) to blobs stored under
115/// `~/.mur/conversations/blob/<sha256>`.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(tag = "t", rename_all = "snake_case")]
118pub enum Content {
119    Text {
120        #[serde(rename = "v")]
121        value: String,
122    },
123    ToolRef {
124        sha256: String,
125        path: String,
126        bytes: u64,
127        #[serde(default)]
128        desc: String,
129    },
130    ImageRef {
131        sha256: String,
132        path: String,
133        #[serde(default)]
134        desc: String,
135    },
136}
137
138impl Content {
139    /// Convenience constructor for `Content::Text`.
140    pub fn text(s: impl Into<String>) -> Self {
141        Content::Text { value: s.into() }
142    }
143
144    /// Plain-text projection for indexing/search. For pointer variants
145    /// (`ToolRef`/`ImageRef`) returns the `desc` field, which may be an
146    /// empty string if the producer didn't supply one — callers doing
147    /// keyword search should treat empty results as "no indexable text"
148    /// rather than treating them as matches.
149    pub fn as_text(&self) -> &str {
150        match self {
151            Content::Text { value } => value,
152            Content::ToolRef { desc, .. } | Content::ImageRef { desc, .. } => desc,
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use chrono::TimeZone;
161
162    #[test]
163    fn message_text_roundtrip() {
164        let m = Message {
165            v: 1,
166            ts: chrono::Utc
167                .with_ymd_and_hms(2026, 4, 19, 11, 30, 45)
168                .unwrap(),
169            src: Source::ClaudeCode,
170            conv: "3a8786a0".into(),
171            role: Role::User,
172            content: Content::text("hello"),
173            meta: serde_json::json!({"project": "mur"}),
174            refs: vec!["pattern:atomic-yaml-write".into()],
175        };
176        let line = serde_json::to_string(&m).unwrap();
177        let back: Message = serde_json::from_str(&line).unwrap();
178        assert_eq!(back.conv, "3a8786a0");
179        assert!(matches!(back.content, Content::Text { ref value } if value == "hello"));
180        assert!(matches!(back.src, Source::ClaudeCode));
181        assert_eq!(back.refs, vec!["pattern:atomic-yaml-write".to_string()]);
182    }
183
184    #[test]
185    fn message_tool_ref_roundtrip() {
186        let m = Message {
187            v: 1,
188            ts: chrono::Utc
189                .with_ymd_and_hms(2026, 4, 19, 11, 30, 45)
190                .unwrap(),
191            src: Source::ClaudeCode,
192            conv: "x".into(),
193            role: Role::Tool,
194            content: Content::ToolRef {
195                sha256: "abc".into(),
196                path: "src/main.rs".into(),
197                bytes: 1234,
198                desc: "read main.rs".into(),
199            },
200            meta: serde_json::Value::Null,
201            refs: vec![],
202        };
203        let line = serde_json::to_string(&m).unwrap();
204        let back: Message = serde_json::from_str(&line).unwrap();
205        assert!(matches!(back.content, Content::ToolRef { ref sha256, .. } if sha256 == "abc"));
206    }
207
208    #[test]
209    fn message_image_ref_roundtrip() {
210        let m = Message {
211            v: 1,
212            ts: chrono::Utc
213                .with_ymd_and_hms(2026, 4, 19, 11, 30, 45)
214                .unwrap(),
215            src: Source::Cursor,
216            conv: "x".into(),
217            role: Role::Tool,
218            content: Content::ImageRef {
219                sha256: "def".into(),
220                path: "attachments/diagram.png".into(),
221                desc: "architecture diagram".into(),
222            },
223            meta: serde_json::Value::Null,
224            refs: vec![],
225        };
226        let line = serde_json::to_string(&m).unwrap();
227        assert!(line.contains("\"t\":\"image_ref\""));
228        let back: Message = serde_json::from_str(&line).unwrap();
229        assert!(matches!(back.content, Content::ImageRef { ref sha256, .. } if sha256 == "def"));
230    }
231
232    #[test]
233    fn source_file_prefix_is_stable() {
234        assert_eq!(Source::ClaudeCode.file_prefix(), "cc");
235        assert_eq!(Source::Cursor.file_prefix(), "cursor");
236        assert_eq!(Source::Gemini.file_prefix(), "gemini");
237        assert_eq!(Source::Aider.file_prefix(), "aider");
238        assert_eq!(Source::Slack.file_prefix(), "slack");
239        assert_eq!(Source::Telegram.file_prefix(), "telegram");
240        assert_eq!(Source::Discord.file_prefix(), "discord");
241        assert_eq!(Source::CommanderEngine.file_prefix(), "commander");
242    }
243
244    #[test]
245    fn message_deserializes_with_meta_and_refs_absent() {
246        // Spec §12 forward-compat guarantee: older rows lacking meta/refs must
247        // still deserialize. Both keys are completely absent from the input.
248        let minimal = r#"{"v":1,"ts":"2026-04-19T11:30:45Z","src":"aider","conv":"c","role":"user","content":{"t":"text","v":"hi"}}"#;
249        let m: Message = serde_json::from_str(minimal).unwrap();
250        assert!(m.meta.is_null());
251        assert!(m.refs.is_empty());
252    }
253
254    #[test]
255    fn source_has_ord_and_hash_for_use_in_collections() {
256        use std::collections::{BTreeSet, HashSet};
257        let set_b: BTreeSet<Source> = [Source::ClaudeCode, Source::Cursor, Source::ClaudeCode]
258            .into_iter()
259            .collect();
260        assert_eq!(set_b.len(), 2);
261        let set_h: HashSet<Source> = [Source::Slack, Source::Slack, Source::Telegram]
262            .into_iter()
263            .collect();
264        assert_eq!(set_h.len(), 2);
265    }
266
267    #[test]
268    fn commander_turn_is_subset() {
269        // Commander's existing ConversationTurn { timestamp: i64, role: String, text: String }
270        // MUST deserialize when reshaped into the new Message format.
271        let commander_json = r#"{"v":1,"ts":"2026-04-19T11:30:45Z","src":"slack","conv":"c","role":"user","content":{"t":"text","v":"hi"},"meta":{},"refs":[]}"#;
272        let _m: Message = serde_json::from_str(commander_json).unwrap();
273    }
274
275    #[test]
276    fn source_from_prefix_roundtrips_all_known() {
277        for src in [
278            Source::ClaudeCode,
279            Source::Cursor,
280            Source::Gemini,
281            Source::Aider,
282            Source::Slack,
283            Source::Telegram,
284            Source::Discord,
285            Source::CommanderEngine,
286        ] {
287            let p = src.file_prefix();
288            assert_eq!(Source::from_prefix(p), Some(src));
289        }
290    }
291
292    #[test]
293    fn source_from_prefix_unknown_is_none() {
294        assert_eq!(Source::from_prefix("bogus"), None);
295        assert_eq!(Source::from_prefix(""), None);
296        assert_eq!(Source::from_prefix("CC"), None); // case-sensitive
297    }
298}