| 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 |