walrus_daemon/hook/system/memory/
mod.rs1use crate::hook::system::MemoryConfig;
9use std::{path::PathBuf, sync::RwLock};
10use wcore::model::{Message, Role};
11
12pub(crate) mod tool;
13
14const MEMORY_PROMPT: &str = include_str!("../../../../prompts/memory.md");
15const EXTRACT_FACTS_PROMPT: &str = include_str!("../../../../prompts/extract-facts.md");
16
17struct FileCache {
19 path: PathBuf,
20 content: String,
21}
22
23impl FileCache {
24 fn load(path: PathBuf) -> Self {
26 let content = std::fs::read_to_string(&path).unwrap_or_default();
27 Self { path, content }
28 }
29
30 fn append(&mut self, text: &str, limit: usize) -> bool {
33 if self.content.len() + text.len() > limit {
34 return false;
35 }
36 if !self.content.is_empty() && !self.content.ends_with('\n') {
37 self.content.push('\n');
38 }
39 self.content.push_str(text);
40 self.flush()
41 }
42
43 fn write(&mut self, text: &str, limit: usize) -> bool {
46 if text.len() > limit {
47 return false;
48 }
49 self.content = text.to_owned();
50 self.flush()
51 }
52
53 fn flush(&self) -> bool {
55 std::fs::write(&self.path, &self.content).is_ok()
56 }
57}
58
59pub struct BuiltinMemory {
60 memory: RwLock<FileCache>,
61 user: RwLock<FileCache>,
62 facts: RwLock<FileCache>,
63 sessions_dir: PathBuf,
64 config: MemoryConfig,
65}
66
67impl BuiltinMemory {
68 pub fn open(dir: PathBuf, config: MemoryConfig) -> Self {
70 let sessions_dir = dir.join("sessions");
71 std::fs::create_dir_all(&sessions_dir).ok();
72
73 let memory = RwLock::new(FileCache::load(dir.join("memory.md")));
74 let user = RwLock::new(FileCache::load(dir.join("user.md")));
75 let facts = RwLock::new(FileCache::load(dir.join("facts.toml")));
76
77 Self {
78 memory,
79 user,
80 facts,
81 sessions_dir,
82 config,
83 }
84 }
85
86 pub fn recall(&self, query: &str) -> String {
89 let query_lower = query.to_lowercase();
90 let mut results = Vec::new();
91
92 let sources: &[(&str, &RwLock<FileCache>)] = &[
93 ("memory", &self.memory),
94 ("user", &self.user),
95 ("facts", &self.facts),
96 ];
97
98 for (label, cache) in sources {
99 let guard = cache.read().unwrap();
100 for line in guard.content.lines() {
101 if line.to_lowercase().contains(&query_lower) {
102 results.push(format!("[{label}] {line}"));
103 }
104 }
105 }
106
107 if results.is_empty() {
108 "no matches found".to_owned()
109 } else {
110 results.join("\n")
111 }
112 }
113
114 pub fn write_memory(&self, content: &str) -> String {
116 let mut guard = self.memory.write().unwrap();
117 if guard.append(content, self.config.memory_limit) {
118 "written to memory".to_owned()
119 } else {
120 format!(
121 "memory limit reached ({} chars, {} used)",
122 self.config.memory_limit,
123 guard.content.len()
124 )
125 }
126 }
127
128 pub fn write_user(&self, content: &str) -> String {
130 let mut guard = self.user.write().unwrap();
131 if guard.write(content, self.config.user_limit) {
132 "written to user profile".to_owned()
133 } else {
134 format!(
135 "user profile limit reached ({} chars)",
136 self.config.user_limit
137 )
138 }
139 }
140
141 pub fn build_prompt(&self) -> String {
143 let mut blocks = Vec::new();
144
145 let mem = self.memory.read().unwrap();
146 if !mem.content.is_empty() {
147 blocks.push(format!("<memory>\n{}\n</memory>", mem.content));
148 }
149
150 let usr = self.user.read().unwrap();
151 if !usr.content.is_empty() {
152 blocks.push(format!("<user>\n{}\n</user>", usr.content));
153 }
154
155 let facts = self.facts.read().unwrap();
156 if !facts.content.is_empty() {
157 blocks.push(format!("<facts>\n{}\n</facts>", facts.content));
158 }
159
160 if blocks.is_empty() {
161 String::new()
162 } else {
163 format!("\n\n{}\n\n{MEMORY_PROMPT}", blocks.join("\n\n"))
164 }
165 }
166
167 pub fn before_run(&self, history: &[Message]) -> Vec<Message> {
169 let last_user = history
170 .iter()
171 .rev()
172 .find(|m| m.role == Role::User && !m.content.is_empty());
173
174 let Some(msg) = last_user else {
175 return Vec::new();
176 };
177
178 let query: String = msg
180 .content
181 .split_whitespace()
182 .take(8)
183 .collect::<Vec<_>>()
184 .join(" ");
185
186 if query.is_empty() {
187 return Vec::new();
188 }
189
190 let result = self.recall(&query);
191 if result == "no matches found" {
192 return Vec::new();
193 }
194
195 vec![Message {
196 role: Role::User,
197 content: format!("<recall>\n{result}\n</recall>"),
198 ..Default::default()
199 }]
200 }
201
202 pub fn after_compact(&self, agent: &str, summary: &str) {
205 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
206 let filename = format!("{agent}_{timestamp}.md");
207 let path = self.sessions_dir.join(filename);
208 if let Err(e) = std::fs::write(&path, summary) {
209 tracing::warn!("failed to save session summary: {e}");
210 }
211
212 self.extract_facts(summary);
214 }
215
216 pub fn extract_facts(&self, summary: &str) {
219 let mut new_facts = Vec::new();
220
221 for line in summary.lines() {
222 let trimmed = line.trim();
223 if trimmed.is_empty() || trimmed.starts_with('#') {
225 continue;
226 }
227
228 if let Some((key, value)) = trimmed.split_once(": ") {
230 let key = key.trim();
231 let value = value.trim();
232 if !key.is_empty()
233 && !value.is_empty()
234 && !key.contains(' ')
235 && !key.contains('/')
236 && key.len() < 32
237 {
238 let safe_key = key.to_lowercase().replace('-', "_");
239 new_facts.push(format!("{safe_key} = {value:?}"));
240 }
241 }
242 }
243
244 if new_facts.is_empty() {
245 return;
246 }
247
248 let mut guard = self.facts.write().unwrap();
249 let addition = new_facts.join("\n");
250 if !guard.content.is_empty() && !guard.content.ends_with('\n') {
251 guard.content.push('\n');
252 }
253 guard.content.push_str(&addition);
254 if !guard.flush() {
255 tracing::warn!("failed to write facts.toml");
256 }
257 }
258}
259
260const _: &str = EXTRACT_FACTS_PROMPT;