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 MCP tool surface over a Unix-domain socket via the
12//! JSON-RPC client in `oxibrain-client`. The daemon's fifteen tools are
13//! `search, recall, brief, navigate, ingest, declare, why, contradictions,
14//! stats, traverse, review_merges, remember, retract, merge_entities, redact`.
15//! The backend maps every `MemoryBackend` method onto that real surface:
16//!
17//! | `MemoryBackend` method | oxibrain tool | args                          |
18//! |---|---|---|
19//! | `put`                  | `remember`    | `{"content": ..., "space": ..., "source_path": "oxicode/<kind>/<subject>"}` |
20//! | `search`               | `search`      | `{"query": ..., "space": ..., "limit": N}`      |
21//! | `list`                 | `search`      | `{"query": <subject>, "space": ..., "limit": 50}` |
22//! | `delete`               | `retract`     | `{"statement_id": ...}` (auditable retraction)  |
23//!
24//! `remember` = `ingest_note` + synchronous extraction on the daemon side, so
25//! every `put` becomes a provenance-bearing episode. `search` returns entity
26//! hits (`entity_id`, `entity_surface`, `entity_type`, `score`, `snippet`),
27//! mapped into `MemoryItem`. Deletion is a statement-scoped retraction; ids
28//! that are not statement ids surface a typed error steering toward `redact`
29//! — never a silent local removal.
30//!
31//! oxibrain-client uses Unix-domain sockets. On non-Unix targets this
32//! module compiles to a stub that returns `BackendUnavailable` for every
33//! call. The same `memory_info` constant is used in both targets so the
34//! TUI's health banner reads "degraded" identically.
35
36use 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// ───────────────────────────────────────────────────────────────────────────
46// Health state
47// ───────────────────────────────────────────────────────────────────────────
48
49/// Health of the Brain connection. Surfaced via `memory_info` so the TUI
50/// health banner reports the state without leaking the underlying
51/// transport.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum BrainHealth {
54    /// Connected to the daemon and ready to serve requests.
55    Connected,
56    /// Daemon reachable but last call failed. Retries on next mutation.
57    Degraded,
58    /// Last `connect()` failed. Backend is in `Unavailable` mode.
59    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/// Migration-specific error type. Distinguishes the cases the
91/// migration core cares about: backend offline, write failure,
92/// runtime failure.
93#[derive(Debug, Clone)]
94pub enum MigrationError {
95    /// Brain daemon is known to be unreachable (handshake failed).
96    BackendOffline,
97    /// The write returned a `ToolError` (= `String`).
98    Backend(String),
99    /// The migration runtime failed to build or execute.
100    Runtime(String),
101    /// The resumable checkpoint could not be written.
102    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 {}
117// ───────────────────────────────────────────────────────────────────────────
118// Backend
119// ───────────────────────────────────────────────────────────────────────────
120
121/// Default space passed to oxibrain when one is not provided. `personal` is
122/// the daemon's conventional default space; override with
123/// [`BrainMemoryBackend::with_scope`] (e.g. to route a project to its own
124/// bucket).
125pub const DEFAULT_BRAIN_SCOPE: &str = "personal";
126
127/// Brain memory backend. The `Arc<Mutex<Option<…>>>` wrapper yields
128/// interior mutability on the optional client while keeping the
129/// `MemoryBackend` trait object signature (`Arc<…>`, no `&mut`).
130pub 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    /// Build a backend pointing at the given socket path. The client
152    /// is not eagerly connected; the first call attempts to attach.
153    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    /// Set the default scope. Tools that do not pass one explicitly use
163    /// this value.
164    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
165        self.scope = scope.into();
166        self
167    }
168
169    /// Current health. Read by the TUI health banner.
170    pub fn health(&self) -> BrainHealth {
171        decode_health(self.health.load(Ordering::SeqCst))
172    }
173
174    /// Probe daemon liveness with a `ping`. Cheaper than a full tool call;
175    /// used by the TUI health prober. Updates the cached health.
176    pub async fn ping(&self) -> Result<(), ToolError> {
177        self.with_client(|c| Box::pin(async move { c.ping().await }))
178            .await
179    }
180
181    /// Space statistics from the daemon's `stats` tool
182    /// (`episodes`, `entities`, `statements`, `contradictions`). Returned as
183    /// the raw parsed JSON so callers render without a typed mirror of
184    /// daemon fields.
185    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    /// Synchronous `stats` on a small current-thread runtime — same pattern
192    /// as [`Self::put_sync`]. Used by the TUI `/memory` command.
193    pub fn stats_sync(&self) -> Result<serde_json::Value, ToolError> {
194        block_on_sync(self.stats())
195    }
196
197    /// Synchronous `search` wrapper for callers outside a tokio executor.
198    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    /// Number of times the connection is currently held. Always 0 or 1
202    /// in production; useful for tests that assert reconnect logic.
203    pub async fn connected(&self) -> bool {
204        self.client.lock().await.is_some()
205    }
206
207    /// Synchronous helper that drives the `MemoryBackend::put` trait
208    /// method on a small current-thread runtime. Used by the
209    /// `migrate` flow and by callers that don't already run inside
210    /// a tokio executor.
211    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    /// Synchronous `ping` — used by flows that need a live health probe
216    /// without an executor of their own.
217    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    /// Run `f` against the connected client. On a connection-level
243    /// failure, clear the cached client and mark the backend as
244    /// `Unavailable` so the next call retries the handshake.
245    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
270/// Drive `fut` to completion on the current thread. Inside a tokio runtime
271/// (e.g. the CLI's async `main`), park the worker first via
272/// `block_in_place` — a nested `Runtime::block_on` would panic. Outside a
273/// runtime, build a small current-thread executor.
274fn 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` keeps this call off the daemon's MCP
299                // sampling path (§12.3): a sampling round-trip is a
300                // server→client request the 0.2.0 client cannot answer, so
301                // realtime extraction would stall every `put` by the
302                // daemon's 120s sampling timeout. The note is durable as an
303                // episode immediately; `recall` surfaces it via the
304                // recent-episodes layer. Revisit with a sampling-capable
305                // client (oxibrain-client 0.3).
306                "extract": false,
307            });
308            let raw = self
309                .with_client(|c| Box::pin(async move { c.call_tool("ingest", args).await }))
310                .await?;
311            // `ingest` answers "Ingested as episode: {id}" — keep the id so
312            // a later `delete` can redact the exact episode.
313            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            // `recall` assembles a context bundle (episodes + statements +
328            // entities); the `search` tool only returns extracted entity
329            // hits, which misses unextracted notes entirely.
330            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            // `list(subject)` is a recall seeded with the subject so
349            // boot-recall stays keyword-scoped; the daemon has no
350            // enumerate op.
351            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            // Episodes are removed via `redact` (destructive, audited).
374            // `retract` only withdraws statements produced by extraction.
375            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
392// ───────────────────────────────────────────────────────────────────────────
393// Parsing helpers
394// ───────────────────────────────────────────────────────────────────────────
395
396/// Parse a `recall` response into `Vec<MemoryItem>`. The daemon assembles a
397/// context bundle `{"layers": [{"kind", "text", "provenance", …}]}`; each
398/// layer's `text` may hold multiple lines (the `recent_episodes` layer packs
399/// one line per episode, with `provenance` ids aligned by line). Map every
400/// non-empty line to one `MemoryItem`, carrying the aligned provenance id
401/// when the counts match so `delete` can redact the exact episode.
402fn 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
435// ───────────────────────────────────────────────────────────────────────────
436// Factory used by the composition root
437// ───────────────────────────────────────────────────────────────────────────
438
439/// Resolves the default socket path for the oxibrain daemon. Canonical per
440/// the Foundation discovery contract (mirror of `oxibrain-client`'s
441/// `default_socket_path`, which ships in 0.3.x; we pin 0.2 so the
442/// resolution lives here): `$OXIBRAIN_SOCKET` if set, else
443/// `$HOME/.oxi/brain/oxibrain.sock`. Never creates directories.
444pub 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        // Layer without provenance → empty id, text kept whole.
501        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}