1pub mod confidence;
11pub mod crud;
12pub mod dedup;
13pub mod embed;
14pub mod error;
15pub mod extract;
16pub mod gc;
17pub mod ingest;
18pub mod llm;
19pub mod pipeline;
20pub mod pipeline_sync;
21pub mod query;
22pub mod search;
23pub mod store;
24pub mod traverse;
25pub mod types;
26pub mod util;
27pub mod utility;
28pub mod vigil_sync;
29
30use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32
33pub use confidence::{Provenance, ProvenanceWeights};
34use embed::{FastEmbedder, LazyEmbedder};
35use error::GraphError;
36pub use ingest::{IngestContext, ProvenancePolicy};
37use store::Db;
38pub use store::ServerConfig;
39#[allow(unused_imports)] use surrealdb::types::SurrealValue;
41use surrealdb::Surreal;
42use types::*;
43
44pub(crate) fn deserialize_take<T: serde::de::DeserializeOwned>(
47 response: &mut surrealdb::IndexedResults,
48 index: usize,
49) -> Result<Vec<T>, GraphError> {
50 let values: Vec<serde_json::Value> = response.take(index)?;
51 values
52 .into_iter()
53 .map(|v| serde_json::from_value(v).map_err(GraphError::from))
54 .collect()
55}
56
57pub(crate) fn deserialize_take_opt<T: serde::de::DeserializeOwned>(
58 response: &mut surrealdb::IndexedResults,
59 index: usize,
60) -> Result<Option<T>, GraphError> {
61 let values: Vec<T> = deserialize_take(response, index)?;
62 Ok(values.into_iter().next())
63}
64
65pub struct GraphMemory {
67 db: Surreal<Db>,
68 embedder: LazyEmbedder,
69 path: PathBuf,
70 scoring: crate::config::GraphScoringConfig,
71 provenance: confidence::ProvenanceWeights,
72 dedup: crate::config::GraphDedupConfig,
73}
74
75impl GraphMemory {
76 pub async fn open(path: &Path) -> Result<Self, GraphError> {
84 let memory_dir = path.parent().unwrap_or(path);
85 let config = crate::config::load_from_dir(memory_dir);
86 let mode = config
87 .graph
88 .as_ref()
89 .map(|g| g.mode.clone())
90 .unwrap_or_else(|| "embedded".to_string());
91
92 match mode.as_str() {
93 "server" => Self::open_server(path).await,
94 _ => Self::open_embedded(path).await,
95 }
96 }
97
98 pub async fn open_embedded(path: &Path) -> Result<Self, GraphError> {
100 std::fs::create_dir_all(path)?;
101
102 let db = store::open(path).await?;
103 let migration = store::init_schema(&db).await?;
104 if migration.ran() {
105 eprintln!(
106 "recall-echo: graph schema migrated v{} → v{} ({} edges backfilled)",
107 migration.from_version, migration.to_version, migration.edges_backfilled
108 );
109 }
110
111 let models_dir = path.join("models");
112 std::fs::create_dir_all(&models_dir)?;
113 let embedder = LazyEmbedder::new(&models_dir);
114
115 let graph_config = load_graph_section(path);
116
117 Ok(Self {
118 db,
119 embedder,
120 path: path.to_path_buf(),
121 scoring: graph_config.scoring,
122 provenance: graph_config.provenance,
123 dedup: graph_config.dedup,
124 })
125 }
126
127 pub async fn open_server(path: &Path) -> Result<Self, GraphError> {
131 let memory_dir = path.parent().unwrap_or(path);
132 let config = crate::config::load_from_dir(memory_dir);
133
134 let graph_section = config.graph.unwrap_or_default();
135 let password = if graph_section.password_file.is_empty() {
136 String::new()
137 } else {
138 let pw_path = if graph_section.password_file.starts_with('/') {
139 std::path::PathBuf::from(&graph_section.password_file)
140 } else {
141 let entity_root = memory_dir.parent().unwrap_or(memory_dir);
143 entity_root.join(&graph_section.password_file)
144 };
145 std::fs::read_to_string(&pw_path)
146 .map(|s| s.trim().to_string())
147 .map_err(|e| {
148 GraphError::Io(std::io::Error::new(
149 e.kind(),
150 format!(
151 "failed to read graph password file {}: {e}",
152 pw_path.display()
153 ),
154 ))
155 })?
156 };
157
158 let scoring = graph_section.scoring.clone();
159 let provenance = graph_section.provenance;
160 let dedup = graph_section.dedup.clone();
161 let server_config = store::ServerConfig {
162 url: graph_section.url,
163 username: graph_section.username,
164 password,
165 namespace: graph_section.namespace,
166 database: graph_section.database,
167 };
168
169 let models_dir = path.join("models");
170 let mut gm = Self::connect(&server_config, &models_dir).await?;
171 gm.scoring = scoring;
172 gm.provenance = provenance;
173 gm.dedup = dedup;
174 Ok(gm)
175 }
176
177 pub async fn connect(
179 config: &store::ServerConfig,
180 models_dir: &Path,
181 ) -> Result<Self, GraphError> {
182 let db = store::connect(config).await?;
183 let migration = store::init_schema(&db).await?;
184 if migration.ran() {
185 eprintln!(
186 "recall-echo: graph schema migrated v{} → v{} ({} edges backfilled)",
187 migration.from_version, migration.to_version, migration.edges_backfilled
188 );
189 }
190
191 std::fs::create_dir_all(models_dir)?;
192 let embedder = LazyEmbedder::new(models_dir);
193
194 Ok(Self {
195 db,
196 embedder,
197 path: models_dir.to_path_buf(),
198 scoring: crate::config::GraphScoringConfig::default(),
199 provenance: confidence::ProvenanceWeights::default(),
200 dedup: crate::config::GraphDedupConfig::default(),
201 })
202 }
203
204 pub fn path(&self) -> &Path {
206 &self.path
207 }
208
209 #[must_use]
212 pub fn provenance_weights(&self) -> &confidence::ProvenanceWeights {
213 &self.provenance
214 }
215
216 #[must_use]
219 pub fn dedup_config(&self) -> &crate::config::GraphDedupConfig {
220 &self.dedup
221 }
222
223 #[allow(dead_code)]
225 pub(crate) fn db(&self) -> &Surreal<Db> {
226 &self.db
227 }
228
229 #[allow(dead_code)]
231 pub(crate) fn embedder(&self) -> Result<&FastEmbedder, GraphError> {
232 self.embedder.get()
233 }
234
235 pub async fn add_entity(&self, entity: NewEntity) -> Result<Entity, GraphError> {
239 crud::add_entity(&self.db, self.embedder.get()?, entity).await
240 }
241
242 pub async fn get_entity(&self, name: &str) -> Result<Option<Entity>, GraphError> {
244 crud::get_entity_by_name(&self.db, name).await
245 }
246
247 pub async fn get_entity_by_id(&self, id: &str) -> Result<Option<Entity>, GraphError> {
249 crud::get_entity_by_id(&self.db, id).await
250 }
251
252 pub async fn update_entity(
254 &self,
255 id: &str,
256 updates: EntityUpdate,
257 ) -> Result<Entity, GraphError> {
258 crud::update_entity(&self.db, self.embedder.get()?, id, updates).await
259 }
260
261 pub async fn delete_entity(&self, id: &str) -> Result<(), GraphError> {
263 crud::delete_entity(&self.db, id).await
264 }
265
266 pub async fn list_entities(
268 &self,
269 entity_type: Option<&str>,
270 ) -> Result<Vec<Entity>, GraphError> {
271 crud::list_entities(&self.db, entity_type).await
272 }
273
274 pub async fn add_relationship(&self, rel: NewRelationship) -> Result<Relationship, GraphError> {
278 crud::add_relationship(&self.db, rel).await
279 }
280
281 pub async fn get_relationships(
283 &self,
284 entity_name: &str,
285 direction: Direction,
286 ) -> Result<Vec<Relationship>, GraphError> {
287 crud::get_relationships(&self.db, entity_name, direction).await
288 }
289
290 pub async fn supersede_relationship(
292 &self,
293 old_id: &str,
294 new: NewRelationship,
295 ) -> Result<Relationship, GraphError> {
296 crud::supersede_relationship(&self.db, old_id, new).await
297 }
298
299 pub async fn update_relationship_confidence(
302 &self,
303 rel_id: &str,
304 confidence: f64,
305 ) -> Result<(), GraphError> {
306 crud::update_relationship_confidence(&self.db, rel_id, confidence).await
307 }
308
309 pub async fn reinforce_relationship(
316 &self,
317 rel_id: &str,
318 evidence: confidence::EdgeEvidence,
319 ) -> Result<(), GraphError> {
320 crud::reinforce_relationship(&self.db, rel_id, evidence).await
321 }
322
323 pub async fn add_episode(&self, episode: NewEpisode) -> Result<Episode, GraphError> {
331 crud::add_episode(&self.db, self.embedder.get()?, episode).await
332 }
333
334 pub async fn add_episode_from(
336 &self,
337 episode: NewEpisode,
338 provenance: Provenance,
339 ) -> Result<Episode, GraphError> {
340 crud::add_episode_from(&self.db, self.embedder.get()?, episode, provenance).await
341 }
342
343 pub async fn get_episodes_by_session(
345 &self,
346 session_id: &str,
347 ) -> Result<Vec<Episode>, GraphError> {
348 crud::get_episodes_by_session(&self.db, session_id).await
349 }
350
351 pub async fn get_episode_by_log_number(
353 &self,
354 log_number: u32,
355 ) -> Result<Option<Episode>, GraphError> {
356 crud::get_episode_by_log_number(&self.db, log_number).await
357 }
358
359 pub async fn ingest_archive(
367 &self,
368 archive_text: &str,
369 context: &IngestContext,
370 llm: Option<&dyn llm::LlmProvider>,
371 ) -> Result<IngestionReport, GraphError> {
372 ingest::ingest_archive(self, archive_text, context, llm).await
373 }
374
375 pub async fn extract_from_archive(
377 &self,
378 archive_text: &str,
379 context: &IngestContext,
380 llm: &dyn llm::LlmProvider,
381 ) -> Result<IngestionReport, GraphError> {
382 ingest::extract_from_archive(self, archive_text, context, llm).await
383 }
384
385 pub async fn mark_extracted(&self, log_number: u32) -> Result<(), GraphError> {
387 crud::mark_episodes_extracted(&self.db, log_number).await
388 }
389
390 pub async fn unextracted_log_numbers(&self) -> Result<Vec<i64>, GraphError> {
392 crud::get_unextracted_log_numbers(&self.db).await
393 }
394
395 pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>, GraphError> {
399 search::search(&self.db, self.embedder.get()?, &self.scoring, query, limit).await
400 }
401
402 pub async fn search_with_options(
404 &self,
405 query: &str,
406 options: &SearchOptions,
407 ) -> Result<Vec<ScoredEntity>, GraphError> {
408 search::search_with_options(
409 &self.db,
410 self.embedder.get()?,
411 &self.scoring,
412 query,
413 options,
414 )
415 .await
416 }
417
418 pub async fn search_episodes(
420 &self,
421 query: &str,
422 limit: usize,
423 ) -> Result<Vec<EpisodeSearchResult>, GraphError> {
424 search::search_episodes(&self.db, self.embedder.get()?, query, limit).await
425 }
426
427 pub async fn query(
431 &self,
432 query_text: &str,
433 options: &QueryOptions,
434 ) -> Result<QueryResult, GraphError> {
435 query::query(
436 &self.db,
437 self.embedder.get()?,
438 &self.scoring,
439 query_text,
440 options,
441 )
442 .await
443 }
444
445 pub async fn traverse(
449 &self,
450 entity_name: &str,
451 depth: u32,
452 ) -> Result<TraversalNode, GraphError> {
453 traverse::traverse(&self.db, entity_name, depth).await
454 }
455
456 pub async fn traverse_filtered(
458 &self,
459 entity_name: &str,
460 depth: u32,
461 type_filter: Option<&str>,
462 ) -> Result<TraversalNode, GraphError> {
463 traverse::traverse_filtered(&self.db, entity_name, depth, type_filter).await
464 }
465
466 pub async fn sync_pipeline(
470 &self,
471 docs: &PipelineDocuments,
472 ) -> Result<PipelineSyncReport, GraphError> {
473 pipeline_sync::sync_pipeline(self, docs).await
474 }
475
476 pub async fn pipeline_stats(
478 &self,
479 staleness_days: u32,
480 ) -> Result<PipelineGraphStats, GraphError> {
481 query::pipeline_stats(&self.db, staleness_days).await
482 }
483
484 pub async fn pipeline_entities(
486 &self,
487 stage: &str,
488 status: Option<&str>,
489 ) -> Result<Vec<EntityDetail>, GraphError> {
490 query::pipeline_entities(&self.db, stage, status).await
491 }
492
493 pub async fn pipeline_flow(
495 &self,
496 entity_name: &str,
497 ) -> Result<Vec<(EntityDetail, String, EntityDetail)>, GraphError> {
498 query::pipeline_flow(&self.db, entity_name).await
499 }
500
501 pub async fn sync_vigil_signals(
505 &self,
506 signals_path: &std::path::Path,
507 ) -> Result<VigilSyncReport, GraphError> {
508 vigil_sync::sync_vigil_signals(self, signals_path).await
509 }
510
511 pub async fn sync_outcomes(
513 &self,
514 outcomes_path: &std::path::Path,
515 ) -> Result<VigilSyncReport, GraphError> {
516 vigil_sync::sync_outcomes(self, outcomes_path).await
517 }
518
519 pub async fn sync_vigil(
521 &self,
522 signals_path: &std::path::Path,
523 outcomes_path: &std::path::Path,
524 ) -> Result<VigilSyncReport, GraphError> {
525 vigil_sync::sync_vigil(self, signals_path, outcomes_path).await
526 }
527
528 pub async fn record_outcome_feedback(
533 &self,
534 session_id: &str,
535 outcome: utility::OutcomeKind,
536 retrieved_entity_ids: &[String],
537 used_entity_ids: Option<&[String]>,
538 ) -> Result<utility::FeedbackReport, GraphError> {
539 utility::record_outcome_feedback(
540 &self.db,
541 session_id,
542 outcome,
543 retrieved_entity_ids,
544 used_entity_ids,
545 )
546 .await
547 }
548
549 pub async fn record_session_outcome(
556 &self,
557 session_id: &str,
558 outcome: utility::OutcomeKind,
559 ) -> Result<utility::FeedbackReport, GraphError> {
560 let session = utility::session_entities(&self.db, session_id).await?;
561 if session.is_empty() {
562 return Ok(utility::FeedbackReport::default());
563 }
564
565 utility::record_outcome_feedback(
566 &self.db,
567 session_id,
568 outcome,
569 &session.retrieved,
570 Some(&session.used),
571 )
572 .await
573 }
574
575 pub async fn record_session_use(
577 &self,
578 session_id: &str,
579 entity_ids: &[String],
580 ) -> Result<u32, GraphError> {
581 utility::record_session_use(&self.db, session_id, entity_ids).await
582 }
583
584 pub async fn run_gc(&self, config: &gc::GcConfig) -> Result<gc::GcReport, GraphError> {
588 gc::run_gc(&self.db, config).await
589 }
590
591 pub async fn gc_stats(&self) -> Result<gc::GcStatsReport, GraphError> {
593 gc::stats_only(&self.db).await
594 }
595
596 pub async fn delete_relationship(&self, id: &str) -> Result<(), GraphError> {
598 crud::delete_relationship(&self.db, id).await
599 }
600
601 pub async fn stats(&self) -> Result<GraphStats, GraphError> {
605 let entity_count = db_count(&self.db, "entity").await?;
606 let relationship_count = db_count(&self.db, "relates_to").await?;
607 let episode_count = db_count(&self.db, "episode").await?;
608
609 let mut type_response = self
611 .db
612 .query("SELECT entity_type, count() AS count FROM entity GROUP BY entity_type")
613 .await?;
614
615 let type_rows: Vec<TypeCount> = type_response.take(0)?;
616 let entity_type_counts: HashMap<String, u64> = type_rows
617 .into_iter()
618 .map(|r| (r.entity_type, r.count))
619 .collect();
620
621 Ok(GraphStats {
622 entity_count,
623 relationship_count,
624 episode_count,
625 entity_type_counts,
626 })
627 }
628}
629
630fn load_graph_section(graph_path: &Path) -> crate::config::GraphSection {
634 let memory_dir = graph_path.parent().unwrap_or(graph_path);
635 crate::config::load_from_dir(memory_dir)
636 .graph
637 .unwrap_or_default()
638}
639
640async fn db_count(db: &Surreal<Db>, table: &str) -> Result<u64, GraphError> {
641 let query = format!("SELECT count() AS count FROM {table} GROUP ALL");
642 let mut response = db.query(&query).await?;
643 let rows: Vec<CountRow> = response.take(0)?;
644 Ok(rows.first().map(|r| r.count).unwrap_or(0))
645}
646
647#[derive(serde::Deserialize, surrealdb::types::SurrealValue)]
648struct CountRow {
649 count: u64,
650}
651
652#[derive(serde::Deserialize, surrealdb::types::SurrealValue)]
653struct TypeCount {
654 entity_type: String,
655 count: u64,
656}