walrus_daemon/hook/system/memory/
mod.rs1use 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 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 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 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 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 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 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 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 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 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 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 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 pub fn build_soul(&self) -> String {
198 self.soul.read().unwrap().clone()
199 }
200
201 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 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 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 fn migrate_legacy(&self, dir: &Path) {
258 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 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 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 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}