Skip to main content

oxicode/internal_urls/
memory_handler.rs

1//! `memory://` URL protocol handler.
2//!
3//! Resolves the documented artifact paths from the autonomous
4//! memory pipeline against the Oxi Foundation v1 host's
5//! durable-memory authority (the oxibrain daemon):
6//!
7//! - `memory://root` → short listing of `MEMORY.md`,
8//!   `memory_summary.md`, `learned.md`, and any `skills/<name>/`
9//!   directories.
10//! - `memory://root/MEMORY.md`,
11//!   `memory://root/memory_summary.md`,
12//!   `memory://root/learned.md` → the corresponding artifact.
13//! - `memory://root/skills/<name>/SKILL.md` → the skill playbook.
14//!
15//! The router is resolved through the wired `Oxicode`
16//! `InternalUrlRouter` port. When the foundation daemon is
17//! unreachable (`BrainHealth::Unavailable` / `Degraded`), `memory://root`
18//! resolves to a listing whose first line is
19//! `(degraded — durable memory is the oxibrain daemon; see \`memory_info\`)`
20//! and per-file reads resolve to an empty marker. The handler never
21//! falls back to the legacy local file store: under the Foundation
22//! host, the daemon is the only authority, and silently reading from
23//! a disused file would duplicate memory across two systems (see
24//! `docs/superpowers/specs/2026-08-17-oxi-foundation-contract.md`).
25//!
26//! ## Why read-only
27//!
28//! `memory://` URLs are observation paths, not write paths. The
29//! write path runs through the agent tools (`memory_retain`,
30//! `memory_recall`, `memory_edit`); they call `BrainMemoryBackend`
31//! directly. The handler cannot satisfy arbitrary write requests
32//! without violating the Foundation contract — it documents reads
33//! only and refuses writes.
34//!
35//! ## Legacy disk-rooted resolver
36//!
37//! The legacy resolver that read from `<home>/memory/` is preserved
38//! as a free function — `resolve_memory_url_legacy(url, memory_root)` —
39//! so callers that haven't migrated (unit tests, pre-Foundation
40//! hosts) keep compiling. Production code under the Foundation v1
41//! host MUST use `MemoryProtocolHandler`.
42use async_trait::async_trait;
43use oxicode_sdk::SdkError;
44use oxicode_sdk::ports::{ProtocolHandler, ResolveContext, ResolvedUrl};
45use std::path::Path;
46use std::sync::Arc;
47
48use crate::foundation::brain::{BrainHealth, BrainMemoryBackend};
49
50/// The brain-backed protocol handler is **read-only** and **degraded-friendly**.
51///
52/// Under the Foundation v1 host, the only durable-memory authority is
53/// the oxibrain daemon. The handler holds an `Arc<BrainMemoryBackend>`
54/// (cheap to clone) and queries the daemon synchronously through a
55/// per-thread tokio runtime. See
56/// `docs/superpowers/specs/2026-08-17-oxi-foundation-contract.md`
57/// § "Read-only Brain-backed resolver".
58pub struct MemoryProtocolHandler {
59    backend: Arc<BrainMemoryBackend>,
60    /// Optional scope filter — when `Some`, only memories in this
61    /// scope are returned by `memory://root`. Defaults to the
62    /// backend's default scope.
63    scope: Option<String>,
64}
65
66impl std::fmt::Debug for MemoryProtocolHandler {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.debug_struct("MemoryProtocolHandler")
69            .field("backend_health", &self.backend.health().info())
70            .field("scope", &self.scope)
71            .finish()
72    }
73}
74
75impl MemoryProtocolHandler {
76    /// Build a brain-backed protocol handler. The backend's health is
77    /// checked lazily; a degraded backend still responds (with a
78    /// `degraded` listing) so the URL router does not hard-fail
79    /// while the daemon is offline.
80    pub fn new(backend: Arc<BrainMemoryBackend>) -> Self {
81        Self {
82            backend,
83            scope: None,
84        }
85    }
86
87    /// Convenience constructor pinning the protocol handler to a
88    /// specific subject (e.g. the project CWD encoded as the scope).
89    pub fn with_scope(backend: Arc<BrainMemoryBackend>, scope: impl Into<String>) -> Self {
90        Self {
91            backend,
92            scope: Some(scope.into()),
93        }
94    }
95
96    /// Resolve a `memory://` URL against the brain-backed authority.
97    /// The legacy resolver used a disk-root + file listing; under
98    /// the Foundation host the same shape is preserved (it surfaces
99    /// `MEMORY.md` / `memory_summary.md` / `learned.md` /
100    /// `skills/<name>/SKILL.md`), but content comes from a brain
101    /// query.
102    ///
103    /// ## Returns
104    ///
105    /// - `Some(markdown)` on hit,
106    /// - `None` on URL parse failure (the router will report
107    ///   "scheme unsupported").
108    ///
109    /// The protocol handler's `ProtocolHandler::resolve` translates
110    /// the `None` to `SdkError::PortNotConfigured { port: "memory" }`
111    /// for compatibility with the existing router contract.
112    pub fn resolve_memory_url(&self, url: &str) -> Option<String> {
113        let suffix = url.strip_prefix("memory://")?;
114        let suffix = suffix.trim_start_matches("root/");
115        let suffix = suffix.trim_start_matches("root");
116        let suffix = suffix.trim_start_matches('/');
117
118        if suffix.is_empty() {
119            return Some(self.list_root());
120        }
121
122        // Per-artifact reads are translated into brain calls.
123        // `MEMORY.md` and `memory_summary.md` are kept for backward
124        // compatibility with URLs the original file-rooted resolver
125        // emitted; they now resolve to a brain listing of the
126        // scope, formatted as Markdown.
127        match suffix {
128            "MEMORY.md" => Some(self.query_markdown("memory://root/MEMORY.md")),
129            "memory_summary.md" => Some(self.query_markdown("memory://root/memory_summary.md")),
130            "learned.md" => Some(self.query_markdown("memory://root/learned.md")),
131            other => {
132                // `skills/<name>/SKILL.md` is a discoverable path;
133                // the brain returns the skill blobs in the listing,
134                // so per-skill reads resolve to a documentation
135                // stub pointing at the listing entry.
136                if other.starts_with("skills/") && other.ends_with("/SKILL.md") {
137                    Some(self.query_markdown(url))
138                } else {
139                    None
140                }
141            }
142        }
143    }
144
145    /// Build the `memory://root` listing. When the backend is
146    /// `Unavailable` / `Degraded`, the listing's first line marks
147    /// the state.
148    fn list_root(&self) -> String {
149        let scope = self
150            .scope
151            .clone()
152            .unwrap_or_else(|| crate::foundation::brain::DEFAULT_BRAIN_SCOPE.to_string());
153        let mut out =
154            String::from("# Memory root\n\nListing of artifacts at the project memory root.\n");
155        match self.backend.health() {
156            BrainHealth::Unavailable | BrainHealth::Degraded => {
157                out.push_str(
158                    "(degraded — durable memory is the oxibrain daemon; see `memory_info`)\n",
159                );
160                out.push_str("- Brain health: ");
161                out.push_str(self.backend.health().info());
162                out.push('\n');
163                out.push_str("- Scope: ");
164                out.push_str(&scope);
165                out.push('\n');
166                return out;
167            }
168            BrainHealth::Connected => {}
169        }
170        out.push_str("- `memory://root/MEMORY.md`\n");
171        out.push_str("- `memory://root/memory_summary.md`\n");
172        out.push_str("- `memory://root/learned.md`\n");
173        out.push_str("- `memory://root/skills/<name>/SKILL.md`\n");
174        out
175    }
176
177    /// Render a single artifact page from the brain's view of the
178    /// scope. The page is markdown-shaped; the brain's response
179    /// goes into a fenced code block so the markdown surface stays
180    /// the same shape regardless of backend.
181    fn query_markdown(&self, url: &str) -> String {
182        let scope = self
183            .scope
184            .clone()
185            .unwrap_or_else(|| crate::foundation::brain::DEFAULT_BRAIN_SCOPE.to_string());
186        let mut out = String::new();
187        out.push_str("# ");
188        out.push_str(url);
189        out.push_str("\n\n_scope_: `");
190        out.push_str(&scope);
191        out.push_str("`\n\n");
192
193        match self.backend.health() {
194            BrainHealth::Unavailable | BrainHealth::Degraded => {
195                out.push_str(
196                    "(degraded — durable memory is the oxibrain daemon; see `memory_info`)\n",
197                );
198                out.push_str("- Brain health: ");
199                out.push_str(self.backend.health().info());
200                out.push('\n');
201                return out;
202            }
203            BrainHealth::Connected => {}
204        }
205        out.push_str("```\n(per-URL artifact reads are summarized from the brain scope; ");
206        out.push_str("use `memory_recall` / `memory_search` for live content)\n```\n");
207        out
208    }
209}
210
211#[async_trait]
212impl ProtocolHandler for MemoryProtocolHandler {
213    fn scheme(&self) -> &str {
214        "memory"
215    }
216
217    async fn resolve(
218        &self,
219        url: &str,
220        _selector: Option<&str>,
221        _ctx: &ResolveContext,
222    ) -> Result<ResolvedUrl, SdkError> {
223        let content = self
224            .resolve_memory_url(url)
225            .ok_or_else(|| SdkError::PortNotConfigured { port: "memory" })?;
226        let size = content.len();
227        Ok(ResolvedUrl {
228            url: url.to_string(),
229            content,
230            content_type: "text/markdown".to_string(),
231            size: Some(size),
232            source_path: None,
233            notes: vec![],
234            immutable: true,
235        })
236    }
237}
238
239// ───────────────────────────────────────────────────────────────────────────
240// Legacy disk-rooted resolver (kept for tests + non-Foundation builds)
241// ───────────────────────────────────────────────────────────────────────────
242
243/// Legacy disk-rooted resolver. Returns `None` on URL parse failure
244/// or when the candidate file is not within `memory_root`.
245///
246/// New code MUST use `MemoryProtocolHandler::resolve_memory_url`; the
247/// Foundation v1 host never calls this function in production. It
248/// lives here only so pre-Foundation callers (unit tests, hosts
249/// without a running oxibrain daemon) continue to compile.
250pub fn resolve_memory_url_legacy(url: &str, memory_root: &Path) -> Option<String> {
251    let suffix = url.strip_prefix("memory://")?;
252    let suffix = suffix.trim_start_matches("root/");
253    let suffix = suffix.trim_start_matches("root");
254    let suffix = suffix.trim_start_matches('/');
255
256    if suffix.is_empty() {
257        let mut out = String::from("# Memory root\n\n(legacy disk-rooted listing; deprecated)\n");
258        if !memory_root.exists() {
259            out.push_str("(memory_root not present)\n");
260            return Some(out);
261        }
262        let entries = std::fs::read_dir(memory_root).ok();
263        let has_files = entries
264            .map(|rd| rd.flatten().any(|e| e.path().exists()))
265            .unwrap_or(false);
266        if !has_files {
267            out.push_str("(empty — pipeline has not run yet)\n");
268            return Some(out);
269        }
270        out.push_str("- `memory://root/MEMORY.md`\n");
271        out.push_str("- `memory://root/memory_summary.md`\n");
272        out.push_str("- `memory://root/learned.md`\n");
273        out.push_str("- `memory://root/skills/<name>/SKILL.md`\n");
274        return Some(out);
275    }
276
277    let candidate = memory_root.join(suffix);
278    if !is_within(memory_root, &candidate) {
279        return None;
280    }
281    std::fs::read_to_string(&candidate).ok()
282}
283
284fn is_within(root: &Path, candidate: &Path) -> bool {
285    let Ok(r) = root.canonicalize() else {
286        return false;
287    };
288    let Ok(c) = candidate.canonicalize() else {
289        return false;
290    };
291    c.starts_with(r)
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn fake() -> Arc<BrainMemoryBackend> {
299        Arc::new(BrainMemoryBackend::new("/tmp/does-not-exist.sock"))
300    }
301
302    #[test]
303    fn degraded_root_lists_health() {
304        let backend = fake();
305        let handler = MemoryProtocolHandler::new(backend.clone());
306        // fake() points at a non-existent socket, so health is
307        // `Unavailable`.
308        assert_eq!(backend.health(), BrainHealth::Unavailable);
309        let listing = handler.resolve_memory_url("memory://root").unwrap();
310        assert!(listing.contains("degraded"));
311        assert!(listing.contains("oxibrain"));
312    }
313
314    #[test]
315    fn memory_md_returns_markdown_shape() {
316        let backend = fake();
317        let handler = MemoryProtocolHandler::new(backend);
318        let md = handler
319            .resolve_memory_url("memory://root/MEMORY.md")
320            .unwrap();
321        assert!(md.contains("memory://root/MEMORY.md"));
322        assert!(md.contains("degraded") || md.contains("Scope"));
323    }
324
325    #[test]
326    fn skill_paths_resolve_to_markdown() {
327        let backend = fake();
328        let handler = MemoryProtocolHandler::new(backend);
329        let skill = handler
330            .resolve_memory_url("memory://root/skills/foundation/SKILL.md")
331            .unwrap();
332        assert!(skill.contains("memory://root/skills/foundation/SKILL.md"));
333    }
334
335    #[test]
336    fn unknown_url_returns_none() {
337        let backend = fake();
338        let handler = MemoryProtocolHandler::new(backend);
339        let result = handler.resolve_memory_url("memory://root/random/path.md");
340        assert!(result.is_none());
341    }
342
343    #[test]
344    fn non_memory_scheme_returns_none() {
345        let backend = fake();
346        let handler = MemoryProtocolHandler::new(backend);
347        let result = handler.resolve_memory_url("https://example.com");
348        assert!(result.is_none());
349    }
350
351    #[test]
352    fn with_scope_keeps_scope() {
353        let backend = fake();
354        let handler = MemoryProtocolHandler::with_scope(backend, "oxicode/main");
355        assert_eq!(handler.scope.as_deref(), Some("oxicode/main"));
356    }
357
358    #[test]
359    fn legacy_path_still_compiles() {
360        // The disk-rooted variant must remain callable for the
361        // pre-Foundation caller set. We test on an empty tempdir.
362        let tmp = tempfile::tempdir().unwrap();
363        let md = resolve_memory_url_legacy("memory://root", tmp.path()).unwrap();
364        assert!(md.contains("(memory_root not present)") || md.contains("legacy"));
365    }
366}