Skip to main content

tandem_memory/
types.rs

1// Memory Context Types
2// Type definitions and error types for the memory system
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use tandem_enterprise_contract::{
7    AccessDecision, AccessEffect, AccessPermission, DataBoundary, DataClass, ResourceKind,
8    ResourceRef, StrictTenantContext,
9};
10use tandem_orchestrator::{KnowledgeScope, KnowledgeTrustLevel};
11use thiserror::Error;
12
13/// Memory tier - determines persistence level
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum MemoryTier {
17    /// Ephemeral session memory - cleared when session ends
18    Session,
19    /// Persistent project memory - survives across sessions
20    Project,
21    /// Cross-project global memory - user preferences and patterns
22    Global,
23}
24
25impl MemoryTier {
26    /// Get the table prefix for this tier
27    pub fn table_prefix(&self) -> &'static str {
28        match self {
29            MemoryTier::Session => "session",
30            MemoryTier::Project => "project",
31            MemoryTier::Global => "global",
32        }
33    }
34}
35
36impl std::fmt::Display for MemoryTier {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            MemoryTier::Session => write!(f, "session"),
40            MemoryTier::Project => write!(f, "project"),
41            MemoryTier::Global => write!(f, "global"),
42        }
43    }
44}
45
46/// Tenant partition for vector-backed memory rows.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct MemoryTenantScope {
49    pub org_id: String,
50    pub workspace_id: String,
51    pub deployment_id: Option<String>,
52}
53
54impl MemoryTenantScope {
55    pub fn local() -> Self {
56        Self {
57            org_id: "local".to_string(),
58            workspace_id: "local".to_string(),
59            deployment_id: None,
60        }
61    }
62
63    /// True when this scope is the single-user local partition rather than an
64    /// explicit hosted/enterprise tenant. Strict-mode stores reject this scope
65    /// (see `MemoryDatabase::set_strict_tenant_enforcement`).
66    pub fn is_local(&self) -> bool {
67        self == &Self::local()
68    }
69}
70
71impl Default for MemoryTenantScope {
72    fn default() -> Self {
73        Self::local()
74    }
75}
76
77/// A memory chunk - unit of storage
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct MemoryChunk {
80    pub id: String,
81    pub content: String,
82    pub tier: MemoryTier,
83    pub session_id: Option<String>,
84    pub project_id: Option<String>,
85    pub source: String, // e.g., "user_message", "assistant_response", "file_content"
86    // File-derived fields (only set when source == "file")
87    pub source_path: Option<String>,
88    pub source_mtime: Option<i64>,
89    pub source_size: Option<i64>,
90    pub source_hash: Option<String>,
91    #[serde(default)]
92    pub tenant_scope: MemoryTenantScope,
93    pub created_at: DateTime<Utc>,
94    pub token_count: i64,
95    pub metadata: Option<serde_json::Value>,
96}
97
98/// Search result with similarity score
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct MemorySearchResult {
101    pub chunk: MemoryChunk,
102    pub similarity: f64,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum GovernedReadMode {
108    LocalNoop,
109    GovernedStrict,
110}
111
112impl GovernedReadMode {
113    pub fn as_str(self) -> &'static str {
114        match self {
115            Self::LocalNoop => "local_noop",
116            Self::GovernedStrict => "governed_strict",
117        }
118    }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct GovernedReadDecision {
123    pub allowed: bool,
124    pub reason: Option<String>,
125}
126
127impl GovernedReadDecision {
128    pub fn allow(reason: impl Into<String>) -> Self {
129        Self {
130            allowed: true,
131            reason: Some(reason.into()),
132        }
133    }
134
135    pub fn deny(reason: impl Into<String>) -> Self {
136        Self {
137            allowed: false,
138            reason: Some(reason.into()),
139        }
140    }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum GovernedReadEvidence {
145    SourceBinding,
146    TenantLocalMemory,
147}
148
149impl GovernedReadEvidence {
150    pub fn as_str(self) -> &'static str {
151        match self {
152            Self::SourceBinding => "source_binding",
153            Self::TenantLocalMemory => "tenant_local_memory",
154        }
155    }
156
157    fn requires_grant(self) -> bool {
158        matches!(self, Self::SourceBinding)
159    }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct GovernedReadTarget {
164    pub resource_ref: ResourceRef,
165    pub data_class: DataClass,
166    pub source_binding_id: Option<String>,
167    pub source_object_id: Option<String>,
168    pub evidence: GovernedReadEvidence,
169}
170
171/// Optional enterprise access projection applied before memory ranking.
172#[derive(Debug, Clone)]
173pub struct MemoryAccessFilter {
174    pub strict_context: Option<StrictTenantContext>,
175    pub now_ms: u64,
176    pub mode: GovernedReadMode,
177}
178
179impl MemoryAccessFilter {
180    pub fn strict(strict_context: StrictTenantContext, now_ms: u64) -> Self {
181        Self::governed(Some(strict_context), now_ms)
182    }
183
184    pub fn governed(strict_context: Option<StrictTenantContext>, now_ms: u64) -> Self {
185        Self {
186            strict_context,
187            now_ms,
188            mode: GovernedReadMode::GovernedStrict,
189        }
190    }
191
192    pub fn local_noop(now_ms: u64) -> Self {
193        Self {
194            strict_context: None,
195            now_ms,
196            mode: GovernedReadMode::LocalNoop,
197        }
198    }
199
200    pub fn allows_chunk(&self, chunk: &MemoryChunk) -> bool {
201        self.decision_for_chunk(chunk).allowed
202    }
203
204    pub fn allows_source_target(&self, target: &MemorySourceAccessTarget) -> bool {
205        self.decision_for_source_target(target).allowed
206    }
207
208    pub fn allows_global_record(&self, record: &GlobalMemoryRecord) -> bool {
209        self.decision_for_global_record(record).allowed
210    }
211
212    pub fn decision_for_chunk(&self, chunk: &MemoryChunk) -> GovernedReadDecision {
213        match governed_read_target_from_chunk(chunk) {
214            Ok(target) => self.decision_for_target(&target),
215            Err(reason) => self.decision_for_missing_target(reason),
216        }
217    }
218
219    pub fn decision_for_global_record(&self, record: &GlobalMemoryRecord) -> GovernedReadDecision {
220        match governed_read_target_from_global_record(record, self.strict_context.as_ref()) {
221            Ok(target) => self.decision_for_target(&target),
222            Err(reason) => self.decision_for_missing_target(reason),
223        }
224    }
225
226    pub fn decision_for_source_target(
227        &self,
228        target: &MemorySourceAccessTarget,
229    ) -> GovernedReadDecision {
230        self.decision_for_target(&GovernedReadTarget {
231            resource_ref: target.resource_ref.clone(),
232            data_class: target.data_class,
233            source_binding_id: target.source_binding_id.clone(),
234            source_object_id: target.source_object_id.clone(),
235            evidence: GovernedReadEvidence::SourceBinding,
236        })
237    }
238
239    fn decision_for_missing_target(&self, reason: &'static str) -> GovernedReadDecision {
240        if self.mode == GovernedReadMode::LocalNoop {
241            GovernedReadDecision::allow("local_noop")
242        } else {
243            GovernedReadDecision::deny(reason)
244        }
245    }
246
247    fn decision_for_target(&self, target: &GovernedReadTarget) -> GovernedReadDecision {
248        if self.mode == GovernedReadMode::LocalNoop {
249            return GovernedReadDecision::allow("local_noop");
250        }
251
252        let Some(strict_context) = self.strict_context.as_ref() else {
253            return GovernedReadDecision::deny("missing_strict_projection");
254        };
255
256        let effective_boundary =
257            effective_data_boundary_for_governed_read(strict_context, self.now_ms);
258        if !effective_boundary.allows(target.data_class) {
259            return GovernedReadDecision::deny("data_class_denied_by_boundary");
260        }
261
262        if strict_context.is_expired_at(self.now_ms) {
263            return GovernedReadDecision::deny("context_expired");
264        }
265
266        if !target.evidence.requires_grant() {
267            if target.resource_ref.organization_id != strict_context.tenant_context.org_id
268                || target.resource_ref.workspace_id != strict_context.tenant_context.workspace_id
269            {
270                return GovernedReadDecision::deny("tenant_scope_mismatch");
271            }
272            if strict_context
273                .resource_scope
274                .explicitly_denies(&target.resource_ref)
275            {
276                return GovernedReadDecision::deny("resource_explicitly_denied_by_scope");
277            }
278            if !strict_context.resource_scope.contains(&target.resource_ref) {
279                return GovernedReadDecision::deny("resource_outside_projected_scope");
280            }
281            return GovernedReadDecision::allow("tenant_local_memory_allowed");
282        }
283
284        let evaluation = strict_context
285            .clone()
286            .with_data_boundary(effective_boundary)
287            .evaluate_access(
288                &target.resource_ref,
289                AccessPermission::Read,
290                target.data_class,
291                self.now_ms,
292            );
293        if evaluation.decision == AccessDecision::Allow {
294            GovernedReadDecision::allow(evaluation.reason)
295        } else {
296            GovernedReadDecision::deny(evaluation.reason)
297        }
298    }
299}
300
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct MemorySourceAccessTarget {
303    pub resource_ref: ResourceRef,
304    pub data_class: DataClass,
305    pub source_binding_id: Option<String>,
306    pub source_object_id: Option<String>,
307}
308
309impl MemorySourceAccessTarget {
310    pub fn from_chunk(chunk: &MemoryChunk) -> Option<Self> {
311        Self::from_metadata(chunk.metadata.as_ref())
312    }
313
314    pub fn from_metadata(metadata: Option<&serde_json::Value>) -> Option<Self> {
315        let binding = metadata?.get("enterprise_source_binding")?;
316        let resource_ref = serde_json::from_value(binding.get("resource_ref")?.clone()).ok()?;
317        let data_class = serde_json::from_value(binding.get("data_class")?.clone()).ok()?;
318        Some(Self {
319            resource_ref,
320            data_class,
321            source_binding_id: binding
322                .get("binding_id")
323                .and_then(serde_json::Value::as_str)
324                .map(ToOwned::to_owned),
325            source_object_id: binding
326                .get("source_object_id")
327                .and_then(serde_json::Value::as_str)
328                .map(ToOwned::to_owned),
329        })
330    }
331}
332
333pub fn effective_data_boundary_for_governed_read(
334    strict_context: &StrictTenantContext,
335    now_ms: u64,
336) -> DataBoundary {
337    if strict_context.data_boundary.is_unrestricted() {
338        let mut grant_data_classes = Vec::new();
339        for grant in strict_context.grants.iter().filter(|grant| {
340            grant.effect == AccessEffect::Allow
341                && !grant.is_expired_at(now_ms)
342                && grant.has_permission(AccessPermission::Read)
343        }) {
344            for data_class in &grant.data_classes {
345                if !grant_data_classes.contains(data_class) {
346                    grant_data_classes.push(*data_class);
347                }
348            }
349        }
350        if grant_data_classes.is_empty() {
351            DataBoundary::governed_default()
352        } else {
353            DataBoundary::allow(grant_data_classes)
354        }
355    } else {
356        strict_context.data_boundary.clone()
357    }
358}
359
360fn governed_read_target_from_chunk(
361    chunk: &MemoryChunk,
362) -> Result<GovernedReadTarget, &'static str> {
363    if let Some(target) = source_binding_target_from_metadata(chunk.metadata.as_ref())? {
364        return Ok(target);
365    }
366
367    let mut resource_ref = ResourceRef::new(
368        chunk.tenant_scope.org_id.clone(),
369        chunk.tenant_scope.workspace_id.clone(),
370        ResourceKind::MemorySpace,
371        memory_chunk_resource_id(chunk),
372    );
373    if let Some(project_id) = chunk.project_id.as_ref() {
374        resource_ref = resource_ref.with_project_id(project_id.clone());
375    }
376
377    Ok(GovernedReadTarget {
378        resource_ref,
379        data_class: data_class_from_metadata(chunk.metadata.as_ref())
380            .unwrap_or(DataClass::Internal),
381        source_binding_id: None,
382        source_object_id: None,
383        evidence: GovernedReadEvidence::TenantLocalMemory,
384    })
385}
386
387fn governed_read_target_from_global_record(
388    record: &GlobalMemoryRecord,
389    strict_context: Option<&StrictTenantContext>,
390) -> Result<GovernedReadTarget, &'static str> {
391    if let Some(target) = source_binding_target_from_metadata(record.metadata.as_ref())? {
392        return Ok(target);
393    }
394
395    let Some(strict_context) = strict_context else {
396        return Err("missing_strict_projection");
397    };
398
399    let mut resource_ref = ResourceRef::new(
400        strict_context.tenant_context.org_id.clone(),
401        strict_context.tenant_context.workspace_id.clone(),
402        ResourceKind::MemorySpace,
403        global_memory_record_resource_id(record),
404    );
405    if let Some(project_id) = record.project_tag.as_ref() {
406        resource_ref = resource_ref.with_project_id(project_id.clone());
407    }
408
409    Ok(GovernedReadTarget {
410        resource_ref,
411        data_class: data_class_from_metadata(record.metadata.as_ref())
412            .unwrap_or(DataClass::Internal),
413        source_binding_id: None,
414        source_object_id: None,
415        evidence: GovernedReadEvidence::TenantLocalMemory,
416    })
417}
418
419fn source_binding_target_from_metadata(
420    metadata: Option<&serde_json::Value>,
421) -> Result<Option<GovernedReadTarget>, &'static str> {
422    let Some(metadata) = metadata else {
423        return Ok(None);
424    };
425    let Some(binding) = metadata.get("enterprise_source_binding") else {
426        return if memory_metadata_is_connector_sourced(Some(metadata)) {
427            Err("missing_resource_ref")
428        } else {
429            Ok(None)
430        };
431    };
432
433    let resource_value = binding.get("resource_ref").ok_or("missing_resource_ref")?;
434    let data_class_value = binding.get("data_class").ok_or("missing_data_class")?;
435    let resource_ref =
436        serde_json::from_value(resource_value.clone()).map_err(|_| "missing_resource_ref")?;
437    let data_class =
438        serde_json::from_value(data_class_value.clone()).map_err(|_| "missing_data_class")?;
439
440    Ok(Some(GovernedReadTarget {
441        resource_ref,
442        data_class,
443        source_binding_id: binding
444            .get("binding_id")
445            .and_then(serde_json::Value::as_str)
446            .map(ToOwned::to_owned),
447        source_object_id: binding
448            .get("source_object_id")
449            .and_then(serde_json::Value::as_str)
450            .map(ToOwned::to_owned),
451        evidence: GovernedReadEvidence::SourceBinding,
452    }))
453}
454
455fn memory_metadata_is_connector_sourced(metadata: Option<&serde_json::Value>) -> bool {
456    metadata
457        .and_then(|value| value.get("memory_trust"))
458        .and_then(|value| value.get("label"))
459        .and_then(serde_json::Value::as_str)
460        .is_some_and(|label| label == "connector_sourced")
461}
462
463fn data_class_from_metadata(metadata: Option<&serde_json::Value>) -> Option<DataClass> {
464    metadata
465        .and_then(|value| value.get("classification"))
466        .and_then(serde_json::Value::as_str)
467        .and_then(data_class_from_label)
468}
469
470fn data_class_from_label(label: &str) -> Option<DataClass> {
471    match label.trim().to_ascii_lowercase().replace('-', "_").as_str() {
472        "public" => Some(DataClass::Public),
473        "internal" => Some(DataClass::Internal),
474        "confidential" => Some(DataClass::Confidential),
475        "restricted" => Some(DataClass::Restricted),
476        "executive" => Some(DataClass::Executive),
477        "credential" => Some(DataClass::Credential),
478        "regulated" => Some(DataClass::Regulated),
479        "customer_data" | "customer" => Some(DataClass::CustomerData),
480        "source_code" | "code" => Some(DataClass::SourceCode),
481        "financial_record" | "financial" | "finance" => Some(DataClass::FinancialRecord),
482        _ => None,
483    }
484}
485
486fn memory_chunk_resource_id(chunk: &MemoryChunk) -> String {
487    match chunk.tier {
488        MemoryTier::Session => chunk
489            .session_id
490            .as_ref()
491            .map(|session_id| format!("session:{session_id}"))
492            .unwrap_or_else(|| "session:default".to_string()),
493        MemoryTier::Project => chunk
494            .project_id
495            .as_ref()
496            .map(|project_id| format!("project:{project_id}"))
497            .unwrap_or_else(|| "project:default".to_string()),
498        MemoryTier::Global => chunk
499            .project_id
500            .as_ref()
501            .map(|project_id| format!("global:{project_id}"))
502            .unwrap_or_else(|| "global".to_string()),
503    }
504}
505
506fn global_memory_record_resource_id(record: &GlobalMemoryRecord) -> String {
507    if record.visibility.eq_ignore_ascii_case("shared") {
508        record
509            .project_tag
510            .as_ref()
511            .map(|project_id| format!("project:{project_id}"))
512            .unwrap_or_else(|| "project:default".to_string())
513    } else {
514        record
515            .session_id
516            .as_ref()
517            .map(|session_id| format!("session:{session_id}"))
518            .unwrap_or_else(|| "global".to_string())
519    }
520}
521
522/// Memory configuration for a project
523#[derive(Debug, Clone, Serialize, Deserialize)]
524pub struct MemoryConfig {
525    /// Maximum chunks to store per project
526    pub max_chunks: i64,
527    /// Chunk size in tokens
528    pub chunk_size: i64,
529    /// Number of chunks to retrieve
530    pub retrieval_k: i64,
531    /// Whether auto-cleanup is enabled
532    pub auto_cleanup: bool,
533    /// Session memory retention in days
534    pub session_retention_days: i64,
535    /// Token budget for memory context injection
536    pub token_budget: i64,
537    /// Overlap between chunks in tokens
538    pub chunk_overlap: i64,
539}
540
541impl Default for MemoryConfig {
542    fn default() -> Self {
543        Self {
544            max_chunks: 10_000,
545            chunk_size: 512,
546            retrieval_k: 5,
547            auto_cleanup: true,
548            session_retention_days: 30,
549            token_budget: 5000,
550            chunk_overlap: 64,
551        }
552    }
553}
554
555/// Memory storage statistics
556#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct MemoryStats {
558    /// Total number of chunks
559    pub total_chunks: i64,
560    /// Number of session chunks
561    pub session_chunks: i64,
562    /// Number of project chunks
563    pub project_chunks: i64,
564    /// Number of global chunks
565    pub global_chunks: i64,
566    /// Total size in bytes
567    pub total_bytes: i64,
568    /// Session memory size in bytes
569    pub session_bytes: i64,
570    /// Project memory size in bytes
571    pub project_bytes: i64,
572    /// Global memory size in bytes
573    pub global_bytes: i64,
574    /// Database file size in bytes
575    pub file_size: i64,
576    /// Last cleanup timestamp
577    pub last_cleanup: Option<DateTime<Utc>>,
578}
579
580/// Context to inject into messages
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct MemoryContext {
583    /// Recent messages from current session
584    pub current_session: Vec<MemoryChunk>,
585    /// Relevant historical chunks
586    pub relevant_history: Vec<MemoryChunk>,
587    /// Important project facts
588    pub project_facts: Vec<MemoryChunk>,
589    /// Total tokens in context
590    pub total_tokens: i64,
591}
592
593/// Metadata describing how memory retrieval executed for a single query.
594#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct MemoryRetrievalMeta {
596    pub used: bool,
597    pub chunks_total: usize,
598    pub session_chunks: usize,
599    pub history_chunks: usize,
600    pub project_fact_chunks: usize,
601    pub score_min: Option<f64>,
602    pub score_max: Option<f64>,
603}
604
605impl MemoryContext {
606    /// Format the context for injection into a prompt
607    pub fn format_for_injection(&self) -> String {
608        let mut parts = Vec::new();
609
610        if !self.current_session.is_empty() {
611            parts.push("<current_session>".to_string());
612            for chunk in &self.current_session {
613                parts.push(format!("- {}", chunk.content));
614            }
615            parts.push("</current_session>".to_string());
616        }
617
618        if !self.relevant_history.is_empty() {
619            parts.push("<relevant_history>".to_string());
620            for chunk in &self.relevant_history {
621                parts.push(format!("- {}", chunk.content));
622            }
623            parts.push("</relevant_history>".to_string());
624        }
625
626        if !self.project_facts.is_empty() {
627            parts.push("<project_facts>".to_string());
628            for chunk in &self.project_facts {
629                parts.push(format!("- {}", chunk.content));
630            }
631            parts.push("</project_facts>".to_string());
632        }
633
634        if parts.is_empty() {
635            String::new()
636        } else {
637            format!("<memory_context>\n{}\n</memory_context>", parts.join("\n"))
638        }
639    }
640}
641
642/// Request to store a message
643#[derive(Debug, Clone, Serialize, Deserialize)]
644pub struct StoreMessageRequest {
645    pub content: String,
646    pub tier: MemoryTier,
647    pub session_id: Option<String>,
648    pub project_id: Option<String>,
649    pub source: String,
650    // File-derived fields (only set when source == "file")
651    pub source_path: Option<String>,
652    pub source_mtime: Option<i64>,
653    pub source_size: Option<i64>,
654    pub source_hash: Option<String>,
655    #[serde(default)]
656    pub tenant_scope: MemoryTenantScope,
657    pub metadata: Option<serde_json::Value>,
658}
659
660/// Project-scoped memory statistics (filtered by project_id)
661#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct ProjectMemoryStats {
663    pub project_id: String,
664    /// Total chunks stored under this project_id (all sources)
665    pub project_chunks: i64,
666    pub project_bytes: i64,
667    /// Chunks/bytes that came from workspace file indexing (source == "file")
668    pub file_index_chunks: i64,
669    pub file_index_bytes: i64,
670    /// Number of indexed files currently tracked for this project_id
671    pub indexed_files: i64,
672    /// Last time indexing completed for this project_id (if known)
673    pub last_indexed_at: Option<DateTime<Utc>>,
674    /// Last run totals (if known)
675    pub last_total_files: Option<i64>,
676    pub last_processed_files: Option<i64>,
677    pub last_indexed_files: Option<i64>,
678    pub last_skipped_files: Option<i64>,
679    pub last_errors: Option<i64>,
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize)]
683pub struct ClearFileIndexResult {
684    pub chunks_deleted: i64,
685    pub bytes_estimated: i64,
686    pub did_vacuum: bool,
687}
688
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
690#[serde(rename_all = "snake_case")]
691pub enum MemoryImportFormat {
692    Directory,
693    Openclaw,
694}
695
696impl std::fmt::Display for MemoryImportFormat {
697    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
698        match self {
699            MemoryImportFormat::Directory => write!(f, "directory"),
700            MemoryImportFormat::Openclaw => write!(f, "openclaw"),
701        }
702    }
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize)]
706pub struct MemoryImportRequest {
707    pub root_path: String,
708    pub format: MemoryImportFormat,
709    pub tier: MemoryTier,
710    pub session_id: Option<String>,
711    pub project_id: Option<String>,
712    #[serde(default)]
713    pub tenant_scope: MemoryTenantScope,
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub source_binding: Option<MemoryImportSourceBinding>,
716    pub sync_deletes: bool,
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub import_namespace: Option<String>,
719}
720
721#[derive(Debug, Clone, Serialize, Deserialize)]
722pub struct MemoryImportSourceBinding {
723    pub binding_id: String,
724    pub connector_id: String,
725    pub resource_ref: serde_json::Value,
726    pub data_class: String,
727    #[serde(default)]
728    pub require_review: bool,
729}
730
731#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(rename_all = "snake_case")]
733pub enum SourceObjectLifecycleState {
734    Active,
735    Quarantined,
736    Tombstoned,
737    Deleted,
738    Rescoped,
739}
740
741impl SourceObjectLifecycleState {
742    pub fn as_str(self) -> &'static str {
743        match self {
744            Self::Active => "active",
745            Self::Quarantined => "quarantined",
746            Self::Tombstoned => "tombstoned",
747            Self::Deleted => "deleted",
748            Self::Rescoped => "rescoped",
749        }
750    }
751
752    pub fn parse(value: &str) -> Self {
753        match value.trim().to_ascii_lowercase().as_str() {
754            "quarantined" => Self::Quarantined,
755            "tombstoned" => Self::Tombstoned,
756            "deleted" => Self::Deleted,
757            "rescoped" => Self::Rescoped,
758            _ => Self::Active,
759        }
760    }
761}
762
763#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
764pub struct SourceObjectLifecycleRecord {
765    pub source_object_id: String,
766    pub tenant_scope: MemoryTenantScope,
767    pub source_binding_id: String,
768    pub connector_id: String,
769    pub state: SourceObjectLifecycleState,
770    pub tier: MemoryTier,
771    pub session_id: Option<String>,
772    pub project_id: Option<String>,
773    pub import_namespace: String,
774    pub indexed_path: String,
775    pub native_object_id: String,
776    pub resource_ref: serde_json::Value,
777    pub data_class: String,
778    pub content_hash: Option<String>,
779    pub source_hash: Option<String>,
780    pub first_seen_at_ms: u64,
781    pub last_seen_at_ms: u64,
782    pub tombstoned_at_ms: Option<u64>,
783    pub metadata: Option<serde_json::Value>,
784}
785
786#[derive(Debug, Clone, Default, Serialize, Deserialize)]
787pub struct MemoryImportStats {
788    pub discovered_files: usize,
789    pub files_processed: usize,
790    pub indexed_files: usize,
791    pub skipped_files: usize,
792    pub deleted_files: usize,
793    pub chunks_created: usize,
794    pub errors: usize,
795}
796
797#[derive(Debug, Clone, Serialize, Deserialize)]
798pub struct MemoryImportProgress {
799    pub files_processed: usize,
800    pub total_files: usize,
801    pub indexed_files: usize,
802    pub skipped_files: usize,
803    pub deleted_files: usize,
804    pub errors: usize,
805    pub chunks_created: usize,
806    pub current_file: String,
807}
808
809/// Request to search memory
810#[derive(Debug, Clone, Serialize, Deserialize)]
811pub struct SearchMemoryRequest {
812    pub query: String,
813    pub tier: Option<MemoryTier>,
814    pub project_id: Option<String>,
815    pub session_id: Option<String>,
816    pub limit: Option<i64>,
817}
818
819/// Embedding backend health surfaced to UI/events.
820#[derive(Debug, Clone, Serialize, Deserialize)]
821pub struct EmbeddingHealth {
822    /// "ok" when embeddings are available, "degraded_disabled" otherwise.
823    pub status: String,
824    /// Optional reason when degraded.
825    pub reason: Option<String>,
826}
827
828/// Memory error types
829#[derive(Error, Debug)]
830pub enum MemoryError {
831    #[error("Database error: {0}")]
832    Database(#[from] rusqlite::Error),
833
834    #[error("IO error: {0}")]
835    Io(#[from] std::io::Error),
836
837    #[error("Serialization error: {0}")]
838    Serialization(#[from] serde_json::Error),
839
840    #[error("Embedding error: {0}")]
841    Embedding(String),
842
843    #[error("Chunking error: {0}")]
844    Chunking(String),
845
846    #[error("Invalid configuration: {0}")]
847    InvalidConfig(String),
848
849    #[error("Tenant scope violation: {0}")]
850    TenantScopeViolation(String),
851
852    #[error("Not found: {0}")]
853    NotFound(String),
854
855    #[error("Tokenization error: {0}")]
856    Tokenization(String),
857
858    #[error("Lock error: {0}")]
859    Lock(String),
860}
861
862impl From<String> for MemoryError {
863    fn from(err: String) -> Self {
864        MemoryError::InvalidConfig(err)
865    }
866}
867
868impl From<&str> for MemoryError {
869    fn from(err: &str) -> Self {
870        MemoryError::InvalidConfig(err.to_string())
871    }
872}
873
874// Implement serialization for Tauri commands
875impl serde::Serialize for MemoryError {
876    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
877    where
878        S: serde::Serializer,
879    {
880        serializer.serialize_str(&self.to_string())
881    }
882}
883
884pub type MemoryResult<T> = Result<T, MemoryError>;
885
886/// Cleanup log entry for audit trail
887#[derive(Debug, Clone, Serialize, Deserialize)]
888pub struct CleanupLogEntry {
889    pub id: String,
890    pub cleanup_type: String,
891    pub tier: MemoryTier,
892    pub project_id: Option<String>,
893    pub session_id: Option<String>,
894    pub chunks_deleted: i64,
895    pub bytes_reclaimed: i64,
896    pub created_at: DateTime<Utc>,
897}
898
899/// Default embedding dimension for all-MiniLM-L6-v2
900pub const DEFAULT_EMBEDDING_DIMENSION: usize = 384;
901
902/// Default embedding model name
903pub const DEFAULT_EMBEDDING_MODEL: &str = "all-MiniLM-L6-v2";
904
905/// Maximum content length for a single chunk (in characters)
906pub const MAX_CHUNK_LENGTH: usize = 4000;
907
908/// Minimum content length for a chunk (in characters)
909pub const MIN_CHUNK_LENGTH: usize = 50;
910
911/// Persistent global memory record keyed by user identity.
912#[derive(Debug, Clone, Serialize, Deserialize)]
913pub struct GlobalMemoryRecord {
914    pub id: String,
915    pub user_id: String,
916    pub source_type: String,
917    pub content: String,
918    pub content_hash: String,
919    pub run_id: String,
920    pub session_id: Option<String>,
921    pub message_id: Option<String>,
922    pub tool_name: Option<String>,
923    pub project_tag: Option<String>,
924    pub channel_tag: Option<String>,
925    pub host_tag: Option<String>,
926    pub metadata: Option<serde_json::Value>,
927    pub provenance: Option<serde_json::Value>,
928    pub redaction_status: String,
929    pub redaction_count: u32,
930    pub visibility: String,
931    pub demoted: bool,
932    pub score_boost: f64,
933    pub created_at_ms: u64,
934    pub updated_at_ms: u64,
935    pub expires_at_ms: Option<u64>,
936}
937
938#[derive(Debug, Clone, Serialize, Deserialize)]
939pub struct GlobalMemoryWriteResult {
940    pub id: String,
941    pub stored: bool,
942    pub deduped: bool,
943}
944
945#[derive(Debug, Clone, Serialize, Deserialize)]
946pub struct GlobalMemorySearchHit {
947    pub record: GlobalMemoryRecord,
948    pub score: f64,
949}
950
951/// Status for a reusable knowledge item.
952#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
953#[serde(rename_all = "snake_case")]
954pub enum KnowledgeItemStatus {
955    #[default]
956    Working,
957    Promoted,
958    ApprovedDefault,
959    Deprecated,
960}
961
962impl std::fmt::Display for KnowledgeItemStatus {
963    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
964        match self {
965            Self::Working => write!(f, "working"),
966            Self::Promoted => write!(f, "promoted"),
967            Self::ApprovedDefault => write!(f, "approved_default"),
968            Self::Deprecated => write!(f, "deprecated"),
969        }
970    }
971}
972
973impl std::str::FromStr for KnowledgeItemStatus {
974    type Err = String;
975    fn from_str(s: &str) -> Result<Self, Self::Err> {
976        match s {
977            "working" => Ok(Self::Working),
978            "promoted" => Ok(Self::Promoted),
979            "approved_default" => Ok(Self::ApprovedDefault),
980            "deprecated" => Ok(Self::Deprecated),
981            other => Err(format!("unknown knowledge item status: {}", other)),
982        }
983    }
984}
985
986impl KnowledgeItemStatus {
987    pub fn as_trust_level(self) -> Option<KnowledgeTrustLevel> {
988        match self {
989            Self::Working => Some(KnowledgeTrustLevel::Working),
990            Self::Promoted => Some(KnowledgeTrustLevel::Promoted),
991            Self::ApprovedDefault => Some(KnowledgeTrustLevel::ApprovedDefault),
992            Self::Deprecated => None,
993        }
994    }
995
996    pub fn is_active(self) -> bool {
997        !matches!(self, Self::Deprecated)
998    }
999}
1000
1001/// A reusable knowledge space scoped to a project, run, or global namespace.
1002#[derive(Debug, Clone, Serialize, Deserialize)]
1003pub struct KnowledgeSpaceRecord {
1004    pub id: String,
1005    pub scope: KnowledgeScope,
1006    pub project_id: Option<String>,
1007    pub namespace: Option<String>,
1008    pub title: Option<String>,
1009    pub description: Option<String>,
1010    pub trust_level: KnowledgeTrustLevel,
1011    pub metadata: Option<serde_json::Value>,
1012    pub created_at_ms: u64,
1013    pub updated_at_ms: u64,
1014}
1015
1016/// A reusable knowledge item promoted from run artifacts or validated memory.
1017#[derive(Debug, Clone, Serialize, Deserialize)]
1018pub struct KnowledgeItemRecord {
1019    pub id: String,
1020    pub space_id: String,
1021    pub coverage_key: String,
1022    pub dedupe_key: String,
1023    pub item_type: String,
1024    pub title: String,
1025    pub summary: Option<String>,
1026    pub payload: serde_json::Value,
1027    pub trust_level: KnowledgeTrustLevel,
1028    pub status: KnowledgeItemStatus,
1029    pub run_id: Option<String>,
1030    pub artifact_refs: Vec<String>,
1031    pub source_memory_ids: Vec<String>,
1032    pub freshness_expires_at_ms: Option<u64>,
1033    pub metadata: Option<serde_json::Value>,
1034    pub created_at_ms: u64,
1035    pub updated_at_ms: u64,
1036}
1037
1038/// Coverage state for a task/topic key within a knowledge space.
1039#[derive(Debug, Clone, Serialize, Deserialize)]
1040pub struct KnowledgeCoverageRecord {
1041    pub coverage_key: String,
1042    pub space_id: String,
1043    pub latest_item_id: Option<String>,
1044    pub latest_dedupe_key: Option<String>,
1045    pub last_seen_at_ms: u64,
1046    pub last_promoted_at_ms: Option<u64>,
1047    pub freshness_expires_at_ms: Option<u64>,
1048    pub metadata: Option<serde_json::Value>,
1049}
1050
1051/// Request to promote or retire a knowledge item.
1052#[derive(Debug, Clone, Serialize, Deserialize)]
1053pub struct KnowledgePromotionRequest {
1054    pub item_id: String,
1055    pub target_status: KnowledgeItemStatus,
1056    pub promoted_at_ms: u64,
1057    #[serde(default, skip_serializing_if = "Option::is_none")]
1058    pub freshness_expires_at_ms: Option<u64>,
1059    #[serde(default, skip_serializing_if = "Option::is_none")]
1060    pub reviewer_id: Option<String>,
1061    #[serde(default, skip_serializing_if = "Option::is_none")]
1062    pub approval_id: Option<String>,
1063    #[serde(default, skip_serializing_if = "Option::is_none")]
1064    pub reason: Option<String>,
1065}
1066
1067/// Result of a knowledge item promotion or retirement.
1068#[derive(Debug, Clone, Serialize, Deserialize)]
1069pub struct KnowledgePromotionResult {
1070    pub previous_status: KnowledgeItemStatus,
1071    pub previous_trust_level: KnowledgeTrustLevel,
1072    pub promoted: bool,
1073    pub item: KnowledgeItemRecord,
1074    pub coverage: KnowledgeCoverageRecord,
1075}
1076
1077#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1078#[serde(rename_all = "lowercase")]
1079pub enum NodeType {
1080    Directory,
1081    File,
1082}
1083
1084impl std::fmt::Display for NodeType {
1085    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1086        match self {
1087            NodeType::Directory => write!(f, "directory"),
1088            NodeType::File => write!(f, "file"),
1089        }
1090    }
1091}
1092
1093impl std::str::FromStr for NodeType {
1094    type Err = String;
1095    fn from_str(s: &str) -> Result<Self, Self::Err> {
1096        match s.to_lowercase().as_str() {
1097            "directory" => Ok(NodeType::Directory),
1098            "file" => Ok(NodeType::File),
1099            _ => Err(format!("unknown node type: {}", s)),
1100        }
1101    }
1102}
1103
1104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1105#[serde(rename_all = "UPPERCASE")]
1106pub enum LayerType {
1107    L0,
1108    L1,
1109    L2,
1110}
1111
1112impl std::fmt::Display for LayerType {
1113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1114        match self {
1115            LayerType::L0 => write!(f, "L0"),
1116            LayerType::L1 => write!(f, "L1"),
1117            LayerType::L2 => write!(f, "L2"),
1118        }
1119    }
1120}
1121
1122impl std::str::FromStr for LayerType {
1123    type Err = String;
1124    fn from_str(s: &str) -> Result<Self, Self::Err> {
1125        match s.to_uppercase().as_str() {
1126            "L0" | "L0_ABSTRACT" => Ok(LayerType::L0),
1127            "L1" | "L1_OVERVIEW" => Ok(LayerType::L1),
1128            "L2" | "L2_DETAIL" => Ok(LayerType::L2),
1129            _ => Err(format!("unknown layer type: {}", s)),
1130        }
1131    }
1132}
1133
1134impl LayerType {
1135    pub fn default_tokens(&self) -> usize {
1136        match self {
1137            LayerType::L0 => 100,
1138            LayerType::L1 => 2000,
1139            LayerType::L2 => 4000,
1140        }
1141    }
1142}
1143
1144#[derive(Debug, Clone, Serialize, Deserialize)]
1145pub struct RetrievalStep {
1146    pub step_type: String,
1147    pub description: String,
1148    pub layer_accessed: Option<LayerType>,
1149    pub nodes_evaluated: usize,
1150    pub scores: std::collections::HashMap<String, f64>,
1151}
1152
1153#[derive(Debug, Clone, Serialize, Deserialize)]
1154pub struct NodeVisit {
1155    pub uri: String,
1156    pub node_type: NodeType,
1157    pub score: f64,
1158    pub depth: usize,
1159    pub layer_loaded: Option<LayerType>,
1160}
1161
1162#[derive(Debug, Clone, Serialize, Deserialize)]
1163pub struct RetrievalTrajectory {
1164    pub id: String,
1165    pub query: String,
1166    pub root_uri: String,
1167    pub steps: Vec<RetrievalStep>,
1168    pub visited_nodes: Vec<NodeVisit>,
1169    pub total_duration_ms: u64,
1170}
1171
1172#[derive(Debug, Clone, Serialize, Deserialize)]
1173pub struct RetrievalResult {
1174    pub node_id: String,
1175    pub uri: String,
1176    pub content: String,
1177    pub layer_type: LayerType,
1178    pub score: f64,
1179    pub trajectory: RetrievalTrajectory,
1180}
1181
1182#[derive(Debug, Clone, Serialize, Deserialize)]
1183pub struct MemoryNode {
1184    pub id: String,
1185    pub uri: String,
1186    pub parent_uri: Option<String>,
1187    pub node_type: NodeType,
1188    pub created_at: DateTime<Utc>,
1189    pub updated_at: DateTime<Utc>,
1190    pub metadata: Option<serde_json::Value>,
1191}
1192
1193#[derive(Debug, Clone, Serialize, Deserialize)]
1194pub struct MemoryLayer {
1195    pub id: String,
1196    pub node_id: String,
1197    pub layer_type: LayerType,
1198    pub content: String,
1199    pub token_count: i64,
1200    pub embedding_id: Option<String>,
1201    pub created_at: DateTime<Utc>,
1202    pub source_chunk_id: Option<String>,
1203}
1204
1205#[derive(Debug, Clone, Serialize, Deserialize)]
1206pub struct TreeNode {
1207    pub node: MemoryNode,
1208    pub children: Vec<TreeNode>,
1209    pub layer_summary: Option<LayerSummary>,
1210}
1211
1212#[derive(Debug, Clone, Serialize, Deserialize)]
1213pub struct LayerSummary {
1214    pub l0_preview: Option<String>,
1215    pub l1_preview: Option<String>,
1216    pub has_l2: bool,
1217}
1218
1219#[derive(Debug, Clone, Serialize, Deserialize)]
1220pub struct DirectoryListing {
1221    pub uri: String,
1222    pub nodes: Vec<MemoryNode>,
1223    pub total_children: usize,
1224    pub directories: Vec<MemoryNode>,
1225    pub files: Vec<MemoryNode>,
1226}
1227
1228#[derive(Debug, Clone, Serialize, Deserialize)]
1229pub struct DistilledFact {
1230    pub id: String,
1231    pub distillation_id: String,
1232    pub content: String,
1233    pub category: FactCategory,
1234    pub importance_score: f64,
1235    pub source_message_ids: Vec<String>,
1236    pub contradicts_fact_id: Option<String>,
1237}
1238
1239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1240#[serde(rename_all = "snake_case")]
1241pub enum FactCategory {
1242    UserPreference,
1243    TaskOutcome,
1244    Learning,
1245    Fact,
1246}
1247
1248impl std::fmt::Display for FactCategory {
1249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1250        match self {
1251            FactCategory::UserPreference => write!(f, "user_preference"),
1252            FactCategory::TaskOutcome => write!(f, "task_outcome"),
1253            FactCategory::Learning => write!(f, "learning"),
1254            FactCategory::Fact => write!(f, "fact"),
1255        }
1256    }
1257}
1258
1259#[derive(Debug, Clone, Serialize, Deserialize)]
1260pub struct DistillationReport {
1261    pub distillation_id: String,
1262    pub session_id: String,
1263    pub distilled_at: DateTime<Utc>,
1264    pub facts_extracted: usize,
1265    pub importance_threshold: f64,
1266    pub user_memory_updated: bool,
1267    pub agent_memory_updated: bool,
1268    #[serde(default)]
1269    pub stored_count: usize,
1270    #[serde(default)]
1271    pub deduped_count: usize,
1272    #[serde(default)]
1273    pub memory_ids: Vec<String>,
1274    #[serde(default)]
1275    pub candidate_ids: Vec<String>,
1276    #[serde(default)]
1277    pub status: String,
1278}
1279
1280#[derive(Debug, Clone, Serialize, Deserialize)]
1281pub struct SessionDistillation {
1282    pub id: String,
1283    pub session_id: String,
1284    pub distilled_at: DateTime<Utc>,
1285    pub input_token_count: i64,
1286    pub output_memory_count: usize,
1287    pub key_facts_extracted: usize,
1288    pub importance_threshold: f64,
1289}