1use chrono::{Duration, Local};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SessionType {
18 Main,
21 Group,
24 Isolated,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct WorkspaceContextConfig {
32 #[serde(default = "default_true")]
34 pub enabled: bool,
35
36 #[serde(default = "default_true")]
38 pub inject_soul: bool,
39
40 #[serde(default = "default_true")]
42 pub inject_agents: bool,
43
44 #[serde(default = "default_true")]
46 pub inject_tools: bool,
47
48 #[serde(default = "default_true")]
50 pub inject_identity: bool,
51
52 #[serde(default = "default_true")]
54 pub inject_user: bool,
55
56 #[serde(default = "default_true")]
58 pub inject_memory: bool,
59
60 #[serde(default = "default_true")]
62 pub inject_heartbeat: bool,
63
64 #[serde(default = "default_true")]
66 pub inject_daily: bool,
67
68 #[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 }
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
98struct WorkspaceFile {
100 path: &'static str,
102 header: &'static str,
104 main_only: bool,
106 config_field: ConfigField,
108}
109
110enum 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, config_field: ConfigField::User,
151 },
152 WorkspaceFile {
153 path: "MEMORY.md",
154 header: "MEMORY.md",
155 main_only: true, 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#[derive(Debug, Clone, Default)]
168pub struct SubagentInfo {
169 pub parent_key: Option<String>,
171 pub task: Option<String>,
173 pub label: Option<String>,
175}
176
177pub struct WorkspaceContext {
182 workspace_dir: PathBuf,
183 config: WorkspaceContextConfig,
184 subagent_info: Option<SubagentInfo>,
185}
186
187impl WorkspaceContext {
188 pub fn new(workspace_dir: PathBuf) -> Self {
190 Self {
191 workspace_dir,
192 config: WorkspaceContextConfig::default(),
193 subagent_info: None,
194 }
195 }
196
197 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 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 fn should_include(&self, file: &WorkspaceFile, session_type: SessionType) -> bool {
221 if file.main_only && session_type != SessionType::Main {
223 return false;
224 }
225
226 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 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 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 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 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 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 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 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 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 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 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, ..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 assert!(audit.iter().any(|(p, e)| p == "SOUL.md" && *e));
510 assert!(audit.iter().any(|(p, e)| p == "MEMORY.md" && *e));
512 assert!(audit.iter().any(|(p, e)| p == "TOOLS.md" && !*e));
514 }
515}