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 four-layer memory architecture with
5//! a knowledge graph (SurrealDB + fastembed) as Layer 0.
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//! - `pulse-null` — Plugin integration for pulse-null entities
18//! - `llm` — HTTP-based LLM provider for entity extraction
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 error;
30pub mod frontmatter;
31pub mod init;
32pub mod jsonl;
33pub mod paths;
34pub mod search;
35pub mod status;
36pub mod summarize;
37pub mod tags;
38
39pub mod graph;
40pub mod graph_bridge;
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/ (knowledge graph store)
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    #[must_use]
72    pub fn new(entity_root: PathBuf) -> Self {
73        Self { entity_root }
74    }
75
76    /// Create a RecallEcho using the default path resolution
77    /// (RECALL_ECHO_HOME env var or current working directory).
78    pub fn from_default() -> Result<Self, error::RecallError> {
79        Ok(Self::new(paths::entity_root()?))
80    }
81
82    /// Entity root directory.
83    #[must_use]
84    pub fn entity_root(&self) -> &Path {
85        &self.entity_root
86    }
87
88    /// Memory directory: {entity_root}/memory/
89    #[must_use]
90    pub fn memory_dir(&self) -> PathBuf {
91        self.entity_root.join("memory")
92    }
93
94    /// Path to MEMORY.md.
95    #[must_use]
96    pub fn memory_file(&self) -> PathBuf {
97        self.memory_dir().join("MEMORY.md")
98    }
99
100    /// Path to EPHEMERAL.md.
101    #[must_use]
102    pub fn ephemeral_file(&self) -> PathBuf {
103        self.memory_dir().join("EPHEMERAL.md")
104    }
105
106    /// Path to conversations directory.
107    #[must_use]
108    pub fn conversations_dir(&self) -> PathBuf {
109        self.memory_dir().join("conversations")
110    }
111
112    /// Path to ARCHIVE.md index.
113    #[must_use]
114    pub fn archive_index(&self) -> PathBuf {
115        self.memory_dir().join("ARCHIVE.md")
116    }
117
118    // ── Core operations ──────────────────────────────────────────────
119
120    /// Read EPHEMERAL.md content without clearing it.
121    /// Returns None if the file doesn't exist or is empty.
122    pub fn consume_content(&self) -> Result<Option<String>, error::RecallError> {
123        consume::consume(&self.ephemeral_file())
124    }
125
126    /// Check if the memory system has been initialized.
127    #[must_use]
128    pub fn is_initialized(&self) -> bool {
129        self.memory_dir().exists() && self.conversations_dir().exists()
130    }
131
132    /// Number of lines in MEMORY.md.
133    #[must_use]
134    pub fn memory_line_count(&self) -> usize {
135        let path = self.memory_file();
136        if !path.exists() {
137            return 0;
138        }
139        fs::read_to_string(&path)
140            .unwrap_or_default()
141            .lines()
142            .count()
143    }
144}
145
146// ---------------------------------------------------------------------------
147// Pulse-null plugin implementation — behind feature flag
148// ---------------------------------------------------------------------------
149
150#[cfg(feature = "pulse-null")]
151mod plugin_impl {
152    use super::*;
153    use std::any::Any;
154    use std::future::Future;
155    use std::pin::Pin;
156
157    use pulse_system_types::plugin::{Plugin, PluginContext, PluginResult, PluginRole};
158    use pulse_system_types::{HealthStatus, PluginMeta, SetupPrompt};
159
160    impl RecallEcho {
161        fn health_check(&self) -> HealthStatus {
162            if !self.memory_dir().exists() {
163                return HealthStatus::Down("memory directory not found".into());
164            }
165            if !self.memory_file().exists() {
166                return HealthStatus::Degraded("MEMORY.md not found".into());
167            }
168            if !self.conversations_dir().exists() {
169                return HealthStatus::Degraded("conversations directory not found".into());
170            }
171            HealthStatus::Healthy
172        }
173
174        fn get_setup_prompts() -> Vec<SetupPrompt> {
175            vec![SetupPrompt {
176                key: "entity_root".into(),
177                question: "Entity root directory:".into(),
178                required: true,
179                secret: false,
180                default: None,
181            }]
182        }
183    }
184
185    /// Factory function — creates a fully initialized recall-echo plugin.
186    pub async fn create(
187        config: &serde_json::Value,
188        ctx: &PluginContext,
189    ) -> Result<Box<dyn Plugin>, Box<dyn std::error::Error + Send + Sync>> {
190        let entity_root = config
191            .get("entity_root")
192            .and_then(|v| v.as_str())
193            .map(PathBuf::from)
194            .unwrap_or_else(|| ctx.entity_root.clone());
195
196        Ok(Box::new(RecallEcho::new(entity_root)))
197    }
198
199    impl Plugin for RecallEcho {
200        fn meta(&self) -> PluginMeta {
201            PluginMeta {
202                name: "recall-echo".into(),
203                version: env!("CARGO_PKG_VERSION").into(),
204                description: "Persistent memory system with knowledge graph".into(),
205            }
206        }
207
208        fn role(&self) -> PluginRole {
209            PluginRole::Memory
210        }
211
212        fn start(&mut self) -> PluginResult<'_> {
213            Box::pin(async { Ok(()) })
214        }
215
216        fn stop(&mut self) -> PluginResult<'_> {
217            Box::pin(async { Ok(()) })
218        }
219
220        fn health(&self) -> Pin<Box<dyn Future<Output = HealthStatus> + Send + '_>> {
221            Box::pin(async move { self.health_check() })
222        }
223
224        fn setup_prompts(&self) -> Vec<SetupPrompt> {
225            Self::get_setup_prompts()
226        }
227
228        fn as_any(&self) -> &dyn Any {
229            self
230        }
231    }
232}
233
234#[cfg(feature = "pulse-null")]
235pub use plugin_impl::create;