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