1use std::path::{Path, PathBuf};
8
9use crate::error::RecallError;
10use crate::graph::traverse::format_traversal;
11use crate::graph::types::*;
12use crate::graph::{IngestContext, Provenance};
13use crate::serve::{
14 AddEntityArgs, IngestArchiveArgs, QueryArgs, RelateArgs, Request, SearchArgs, TraverseArgs,
15};
16use crate::serve_client;
17
18const GREEN: &str = "\x1b[32m";
19const CYAN: &str = "\x1b[36m";
20const YELLOW: &str = "\x1b[33m";
21const BOLD: &str = "\x1b[1m";
22const DIM: &str = "\x1b[2m";
23const RESET: &str = "\x1b[0m";
24
25pub async fn init(memory_dir: &Path) -> Result<(), RecallError> {
27 let graph_dir = memory_dir.join("graph");
28 serve_client::exclusive(memory_dir, |_graph| async { Ok(()) }).await?;
29 println!(
30 "{GREEN}✓{RESET} Graph store initialized at {}",
31 graph_dir.display()
32 );
33 Ok(())
34}
35
36pub async fn graph_status(memory_dir: &Path) -> Result<(), RecallError> {
38 let graph_dir = memory_dir.join("graph");
39 if !graph_dir.exists() {
40 return Err(RecallError::NotInitialized(
41 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
42 ));
43 }
44 let data = serve_client::execute(memory_dir, &Request::Status).await?;
45 let stats: GraphStats = serde_json::from_value(data)?;
46
47 println!("{BOLD}Graph Memory Status{RESET}");
48 println!(" Entities: {}", stats.entity_count);
49 println!(" Relationships: {}", stats.relationship_count);
50 println!(" Episodes: {}", stats.episode_count);
51
52 if stats.episode_count > 0 && stats.entity_count == 0 {
57 let extraction = crate::config::load_from_dir(memory_dir).extraction;
58 println!(
59 "\n {YELLOW}No entities yet.{RESET} Episodes are ingested automatically; turning"
60 );
61 println!(" them into entities is a separate LLM pass.");
62 if extraction.background_enabled {
63 println!(
64 " The daemon runs it after {}s of quiet. To run it now:",
65 extraction.idle_after_secs
66 );
67 }
68 println!(" {DIM}recall-echo graph extract --all{RESET}");
69 }
70
71 if !stats.entity_type_counts.is_empty() {
72 println!("\n {DIM}By type:{RESET}");
73 let mut types: Vec<_> = stats.entity_type_counts.iter().collect();
74 types.sort_by(|a, b| b.1.cmp(a.1));
75 for (t, count) in types {
76 println!(" {t}: {count}");
77 }
78 }
79
80 print_daemon_line(memory_dir).await;
81 Ok(())
82}
83
84async fn print_daemon_line(memory_dir: &Path) {
86 println!();
87 match serve_client::daemon_info(memory_dir).await {
88 Ok(Some(info)) => println!(
89 " {DIM}Daemon:{RESET} running — pid {}, v{}, up {}s",
90 info.pid, info.version, info.uptime_secs
91 ),
92 Ok(None) if serve_client::graph_mode(memory_dir) == "server" => {
93 println!(" {DIM}Daemon:{RESET} not used — [graph] mode = server");
94 }
95 Ok(None) => println!(" {DIM}Daemon:{RESET} not running"),
96 Err(e) => println!(" {DIM}Daemon:{RESET} unknown ({e})"),
97 }
98}
99
100pub async fn daemon_status(memory_dir: &Path) -> Result<(), RecallError> {
102 println!("{BOLD}Graph Daemon{RESET}");
103 println!(
104 " Socket: {}",
105 serve_client::socket_path(memory_dir)?.display()
106 );
107 match serve_client::daemon_info(memory_dir).await? {
108 Some(info) => {
109 println!(" State: {GREEN}running{RESET}");
110 println!(" Pid: {}", info.pid);
111 println!(" Version: {}", info.version);
112 println!(" Uptime: {}s", info.uptime_secs);
113 print_extraction_lines(&info.extraction);
114 }
115 None if serve_client::graph_mode(memory_dir) == "server" => {
116 println!(" State: not used — [graph] mode = server");
117 }
118 None => println!(" State: {YELLOW}not running{RESET}"),
119 }
120 println!(
121 " Log: {}",
122 serve_client::daemon_log_path(memory_dir).display()
123 );
124 Ok(())
125}
126
127fn print_extraction_lines(status: &crate::serve::ExtractionStatus) {
129 if !status.enabled {
130 let reason = status.disabled_reason.as_deref().unwrap_or("not running");
131 println!(" Extraction: {YELLOW}off{RESET} — {DIM}{reason}{RESET}");
132 return;
133 }
134 match status.last_run_secs_ago {
135 Some(secs) => println!(
136 " Extraction: {GREEN}on{RESET} — {} archives in {} runs, last {}s ago ({}ms)",
137 status.archives,
138 status.runs,
139 secs,
140 status.last_run_ms.unwrap_or(0)
141 ),
142 None => println!(" Extraction: {GREEN}on{RESET} — nothing extracted yet"),
143 }
144 if let Some(error) = &status.last_error {
145 println!(" {DIM}Last extraction error: {error}{RESET}");
146 }
147}
148
149pub async fn daemon_stop(memory_dir: &Path) -> Result<(), RecallError> {
151 if serve_client::stop_daemon(memory_dir).await? {
152 println!("{GREEN}✓{RESET} Graph daemon stopped");
153 } else {
154 println!("{YELLOW}No graph daemon running.{RESET}");
155 }
156 Ok(())
157}
158
159pub async fn add_entity(
161 memory_dir: &Path,
162 name: &str,
163 entity_type: &str,
164 abstract_text: &str,
165 overview: Option<&str>,
166 source: Option<&str>,
167) -> Result<(), RecallError> {
168 let request = Request::AddEntity(AddEntityArgs {
169 name: name.to_string(),
170 entity_type: entity_type.to_string(),
171 abstract_text: abstract_text.to_string(),
172 overview: overview.map(String::from),
173 source: source.map(String::from),
174 });
175 let entity: Entity =
176 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
177
178 println!(
179 "{GREEN}✓{RESET} Created entity: {BOLD}{}{RESET} ({}) [{}]",
180 entity.name,
181 entity.entity_type,
182 entity.id_string()
183 );
184 Ok(())
185}
186
187pub async fn relate(
189 memory_dir: &Path,
190 from: &str,
191 rel_type: &str,
192 to: &str,
193 description: Option<&str>,
194 source: Option<&str>,
195) -> Result<(), RecallError> {
196 let request = Request::Relate(RelateArgs {
197 from: from.to_string(),
198 rel_type: rel_type.to_string(),
199 to: to.to_string(),
200 description: description.map(String::from),
201 source: source.map(String::from),
202 });
203 let rel: Relationship =
204 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
205
206 println!(
207 "{GREEN}✓{RESET} {from} {CYAN}—[{rel_type}]→{RESET} {to} [{}]",
208 rel.id_string()
209 );
210 Ok(())
211}
212
213pub async fn search(
215 memory_dir: &Path,
216 query: &str,
217 limit: usize,
218 entity_type: Option<&str>,
219 keyword: Option<&str>,
220) -> Result<(), RecallError> {
221 let request = Request::Search(SearchArgs {
222 query: query.to_string(),
223 limit,
224 entity_type: entity_type.map(String::from),
225 keyword: keyword.map(String::from),
226 });
227 let results: Vec<ScoredEntity> =
228 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
229
230 if results.is_empty() {
231 println!("{YELLOW}No results.{RESET}");
232 return Ok(());
233 }
234
235 for (i, r) in results.iter().enumerate() {
236 println!(
237 "{BOLD}{}. {}{RESET} ({}) — score: {:.3}",
238 i + 1,
239 r.entity.name,
240 r.entity.entity_type,
241 r.score
242 );
243 println!(" {DIM}{}{RESET}", r.entity.abstract_text);
244 }
245 Ok(())
246}
247
248pub async fn ingest(
254 memory_dir: &Path,
255 archive_path: &Path,
256 provenance: Option<Provenance>,
257) -> Result<(), RecallError> {
258 let graph_dir = memory_dir.join("graph");
259 if !graph_dir.exists() {
260 return Err(RecallError::NotInitialized(
261 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
262 ));
263 }
264
265 let content = std::fs::read_to_string(archive_path)?;
266
267 let (session_id, log_number) = extract_archive_metadata(&content, archive_path);
269
270 let request = Request::IngestArchive(IngestArchiveArgs {
271 content,
272 session_id,
273 log_number,
274 provenance,
275 });
276 let report: IngestionReport =
277 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
278
279 println!(
280 "{GREEN}✓{RESET} Ingested {}: {} episodes created {DIM}(provenance: {}){RESET}",
281 archive_path.display(),
282 report.episodes_created,
283 provenance_label(provenance)
284 );
285 if !report.errors.is_empty() {
286 for err in &report.errors {
287 println!(" {YELLOW}warning:{RESET} {err}");
288 }
289 }
290 Ok(())
291}
292
293fn provenance_label(provenance: Option<Provenance>) -> &'static str {
295 match provenance {
296 Some(Provenance::External) => "external",
297 Some(Provenance::User) => "user",
298 Some(Provenance::SelfGenerated) => "self",
299 None => "per turn role",
300 }
301}
302
303pub async fn ingest_all(
305 memory_dir: &Path,
306 provenance: Option<Provenance>,
307) -> Result<(), RecallError> {
308 let graph_dir = memory_dir.join("graph");
309 if !graph_dir.exists() {
310 return Err(RecallError::NotInitialized(
311 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
312 ));
313 }
314
315 let conversations_dir = find_conversations_dir(memory_dir)?;
316
317 let mut files: Vec<_> = std::fs::read_dir(&conversations_dir)?
319 .filter_map(|e| e.ok())
320 .filter(|e| {
321 let name = e.file_name().to_string_lossy().to_string();
322 name.starts_with("conversation-") || name.starts_with("archive-log-")
323 })
324 .collect();
325 files.sort_by_key(|e| e.file_name());
326
327 if files.is_empty() {
328 println!("{YELLOW}No conversation archives found.{RESET}");
329 return Ok(());
330 }
331
332 serve_client::exclusive(memory_dir, |gm| async move {
333 let mut total_episodes = 0u32;
334 let mut ingested = 0u32;
335 let mut skipped = 0u32;
336
337 for entry in &files {
338 let path = entry.path();
339 let content = std::fs::read_to_string(&path)?;
340
341 let (session_id, log_number) = extract_archive_metadata(&content, &path);
342
343 if let Some(ln) = log_number {
345 if let Ok(Some(_)) = gm.get_episode_by_log_number(ln).await {
346 skipped += 1;
347 continue;
348 }
349 }
350
351 let context = IngestContext::new(session_id, log_number).with_override(provenance);
352 let report = gm.ingest_archive(&content, &context, None).await?;
353
354 total_episodes += report.episodes_created;
355 ingested += 1;
356
357 println!(
358 " {GREEN}✓{RESET} {} — {} episodes",
359 path.file_name().unwrap_or_default().to_string_lossy(),
360 report.episodes_created
361 );
362 }
363
364 println!(
365 "\n{GREEN}✓{RESET} Ingested {ingested} archives ({total_episodes} episodes), skipped {skipped} already ingested {DIM}(provenance: {}){RESET}",
366 provenance_label(provenance)
367 );
368 Ok(())
369 })
370 .await
371}
372
373pub(crate) fn extract_archive_metadata(content: &str, path: &Path) -> (String, Option<u32>) {
375 let mut session_id = "unknown".to_string();
376 let mut log_number: Option<u32> = None;
377
378 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
380 let num_str = name
381 .strip_prefix("conversation-")
382 .or_else(|| name.strip_prefix("archive-log-"));
383 if let Some(num_str) = num_str {
384 if let Ok(n) = num_str.parse::<u32>() {
385 log_number = Some(n);
386 }
387 }
388 }
389
390 if let Some(stripped) = content.strip_prefix("---") {
392 if let Some(end) = stripped.find("---") {
393 let frontmatter = &stripped[..end];
394 for line in frontmatter.lines() {
395 let line = line.trim();
396 if let Some(val) = line.strip_prefix("session_id:") {
397 session_id = val.trim().trim_matches('"').to_string();
398 }
399 }
400 }
401 }
402
403 (session_id, log_number)
404}
405
406pub async fn traverse(
408 memory_dir: &Path,
409 entity_name: &str,
410 depth: u32,
411 type_filter: Option<&str>,
412) -> Result<(), RecallError> {
413 let request = Request::Traverse(TraverseArgs {
414 entity: entity_name.to_string(),
415 depth,
416 type_filter: type_filter.map(String::from),
417 });
418 let tree: TraversalNode =
419 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
420
421 let output = format_traversal(&tree, 0);
422 print!("{output}");
423 Ok(())
424}
425
426pub async fn hybrid_query(
428 memory_dir: &Path,
429 query: &str,
430 limit: usize,
431 entity_type: Option<&str>,
432 keyword: Option<&str>,
433 depth: u32,
434 episodes: bool,
435) -> Result<(), RecallError> {
436 let request = Request::Query(QueryArgs {
437 query: query.to_string(),
438 limit,
439 entity_type: entity_type.map(String::from),
440 keyword: keyword.map(String::from),
441 depth,
442 episodes,
443 });
444 let result: QueryResult =
445 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
446
447 if result.entities.is_empty() && result.episodes.is_empty() {
448 println!("{YELLOW}No results.{RESET}");
449 return Ok(());
450 }
451
452 if !result.entities.is_empty() {
453 println!("{BOLD}Entities:{RESET}");
454 for (i, r) in result.entities.iter().enumerate() {
455 let source_tag = match &r.source {
456 MatchSource::Semantic => "semantic".to_string(),
457 MatchSource::Graph { parent, rel_type } => {
458 format!("graph: {parent} —[{rel_type}]")
459 }
460 MatchSource::Keyword => "keyword".to_string(),
461 };
462 println!(
463 " {BOLD}{}. {}{RESET} ({}) — {:.3} [{DIM}{source_tag}{RESET}]",
464 i + 1,
465 r.entity.name,
466 r.entity.entity_type,
467 r.score
468 );
469 println!(" {DIM}{}{RESET}", r.entity.abstract_text);
470 }
471 }
472
473 if !result.episodes.is_empty() {
474 println!("\n{BOLD}Episodes:{RESET}");
475 for (i, ep) in result.episodes.iter().enumerate() {
476 let log = ep
477 .episode
478 .log_number
479 .map(|n| format!("#{n}"))
480 .unwrap_or_default();
481 println!(
482 " {BOLD}{}. {}{RESET} ({}) — {:.3}",
483 i + 1,
484 ep.episode.session_id,
485 log,
486 ep.score
487 );
488 println!(" {DIM}{}{RESET}", ep.episode.abstract_text);
489 }
490 }
491
492 Ok(())
493}
494
495#[cfg(feature = "llm")]
497#[derive(Default)]
498struct ExtractionTotals {
499 entities_created: u32,
500 entities_merged: u32,
501 entities_skipped: u32,
502 relationships: u32,
503 errors: Vec<String>,
504 processed: u32,
505 estimated_tokens: u64,
506 quarantined: Vec<u32>,
507 dedup_llm_calls: u32,
508 dedup_fast_path: u32,
509}
510
511#[cfg(feature = "llm")]
513fn print_extract_dry_run(conversations_dir: &Path, log_numbers: &[u32]) {
514 println!(
515 "{BOLD}Dry run — {}{RESET} archives to extract",
516 log_numbers.len()
517 );
518 for ln in log_numbers {
519 let path = find_archive_file(conversations_dir, *ln);
520 let label = match &path {
521 Ok(p) => p
522 .file_name()
523 .unwrap_or_default()
524 .to_string_lossy()
525 .to_string(),
526 Err(_) => format!("log {ln:03} (file not found)"),
527 };
528 println!(" {label}");
529 }
530}
531
532#[cfg(feature = "llm")]
534fn print_extract_summary(totals: &ExtractionTotals) {
535 println!(
536 "\n{GREEN}✓{RESET} Done: {} archives — +{} created, ~{} merged, -{} skipped, {} relationships",
537 totals.processed,
538 totals.entities_created,
539 totals.entities_merged,
540 totals.entities_skipped,
541 totals.relationships,
542 );
543 println!(
544 " Estimated tokens: ~{}",
545 format_tokens(totals.estimated_tokens)
546 );
547 let dedup_total = totals.dedup_llm_calls + totals.dedup_fast_path;
548 if dedup_total > 0 {
549 println!(
550 " Dedup: {} of {} candidates needed a model call ({} resolved locally)",
551 totals.dedup_llm_calls, dedup_total, totals.dedup_fast_path
552 );
553 }
554
555 if !totals.quarantined.is_empty() {
556 println!(
557 " {YELLOW}Quarantined: {} archives{RESET}",
558 totals.quarantined.len()
559 );
560 }
561
562 if !totals.errors.is_empty() {
563 println!("\n{YELLOW}Warnings ({}):{RESET}", totals.errors.len());
564 for err in totals.errors.iter().take(10) {
565 println!(" {DIM}{err}{RESET}");
566 }
567 if totals.errors.len() > 10 {
568 println!(" {DIM}... and {} more{RESET}", totals.errors.len() - 10);
569 }
570 }
571}
572
573#[cfg(feature = "llm")]
575fn format_tokens(tokens: u64) -> String {
576 if tokens >= 1_000_000 {
577 format!("{:.1}M", tokens as f64 / 1_000_000.0)
578 } else if tokens >= 1_000 {
579 format!("{:.0}K", tokens as f64 / 1_000.0)
580 } else {
581 tokens.to_string()
582 }
583}
584
585#[cfg(feature = "llm")]
587#[allow(clippy::too_many_arguments)]
588pub async fn extract(
589 memory_dir: &Path,
590 log: Option<u32>,
591 all: bool,
592 dry_run: bool,
593 model_override: Option<String>,
594 provider_override: Option<String>,
595 delay_ms: u64,
596 max_tokens: u64,
597) -> Result<(), RecallError> {
598 let graph_dir = memory_dir.join("graph");
599 if !graph_dir.exists() {
600 return Err(RecallError::NotInitialized(
601 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
602 ));
603 }
604
605 serve_client::exclusive(memory_dir, |gm| async move {
606 let log_numbers: Vec<u32> = if let Some(ln) = log {
608 vec![ln]
609 } else if all {
610 gm.unextracted_log_numbers()
611 .await?
612 .into_iter()
613 .map(|n| n as u32)
614 .collect()
615 } else {
616 return Err(RecallError::Other("Specify --log <N> or --all".into()));
617 };
618
619 if log_numbers.is_empty() {
620 println!("{YELLOW}No unextracted archives found.{RESET}");
621 return Ok(());
622 }
623
624 let conversations_dir = find_conversations_dir(memory_dir)?;
625
626 if dry_run {
627 print_extract_dry_run(&conversations_dir, &log_numbers);
628 return Ok(());
629 }
630
631 let (llm, model_name) = crate::llm_provider::create_provider(
633 memory_dir,
634 provider_override.as_deref(),
635 model_override.as_deref(),
636 )?;
637
638 let total_count = log_numbers.len();
639 let budget_label = if max_tokens > 0 {
640 format!(" (budget: {})", format_tokens(max_tokens))
641 } else {
642 String::new()
643 };
644 let model_label = if model_name.is_empty() {
647 "the provider default"
648 } else {
649 &model_name
650 };
651 println!(
652 "{BOLD}Extracting entities from {total_count} archives using {model_label}{budget_label}{RESET}",
653 );
654
655 let quarantine_path = graph_dir.join("extraction-quarantine.txt");
656 let mut totals = ExtractionTotals::default();
657
658 for (idx, ln) in log_numbers.iter().enumerate() {
659 if max_tokens > 0 && totals.estimated_tokens >= max_tokens {
661 println!(
662 "\n{YELLOW}⚠ Token budget exhausted (~{} / {}). Stopping.{RESET}",
663 format_tokens(totals.estimated_tokens),
664 format_tokens(max_tokens),
665 );
666 println!(" Re-run to continue — resume is automatic via unextracted log numbers.");
667 break;
668 }
669
670 let archive_path = match find_archive_file(&conversations_dir, *ln) {
671 Ok(p) => p,
672 Err(e) => {
673 println!(
674 " {YELLOW}⚠{RESET} [{}/{}] log {ln:03}: {e}",
675 idx + 1,
676 total_count
677 );
678 totals.errors.push(format!("log {ln:03}: {e}"));
679 continue;
680 }
681 };
682
683 let content = std::fs::read_to_string(&archive_path)?;
684 let (session_id, _) = extract_archive_metadata(&content, &archive_path);
685 let context = IngestContext::new(session_id, Some(*ln));
686
687 let report = match gm.extract_from_archive(&content, &context, &*llm).await {
689 Ok(r) => r,
690 Err(e) => {
691 println!(
692 " {YELLOW}⚠{RESET} [{}/{}] log {ln:03}: failed, retrying... ({e})",
693 idx + 1,
694 total_count
695 );
696 match gm.extract_from_archive(&content, &context, &*llm).await {
697 Ok(r) => r,
698 Err(e2) => {
699 println!(
700 " {YELLOW}✗{RESET} [{}/{}] log {ln:03}: quarantined ({e2})",
701 idx + 1,
702 total_count
703 );
704 totals.quarantined.push(*ln);
705 totals
706 .errors
707 .push(format!("log {ln:03}: quarantined after retry: {e2}"));
708 continue;
709 }
710 }
711 }
712 };
713
714 println!(
715 " {GREEN}✓{RESET} [{}/{}] log {ln:03}: +{} entities, ~{} merged, -{} skipped, {} rels (~{})",
716 idx + 1,
717 total_count,
718 report.entities_created,
719 report.entities_merged,
720 report.entities_skipped,
721 report.relationships_created,
722 format_tokens(report.estimated_tokens),
723 );
724
725 gm.mark_extracted(*ln).await?;
726
727 totals.entities_created += report.entities_created;
728 totals.entities_merged += report.entities_merged;
729 totals.entities_skipped += report.entities_skipped;
730 totals.relationships += report.relationships_created;
731 totals.errors.extend(report.errors);
732 totals.processed += 1;
733 totals.estimated_tokens += report.estimated_tokens;
734 totals.dedup_llm_calls += report.dedup_llm_calls;
735 totals.dedup_fast_path += report.dedup_fast_path;
736
737 if delay_ms > 0 && *ln != *log_numbers.last().unwrap() {
739 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
740 }
741 }
742
743 if !totals.quarantined.is_empty() {
745 use std::io::Write;
746 let mut file = std::fs::OpenOptions::new()
747 .create(true)
748 .append(true)
749 .open(&quarantine_path)?;
750 for ln in &totals.quarantined {
751 writeln!(file, "{ln:03}")?;
752 }
753 println!(
754 "\n {YELLOW}Quarantined {} archives → {}{RESET}",
755 totals.quarantined.len(),
756 quarantine_path.display()
757 );
758 }
759
760 print_extract_summary(&totals);
761 Ok(())
762 })
763 .await
764}
765
766pub async fn vigil_sync(
770 memory_dir: &Path,
771 signals_path: Option<&Path>,
772 outcomes_path: Option<&Path>,
773) -> Result<(), RecallError> {
774 let graph_dir = memory_dir.join("graph");
775 if !graph_dir.exists() {
776 return Err(RecallError::NotInitialized(
777 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
778 ));
779 }
780
781 let entity_root = memory_dir.parent().unwrap_or(memory_dir);
783
784 let default_signals = entity_root.join("vigil").join("signals.json");
785 let default_outcomes = entity_root.join("caliber").join("outcomes.json");
786
787 let sig_path = signals_path.unwrap_or(&default_signals);
788 let out_path = outcomes_path.unwrap_or(&default_outcomes);
789
790 serve_client::exclusive(memory_dir, |gm| async move {
791 let report = gm.sync_vigil(sig_path, out_path).await?;
792
793 println!("{BOLD}Vigil Sync{RESET}");
794 println!(" Measurements: +{}", report.measurements_created);
795 println!(" Outcomes: +{}", report.outcomes_created);
796 println!(" Relationships: +{}", report.relationships_created);
797 println!(" Skipped: {}", report.skipped);
798
799 if !report.errors.is_empty() {
800 println!("\n {YELLOW}Warnings:{RESET}");
801 for err in &report.errors {
802 println!(" {DIM}{err}{RESET}");
803 }
804 }
805
806 if report.measurements_created == 0 && report.outcomes_created == 0 {
807 println!("\n {DIM}No new data — graph is in sync.{RESET}");
808 }
809
810 Ok(())
811 })
812 .await
813}
814
815pub async fn pipeline_sync(
819 memory_dir: &Path,
820 docs_dir_override: Option<&Path>,
821) -> Result<(), RecallError> {
822 let graph_dir = memory_dir.join("graph");
823 if !graph_dir.exists() {
824 return Err(RecallError::NotInitialized(
825 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
826 ));
827 }
828
829 let docs_dir = if let Some(d) = docs_dir_override {
831 d.to_path_buf()
832 } else {
833 let cfg = crate::config::load_from_dir(memory_dir);
834 match cfg.pipeline.and_then(|p| p.docs_dir) {
835 Some(d) => {
836 let path = PathBuf::from(shellexpand(&d));
837 if !path.exists() {
838 return Err(RecallError::Config(format!(
839 "Configured docs_dir does not exist: {}",
840 path.display()
841 )));
842 }
843 path
844 }
845 None => {
846 return Err(
847 "No docs directory specified. Use --docs-dir or set [pipeline] docs_dir in config.".into(),
848 );
849 }
850 }
851 };
852
853 let docs = read_pipeline_docs(&docs_dir)?;
855
856 let report = crate::graph_bridge::sync_pipeline_into_graph(memory_dir, docs).await?;
857
858 println!("{BOLD}Pipeline Sync{RESET}");
859 println!(" Created: {}", report.entities_created);
860 println!(" Updated: {}", report.entities_updated);
861 println!(" Archived: {}", report.entities_archived);
862 println!(
863 " Relationships: +{} / ~{} skipped",
864 report.relationships_created, report.relationships_skipped
865 );
866
867 if !report.errors.is_empty() {
868 println!("\n {YELLOW}Warnings:{RESET}");
869 for err in &report.errors {
870 println!(" {DIM}{err}{RESET}");
871 }
872 }
873
874 if report.entities_created == 0 && report.entities_updated == 0 && report.entities_archived == 0
875 {
876 println!("\n {DIM}No changes — graph is in sync.{RESET}");
877 }
878
879 Ok(())
880}
881
882pub async fn pipeline_status(memory_dir: &Path, staleness_days: u32) -> Result<(), RecallError> {
884 let graph_dir = memory_dir.join("graph");
885 if !graph_dir.exists() {
886 return Err(RecallError::NotInitialized(
887 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
888 ));
889 }
890
891 serve_client::exclusive(memory_dir, |gm| async move {
892 let stats = gm.pipeline_stats(staleness_days).await?;
893
894 println!(
895 "{BOLD}Pipeline Status{RESET} ({} entities)",
896 stats.total_entities
897 );
898
899 if stats.by_stage.is_empty() {
900 println!(
901 " {DIM}No pipeline entities in graph. Run `graph pipeline sync` first.{RESET}"
902 );
903 return Ok(());
904 }
905
906 let stage_order = ["learning", "thoughts", "curiosity", "reflections", "praxis"];
908 for stage in &stage_order {
909 if let Some(statuses) = stats.by_stage.get(*stage) {
910 println!("\n {CYAN}{}{RESET}", stage.to_uppercase());
911 let mut items: Vec<_> = statuses.iter().collect();
912 items.sort_by_key(|(s, _)| (*s).clone());
913 for (status, count) in items {
914 println!(" {status}: {count}");
915 }
916 }
917 }
918
919 if !stats.stale_thoughts.is_empty() {
920 println!("\n {YELLOW}Stale thoughts (>{staleness_days}d):{RESET}");
921 for entity in &stats.stale_thoughts {
922 println!(" {DIM}•{RESET} {}", entity.name);
923 }
924 }
925
926 if !stats.stale_questions.is_empty() {
927 println!(
928 "\n {YELLOW}Stale questions (>{}d):{RESET}",
929 staleness_days * 2
930 );
931 for entity in &stats.stale_questions {
932 println!(" {DIM}•{RESET} {}", entity.name);
933 }
934 }
935
936 if let Some(ref last) = stats.last_movement {
937 println!("\n {DIM}Last movement: {last}{RESET}");
938 }
939
940 Ok(())
941 })
942 .await
943}
944
945pub async fn pipeline_flow(memory_dir: &Path, entity_name: &str) -> Result<(), RecallError> {
947 let graph_dir = memory_dir.join("graph");
948 if !graph_dir.exists() {
949 return Err(RecallError::NotInitialized(
950 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
951 ));
952 }
953
954 serve_client::exclusive(memory_dir, |gm| async move {
955 let chain = gm.pipeline_flow(entity_name).await?;
956
957 if chain.is_empty() {
958 println!("{YELLOW}No pipeline relationships found for \"{entity_name}\".{RESET}");
959 return Ok(());
960 }
961
962 println!("{BOLD}Pipeline Flow: {entity_name}{RESET}\n");
963 for (source, rel_type, target) in &chain {
964 println!(
965 " {} ({}) {CYAN}—[{rel_type}]→{RESET} {} ({})",
966 source.name, source.entity_type, target.name, target.entity_type
967 );
968 }
969
970 Ok(())
971 })
972 .await
973}
974
975pub async fn pipeline_stale(memory_dir: &Path, staleness_days: u32) -> Result<(), RecallError> {
977 let graph_dir = memory_dir.join("graph");
978 if !graph_dir.exists() {
979 return Err(RecallError::NotInitialized(
980 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
981 ));
982 }
983
984 serve_client::exclusive(memory_dir, |gm| async move {
985 let stats = gm.pipeline_stats(staleness_days).await?;
986
987 let total_stale = stats.stale_thoughts.len() + stats.stale_questions.len();
988 if total_stale == 0 {
989 println!("{GREEN}✓{RESET} No stale pipeline entities.");
990 return Ok(());
991 }
992
993 println!("{BOLD}Stale Pipeline Entities{RESET}\n");
994
995 if !stats.stale_thoughts.is_empty() {
996 println!(" {YELLOW}Thoughts (>{staleness_days} days):{RESET}");
997 for entity in &stats.stale_thoughts {
998 println!(" • {} {DIM}({}){RESET}", entity.name, entity.entity_type);
999 }
1000 }
1001
1002 if !stats.stale_questions.is_empty() {
1003 println!(" {YELLOW}Questions (>{} days):{RESET}", staleness_days * 2);
1004 for entity in &stats.stale_questions {
1005 println!(" • {} {DIM}({}){RESET}", entity.name, entity.entity_type);
1006 }
1007 }
1008
1009 Ok(())
1010 })
1011 .await
1012}
1013
1014fn read_pipeline_docs(dir: &Path) -> Result<PipelineDocuments, RecallError> {
1016 let read_or_empty = |name: &str| -> String {
1017 let path = dir.join(name);
1018 std::fs::read_to_string(&path).unwrap_or_default()
1019 };
1020
1021 Ok(PipelineDocuments {
1022 learning: read_or_empty("LEARNING.md"),
1023 thoughts: read_or_empty("THOUGHTS.md"),
1024 curiosity: read_or_empty("CURIOSITY.md"),
1025 reflections: read_or_empty("REFLECTIONS.md"),
1026 praxis: read_or_empty("PRAXIS.md"),
1027 })
1028}
1029
1030fn shellexpand(path: &str) -> String {
1032 if let Some(rest) = path.strip_prefix("~/") {
1033 if let Some(home) = dirs::home_dir() {
1034 return home.join(rest).to_string_lossy().to_string();
1035 }
1036 }
1037 path.to_string()
1038}
1039
1040pub(crate) fn find_conversations_dir(memory_dir: &Path) -> Result<PathBuf, RecallError> {
1042 let conv = memory_dir.join("conversations");
1043 if conv.exists() {
1044 return Ok(conv);
1045 }
1046 if let Some(parent) = memory_dir.parent() {
1047 let parent_conv = parent.join("conversations");
1048 if parent_conv.exists() {
1049 return Ok(parent_conv);
1050 }
1051 }
1052 Err(RecallError::NotInitialized(
1053 "conversations/ directory not found".into(),
1054 ))
1055}
1056
1057#[derive(Debug, Clone)]
1062pub struct GcOptions {
1063 pub execute: bool,
1065 pub stale_days: u64,
1066 pub stale_confidence: f64,
1067 pub dead_confidence: f64,
1068 pub dead_min_age_days: u64,
1069 pub episodes: bool,
1071 pub episode_max_age_days: u64,
1072 pub stats_only: bool,
1074}
1075
1076impl Default for GcOptions {
1077 fn default() -> Self {
1078 let defaults = crate::graph::gc::GcConfig::default();
1079 Self {
1080 execute: false,
1081 stale_days: defaults.stale_days,
1082 stale_confidence: defaults.stale_confidence,
1083 dead_confidence: defaults.dead_confidence,
1084 dead_min_age_days: defaults.dead_min_age_days,
1085 episodes: false,
1086 episode_max_age_days: defaults.episode_max_age_days,
1087 stats_only: false,
1088 }
1089 }
1090}
1091
1092impl GcOptions {
1093 fn to_config(&self) -> crate::graph::gc::GcConfig {
1094 crate::graph::gc::GcConfig {
1095 stale_days: self.stale_days,
1096 stale_confidence: self.stale_confidence,
1097 dead_confidence: self.dead_confidence,
1098 dead_min_age_days: self.dead_min_age_days,
1099 collect_episodes: self.episodes,
1100 episode_max_age_days: self.episode_max_age_days,
1101 dry_run: !self.execute,
1102 protect_pipeline: true,
1103 }
1104 }
1105}
1106
1107pub async fn gc(memory_dir: &Path, options: &GcOptions) -> Result<(), RecallError> {
1109 use crate::graph::gc::GcActionKind;
1110
1111 let stats_only = options.stats_only;
1112 let config = options.to_config();
1113
1114 let graph_dir = memory_dir.join("graph");
1115 if !graph_dir.exists() {
1116 return Err(RecallError::NotInitialized(
1117 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
1118 ));
1119 }
1120
1121 serve_client::exclusive(memory_dir, |gm| async move {
1122 if stats_only {
1123 let stats = gm.gc_stats().await?;
1124 println!("{BOLD}Graph Health{RESET}");
1125 println!(" Entities: {}", stats.total_entities);
1126 println!(" Relationships: {}", stats.total_relationships);
1127 println!(
1128 " Pipeline entities: {} {DIM}(protected){RESET}",
1129 stats.pipeline_entities
1130 );
1131 println!(" Zero-access entities: {}", stats.zero_access_entities);
1132 println!(
1133 " Low confidence rels: {} {DIM}(< 0.5){RESET}",
1134 stats.low_confidence_rels
1135 );
1136 println!(
1137 " Very low conf. rels: {} {DIM}(< 0.2){RESET}",
1138 stats.very_low_confidence_rels
1139 );
1140 println!(" Superseded rels: {}", stats.superseded_rels);
1141 return Ok(());
1142 }
1143
1144 let report = gm.run_gc(&config).await?;
1145
1146 if report.dry_run {
1148 println!(
1149 "{BOLD}{YELLOW}GC Dry Run{RESET} {DIM}(pass --execute to actually delete){RESET}"
1150 );
1151 } else {
1152 println!("{BOLD}{GREEN}GC Executed{RESET}");
1153 }
1154
1155 println!("\n{BOLD}Scan{RESET}");
1156 println!(" Entities scanned: {}", report.entities_scanned);
1157 println!(" Relationships scanned: {}", report.relationships_scanned);
1158 if config.collect_episodes {
1159 println!(" Episodes scanned: {}", report.episodes_scanned);
1160 }
1161
1162 println!("\n{BOLD}Results{RESET}");
1163 println!(" Stale relationships: {}", report.stale_relationships);
1164 println!(" Dead relationships: {}", report.dead_relationships);
1165 println!(" Orphaned entities: {}", report.orphaned_entities);
1166 if config.collect_episodes {
1167 println!(" Spent episodes: {}", report.spent_episodes);
1168 }
1169
1170 let verb = if report.dry_run {
1171 "would remove"
1172 } else {
1173 "removed"
1174 };
1175 println!(" Total {verb}: {}", report.total_removed);
1176
1177 if !report.actions.is_empty() {
1179 println!("\n{BOLD}Actions{RESET}");
1180 for action in &report.actions {
1181 let icon = match action.kind {
1182 GcActionKind::StaleRelationship => format!("{YELLOW}⚠{RESET}"),
1183 GcActionKind::DeadRelationship => format!("{YELLOW}✗{RESET}"),
1184 GcActionKind::OrphanedEntity => format!("{CYAN}○{RESET}"),
1185 GcActionKind::SpentEpisode => format!("{CYAN}◌{RESET}"),
1186 };
1187 println!(
1188 " {icon} [{kind}] {name}",
1189 kind = action.kind,
1190 name = action.target_name,
1191 );
1192 println!(" {DIM}{reason}{RESET}", reason = action.reason);
1193 }
1194 }
1195
1196 if !report.errors.is_empty() {
1197 println!("\n{BOLD}Errors{RESET}");
1198 for err in &report.errors {
1199 println!(" \x1b[31m✗\x1b[0m {err}");
1200 }
1201 }
1202
1203 Ok(())
1204 })
1205 .await
1206}
1207
1208pub async fn feedback(
1213 memory_dir: &Path,
1214 session_id: &str,
1215 outcome: &str,
1216) -> Result<(), RecallError> {
1217 use crate::graph::utility::OutcomeKind;
1218 use crate::serve::FeedbackArgs;
1219
1220 let outcome: OutcomeKind = outcome.parse().map_err(RecallError::Other)?;
1221
1222 let request = Request::Feedback(FeedbackArgs {
1223 session_id: session_id.to_string(),
1224 outcome,
1225 });
1226 let report: crate::graph::utility::FeedbackReport =
1227 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
1228
1229 if report.entities_updated == 0 && report.utilities.is_empty() {
1230 println!(
1231 "{YELLOW}No entities recorded for session {session_id}.{RESET} \
1232 {DIM}Nothing to apply the outcome to.{RESET}"
1233 );
1234 return Ok(());
1235 }
1236
1237 println!(
1238 "{GREEN}✓{RESET} Session {BOLD}{session_id}{RESET} recorded as {BOLD}{outcome}{RESET} \
1239 — {} entities updated",
1240 report.entities_updated
1241 );
1242
1243 for entity in &report.utilities {
1244 println!(
1245 " {DIM}{}{RESET} utility {CYAN}{:.3}{RESET}",
1246 entity.entity_id, entity.utility_score
1247 );
1248 }
1249
1250 if !report.errors.is_empty() {
1251 println!("\n{YELLOW}Warnings:{RESET}");
1252 for err in &report.errors {
1253 println!(" {DIM}{err}{RESET}");
1254 }
1255 }
1256
1257 Ok(())
1258}
1259
1260pub async fn decay_report(
1262 memory_dir: &Path,
1263 entity_name: Option<&str>,
1264 show_all: bool,
1265) -> Result<(), RecallError> {
1266 use crate::graph::confidence;
1267 use crate::graph::types::Direction;
1268
1269 let graph_dir = memory_dir.join("graph");
1270 if !graph_dir.exists() {
1271 return Err(RecallError::NotInitialized(
1272 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
1273 ));
1274 }
1275
1276 serve_client::exclusive(memory_dir, |gm| async move {
1277 let now = chrono::Utc::now();
1278
1279 let rels = if let Some(name) = entity_name {
1280 gm.get_relationships(name, Direction::Both).await?
1281 } else {
1282 crate::graph::crud::list_all_relationships(gm.db()).await?
1283 };
1284
1285 if rels.is_empty() {
1286 println!("{YELLOW}No relationships found.{RESET}");
1287 return Ok(());
1288 }
1289
1290 println!(
1291 "{BOLD}Decay Report{RESET} ({} relationships, half-life: {} days)\n",
1292 rels.len(),
1293 confidence::DEFAULT_HALF_LIFE_DAYS
1294 );
1295
1296 let mut decayed_count = 0u32;
1297 let mut total_decay = 0.0_f64;
1298
1299 for rel in &rels {
1300 let effective = confidence::effective_confidence(
1301 rel.confidence,
1302 rel.last_reinforced.as_ref(),
1303 &rel.valid_from,
1304 &now,
1305 );
1306
1307 let decay_amount = rel.confidence - effective;
1308 if decay_amount > 0.001 {
1309 decayed_count += 1;
1310 }
1311 total_decay += decay_amount;
1312
1313 if !show_all && decay_amount < 0.001 {
1314 continue;
1315 }
1316
1317 let from_short = match &rel.from_id {
1318 serde_json::Value::String(s) => s.split(':').next_back().unwrap_or(s).to_string(),
1319 other => other.to_string(),
1320 };
1321 let to_short = match &rel.to_id {
1322 serde_json::Value::String(s) => s.split(':').next_back().unwrap_or(s).to_string(),
1323 other => other.to_string(),
1324 };
1325
1326 let reinforced_tag = match &rel.last_reinforced {
1327 Some(serde_json::Value::String(s)) => format!(" {DIM}(reinforced: {s}){RESET}"),
1328 _ => String::new(),
1329 };
1330
1331 let decay_indicator = if decay_amount > 0.2 {
1332 format!("\x1b[31m↓{:.0}%\x1b[0m", decay_amount * 100.0)
1333 } else if decay_amount > 0.05 {
1334 format!("{YELLOW}↓{:.0}%{RESET}", decay_amount * 100.0)
1335 } else {
1336 format!("{DIM}≈{RESET}")
1337 };
1338
1339 let edge_evidence = rel.edge_evidence();
1342 let coherence = edge_evidence.self_reinforcements();
1343 let evidence = rel.evidence();
1344 let evidence_tag = format!(
1345 " {DIM}[n={:.1} ±{:.2}{}]{RESET}",
1346 evidence.concentration(),
1347 evidence.variance().sqrt(),
1348 if coherence > 0 {
1349 format!(", self×{coherence}")
1350 } else {
1351 String::new()
1352 }
1353 );
1354
1355 println!(
1356 " {from_short} {CYAN}—[{}]→{RESET} {to_short} stored:{:.2} effective:{:.2} {decay_indicator}{evidence_tag}{reinforced_tag}",
1357 rel.rel_type, rel.confidence, effective,
1358 );
1359 }
1360
1361 println!(
1362 "\n{BOLD}Summary{RESET}: {decayed_count}/{} relationships decayed, avg decay: {:.3}",
1363 rels.len(),
1364 if rels.is_empty() {
1365 0.0
1366 } else {
1367 total_decay / rels.len() as f64
1368 }
1369 );
1370
1371 Ok(())
1372 })
1373 .await
1374}
1375
1376#[cfg(feature = "llm")]
1378pub(crate) fn find_archive_file(
1379 conversations_dir: &Path,
1380 log_number: u32,
1381) -> Result<PathBuf, RecallError> {
1382 let patterns = [
1384 format!("conversation-{log_number:03}.md"),
1385 format!("conversation-{log_number}.md"),
1386 format!("archive-log-{log_number:03}.md"),
1387 format!("archive-log-{log_number}.md"),
1388 ];
1389
1390 for name in &patterns {
1391 let path = conversations_dir.join(name);
1392 if path.exists() {
1393 return Ok(path);
1394 }
1395 }
1396
1397 Err(RecallError::Other(format!(
1398 "no archive file for log {log_number:03}",
1399 )))
1400}