Skip to main content

oxi/internal_urls/
memory_handler.rs

1//! `memory://` URL protocol handler.
2//!
3//! Resolves the documented artifact paths from the autonomous
4//! memory pipeline (omp `memory-protocol.ts` port):
5//!
6//! - `memory://root` → short listing of `MEMORY.md`,
7//!   `memory_summary.md`, `learned.md`, and any `skills/<name>/`
8//!   directories.
9//! - `memory://root/MEMORY.md`,
10//!   `memory://root/memory_summary.md`,
11//!   `memory://root/learned.md` → the corresponding artifact.
12//! - `memory://root/skills/<name>/SKILL.md` → the skill playbook.
13//!
14//! The router is resolved through the wired `Oxi`
15//! `InternalUrlRouter` port. When the local pipeline has not run
16//! yet, every path resolves to an empty listing.
17#![allow(missing_docs)]
18
19use std::path::{Path, PathBuf};
20
21use async_trait::async_trait;
22use oxi_sdk::SdkError;
23use oxi_sdk::ports::{ProtocolHandler, ResolveContext, ResolvedUrl};
24
25/// Resolve a `memory://` URL to plain markdown text.
26///
27/// `memory_root` is the project's `<home>/memory/<encoded-cwd>/`
28/// directory (see `memory_summary::memory_root`).
29pub fn resolve_memory_url(url: &str, memory_root: &Path) -> Option<String> {
30    let suffix = url.strip_prefix("memory://")?;
31    let suffix = suffix.trim_start_matches("root/");
32    let suffix = suffix.trim_start_matches("root");
33    let suffix = suffix.trim_start_matches('/');
34
35    if suffix.is_empty() {
36        return Some(list_root(memory_root));
37    }
38
39    let candidate = memory_root.join(suffix);
40    if !is_within(memory_root, &candidate) {
41        return None;
42    }
43    std::fs::read_to_string(&candidate).ok()
44}
45
46fn is_within(root: &Path, candidate: &Path) -> bool {
47    let Ok(r) = root.canonicalize() else {
48        return false;
49    };
50    let Ok(c) = candidate.canonicalize() else {
51        return false;
52    };
53    c.starts_with(r)
54}
55
56fn list_root(memory_root: &Path) -> String {
57    let mut out =
58        String::from("# Memory root\n\nListing of artifacts at the project memory root.\n");
59    let entries = std::fs::read_dir(memory_root).ok();
60    let has_files = entries
61        .map(|rd| {
62            rd.flatten().any(|e| {
63                let p = e.path();
64                p.is_file() || p.is_dir()
65            })
66        })
67        .unwrap_or(false);
68    if !has_files {
69        out.push_str("(empty — pipeline has not run yet)\n");
70        return out;
71    }
72    out.push_str("- `memory://root/MEMORY.md`\n");
73    out.push_str("- `memory://root/memory_summary.md`\n");
74    out.push_str("- `memory://root/learned.md`\n");
75    out.push_str("- `memory://root/skills/<name>/SKILL.md`\n");
76    out
77}
78
79/// Protocol-handler wrapper that resolves `memory://…` URLs against
80/// a single memory root registered at construction time.
81pub struct MemoryProtocolHandler {
82    memory_root: PathBuf,
83}
84
85impl MemoryProtocolHandler {
86    pub fn new(memory_root: PathBuf) -> Self {
87        Self { memory_root }
88    }
89}
90
91#[async_trait]
92impl ProtocolHandler for MemoryProtocolHandler {
93    fn scheme(&self) -> &str {
94        "memory"
95    }
96
97    async fn resolve(
98        &self,
99        url: &str,
100        _selector: Option<&str>,
101        _ctx: &ResolveContext,
102    ) -> Result<ResolvedUrl, SdkError> {
103        let content = resolve_memory_url(url, &self.memory_root)
104            .ok_or_else(|| SdkError::PortNotConfigured { port: "memory" })?;
105        let size = content.len();
106        Ok(ResolvedUrl {
107            url: url.to_string(),
108            content,
109            content_type: "text/markdown".to_string(),
110            size: Some(size),
111            source_path: None,
112            notes: vec![],
113            immutable: true,
114        })
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn resolve_root_returns_listing() {
124        let dir = tempfile::tempdir().unwrap();
125        std::fs::write(dir.path().join("MEMORY.md"), "hi").unwrap();
126        let out = resolve_memory_url("memory://root", dir.path()).unwrap();
127        assert!(out.contains("Memory root"));
128    }
129
130    #[test]
131    fn resolve_specific_file() {
132        let dir = tempfile::tempdir().unwrap();
133        let p = dir.path().join("MEMORY.md");
134        std::fs::write(&p, "hello").unwrap();
135        let out = resolve_memory_url("memory://root/MEMORY.md", dir.path()).unwrap();
136        assert_eq!(out, "hello");
137    }
138
139    #[test]
140    fn traversal_rejected() {
141        let dir = tempfile::tempdir().unwrap();
142        assert!(resolve_memory_url("memory://root/../../etc/passwd", dir.path()).is_none());
143    }
144}