Skip to main content

recall_echo/graph/
mod.rs

1//! recall-graph — Knowledge graph with semantic search for AI memory systems.
2//!
3//! Provides a structured graph layer (Layer 0) underneath flat-file memory systems.
4//! Used by recall-echo (pulse-null entities) and recall-claude (Claude Code users).
5
6pub mod confidence;
7pub mod crud;
8pub mod dedup;
9pub mod embed;
10pub mod error;
11pub mod extract;
12pub mod gc;
13pub mod ingest;
14pub mod llm;
15pub mod pipeline;
16pub mod pipeline_sync;
17pub mod query;
18pub mod search;
19pub mod store;
20pub mod traverse;
21pub mod types;
22pub mod util;
23pub mod utility;
24pub mod vigil_sync;
25
26use std::collections::HashMap;
27use std::path::{Path, PathBuf};
28
29use embed::FastEmbedder;
30use error::GraphError;
31use store::Db;
32#[cfg(feature = "server")]
33pub use store::ServerConfig;
34#[allow(unused_imports)] // Required in scope for SurrealValue derive macro expansion
35use surrealdb::types::SurrealValue;
36use surrealdb::Surreal;
37use types::*;
38
39/// Take serde_json::Value results from a SurrealDB response and deserialize to a Rust type.
40/// This avoids needing SurrealValue derive on complex types.
41pub(crate) fn deserialize_take<T: serde::de::DeserializeOwned>(
42    response: &mut surrealdb::IndexedResults,
43    index: usize,
44) -> Result<Vec<T>, GraphError> {
45    let values: Vec<serde_json::Value> = response.take(index)?;
46    values
47        .into_iter()
48        .map(|v| serde_json::from_value(v).map_err(GraphError::from))
49        .collect()
50}
51
52pub(crate) fn deserialize_take_opt<T: serde::de::DeserializeOwned>(
53    response: &mut surrealdb::IndexedResults,
54    index: usize,
55) -> Result<Option<T>, GraphError> {
56    let values: Vec<T> = deserialize_take(response, index)?;
57    Ok(values.into_iter().next())
58}
59
60/// The main entry point for graph memory operations.
61pub struct GraphMemory {
62    db: Surreal<Db>,
63    embedder: FastEmbedder,
64    path: PathBuf,
65    scoring: crate::config::GraphScoringConfig,
66}
67
68impl GraphMemory {
69    /// Open a graph store at the given path.
70    ///
71    /// In embedded mode: opens SurrealKV at `path/surreal/`.
72    /// In server mode: reads `.recall-echo.toml` from the parent directory
73    /// to get connection settings, then connects via WebSocket.
74    /// The `path` is still used for the FastEmbed models cache.
75    #[cfg(feature = "embedded")]
76    pub async fn open(path: &Path) -> Result<Self, GraphError> {
77        std::fs::create_dir_all(path)?;
78
79        let db = store::open(path).await?;
80        store::init_schema(&db).await?;
81
82        let models_dir = path.join("models");
83        std::fs::create_dir_all(&models_dir)?;
84        let embedder = FastEmbedder::new(&models_dir)?;
85
86        let scoring = load_scoring_config(path);
87
88        Ok(Self {
89            db,
90            embedder,
91            path: path.to_path_buf(),
92            scoring,
93        })
94    }
95
96    /// Open a graph store at the given path.
97    ///
98    /// In server mode: reads `.recall-echo.toml` from the parent directory
99    /// (memory_dir) to get connection settings, then connects via WebSocket.
100    /// The `path` is still used for the FastEmbed models cache.
101    #[cfg(feature = "server")]
102    pub async fn open(path: &Path) -> Result<Self, GraphError> {
103        let memory_dir = path.parent().unwrap_or(path);
104        let config = crate::config::load_from_dir(memory_dir);
105
106        let graph_section = config.graph.unwrap_or_default();
107        let password = if graph_section.password_file.is_empty() {
108            String::new()
109        } else {
110            let pw_path = if graph_section.password_file.starts_with('/') {
111                std::path::PathBuf::from(&graph_section.password_file)
112            } else {
113                // Relative to entity root (memory_dir's parent)
114                let entity_root = memory_dir.parent().unwrap_or(memory_dir);
115                entity_root.join(&graph_section.password_file)
116            };
117            std::fs::read_to_string(&pw_path)
118                .map(|s| s.trim().to_string())
119                .map_err(|e| {
120                    GraphError::Io(std::io::Error::new(
121                        e.kind(),
122                        format!(
123                            "failed to read graph password file {}: {e}",
124                            pw_path.display()
125                        ),
126                    ))
127                })?
128        };
129
130        let scoring = graph_section.scoring.clone();
131        let server_config = store::ServerConfig {
132            url: graph_section.url,
133            username: graph_section.username,
134            password,
135            namespace: graph_section.namespace,
136            database: graph_section.database,
137        };
138
139        let models_dir = path.join("models");
140        let mut gm = Self::connect(&server_config, &models_dir).await?;
141        gm.scoring = scoring;
142        Ok(gm)
143    }
144
145    /// Connect to a SurrealDB server over WebSocket with explicit config.
146    #[cfg(feature = "server")]
147    pub async fn connect(
148        config: &store::ServerConfig,
149        models_dir: &Path,
150    ) -> Result<Self, GraphError> {
151        let db = store::connect(config).await?;
152        store::init_schema(&db).await?;
153
154        std::fs::create_dir_all(models_dir)?;
155        let embedder = FastEmbedder::new(models_dir)?;
156
157        Ok(Self {
158            db,
159            embedder,
160            path: models_dir.to_path_buf(),
161            scoring: crate::config::GraphScoringConfig::default(),
162        })
163    }
164
165    /// Path to the graph store.
166    pub fn path(&self) -> &Path {
167        &self.path
168    }
169
170    /// Internal access to the database handle.
171    #[allow(dead_code)]
172    pub(crate) fn db(&self) -> &Surreal<Db> {
173        &self.db
174    }
175
176    /// Internal access to the embedder.
177    #[allow(dead_code)]
178    pub(crate) fn embedder(&self) -> &FastEmbedder {
179        &self.embedder
180    }
181
182    // --- Entity CRUD ---
183
184    /// Add a new entity to the graph.
185    pub async fn add_entity(&self, entity: NewEntity) -> Result<Entity, GraphError> {
186        crud::add_entity(&self.db, &self.embedder, entity).await
187    }
188
189    /// Get an entity by name.
190    pub async fn get_entity(&self, name: &str) -> Result<Option<Entity>, GraphError> {
191        crud::get_entity_by_name(&self.db, name).await
192    }
193
194    /// Get an entity by its record ID.
195    pub async fn get_entity_by_id(&self, id: &str) -> Result<Option<Entity>, GraphError> {
196        crud::get_entity_by_id(&self.db, id).await
197    }
198
199    /// Update an entity's fields.
200    pub async fn update_entity(
201        &self,
202        id: &str,
203        updates: EntityUpdate,
204    ) -> Result<Entity, GraphError> {
205        crud::update_entity(&self.db, &self.embedder, id, updates).await
206    }
207
208    /// Delete an entity and its relationships.
209    pub async fn delete_entity(&self, id: &str) -> Result<(), GraphError> {
210        crud::delete_entity(&self.db, id).await
211    }
212
213    /// List all entities, optionally filtered by type.
214    pub async fn list_entities(
215        &self,
216        entity_type: Option<&str>,
217    ) -> Result<Vec<Entity>, GraphError> {
218        crud::list_entities(&self.db, entity_type).await
219    }
220
221    // --- Relationships ---
222
223    /// Create a relationship between two named entities.
224    pub async fn add_relationship(&self, rel: NewRelationship) -> Result<Relationship, GraphError> {
225        crud::add_relationship(&self.db, rel).await
226    }
227
228    /// Get relationships for an entity.
229    pub async fn get_relationships(
230        &self,
231        entity_name: &str,
232        direction: Direction,
233    ) -> Result<Vec<Relationship>, GraphError> {
234        crud::get_relationships(&self.db, entity_name, direction).await
235    }
236
237    /// Supersede a relationship: close the old one, create a new one.
238    pub async fn supersede_relationship(
239        &self,
240        old_id: &str,
241        new: NewRelationship,
242    ) -> Result<Relationship, GraphError> {
243        crud::supersede_relationship(&self.db, old_id, new).await
244    }
245
246    /// Update relationship confidence (Bayesian posterior).
247    pub async fn update_relationship_confidence(
248        &self,
249        rel_id: &str,
250        confidence: f64,
251    ) -> Result<(), GraphError> {
252        crud::update_relationship_confidence(&self.db, rel_id, confidence).await
253    }
254
255    /// Reinforce a relationship: Bayesian update + reset decay clock.
256    ///
257    /// Called when a relationship is corroborated. Updates confidence and resets
258    /// `last_reinforced` to now, preventing temporal decay from eroding the edge.
259    pub async fn reinforce_relationship(
260        &self,
261        rel_id: &str,
262        new_confidence: f64,
263    ) -> Result<(), GraphError> {
264        crud::reinforce_relationship(&self.db, rel_id, new_confidence).await
265    }
266
267    // --- Episodes ---
268
269    /// Add a new episode to the graph.
270    pub async fn add_episode(&self, episode: NewEpisode) -> Result<Episode, GraphError> {
271        crud::add_episode(&self.db, &self.embedder, episode).await
272    }
273
274    /// Get episodes by session ID.
275    pub async fn get_episodes_by_session(
276        &self,
277        session_id: &str,
278    ) -> Result<Vec<Episode>, GraphError> {
279        crud::get_episodes_by_session(&self.db, session_id).await
280    }
281
282    /// Get episode by log number.
283    pub async fn get_episode_by_log_number(
284        &self,
285        log_number: u32,
286    ) -> Result<Option<Episode>, GraphError> {
287        crud::get_episode_by_log_number(&self.db, log_number).await
288    }
289
290    // --- Ingestion ---
291
292    /// Ingest a conversation archive into the knowledge graph.
293    pub async fn ingest_archive(
294        &self,
295        archive_text: &str,
296        session_id: &str,
297        log_number: Option<u32>,
298        llm: Option<&dyn llm::LlmProvider>,
299    ) -> Result<IngestionReport, GraphError> {
300        ingest::ingest_archive(self, archive_text, session_id, log_number, llm).await
301    }
302
303    /// Run LLM extraction on an archive without creating episodes.
304    pub async fn extract_from_archive(
305        &self,
306        archive_text: &str,
307        session_id: &str,
308        log_number: Option<u32>,
309        llm: &dyn llm::LlmProvider,
310    ) -> Result<IngestionReport, GraphError> {
311        ingest::extract_from_archive(self, archive_text, session_id, log_number, llm).await
312    }
313
314    /// Mark all episodes with a given log_number as extracted.
315    pub async fn mark_extracted(&self, log_number: u32) -> Result<(), GraphError> {
316        crud::mark_episodes_extracted(&self.db, log_number).await
317    }
318
319    /// Get log numbers of episodes that have NOT been extracted.
320    pub async fn unextracted_log_numbers(&self) -> Result<Vec<i64>, GraphError> {
321        crud::get_unextracted_log_numbers(&self.db).await
322    }
323
324    // --- Search ---
325
326    /// Semantic search across entities (legacy — returns full Entity).
327    pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>, GraphError> {
328        search::search(&self.db, &self.embedder, &self.scoring, query, limit).await
329    }
330
331    /// Search with options — L1 projections, type/keyword filters.
332    pub async fn search_with_options(
333        &self,
334        query: &str,
335        options: &SearchOptions,
336    ) -> Result<Vec<ScoredEntity>, GraphError> {
337        search::search_with_options(&self.db, &self.embedder, &self.scoring, query, options).await
338    }
339
340    /// Semantic search across episodes.
341    pub async fn search_episodes(
342        &self,
343        query: &str,
344        limit: usize,
345    ) -> Result<Vec<EpisodeSearchResult>, GraphError> {
346        search::search_episodes(&self.db, &self.embedder, query, limit).await
347    }
348
349    // --- Hybrid Query ---
350
351    /// Hybrid query: semantic + graph expansion + optional episode search.
352    pub async fn query(
353        &self,
354        query_text: &str,
355        options: &QueryOptions,
356    ) -> Result<QueryResult, GraphError> {
357        query::query(&self.db, &self.embedder, &self.scoring, query_text, options).await
358    }
359
360    // --- Traversal ---
361
362    /// Traverse the graph from a named entity.
363    pub async fn traverse(
364        &self,
365        entity_name: &str,
366        depth: u32,
367    ) -> Result<TraversalNode, GraphError> {
368        traverse::traverse(&self.db, entity_name, depth).await
369    }
370
371    /// Traverse with type filter.
372    pub async fn traverse_filtered(
373        &self,
374        entity_name: &str,
375        depth: u32,
376        type_filter: Option<&str>,
377    ) -> Result<TraversalNode, GraphError> {
378        traverse::traverse_filtered(&self.db, entity_name, depth, type_filter).await
379    }
380
381    // --- Pipeline ---
382
383    /// Sync pipeline documents into the graph.
384    pub async fn sync_pipeline(
385        &self,
386        docs: &PipelineDocuments,
387    ) -> Result<PipelineSyncReport, GraphError> {
388        pipeline_sync::sync_pipeline(self, docs).await
389    }
390
391    /// Get pipeline stats from the graph.
392    pub async fn pipeline_stats(
393        &self,
394        staleness_days: u32,
395    ) -> Result<PipelineGraphStats, GraphError> {
396        query::pipeline_stats(&self.db, staleness_days).await
397    }
398
399    /// Get pipeline entities by stage and optional status.
400    pub async fn pipeline_entities(
401        &self,
402        stage: &str,
403        status: Option<&str>,
404    ) -> Result<Vec<EntityDetail>, GraphError> {
405        query::pipeline_entities(&self.db, stage, status).await
406    }
407
408    /// Trace pipeline flow for an entity.
409    pub async fn pipeline_flow(
410        &self,
411        entity_name: &str,
412    ) -> Result<Vec<(EntityDetail, String, EntityDetail)>, GraphError> {
413        query::pipeline_flow(&self.db, entity_name).await
414    }
415
416    // --- Vigil Sync ---
417
418    /// Sync vigil signal vectors into the graph as Measurement entities.
419    pub async fn sync_vigil_signals(
420        &self,
421        signals_path: &std::path::Path,
422    ) -> Result<VigilSyncReport, GraphError> {
423        vigil_sync::sync_vigil_signals(self, signals_path).await
424    }
425
426    /// Sync outcome records into the graph as Outcome entities.
427    pub async fn sync_outcomes(
428        &self,
429        outcomes_path: &std::path::Path,
430    ) -> Result<VigilSyncReport, GraphError> {
431        vigil_sync::sync_outcomes(self, outcomes_path).await
432    }
433
434    /// Sync both vigil signals and outcomes in one call.
435    pub async fn sync_vigil(
436        &self,
437        signals_path: &std::path::Path,
438        outcomes_path: &std::path::Path,
439    ) -> Result<VigilSyncReport, GraphError> {
440        vigil_sync::sync_vigil(self, signals_path, outcomes_path).await
441    }
442
443    /// Record outcome feedback: link retrieved entities to a session outcome and
444    /// update their `utility_score` via EMA. `used_entity_ids` distinguishes the
445    /// entities the response actually leaned on (full alpha) from retrieved-but-
446    /// unused (muted alpha). Pass `None` to treat all retrieved as used.
447    pub async fn record_outcome_feedback(
448        &self,
449        session_id: &str,
450        outcome: utility::OutcomeKind,
451        retrieved_entity_ids: &[String],
452        used_entity_ids: Option<&[String]>,
453    ) -> Result<utility::FeedbackReport, GraphError> {
454        utility::record_outcome_feedback(
455            &self.db,
456            session_id,
457            outcome,
458            retrieved_entity_ids,
459            used_entity_ids,
460        )
461        .await
462    }
463
464    // --- Garbage Collection ---
465
466    /// Run garbage collection with the given config.
467    pub async fn run_gc(&self, config: &gc::GcConfig) -> Result<gc::GcReport, GraphError> {
468        gc::run_gc(&self.db, config).await
469    }
470
471    /// Get GC health stats without running collection.
472    pub async fn gc_stats(&self) -> Result<gc::GcStatsReport, GraphError> {
473        gc::stats_only(&self.db).await
474    }
475
476    /// Delete a single relationship by ID.
477    pub async fn delete_relationship(&self, id: &str) -> Result<(), GraphError> {
478        crud::delete_relationship(&self.db, id).await
479    }
480
481    // --- Stats ---
482
483    /// Get graph statistics.
484    pub async fn stats(&self) -> Result<GraphStats, GraphError> {
485        let entity_count = db_count(&self.db, "entity").await?;
486        let relationship_count = db_count(&self.db, "relates_to").await?;
487        let episode_count = db_count(&self.db, "episode").await?;
488
489        // Count by type
490        let mut type_response = self
491            .db
492            .query("SELECT entity_type, count() AS count FROM entity GROUP BY entity_type")
493            .await?;
494
495        let type_rows: Vec<TypeCount> = type_response.take(0)?;
496        let entity_type_counts: HashMap<String, u64> = type_rows
497            .into_iter()
498            .map(|r| (r.entity_type, r.count))
499            .collect();
500
501        Ok(GraphStats {
502            entity_count,
503            relationship_count,
504            episode_count,
505            entity_type_counts,
506        })
507    }
508}
509
510/// Load `[graph.scoring]` from `.recall-echo.toml` in the memory directory
511/// (the parent of the graph store path). Returns defaults if the config file
512/// or the `[graph.scoring]` section is absent, preserving legacy behavior.
513#[cfg(feature = "embedded")]
514fn load_scoring_config(graph_path: &Path) -> crate::config::GraphScoringConfig {
515    let memory_dir = graph_path.parent().unwrap_or(graph_path);
516    crate::config::load_from_dir(memory_dir)
517        .graph
518        .map(|g| g.scoring)
519        .unwrap_or_default()
520}
521
522async fn db_count(db: &Surreal<Db>, table: &str) -> Result<u64, GraphError> {
523    let query = format!("SELECT count() AS count FROM {table} GROUP ALL");
524    let mut response = db.query(&query).await?;
525    let rows: Vec<CountRow> = response.take(0)?;
526    Ok(rows.first().map(|r| r.count).unwrap_or(0))
527}
528
529#[derive(serde::Deserialize, surrealdb::types::SurrealValue)]
530struct CountRow {
531    count: u64,
532}
533
534#[derive(serde::Deserialize, surrealdb::types::SurrealValue)]
535struct TypeCount {
536    entity_type: String,
537    count: u64,
538}