Skip to main content

agentic_contracts/
sister.rs

1//! Core Sister trait that all sisters must implement.
2
3use crate::errors::SisterResult;
4use crate::types::{Capability, HealthStatus, SisterType, Version};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9/// Configuration for initializing a sister.
10///
11/// v0.2.0: Made data paths flexible to support sisters with different
12/// storage models:
13/// - Memory/Vision: single data file (`data_path`)
14/// - Identity: multiple directories (`data_paths`)
15/// - Codebase: multiple graph files loaded dynamically
16/// - Time: single data file
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SisterConfig {
19    /// Primary data file/directory path.
20    /// Used by sisters with a single data location (Memory, Vision, Time)
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub data_path: Option<PathBuf>,
23
24    /// Additional named data paths.
25    /// Used by sisters with multiple data locations (Identity, Codebase).
26    ///
27    /// Examples:
28    /// - Identity: {"identities": "/path/to/identities", "receipts": "/path/to/receipts"}
29    /// - Codebase: {"default_graph": "/path/to/graph.acb"}
30    #[serde(default)]
31    pub data_paths: HashMap<String, PathBuf>,
32
33    /// Whether to create if not exists
34    pub create_if_missing: bool,
35
36    /// Read-only mode
37    pub read_only: bool,
38
39    /// Memory budget in megabytes (optional)
40    pub memory_budget_mb: Option<usize>,
41
42    /// Custom options (sister-specific)
43    #[serde(default)]
44    pub options: HashMap<String, serde_json::Value>,
45}
46
47impl Default for SisterConfig {
48    fn default() -> Self {
49        Self {
50            data_path: None,
51            data_paths: HashMap::new(),
52            create_if_missing: true,
53            read_only: false,
54            memory_budget_mb: None,
55            options: HashMap::new(),
56        }
57    }
58}
59
60impl SisterConfig {
61    /// Create a new config with a single data path
62    pub fn new(data_path: impl Into<PathBuf>) -> Self {
63        Self {
64            data_path: Some(data_path.into()),
65            ..Default::default()
66        }
67    }
68
69    /// Create a config with no data path (for stateless sisters like Time)
70    pub fn stateless() -> Self {
71        Self::default()
72    }
73
74    /// Create a config with multiple named paths (for Identity, Codebase)
75    pub fn with_paths(paths: HashMap<String, PathBuf>) -> Self {
76        Self {
77            data_paths: paths,
78            ..Default::default()
79        }
80    }
81
82    /// Get the primary data path, falling back to "." if none set
83    pub fn primary_path(&self) -> PathBuf {
84        self.data_path.clone().unwrap_or_else(|| PathBuf::from("."))
85    }
86
87    /// Get a named data path
88    pub fn get_path(&self, name: &str) -> Option<&PathBuf> {
89        self.data_paths.get(name)
90    }
91
92    /// Add a named data path
93    pub fn add_path(mut self, name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
94        self.data_paths.insert(name.into(), path.into());
95        self
96    }
97
98    /// Set read-only mode
99    pub fn read_only(mut self, read_only: bool) -> Self {
100        self.read_only = read_only;
101        self
102    }
103
104    /// Set create if missing
105    pub fn create_if_missing(mut self, create: bool) -> Self {
106        self.create_if_missing = create;
107        self
108    }
109
110    /// Set memory budget
111    pub fn memory_budget(mut self, mb: usize) -> Self {
112        self.memory_budget_mb = Some(mb);
113        self
114    }
115
116    /// Add a custom option
117    pub fn option(mut self, key: impl Into<String>, value: impl Serialize) -> Self {
118        if let Ok(v) = serde_json::to_value(value) {
119            self.options.insert(key.into(), v);
120        }
121        self
122    }
123
124    /// Get a custom option
125    pub fn get_option<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Option<T> {
126        self.options
127            .get(key)
128            .and_then(|v| serde_json::from_value(v.clone()).ok())
129    }
130}
131
132/// The core trait that ALL sisters must implement.
133///
134/// This is the foundation of the sister ecosystem. Every sister—Memory, Vision,
135/// Codebase, Identity, Time, and all future sisters—must implement this trait.
136pub trait Sister: Send + Sync {
137    /// The type of this sister
138    const SISTER_TYPE: SisterType;
139
140    /// File extension for this sister's format (without dot)
141    const FILE_EXTENSION: &'static str;
142
143    /// Initialize the sister with configuration
144    fn init(config: SisterConfig) -> SisterResult<Self>
145    where
146        Self: Sized;
147
148    /// Check health status
149    fn health(&self) -> HealthStatus;
150
151    /// Get current version
152    fn version(&self) -> Version;
153
154    /// Shutdown gracefully
155    fn shutdown(&mut self) -> SisterResult<()>;
156
157    /// Get capabilities this sister provides
158    fn capabilities(&self) -> Vec<Capability>;
159
160    // ═══════════════════════════════════════════════════════
161    // DEFAULT IMPLEMENTATIONS
162    // ═══════════════════════════════════════════════════════
163
164    /// Get the sister type
165    fn sister_type(&self) -> SisterType {
166        Self::SISTER_TYPE
167    }
168
169    /// Get the file extension
170    fn file_extension(&self) -> &'static str {
171        Self::FILE_EXTENSION
172    }
173
174    /// Check if the sister is healthy
175    fn is_healthy(&self) -> bool {
176        self.health().healthy
177    }
178
179    /// Get a human-readable name
180    fn name(&self) -> String {
181        format!("Agentic{:?}", Self::SISTER_TYPE)
182    }
183
184    /// Get MCP tool prefix
185    fn mcp_prefix(&self) -> &'static str {
186        Self::SISTER_TYPE.mcp_prefix()
187    }
188}
189
190/// Information about a sister (for discovery)
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct SisterInfo {
193    pub sister_type: SisterType,
194    pub version: Version,
195    pub file_extension: String,
196    pub capabilities: Vec<Capability>,
197    pub mcp_prefix: String,
198}
199
200impl SisterInfo {
201    /// Create from a sister instance
202    pub fn from_sister<S: Sister>(sister: &S) -> Self {
203        Self {
204            sister_type: S::SISTER_TYPE,
205            version: sister.version(),
206            file_extension: S::FILE_EXTENSION.to_string(),
207            capabilities: sister.capabilities(),
208            mcp_prefix: S::SISTER_TYPE.mcp_prefix().to_string(),
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_config_builder_single_path() {
219        let config = SisterConfig::new("/data/memory")
220            .read_only(true)
221            .memory_budget(512)
222            .option("custom_key", "custom_value");
223
224        assert_eq!(config.primary_path(), PathBuf::from("/data/memory"));
225        assert!(config.read_only);
226        assert_eq!(config.memory_budget_mb, Some(512));
227        assert_eq!(
228            config.get_option::<String>("custom_key"),
229            Some("custom_value".to_string())
230        );
231    }
232
233    #[test]
234    fn test_config_multi_path() {
235        let config = SisterConfig::default()
236            .add_path("identities", "/data/identities")
237            .add_path("receipts", "/data/receipts")
238            .add_path("trust", "/data/trust");
239
240        assert_eq!(
241            config.get_path("identities"),
242            Some(&PathBuf::from("/data/identities"))
243        );
244        assert_eq!(
245            config.get_path("receipts"),
246            Some(&PathBuf::from("/data/receipts"))
247        );
248        assert_eq!(config.data_paths.len(), 3);
249    }
250
251    #[test]
252    fn test_config_stateless() {
253        let config = SisterConfig::stateless();
254        assert!(config.data_path.is_none());
255        assert!(config.data_paths.is_empty());
256    }
257}