oxicode/internal_urls/
memory_handler.rs1use 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
50pub struct MemoryProtocolHandler {
59 backend: Arc<BrainMemoryBackend>,
60 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 pub fn new(backend: Arc<BrainMemoryBackend>) -> Self {
81 Self {
82 backend,
83 scope: None,
84 }
85 }
86
87 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 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 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 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 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 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
239pub 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 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 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}