Skip to main content

robit_agent/
memory.rs

1//! File-based memory mechanism.
2//!
3//! When the SQLite memory tools are disabled (`memory_mode = "file"`), the
4//! agent keeps long-term memory in Markdown files under the memory
5//! directory: a master `memory.md` plus daily `memory-YYYY-MM-DD.md` files.
6//! This module resolves the memory settings and builds the
7//! `{memory_section}` injected into the system prompt: the mechanism
8//! explanation plus the current master file content (bounded by
9//! [`MAX_MEMORY_INJECT_BYTES`], truncated beyond that).
10
11use 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
20/// Upper bound for the master memory file content injected into the
21/// system prompt. Larger files are truncated with a note pointing at `read`.
22pub const MAX_MEMORY_INJECT_BYTES: usize = 16 * 1024;
23
24/// Memory settings for one Agent construction.
25#[derive(Debug, Clone)]
26pub struct MemorySettings {
27    /// The active memory mechanism.
28    pub mode: MemoryMode,
29    /// Directory holding the memory files (same root as robit.db).
30    pub dir: std::path::PathBuf,
31}
32
33/// Resolve the memory directory with spec §8 degradation: a resolution
34/// failure (e.g. `global_storage = true` but no home directory) must not
35/// block Agent startup — warn and fall back to the project-local dir.
36fn 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
53/// Resolve memory settings from config. Called by frontends when
54/// constructing an Agent.
55pub 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
72/// Build the `{memory_section}` text for the system prompt.
73///
74/// Returns an empty string for `tools` / `off` modes; for `file` mode
75/// returns the mechanism explanation plus the master file content.
76/// The `## Memory` heading is generated here (carried by the non-empty
77/// text), so no empty heading leaks into the prompt.
78pub 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) => {} // not created yet — the explanation covers first-time creation
95        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(_)) => {} // empty/whitespace-only file: treat as missing, no dangling separator
100        Err(err) => {
101            // Read failure must not block startup: degrade to the
102            // explanation plus a failure note.
103            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
119/// Read the master memory file. Returns `Ok(None)` when it does not exist yet.
120/// Content larger than [`MAX_MEMORY_INJECT_BYTES`] is truncated with a note.
121fn 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        // The slice may split a multi-byte char at the cut point;
130        // from_utf8_lossy substitutes it instead of failing.
131        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        // spec §8: a resolution error must not block startup — fall back to project-local
194        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        // 记忆文件尚不存在:注入机制说明(含目录路径与当日文件名),无内容分隔符
213        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        // 0 字节 memory.md:视为缺失 → 不产生悬空 --- 分隔符
252        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        // 超过 16KB 且截断点落在多字节字符中间 → 截断不 panic
269        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); // 每字符 3 字节 → 18000 字节,16384 切点落在字符中间
273        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        // memory.md 被同名目录占用 → 读取失败:不 panic,降级为说明 + 失败提示
288        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        // 记忆内容含字面占位符文本 → 必须原样保留
304        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}