Skip to main content

recall_echo/
lib.rs

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