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            EventType::UserPreference => Some(0.75),
171            _ => None,
172        }
173    }
174
175    /// Returns `true` if this event type supports auto-supersession.
176    pub fn is_supersession_type(&self) -> bool {
177        matches!(
178            self,
179            EventType::Decision
180                | EventType::LessonLearned
181                | EventType::UserPreference
182                | EventType::UserFact
183                | EventType::Reminder
184        )
185    }
186
187    /// Converts an optional string (from JSON/SQL) into `Option<EventType>`.
188    pub fn from_optional(s: Option<&str>) -> Option<EventType> {
189        s.map(|v| EventType::from_str(v).unwrap_or_else(|e| match e {}))
190    }
191
192    /// Returns all event types that have a dedup threshold.
193    pub fn types_with_dedup_threshold() -> Vec<EventType> {
194        // Keep in sync with dedup_threshold() match arms above.
195        vec![
196            EventType::ErrorPattern,
197            EventType::SessionSummary,
198            EventType::TaskCompletion,
199            EventType::Decision,
200            EventType::LessonLearned,
201            EventType::UserFact,
202            EventType::UserPreference,
203        ]
204    }
205}
206
207impl fmt::Display for EventType {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        let s = match self {
210            EventType::SessionSummary => "session_summary",
211            EventType::TaskCompletion => "task_completion",
212            EventType::ErrorPattern => "error_pattern",
213            EventType::LessonLearned => "lesson_learned",
214            EventType::Decision => "decision",
215            EventType::BlockedContext => "blocked_context",
216            EventType::UserPreference => "user_preference",
217            EventType::UserFact => "user_fact",
218            EventType::AdvisorInsight => "advisor_insight",
219            EventType::GitCommit => "git_commit",
220            EventType::GitMerge => "git_merge",
221            EventType::GitConflict => "git_conflict",
222            EventType::SessionStart => "session_start",
223            EventType::SessionEnd => "session_end",
224            EventType::ContextWarning => "context_warning",
225            EventType::BudgetAlert => "budget_alert",
226            EventType::CoordinationSnapshot => "coordination_snapshot",
227            EventType::Checkpoint => "checkpoint",
228            EventType::Reminder => "reminder",
229            EventType::Memory => "memory",
230            EventType::CodeChunk => "code_chunk",
231            EventType::FileSummary => "file_summary",
232            EventType::Unknown(s) => s.as_str(),
233        };
234        write!(f, "{s}")
235    }
236}
237
238impl FromStr for EventType {
239    type Err = std::convert::Infallible;
240
241    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
242        Ok(match s {
243            "session_summary" => EventType::SessionSummary,
244            "task_completion" => EventType::TaskCompletion,
245            "error_pattern" => EventType::ErrorPattern,
246            "lesson_learned" => EventType::LessonLearned,
247            "decision" => EventType::Decision,
248            "blocked_context" => EventType::BlockedContext,
249            "user_preference" => EventType::UserPreference,
250            "user_fact" => EventType::UserFact,
251            "advisor_insight" => EventType::AdvisorInsight,
252            "git_commit" => EventType::GitCommit,
253            "git_merge" => EventType::GitMerge,
254            "git_conflict" => EventType::GitConflict,
255            "session_start" => EventType::SessionStart,
256            "session_end" => EventType::SessionEnd,
257            "context_warning" => EventType::ContextWarning,
258            "budget_alert" => EventType::BudgetAlert,
259            "coordination_snapshot" => EventType::CoordinationSnapshot,
260            "checkpoint" => EventType::Checkpoint,
261            "reminder" => EventType::Reminder,
262            "memory" => EventType::Memory,
263            "code_chunk" => EventType::CodeChunk,
264            "file_summary" => EventType::FileSummary,
265            other => EventType::Unknown(other.to_string()),
266        })
267    }
268}
269
270impl Serialize for EventType {
271    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
272    where
273        S: serde::Serializer,
274    {
275        serializer.serialize_str(&self.to_string())
276    }
277}
278
279impl<'de> Deserialize<'de> for EventType {
280    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
281    where
282        D: serde::Deserializer<'de>,
283    {
284        let s = String::deserialize(deserializer)?;
285        // FromStr is infallible for EventType
286        Ok(EventType::from_str(&s).unwrap_or_else(|e| match e {}))
287    }
288}
289
290impl schemars::JsonSchema for EventType {
291    fn schema_name() -> std::borrow::Cow<'static, str> {
292        std::borrow::Cow::Borrowed("EventType")
293    }
294
295    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
296        schemars::json_schema!({
297            "type": "string",
298            "description": "Memory event type",
299            "enum": [
300                "session_summary", "task_completion", "error_pattern", "lesson_learned",
301                "decision", "blocked_context", "user_preference", "user_fact",
302                "advisor_insight", "git_commit", "git_merge", "git_conflict",
303                "session_start", "session_end", "context_warning", "budget_alert",
304                "coordination_snapshot", "checkpoint", "reminder", "memory",
305                "code_chunk", "file_summary"
306            ]
307        })
308    }
309}
310
311#[derive(Debug, Clone)]
312pub struct MemoryInput {
313    pub content: String,
314    pub id: Option<String>,
315    pub tags: Vec<String>,
316    pub importance: f64,
317    pub metadata: serde_json::Value,
318    pub event_type: Option<EventType>,
319    pub session_id: Option<String>,
320    pub project: Option<String>,
321    pub priority: Option<i32>,
322    pub entity_id: Option<String>,
323    pub agent_type: Option<String>,
324    pub ttl_seconds: Option<i64>,
325    /// ISO 8601 timestamp for when the event actually occurred.
326    /// When provided, this overrides the default `event_at = now()` on insert.
327    pub referenced_date: Option<String>,
328    /// Source of the memory (e.g. "cli_input", "mcp", "import"). Defaults to "cli_input".
329    pub source_type: Option<String>,
330}
331
332impl Default for MemoryInput {
333    fn default() -> Self {
334        Self {
335            content: String::new(),
336            id: None,
337            tags: Vec::new(),
338            importance: 0.5,
339            metadata: serde_json::json!({}),
340            event_type: None,
341            session_id: None,
342            project: None,
343            priority: None,
344            entity_id: None,
345            agent_type: None,
346            ttl_seconds: None,
347            referenced_date: None,
348            source_type: None,
349        }
350    }
351}
352
353impl MemoryInput {
354    /// Sets `event_type` from `event_type_str` when provided, otherwise preserves the
355    /// existing `self.event_type`. Then applies derived defaults for `ttl_seconds` and
356    /// `priority` (only when those fields are `None`) based on the effective event type.
357    pub fn apply_event_type_defaults(&mut self, event_type_str: Option<&str>) {
358        let event_type =
359            EventType::from_optional(event_type_str).or_else(|| self.event_type.clone());
360
361        if self.ttl_seconds.is_none() {
362            self.ttl_seconds = event_type
363                .as_ref()
364                .map(EventType::default_ttl)
365                .unwrap_or(Some(TTL_LONG_TERM));
366        }
367        if self.priority.is_none() {
368            self.priority = event_type.as_ref().map(EventType::default_priority);
369        }
370        self.event_type = event_type;
371    }
372}
373
374#[derive(Debug, Clone, Default)]
375pub struct MemoryUpdate {
376    pub content: Option<String>,
377    pub tags: Option<Vec<String>>,
378    pub importance: Option<f64>,
379    pub metadata: Option<serde_json::Value>,
380    pub event_type: Option<EventType>,
381    pub priority: Option<i32>,
382}
383
384#[derive(Debug, Clone, Default)]
385pub struct SearchOptions {
386    pub event_type: Option<EventType>,
387    pub project: Option<String>,
388    pub session_id: Option<String>,
389    pub include_superseded: Option<bool>,
390    pub importance_min: Option<f64>,
391    pub created_after: Option<String>,
392    pub created_before: Option<String>,
393    pub context_tags: Option<Vec<String>>,
394    pub entity_id: Option<String>,
395    pub agent_type: Option<String>,
396    /// ISO 8601 lower bound for the `event_at` column (inclusive).
397    pub event_after: Option<String>,
398    /// ISO 8601 upper bound for the `event_at` column (inclusive).
399    pub event_before: Option<String>,
400    /// When true, inject `_explain` component scores into each result's metadata.
401    pub explain: Option<bool>,
402}
403
404#[derive(Debug, Clone, Default)]
405pub struct WelcomeOptions {
406    pub session_id: Option<String>,
407    pub project: Option<String>,
408    pub agent_type: Option<String>,
409    pub entity_id: Option<String>,
410    pub budget_tokens: Option<usize>,
411}
412
413#[derive(Debug, Clone)]
414pub struct CheckpointInput {
415    pub task_title: String,
416    pub progress: String,
417    pub plan: Option<String>,
418    pub files_touched: Option<serde_json::Value>,
419    pub decisions: Option<Vec<String>>,
420    pub key_context: Option<String>,
421    pub next_steps: Option<String>,
422    pub session_id: Option<String>,
423    pub project: Option<String>,
424}
425
426/// Checks if a string represents a known event type.
427/// Thin wrapper that delegates to `EventType`.
428pub fn is_valid_event_type(event_type: &str) -> bool {
429    EventType::from_str(event_type)
430        .map(|et| et.is_valid())
431        .unwrap_or(false)
432}
433
434pub fn parse_duration(text: &str) -> Result<chrono::Duration> {
435    if text.is_empty() {
436        return Err(anyhow::anyhow!("duration cannot be empty"));
437    }
438
439    let mut weeks: i64 = 0;
440    let mut days: i64 = 0;
441    let mut hours: i64 = 0;
442    let mut minutes: i64 = 0;
443    let mut last_rank: i32 = -1;
444    let mut idx: usize = 0;
445    let bytes = text.as_bytes();
446
447    while idx < bytes.len() {
448        if !bytes[idx].is_ascii_digit() {
449            return Err(anyhow::anyhow!("invalid duration format: {text}"));
450        }
451
452        let start = idx;
453        while idx < bytes.len() && bytes[idx].is_ascii_digit() {
454            idx += 1;
455        }
456
457        if idx >= bytes.len() {
458            return Err(anyhow::anyhow!("invalid duration format: {text}"));
459        }
460
461        let value = text[start..idx]
462            .parse::<i64>()
463            .map_err(|_| anyhow::anyhow!("invalid duration value: {text}"))?;
464        let unit = bytes[idx] as char;
465        idx += 1;
466
467        let rank = match unit {
468            'w' => 0,
469            'd' => 1,
470            'h' => 2,
471            'm' => 3,
472            _ => return Err(anyhow::anyhow!("invalid duration unit in: {text}")),
473        };
474
475        if rank <= last_rank {
476            return Err(anyhow::anyhow!("invalid duration order in: {text}"));
477        }
478        last_rank = rank;
479
480        match unit {
481            'w' => weeks = value,
482            'd' => days = value,
483            'h' => hours = value,
484            'm' => minutes = value,
485            _ => return Err(anyhow::anyhow!("invalid duration unit in: {text}")),
486        }
487    }
488
489    let total = chrono::Duration::weeks(weeks)
490        + chrono::Duration::days(days)
491        + chrono::Duration::hours(hours)
492        + chrono::Duration::minutes(minutes);
493
494    if total.num_seconds() <= 0 {
495        return Err(anyhow::anyhow!("duration must be greater than zero"));
496    }
497
498    Ok(total)
499}
500
501/// Search result item returned by memory queries.
502#[derive(Debug, Clone, PartialEq, Serialize)]
503pub struct SearchResult {
504    /// Memory identifier.
505    pub id: String,
506    /// Stored memory content.
507    pub content: String,
508    /// Memory tags.
509    pub tags: Vec<String>,
510    /// Importance score in the range [0.0, 1.0].
511    pub importance: f64,
512    /// Arbitrary JSON metadata payload.
513    pub metadata: serde_json::Value,
514    pub event_type: Option<EventType>,
515    pub session_id: Option<String>,
516    pub project: Option<String>,
517    pub entity_id: Option<String>,
518    pub agent_type: Option<String>,
519}
520
521/// Semantic search result item with similarity score.
522#[derive(Debug, Clone, PartialEq, Serialize)]
523pub struct SemanticResult {
524    /// Memory identifier.
525    pub id: String,
526    /// Stored memory content.
527    pub content: String,
528    /// Memory tags.
529    pub tags: Vec<String>,
530    /// Importance score in the range [0.0, 1.0].
531    pub importance: f64,
532    /// Arbitrary JSON metadata payload.
533    pub metadata: serde_json::Value,
534    pub event_type: Option<EventType>,
535    pub session_id: Option<String>,
536    pub project: Option<String>,
537    pub entity_id: Option<String>,
538    pub agent_type: Option<String>,
539    /// Similarity score in the range [0.0, 1.0].
540    pub score: f32,
541}
542
543#[derive(Debug, Clone)]
544pub struct GraphNode {
545    pub id: String,
546    pub content: String,
547    pub event_type: Option<EventType>,
548    pub metadata: serde_json::Value,
549    pub hop: usize,
550    pub weight: f64,
551    pub edge_type: String,
552    pub created_at: String,
553}
554
555/// A directed relationship between two memories.
556#[derive(Debug, Clone, PartialEq)]
557pub struct Relationship {
558    /// Relationship identifier.
559    pub id: String,
560    /// Source memory identifier.
561    pub source_id: String,
562    /// Target memory identifier.
563    pub target_id: String,
564    /// Relationship type label (e.g. "links_to", "related").
565    pub rel_type: String,
566    pub weight: f64,
567    /// Arbitrary JSON metadata payload.
568    pub metadata: serde_json::Value,
569    pub created_at: String,
570}
571
572/// Result of a paginated list query.
573#[derive(Debug, Clone, PartialEq)]
574pub struct ListResult {
575    /// Memories in the current page.
576    pub memories: Vec<SearchResult>,
577    /// Total number of memories in the store.
578    pub total: usize,
579}
580
581/// Backup info returned by `BackupManager::create_backup`.
582#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct BackupInfo {
584    pub path: std::path::PathBuf,
585    pub size_bytes: u64,
586    pub created_at: String,
587}