1pub mod confidence;
11pub mod correct;
12pub mod crud;
13pub mod dedup;
14pub mod edge_view;
15pub mod embed;
16pub mod error;
17pub mod extract;
18pub mod gc;
19pub mod ingest;
20pub mod inspect;
21pub mod llm;
22pub mod pipeline;
23pub mod pipeline_sync;
24pub mod query;
25pub mod search;
26pub mod store;
27pub mod traverse;
28pub mod types;
29pub mod util;
30pub mod utility;
31pub mod vigil_sync;
32
33use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35
36pub use confidence::{Provenance, ProvenanceWeights};
37use embed::{FastEmbedder, LazyEmbedder};
38use error::GraphError;
39pub use ingest::{IngestContext, ProvenancePolicy};
40use store::Db;
41pub use store::ServerConfig;
42#[allow(unused_imports)] use surrealdb::types::SurrealValue;
44use surrealdb::Surreal;
45use types::*;
46
47pub(crate) fn deserialize_take<T: serde::de::DeserializeOwned>(
50 response: &mut surrealdb::IndexedResults,
51 index: usize,
52) -> Result<Vec<T>, GraphError> {
53 let values: Vec<serde_json::Value> = response.take(index)?;
54 values
55 .into_iter()
56 .map(|v| serde_json::from_value(v).map_err(GraphError::from))
57 .collect()
58}
59
60pub(crate) fn deserialize_take_opt<T: serde::de::DeserializeOwned>(
61 response: &mut surrealdb::IndexedResults,
62 index: usize,
63) -> Result<Option<T>, GraphError> {
64 let values: Vec<T> = deserialize_take(response, index)?;
65 Ok(values.into_iter().next())
66}
67
68pub struct GraphMemory {
70 db: Surreal<Db>,
71 embedder: LazyEmbedder,
72 path: PathBuf,
73 scoring: crate::config::GraphScoringConfig,
74 provenance: confidence::ProvenanceWeights,
75 dedup: crate::config::GraphDedupConfig,
76}
77
78impl GraphMemory {
79 pub async fn open(path: &Path) -> Result<Self, GraphError> {
87 let memory_dir = path.parent().unwrap_or(path);
88 let config = crate::config::load_from_dir(memory_dir);
89 let mode = config
90 .graph
91 .as_ref()
92 .map(|g| g.mode.clone())
93 .unwrap_or_else(|| "embedded".to_string());
94
95 match mode.as_str() {
96 "server" => Self::open_server(path).await,
97 _ => Self::open_embedded(path).await,
98 }
99 }
100
101 pub async fn open_embedded(path: &Path) -> Result<Self, GraphError> {
103 std::fs::create_dir_all(path)?;
104
105 let db = store::open(path).await?;
106 let migration = store::init_schema(&db).await?;
107 if migration.ran() {
108 eprintln!(
109 "recall-echo: graph schema migrated v{} → v{} ({} edges backfilled)",
110 migration.from_version, migration.to_version, migration.edges_backfilled
111 );
112 }
113
114 let models_dir = path.join("models");
115 std::fs::create_dir_all(&models_dir)?;
116 let embedder = LazyEmbedder::new(&models_dir);
117
118 let graph_config = load_graph_section(path);
119
120 Ok(Self {
121 db,
122 embedder,
123 path: path.to_path_buf(),
124 scoring: graph_config.scoring,
125 provenance: graph_config.provenance,
126 dedup: graph_config.dedup,
127 })
128 }
129
130 pub async fn open_server(path: &Path) -> Result<Self, GraphError> {
134 let memory_dir = path.parent().unwrap_or(path);
135 let config = crate::config::load_from_dir(memory_dir);
136
137 let graph_section = config.graph.unwrap_or_default();
138 let password = if graph_section.password_file.is_empty() {
139 String::new()
140 } else {
141 let pw_path = if graph_section.password_file.starts_with('/') {
142 std::path::PathBuf::from(&graph_section.password_file)
143 } else {
144 let entity_root = memory_dir.parent().unwrap_or(memory_dir);
146 entity_root.join(&graph_section.password_file)
147 };
148 std::fs::read_to_string(&pw_path)
149 .map(|s| s.trim().to_string())
150 .map_err(|e| {
151 GraphError::Io(std::io::Error::new(
152 e.kind(),
153 format!(
154 "failed to read graph password file {}: {e}",
155 pw_path.display()
156 ),
157 ))
158 })?
159 };
160
161 let scoring = graph_section.scoring.clone();
162 let provenance = graph_section.provenance;
163 let dedup = graph_section.dedup.clone();
164 let server_config = store::ServerConfig {
165 url: graph_section.url,
166 username: graph_section.username,
167 password,
168 namespace: graph_section.namespace,
169 database: graph_section.database,
170 };
171
172 let models_dir = path.join("models");
173 let mut gm = Self::connect(&server_config, &models_dir).await?;
174 gm.scoring = scoring;
175 gm.provenance = provenance;
176 gm.dedup = dedup;
177 Ok(gm)
178 }
179
180 pub async fn connect(
182 config: &store::ServerConfig,
183 models_dir: &Path,
184 ) -> Result<Self, GraphError> {
185 let db = store::connect(config).await?;
186 let migration = store::init_schema(&db).await?;
187 if migration.ran() {
188 eprintln!(
189 "recall-echo: graph schema migrated v{} → v{} ({} edges backfilled)",
190 migration.from_version, migration.to_version, migration.edges_backfilled
191 );
192 }
193
194 std::fs::create_dir_all(models_dir)?;
195 let embedder = LazyEmbedder::new(models_dir);
196
197 Ok(Self {
198 db,
199 embedder,
200 path: models_dir.to_path_buf(),
201 scoring: crate::config::GraphScoringConfig::default(),
202 provenance: confidence::ProvenanceWeights::default(),
203 dedup: crate::config::GraphDedupConfig::default(),
204 })
205 }
206
207 pub fn path(&self) -> &Path {
209 &self.path
210 }
211
212 #[must_use]
215 pub fn provenance_weights(&self) -> &confidence::ProvenanceWeights {
216 &self.provenance
217 }
218
219 #[must_use]
222 pub fn dedup_config(&self) -> &crate::config::GraphDedupConfig {
223 &self.dedup
224 }
225
226 #[allow(dead_code)]
228 pub(crate) fn db(&self) -> &Surreal<Db> {
229 &self.db
230 }
231
232 #[allow(dead_code)]
234 pub(crate) fn embedder(&self) -> Result<&FastEmbedder, GraphError> {
235 self.embedder.get()
236 }
237
238 pub async fn add_entity(&self, entity: NewEntity) -> Result<Entity, GraphError> {
242 crud::add_entity(&self.db, self.embedder.get()?, entity).await
243 }
244
245 pub async fn get_entity(&self, name: &str) -> Result<Option<Entity>, GraphError> {
247 crud::get_entity_by_name(&self.db, name).await
248 }
249
250 pub async fn get_entity_by_id(&self, id: &str) -> Result<Option<Entity>, GraphError> {
252 crud::get_entity_by_id(&self.db, id).await
253 }
254
255 pub async fn update_entity(
257 &self,
258 id: &str,
259 updates: EntityUpdate,
260 ) -> Result<Entity, GraphError> {
261 crud::update_entity(&self.db, self.embedder.get()?, id, updates).await
262 }
263
264 pub async fn delete_entity(&self, id: &str) -> Result<(), GraphError> {
266 crud::delete_entity(&self.db, id).await
267 }
268
269 pub async fn list_entities(
271 &self,
272 entity_type: Option<&str>,
273 ) -> Result<Vec<Entity>, GraphError> {
274 crud::list_entities(&self.db, entity_type).await
275 }
276
277 pub async fn add_relationship(&self, rel: NewRelationship) -> Result<Relationship, GraphError> {
281 crud::add_relationship(&self.db, rel).await
282 }
283
284 pub async fn get_relationships(
286 &self,
287 entity_name: &str,
288 direction: Direction,
289 ) -> Result<Vec<Relationship>, GraphError> {
290 crud::get_relationships(&self.db, entity_name, direction).await
291 }
292
293 pub async fn supersede_relationship(
295 &self,
296 old_id: &str,
297 new: NewRelationship,
298 ) -> Result<Relationship, GraphError> {
299 crud::supersede_relationship(&self.db, old_id, new).await
300 }
301
302 pub async fn update_relationship_confidence(
305 &self,
306 rel_id: &str,
307 confidence: f64,
308 ) -> Result<(), GraphError> {
309 crud::update_relationship_confidence(&self.db, rel_id, confidence).await
310 }
311
312 pub async fn reinforce_relationship(
319 &self,
320 rel_id: &str,
321 evidence: confidence::EdgeEvidence,
322 ) -> Result<(), GraphError> {
323 crud::reinforce_relationship(&self.db, rel_id, evidence).await
324 }
325
326 pub async fn correct(
334 &self,
335 target: &correct::CorrectTarget,
336 correction: correct::Correction,
337 ) -> Result<correct::CorrectionReport, GraphError> {
338 correct::correct(&self.db, &self.provenance, target, correction).await
339 }
340
341 pub async fn overview(&self, per_type: usize) -> Result<inspect::MemoryOverview, GraphError> {
345 let stats = self.stats().await?;
346 inspect::overview(&self.db, stats, per_type).await
347 }
348
349 pub async fn about(
351 &self,
352 topic: &str,
353 limit: usize,
354 ) -> Result<inspect::TopicReport, GraphError> {
355 inspect::about(&self.db, self.embedder.get()?, &self.scoring, topic, limit).await
356 }
357
358 pub async fn add_episode(&self, episode: NewEpisode) -> Result<Episode, GraphError> {
366 crud::add_episode(&self.db, self.embedder.get()?, episode).await
367 }
368
369 pub async fn add_episode_from(
371 &self,
372 episode: NewEpisode,
373 provenance: Provenance,
374 ) -> Result<Episode, GraphError> {
375 crud::add_episode_from(&self.db, self.embedder.get()?, episode, provenance).await
376 }
377
378 pub async fn get_episodes_by_session(
380 &self,
381 session_id: &str,
382 ) -> Result<Vec<Episode>, GraphError> {
383 crud::get_episodes_by_session(&self.db, session_id).await
384 }
385
386 pub async fn get_episode_by_log_number(
388 &self,
389 log_number: u32,
390 ) -> Result<Option<Episode>, GraphError> {
391 crud::get_episode_by_log_number(&self.db, log_number).await
392 }
393
394 pub async fn ingest_archive(
402 &self,
403 archive_text: &str,
404 context: &IngestContext,
405 llm: Option<&dyn llm::LlmProvider>,
406 ) -> Result<IngestionReport, GraphError> {
407 ingest::ingest_archive(self, archive_text, context, llm).await
408 }
409
410 pub async fn extract_from_archive(
412 &self,
413 archive_text: &str,
414 context: &IngestContext,
415 llm: &dyn llm::LlmProvider,
416 ) -> Result<IngestionReport, GraphError> {
417 ingest::extract_from_archive(self, archive_text, context, llm).await
418 }
419
420 pub async fn mark_extracted(&self, log_number: u32) -> Result<(), GraphError> {
422 crud::mark_episodes_extracted(&self.db, log_number).await
423 }
424
425 pub async fn unextracted_log_numbers(&self) -> Result<Vec<i64>, GraphError> {
427 crud::get_unextracted_log_numbers(&self.db).await
428 }
429
430 pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>, GraphError> {
434 search::search(&self.db, self.embedder.get()?, &self.scoring, query, limit).await
435 }
436
437 pub async fn search_with_options(
439 &self,
440 query: &str,
441 options: &SearchOptions,
442 ) -> Result<Vec<ScoredEntity>, GraphError> {
443 search::search_with_options(
444 &self.db,
445 self.embedder.get()?,
446 &self.scoring,
447 query,
448 options,
449 )
450 .await
451 }
452
453 pub async fn search_episodes(
455 &self,
456 query: &str,
457 limit: usize,
458 ) -> Result<Vec<EpisodeSearchResult>, GraphError> {
459 search::search_episodes(&self.db, self.embedder.get()?, query, limit).await
460 }
461
462 pub async fn query(
466 &self,
467 query_text: &str,
468 options: &QueryOptions,
469 ) -> Result<QueryResult, GraphError> {
470 query::query(
471 &self.db,
472 self.embedder.get()?,
473 &self.scoring,
474 query_text,
475 options,
476 )
477 .await
478 }
479
480 pub async fn traverse(
484 &self,
485 entity_name: &str,
486 depth: u32,
487 ) -> Result<TraversalNode, GraphError> {
488 traverse::traverse(&self.db, entity_name, depth).await
489 }
490
491 pub async fn traverse_filtered(
493 &self,
494 entity_name: &str,
495 depth: u32,
496 type_filter: Option<&str>,
497 ) -> Result<TraversalNode, GraphError> {
498 traverse::traverse_filtered(&self.db, entity_name, depth, type_filter).await
499 }
500
501 pub async fn sync_pipeline(
505 &self,
506 docs: &PipelineDocuments,
507 ) -> Result<PipelineSyncReport, GraphError> {
508 pipeline_sync::sync_pipeline(self, docs).await
509 }
510
511 pub async fn pipeline_stats(
513 &self,
514 staleness_days: u32,
515 ) -> Result<PipelineGraphStats, GraphError> {
516 query::pipeline_stats(&self.db, staleness_days).await
517 }
518
519 pub async fn pipeline_entities(
521 &self,
522 stage: &str,
523 status: Option<&str>,
524 ) -> Result<Vec<EntityDetail>, GraphError> {
525 query::pipeline_entities(&self.db, stage, status).await
526 }
527
528 pub async fn pipeline_flow(
530 &self,
531 entity_name: &str,
532 ) -> Result<Vec<(EntityDetail, String, EntityDetail)>, GraphError> {
533 query::pipeline_flow(&self.db, entity_name).await
534 }
535
536 pub async fn sync_vigil_signals(
540 &self,
541 signals_path: &std::path::Path,
542 ) -> Result<VigilSyncReport, GraphError> {
543 vigil_sync::sync_vigil_signals(self, signals_path).await
544 }
545
546 pub async fn sync_outcomes(
548 &self,
549 outcomes_path: &std::path::Path,
550 ) -> Result<VigilSyncReport, GraphError> {
551 vigil_sync::sync_outcomes(self, outcomes_path).await
552 }
553
554 pub async fn sync_vigil(
556 &self,
557 signals_path: &std::path::Path,
558 outcomes_path: &std::path::Path,
559 ) -> Result<VigilSyncReport, GraphError> {
560 vigil_sync::sync_vigil(self, signals_path, outcomes_path).await
561 }
562
563 pub async fn record_outcome_feedback(
568 &self,
569 session_id: &str,
570 outcome: utility::OutcomeKind,
571 retrieved_entity_ids: &[String],
572 used_entity_ids: Option<&[String]>,
573 ) -> Result<utility::FeedbackReport, GraphError> {
574 utility::record_outcome_feedback(
575 &self.db,
576 session_id,
577 outcome,
578 retrieved_entity_ids,
579 used_entity_ids,
580 )
581 .await
582 }
583
584 pub async fn record_session_outcome(
591 &self,
592 session_id: &str,
593 outcome: utility::OutcomeKind,
594 ) -> Result<utility::FeedbackReport, GraphError> {
595 let session = utility::session_entities(&self.db, session_id).await?;
596 if session.is_empty() {
597 return Ok(utility::FeedbackReport::default());
598 }
599
600 utility::record_outcome_feedback(
601 &self.db,
602 session_id,
603 outcome,
604 &session.retrieved,
605 Some(&session.used),
606 )
607 .await
608 }
609
610 pub async fn record_session_use(
612 &self,
613 session_id: &str,
614 entity_ids: &[String],
615 ) -> Result<u32, GraphError> {
616 utility::record_session_use(&self.db, session_id, entity_ids).await
617 }
618
619 pub async fn run_gc(&self, config: &gc::GcConfig) -> Result<gc::GcReport, GraphError> {
623 gc::run_gc(&self.db, config).await
624 }
625
626 pub async fn gc_stats(&self) -> Result<gc::GcStatsReport, GraphError> {
628 gc::stats_only(&self.db).await
629 }
630
631 pub async fn delete_relationship(&self, id: &str) -> Result<(), GraphError> {
633 crud::delete_relationship(&self.db, id).await
634 }
635
636 pub async fn stats(&self) -> Result<GraphStats, GraphError> {
640 let entity_count = db_count(&self.db, "entity").await?;
641 let relationship_count = db_count(&self.db, "relates_to").await?;
642 let episode_count = db_count(&self.db, "episode").await?;
643
644 let mut type_response = self
646 .db
647 .query("SELECT entity_type, count() AS count FROM entity GROUP BY entity_type")
648 .await?;
649
650 let type_rows: Vec<TypeCount> = type_response.take(0)?;
651 let entity_type_counts: HashMap<String, u64> = type_rows
652 .into_iter()
653 .map(|r| (r.entity_type, r.count))
654 .collect();
655
656 Ok(GraphStats {
657 entity_count,
658 relationship_count,
659 episode_count,
660 entity_type_counts,
661 })
662 }
663}
664
665fn load_graph_section(graph_path: &Path) -> crate::config::GraphSection {
669 let memory_dir = graph_path.parent().unwrap_or(graph_path);
670 crate::config::load_from_dir(memory_dir)
671 .graph
672 .unwrap_or_default()
673}
674
675async fn db_count(db: &Surreal<Db>, table: &str) -> Result<u64, GraphError> {
676 let query = format!("SELECT count() AS count FROM {table} GROUP ALL");
677 let mut response = db.query(&query).await?;
678 let rows: Vec<CountRow> = response.take(0)?;
679 Ok(rows.first().map(|r| r.count).unwrap_or(0))
680}
681
682#[derive(serde::Deserialize, surrealdb::types::SurrealValue)]
683struct CountRow {
684 count: u64,
685}
686
687#[derive(serde::Deserialize, surrealdb::types::SurrealValue)]
688struct TypeCount {
689 entity_type: String,
690 count: u64,
691}