返回 DeepSeek-TUI-2026
memory.rs
根目录 / crates / tui / src / memory.rs
1 //! User-level memory file.
2 //!
3 //! v0.8.8 ships an MVP that lets the user keep a persistent personal
4 //! note file the model sees on every turn:
5 //!
6 //! - **Load** `~/.deepseek/memory.md` (path is configurable via
7 //! `memory_path` in `config.toml` and `DEEPSEEK_MEMORY_PATH` env),
8 //! wrap it in a `<user_memory>` block, and prepend it to the system
9 //! prompt alongside the existing `<project_instructions>` block.
10 //! - **`# foo`** typed in the composer appends `foo` to the memory
11 //! file as a timestamped bullet — fast capture without leaving the TUI.
12 //! - **`/memory`** shows the resolved file path and current contents, and
13 //! **`/memory edit`** prints a copy-pasteable `$VISUAL` / `$EDITOR`
14 //! command for opening the file yourself.
15 //! - **`remember` tool** lets the model itself append a bullet when it
16 //! notices a durable preference or convention worth keeping across
17 //! sessions.
18 //!
19 //! Default behavior is **opt-in**: load + use the memory file only when
20 //! `[memory] enabled = true` in `config.toml` or `DEEPSEEK_MEMORY=on`.
21 //! That keeps existing users on zero-overhead behavior and makes the
22 //! feature explicit.
23
24 use std::fs;
25 use std::io::{self, Write};
26 use std::path::Path;
27
28 use chrono::Utc;
29
30 /// Maximum size of the user memory file. Larger files are loaded but the
31 /// `<user_memory>` block carries a "(truncated)" marker so the user knows
32 /// the model only saw a slice. Mirrors `project_context::MAX_CONTEXT_SIZE`.
33 const MAX_MEMORY_SIZE: usize = 100 * 1024;
34
35 /// Read the user memory file at `path`, returning `None` when the file
36 /// doesn't exist or is empty after trimming.
37 #[must_use]
38 pub fn load(path: &Path) -> Option<String> {
39 let content = fs::read_to_string(path).ok()?;
40 if content.trim().is_empty() {
41 return None;
42 }
43 Some(content)
44 }
45
46 /// Wrap memory content in a `<user_memory>` block ready to prepend to the
47 /// system prompt. The `source` value is rendered verbatim into a
48 /// `source="…"` attribute — pass the path so the model can see where the
49 /// memory came from. Returns `None` for empty content.
50 #[must_use]
51 pub fn as_system_block(content: &str, source: &Path) -> Option<String> {
52 let trimmed = content.trim();
53 if trimmed.is_empty() {
54 return None;
55 }
56
57 let display = source.display();
58 let payload = if content.len() > MAX_MEMORY_SIZE {
59 let mut head = content[..MAX_MEMORY_SIZE].to_string();
60 head.push_str("\n…(truncated, raise [memory].max_size or trim memory.md)");
61 head
62 } else {
63 trimmed.to_string()
64 };
65
66 Some(format!(
67 "<user_memory source=\"{display}\">\n{payload}\n</user_memory>"
68 ))
69 }
70
71 /// Compose the `<user_memory>` block for the system prompt, honouring the
72 /// opt-in toggle. Returns `None` when the feature is disabled or the file
73 /// is missing / empty so the caller doesn't have to check both conditions.
74 ///
75 /// Callers that hold a `&Config` should pass `config.memory_enabled()` and
76 /// `config.memory_path()` directly. The split keeps this module
77 /// `Config`-free so it can be reused from sub-agent / engine boundaries
78 /// where the high-level `Config` isn't available.
79 #[must_use]
80 pub fn compose_block(enabled: bool, path: &Path) -> Option<String> {
81 if !enabled {
82 return None;
83 }
84 let content = load(path)?;
85 as_system_block(&content, path)
86 }
87
88 /// Append `entry` to the memory file at `path`, creating it (and its
89 /// parent directory) if needed. The entry is timestamped so the user can
90 /// later see when each note was added. The leading `#` from a `# foo`
91 /// quick-add is stripped so the file stays as readable Markdown.
92 pub fn append_entry(path: &Path, entry: &str) -> io::Result<()> {
93 let trimmed = entry.trim_start_matches('#').trim();
94 if trimmed.is_empty() {
95 return Err(io::Error::new(
96 io::ErrorKind::InvalidInput,
97 "memory entry is empty after stripping `#` prefix",
98 ));
99 }
100
101 if let Some(parent) = path.parent()
102 && !parent.as_os_str().is_empty()
103 {
104 fs::create_dir_all(parent)?;
105 }
106
107 let timestamp = Utc::now().format("%Y-%m-%d %H:%M UTC");
108 let mut file = fs::OpenOptions::new()
109 .create(true)
110 .append(true)
111 .open(path)?;
112 writeln!(file, "- ({timestamp}) {trimmed}")?;
113 Ok(())
114 }
115
116 #[cfg(test)]
117 mod tests {
118 use super::*;
119 use tempfile::tempdir;
120
121 #[test]
122 fn load_returns_none_for_missing_file() {
123 let tmp = tempdir().unwrap();
124 let path = tmp.path().join("never-existed.md");
125 assert!(load(&path).is_none());
126 }
127
128 #[test]
129 fn load_returns_none_for_whitespace_only_file() {
130 let tmp = tempdir().unwrap();
131 let path = tmp.path().join("memory.md");
132 fs::write(&path, " \n \n").unwrap();
133 assert!(load(&path).is_none());
134 }
135
136 #[test]
137 fn load_returns_content_for_real_file() {
138 let tmp = tempdir().unwrap();
139 let path = tmp.path().join("memory.md");
140 fs::write(&path, "remember the milk").unwrap();
141 assert_eq!(load(&path).as_deref(), Some("remember the milk"));
142 }
143
144 #[test]
145 fn as_system_block_produces_xml_wrapper() {
146 let block = as_system_block("note 1", Path::new("/tmp/m.md")).unwrap();
147 assert!(block.contains("<user_memory source=\"/tmp/m.md\">"));
148 assert!(block.contains("note 1"));
149 assert!(block.ends_with("</user_memory>"));
150 }
151
152 #[test]
153 fn as_system_block_returns_none_for_empty_content() {
154 assert!(as_system_block(" ", Path::new("/tmp/m.md")).is_none());
155 }
156
157 #[test]
158 fn as_system_block_truncates_oversize_input() {
159 let big = "x".repeat(MAX_MEMORY_SIZE + 100);
160 let block = as_system_block(&big, Path::new("/tmp/m.md")).unwrap();
161 assert!(block.contains("(truncated"));
162 }
163
164 #[test]
165 fn append_entry_creates_file_and_writes_one_bullet() {
166 let tmp = tempdir().unwrap();
167 let path = tmp.path().join("memory.md");
168 append_entry(&path, "# remember the milk").unwrap();
169
170 let body = fs::read_to_string(&path).unwrap();
171 assert!(body.contains("remember the milk"), "{body}");
172 assert!(
173 body.starts_with("- ("),
174 "should start with bullet + date: {body}"
175 );
176 assert!(body.trim_end().ends_with("remember the milk"));
177 }
178
179 #[test]
180 fn append_entry_appends_subsequent_lines() {
181 let tmp = tempdir().unwrap();
182 let path = tmp.path().join("memory.md");
183 append_entry(&path, "# first").unwrap();
184 append_entry(&path, "second").unwrap();
185 let body = fs::read_to_string(&path).unwrap();
186 assert!(body.contains("first"));
187 assert!(body.contains("second"));
188 // Two bullets means two lines of `- (date) entry`.
189 assert_eq!(body.matches("- (").count(), 2);
190 }
191
192 #[test]
193 fn append_entry_rejects_empty_after_strip() {
194 let tmp = tempdir().unwrap();
195 let path = tmp.path().join("memory.md");
196 let err = append_entry(&path, "###").unwrap_err();
197 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
198 }
199 }
200
200 lines RUST