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