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::{StarpodError, Result};
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(
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        // Create per-user memory store (owns its own FTS5 index in user_dir/memory.db)
66        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    /// Build bootstrap context: SOUL.md from agent, USER.md + MEMORY.md + recent logs from user.
76    pub fn bootstrap_context(&self, bootstrap_file_cap: usize) -> Result<String> {
77        let mut parts = Vec::new();
78
79        // SOUL.md from agent store
80        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        // USER.md from user dir
85        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        // MEMORY.md from user dir
90        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        // Recent daily logs from user dir (last 3 days)
95        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    /// Search both agent-level and user-level content.
122    ///
123    /// Queries both the shared agent FTS index and the per-user FTS index
124    /// concurrently, merges results by rank, and returns the top `limit`.
125    pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
126        // Query both stores concurrently
127        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        // Merge: interleave by rank (more negative = better match)
136        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    /// Write a file, routing to the appropriate location.
144    ///
145    /// - `USER.md`, `MEMORY.md`, `memory/*` → user store (with FTS reindex)
146    /// - Everything else → agent store (shared)
147    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    /// Append to the user's daily log (with FTS reindex).
156    pub async fn append_daily(&self, text: &str) -> Result<()> {
157        self.user_store.append_daily(text).await
158    }
159
160    /// Read a file from the appropriate location.
161    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    /// Check if BOOTSTRAP.md exists (delegates to agent store).
170    pub fn has_bootstrap(&self) -> bool {
171        self.agent_store.has_bootstrap()
172    }
173
174    /// Clear BOOTSTRAP.md (delegates to agent store).
175    pub fn clear_bootstrap(&self) -> Result<()> {
176        self.agent_store.clear_bootstrap()
177    }
178}
179
180/// Check if a file path should be stored per-user.
181fn is_user_file(name: &str) -> bool {
182    name == "USER.md"
183        || name == "MEMORY.md"
184        || name.starts_with("memory/")
185}
186
187/// Read a file from the user directory, returning empty string if not found.
188fn 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
197/// Cap a string at a maximum length.
198fn 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        // Per-user memory.db should be created
234        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        // Write custom user profile
243        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        // Should have SOUL.md from agent (contains "Aster")
247        assert!(ctx.contains("SOUL.md"));
248        assert!(ctx.contains("Aster"));
249        // Should have USER.md from user
250        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        // USER.md goes to user dir
259        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        // Non-user files go to agent store
265        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        // Read USER.md from user dir
292        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        // Read SOUL.md from agent store
297        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        // Write user-specific memory content
307        view.write_file("MEMORY.md", "# Memory\n\nAlice prefers dark mode and Vim keybindings.\n")
308            .await
309            .unwrap();
310
311        // Search should find user content
312        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        // Write user-specific content
323        view.write_file("MEMORY.md", "# Memory\n\nThe assistant helps with Rust code.\n")
324            .await
325            .unwrap();
326
327        // Search for "assistant" — should match both SOUL.md (agent) and MEMORY.md (user)
328        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        // "café" = 5 bytes: c(1) a(1) f(1) é(2)
361        let s = "café";
362        assert_eq!(s.len(), 5);
363        // Slicing at 4 would split the 'é' (bytes 3-4). cap_str should not panic.
364        let result = cap_str(s, 4);
365        assert_eq!(result, "caf"); // truncates before the multi-byte char
366        // Slicing at 5 returns the full string
367        assert_eq!(cap_str(s, 5), "café");
368        assert_eq!(cap_str(s, 100), "café");
369        // Edge: cap at 0
370        assert_eq!(cap_str(s, 0), "");
371    }
372
373    #[test]
374    fn cap_str_handles_emoji() {
375        // "hi 👋" = 7 bytes: h(1) i(1) (1) 👋(4)
376        let s = "hi 👋";
377        assert_eq!(s.len(), 7);
378        for i in 4..7 {
379            // Slicing at 4,5,6 would all split the emoji; cap_str should truncate before it
380            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        // Attempt path traversal via user file path
391        let result = read_user_file(&user_dir, "memory/../../../etc/passwd");
392        assert!(result.is_err(), "read_user_file should reject path traversal");
393    }
394}