Skip to main content

rustyclaw_core/
workspace_context.rs

1//! Workspace context injection.
2//!
3//! Loads and injects workspace files into system prompts for agent continuity.
4//! This enables the agent to maintain personality (SOUL.md), remember context
5//! (MEMORY.md), and follow workspace conventions (AGENTS.md, TOOLS.md).
6
7use chrono::{Duration, Local};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12/// Session type for security scoping.
13///
14/// Different session types have different access levels to sensitive files
15/// like MEMORY.md and USER.md which may contain private information.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SessionType {
18    /// Main/direct session with the owner.
19    /// Has full access to all workspace files including MEMORY.md.
20    Main,
21    /// Group chat or shared context.
22    /// Restricted access — excludes MEMORY.md and USER.md for privacy.
23    Group,
24    /// Isolated sub-agent session.
25    /// May have restricted access depending on spawning context.
26    Isolated,
27}
28
29/// Configuration for workspace context injection.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct WorkspaceContextConfig {
32    /// Enable workspace file injection.
33    #[serde(default = "default_true")]
34    pub enabled: bool,
35
36    /// Inject SOUL.md (personality).
37    #[serde(default = "default_true")]
38    pub inject_soul: bool,
39
40    /// Inject AGENTS.md (behavior guidelines).
41    #[serde(default = "default_true")]
42    pub inject_agents: bool,
43
44    /// Inject TOOLS.md (tool usage notes).
45    #[serde(default = "default_true")]
46    pub inject_tools: bool,
47
48    /// Inject IDENTITY.md (agent identity).
49    #[serde(default = "default_true")]
50    pub inject_identity: bool,
51
52    /// Inject USER.md (user profile) — main session only.
53    #[serde(default = "default_true")]
54    pub inject_user: bool,
55
56    /// Inject MEMORY.md (long-term memory) — main session only.
57    #[serde(default = "default_true")]
58    pub inject_memory: bool,
59
60    /// Inject HEARTBEAT.md (periodic task checklist).
61    #[serde(default = "default_true")]
62    pub inject_heartbeat: bool,
63
64    /// Inject daily memory files (memory/YYYY-MM-DD.md) — main session only.
65    #[serde(default = "default_true")]
66    pub inject_daily: bool,
67
68    /// Number of daily memory files to include (today + N days back).
69    #[serde(default = "default_daily_lookback")]
70    pub daily_lookback_days: u32,
71}
72
73fn default_true() -> bool {
74    true
75}
76
77fn default_daily_lookback() -> u32 {
78    1 // Today + yesterday
79}
80
81impl Default for WorkspaceContextConfig {
82    fn default() -> Self {
83        Self {
84            enabled: true,
85            inject_soul: true,
86            inject_agents: true,
87            inject_tools: true,
88            inject_identity: true,
89            inject_user: true,
90            inject_memory: true,
91            inject_heartbeat: true,
92            inject_daily: true,
93            daily_lookback_days: default_daily_lookback(),
94        }
95    }
96}
97
98/// Workspace file metadata.
99struct WorkspaceFile {
100    /// Relative path from workspace.
101    path: &'static str,
102    /// Header to use in prompt.
103    header: &'static str,
104    /// Only include in main session (privacy).
105    main_only: bool,
106    /// Config field to check for inclusion.
107    config_field: ConfigField,
108}
109
110/// Which config field controls this file.
111enum ConfigField {
112    Soul,
113    Agents,
114    Tools,
115    Identity,
116    User,
117    Memory,
118    Heartbeat,
119}
120
121const WORKSPACE_FILES: &[WorkspaceFile] = &[
122    WorkspaceFile {
123        path: "SOUL.md",
124        header: "SOUL.md",
125        main_only: false,
126        config_field: ConfigField::Soul,
127    },
128    WorkspaceFile {
129        path: "AGENTS.md",
130        header: "AGENTS.md",
131        main_only: false,
132        config_field: ConfigField::Agents,
133    },
134    WorkspaceFile {
135        path: "TOOLS.md",
136        header: "TOOLS.md",
137        main_only: false,
138        config_field: ConfigField::Tools,
139    },
140    WorkspaceFile {
141        path: "IDENTITY.md",
142        header: "IDENTITY.md",
143        main_only: false,
144        config_field: ConfigField::Identity,
145    },
146    WorkspaceFile {
147        path: "USER.md",
148        header: "USER.md",
149        main_only: true, // Privacy: only in main session
150        config_field: ConfigField::User,
151    },
152    WorkspaceFile {
153        path: "MEMORY.md",
154        header: "MEMORY.md",
155        main_only: true, // Privacy: only in main session
156        config_field: ConfigField::Memory,
157    },
158    WorkspaceFile {
159        path: "HEARTBEAT.md",
160        header: "HEARTBEAT.md",
161        main_only: false,
162        config_field: ConfigField::Heartbeat,
163    },
164];
165
166/// Workspace context builder.
167///
168/// Loads workspace files and builds system prompt sections for injection
169/// into agent conversations.
170pub struct WorkspaceContext {
171    workspace_dir: PathBuf,
172    config: WorkspaceContextConfig,
173}
174
175impl WorkspaceContext {
176    /// Create a new workspace context builder.
177    pub fn new(workspace_dir: PathBuf) -> Self {
178        Self {
179            workspace_dir,
180            config: WorkspaceContextConfig::default(),
181        }
182    }
183
184    /// Create a workspace context with custom config.
185    pub fn with_config(workspace_dir: PathBuf, config: WorkspaceContextConfig) -> Self {
186        Self {
187            workspace_dir,
188            config,
189        }
190    }
191
192    /// Check if a file should be included based on config and session type.
193    fn should_include(&self, file: &WorkspaceFile, session_type: SessionType) -> bool {
194        // Skip main-only files in non-main sessions
195        if file.main_only && session_type != SessionType::Main {
196            return false;
197        }
198
199        // Check config field
200        match file.config_field {
201            ConfigField::Soul => self.config.inject_soul,
202            ConfigField::Agents => self.config.inject_agents,
203            ConfigField::Tools => self.config.inject_tools,
204            ConfigField::Identity => self.config.inject_identity,
205            ConfigField::User => self.config.inject_user,
206            ConfigField::Memory => self.config.inject_memory,
207            ConfigField::Heartbeat => self.config.inject_heartbeat,
208        }
209    }
210
211    /// Build system prompt section from workspace files.
212    ///
213    /// Returns a formatted string containing all applicable workspace files
214    /// for inclusion in the system prompt.
215    pub fn build_context(&self, session_type: SessionType) -> String {
216        if !self.config.enabled {
217            return String::new();
218        }
219
220        let mut sections = Vec::new();
221
222        // Load standard workspace files
223        for file in WORKSPACE_FILES {
224            if !self.should_include(file, session_type) {
225                continue;
226            }
227
228            let path = self.workspace_dir.join(file.path);
229
230            if let Ok(content) = fs::read_to_string(&path) {
231                let content = content.trim();
232                if !content.is_empty() {
233                    sections.push(format!("## {}\n{}", file.header, content));
234                }
235            }
236        }
237
238        // Add daily memory files for main session
239        if session_type == SessionType::Main && self.config.inject_daily {
240            if let Some(daily) = self.load_daily_memory() {
241                sections.push(daily);
242            }
243        }
244
245        if sections.is_empty() {
246            String::new()
247        } else {
248            format!(
249                "# Project Context\n\
250                 The following project context files have been loaded:\n\n{}",
251                sections.join("\n\n---\n\n")
252            )
253        }
254    }
255
256    /// Load today's and recent daily memory files.
257    fn load_daily_memory(&self) -> Option<String> {
258        let today = Local::now().date_naive();
259        let mut daily_sections = Vec::new();
260
261        for i in 0..=self.config.daily_lookback_days {
262            let date = today - Duration::days(i as i64);
263            let filename = format!("memory/{}.md", date.format("%Y-%m-%d"));
264            let path = self.workspace_dir.join(&filename);
265
266            if let Ok(content) = fs::read_to_string(&path) {
267                let content = content.trim();
268                if !content.is_empty() {
269                    daily_sections.push(format!("### {}\n{}", filename, content));
270                }
271            }
272        }
273
274        if daily_sections.is_empty() {
275            None
276        } else {
277            Some(format!(
278                "## Recent Daily Notes\n{}",
279                daily_sections.join("\n\n")
280            ))
281        }
282    }
283
284    /// Get list of files that should be audited on startup.
285    ///
286    /// Returns a list of (path, exists) tuples for workspace file status.
287    pub fn audit_files(&self, session_type: SessionType) -> Vec<(String, bool)> {
288        WORKSPACE_FILES
289            .iter()
290            .filter(|f| self.should_include(f, session_type))
291            .map(|f| {
292                let exists = self.workspace_dir.join(f.path).exists();
293                (f.path.to_string(), exists)
294            })
295            .collect()
296    }
297
298    /// Get the workspace directory.
299    pub fn workspace_dir(&self) -> &Path {
300        &self.workspace_dir
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use std::fs;
308    use tempfile::TempDir;
309
310    fn setup_workspace() -> TempDir {
311        let dir = TempDir::new().unwrap();
312        fs::write(dir.path().join("SOUL.md"), "Be helpful and concise.").unwrap();
313        fs::write(dir.path().join("MEMORY.md"), "User prefers Rust.").unwrap();
314        fs::write(dir.path().join("AGENTS.md"), "Follow instructions.").unwrap();
315        fs::create_dir(dir.path().join("memory")).unwrap();
316
317        let today = Local::now().format("%Y-%m-%d").to_string();
318        fs::write(
319            dir.path().join(format!("memory/{}.md", today)),
320            "# Today\nWorked on RustyClaw.",
321        )
322        .unwrap();
323        dir
324    }
325
326    #[test]
327    fn test_main_session_includes_memory() {
328        let workspace = setup_workspace();
329        let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
330
331        let prompt = ctx.build_context(SessionType::Main);
332        assert!(prompt.contains("SOUL.md"));
333        assert!(prompt.contains("MEMORY.md"));
334        assert!(prompt.contains("User prefers Rust"));
335    }
336
337    #[test]
338    fn test_group_session_excludes_memory() {
339        let workspace = setup_workspace();
340        let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
341
342        let prompt = ctx.build_context(SessionType::Group);
343        assert!(prompt.contains("SOUL.md"));
344        assert!(!prompt.contains("MEMORY.md"));
345        assert!(!prompt.contains("User prefers Rust"));
346    }
347
348    #[test]
349    fn test_isolated_session_excludes_memory() {
350        let workspace = setup_workspace();
351        let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
352
353        let prompt = ctx.build_context(SessionType::Isolated);
354        assert!(prompt.contains("SOUL.md"));
355        assert!(!prompt.contains("MEMORY.md"));
356    }
357
358    #[test]
359    fn test_daily_memory_loading() {
360        let workspace = setup_workspace();
361        let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
362
363        let prompt = ctx.build_context(SessionType::Main);
364        assert!(prompt.contains("Recent Daily Notes"));
365        assert!(prompt.contains("Worked on RustyClaw"));
366    }
367
368    #[test]
369    fn test_disabled_context() {
370        let workspace = setup_workspace();
371        let config = WorkspaceContextConfig {
372            enabled: false,
373            ..Default::default()
374        };
375        let ctx = WorkspaceContext::with_config(workspace.path().to_path_buf(), config);
376
377        let prompt = ctx.build_context(SessionType::Main);
378        assert!(prompt.is_empty());
379    }
380
381    #[test]
382    fn test_selective_injection() {
383        let workspace = setup_workspace();
384        let config = WorkspaceContextConfig {
385            enabled: true,
386            inject_soul: true,
387            inject_memory: false, // Disabled
388            ..Default::default()
389        };
390        let ctx = WorkspaceContext::with_config(workspace.path().to_path_buf(), config);
391
392        let prompt = ctx.build_context(SessionType::Main);
393        assert!(prompt.contains("SOUL.md"));
394        assert!(!prompt.contains("MEMORY.md"));
395    }
396
397    #[test]
398    fn test_audit_files() {
399        let workspace = setup_workspace();
400        let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
401
402        let audit = ctx.audit_files(SessionType::Main);
403        
404        // SOUL.md exists
405        assert!(audit.iter().any(|(p, e)| p == "SOUL.md" && *e));
406        // MEMORY.md exists
407        assert!(audit.iter().any(|(p, e)| p == "MEMORY.md" && *e));
408        // TOOLS.md doesn't exist
409        assert!(audit.iter().any(|(p, e)| p == "TOOLS.md" && !*e));
410    }
411}