Skip to main content

agentic_contracts/
context.rs

1//! Context management traits for all sisters.
2//!
3//! v0.2.0 splits the old monolithic `ContextManagement` into two traits
4//! that match how sisters actually work:
5//!
6//! - **SessionManagement**: Append-only sequential sessions (Memory, Vision, Identity)
7//! - **WorkspaceManagement**: Switchable, named workspaces (Codebase)
8//!
9//! Sisters implement whichever fits. Time implements neither (stateless).
10//! Hydra can query both via the unified `ContextInfo` type.
11
12use crate::errors::SisterResult;
13use crate::types::{Metadata, SisterType, UniqueId};
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16
17/// Unique identifier for a context (session or workspace).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct ContextId(pub UniqueId);
20
21impl ContextId {
22    /// Create a new random context ID
23    pub fn new() -> Self {
24        Self(UniqueId::new())
25    }
26
27    /// The default context (always exists)
28    pub fn default_context() -> Self {
29        Self(UniqueId::nil())
30    }
31
32    /// Check if this is the default context
33    pub fn is_default(&self) -> bool {
34        self.0 == UniqueId::nil()
35    }
36}
37
38impl Default for ContextId {
39    fn default() -> Self {
40        Self::new()
41    }
42}
43
44impl std::fmt::Display for ContextId {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        write!(f, "ctx_{}", self.0)
47    }
48}
49
50impl From<&str> for ContextId {
51    fn from(s: &str) -> Self {
52        let s = s.strip_prefix("ctx_").unwrap_or(s);
53        if let Ok(uuid) = uuid::Uuid::parse_str(s) {
54            Self(UniqueId::from_uuid(uuid))
55        } else {
56            Self::new()
57        }
58    }
59}
60
61/// Summary information about a context
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ContextSummary {
64    pub id: ContextId,
65    pub name: String,
66    pub created_at: DateTime<Utc>,
67    pub updated_at: DateTime<Utc>,
68    pub item_count: usize,
69    pub size_bytes: usize,
70}
71
72/// Full context information
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ContextInfo {
75    pub id: ContextId,
76    pub name: String,
77    pub created_at: DateTime<Utc>,
78    pub updated_at: DateTime<Utc>,
79    pub item_count: usize,
80    pub size_bytes: usize,
81    #[serde(default)]
82    pub metadata: Metadata,
83}
84
85impl From<ContextInfo> for ContextSummary {
86    fn from(info: ContextInfo) -> Self {
87        Self {
88            id: info.id,
89            name: info.name,
90            created_at: info.created_at,
91            updated_at: info.updated_at,
92            item_count: info.item_count,
93            size_bytes: info.size_bytes,
94        }
95    }
96}
97
98/// Exportable context snapshot (for backup/transfer)
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ContextSnapshot {
101    /// Which sister type this came from
102    pub sister_type: SisterType,
103
104    /// Version of the sister that created this
105    pub version: crate::types::Version,
106
107    /// Context information
108    pub context_info: ContextInfo,
109
110    /// Serialized context data (sister-specific format)
111    #[serde(with = "base64_serde")]
112    pub data: Vec<u8>,
113
114    /// Checksum of the data (BLAKE3)
115    #[serde(with = "hex_serde")]
116    pub checksum: [u8; 32],
117
118    /// When this snapshot was created
119    pub snapshot_at: DateTime<Utc>,
120}
121
122impl ContextSnapshot {
123    /// Verify the checksum
124    pub fn verify(&self) -> bool {
125        let computed = blake3::hash(&self.data);
126        computed.as_bytes() == &self.checksum
127    }
128}
129
130// ═══════════════════════════════════════════════════════════════════
131// SESSION MANAGEMENT — Append-only sequential sessions
132// ═══════════════════════════════════════════════════════════════════
133
134/// Session management for sisters with append-only sequential sessions.
135///
136/// Used by: Memory (sessions), Vision (sessions), Identity (chains)
137///
138/// Key difference from WorkspaceManagement:
139/// - Sessions are sequential — you don't "switch back" to an old session
140/// - Sessions are append-only — you can't delete past sessions
141/// - The current session is always the latest one
142///
143/// NOT used by: Time (stateless), Codebase (uses WorkspaceManagement)
144pub trait SessionManagement {
145    /// Start a new session. Returns the session ID.
146    /// The previous session (if any) is automatically ended
147    fn start_session(&mut self, name: &str) -> SisterResult<ContextId>;
148
149    /// Start a new session with metadata
150    fn start_session_with_metadata(
151        &mut self,
152        name: &str,
153        metadata: Metadata,
154    ) -> SisterResult<ContextId> {
155        let _ = metadata;
156        self.start_session(name)
157    }
158
159    /// End the current session.
160    /// After this, a new session must be started before operations
161    fn end_session(&mut self) -> SisterResult<()>;
162
163    /// Get the current session ID.
164    /// Returns None if no session is active
165    fn current_session(&self) -> Option<ContextId>;
166
167    /// Get info about the current session
168    fn current_session_info(&self) -> SisterResult<ContextInfo>;
169
170    /// List all past sessions (most recent first)
171    fn list_sessions(&self) -> SisterResult<Vec<ContextSummary>>;
172
173    /// Get info about a specific past session
174    fn get_session_info(&self, id: ContextId) -> SisterResult<ContextInfo> {
175        self.list_sessions()?
176            .into_iter()
177            .find(|s| s.id == id)
178            .map(|summary| ContextInfo {
179                id: summary.id,
180                name: summary.name,
181                created_at: summary.created_at,
182                updated_at: summary.updated_at,
183                item_count: summary.item_count,
184                size_bytes: summary.size_bytes,
185                metadata: Metadata::new(),
186            })
187            .ok_or_else(|| crate::errors::SisterError::context_not_found(id.to_string()))
188    }
189
190    /// Export a session as a snapshot (for backup/transfer)
191    fn export_session(&self, id: ContextId) -> SisterResult<ContextSnapshot>;
192
193    /// Import a session from a snapshot
194    fn import_session(&mut self, snapshot: ContextSnapshot) -> SisterResult<ContextId>;
195}
196
197// ═══════════════════════════════════════════════════════════════════
198// WORKSPACE MANAGEMENT — Switchable named workspaces
199// ═══════════════════════════════════════════════════════════════════
200
201/// Workspace management for sisters with switchable, named contexts.
202///
203/// Used by: Codebase (workspaces/graphs)
204///
205/// Key difference from SessionManagement:
206/// - Workspaces are concurrent — you can switch between them
207/// - Workspaces can be created, renamed, and deleted
208/// - Multiple workspaces exist simultaneously
209pub trait WorkspaceManagement {
210    /// Create a new workspace
211    fn create_workspace(&mut self, name: &str) -> SisterResult<ContextId>;
212
213    /// Create a new workspace with metadata
214    fn create_workspace_with_metadata(
215        &mut self,
216        name: &str,
217        metadata: Metadata,
218    ) -> SisterResult<ContextId> {
219        let _ = metadata;
220        self.create_workspace(name)
221    }
222
223    /// Switch to a different workspace
224    fn switch_workspace(&mut self, id: ContextId) -> SisterResult<()>;
225
226    /// Get the current workspace ID
227    fn current_workspace(&self) -> ContextId;
228
229    /// Get info about the current workspace
230    fn current_workspace_info(&self) -> SisterResult<ContextInfo>;
231
232    /// List all workspaces
233    fn list_workspaces(&self) -> SisterResult<Vec<ContextSummary>>;
234
235    /// Delete a workspace.
236    /// Cannot delete the current workspace — switch first
237    fn delete_workspace(&mut self, id: ContextId) -> SisterResult<()>;
238
239    /// Rename a workspace
240    fn rename_workspace(&mut self, id: ContextId, new_name: &str) -> SisterResult<()>;
241
242    /// Export workspace as snapshot
243    fn export_workspace(&self, id: ContextId) -> SisterResult<ContextSnapshot>;
244
245    /// Import workspace from snapshot
246    fn import_workspace(&mut self, snapshot: ContextSnapshot) -> SisterResult<ContextId>;
247
248    /// Get workspace info by ID
249    fn get_workspace_info(&self, id: ContextId) -> SisterResult<ContextInfo> {
250        self.list_workspaces()?
251            .into_iter()
252            .find(|w| w.id == id)
253            .map(|summary| ContextInfo {
254                id: summary.id,
255                name: summary.name,
256                created_at: summary.created_at,
257                updated_at: summary.updated_at,
258                item_count: summary.item_count,
259                size_bytes: summary.size_bytes,
260                metadata: Metadata::new(),
261            })
262            .ok_or_else(|| crate::errors::SisterError::context_not_found(id.to_string()))
263    }
264
265    /// Check if a workspace exists
266    fn workspace_exists(&self, id: ContextId) -> bool {
267        self.get_workspace_info(id).is_ok()
268    }
269}
270
271/// Session context for Hydra integration (token-efficient summary).
272///
273/// Works for both session-based and workspace-based sisters.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct SessionContext {
276    /// Which sister this is from
277    pub sister_type: SisterType,
278
279    /// Current context ID (session or workspace)
280    pub context_id: ContextId,
281
282    /// Context name
283    pub context_name: String,
284
285    /// Brief summary for LLM context
286    pub summary: String,
287
288    /// Recent/relevant items (for quick reference)
289    pub recent_items: Vec<String>,
290
291    /// Additional metadata
292    #[serde(default)]
293    pub metadata: Metadata,
294}
295
296// Base64 serialization for binary data
297mod base64_serde {
298    use base64::{engine::general_purpose::STANDARD, Engine};
299    use serde::{Deserialize, Deserializer, Serializer};
300
301    pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
302    where
303        S: Serializer,
304    {
305        serializer.serialize_str(&STANDARD.encode(bytes))
306    }
307
308    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
309    where
310        D: Deserializer<'de>,
311    {
312        let s = String::deserialize(deserializer)?;
313        STANDARD.decode(&s).map_err(serde::de::Error::custom)
314    }
315}
316
317// Hex serialization for checksums
318mod hex_serde {
319    use serde::{Deserialize, Deserializer, Serializer};
320
321    pub fn serialize<S>(bytes: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
322    where
323        S: Serializer,
324    {
325        serializer.serialize_str(&hex::encode(bytes))
326    }
327
328    pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
329    where
330        D: Deserializer<'de>,
331    {
332        let s = String::deserialize(deserializer)?;
333        let bytes = hex::decode(&s).map_err(serde::de::Error::custom)?;
334        bytes
335            .try_into()
336            .map_err(|_| serde::de::Error::custom("invalid checksum length"))
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn test_context_id() {
346        let id = ContextId::new();
347        let s = id.to_string();
348        assert!(s.starts_with("ctx_"));
349
350        let default = ContextId::default_context();
351        assert!(default.is_default());
352    }
353
354    #[test]
355    fn test_context_id_from_str() {
356        let id = ContextId::new();
357        let s = id.to_string();
358        let parsed: ContextId = s.as_str().into();
359        assert!(!parsed.is_default() || id.is_default());
360    }
361}