Skip to main content

origin_types/
sources.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Document source types -- MemoryType enum, RawDocument, SourceType, SyncStatus.
3
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Closed taxonomy of memory facets -- validated at API boundary.
8/// Stored as lowercase TEXT in SQLite.
9///
10/// Six user-facing types after taxonomy refactor:
11/// - Identity, Preference -- about the user (Protected tier)
12/// - Decision -- choices made (Standard tier)
13/// - Lesson -- positive learnings (Standard tier)
14/// - Gotcha -- traps/warnings/negative learnings (Standard tier)
15/// - Fact -- durable knowledge (Standard tier)
16///
17/// Goal is deprecated: incoming "goal" parses as Identity (aspirations
18/// are part of who the user is). Existing rows migrate via DB migration.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum MemoryType {
22    Identity,
23    Preference,
24    Decision,
25    Lesson,
26    Gotcha,
27    Fact,
28}
29
30impl MemoryType {
31    /// All valid lowercase string values (6 canonical types).
32    pub fn all_values() -> &'static [&'static str] {
33        &[
34            "identity",
35            "preference",
36            "decision",
37            "lesson",
38            "gotcha",
39            "fact",
40        ]
41    }
42
43    /// Check if input is the "profile" high-level alias (case-insensitive).
44    /// Used by the store flow to detect when async LLM sub-classification is needed.
45    pub fn is_profile_alias(s: &str) -> bool {
46        s.eq_ignore_ascii_case("profile")
47    }
48
49    /// Check if input is the "knowledge" high-level alias (case-insensitive).
50    /// Knowledge expands to fact | lesson | gotcha and needs sub-classification.
51    pub fn is_knowledge_alias(s: &str) -> bool {
52        s.eq_ignore_ascii_case("knowledge")
53    }
54}
55
56impl std::fmt::Display for MemoryType {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        let s = match self {
59            Self::Identity => "identity",
60            Self::Preference => "preference",
61            Self::Decision => "decision",
62            Self::Lesson => "lesson",
63            Self::Gotcha => "gotcha",
64            Self::Fact => "fact",
65        };
66        f.write_str(s)
67    }
68}
69
70impl std::str::FromStr for MemoryType {
71    type Err = String;
72    fn from_str(s: &str) -> Result<Self, Self::Err> {
73        match s.to_lowercase().as_str() {
74            "identity" => Ok(Self::Identity),
75            "preference" => Ok(Self::Preference),
76            "decision" => Ok(Self::Decision),
77            "lesson" => Ok(Self::Lesson),
78            "gotcha" => Ok(Self::Gotcha),
79            "fact" => Ok(Self::Fact),
80            // Deprecated: "goal" folds into Identity (aspirations = identity).
81            "goal" => Ok(Self::Identity),
82            // High-level alias: "profile" needs async LLM sub-classification
83            "profile" => Err(
84                "profile requires sub-classification into identity or preference -- use classify_memory_type".to_string()
85            ),
86            // High-level alias: "knowledge" needs sub-classification (fact | lesson | gotcha)
87            "knowledge" => Err(
88                "knowledge requires sub-classification into fact, lesson, or gotcha -- use classify_memory_type".to_string()
89            ),
90            // Backward compat: removed types map to Fact
91            "correction" | "custom" | "recap" => Ok(Self::Fact),
92            _ => Err(format!(
93                "invalid memory_type '{}', valid values: {}",
94                s,
95                Self::all_values().join(", ")
96            )),
97        }
98    }
99}
100
101/// Stability tiers determine supersede behavior, confidence defaults, and retrieval decay.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum StabilityTier {
104    /// identity, preference -- supersede requires human confirmation
105    Protected,
106    /// fact, decision, lesson, gotcha -- supersede auto-applies unconfirmed
107    Standard,
108    /// catch-all for unknown / removed types -- supersede auto-applies silently
109    Ephemeral,
110}
111
112/// Map a memory type string to its stability tier. NULL -> Ephemeral.
113pub fn stability_tier(memory_type: Option<&str>) -> StabilityTier {
114    match memory_type {
115        Some("identity") | Some("preference") => StabilityTier::Protected,
116        Some("fact") | Some("decision") | Some("lesson") | Some("gotcha") => {
117            StabilityTier::Standard
118        }
119        // Deprecated: "goal" still in DB rows pre-migration -> treat as Identity (Protected).
120        Some("goal") => StabilityTier::Protected,
121        _ => StabilityTier::Ephemeral,
122    }
123}
124
125/// A raw document fetched from any source, ready for chunking and embedding.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct RawDocument {
128    /// Source identifier ("gmail", "notion", "local_files", etc.)
129    pub source: String,
130    /// Unique ID within the source (message ID, page ID, file path)
131    pub source_id: String,
132    /// Document title (filename, subject line, page title)
133    pub title: String,
134    /// LLM-generated summary (stored separately from chunk content)
135    pub summary: Option<String>,
136    /// Plain text content
137    pub content: String,
138    /// Deep link back to the source (URL, file path)
139    pub url: Option<String>,
140    /// Unix timestamp of last modification
141    pub last_modified: i64,
142    /// Additional metadata
143    pub metadata: HashMap<String, String>,
144
145    // --- Memory layer fields (all optional for backward compat) ---
146    /// Memory category: "preference", "decision", "fact", "goal", "relationship"
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub memory_type: Option<String>,
149    /// Space context: "work", "personal", "health", or "project:<name>"
150    #[serde(default, alias = "domain", skip_serializing_if = "Option::is_none")]
151    pub space: Option<String>,
152    /// Which AI agent stored this memory (e.g. "claude-code", "chatgpt")
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub source_agent: Option<String>,
155    /// Confidence score (0.0-1.0) assigned by the storing agent
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub confidence: Option<f32>,
158    /// Whether a human has confirmed this memory
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub confirmed: Option<bool>,
161    /// Stability tier: "new", "learned", or "confirmed"
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub stability: Option<String>,
164    /// source_id of the memory this entry supersedes (version chain)
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub supersedes: Option<String>,
167    /// Whether this is a pending revision awaiting human approval (Protected tier supersede)
168    #[serde(default)]
169    pub pending_revision: bool,
170    /// Link to a knowledge graph entity (nullable, cascade handled manually)
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub entity_id: Option<String>,
173    /// Quality assessment: "low", "medium", "high" (NULL = unassessed)
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub quality: Option<String>,
176    /// T8 salience prior: importance rating 1-10 (LLM-assigned), NULL = unrated.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub importance: Option<u8>,
179    /// Whether this memory is a recap/summary of other memories
180    #[serde(default)]
181    pub is_recap: bool,
182    /// Deprecated: enrichment status is now derived from the `enrichment_steps` table.
183    /// This field is ignored on INSERT. Kept for API compatibility with downstream consumers.
184    #[serde(default = "default_enrichment_status")]
185    pub enrichment_status: String,
186    /// How superseded content is handled: "hide" (default) or "archive" (visible but muted)
187    #[serde(default = "default_supersede_mode")]
188    pub supersede_mode: String,
189    /// JSON object with type-specific structured fields (e.g. {"claim": "...", "context": "..."})
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub structured_fields: Option<String>,
192    /// LLM-generated question this memory answers -- embedded for vector search
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub retrieval_cue: Option<String>,
195    /// Original prose content, preserved when structured_fields are promoted to primary content
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub source_text: Option<String>,
198}
199
200fn default_enrichment_status() -> String {
201    "raw".to_string()
202}
203
204fn default_supersede_mode() -> String {
205    "hide".to_string()
206}
207
208impl Default for RawDocument {
209    fn default() -> Self {
210        Self {
211            source: String::new(),
212            source_id: String::new(),
213            title: String::new(),
214            summary: None,
215            content: String::new(),
216            url: None,
217            last_modified: 0,
218            metadata: HashMap::new(),
219            memory_type: None,
220            space: None,
221            source_agent: None,
222            confidence: None,
223            confirmed: None,
224            stability: None,
225            supersedes: None,
226            pending_revision: false,
227            entity_id: None,
228            quality: None,
229            importance: None,
230            is_recap: false,
231            enrichment_status: "raw".to_string(),
232            supersede_mode: "hide".to_string(),
233            structured_fields: None,
234            retrieval_cue: None,
235            source_text: None,
236        }
237    }
238}
239
240/// Persisted source type.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "lowercase")]
243pub enum SourceType {
244    Obsidian,
245    Directory,
246}
247
248impl SourceType {
249    pub fn as_str(&self) -> &'static str {
250        match self {
251            Self::Obsidian => "obsidian",
252            Self::Directory => "directory",
253        }
254    }
255}
256
257/// Sync status for a connected source.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub enum SyncStatus {
260    Active,
261    Paused,
262    Error(String),
263}
264
265/// Status of a connected source.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct SourceStatus {
268    pub name: String,
269    pub connected: bool,
270    pub requires_auth: bool,
271    pub last_sync: Option<i64>,
272    pub document_count: u64,
273    pub error: Option<String>,
274}
275
276/// Persisted source configuration -- stored in config.json.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct Source {
279    pub id: String,
280    pub source_type: SourceType,
281    pub path: std::path::PathBuf,
282    #[serde(default = "default_sync_status")]
283    pub status: SyncStatus,
284    pub last_sync: Option<i64>,
285    #[serde(default)]
286    pub file_count: u64,
287    #[serde(default)]
288    pub memory_count: u64,
289    /// Number of files that failed to read / ingest in the last sync.
290    #[serde(default)]
291    pub last_sync_errors: u64,
292    /// Categorized detail of last sync errors for UI display.
293    /// Known values: "google_drive_offline", "file_read_errors".
294    #[serde(default)]
295    pub last_sync_error_detail: Option<String>,
296}
297
298fn default_sync_status() -> SyncStatus {
299    SyncStatus::Active
300}