Skip to main content

recall_echo/
lib.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! recall-echo — Persistent memory system with knowledge graph.
6//!
7//! A general-purpose persistent memory system for any LLM tool — Claude Code,
8//! Ollama, or any provider. Features a four-layer memory architecture with
9//! a knowledge graph (SurrealDB + fastembed) as Layer 0.
10//!
11//! # Architecture
12//!
13//! ```text
14//! Transcript adapters (Claude Code, Codex, Grok, pulse-null Messages)
15//!     → Conversation (universal internal format)
16//!     → Archive pipeline (markdown + index + ephemeral + graph)
17//! ```
18//!
19//! Sessions arrive either because the CLI told us (Claude Code's `SessionEnd`
20//! hook) or because we read what it wrote ([`capture`], over [`transcript`]).
21//!
22//! # Features
23//!
24//! - `pulse-null` — Plugin integration for pulse-null entities
25//! - `llm` — HTTP-based LLM provider for entity extraction
26
27pub mod agent_cli;
28pub mod archive;
29pub mod capture;
30pub mod checkpoint;
31pub mod cli_provider;
32pub mod config;
33pub mod config_cli;
34pub mod consume;
35pub mod conversation;
36pub mod dashboard;
37pub mod distill;
38pub mod ephemeral;
39pub mod error;
40pub mod frontmatter;
41pub mod init;
42pub mod jsonl;
43pub mod mcp;
44pub mod paths;
45pub mod search;
46pub mod serve;
47pub mod serve_capture;
48pub mod serve_client;
49#[cfg(feature = "llm")]
50pub mod serve_extract;
51mod serve_security;
52pub mod status;
53pub mod summarize;
54pub mod tags;
55pub mod transcript;
56
57pub mod graph;
58pub mod graph_bridge;
59pub mod graph_cli;
60#[cfg(feature = "llm")]
61pub mod llm_provider;
62
63#[cfg(feature = "pulse-null")]
64pub mod pulse_null;
65
66#[cfg(feature = "bench")]
67pub mod bench;
68
69use std::fs;
70use std::path::{Path, PathBuf};
71
72pub use archive::SessionMetadata;
73pub use summarize::ConversationSummary;
74
75/// The recall-echo memory system.
76///
77/// All paths are derived from entity_root:
78/// ```text
79/// {entity_root}/memory/
80/// ├── MEMORY.md
81/// ├── EPHEMERAL.md
82/// ├── ARCHIVE.md
83/// ├── conversations/
84/// └── graph/ (knowledge graph store)
85/// ```
86pub struct RecallEcho {
87    entity_root: PathBuf,
88}
89
90impl RecallEcho {
91    /// Create a new RecallEcho instance with a specific entity root directory.
92    #[must_use]
93    pub fn new(entity_root: PathBuf) -> Self {
94        Self { entity_root }
95    }
96
97    /// Create a RecallEcho using the default path resolution
98    /// (RECALL_ECHO_HOME env var or current working directory).
99    pub fn from_default() -> Result<Self, error::RecallError> {
100        Ok(Self::new(paths::entity_root()?))
101    }
102
103    /// Entity root directory.
104    #[must_use]
105    pub fn entity_root(&self) -> &Path {
106        &self.entity_root
107    }
108
109    /// Memory directory: {entity_root}/memory/
110    #[must_use]
111    pub fn memory_dir(&self) -> PathBuf {
112        self.entity_root.join("memory")
113    }
114
115    /// Path to MEMORY.md.
116    #[must_use]
117    pub fn memory_file(&self) -> PathBuf {
118        self.memory_dir().join("MEMORY.md")
119    }
120
121    /// Path to EPHEMERAL.md.
122    #[must_use]
123    pub fn ephemeral_file(&self) -> PathBuf {
124        self.memory_dir().join("EPHEMERAL.md")
125    }
126
127    /// Path to conversations directory.
128    #[must_use]
129    pub fn conversations_dir(&self) -> PathBuf {
130        self.memory_dir().join("conversations")
131    }
132
133    /// Path to ARCHIVE.md index.
134    #[must_use]
135    pub fn archive_index(&self) -> PathBuf {
136        self.memory_dir().join("ARCHIVE.md")
137    }
138
139    // ── Core operations ──────────────────────────────────────────────
140
141    /// Read EPHEMERAL.md content without clearing it.
142    /// Returns None if the file doesn't exist or is empty.
143    pub fn consume_content(&self) -> Result<Option<String>, error::RecallError> {
144        consume::consume(&self.ephemeral_file())
145    }
146
147    /// Check if the memory system has been initialized.
148    #[must_use]
149    pub fn is_initialized(&self) -> bool {
150        self.memory_dir().exists() && self.conversations_dir().exists()
151    }
152
153    /// Number of lines in MEMORY.md.
154    #[must_use]
155    pub fn memory_line_count(&self) -> usize {
156        let path = self.memory_file();
157        if !path.exists() {
158            return 0;
159        }
160        fs::read_to_string(&path)
161            .unwrap_or_default()
162            .lines()
163            .count()
164    }
165}
166
167// ---------------------------------------------------------------------------
168// Pulse-null plugin implementation — behind feature flag
169// ---------------------------------------------------------------------------
170
171#[cfg(feature = "pulse-null")]
172mod plugin_impl {
173    use super::*;
174    use std::any::Any;
175    use std::future::Future;
176    use std::pin::Pin;
177
178    use pulse_system_types::plugin::{Plugin, PluginContext, PluginResult, PluginRole};
179    use pulse_system_types::{HealthStatus, PluginMeta, SetupPrompt};
180
181    impl RecallEcho {
182        fn health_check(&self) -> HealthStatus {
183            if !self.memory_dir().exists() {
184                return HealthStatus::Down("memory directory not found".into());
185            }
186            if !self.memory_file().exists() {
187                return HealthStatus::Degraded("MEMORY.md not found".into());
188            }
189            if !self.conversations_dir().exists() {
190                return HealthStatus::Degraded("conversations directory not found".into());
191            }
192            HealthStatus::Healthy
193        }
194
195        fn get_setup_prompts() -> Vec<SetupPrompt> {
196            vec![SetupPrompt {
197                key: "entity_root".into(),
198                question: "Entity root directory:".into(),
199                required: true,
200                secret: false,
201                default: None,
202            }]
203        }
204    }
205
206    /// Factory function — creates a fully initialized recall-echo plugin.
207    pub async fn create(
208        config: &serde_json::Value,
209        ctx: &PluginContext,
210    ) -> Result<Box<dyn Plugin>, Box<dyn std::error::Error + Send + Sync>> {
211        let entity_root = config
212            .get("entity_root")
213            .and_then(|v| v.as_str())
214            .map(PathBuf::from)
215            .unwrap_or_else(|| ctx.entity_root.clone());
216
217        Ok(Box::new(RecallEcho::new(entity_root)))
218    }
219
220    impl Plugin for RecallEcho {
221        fn meta(&self) -> PluginMeta {
222            PluginMeta {
223                name: "recall-echo".into(),
224                version: env!("CARGO_PKG_VERSION").into(),
225                description: "Persistent memory system with knowledge graph".into(),
226            }
227        }
228
229        fn role(&self) -> PluginRole {
230            PluginRole::Memory
231        }
232
233        fn start(&mut self) -> PluginResult<'_> {
234            Box::pin(async { Ok(()) })
235        }
236
237        fn stop(&mut self) -> PluginResult<'_> {
238            Box::pin(async { Ok(()) })
239        }
240
241        fn health(&self) -> Pin<Box<dyn Future<Output = HealthStatus> + Send + '_>> {
242            Box::pin(async move { self.health_check() })
243        }
244
245        fn setup_prompts(&self) -> Vec<SetupPrompt> {
246            Self::get_setup_prompts()
247        }
248
249        fn as_any(&self) -> &dyn Any {
250            self
251        }
252    }
253}
254
255#[cfg(feature = "pulse-null")]
256pub use plugin_impl::create;