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