Skip to main content

mag/memory_core/
domain.rs

1use std::fmt;
2use std::str::FromStr;
3
4use anyhow::Result;
5use serde::{Deserialize, Serialize};
6
7pub const TTL_EPHEMERAL: i64 = 3600;
8pub const TTL_SHORT_TERM: i64 = 86_400;
9pub const TTL_LONG_TERM: i64 = 1_209_600;
10
11// ── Relationship type constants ──────────────────────────────────────────
12/// Temporal adjacency: the source memory was stored immediately before the target
13/// within the same session.
14pub const REL_PRECEDED_BY: &str = "PRECEDED_BY";
15/// Entity co-occurrence: two memories share an entity tag.
16pub const REL_RELATES_TO: &str = "RELATES_TO";
17/// Semantic similarity detected at store time (auto-relate).
18pub const REL_RELATED: &str = "related";
19/// Alternate semantic-similarity labels used in graph scoring.
20pub const REL_SIMILAR_TO: &str = "SIMILAR_TO";
21pub const REL_SHARES_THEME: &str = "SHARES_THEME";
22pub const REL_PARALLEL_CONTEXT: &str = "PARALLEL_CONTEXT";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum MemoryKind {
26    Episodic,
27    Semantic,
28}
29
30/// Strongly-typed event type for memories.
31///
32/// Serializes to/from its snake_case string representation for SQLite TEXT
33/// column backward compatibility and MCP JSON protocol compatibility.
34/// The `Unknown(String)` variant provides forward compatibility for
35/// event types not yet defined in the enum.
36#[derive(Debug, Clone, PartialEq, Eq, Hash)]
37pub enum EventType {
38    SessionSummary,
39    TaskCompletion,
40    ErrorPattern,
41    LessonLearned,
42    Decision,
43    BlockedContext,
44    UserPreference,
45    UserFact,
46    AdvisorInsight,
47    GitCommit,
48    GitMerge,
49    GitConflict,
50    SessionStart,
51    SessionEnd,
52    ContextWarning,
53    BudgetAlert,
54    CoordinationSnapshot,
55    Checkpoint,
56    Reminder,
57    Memory,
58    CodeChunk,
59    FileSummary,
60    /// Forward-compatibility variant for unknown event types.
61    Unknown(String),
62}
63
64impl EventType {
65    /// Returns `true` if this is a known (non-Unknown) event type.
66    pub fn is_valid(&self) -> bool {
67        !matches!(self, EventType::Unknown(_))
68    }
69
70    /// Returns the memory kind for this event type.
71    pub fn memory_kind(&self) -> MemoryKind {
72        match self {
73            EventType::ErrorPattern
74            | EventType::LessonLearned
75            | EventType::UserPreference
76            | EventType::GitConflict
77            | EventType::Reminder
78            | EventType::Decision => MemoryKind::Semantic,
79            EventType::Unknown(_) => MemoryKind::Episodic,
80            _ => MemoryKind::Episodic,
81        }
82    }
83
84    /// Returns the default priority for this event type.
85    pub fn default_priority(&self) -> i32 {
86        match self {
87            EventType::ErrorPattern
88            | EventType::LessonLearned
89            | EventType::UserPreference
90            | EventType::GitConflict => 4,
91            EventType::Decision | EventType::TaskCompletion | EventType::AdvisorInsight => 3,
92            EventType::GitCommit
93            | EventType::GitMerge
94            | EventType::SessionEnd
95            | EventType::BudgetAlert => 2,
96            EventType::SessionSummary
97            | EventType::SessionStart
98            | EventType::ContextWarning
99            | EventType::CoordinationSnapshot => 1,
100            EventType::BlockedContext
101            | EventType::Checkpoint
102            | EventType::Reminder
103            | EventType::Memory
104            | EventType::FileSummary => 1,
105            EventType::CodeChunk => 0,
106            EventType::UserFact => 4,
107            EventType::Unknown(_) => 0,
108        }
109    }
110
111    /// Returns the default TTL for this event type.
112    pub fn default_ttl(&self) -> Option<i64> {
113        match self {
114            EventType::SessionSummary => Some(TTL_EPHEMERAL),
115            EventType::TaskCompletion => Some(TTL_LONG_TERM),
116            EventType::ErrorPattern => None,
117            EventType::LessonLearned => None,
118            EventType::Decision => Some(TTL_LONG_TERM),
119            EventType::BlockedContext => Some(TTL_SHORT_TERM),
120            EventType::UserPreference => None,
121            EventType::UserFact => None,
122            EventType::AdvisorInsight => Some(TTL_LONG_TERM),
123            EventType::GitCommit => Some(TTL_LONG_TERM),
124            EventType::GitMerge => Some(TTL_LONG_TERM),
125            EventType::GitConflict => None,
126            EventType::SessionStart => Some(TTL_SHORT_TERM),
127            EventType::SessionEnd => Some(TTL_LONG_TERM),
128            EventType::ContextWarning => Some(TTL_SHORT_TERM),
129            EventType::BudgetAlert => Some(TTL_LONG_TERM),
130            EventType::CoordinationSnapshot => Some(TTL_SHORT_TERM),
131            EventType::Checkpoint => Some(604_800),
132            EventType::Reminder => None,
133            EventType::Memory => Some(TTL_SHORT_TERM),
134            EventType::CodeChunk => Some(TTL_EPHEMERAL),
135            EventType::FileSummary => Some(TTL_SHORT_TERM),
136            EventType::Unknown(_) => Some(TTL_LONG_TERM),
137        }
138    }
139
140    /// Returns the type weight for search scoring.
141    pub fn type_weight(&self) -> f64 {
142        match self {
143            EventType::Checkpoint => 2.5,
144            EventType::Reminder => 3.0,
145            EventType::Decision => 2.0,
146            EventType::LessonLearned => 2.0,
147            EventType::ErrorPattern => 2.0,
148            EventType::UserPreference => 2.0,
149            EventType::TaskCompletion => 1.4,
150            EventType::SessionSummary => 1.2,
151            EventType::BlockedContext => 1.0,
152            EventType::GitCommit => 1.0,
153            EventType::GitMerge => 1.0,
154            EventType::GitConflict => 1.0,
155            EventType::CoordinationSnapshot => 0.2,
156            EventType::Memory => 1.0,
157            _ => 1.0,
158        }
159    }
160
161    /// Returns the dedup threshold for this event type, if applicable.
162    pub fn dedup_threshold(&self) -> Option<f64> {
163        match self {
164            EventType::ErrorPattern => Some(0.70),
165            EventType::SessionSummary => Some(0.75),
166            EventType::TaskCompletion => Some(0.85),
167            EventType::Decision => Some(0.80),
168            EventType::LessonLearned => Some(0.85),
169            EventType::UserFact => Some(0.85),
170            _ => None,
171        }
172    }
173
174    /// Returns `true` if this event type supports auto-supersession.
175    pub fn is_supersession_type(&self) -> bool {
176        matches!(
177            self,
178            EventType::Decision
179                | EventType::LessonLearned
180                | EventType::UserPreference
181                | EventType::UserFact
182                | EventType::Reminder
183        )
184    }
185
186    /// Converts an optional string (from JSON/SQL) into `Option<EventType>`.
187    pub fn from_optional(s: Option<&str>) -> Option<EventType> {
188        s.map(|v| EventType::from_str(v).unwrap_or_else(|e| match e {}))
189    }
190
191    /// Returns all event types that have a dedup threshold.
192    pub fn types_with_dedup_threshold() -> Vec<EventType> {
193        // Keep in sync with dedup_threshold() match arms above.
194        vec![
195            EventType::ErrorPattern,
196            EventType::SessionSummary,
197            EventType::TaskCompletion,
198            EventType::Decision,
199            EventType::LessonLearned,
200            EventType::UserFact,
201        ]
202    }
203}
204
205impl fmt::Display for EventType {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        let s = match self {
208            EventType::SessionSummary => "session_summary",
209            EventType::TaskCompletion => "task_completion",
210            EventType::ErrorPattern => "error_pattern",
211            EventType::LessonLearned => "lesson_learned",
212            EventType::Decision => "decision",
213            EventType::BlockedContext => "blocked_context",
214            EventType::UserPreference => "user_preference",
215            EventType::UserFact => "user_fact",
216            EventType::AdvisorInsight => "advisor_insight",
217            EventType::GitCommit => "git_commit",
218            EventType::GitMerge => "git_merge",
219            EventType::GitConflict => "git_conflict",
220            EventType::SessionStart => "session_start",
221            EventType::SessionEnd => "session_end",
222            EventType::ContextWarning => "context_warning",
223            EventType::BudgetAlert => "budget_alert",
224            EventType::CoordinationSnapshot => "coordination_snapshot",
225            EventType::Checkpoint => "checkpoint",
226            EventType::Reminder => "reminder",
227            EventType::Memory => "memory",
228            EventType::CodeChunk => "code_chunk",
229            EventType::FileSummary => "file_summary",
230            EventType::Unknown(s) => s.as_str(),
231        };
232        write!(f, "{s}")
233    }
234}
235
236impl FromStr for EventType {
237    type Err = std::convert::Infallible;
238
239    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
240        Ok(match s {
241            "session_summary" => EventType::SessionSummary,
242            "task_completion" => EventType::TaskCompletion,
243            "error_pattern" => EventType::ErrorPattern,
244            "lesson_learned" => EventType::LessonLearned,
245            "decision" => EventType::Decision,
246            "blocked_context" => EventType::BlockedContext,
247            "user_preference" => EventType::UserPreference,
248            "user_fact" => EventType::UserFact,
249            "advisor_insight" => EventType::AdvisorInsight,
250            "git_commit" => EventType::GitCommit,
251            "git_merge" => EventType::GitMerge,
252            "git_conflict" => EventType::GitConflict,
253            "session_start" => EventType::SessionStart,
254            "session_end" => EventType::SessionEnd,
255            "context_warning" => EventType::ContextWarning,
256            "budget_alert" => EventType::BudgetAlert,
257            "coordination_snapshot" => EventType::CoordinationSnapshot,
258            "checkpoint" => EventType::Checkpoint,
259            "reminder" => EventType::Reminder,
260            "memory" => EventType::Memory,
261            "code_chunk" => EventType::CodeChunk,
262            "file_summary" => EventType::FileSummary,
263            other => EventType::Unknown(other.to_string()),
264        })
265    }
266}
267
268impl Serialize for EventType {
269    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
270    where
271        S: serde::Serializer,
272    {
273        serializer.serialize_str(&self.to_string())
274    }
275}
276
277impl<'de> Deserialize<'de> for EventType {
278    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
279    where
280        D: serde::Deserializer<'de>,
281    {
282        let s = String::deserialize(deserializer)?;
283        // FromStr is infallible for EventType
284        Ok(EventType::from_str(&s).unwrap_or_else(|e| match e {}))
285    }
286}
287
288impl schemars::JsonSchema for EventType {
289    fn schema_name() -> std::borrow::Cow<'static, str> {
290        std::borrow::Cow::Borrowed("EventType")
291    }
292
293    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
294        schemars::json_schema!({
295            "type": "string",
296            "description": "Memory event type",
297            "enum": [
298                "session_summary", "task_completion", "error_pattern", "lesson_learned",
299                "decision", "blocked_context", "user_preference", "user_fact",
300                "advisor_insight", "git_commit", "git_merge", "git_conflict",
301                "session_start", "session_end", "context_warning", "budget_alert",
302                "coordination_snapshot", "checkpoint", "reminder", "memory",
303                "code_chunk", "file_summary"
304            ]
305        })
306    }
307}
308
309#[derive(Debug, Clone)]
310pub struct MemoryInput {
311    pub content: String,
312    pub id: Option<String>,
313    pub tags: Vec<String>,
314    pub importance: f64,
315    pub metadata: serde_json::Value,
316    pub event_type: Option<EventType>,
317    pub session_id: Option<String>,
318    pub project: Option<String>,
319    pub priority: Option<i32>,
320    pub entity_id: Option<String>,
321    pub agent_type: Option<String>,
322    pub ttl_seconds: Option<i64>,
323    /// ISO 8601 timestamp for when the event actually occurred.
324    /// When provided, this overrides the default `event_at = now()` on insert.
325    pub referenced_date: Option<String>,
326    /// Source of the memory (e.g. "cli_input", "mcp", "import"). Defaults to "cli_input".
327    pub source_type: Option<String>,
328}
329
330impl Default for MemoryInput {
331    fn default() -> Self {
332        Self {
333            content: String::new(),
334            id: None,
335            tags: Vec::new(),
336            importance: 0.5,
337            metadata: serde_json::json!({}),
338            event_type: None,
339            session_id: None,
340            project: None,
341            priority: None,
342            entity_id: None,
343            agent_type: None,
344            ttl_seconds: None,
345            referenced_date: None,
346            source_type: None,
347        }
348    }
349}
350
351impl MemoryInput {
352    /// Sets `event_type` from `event_type_str` when provided, otherwise preserves the
353    /// existing `self.event_type`. Then applies derived defaults for `ttl_seconds` and
354    /// `priority` (only when those fields are `None`) based on the effective event type.
355    pub fn apply_event_type_defaults(&mut self, event_type_str: Option<&str>) {
356        let event_type =
357            EventType::from_optional(event_type_str).or_else(|| self.event_type.clone());
358
359        if self.ttl_seconds.is_none() {
360            self.ttl_seconds = event_type
361                .as_ref()
362                .map(EventType::default_ttl)
363                .unwrap_or(Some(TTL_LONG_TERM));
364        }
365        if self.priority.is_none() {
366            self.priority = event_type.as_ref().map(EventType::default_priority);
367        }
368        self.event_type = event_type;
369    }
370}
371
372#[derive(Debug, Clone, Default)]
373pub struct MemoryUpdate {
374    pub content: Option<String>,
375    pub tags: Option<Vec<String>>,
376    pub importance: Option<f64>,
377    pub metadata: Option<serde_json::Value>,
378    pub event_type: Option<EventType>,
379    pub priority: Option<i32>,
380}
381
382#[derive(Debug, Clone, Default)]
383pub struct SearchOptions {
384    pub event_type: Option<EventType>,
385    pub project: Option<String>,
386    pub session_id: Option<String>,
387    pub include_superseded: Option<bool>,
388    pub importance_min: Option<f64>,
389    pub created_after: Option<String>,
390    pub created_before: Option<String>,
391    pub context_tags: Option<Vec<String>>,
392    pub entity_id: Option<String>,
393    pub agent_type: Option<String>,
394    /// ISO 8601 lower bound for the `event_at` column (inclusive).
395    pub event_after: Option<String>,
396    /// ISO 8601 upper bound for the `event_at` column (inclusive).
397    pub event_before: Option<String>,
398    /// When true, inject `_explain` component scores into each result's metadata.
399    pub explain: Option<bool>,
400}
401
402#[derive(Debug, Clone)]
403pub struct CheckpointInput {
404    pub task_title: String,
405    pub progress: String,
406    pub plan: Option<String>,
407    pub files_touched: Option<serde_json::Value>,
408    pub decisions: Option<Vec<String>>,
409    pub key_context: Option<String>,
410    pub next_steps: Option<String>,
411    pub session_id: Option<String>,
412    pub project: Option<String>,
413}
414
415/// Checks if a string represents a known event type.
416/// Thin wrapper that delegates to `EventType`.
417pub fn is_valid_event_type(event_type: &str) -> bool {
418    EventType::from_str(event_type)
419        .map(|et| et.is_valid())
420        .unwrap_or(false)
421}
422
423pub fn parse_duration(text: &str) -> Result<chrono::Duration> {
424    if text.is_empty() {
425        return Err(anyhow::anyhow!("duration cannot be empty"));
426    }
427
428    let mut weeks: i64 = 0;
429    let mut days: i64 = 0;
430    let mut hours: i64 = 0;
431    let mut minutes: i64 = 0;
432    let mut last_rank: i32 = -1;
433    let mut idx: usize = 0;
434    let bytes = text.as_bytes();
435
436    while idx < bytes.len() {
437        if !bytes[idx].is_ascii_digit() {
438            return Err(anyhow::anyhow!("invalid duration format: {text}"));
439        }
440
441        let start = idx;
442        while idx < bytes.len() && bytes[idx].is_ascii_digit() {
443            idx += 1;
444        }
445
446        if idx >= bytes.len() {
447            return Err(anyhow::anyhow!("invalid duration format: {text}"));
448        }
449
450        let value = text[start..idx]
451            .parse::<i64>()
452            .map_err(|_| anyhow::anyhow!("invalid duration value: {text}"))?;
453        let unit = bytes[idx] as char;
454        idx += 1;
455
456        let rank = match unit {
457            'w' => 0,
458            'd' => 1,
459            'h' => 2,
460            'm' => 3,
461            _ => return Err(anyhow::anyhow!("invalid duration unit in: {text}")),
462        };
463
464        if rank <= last_rank {
465            return Err(anyhow::anyhow!("invalid duration order in: {text}"));
466        }
467        last_rank = rank;
468
469        match unit {
470            'w' => weeks = value,
471            'd' => days = value,
472            'h' => hours = value,
473            'm' => minutes = value,
474            _ => return Err(anyhow::anyhow!("invalid duration unit in: {text}")),
475        }
476    }
477
478    let total = chrono::Duration::weeks(weeks)
479        + chrono::Duration::days(days)
480        + chrono::Duration::hours(hours)
481        + chrono::Duration::minutes(minutes);
482
483    if total.num_seconds() <= 0 {
484        return Err(anyhow::anyhow!("duration must be greater than zero"));
485    }
486
487    Ok(total)
488}
489
490/// Search result item returned by memory queries.
491#[derive(Debug, Clone, PartialEq, Serialize)]
492pub struct SearchResult {
493    /// Memory identifier.
494    pub id: String,
495    /// Stored memory content.
496    pub content: String,
497    /// Memory tags.
498    pub tags: Vec<String>,
499    /// Importance score in the range [0.0, 1.0].
500    pub importance: f64,
501    /// Arbitrary JSON metadata payload.
502    pub metadata: serde_json::Value,
503    pub event_type: Option<EventType>,
504    pub session_id: Option<String>,
505    pub project: Option<String>,
506    pub entity_id: Option<String>,
507    pub agent_type: Option<String>,
508}
509
510/// Semantic search result item with similarity score.
511#[derive(Debug, Clone, PartialEq, Serialize)]
512pub struct SemanticResult {
513    /// Memory identifier.
514    pub id: String,
515    /// Stored memory content.
516    pub content: String,
517    /// Memory tags.
518    pub tags: Vec<String>,
519    /// Importance score in the range [0.0, 1.0].
520    pub importance: f64,
521    /// Arbitrary JSON metadata payload.
522    pub metadata: serde_json::Value,
523    pub event_type: Option<EventType>,
524    pub session_id: Option<String>,
525    pub project: Option<String>,
526    pub entity_id: Option<String>,
527    pub agent_type: Option<String>,
528    /// Similarity score in the range [0.0, 1.0].
529    pub score: f32,
530}
531
532#[derive(Debug, Clone)]
533pub struct GraphNode {
534    pub id: String,
535    pub content: String,
536    pub event_type: Option<EventType>,
537    pub metadata: serde_json::Value,
538    pub hop: usize,
539    pub weight: f64,
540    pub edge_type: String,
541    pub created_at: String,
542}
543
544/// A directed relationship between two memories.
545#[derive(Debug, Clone, PartialEq)]
546pub struct Relationship {
547    /// Relationship identifier.
548    pub id: String,
549    /// Source memory identifier.
550    pub source_id: String,
551    /// Target memory identifier.
552    pub target_id: String,
553    /// Relationship type label (e.g. "links_to", "related").
554    pub rel_type: String,
555    pub weight: f64,
556    /// Arbitrary JSON metadata payload.
557    pub metadata: serde_json::Value,
558    pub created_at: String,
559}
560
561/// Result of a paginated list query.
562#[derive(Debug, Clone, PartialEq)]
563pub struct ListResult {
564    /// Memories in the current page.
565    pub memories: Vec<SearchResult>,
566    /// Total number of memories in the store.
567    pub total: usize,
568}
569
570/// Backup info returned by `BackupManager::create_backup`.
571#[derive(Debug, Clone, Serialize, Deserialize)]
572pub struct BackupInfo {
573    pub path: std::path::PathBuf,
574    pub size_bytes: u64,
575    pub created_at: String,
576}