Skip to main content

walrus_daemon/hook/system/memory/
mod.rs

1//! Built-in memory — markdown file storage at `{config_dir}/memory/`.
2//!
3//! [`BuiltinMemory`] manages four storage areas: `memory.md` (agent notes),
4//! `user.md` (user profile), `facts.toml` (structured facts), and `sessions/`
5//! (compact summaries). File contents are cached in-memory with write-through
6//! on modification. Thread-safe via `std::sync::RwLock`.
7
8use 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
17/// In-memory cache of a single file's content.
18struct FileCache {
19    path: PathBuf,
20    content: String,
21}
22
23impl FileCache {
24    /// Load from disk, or empty string if file doesn't exist.
25    fn load(path: PathBuf) -> Self {
26        let content = std::fs::read_to_string(&path).unwrap_or_default();
27        Self { path, content }
28    }
29
30    /// Append text and flush to disk, respecting a character limit.
31    /// Returns `true` if the write succeeded.
32    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    /// Overwrite content and flush to disk, respecting a character limit.
44    /// Returns `true` if the write succeeded.
45    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    /// Write content to disk.
54    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    /// Open (or create) memory storage at the given directory.
69    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    /// Substring search across memory.md, user.md, and facts.toml.
87    /// Returns matching lines with source labels.
88    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    /// Append to memory.md, respecting `memory_limit`.
115    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    /// Write to user.md, respecting `user_limit`. Overwrites existing content.
129    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    /// Build XML blocks for system prompt injection.
142    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    /// Recall from last user message, return as injected message.
168    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        // Use the first few words as a recall query.
179        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    /// Save a session summary after compaction. Runs synchronously.
203    /// Spawns facts extraction as a background task.
204    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        // Extract facts synchronously (simple heuristic, no LLM).
213        self.extract_facts(summary);
214    }
215
216    /// Lightweight facts extraction from a summary string.
217    /// Looks for "Name: Value", "Key = Value" patterns and appends to facts.toml.
218    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            // Skip empty lines and headings.
224            if trimmed.is_empty() || trimmed.starts_with('#') {
225                continue;
226            }
227
228            // Match "Key: Value" patterns (but not URLs like "https://").
229            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
260// Suppress unused warning for the extract-facts prompt — will be used when
261// LLM extraction is added.
262const _: &str = EXTRACT_FACTS_PROMPT;