1use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use starpod_core::{Result, StarpodError};
11
12use crate::scoring;
13use crate::store::{MemoryStore, SearchResult};
14
15pub struct UserMemoryView {
28 agent_store: Arc<MemoryStore>,
30 user_store: MemoryStore,
32 user_dir: PathBuf,
34}
35
36impl UserMemoryView {
37 pub async fn new(agent_store: Arc<MemoryStore>, user_dir: PathBuf) -> Result<Self> {
45 std::fs::create_dir_all(&user_dir).map_err(StarpodError::Io)?;
47 std::fs::create_dir_all(user_dir.join("memory")).map_err(StarpodError::Io)?;
48
49 let user_md = user_dir.join("USER.md");
51 if !user_md.exists() {
52 std::fs::write(&user_md, crate::defaults::DEFAULT_USER).map_err(StarpodError::Io)?;
53 }
54 let memory_md = user_dir.join("MEMORY.md");
55 if !memory_md.exists() {
56 std::fs::write(
57 &memory_md,
58 "# Memory Index\n\nImportant facts and links to memory files.\n",
59 )
60 .map_err(StarpodError::Io)?;
61 }
62
63 let user_store = MemoryStore::new_user(&user_dir).await?;
65
66 Ok(Self {
67 agent_store,
68 user_store,
69 user_dir,
70 })
71 }
72
73 pub fn bootstrap_context(&self, bootstrap_file_cap: usize) -> Result<String> {
75 let mut parts = Vec::new();
76
77 let soul = self.agent_store.read_file("SOUL.md")?;
79 let capped = cap_str(&soul, bootstrap_file_cap);
80 parts.push(format!("--- SOUL.md ---\n{}", capped));
81
82 let user_content = read_user_file(&self.user_dir, "USER.md")?;
84 let capped = cap_str(&user_content, bootstrap_file_cap);
85 parts.push(format!("--- USER.md ---\n{}", capped));
86
87 let memory_content = read_user_file(&self.user_dir, "MEMORY.md")?;
89 let capped = cap_str(&memory_content, bootstrap_file_cap);
90 parts.push(format!("--- MEMORY.md ---\n{}", capped));
91
92 let memory_dir = self.user_dir.join("memory");
94 if memory_dir.exists() {
95 let mut entries: Vec<_> = std::fs::read_dir(&memory_dir)
96 .map_err(StarpodError::Io)?
97 .filter_map(|e| e.ok())
98 .filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
99 .collect();
100 entries.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
101 entries.truncate(3);
102
103 for entry in entries {
104 let name = entry.file_name().to_string_lossy().to_string();
105 if let Ok(content) = std::fs::read_to_string(entry.path()) {
106 let capped = cap_str(&content, bootstrap_file_cap);
107 parts.push(format!("--- daily/{} ---\n{}", name, capped));
108 }
109 }
110 }
111
112 Ok(parts.join("\n\n"))
113 }
114
115 pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
120 let (agent_results, user_results) = tokio::join!(
122 self.agent_store.search(query, limit),
123 self.user_store.search(query, limit),
124 );
125
126 let mut results = agent_results?;
127 let mut user_hits = user_results?;
128
129 results.append(&mut user_hits);
131 results.sort_by(|a, b| {
132 a.rank
133 .partial_cmp(&b.rank)
134 .unwrap_or(std::cmp::Ordering::Equal)
135 });
136 results.truncate(limit);
137
138 Ok(results)
139 }
140
141 pub async fn write_file(&self, name: &str, content: &str) -> Result<()> {
146 if is_user_file(name) {
147 self.user_store.write_file(name, content).await
148 } else {
149 self.agent_store.write_file(name, content).await
150 }
151 }
152
153 pub async fn append_daily(&self, text: &str) -> Result<()> {
155 self.user_store.append_daily(text).await
156 }
157
158 pub fn read_file(&self, name: &str) -> Result<String> {
160 if is_user_file(name) {
161 read_user_file(&self.user_dir, name)
162 } else {
163 self.agent_store.read_file(name)
164 }
165 }
166
167 pub fn has_bootstrap(&self) -> bool {
169 self.agent_store.has_bootstrap()
170 }
171
172 pub fn clear_bootstrap(&self) -> Result<()> {
174 self.agent_store.clear_bootstrap()
175 }
176}
177
178fn is_user_file(name: &str) -> bool {
180 name == "USER.md" || name == "MEMORY.md" || name.starts_with("memory/")
181}
182
183fn read_user_file(user_dir: &Path, name: &str) -> Result<String> {
185 scoring::validate_path(name, user_dir)?;
186 let path = user_dir.join(name);
187 if !path.exists() {
188 return Ok(String::new());
189 }
190 std::fs::read_to_string(&path).map_err(StarpodError::Io)
191}
192
193fn cap_str(s: &str, max: usize) -> &str {
195 if s.len() > max {
196 let mut end = max;
197 while end > 0 && !s.is_char_boundary(end) {
198 end -= 1;
199 }
200 &s[..end]
201 } else {
202 s
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use chrono::Local;
210 use tempfile::TempDir;
211
212 async fn setup() -> (TempDir, Arc<MemoryStore>, PathBuf) {
213 let tmp = TempDir::new().unwrap();
214 let agent_home = tmp.path().join("agent_home");
215 let config_dir = agent_home.join("config");
216 let db_dir = tmp.path().join("db");
217 let store = Arc::new(
218 MemoryStore::new(&agent_home, &config_dir, &db_dir)
219 .await
220 .unwrap(),
221 );
222 let user_dir = tmp.path().join("users").join("alice");
223 (tmp, store, user_dir)
224 }
225
226 #[tokio::test]
227 async fn user_view_creates_structure() {
228 let (_tmp, store, user_dir) = setup().await;
229 let _view = UserMemoryView::new(store, user_dir.clone()).await.unwrap();
230
231 assert!(user_dir.exists());
232 assert!(user_dir.join("memory").exists());
233 assert!(user_dir.join("USER.md").exists());
234 assert!(user_dir.join("MEMORY.md").exists());
235 assert!(user_dir.join("memory.db").exists());
237 }
238
239 #[tokio::test]
240 async fn user_view_bootstrap_context() {
241 let (_tmp, store, user_dir) = setup().await;
242 let view = UserMemoryView::new(store, user_dir.clone()).await.unwrap();
243
244 std::fs::write(user_dir.join("USER.md"), "# User\nAlice is a developer.\n").unwrap();
246
247 let ctx = view.bootstrap_context(20_000).unwrap();
248 assert!(ctx.contains("SOUL.md"));
250 assert!(ctx.contains("Nova"));
251 assert!(ctx.contains("Alice is a developer"));
253 }
254
255 #[tokio::test]
256 async fn user_view_write_routes_correctly() {
257 let (_tmp, store, user_dir) = setup().await;
258 let view = UserMemoryView::new(Arc::clone(&store), user_dir.clone())
259 .await
260 .unwrap();
261
262 view.write_file("USER.md", "# User\nBob\n").await.unwrap();
264 assert!(user_dir.join("USER.md").exists());
265 let content = std::fs::read_to_string(user_dir.join("USER.md")).unwrap();
266 assert!(content.contains("Bob"));
267
268 view.write_file("test-shared.md", "# Test\nShared content\n")
270 .await
271 .unwrap();
272 let content = store.read_file("test-shared.md").unwrap();
273 assert!(content.contains("Shared content"));
274 }
275
276 #[tokio::test]
277 async fn user_view_append_daily() {
278 let (_tmp, store, user_dir) = setup().await;
279 let view = UserMemoryView::new(store, user_dir.clone()).await.unwrap();
280
281 view.append_daily("Had a meeting").await.unwrap();
282 view.append_daily("Reviewed code").await.unwrap();
283
284 let today = Local::now().format("%Y-%m-%d").to_string();
285 let path = user_dir.join("memory").join(format!("{}.md", today));
286 assert!(path.exists());
287 let content = std::fs::read_to_string(path).unwrap();
288 assert!(content.contains("Had a meeting"));
289 assert!(content.contains("Reviewed code"));
290 }
291
292 #[tokio::test]
293 async fn user_view_read_routes_correctly() {
294 let (_tmp, store, user_dir) = setup().await;
295 let view = UserMemoryView::new(Arc::clone(&store), user_dir.clone())
296 .await
297 .unwrap();
298
299 std::fs::write(user_dir.join("USER.md"), "custom user data").unwrap();
301 let content = view.read_file("USER.md").unwrap();
302 assert!(content.contains("custom user data"));
303
304 let content = view.read_file("SOUL.md").unwrap();
306 assert!(content.contains("Nova"));
307 }
308
309 #[tokio::test]
310 async fn user_view_search_finds_user_files() {
311 let (_tmp, store, user_dir) = setup().await;
312 let view = UserMemoryView::new(Arc::clone(&store), user_dir.clone())
313 .await
314 .unwrap();
315
316 view.write_file(
318 "MEMORY.md",
319 "# Memory\n\nAlice prefers dark mode and Vim keybindings.\n",
320 )
321 .await
322 .unwrap();
323
324 let results = view.search("dark mode Vim", 5).await.unwrap();
326 assert!(
327 !results.is_empty(),
328 "Should find user memory content via FTS"
329 );
330 assert!(results.iter().any(|r| r.text.contains("dark mode")));
331 }
332
333 #[tokio::test]
334 async fn user_view_search_merges_agent_and_user() {
335 let (_tmp, store, user_dir) = setup().await;
336 let view = UserMemoryView::new(Arc::clone(&store), user_dir.clone())
337 .await
338 .unwrap();
339
340 view.write_file(
342 "MEMORY.md",
343 "# Memory\n\nThe assistant helps with Rust code.\n",
344 )
345 .await
346 .unwrap();
347
348 let results = view.search("assistant", 10).await.unwrap();
350 let sources: Vec<&str> = results.iter().map(|r| r.source.as_str()).collect();
351 assert!(
352 sources.contains(&"SOUL.md") || sources.contains(&"MEMORY.md"),
353 "Should find results from both agent and user stores"
354 );
355 }
356
357 #[tokio::test]
358 async fn user_view_append_daily_is_searchable() {
359 let (_tmp, store, user_dir) = setup().await;
360 let view = UserMemoryView::new(Arc::clone(&store), user_dir.clone())
361 .await
362 .unwrap();
363
364 view.append_daily("Discussed quantum computing with Bob")
365 .await
366 .unwrap();
367
368 let results = view.search("quantum computing", 5).await.unwrap();
369 assert!(
370 !results.is_empty(),
371 "Daily log entries should be searchable"
372 );
373 assert!(results.iter().any(|r| r.text.contains("quantum computing")));
374 }
375
376 #[test]
377 fn is_user_file_classification() {
378 assert!(is_user_file("USER.md"));
379 assert!(is_user_file("MEMORY.md"));
380 assert!(is_user_file("memory/2026-03-17.md"));
381 assert!(!is_user_file("SOUL.md"));
382 assert!(!is_user_file("test.md"));
383 assert!(!is_user_file("HEARTBEAT.md"));
384 }
385
386 #[test]
387 fn cap_str_handles_multibyte_utf8() {
388 let s = "café";
390 assert_eq!(s.len(), 5);
391 let result = cap_str(s, 4);
393 assert_eq!(result, "caf"); assert_eq!(cap_str(s, 5), "café");
396 assert_eq!(cap_str(s, 100), "café");
397 assert_eq!(cap_str(s, 0), "");
399 }
400
401 #[test]
402 fn cap_str_handles_emoji() {
403 let s = "hi 👋";
405 assert_eq!(s.len(), 7);
406 for i in 4..7 {
407 assert_eq!(cap_str(s, i), "hi ");
409 }
410 assert_eq!(cap_str(s, 7), "hi 👋");
411 }
412
413 #[tokio::test]
414 async fn read_user_file_rejects_traversal() {
415 let (_tmp, store, user_dir) = setup().await;
416 let _view = UserMemoryView::new(Arc::clone(&store), user_dir.clone())
417 .await
418 .unwrap();
419
420 let result = read_user_file(&user_dir, "memory/../../../etc/passwd");
422 assert!(
423 result.is_err(),
424 "read_user_file should reject path traversal"
425 );
426 }
427}