Skip to main content

oxios_kernel/
space.rs

1//! Space: logical work partition for context isolation.
2//!
3//! A Space provides isolated memory and workspace for different
4//! contexts (projects, topics, domains). The OS automatically
5//! routes user messages to the appropriate Space based on
6//! filesystem paths, keywords, or LLM-based topic detection.
7
8pub mod conversation_buffer;
9pub mod detection;
10pub mod knowledge_bridge;
11pub mod manager;
12
13pub use conversation_buffer::{ConversationBuffer, ConversationTurn};
14pub use detection::{extract_filesystem_path, match_keywords, PathMatcher};
15pub use knowledge_bridge::{CrossRefEntry, KnowledgeBridge, KnowledgeFlow};
16pub use manager::{SpaceManager, SpaceManagerError};
17
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use std::path::{Path, PathBuf};
21use uuid::Uuid;
22
23/// Unique identifier for a Space.
24pub type SpaceId = Uuid;
25
26/// How a Space was created.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum SpaceSource {
30    /// Auto-created from a detected filesystem path.
31    AutoResource,
32    /// Auto-created from a detected topic shift.
33    AutoTopic,
34    /// Explicitly created by the user.
35    Manual,
36}
37
38impl std::fmt::Display for SpaceSource {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            SpaceSource::AutoResource => write!(f, "auto_resource"),
42            SpaceSource::AutoTopic => write!(f, "auto_topic"),
43            SpaceSource::Manual => write!(f, "manual"),
44        }
45    }
46}
47
48/// A logical work partition.
49///
50/// Each Space has its own scoped memory and workspace.
51/// The OS automatically routes messages to the appropriate Space.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Space {
54    /// Unique identifier.
55    pub id: SpaceId,
56    /// Human-readable name.
57    /// - AutoResource: derived from directory name (e.g. "oxios")
58    /// - AutoTopic: estimated from LLM classification (e.g. "일상")
59    /// - Default Space: empty string (named after topic forms)
60    pub name: String,
61    /// How this Space was created.
62    pub source: SpaceSource,
63    /// Actual filesystem paths bound to this Space.
64    /// AgentRuntime sets CWD to paths[0] when executing.
65    /// Empty for non-filesystem Spaces (일상, 요리 등).
66    pub paths: Vec<PathBuf>,
67    /// Scratch workspace directory for this Space.
68    /// Temporary files, logs, build artifacts go here.
69    pub workspace_dir: PathBuf,
70    /// Tags for keyword matching (Layer 2 detection).
71    #[serde(default)]
72    pub tags: Vec<String>,
73    /// Whether this Space is currently active.
74    #[serde(default)]
75    pub active: bool,
76    /// When this Space was created.
77    pub created_at: DateTime<Utc>,
78    /// When this Space was last active.
79    pub last_active_at: DateTime<Utc>,
80    /// Number of interactions in this Space.
81    #[serde(default)]
82    pub interaction_count: u64,
83    /// Whether this Space allows cross-Space knowledge access.
84    /// Default: true. Set to false for private Spaces.
85    #[serde(default = "default_true")]
86    pub knowledge_visible: bool,
87}
88
89fn default_true() -> bool {
90    true
91}
92
93impl Space {
94    /// Create a new Space with the given name and source.
95    pub fn new(name: impl Into<String>, source: SpaceSource) -> Self {
96        let now = Utc::now();
97        Self {
98            id: SpaceId::new_v4(),
99            name: name.into(),
100            source,
101            paths: Vec::new(),
102            workspace_dir: PathBuf::new(),
103            tags: Vec::new(),
104            active: false,
105            created_at: now,
106            last_active_at: now,
107            interaction_count: 0,
108            knowledge_visible: true,
109        }
110    }
111
112    /// Create a Space from a detected filesystem path.
113    pub fn from_path(path: &Path) -> Self {
114        let name = path
115            .file_name()
116            .and_then(|n| n.to_str())
117            .unwrap_or("unknown")
118            .to_string();
119        let mut space = Self::new(&name, SpaceSource::AutoResource);
120        space.paths.push(path.to_path_buf());
121        space
122    }
123
124    /// Create a Space from a detected topic.
125    pub fn from_topic(topic: &str) -> Self {
126        Self::new(topic, SpaceSource::AutoTopic)
127    }
128
129    /// Record that this Space was interacted with.
130    pub fn touch(&mut self) {
131        self.last_active_at = Utc::now();
132        self.interaction_count += 1;
133    }
134
135    /// Mark this Space as active.
136    pub fn activate(&mut self) {
137        self.active = true;
138    }
139
140    /// Mark this Space as inactive.
141    pub fn deactivate(&mut self) {
142        self.active = false;
143    }
144
145    /// Whether this Space has a name (non-empty).
146    pub fn is_named(&self) -> bool {
147        !self.name.is_empty()
148    }
149
150    /// Whether this is the default (unnamed) Space.
151    pub fn is_default(&self) -> bool {
152        self.name.is_empty()
153    }
154
155    /// Get the emoji indicator for this Space.
156    pub fn emoji(&self) -> &'static str {
157        if self.name.is_empty() {
158            "⚪"
159        } else {
160            // Map common names to emojis
161            match self.name.to_lowercase().as_str() {
162                "oxios" | "dev" | "개발" => "🔧",
163                "일상" | "daily" | "生活" => "🏠",
164                "blog" | "블로그" => "📝",
165                "docs" | "문서" => "📄",
166                "study" | "공부" | "학습" => "📚",
167                "cook" | "요리" | "recipe" | "레시피" => "🍳",
168                "work" | "업무" => "💼",
169                _ => "📦",
170            }
171        }
172    }
173
174    /// Add a tag for keyword matching.
175    pub fn add_tag(&mut self, tag: impl Into<String>) {
176        let tag = tag.into();
177        if !self.tags.contains(&tag) {
178            self.tags.push(tag);
179        }
180    }
181}
182
183#[allow(missing_docs)]
184pub static DEFAULT_SPACE_ID: std::sync::OnceLock<uuid::Uuid> = std::sync::OnceLock::new();
185
186/// Get the default Space ID.
187pub fn default_space_id() -> SpaceId {
188    *DEFAULT_SPACE_ID
189        .get_or_init(|| uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap())
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_space_new() {
198        let s = Space::new("oxios", SpaceSource::AutoResource);
199        assert_eq!(s.name, "oxios");
200        assert_eq!(s.source, SpaceSource::AutoResource);
201        assert!(!s.active);
202        assert_eq!(s.interaction_count, 0);
203    }
204
205    #[test]
206    fn test_space_from_path() {
207        let path = PathBuf::from("/projects/oxios");
208        let s = Space::from_path(&path);
209        assert_eq!(s.name, "oxios");
210        assert_eq!(s.source, SpaceSource::AutoResource);
211        assert_eq!(s.paths, vec![path]);
212    }
213
214    #[test]
215    fn test_space_touch() {
216        let mut s = Space::new("test", SpaceSource::Manual);
217        assert_eq!(s.interaction_count, 0);
218        s.touch();
219        assert_eq!(s.interaction_count, 1);
220    }
221
222    #[test]
223    fn test_space_emoji() {
224        let mut s = Space::new("", SpaceSource::Manual);
225        assert_eq!(s.emoji(), "⚪");
226
227        let mut s = Space::new("oxios", SpaceSource::AutoResource);
228        assert_eq!(s.emoji(), "🔧");
229
230        let mut s = Space::new("일상", SpaceSource::AutoTopic);
231        assert_eq!(s.emoji(), "🏠");
232
233        let mut s = Space::new("random", SpaceSource::Manual);
234        assert_eq!(s.emoji(), "📦");
235    }
236
237    #[test]
238    fn test_space_default() {
239        let s = Space::new("", SpaceSource::Manual);
240        assert!(s.is_default());
241        assert!(!s.is_named());
242
243        let s = Space::new("oxios", SpaceSource::AutoResource);
244        assert!(!s.is_default());
245        assert!(s.is_named());
246    }
247}