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