1use std::path::{Path, PathBuf};
12
13use robit_ai::config::{resolve_memory_mode, MemoryMode, RobitConfig};
14
15use crate::error::Result;
16use crate::storage::resolve_memory_dir;
17
18const MEMORY_PROMPT_TEMPLATE: &str = include_str!("../prompts/memory.md");
19
20pub const MAX_MEMORY_INJECT_BYTES: usize = 16 * 1024;
23
24#[derive(Debug, Clone)]
26pub struct MemorySettings {
27 pub mode: MemoryMode,
29 pub dir: std::path::PathBuf,
31}
32
33fn memory_dir_with_fallback(
37 working_dir: &Path,
38 resolved: crate::error::Result<PathBuf>,
39) -> PathBuf {
40 match resolved {
41 Ok(dir) => dir,
42 Err(err) => {
43 tracing::warn!(
44 "Failed to resolve memory dir: {}; falling back to project-local .robit/memory",
45 err
46 );
47 crate::storage::resolve_memory_dir(working_dir, false)
48 .expect("project-local memory dir resolution cannot fail")
49 }
50 }
51}
52
53pub fn resolve_memory_settings(
56 config: &RobitConfig,
57 working_dir: &Path,
58) -> Result<MemorySettings> {
59 let mode = resolve_memory_mode(config);
60 let global_storage = config
61 .app
62 .as_ref()
63 .and_then(|a| a.global_storage)
64 .unwrap_or(false);
65 let dir = memory_dir_with_fallback(
66 working_dir,
67 resolve_memory_dir(working_dir, global_storage),
68 );
69 Ok(MemorySettings { mode, dir })
70}
71
72pub fn build_memory_section(settings: &MemorySettings, date: &str) -> String {
79 match settings.mode {
80 MemoryMode::Tools | MemoryMode::Off => String::new(),
81 MemoryMode::File => build_file_section(settings, date),
82 }
83}
84
85fn build_file_section(settings: &MemorySettings, date: &str) -> String {
86 let dir_display = settings.dir.display().to_string();
87 let explanation = MEMORY_PROMPT_TEMPLATE
88 .replace("{memory_dir}", &dir_display)
89 .replace("{date}", date);
90
91 let mut section = format!("## Memory\n\n{}", explanation.trim());
92
93 match read_master_file(&settings.dir) {
94 Ok(None) => {} Ok(Some(content)) if !content.trim().is_empty() => {
96 section.push_str("\n\n---\n\n");
97 section.push_str(&content);
98 }
99 Ok(Some(_)) => {} Err(err) => {
101 tracing::warn!(
104 "Failed to read {}: {}",
105 settings.dir.join("memory.md").display(),
106 err
107 );
108 section.push_str(&format!(
109 "\n\n(Failed to read memory.md: {}. You can check the file later \
110 with the read tool.)",
111 err
112 ));
113 }
114 }
115
116 section
117}
118
119fn read_master_file(dir: &Path) -> std::io::Result<Option<String>> {
122 let path = dir.join("memory.md");
123 let bytes = match std::fs::read(&path) {
124 Ok(b) => b,
125 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
126 Err(e) => return Err(e),
127 };
128 if bytes.len() > MAX_MEMORY_INJECT_BYTES {
129 let mut content =
132 String::from_utf8_lossy(&bytes[..MAX_MEMORY_INJECT_BYTES]).into_owned();
133 content.push_str(&format!(
134 "\n\n... (truncated; use the read tool to load the full \
135 {})",
136 dir.join("memory.md").display()
137 ));
138 Ok(Some(content))
139 } else {
140 Ok(Some(String::from_utf8_lossy(&bytes).into_owned()))
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use robit_ai::config::{AppConfig, MemoryMode, RobitConfig};
148 use std::collections::HashMap;
149 use std::fs;
150 use tempfile::TempDir;
151
152 fn config_with(mode: Option<MemoryMode>, global_storage: bool) -> RobitConfig {
153 RobitConfig {
154 default_model: None,
155 providers: HashMap::new(),
156 app: Some(AppConfig {
157 memory_mode: mode,
158 global_storage: Some(global_storage),
159 ..Default::default()
160 }),
161 channels: None,
162 default_image_model: None,
163 image_providers: HashMap::new(),
164 }
165 }
166
167 #[test]
168 fn resolve_defaults_to_file_mode_local_dir() {
169 let tmp = TempDir::new().unwrap();
170 let settings = resolve_memory_settings(&config_with(None, false), tmp.path()).unwrap();
171 assert_eq!(settings.mode, MemoryMode::File);
172 assert_eq!(settings.dir, tmp.path().join(".robit/memory"));
173 }
174
175 #[test]
176 fn resolve_global_storage_uses_home() {
177 let tmp = TempDir::new().unwrap();
178 let settings = resolve_memory_settings(&config_with(None, true), tmp.path()).unwrap();
179 let home = dirs::home_dir().unwrap();
180 assert_eq!(settings.dir, home.join(".robit/memory"));
181 }
182
183 #[test]
184 fn memory_dir_fallback_passes_through_ok() {
185 let tmp = TempDir::new().unwrap();
186 let resolved = crate::storage::resolve_memory_dir(tmp.path(), false);
187 let dir = memory_dir_with_fallback(tmp.path(), resolved);
188 assert_eq!(dir, tmp.path().join(".robit").join("memory"));
189 }
190
191 #[test]
192 fn memory_dir_fallback_degrades_on_error() {
193 let tmp = TempDir::new().unwrap();
195 let err = crate::error::AgentError::InternalError("no home directory".to_string());
196 let dir = memory_dir_with_fallback(tmp.path(), Err(err));
197 assert_eq!(dir, tmp.path().join(".robit").join("memory"));
198 }
199
200 #[test]
201 fn section_empty_for_tools_and_off() {
202 let tmp = TempDir::new().unwrap();
203 for mode in [MemoryMode::Tools, MemoryMode::Off] {
204 let settings =
205 resolve_memory_settings(&config_with(Some(mode), false), tmp.path()).unwrap();
206 assert_eq!(build_memory_section(&settings, "2026-09-30"), "");
207 }
208 }
209
210 #[test]
211 fn file_section_contains_mechanism_explanation() {
212 let tmp = TempDir::new().unwrap();
214 let settings = resolve_memory_settings(
215 &config_with(Some(MemoryMode::File), false),
216 tmp.path(),
217 )
218 .unwrap();
219 let section = build_memory_section(&settings, "2026-09-30");
220 assert!(section.starts_with("## Memory\n"));
221 assert!(section.contains(
222 &tmp
223 .path()
224 .join(".robit")
225 .join("memory")
226 .display()
227 .to_string()
228 ));
229 assert!(section.contains("memory-2026-09-30.md"));
230 assert!(!section.contains("---"));
231 }
232
233 #[test]
234 fn file_section_includes_master_content() {
235 let tmp = TempDir::new().unwrap();
236 let dir = tmp.path().join(".robit/memory");
237 fs::create_dir_all(&dir).unwrap();
238 fs::write(dir.join("memory.md"), "# 记忆\n\n- 用户使用中文交流\n").unwrap();
239 let settings = resolve_memory_settings(
240 &config_with(Some(MemoryMode::File), false),
241 tmp.path(),
242 )
243 .unwrap();
244 let section = build_memory_section(&settings, "2026-09-30");
245 assert!(section.contains("---"));
246 assert!(section.contains("用户使用中文交流"));
247 }
248
249 #[test]
250 fn file_section_no_separator_for_empty_master_file() {
251 let tmp = TempDir::new().unwrap();
253 let dir = tmp.path().join(".robit/memory");
254 fs::create_dir_all(&dir).unwrap();
255 fs::write(dir.join("memory.md"), "").unwrap();
256 let settings = resolve_memory_settings(
257 &config_with(Some(MemoryMode::File), false),
258 tmp.path(),
259 )
260 .unwrap();
261 let section = build_memory_section(&settings, "2026-09-30");
262 assert!(section.starts_with("## Memory\n"));
263 assert!(!section.contains("---"));
264 }
265
266 #[test]
267 fn file_section_truncates_oversized_master_file() {
268 let tmp = TempDir::new().unwrap();
270 let dir = tmp.path().join(".robit/memory");
271 fs::create_dir_all(&dir).unwrap();
272 let big = "记".repeat(6000); fs::write(dir.join("memory.md"), &big).unwrap();
274 let settings = resolve_memory_settings(
275 &config_with(Some(MemoryMode::File), false),
276 tmp.path(),
277 )
278 .unwrap();
279 let section = build_memory_section(&settings, "2026-09-30");
280 assert!(section.contains("truncated"));
281 assert!(section.contains('\u{FFFD}'));
282 assert!(section.len() < MAX_MEMORY_INJECT_BYTES + 4096);
283 }
284
285 #[test]
286 fn file_section_degrades_when_master_file_unreadable() {
287 let tmp = TempDir::new().unwrap();
289 let dir = tmp.path().join(".robit/memory");
290 fs::create_dir_all(dir.join("memory.md")).unwrap();
291 let settings = resolve_memory_settings(
292 &config_with(Some(MemoryMode::File), false),
293 tmp.path(),
294 )
295 .unwrap();
296 let section = build_memory_section(&settings, "2026-09-30");
297 assert!(section.starts_with("## Memory\n"));
298 assert!(section.contains("Failed to read memory.md"));
299 }
300
301 #[test]
302 fn master_content_placeholders_not_replaced() {
303 let tmp = TempDir::new().unwrap();
305 let dir = tmp.path().join(".robit/memory");
306 fs::create_dir_all(&dir).unwrap();
307 fs::write(dir.join("memory.md"), "占位符示例 {memory_dir} {date}").unwrap();
308 let settings = resolve_memory_settings(
309 &config_with(Some(MemoryMode::File), false),
310 tmp.path(),
311 )
312 .unwrap();
313 let section = build_memory_section(&settings, "2026-09-30");
314 assert!(section.contains("占位符示例 {memory_dir} {date}"));
315 }
316}