1use std::pin::Pin;
37use std::sync::Arc;
38use std::sync::atomic::{AtomicU8, Ordering};
39
40use serde_json::json;
41use tokio::sync::Mutex;
42
43use oxicode_agent::tools::{MemoryBackend, MemoryItem, ToolError};
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum BrainHealth {
54 Connected,
56 Degraded,
58 Unavailable,
60}
61
62impl BrainHealth {
63 pub fn info(self) -> &'static str {
64 match self {
65 BrainHealth::Connected => "ok: oxibrain daemon connected",
66 BrainHealth::Degraded => "degraded: oxibrain daemon unreachable",
67 BrainHealth::Unavailable => "degraded: oxibrain daemon unreachable",
68 }
69 }
70}
71
72const HEALTH_CONNECTED: u8 = 0;
73const HEALTH_DEGRADED: u8 = 1;
74const HEALTH_UNAVAILABLE: u8 = 2;
75fn encode_health(h: BrainHealth) -> u8 {
76 match h {
77 BrainHealth::Connected => HEALTH_CONNECTED,
78 BrainHealth::Degraded => HEALTH_DEGRADED,
79 BrainHealth::Unavailable => HEALTH_UNAVAILABLE,
80 }
81}
82fn decode_health(b: u8) -> BrainHealth {
83 match b {
84 HEALTH_CONNECTED => BrainHealth::Connected,
85 HEALTH_DEGRADED => BrainHealth::Degraded,
86 _ => BrainHealth::Unavailable,
87 }
88}
89
90#[derive(Debug, Clone)]
94pub enum MigrationError {
95 BackendOffline,
97 Backend(String),
99 Runtime(String),
101 Checkpoint(String),
103}
104
105impl std::fmt::Display for MigrationError {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 match self {
108 MigrationError::BackendOffline => f.write_str("brain daemon offline"),
109 MigrationError::Backend(e) => write!(f, "brain write failed: {e}"),
110 MigrationError::Runtime(e) => write!(f, "migration runtime: {e}"),
111 MigrationError::Checkpoint(e) => write!(f, "checkpoint write failed: {e}"),
112 }
113 }
114}
115
116impl std::error::Error for MigrationError {}
117pub const DEFAULT_BRAIN_SCOPE: &str = "personal";
126
127pub struct BrainMemoryBackend {
131 socket_path: std::path::PathBuf,
132 client: Arc<Mutex<Option<oxibrain_client::BrainClient>>>,
133 health: Arc<AtomicU8>,
134 scope: String,
135}
136
137impl std::fmt::Debug for BrainMemoryBackend {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 f.debug_struct("BrainMemoryBackend")
140 .field("socket_path", &self.socket_path)
141 .field("scope", &self.scope)
142 .field(
143 "health",
144 &decode_health(self.health.load(Ordering::SeqCst)).info(),
145 )
146 .finish()
147 }
148}
149
150impl BrainMemoryBackend {
151 pub fn new(socket_path: impl Into<std::path::PathBuf>) -> Self {
154 Self {
155 socket_path: socket_path.into(),
156 client: Arc::new(Mutex::new(None)),
157 health: Arc::new(AtomicU8::new(HEALTH_UNAVAILABLE)),
158 scope: DEFAULT_BRAIN_SCOPE.to_string(),
159 }
160 }
161
162 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
165 self.scope = scope.into();
166 self
167 }
168
169 pub fn health(&self) -> BrainHealth {
171 decode_health(self.health.load(Ordering::SeqCst))
172 }
173
174 pub async fn ping(&self) -> Result<(), ToolError> {
177 self.with_client(|c| Box::pin(async move { c.ping().await }))
178 .await
179 }
180
181 pub async fn stats(&self) -> Result<serde_json::Value, ToolError> {
186 let space = self.scope.clone();
187 self.with_client(|c| Box::pin(async move { c.stats(&space).await }))
188 .await
189 }
190
191 pub fn stats_sync(&self) -> Result<serde_json::Value, ToolError> {
194 block_on_sync(self.stats())
195 }
196
197 pub fn search_sync(&self, query: &str, k: usize) -> Result<Vec<MemoryItem>, ToolError> {
199 block_on_sync(<Self as MemoryBackend>::search(self, query, k))
200 }
201 pub async fn connected(&self) -> bool {
204 self.client.lock().await.is_some()
205 }
206
207 pub fn put_sync(&self, content: &str, kind: &str, subject: &str) -> Result<String, ToolError> {
212 block_on_sync(<Self as MemoryBackend>::put(self, content, kind, subject))
213 }
214
215 pub fn ping_sync(&self) -> Result<(), ToolError> {
218 block_on_sync(self.ping())
219 }
220
221 async fn ensure_connected(&self) -> Result<(), ToolError> {
222 let mut guard = self.client.lock().await;
223 if guard.is_some() {
224 return Ok(());
225 }
226 match oxibrain_client::BrainClient::connect(&self.socket_path).await {
227 Ok(client) => {
228 *guard = Some(client);
229 self.health.store(HEALTH_CONNECTED, Ordering::SeqCst);
230 Ok(())
231 }
232 Err(e) => {
233 self.health.store(HEALTH_UNAVAILABLE, Ordering::SeqCst);
234 Err(format!(
235 "backend unavailable: oxibrain daemon unreachable at {}: {e}",
236 self.socket_path.display()
237 ))
238 }
239 }
240 }
241
242 async fn with_client<R>(
246 &self,
247 f: impl FnOnce(
248 &mut oxibrain_client::BrainClient,
249 ) -> futures::future::BoxFuture<'_, anyhow::Result<R>>,
250 ) -> Result<R, ToolError> {
251 self.ensure_connected().await?;
252 let mut guard = self.client.lock().await;
253 let client = guard.as_mut().ok_or_else(|| {
254 "backend unavailable: oxibrain client missing after handshake".to_string()
255 })?;
256 match f(client).await {
257 Ok(v) => {
258 self.health.store(HEALTH_CONNECTED, Ordering::SeqCst);
259 Ok(v)
260 }
261 Err(e) => {
262 self.health.store(HEALTH_DEGRADED, Ordering::SeqCst);
263 *guard = None;
264 Err(format!("backend unavailable: oxibrain call failed: {e}"))
265 }
266 }
267 }
268}
269
270fn block_on_sync<F: std::future::Future>(fut: F) -> F::Output {
275 match tokio::runtime::Handle::try_current() {
276 Ok(handle) => tokio::task::block_in_place(|| handle.block_on(fut)),
277 Err(_) => {
278 let rt = tokio::runtime::Builder::new_current_thread()
279 .enable_io()
280 .build()
281 .expect("build current-thread tokio runtime");
282 rt.block_on(fut)
283 }
284 }
285}
286impl MemoryBackend for BrainMemoryBackend {
287 fn put<'a>(
288 &'a self,
289 content: &'a str,
290 kind: &'a str,
291 subject: &'a str,
292 ) -> Pin<Box<dyn Future<Output = Result<String, ToolError>> + Send + 'a>> {
293 Box::pin(async move {
294 let args = json!({
295 "content": content,
296 "space": self.scope,
297 "source_path": format!("oxicode/{kind}/{subject}"),
298 "extract": false,
307 });
308 let raw = self
309 .with_client(|c| Box::pin(async move { c.call_tool("ingest", args).await }))
310 .await?;
311 let id = raw
314 .split_once("episode:")
315 .map(|(_, tail)| tail.trim().to_string())
316 .unwrap_or_else(|| raw.trim().to_string());
317 Ok(id)
318 })
319 }
320
321 fn search<'a>(
322 &'a self,
323 query: &'a str,
324 k: usize,
325 ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
326 Box::pin(async move {
327 let args = json!({
331 "query": query,
332 "space": self.scope,
333 "token_budget": 4000,
334 });
335 let _ = k;
336 let raw = self
337 .with_client(|c| Box::pin(async move { c.call_tool("recall", args).await }))
338 .await?;
339 parse_memory_items(&raw)
340 })
341 }
342
343 fn list<'a>(
344 &'a self,
345 subject: &'a str,
346 ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
347 Box::pin(async move {
348 let args = json!({
352 "query": subject,
353 "space": self.scope,
354 "token_budget": 2000,
355 });
356 let raw = self
357 .with_client(|c| Box::pin(async move { c.call_tool("recall", args).await }))
358 .await?;
359 parse_memory_items(&raw)
360 })
361 }
362
363 fn delete<'a>(
364 &'a self,
365 id: &'a str,
366 ) -> Pin<Box<dyn Future<Output = Result<(), ToolError>> + Send + 'a>> {
367 Box::pin(async move {
368 if id.trim().is_empty() {
369 return Err("backend unavailable: brain delete requires an episode id \
370 (a `put` return value or a recall provenance id)"
371 .to_string());
372 }
373 let args = json!({
376 "target_kind": "episode",
377 "target_id": id,
378 "space": self.scope,
379 });
380 let _ = self
381 .with_client(|c| Box::pin(async move { c.call_tool("redact", args).await }))
382 .await?;
383 Ok(())
384 })
385 }
386
387 fn memory_info(&self) -> Option<String> {
388 Some(self.health().info().to_string())
389 }
390}
391
392fn parse_memory_items(raw: &str) -> Result<Vec<MemoryItem>, ToolError> {
403 let value: serde_json::Value = serde_json::from_str(raw)
404 .map_err(|e| format!("backend unavailable: malformed memory response: {e}"))?;
405 let layers = value
406 .get("layers")
407 .and_then(|v| v.as_array())
408 .ok_or_else(|| "backend unavailable: unrecognized memory response shape".to_string())?;
409 let mut out = Vec::new();
410 for layer in layers {
411 let kind = layer
412 .get("kind")
413 .and_then(|v| v.as_str())
414 .unwrap_or("layer");
415 let text = layer.get("text").and_then(|v| v.as_str()).unwrap_or("");
416 let provenance: Vec<&str> = layer
417 .get("provenance")
418 .and_then(|v| v.as_array())
419 .map(|a| a.iter().filter_map(|p| p.as_str()).collect())
420 .unwrap_or_default();
421 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
422 for (i, line) in lines.iter().enumerate() {
423 let id = provenance.get(i).map(|p| p.to_string()).unwrap_or_default();
424 out.push(MemoryItem {
425 id,
426 kind: kind.to_string(),
427 content: line.trim().to_string(),
428 subject: String::new(),
429 });
430 }
431 }
432 Ok(out)
433}
434
435pub fn default_socket_path() -> std::path::PathBuf {
445 if let Ok(p) = std::env::var("OXIBRAIN_SOCKET") {
446 return std::path::PathBuf::from(p);
447 }
448 if let Some(home) = dirs::home_dir() {
449 return home.join(".oxi").join("brain").join("oxibrain.sock");
450 }
451 std::path::PathBuf::from(".oxi/brain/oxibrain.sock")
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn health_info_strings_match_spec() {
460 assert_eq!(
461 BrainHealth::Connected.info(),
462 "ok: oxibrain daemon connected"
463 );
464 assert_eq!(
465 BrainHealth::Degraded.info(),
466 "degraded: oxibrain daemon unreachable"
467 );
468 assert_eq!(
469 BrainHealth::Unavailable.info(),
470 "degraded: oxibrain daemon unreachable"
471 );
472 }
473
474 #[test]
475 fn health_round_trip() {
476 for h in [
477 BrainHealth::Connected,
478 BrainHealth::Degraded,
479 BrainHealth::Unavailable,
480 ] {
481 assert_eq!(decode_health(encode_health(h)), h);
482 }
483 }
484
485 #[test]
486 fn parse_memory_items_maps_recall_layers() {
487 let raw = r#"{"layers":[
488 {"kind":"recent_episodes",
489 "text":"first note\nsecond note\n",
490 "provenance":["ep-1","ep-2"]},
491 {"kind":"statements","text":"oxicode prefers Korean prose"}
492 ]}"#;
493 let items = parse_memory_items(raw).unwrap();
494 assert_eq!(items.len(), 3);
495 assert_eq!(items[0].id, "ep-1");
496 assert_eq!(items[0].kind, "recent_episodes");
497 assert_eq!(items[0].content, "first note");
498 assert_eq!(items[1].id, "ep-2");
499 assert_eq!(items[1].content, "second note");
500 assert_eq!(items[2].id, "");
502 assert_eq!(items[2].kind, "statements");
503 assert_eq!(items[2].content, "oxicode prefers Korean prose");
504 }
505
506 #[test]
507 fn parse_memory_items_skips_blank_lines() {
508 let raw = r#"{"layers":[{"kind":"recent_episodes",
509 "text":"\n \nonly line\n","provenance":["ep-9"]}]}"#;
510 let items = parse_memory_items(raw).unwrap();
511 assert_eq!(items.len(), 1);
512 assert_eq!(items[0].id, "ep-9");
513 assert_eq!(items[0].content, "only line");
514 }
515
516 #[test]
517 fn parse_memory_items_empty_layers_is_ok() {
518 let items = parse_memory_items(r#"{"layers":[]}"#).unwrap();
519 assert!(items.is_empty());
520 }
521
522 #[test]
523 fn parse_memory_items_rejects_unknown_wrapper() {
524 let raw = r#"{"unexpected":"shape"}"#;
525 let err = parse_memory_items(raw).unwrap_err();
526 assert!(err.starts_with("backend unavailable"));
527 }
528
529 #[test]
530 fn backend_starts_unavailable() {
531 let backend = BrainMemoryBackend::new("/tmp/does-not-exist.sock");
532 assert_eq!(backend.health(), BrainHealth::Unavailable);
533 assert_eq!(
534 backend.memory_info().as_deref(),
535 Some("degraded: oxibrain daemon unreachable")
536 );
537 }
538
539 #[test]
540 fn backend_with_scope_keeps_scope() {
541 let backend = BrainMemoryBackend::new("/tmp/x.sock").with_scope("oxicode/main");
542 assert_eq!(backend.scope, "oxicode/main");
543 }
544}