Skip to main content

starpod_memory/
user_view.rs

1//! Per-user memory view — overlays user-specific files on top of shared agent memory.
2//!
3//! In multi-user mode, each user has their own `USER.md`, `MEMORY.md`, `memory/` daily logs,
4//! while sharing the agent's `SOUL.md` and search index. User files are indexed in a per-user
5//! SQLite FTS5 database for fast search.
6
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use starpod_core::{Result, StarpodError};
11
12use crate::scoring;
13use crate::store::{MemoryStore, SearchResult};
14
15/// A per-user view over the shared agent memory store.
16///
17/// Provides file routing and search merging for multi-user deployments:
18///
19/// - **Shared files** (`SOUL.md`, `HEARTBEAT.md`, `BOOT.md`, `BOOTSTRAP.md`) come
20///   from the agent-level store and are shared across all users.
21/// - **Per-user files** (`USER.md`, `MEMORY.md`, `memory/*` daily logs) live in
22///   `users/<id>/` and are indexed in a per-user SQLite FTS5 database.
23/// - **Search** queries both agent-level and user-level indexes concurrently,
24///   merging results by rank.
25/// - **Writes** automatically route to the correct store based on the file path,
26///   with FTS reindexing handled transparently.
27pub struct UserMemoryView {
28    /// Shared agent-level memory store (SOUL.md, search index).
29    agent_store: Arc<MemoryStore>,
30    /// Per-user memory store with its own FTS5 index in `user_dir/memory.db`.
31    user_store: MemoryStore,
32    /// Per-user directory (.starpod/users/<id>/).
33    user_dir: PathBuf,
34}
35
36impl UserMemoryView {
37    /// Create a new per-user memory view.
38    ///
39    /// Creates the user directory structure, seeds default `USER.md` and
40    /// `MEMORY.md` if they don't exist, and initializes a per-user FTS5
41    /// index (stored in `user_dir/memory.db`).
42    ///
43    /// This is `async` because it initializes the per-user SQLite database.
44    pub async fn new(agent_store: Arc<MemoryStore>, user_dir: PathBuf) -> Result<Self> {
45        // Create user directory structure
46        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        // Seed defaults if they don't exist
50        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        // Create per-user memory store (owns its own FTS5 index in user_dir/memory.db)
64        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    /// Build bootstrap context: SOUL.md from agent, USER.md + MEMORY.md + recent logs from user.
74    pub fn bootstrap_context(&self, bootstrap_file_cap: usize) -> Result<String> {
75        let mut parts = Vec::new();
76
77        // SOUL.md from agent store
78        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        // USER.md from user dir
83        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        // MEMORY.md from user dir
88        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        // Recent daily logs from user dir (last 3 days)
93        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    /// Search both agent-level and user-level content.
116    ///
117    /// Queries both the shared agent FTS index and the per-user FTS index
118    /// concurrently, merges results by rank, and returns the top `limit`.
119    pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
120        // Query both stores concurrently
121        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        // Merge: interleave by rank (more negative = better match)
130        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    /// Write a file, routing to the appropriate location.
142    ///
143    /// - `USER.md`, `MEMORY.md`, `memory/*` → user store (with FTS reindex)
144    /// - Everything else → agent store (shared)
145    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    /// Append to the user's daily log (with FTS reindex).
154    pub async fn append_daily(&self, text: &str) -> Result<()> {
155        self.user_store.append_daily(text).await
156    }
157
158    /// Read a file from the appropriate location.
159    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    /// Check if BOOTSTRAP.md exists (delegates to agent store).
168    pub fn has_bootstrap(&self) -> bool {
169        self.agent_store.has_bootstrap()
170    }
171
172    /// Clear BOOTSTRAP.md (delegates to agent store).
173    pub fn clear_bootstrap(&self) -> Result<()> {
174        self.agent_store.clear_bootstrap()
175    }
176}
177
178/// Check if a file path should be stored per-user.
179fn is_user_file(name: &str) -> bool {
180    name == "USER.md" || name == "MEMORY.md" || name.starts_with("memory/")
181}
182
183/// Read a file from the user directory, returning empty string if not found.
184fn 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
193/// Cap a string at a maximum length.
194fn 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        // Per-user memory.db should be created
236        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        // Write custom user profile
245        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        // Should have SOUL.md from agent (contains "Nova")
249        assert!(ctx.contains("SOUL.md"));
250        assert!(ctx.contains("Nova"));
251        // Should have USER.md from user
252        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        // USER.md goes to user dir
263        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        // Non-user files go to agent store
269        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        // Read USER.md from user dir
300        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        // Read SOUL.md from agent store
305        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        // Write user-specific memory content
317        view.write_file(
318            "MEMORY.md",
319            "# Memory\n\nAlice prefers dark mode and Vim keybindings.\n",
320        )
321        .await
322        .unwrap();
323
324        // Search should find user content
325        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        // Write user-specific content
341        view.write_file(
342            "MEMORY.md",
343            "# Memory\n\nThe assistant helps with Rust code.\n",
344        )
345        .await
346        .unwrap();
347
348        // Search for "assistant" — should match both SOUL.md (agent) and MEMORY.md (user)
349        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        // "café" = 5 bytes: c(1) a(1) f(1) é(2)
389        let s = "café";
390        assert_eq!(s.len(), 5);
391        // Slicing at 4 would split the 'é' (bytes 3-4). cap_str should not panic.
392        let result = cap_str(s, 4);
393        assert_eq!(result, "caf"); // truncates before the multi-byte char
394                                   // Slicing at 5 returns the full string
395        assert_eq!(cap_str(s, 5), "café");
396        assert_eq!(cap_str(s, 100), "café");
397        // Edge: cap at 0
398        assert_eq!(cap_str(s, 0), "");
399    }
400
401    #[test]
402    fn cap_str_handles_emoji() {
403        // "hi 👋" = 7 bytes: h(1) i(1) (1) 👋(4)
404        let s = "hi 👋";
405        assert_eq!(s.len(), 7);
406        for i in 4..7 {
407            // Slicing at 4,5,6 would all split the emoji; cap_str should truncate before it
408            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        // Attempt path traversal via user file path
421        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}