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