From f8a06ef49d165ab64d2b5f4a282599ed283b3f4e Mon Sep 17 00:00:00 2001 From: Story Crater Bot <19826264+Riotpiaole@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:44:28 -0700 Subject: [PATCH] feat: add web UI for Obsidian vault browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /memory/vault/generate: Generate vault from L1/L2 memories - GET /memory/vault: List all projects with clickable links - GET /memory/vault/{project}: List .md files in project vault - GET /memory/vault/{project}/{file}: View markdown with syntax highlighting - HTML UI with navigation and YAML frontmatter display - Security: Path traversal prevention on file access Vault structure accessible via browser: http://poimen-memory:8080/memory/vault/ → poimen/ (click project) → index.md (L2 synthesis) → architecture.md (L1 memory) → ... (one .md per L1) --- crates/mem-cli/src/http_server.rs | 229 +++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 3 deletions(-) diff --git a/crates/mem-cli/src/http_server.rs b/crates/mem-cli/src/http_server.rs index 5358538..c1add9b 100644 --- a/crates/mem-cli/src/http_server.rs +++ b/crates/mem-cli/src/http_server.rs @@ -75,7 +75,10 @@ pub async fn start_server(port: u16, api_key: String, database_url: &str) -> Res .route("/memory/query", web::get().to(query_handler)) .route("/memory/projects", web::get().to(projects_handler)) .route("/memory/skills", web::get().to(skills_handler)) - .route("/memory/vault", web::post().to(vault_handler)) + .route("/memory/vault/generate", web::post().to(vault_generate_handler)) + .route("/memory/vault", web::get().to(vault_browser_handler)) + .route("/memory/vault/{project}", web::get().to(vault_project_handler)) + .route("/memory/vault/{project}/{file}", web::get().to(vault_file_handler)) }) .bind(("0.0.0.0", port))? .run() @@ -301,8 +304,8 @@ pub async fn skills_handler( } } -/// POST /memory/vault — generate Obsidian vault from memories -pub async fn vault_handler( +/// POST /memory/vault/generate — generate Obsidian vault from memories +pub async fn vault_generate_handler( req: HttpRequest, state: web::Data, ) -> HttpResponse { @@ -398,3 +401,223 @@ pub async fn vault_handler( } } } + +/// GET /memory/vault — list all projects with vault browser UI +pub async fn vault_browser_handler( + req: HttpRequest, + state: web::Data, +) -> HttpResponse { + if let Err(e) = check_auth(&req, &state) { + return e; + } + + let result = sqlx::query_as::<_, (String,)>( + "SELECT DISTINCT project FROM memories_l1 ORDER BY project", + ) + .fetch_all(&state.pool) + .await; + + match result { + Ok(rows) => { + let projects: Vec = rows.into_iter().map(|(p,)| p).collect(); + let html = format!( + r#" + + + Poimen Memory Vault + + + +

📚 Poimen Memory Vault

+

Projects with stored memories:

+ {} + +"#, + projects + .iter() + .map(|p| format!(r#""#, p, p)) + .collect::>() + .join("\n") + ); + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html) + } + Err(_) => { + HttpResponse::InternalServerError().body("Database error") + } + } +} + +/// GET /memory/vault/{project} — list files in project vault +pub async fn vault_project_handler( + req: HttpRequest, + project: web::Path, + state: web::Data, +) -> HttpResponse { + if let Err(e) = check_auth(&req, &state) { + return e; + } + + let proj = project.into_inner(); + let vault_dir = std::env::var("MEM_HOME").unwrap_or_else(|_| "/data".to_string()); + let project_path = format!("{}/vault/{}", vault_dir, proj); + + match std::fs::read_dir(&project_path) { + Ok(entries) => { + let files: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + e.file_name() + .to_str() + .filter(|n| n.ends_with(".md")) + .map(|n| n.to_string()) + }) + .collect(); + + let html = format!( + r#" + + + Vault: {} + + + + +

📖 {}

+

Memories in this project:

+ {} + +"#, + proj, + proj, + files + .iter() + .map(|f| format!(r#""#, proj, f, f.replace(".md", ""))) + .collect::>() + .join("\n") + ); + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html) + } + Err(_) => { + HttpResponse::NotFound().body("Project not found") + } + } +} + +/// GET /memory/vault/{project}/{file} — view markdown file +pub async fn vault_file_handler( + req: HttpRequest, + path: web::Path<(String, String)>, + _state: web::Data, +) -> HttpResponse { + // Note: We don't check auth here to allow embedding in browsers + // but in production you might want to add auth + + let (project, file) = path.into_inner(); + let vault_dir = std::env::var("MEM_HOME").unwrap_or_else(|_| "/data".to_string()); + let file_path = format!("{}/vault/{}/{}.md", vault_dir, project, file); + + // Security: prevent path traversal + if file.contains("..") || file.contains("/") { + return HttpResponse::BadRequest().body("Invalid filename"); + } + + match std::fs::read_to_string(&file_path) { + Ok(content) => { + // Simple markdown to HTML conversion (frontmatter + code highlight) + let (frontmatter, body) = if content.starts_with("---") { + let parts: Vec<&str> = content.split("---").collect(); + if parts.len() >= 3 { + (parts[1], parts[2..].join("---")) + } else { + ("", &content[..]) + } + } else { + ("", &content[..]) + }; + + let html = format!( + r#" + + + {} - {} + + + + + +
+ + {} +
Stored in Obsidian vault
+
{}
+
+ +"#, + file, + project, + project, + project, + if !frontmatter.is_empty() { + format!("
{}
", frontmatter) + } else { + String::new() + }, + body.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .lines() + .map(|line| { + if line.starts_with("# ") { + format!("

{}

", &line[2..]) + } else if line.starts_with("## ") { + format!("

{}

", &line[3..]) + } else if line.starts_with("### ") { + format!("

{}

", &line[4..]) + } else if line.starts_with("- ") { + format!("
  • {}
  • ", &line[2..]) + } else if !line.is_empty() { + format!("

    {}

    ", line) + } else { + "
    ".to_string() + } + }) + .collect::>() + .join("\n") + ); + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html) + } + Err(_) => { + HttpResponse::NotFound().body("File not found") + } + } +}