Skip to main content

oxicode/foundation/
brain.rs

1//! Brain (oxibrain) memory backend.
2//!
3//! `BrainMemoryBackend` is the **only** durable-memory authority under the
4//! Oxi Foundation v1 host. The legacy local memory backends
5//! (`memory_sqlite`, `memory_summary`, `memory_mnemopi`, `memory_workers`,
6//! `mnemopi`) stay compilable during the migration window but are no
7//! longer durable: they are read-only mirrors, never write targets.
8//!
9//! ## Wire protocol
10//!
11//! oxibrain exposes its `memory.*` tool surface over a Unix-domain socket
12//! via the JSON-RPC client in `oxibrain-client`. The backend translates
13//! every `MemoryBackend` method into one oxibrain tool call:
14//!
15//! | `MemoryBackend` method | oxibrain tool          | args shape                            |
16//! |---|---|---|
17//! | `put`                  | `memory.put`           | `{"content": ..., "kind": ..., "subject": ...}` |
18//! | `search`               | `memory.search`        | `{"query": ..., "k": N}`              |
19//! | `list`                 | `memory.list`          | `{"subject": ...}`                    |
20//! | `delete`               | `memory.delete`        | `{"id": ...}`                         |
21//!
22//! ## Degraded mode
23//!
24//! When the daemon is unreachable, every mutation returns
25//! `ToolError(String)` carrying `"backend unavailable: oxibrain daemon unreachable"`.
26//! The local file store is **never** consulted as a fallback, because doing
27//! so would silently duplicate memory across two authorities and break
28//! the Foundation contract. Tools surface `degraded` to the user instead
29//! of pretending the store succeeded.
30//!
31//! ## Unix-only
32//!
33//! oxibrain-client uses Unix-domain sockets. On non-Unix targets this
34//! module compiles to a stub that returns `BackendUnavailable` for every
35//! call. The same `memory_info` constant is used in both targets so the
36//! TUI's health banner reads "degraded" identically.
37
38use std::pin::Pin;
39use std::sync::Arc;
40use std::sync::atomic::{AtomicU8, Ordering};
41
42use serde_json::json;
43use tokio::sync::Mutex;
44
45use oxicode_agent::tools::{MemoryBackend, MemoryItem, ToolError};
46
47// ───────────────────────────────────────────────────────────────────────────
48// Health state
49// ───────────────────────────────────────────────────────────────────────────
50
51/// Health of the Brain connection. Surfaced via `memory_info` so the TUI
52/// health banner reports the state without leaking the underlying
53/// transport.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum BrainHealth {
56    /// Connected to the daemon and ready to serve requests.
57    Connected,
58    /// Daemon reachable but last call failed. Retries on next mutation.
59    Degraded,
60    /// Last `connect()` failed. Backend is in `Unavailable` mode.
61    Unavailable,
62}
63
64impl BrainHealth {
65    pub fn info(self) -> &'static str {
66        match self {
67            BrainHealth::Connected => "ok: oxibrain daemon connected",
68            BrainHealth::Degraded => "degraded: oxibrain daemon unreachable",
69            BrainHealth::Unavailable => "degraded: oxibrain daemon unreachable",
70        }
71    }
72}
73
74const HEALTH_CONNECTED: u8 = 0;
75const HEALTH_DEGRADED: u8 = 1;
76const HEALTH_UNAVAILABLE: u8 = 2;
77fn encode_health(h: BrainHealth) -> u8 {
78    match h {
79        BrainHealth::Connected => HEALTH_CONNECTED,
80        BrainHealth::Degraded => HEALTH_DEGRADED,
81        BrainHealth::Unavailable => HEALTH_UNAVAILABLE,
82    }
83}
84fn decode_health(b: u8) -> BrainHealth {
85    match b {
86        HEALTH_CONNECTED => BrainHealth::Connected,
87        HEALTH_DEGRADED => BrainHealth::Degraded,
88        _ => BrainHealth::Unavailable,
89    }
90}
91
92/// Migration-specific error type. Distinguishes the cases the
93/// migration core cares about: backend offline, write failure,
94/// runtime failure.
95#[derive(Debug, Clone)]
96pub enum MigrationError {
97    /// Brain daemon is known to be unreachable (handshake failed).
98    BackendOffline,
99    /// The write returned a `ToolError` (= `String`).
100    Backend(String),
101    /// The migration runtime failed to build or execute.
102    Runtime(String),
103    /// The resumable checkpoint could not be written.
104    Checkpoint(String),
105}
106
107impl std::fmt::Display for MigrationError {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            MigrationError::BackendOffline => f.write_str("brain daemon offline"),
111            MigrationError::Backend(e) => write!(f, "brain write failed: {e}"),
112            MigrationError::Runtime(e) => write!(f, "migration runtime: {e}"),
113            MigrationError::Checkpoint(e) => write!(f, "checkpoint write failed: {e}"),
114        }
115    }
116}
117
118impl std::error::Error for MigrationError {}
119// ───────────────────────────────────────────────────────────────────────────
120// Backend
121// ───────────────────────────────────────────────────────────────────────────
122
123/// Default scope identifier passed to oxibrain when one is not provided.
124/// oxicode uses the project working directory when known; the fallback
125/// is the literal `"default"` so the daemon can route to a project
126/// bucket.
127pub const DEFAULT_BRAIN_SCOPE: &str = "default";
128
129/// Brain memory backend. The `Arc<Mutex<Option<…>>>` wrapper yields
130/// interior mutability on the optional client while keeping the
131/// `MemoryBackend` trait object signature (`Arc<…>`, no `&mut`).
132pub struct BrainMemoryBackend {
133    socket_path: std::path::PathBuf,
134    client: Arc<Mutex<Option<oxibrain_client::BrainClient>>>,
135    health: Arc<AtomicU8>,
136    scope: String,
137}
138
139impl std::fmt::Debug for BrainMemoryBackend {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        f.debug_struct("BrainMemoryBackend")
142            .field("socket_path", &self.socket_path)
143            .field("scope", &self.scope)
144            .field(
145                "health",
146                &decode_health(self.health.load(Ordering::SeqCst)).info(),
147            )
148            .finish()
149    }
150}
151
152impl BrainMemoryBackend {
153    /// Build a backend pointing at the given socket path. The client
154    /// is not eagerly connected; the first call attempts to attach.
155    pub fn new(socket_path: impl Into<std::path::PathBuf>) -> Self {
156        Self {
157            socket_path: socket_path.into(),
158            client: Arc::new(Mutex::new(None)),
159            health: Arc::new(AtomicU8::new(HEALTH_UNAVAILABLE)),
160            scope: DEFAULT_BRAIN_SCOPE.to_string(),
161        }
162    }
163
164    /// Set the default scope. Tools that do not pass one explicitly use
165    /// this value.
166    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
167        self.scope = scope.into();
168        self
169    }
170
171    /// Current health. Read by the TUI health banner.
172    pub fn health(&self) -> BrainHealth {
173        decode_health(self.health.load(Ordering::SeqCst))
174    }
175
176    /// Number of times the connection is currently held. Always 0 or 1
177    /// in production; useful for tests that assert reconnect logic.
178    pub async fn connected(&self) -> bool {
179        self.client.lock().await.is_some()
180    }
181
182    /// Synchronous helper that drives the `MemoryBackend::put` trait
183    /// method on a small current-thread runtime. Used by the
184    /// `migrate` flow and by callers that don't already run inside
185    /// a tokio executor.
186    pub fn put_sync(&self, content: &str, kind: &str, subject: &str) -> Result<String, ToolError> {
187        let rt = tokio::runtime::Builder::new_current_thread()
188            .enable_io()
189            .build()
190            .map_err(|e| format!("backend unavailable: tokio runtime: {e}"))?;
191        let future = <Self as MemoryBackend>::put(self, content, kind, subject);
192        rt.block_on(future)
193    }
194
195    async fn ensure_connected(&self) -> Result<(), ToolError> {
196        let mut guard = self.client.lock().await;
197        if guard.is_some() {
198            return Ok(());
199        }
200        match oxibrain_client::BrainClient::connect(&self.socket_path).await {
201            Ok(client) => {
202                *guard = Some(client);
203                self.health.store(HEALTH_CONNECTED, Ordering::SeqCst);
204                Ok(())
205            }
206            Err(e) => {
207                self.health.store(HEALTH_UNAVAILABLE, Ordering::SeqCst);
208                Err(format!(
209                    "backend unavailable: oxibrain daemon unreachable at {}: {e}",
210                    self.socket_path.display()
211                ))
212            }
213        }
214    }
215
216    /// Run `f` against the connected client. On a connection-level
217    /// failure, clear the cached client and mark the backend as
218    /// `Unavailable` so the next call retries the handshake.
219    async fn with_client<R>(
220        &self,
221        f: impl FnOnce(
222            &mut oxibrain_client::BrainClient,
223        ) -> futures::future::BoxFuture<'_, anyhow::Result<R>>,
224    ) -> Result<R, ToolError> {
225        self.ensure_connected().await?;
226        let mut guard = self.client.lock().await;
227        let client = guard.as_mut().ok_or_else(|| {
228            "backend unavailable: oxibrain client missing after handshake".to_string()
229        })?;
230        match f(client).await {
231            Ok(v) => {
232                self.health.store(HEALTH_CONNECTED, Ordering::SeqCst);
233                Ok(v)
234            }
235            Err(e) => {
236                self.health.store(HEALTH_DEGRADED, Ordering::SeqCst);
237                *guard = None;
238                Err(format!("backend unavailable: oxibrain call failed: {e}"))
239            }
240        }
241    }
242}
243
244impl MemoryBackend for BrainMemoryBackend {
245    fn put<'a>(
246        &'a self,
247        content: &'a str,
248        kind: &'a str,
249        subject: &'a str,
250    ) -> Pin<Box<dyn Future<Output = Result<String, ToolError>> + Send + 'a>> {
251        Box::pin(async move {
252            let content = content.to_string();
253            let kind = kind.to_string();
254            let subject = subject.to_string();
255            let scope = self.scope.clone();
256            let args = json!({
257                "content": content,
258                "kind": kind,
259                "subject": subject,
260                "scope": scope,
261            });
262            let raw = self
263                .with_client(|c| Box::pin(async move { c.call_tool("memory.put", args).await }))
264                .await?;
265            // Response is the new ID; if the daemon returns a struct,
266            // extract `id`. Otherwise treat the raw response as the ID.
267            let id = serde_json::from_str::<serde_json::Value>(&raw)
268                .ok()
269                .and_then(|v| {
270                    v.get("id")
271                        .and_then(|i| i.as_str().map(|s| s.to_string()))
272                        .or_else(|| v.get("id").and_then(|i| i.as_u64().map(|n| n.to_string())))
273                })
274                .unwrap_or_else(|| raw.trim().to_string());
275            Ok(id)
276        })
277    }
278
279    fn search<'a>(
280        &'a self,
281        query: &'a str,
282        k: usize,
283    ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
284        Box::pin(async move {
285            let query = query.to_string();
286            let scope = self.scope.clone();
287            let args = json!({
288                "query": query,
289                "k": k,
290                "scope": scope,
291            });
292            let raw = self
293                .with_client(|c| Box::pin(async move { c.call_tool("memory.search", args).await }))
294                .await?;
295            parse_memory_items(&raw)
296        })
297    }
298
299    fn list<'a>(
300        &'a self,
301        subject: &'a str,
302    ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
303        Box::pin(async move {
304            let subject = subject.to_string();
305            let scope = self.scope.clone();
306            let args = json!({
307                "subject": subject,
308                "scope": scope,
309            });
310            let raw = self
311                .with_client(|c| Box::pin(async move { c.call_tool("memory.list", args).await }))
312                .await?;
313            parse_memory_items(&raw)
314        })
315    }
316
317    fn delete<'a>(
318        &'a self,
319        id: &'a str,
320    ) -> Pin<Box<dyn Future<Output = Result<(), ToolError>> + Send + 'a>> {
321        Box::pin(async move {
322            let id = id.to_string();
323            let args = json!({ "id": id });
324            let _ = self
325                .with_client(|c| Box::pin(async move { c.call_tool("memory.delete", args).await }))
326                .await?;
327            Ok(())
328        })
329    }
330
331    fn memory_info(&self) -> Option<String> {
332        Some(self.health().info().to_string())
333    }
334}
335
336// ───────────────────────────────────────────────────────────────────────────
337// Parsing helpers
338// ───────────────────────────────────────────────────────────────────────────
339
340/// Parse the JSON-RPC response from the daemon into a `Vec<MemoryItem>`.
341/// Accepts both the bare `[ {...} ]` shape and the wrapped `{ "items": [...] }`
342/// shape (the legacy oxibrain response style).
343fn parse_memory_items(raw: &str) -> Result<Vec<MemoryItem>, ToolError> {
344    let value: serde_json::Value = serde_json::from_str(raw)
345        .map_err(|e| format!("backend unavailable: malformed memory response: {e}"))?;
346    let items = value
347        .get("items")
348        .and_then(|i| i.as_array())
349        .or_else(|| value.as_array())
350        .ok_or_else(|| "backend unavailable: memory response missing 'items' array".to_string())?;
351    let mut out = Vec::with_capacity(items.len());
352    for item in items {
353        let id = match item.get("id") {
354            Some(serde_json::Value::String(s)) => s.clone(),
355            Some(serde_json::Value::Number(n)) => n.to_string(),
356            _ => String::new(),
357        };
358        let kind = item
359            .get("kind")
360            .and_then(|v| v.as_str())
361            .unwrap_or("fact")
362            .to_string();
363        let content = item
364            .get("content")
365            .and_then(|v| v.as_str())
366            .unwrap_or("")
367            .to_string();
368        let subject = item
369            .get("subject")
370            .and_then(|v| v.as_str())
371            .unwrap_or("")
372            .to_string();
373        out.push(MemoryItem {
374            id,
375            kind,
376            content,
377            subject,
378        });
379    }
380    Ok(out)
381}
382
383// ───────────────────────────────────────────────────────────────────────────
384// Factory used by the composition root
385// ───────────────────────────────────────────────────────────────────────────
386
387/// Resolves the default socket path for the oxibrain daemon. Honors
388/// `OXIBRAIN_SOCKET` if set; otherwise `$XDG_RUNTIME_DIR/oxibrain.sock`
389/// (Linux) or `~/.oxi/run/oxibrain.sock` (macOS).
390pub fn default_socket_path() -> std::path::PathBuf {
391    if let Ok(p) = std::env::var("OXIBRAIN_SOCKET") {
392        return std::path::PathBuf::from(p);
393    }
394    if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") {
395        return std::path::PathBuf::from(runtime).join("oxibrain.sock");
396    }
397    if let Some(home) = dirs::home_dir() {
398        return home.join(".oxi").join("run").join("oxibrain.sock");
399    }
400    std::path::PathBuf::from("/tmp/oxibrain.sock")
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn health_info_strings_match_spec() {
409        assert_eq!(
410            BrainHealth::Connected.info(),
411            "ok: oxibrain daemon connected"
412        );
413        assert_eq!(
414            BrainHealth::Degraded.info(),
415            "degraded: oxibrain daemon unreachable"
416        );
417        assert_eq!(
418            BrainHealth::Unavailable.info(),
419            "degraded: oxibrain daemon unreachable"
420        );
421    }
422
423    #[test]
424    fn health_round_trip() {
425        for h in [
426            BrainHealth::Connected,
427            BrainHealth::Degraded,
428            BrainHealth::Unavailable,
429        ] {
430            assert_eq!(decode_health(encode_health(h)), h);
431        }
432    }
433
434    #[test]
435    fn parse_memory_items_accepts_bare_array() {
436        let raw = r#"[{"id":"a","kind":"fact","content":"hello","subject":"proj"}]"#;
437        let items = parse_memory_items(raw).unwrap();
438        assert_eq!(items.len(), 1);
439        assert_eq!(items[0].id, "a");
440        assert_eq!(items[0].kind, "fact");
441        assert_eq!(items[0].content, "hello");
442        assert_eq!(items[0].subject, "proj");
443    }
444
445    #[test]
446    fn parse_memory_items_accepts_wrapped_array() {
447        let raw = r#"{"items":[{"id":"a","kind":"fact","content":"hello","subject":"proj"}]}"#;
448        let items = parse_memory_items(raw).unwrap();
449        assert_eq!(items.len(), 1);
450        assert_eq!(items[0].id, "a");
451    }
452
453    #[test]
454    fn parse_memory_items_rejects_missing_items() {
455        let raw = r#"{"unexpected":"shape"}"#;
456        let err = parse_memory_items(raw).unwrap_err();
457        assert!(err.starts_with("backend unavailable"));
458    }
459
460    #[test]
461    fn backend_starts_unavailable() {
462        let backend = BrainMemoryBackend::new("/tmp/does-not-exist.sock");
463        assert_eq!(backend.health(), BrainHealth::Unavailable);
464        assert_eq!(
465            backend.memory_info().as_deref(),
466            Some("degraded: oxibrain daemon unreachable")
467        );
468    }
469
470    #[test]
471    fn backend_with_scope_keeps_scope() {
472        let backend = BrainMemoryBackend::new("/tmp/x.sock").with_scope("oxicode/main");
473        assert_eq!(backend.scope, "oxicode/main");
474    }
475}