Skip to main content

walrus_daemon/hook/system/memory/
mod.rs

1//! Built-in memory — file-per-entry storage at `{config_dir}/memory/`.
2//!
3//! [`Memory`] manages individual entry files under `entries/`, a curated
4//! `MEMORY.md` overview, and session summaries under `sessions/`. Entry
5//! recall uses BM25 ranking. All I/O goes through the [`Storage`] trait
6//! for testability.
7
8use crate::hook::system::MemoryConfig;
9use std::{
10    collections::HashMap,
11    path::{Path, PathBuf},
12    sync::RwLock,
13};
14use wcore::model::{Message, Role};
15
16pub mod bm25;
17pub mod entry;
18pub mod storage;
19pub(crate) mod tool;
20
21use entry::MemoryEntry;
22use storage::Storage;
23
24const MEMORY_PROMPT: &str = include_str!("../../../../prompts/memory.md");
25
26const DEFAULT_SOUL: &str = include_str!("../../../../prompts/walrus.md");
27
28pub struct Memory {
29    storage: Box<dyn Storage>,
30    entries: RwLock<HashMap<String, MemoryEntry>>,
31    index: RwLock<String>,
32    soul: RwLock<String>,
33    index_path: PathBuf,
34    soul_path: PathBuf,
35    entries_dir: PathBuf,
36    sessions_dir: PathBuf,
37    config: MemoryConfig,
38}
39
40impl Memory {
41    /// Open (or create) memory storage at the given directory.
42    ///
43    /// `config_dir` is the parent config directory where `Walrus.md` lives.
44    /// `dir` is the memory-specific subdirectory (`{config_dir}/memory/`).
45    pub fn open(dir: PathBuf, config: MemoryConfig, storage: Box<dyn Storage>) -> Self {
46        let entries_dir = dir.join("entries");
47        let sessions_dir = dir.join("sessions");
48        let index_path = dir.join("MEMORY.md");
49        // Walrus.md lives in the parent config dir, not inside memory/
50        let soul_path = dir
51            .parent()
52            .map(|p| p.join("Walrus.md"))
53            .unwrap_or_else(|| dir.join("Walrus.md"));
54
55        storage.create_dir_all(&entries_dir).ok();
56        storage.create_dir_all(&sessions_dir).ok();
57
58        // Seed Walrus.md if it doesn't exist
59        if !storage.exists(&soul_path) {
60            storage.write(&soul_path, DEFAULT_SOUL).ok();
61        }
62
63        let soul_content = storage
64            .read(&soul_path)
65            .unwrap_or_else(|_| DEFAULT_SOUL.to_owned());
66
67        let mem = Self {
68            storage,
69            entries: RwLock::new(HashMap::new()),
70            index: RwLock::new(String::new()),
71            soul: RwLock::new(soul_content),
72            index_path,
73            soul_path,
74            entries_dir,
75            sessions_dir,
76            config,
77        };
78
79        mem.migrate_legacy(&dir);
80        mem.load_entries();
81        mem.load_index();
82        mem
83    }
84
85    /// Load all entry files from the entries directory.
86    fn load_entries(&self) {
87        let paths = match self.storage.list(&self.entries_dir) {
88            Ok(p) => p,
89            Err(_) => return,
90        };
91
92        let mut entries = self.entries.write().unwrap();
93        for path in paths {
94            if path.extension().and_then(|e| e.to_str()) != Some("md") {
95                continue;
96            }
97            let raw = match self.storage.read(&path) {
98                Ok(r) => r,
99                Err(_) => continue,
100            };
101            match MemoryEntry::parse(path, &raw) {
102                Ok(entry) => {
103                    entries.insert(entry.name.clone(), entry);
104                }
105                Err(e) => {
106                    tracing::warn!("failed to parse memory entry: {e}");
107                }
108            }
109        }
110    }
111
112    /// Load MEMORY.md index content.
113    fn load_index(&self) {
114        if let Ok(content) = self.storage.read(&self.index_path) {
115            *self.index.write().unwrap() = content;
116        }
117    }
118
119    /// BM25-ranked recall over all entries.
120    pub fn recall(&self, query: &str, limit: usize) -> String {
121        let entries = self.entries.read().unwrap();
122        if entries.is_empty() {
123            return "no memories found".to_owned();
124        }
125
126        // Single vector for both scoring and result lookup — avoids HashMap
127        // iteration order aliasing between separate `.values()` calls.
128        let entry_vec: Vec<&MemoryEntry> = entries.values().collect();
129        let docs: Vec<(usize, String)> = entry_vec
130            .iter()
131            .enumerate()
132            .map(|(i, e)| (i, e.search_text()))
133            .collect();
134        let doc_refs: Vec<(usize, &str)> = docs.iter().map(|(i, s)| (*i, s.as_str())).collect();
135
136        let results = bm25::score(&doc_refs, query, limit);
137        if results.is_empty() {
138            return "no memories found".to_owned();
139        }
140
141        results
142            .iter()
143            .map(|(idx, _score)| {
144                let e = &entry_vec[*idx];
145                format!("## {}\n{}\n\n{}", e.name, e.description, e.content)
146            })
147            .collect::<Vec<_>>()
148            .join("\n---\n")
149    }
150
151    /// Create or update a memory entry.
152    pub fn remember(&self, name: String, description: String, content: String) -> String {
153        let entry = MemoryEntry::new(name.clone(), description, content, &self.entries_dir);
154        if let Err(e) = entry.save(self.storage.as_ref()) {
155            return format!("failed to save entry: {e}");
156        }
157        self.entries.write().unwrap().insert(name.clone(), entry);
158        format!("remembered: {name}")
159    }
160
161    /// Delete a memory entry by name.
162    pub fn forget(&self, name: &str) -> String {
163        let mut entries = self.entries.write().unwrap();
164        match entries.remove(name) {
165            Some(entry) => {
166                if let Err(e) = entry.delete(self.storage.as_ref()) {
167                    tracing::warn!("failed to delete entry file: {e}");
168                }
169                format!("forgot: {name}")
170            }
171            None => format!("no entry named: {name}"),
172        }
173    }
174
175    /// Overwrite MEMORY.md (the curated overview).
176    pub fn write_index(&self, content: &str) -> String {
177        if let Err(e) = self.storage.write(&self.index_path, content) {
178            return format!("failed to write MEMORY.md: {e}");
179        }
180        *self.index.write().unwrap() = content.to_owned();
181        "MEMORY.md updated".to_owned()
182    }
183
184    /// Overwrite Walrus.md (the soul/identity file). Gated by `soul_editable`.
185    pub fn write_soul(&self, content: &str) -> String {
186        if !self.config.soul_editable {
187            return "soul editing is disabled in config".to_owned();
188        }
189        if let Err(e) = self.storage.write(&self.soul_path, content) {
190            return format!("failed to write Walrus.md: {e}");
191        }
192        *self.soul.write().unwrap() = content.to_owned();
193        "Walrus.md updated".to_owned()
194    }
195
196    /// Return the soul content for system prompt injection.
197    pub fn build_soul(&self) -> String {
198        self.soul.read().unwrap().clone()
199    }
200
201    /// Build system prompt block from MEMORY.md content.
202    pub fn build_prompt(&self) -> String {
203        let index = self.index.read().unwrap();
204        if index.is_empty() {
205            return format!("\n\n{MEMORY_PROMPT}");
206        }
207        format!("\n\n<memory>\n{}\n</memory>\n\n{MEMORY_PROMPT}", *index)
208    }
209
210    /// Auto-recall from last user message, injected before each turn.
211    pub fn before_run(&self, history: &[Message]) -> Vec<Message> {
212        let last_user = history
213            .iter()
214            .rev()
215            .find(|m| m.role == Role::User && !m.content.is_empty());
216
217        let Some(msg) = last_user else {
218            return Vec::new();
219        };
220
221        let query: String = msg
222            .content
223            .split_whitespace()
224            .take(8)
225            .collect::<Vec<_>>()
226            .join(" ");
227
228        if query.is_empty() {
229            return Vec::new();
230        }
231
232        let limit = self.config.recall_limit;
233        let result = self.recall(&query, limit);
234        if result == "no memories found" {
235            return Vec::new();
236        }
237
238        vec![Message {
239            role: Role::User,
240            content: format!("<recall>\n{result}\n</recall>"),
241            auto_injected: true,
242            ..Default::default()
243        }]
244    }
245
246    /// Save a session summary after compaction.
247    pub fn after_compact(&self, agent: &str, summary: &str) {
248        let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
249        let filename = format!("{agent}_{timestamp}.md");
250        let path = self.sessions_dir.join(filename);
251        if let Err(e) = self.storage.write(&path, summary) {
252            tracing::warn!("failed to save session summary: {e}");
253        }
254    }
255
256    /// Migrate legacy files (memory.md, user.md, facts.toml) to entry format.
257    fn migrate_legacy(&self, dir: &Path) {
258        // Only migrate if entries dir is empty.
259        let existing = self.storage.list(&self.entries_dir).unwrap_or_default();
260        if !existing.is_empty() {
261            return;
262        }
263
264        let memory_path = dir.join("memory.md");
265        let user_path = dir.join("user.md");
266        let facts_path = dir.join("facts.toml");
267
268        let has_legacy = self.storage.exists(&memory_path)
269            || self.storage.exists(&user_path)
270            || self.storage.exists(&facts_path);
271
272        if !has_legacy {
273            return;
274        }
275
276        // memory.md → split by double-newline into entries + seed MEMORY.md
277        if let Ok(content) = self.storage.read(&memory_path)
278            && !content.trim().is_empty()
279        {
280            self.storage.write(&self.index_path, &content).ok();
281
282            for (i, chunk) in content.split("\n\n").enumerate() {
283                let chunk = chunk.trim();
284                if chunk.is_empty() {
285                    continue;
286                }
287                let name = format!("migrated-memory-{}", i + 1);
288                let entry = MemoryEntry::new(
289                    name,
290                    "Migrated from memory.md".to_owned(),
291                    chunk.to_owned(),
292                    &self.entries_dir,
293                );
294                entry.save(self.storage.as_ref()).ok();
295            }
296            self.storage
297                .rename(&memory_path, &dir.join("memory.md.bak"))
298                .ok();
299        }
300
301        // user.md → single entry
302        if let Ok(content) = self.storage.read(&user_path)
303            && !content.trim().is_empty()
304        {
305            let entry = MemoryEntry::new(
306                "user-profile".to_owned(),
307                "User profile migrated from user.md".to_owned(),
308                content,
309                &self.entries_dir,
310            );
311            entry.save(self.storage.as_ref()).ok();
312            self.storage
313                .rename(&user_path, &dir.join("user.md.bak"))
314                .ok();
315        }
316
317        // facts.toml → single entry
318        if let Ok(content) = self.storage.read(&facts_path)
319            && !content.trim().is_empty()
320        {
321            let entry = MemoryEntry::new(
322                "known-facts".to_owned(),
323                "Known facts migrated from facts.toml".to_owned(),
324                content,
325                &self.entries_dir,
326            );
327            entry.save(self.storage.as_ref()).ok();
328            self.storage
329                .rename(&facts_path, &dir.join("facts.toml.bak"))
330                .ok();
331        }
332    }
333}