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/// Sub-agent context with parent information for isolation.
167#[derive(Debug, Clone, Default)]
168pub struct SubagentInfo {
169    /// Parent session key for communication.
170    pub parent_key: Option<String>,
171    /// Task description assigned to this sub-agent.
172    pub task: Option<String>,
173    /// Label if provided.
174    pub label: Option<String>,
175}
176
177/// Workspace context builder.
178///
179/// Loads workspace files and builds system prompt sections for injection
180/// into agent conversations.
181pub struct WorkspaceContext {
182    workspace_dir: PathBuf,
183    config: WorkspaceContextConfig,
184    subagent_info: Option<SubagentInfo>,
185}
186
187impl WorkspaceContext {
188    /// Create a new workspace context builder.
189    pub fn new(workspace_dir: PathBuf) -> Self {
190        Self {
191            workspace_dir,
192            config: WorkspaceContextConfig::default(),
193            subagent_info: None,
194        }
195    }
196
197    /// Create a workspace context with custom config.
198    pub fn with_config(workspace_dir: PathBuf, config: WorkspaceContextConfig) -> Self {
199        Self {
200            workspace_dir,
201            config,
202            subagent_info: None,
203        }
204    }
205
206    /// Create a workspace context for a sub-agent session.
207    pub fn for_subagent(
208        workspace_dir: PathBuf,
209        config: WorkspaceContextConfig,
210        info: SubagentInfo,
211    ) -> Self {
212        Self {
213            workspace_dir,
214            config,
215            subagent_info: Some(info),
216        }
217    }
218
219    /// Check if a file should be included based on config and session type.
220    fn should_include(&self, file: &WorkspaceFile, session_type: SessionType) -> bool {
221        // Skip main-only files in non-main sessions
222        if file.main_only && session_type != SessionType::Main {
223            return false;
224        }
225
226        // Check config field
227        match file.config_field {
228            ConfigField::Soul => self.config.inject_soul,
229            ConfigField::Agents => self.config.inject_agents,
230            ConfigField::Tools => self.config.inject_tools,
231            ConfigField::Identity => self.config.inject_identity,
232            ConfigField::User => self.config.inject_user,
233            ConfigField::Memory => self.config.inject_memory,
234            ConfigField::Heartbeat => self.config.inject_heartbeat,
235        }
236    }
237
238    /// Build system prompt section from workspace files.
239    ///
240    /// Returns a formatted string containing all applicable workspace files
241    /// for inclusion in the system prompt.
242    pub fn build_context(&self, session_type: SessionType) -> String {
243        if !self.config.enabled {
244            return String::new();
245        }
246
247        let mut sections = Vec::new();
248
249        // Load standard workspace files
250        for file in WORKSPACE_FILES {
251            if !self.should_include(file, session_type) {
252                continue;
253            }
254
255            let path = self.workspace_dir.join(file.path);
256
257            if let Ok(content) = fs::read_to_string(&path) {
258                let content = content.trim();
259                if !content.is_empty() {
260                    sections.push(format!("## {}\n{}", file.header, content));
261                }
262            }
263        }
264
265        // Add daily memory files for main session
266        if session_type == SessionType::Main && self.config.inject_daily {
267            if let Some(daily) = self.load_daily_memory() {
268                sections.push(daily);
269            }
270        }
271
272        // Add sub-agent guidance for isolated sessions
273        if session_type == SessionType::Isolated {
274            sections.push(self.build_subagent_guidance());
275        }
276
277        if sections.is_empty() {
278            String::new()
279        } else {
280            format!(
281                "# Project Context\n\
282                 The following project context files have been loaded:\n\n{}",
283                sections.join("\n\n---\n\n")
284            )
285        }
286    }
287
288    /// Build guidance section for sub-agent sessions.
289    fn build_subagent_guidance(&self) -> String {
290        let mut guidance = String::from("## Sub-Agent Guidelines\n\n");
291        guidance.push_str(
292            "You are running in an **isolated sub-agent session** spawned by a parent agent.\n\n",
293        );
294
295        // Add task context if available
296        if let Some(ref info) = self.subagent_info {
297            if let Some(ref task) = info.task {
298                guidance.push_str(&format!("**Your assigned task:** {}\n\n", task));
299            }
300            if let Some(ref label) = info.label {
301                guidance.push_str(&format!("**Session label:** {}\n\n", label));
302            }
303        }
304
305        // Add tool categories overview
306        guidance.push_str(
307            "### Available Tool Categories
308- **Files:** read_file, write_file, edit_file, list_directory, search_files, find_files
309- **Shell:** execute_command, process (background commands)
310- **Web:** web_fetch, web_search, browser (automation)
311- **Memory:** memory_search, memory_get, save_memory
312- **Sessions:** sessions_send (communicate with parent)
313- **Tasks:** task_describe (update what you're doing — shown in sidebar)
314- **Secrets:** secrets_list, secrets_get
315- **Scheduling:** cron
316
317",
318        );
319
320        guidance.push_str(
321            "### Communication
322- Your final output will be delivered to the parent session automatically when you complete
323- If you need to send interim updates, use `sessions_send` with the parent session key
324- Do **not** assume access to messaging channels (Signal, Discord, etc.) — route through the parent
325
326### Status Updates
327- Use `task_describe` to update what you're currently doing (displayed in sidebar)
328- Keep descriptions short: \"Cloning repo\", \"Running tests\", \"Analyzing results\"
329- Update when starting major phases of work
330
331### Tools
332You have access to the same tools as the parent agent. **Use them to verify assumptions.**
333
334Before claiming something is missing or unavailable:
335- Use `execute_command` to check if software is installed (e.g., `which node`, `python --version`)
336- Use `browser` action=status to check browser connectivity
337- Use `secrets_list` to see available credentials
338- Use `read_file` to check if files exist
339
340**Do not assume.** Check with tools first.
341
342### Blocking Issues
343If you cannot proceed due to missing resources (e.g., browser not attached, credentials unavailable):
3441. **Verify with tools first** — run commands or checks to confirm the issue
3452. **Clearly state what's blocking you** — be specific about what you checked and found
3463. **List actions needed** — what the user or parent agent can do to unblock you
3474. **Exit cleanly** — don't retry indefinitely or loop; complete with a clear status message
348
349Example: \"Browser relay not connected (verified via `browser action=status`). To proceed, the user needs to attach a Chrome tab via the Browser Relay toolbar button.\"
350
351### Scope
352- Focus on your assigned task; do not take on unrelated work
353- If the task is complete, summarize your results clearly for the parent session
354"
355        );
356
357        guidance
358    }
359
360    /// Load today's and recent daily memory files.
361    fn load_daily_memory(&self) -> Option<String> {
362        let today = Local::now().date_naive();
363        let mut daily_sections = Vec::new();
364
365        for i in 0..=self.config.daily_lookback_days {
366            let date = today - Duration::days(i as i64);
367            let filename = format!("memory/{}.md", date.format("%Y-%m-%d"));
368            let path = self.workspace_dir.join(&filename);
369
370            if let Ok(content) = fs::read_to_string(&path) {
371                let content = content.trim();
372                if !content.is_empty() {
373                    daily_sections.push(format!("### {}\n{}", filename, content));
374                }
375            }
376        }
377
378        if daily_sections.is_empty() {
379            None
380        } else {
381            Some(format!(
382                "## Recent Daily Notes\n{}",
383                daily_sections.join("\n\n")
384            ))
385        }
386    }
387
388    /// Get list of files that should be audited on startup.
389    ///
390    /// Returns a list of (path, exists) tuples for workspace file status.
391    pub fn audit_files(&self, session_type: SessionType) -> Vec<(String, bool)> {
392        WORKSPACE_FILES
393            .iter()
394            .filter(|f| self.should_include(f, session_type))
395            .map(|f| {
396                let exists = self.workspace_dir.join(f.path).exists();
397                (f.path.to_string(), exists)
398            })
399            .collect()
400    }
401
402    /// Get the workspace directory.
403    pub fn workspace_dir(&self) -> &Path {
404        &self.workspace_dir
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use std::fs;
412    use tempfile::TempDir;
413
414    fn setup_workspace() -> TempDir {
415        let dir = TempDir::new().unwrap();
416        fs::write(dir.path().join("SOUL.md"), "Be helpful and concise.").unwrap();
417        fs::write(dir.path().join("MEMORY.md"), "User prefers Rust.").unwrap();
418        fs::write(dir.path().join("AGENTS.md"), "Follow instructions.").unwrap();
419        fs::create_dir(dir.path().join("memory")).unwrap();
420
421        let today = Local::now().format("%Y-%m-%d").to_string();
422        fs::write(
423            dir.path().join(format!("memory/{}.md", today)),
424            "# Today\nWorked on RustyClaw.",
425        )
426        .unwrap();
427        dir
428    }
429
430    #[test]
431    fn test_main_session_includes_memory() {
432        let workspace = setup_workspace();
433        let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
434
435        let prompt = ctx.build_context(SessionType::Main);
436        assert!(prompt.contains("SOUL.md"));
437        assert!(prompt.contains("MEMORY.md"));
438        assert!(prompt.contains("User prefers Rust"));
439    }
440
441    #[test]
442    fn test_group_session_excludes_memory() {
443        let workspace = setup_workspace();
444        let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
445
446        let prompt = ctx.build_context(SessionType::Group);
447        assert!(prompt.contains("SOUL.md"));
448        assert!(!prompt.contains("MEMORY.md"));
449        assert!(!prompt.contains("User prefers Rust"));
450    }
451
452    #[test]
453    fn test_isolated_session_excludes_memory() {
454        let workspace = setup_workspace();
455        let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
456
457        let prompt = ctx.build_context(SessionType::Isolated);
458        assert!(prompt.contains("SOUL.md"));
459        assert!(!prompt.contains("MEMORY.md"));
460    }
461
462    #[test]
463    fn test_daily_memory_loading() {
464        let workspace = setup_workspace();
465        let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
466
467        let prompt = ctx.build_context(SessionType::Main);
468        assert!(prompt.contains("Recent Daily Notes"));
469        assert!(prompt.contains("Worked on RustyClaw"));
470    }
471
472    #[test]
473    fn test_disabled_context() {
474        let workspace = setup_workspace();
475        let config = WorkspaceContextConfig {
476            enabled: false,
477            ..Default::default()
478        };
479        let ctx = WorkspaceContext::with_config(workspace.path().to_path_buf(), config);
480
481        let prompt = ctx.build_context(SessionType::Main);
482        assert!(prompt.is_empty());
483    }
484
485    #[test]
486    fn test_selective_injection() {
487        let workspace = setup_workspace();
488        let config = WorkspaceContextConfig {
489            enabled: true,
490            inject_soul: true,
491            inject_memory: false, // Disabled
492            ..Default::default()
493        };
494        let ctx = WorkspaceContext::with_config(workspace.path().to_path_buf(), config);
495
496        let prompt = ctx.build_context(SessionType::Main);
497        assert!(prompt.contains("SOUL.md"));
498        assert!(!prompt.contains("MEMORY.md"));
499    }
500
501    #[test]
502    fn test_audit_files() {
503        let workspace = setup_workspace();
504        let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
505
506        let audit = ctx.audit_files(SessionType::Main);
507
508        // SOUL.md exists
509        assert!(audit.iter().any(|(p, e)| p == "SOUL.md" && *e));
510        // MEMORY.md exists
511        assert!(audit.iter().any(|(p, e)| p == "MEMORY.md" && *e));
512        // TOOLS.md doesn't exist
513        assert!(audit.iter().any(|(p, e)| p == "TOOLS.md" && !*e));
514    }
515}