1use std::path::{Path, PathBuf};
8
9use crate::error::RecallError;
10use crate::graph::correct::{CorrectTarget, Correction, CorrectionReport, EdgeCorrection, Removal};
11use crate::graph::edge_view::EdgeView;
12use crate::graph::traverse::format_traversal;
13use crate::graph::types::*;
14use crate::graph::{IngestContext, Provenance};
15use crate::serve::{
16 AddEntityArgs, CorrectArgs, IngestArchiveArgs, QueryArgs, RelateArgs, Request, SearchArgs,
17 TraverseArgs,
18};
19use crate::serve_client;
20
21const GREEN: &str = "\x1b[32m";
22const CYAN: &str = "\x1b[36m";
23const YELLOW: &str = "\x1b[33m";
24const BOLD: &str = "\x1b[1m";
25const DIM: &str = "\x1b[2m";
26const RESET: &str = "\x1b[0m";
27
28pub async fn init(memory_dir: &Path) -> Result<(), RecallError> {
30 let graph_dir = memory_dir.join("graph");
31 serve_client::exclusive(memory_dir, |_graph| async { Ok(()) }).await?;
32 println!(
33 "{GREEN}✓{RESET} Graph store initialized at {}",
34 graph_dir.display()
35 );
36 Ok(())
37}
38
39pub async fn graph_status(memory_dir: &Path) -> Result<(), RecallError> {
41 let graph_dir = memory_dir.join("graph");
42 if !graph_dir.exists() {
43 return Err(RecallError::NotInitialized(
44 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
45 ));
46 }
47 let data = serve_client::execute(memory_dir, &Request::Status).await?;
48 let stats: GraphStats = serde_json::from_value(data)?;
49
50 println!("{BOLD}Graph Memory Status{RESET}");
51 println!(" Entities: {}", stats.entity_count);
52 println!(" Relationships: {}", stats.relationship_count);
53 println!(" Episodes: {}", stats.episode_count);
54
55 if stats.episode_count > 0 && stats.entity_count == 0 {
60 let extraction = crate::config::load_from_dir(memory_dir).extraction;
61 println!(
62 "\n {YELLOW}No entities yet.{RESET} Episodes are ingested automatically; turning"
63 );
64 println!(" them into entities is a separate LLM pass.");
65 if extraction.background_enabled {
66 println!(
67 " The daemon runs it after {}s of quiet. To run it now:",
68 extraction.idle_after_secs
69 );
70 }
71 println!(" {DIM}recall-echo graph extract --all{RESET}");
72 }
73
74 if !stats.entity_type_counts.is_empty() {
75 println!("\n {DIM}By type:{RESET}");
76 let mut types: Vec<_> = stats.entity_type_counts.iter().collect();
77 types.sort_by(|a, b| b.1.cmp(a.1));
78 for (t, count) in types {
79 println!(" {t}: {count}");
80 }
81 }
82
83 print_daemon_line(memory_dir).await;
84 Ok(())
85}
86
87async fn print_daemon_line(memory_dir: &Path) {
89 println!();
90 match serve_client::daemon_info(memory_dir).await {
91 Ok(Some(info)) => println!(
92 " {DIM}Daemon:{RESET} running — pid {}, v{}, up {}s",
93 info.pid, info.version, info.uptime_secs
94 ),
95 Ok(None) if serve_client::graph_mode(memory_dir) == "server" => {
96 println!(" {DIM}Daemon:{RESET} not used — [graph] mode = server");
97 }
98 Ok(None) => println!(" {DIM}Daemon:{RESET} not running"),
99 Err(e) => println!(" {DIM}Daemon:{RESET} unknown ({e})"),
100 }
101}
102
103pub async fn daemon_status(memory_dir: &Path) -> Result<(), RecallError> {
105 println!("{BOLD}Graph Daemon{RESET}");
106 println!(
107 " Socket: {}",
108 serve_client::socket_path(memory_dir)?.display()
109 );
110 match serve_client::daemon_info(memory_dir).await? {
111 Some(info) => {
112 println!(" State: {GREEN}running{RESET}");
113 println!(" Pid: {}", info.pid);
114 println!(" Version: {}", info.version);
115 println!(" Uptime: {}s", info.uptime_secs);
116 print_extraction_lines(&info.extraction);
117 }
118 None if serve_client::graph_mode(memory_dir) == "server" => {
119 println!(" State: not used — [graph] mode = server");
120 }
121 None => println!(" State: {YELLOW}not running{RESET}"),
122 }
123 println!(
124 " Log: {}",
125 serve_client::daemon_log_path(memory_dir).display()
126 );
127 Ok(())
128}
129
130fn print_extraction_lines(status: &crate::serve::ExtractionStatus) {
132 if !status.enabled {
133 let reason = status.disabled_reason.as_deref().unwrap_or("not running");
134 println!(" Extraction: {YELLOW}off{RESET} — {DIM}{reason}{RESET}");
135 return;
136 }
137 match status.last_run_secs_ago {
138 Some(secs) => println!(
139 " Extraction: {GREEN}on{RESET} — {} archives in {} runs, last {}s ago ({}ms)",
140 status.archives,
141 status.runs,
142 secs,
143 status.last_run_ms.unwrap_or(0)
144 ),
145 None => println!(" Extraction: {GREEN}on{RESET} — nothing extracted yet"),
146 }
147 if let Some(error) = &status.last_error {
148 println!(" {DIM}Last extraction error: {error}{RESET}");
149 }
150}
151
152pub async fn daemon_stop(memory_dir: &Path) -> Result<(), RecallError> {
154 if serve_client::stop_daemon(memory_dir).await? {
155 println!("{GREEN}✓{RESET} Graph daemon stopped");
156 } else {
157 println!("{YELLOW}No graph daemon running.{RESET}");
158 }
159 Ok(())
160}
161
162pub async fn add_entity(
164 memory_dir: &Path,
165 name: &str,
166 entity_type: &str,
167 abstract_text: &str,
168 overview: Option<&str>,
169 source: Option<&str>,
170) -> Result<(), RecallError> {
171 let request = Request::AddEntity(AddEntityArgs {
172 name: name.to_string(),
173 entity_type: entity_type.to_string(),
174 abstract_text: abstract_text.to_string(),
175 overview: overview.map(String::from),
176 source: source.map(String::from),
177 });
178 let entity: Entity =
179 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
180
181 println!(
182 "{GREEN}✓{RESET} Created entity: {BOLD}{}{RESET} ({}) [{}]",
183 entity.name,
184 entity.entity_type,
185 entity.id_string()
186 );
187 Ok(())
188}
189
190pub async fn relate(
192 memory_dir: &Path,
193 from: &str,
194 rel_type: &str,
195 to: &str,
196 description: Option<&str>,
197 source: Option<&str>,
198) -> Result<(), RecallError> {
199 let request = Request::Relate(RelateArgs {
200 from: from.to_string(),
201 rel_type: rel_type.to_string(),
202 to: to.to_string(),
203 description: description.map(String::from),
204 source: source.map(String::from),
205 });
206 let rel: Relationship =
207 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
208
209 println!(
210 "{GREEN}✓{RESET} {from} {CYAN}—[{rel_type}]→{RESET} {to} [{}]",
211 rel.id_string()
212 );
213 Ok(())
214}
215
216pub async fn search(
218 memory_dir: &Path,
219 query: &str,
220 limit: usize,
221 entity_type: Option<&str>,
222 keyword: Option<&str>,
223) -> Result<(), RecallError> {
224 let request = Request::Search(SearchArgs {
225 query: query.to_string(),
226 limit,
227 entity_type: entity_type.map(String::from),
228 keyword: keyword.map(String::from),
229 });
230 let results: Vec<ScoredEntity> =
231 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
232
233 if results.is_empty() {
234 println!("{YELLOW}No results.{RESET}");
235 return Ok(());
236 }
237
238 for (i, r) in results.iter().enumerate() {
239 println!(
240 "{BOLD}{}. {}{RESET} ({}) — score: {:.3}",
241 i + 1,
242 r.entity.name,
243 r.entity.entity_type,
244 r.score
245 );
246 println!(" {DIM}{}{RESET}", r.entity.abstract_text);
247 }
248 Ok(())
249}
250
251pub async fn ingest(
257 memory_dir: &Path,
258 archive_path: &Path,
259 provenance: Option<Provenance>,
260) -> Result<(), RecallError> {
261 let graph_dir = memory_dir.join("graph");
262 if !graph_dir.exists() {
263 return Err(RecallError::NotInitialized(
264 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
265 ));
266 }
267
268 let content = std::fs::read_to_string(archive_path)?;
269
270 let (session_id, log_number) = extract_archive_metadata(&content, archive_path);
272
273 let request = Request::IngestArchive(IngestArchiveArgs {
274 content,
275 session_id,
276 log_number,
277 provenance,
278 });
279 let report: IngestionReport =
280 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
281
282 println!(
283 "{GREEN}✓{RESET} Ingested {}: {} episodes created {DIM}(provenance: {}){RESET}",
284 archive_path.display(),
285 report.episodes_created,
286 provenance_label(provenance)
287 );
288 if !report.errors.is_empty() {
289 for err in &report.errors {
290 println!(" {YELLOW}warning:{RESET} {err}");
291 }
292 }
293 Ok(())
294}
295
296fn provenance_label(provenance: Option<Provenance>) -> &'static str {
298 match provenance {
299 Some(Provenance::External) => "external",
300 Some(Provenance::User) => "user",
301 Some(Provenance::SelfGenerated) => "self",
302 None => "per turn role",
303 }
304}
305
306pub async fn ingest_all(
308 memory_dir: &Path,
309 provenance: Option<Provenance>,
310) -> Result<(), RecallError> {
311 let graph_dir = memory_dir.join("graph");
312 if !graph_dir.exists() {
313 return Err(RecallError::NotInitialized(
314 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
315 ));
316 }
317
318 let conversations_dir = find_conversations_dir(memory_dir)?;
319
320 let mut files: Vec<_> = std::fs::read_dir(&conversations_dir)?
322 .filter_map(|e| e.ok())
323 .filter(|e| {
324 let name = e.file_name().to_string_lossy().to_string();
325 name.starts_with("conversation-") || name.starts_with("archive-log-")
326 })
327 .collect();
328 files.sort_by_key(|e| e.file_name());
329
330 if files.is_empty() {
331 println!("{YELLOW}No conversation archives found.{RESET}");
332 return Ok(());
333 }
334
335 serve_client::exclusive(memory_dir, |gm| async move {
336 let mut total_episodes = 0u32;
337 let mut ingested = 0u32;
338 let mut skipped = 0u32;
339
340 for entry in &files {
341 let path = entry.path();
342 let content = std::fs::read_to_string(&path)?;
343
344 let (session_id, log_number) = extract_archive_metadata(&content, &path);
345
346 if let Some(ln) = log_number {
348 if let Ok(Some(_)) = gm.get_episode_by_log_number(ln).await {
349 skipped += 1;
350 continue;
351 }
352 }
353
354 let context = IngestContext::new(session_id, log_number).with_override(provenance);
355 let report = gm.ingest_archive(&content, &context, None).await?;
356
357 total_episodes += report.episodes_created;
358 ingested += 1;
359
360 println!(
361 " {GREEN}✓{RESET} {} — {} episodes",
362 path.file_name().unwrap_or_default().to_string_lossy(),
363 report.episodes_created
364 );
365 }
366
367 println!(
368 "\n{GREEN}✓{RESET} Ingested {ingested} archives ({total_episodes} episodes), skipped {skipped} already ingested {DIM}(provenance: {}){RESET}",
369 provenance_label(provenance)
370 );
371 Ok(())
372 })
373 .await
374}
375
376pub(crate) fn extract_archive_metadata(content: &str, path: &Path) -> (String, Option<u32>) {
378 let mut session_id = "unknown".to_string();
379 let mut log_number: Option<u32> = None;
380
381 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
383 let num_str = name
384 .strip_prefix("conversation-")
385 .or_else(|| name.strip_prefix("archive-log-"));
386 if let Some(num_str) = num_str {
387 if let Ok(n) = num_str.parse::<u32>() {
388 log_number = Some(n);
389 }
390 }
391 }
392
393 if let Some(stripped) = content.strip_prefix("---") {
395 if let Some(end) = stripped.find("---") {
396 let frontmatter = &stripped[..end];
397 for line in frontmatter.lines() {
398 let line = line.trim();
399 if let Some(val) = line.strip_prefix("session_id:") {
400 session_id = val.trim().trim_matches('"').to_string();
401 }
402 }
403 }
404 }
405
406 (session_id, log_number)
407}
408
409pub async fn traverse(
411 memory_dir: &Path,
412 entity_name: &str,
413 depth: u32,
414 type_filter: Option<&str>,
415) -> Result<(), RecallError> {
416 let request = Request::Traverse(TraverseArgs {
417 entity: entity_name.to_string(),
418 depth,
419 type_filter: type_filter.map(String::from),
420 });
421 let tree: TraversalNode =
422 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
423
424 let output = format_traversal(&tree, 0);
425 print!("{output}");
426 Ok(())
427}
428
429pub async fn hybrid_query(
431 memory_dir: &Path,
432 query: &str,
433 limit: usize,
434 entity_type: Option<&str>,
435 keyword: Option<&str>,
436 depth: u32,
437 episodes: bool,
438) -> Result<(), RecallError> {
439 let request = Request::Query(QueryArgs {
440 query: query.to_string(),
441 limit,
442 entity_type: entity_type.map(String::from),
443 keyword: keyword.map(String::from),
444 depth,
445 episodes,
446 });
447 let result: QueryResult =
448 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
449
450 if result.entities.is_empty() && result.episodes.is_empty() {
451 println!("{YELLOW}No results.{RESET}");
452 return Ok(());
453 }
454
455 if !result.entities.is_empty() {
456 println!("{BOLD}Entities:{RESET}");
457 for (i, r) in result.entities.iter().enumerate() {
458 let source_tag = match &r.source {
459 MatchSource::Semantic => "semantic".to_string(),
460 MatchSource::Graph { parent, rel_type } => {
461 format!("graph: {parent} —[{rel_type}]")
462 }
463 MatchSource::Keyword => "keyword".to_string(),
464 };
465 println!(
466 " {BOLD}{}. {}{RESET} ({}) — {:.3} [{DIM}{source_tag}{RESET}]",
467 i + 1,
468 r.entity.name,
469 r.entity.entity_type,
470 r.score
471 );
472 println!(" {DIM}{}{RESET}", r.entity.abstract_text);
473 }
474 }
475
476 if !result.episodes.is_empty() {
477 println!("\n{BOLD}Episodes:{RESET}");
478 for (i, ep) in result.episodes.iter().enumerate() {
479 let log = ep
480 .episode
481 .log_number
482 .map(|n| format!("#{n}"))
483 .unwrap_or_default();
484 println!(
485 " {BOLD}{}. {}{RESET} ({}) — {:.3}",
486 i + 1,
487 ep.episode.session_id,
488 log,
489 ep.score
490 );
491 println!(" {DIM}{}{RESET}", ep.episode.abstract_text);
492 }
493 }
494
495 Ok(())
496}
497
498#[cfg(feature = "llm")]
500#[derive(Default)]
501struct ExtractionTotals {
502 entities_created: u32,
503 entities_merged: u32,
504 entities_skipped: u32,
505 relationships: u32,
506 errors: Vec<String>,
507 processed: u32,
508 estimated_tokens: u64,
509 measured_tokens: u64,
510 quarantined: Vec<u32>,
511 dedup_llm_calls: u32,
512 dedup_fast_path: u32,
513}
514
515#[cfg(feature = "llm")]
517fn print_extract_dry_run(conversations_dir: &Path, log_numbers: &[u32]) {
518 println!(
519 "{BOLD}Dry run — {}{RESET} archives to extract",
520 log_numbers.len()
521 );
522 for ln in log_numbers {
523 let path = find_archive_file(conversations_dir, *ln);
524 let label = match &path {
525 Ok(p) => p
526 .file_name()
527 .unwrap_or_default()
528 .to_string_lossy()
529 .to_string(),
530 Err(_) => format!("log {ln:03} (file not found)"),
531 };
532 println!(" {label}");
533 }
534}
535
536#[cfg(feature = "llm")]
538fn print_extract_summary(totals: &ExtractionTotals) {
539 println!(
540 "\n{GREEN}✓{RESET} Done: {} archives — +{} created, ~{} merged, -{} skipped, {} relationships",
541 totals.processed,
542 totals.entities_created,
543 totals.entities_merged,
544 totals.entities_skipped,
545 totals.relationships,
546 );
547 println!(
548 " Tokens: {}",
549 format_token_bill(totals.measured_tokens, totals.estimated_tokens)
550 );
551 let dedup_total = totals.dedup_llm_calls + totals.dedup_fast_path;
552 if dedup_total > 0 {
553 println!(
554 " Dedup: {} of {} candidates needed a model call ({} resolved locally)",
555 totals.dedup_llm_calls, dedup_total, totals.dedup_fast_path
556 );
557 }
558
559 if !totals.quarantined.is_empty() {
560 println!(
561 " {YELLOW}Quarantined: {} archives{RESET}",
562 totals.quarantined.len()
563 );
564 }
565
566 if !totals.errors.is_empty() {
567 println!("\n{YELLOW}Warnings ({}):{RESET}", totals.errors.len());
568 for err in totals.errors.iter().take(10) {
569 println!(" {DIM}{err}{RESET}");
570 }
571 if totals.errors.len() > 10 {
572 println!(" {DIM}... and {} more{RESET}", totals.errors.len() - 10);
573 }
574 }
575}
576
577#[cfg(feature = "llm")]
584fn format_token_bill(measured: u64, estimated: u64) -> String {
585 match (measured, estimated) {
586 (0, 0) => "0".to_string(),
587 (measured, 0) => format!("{} measured", format_tokens(measured)),
588 (0, estimated) => format!("~{} estimated", format_tokens(estimated)),
589 (measured, estimated) => format!(
590 "{} measured + ~{} estimated",
591 format_tokens(measured),
592 format_tokens(estimated)
593 ),
594 }
595}
596
597#[cfg(feature = "llm")]
599fn format_tokens(tokens: u64) -> String {
600 if tokens >= 1_000_000 {
601 format!("{:.1}M", tokens as f64 / 1_000_000.0)
602 } else if tokens >= 1_000 {
603 format!("{:.0}K", tokens as f64 / 1_000.0)
604 } else {
605 tokens.to_string()
606 }
607}
608
609#[cfg(feature = "llm")]
611#[allow(clippy::too_many_arguments)]
612pub async fn extract(
613 memory_dir: &Path,
614 log: Option<u32>,
615 all: bool,
616 dry_run: bool,
617 model_override: Option<String>,
618 provider_override: Option<String>,
619 delay_ms: u64,
620 max_tokens: u64,
621) -> Result<(), RecallError> {
622 let graph_dir = memory_dir.join("graph");
623 if !graph_dir.exists() {
624 return Err(RecallError::NotInitialized(
625 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
626 ));
627 }
628
629 serve_client::exclusive(memory_dir, |gm| async move {
630 let log_numbers: Vec<u32> = if let Some(ln) = log {
632 vec![ln]
633 } else if all {
634 gm.unextracted_log_numbers()
635 .await?
636 .into_iter()
637 .map(|n| n as u32)
638 .collect()
639 } else {
640 return Err(RecallError::Other("Specify --log <N> or --all".into()));
641 };
642
643 if log_numbers.is_empty() {
644 println!("{YELLOW}No unextracted archives found.{RESET}");
645 return Ok(());
646 }
647
648 let conversations_dir = find_conversations_dir(memory_dir)?;
649
650 if dry_run {
651 print_extract_dry_run(&conversations_dir, &log_numbers);
652 return Ok(());
653 }
654
655 let (llm, model_name) = crate::llm_provider::create_provider(
657 memory_dir,
658 provider_override.as_deref(),
659 model_override.as_deref(),
660 )?;
661
662 let total_count = log_numbers.len();
663 let budget_label = if max_tokens > 0 {
664 format!(" (budget: {})", format_tokens(max_tokens))
665 } else {
666 String::new()
667 };
668 let model_label = if model_name.is_empty() {
671 "the provider default"
672 } else {
673 &model_name
674 };
675 println!(
676 "{BOLD}Extracting entities from {total_count} archives using {model_label}{budget_label}{RESET}",
677 );
678
679 let quarantine_path = graph_dir.join("extraction-quarantine.txt");
680 let mut totals = ExtractionTotals::default();
681
682 for (idx, ln) in log_numbers.iter().enumerate() {
683 let spent = totals.measured_tokens + totals.estimated_tokens;
686 if max_tokens > 0 && spent >= max_tokens {
687 println!(
688 "\n{YELLOW}⚠ Token budget exhausted ({} of {}). Stopping.{RESET}",
689 format_token_bill(totals.measured_tokens, totals.estimated_tokens),
690 format_tokens(max_tokens),
691 );
692 println!(" Re-run to continue — resume is automatic via unextracted log numbers.");
693 break;
694 }
695
696 let archive_path = match find_archive_file(&conversations_dir, *ln) {
697 Ok(p) => p,
698 Err(e) => {
699 println!(
700 " {YELLOW}⚠{RESET} [{}/{}] log {ln:03}: {e}",
701 idx + 1,
702 total_count
703 );
704 totals.errors.push(format!("log {ln:03}: {e}"));
705 continue;
706 }
707 };
708
709 let content = std::fs::read_to_string(&archive_path)?;
710 let (session_id, _) = extract_archive_metadata(&content, &archive_path);
711 let context = IngestContext::new(session_id, Some(*ln));
712
713 let report = match gm.extract_from_archive(&content, &context, &*llm).await {
715 Ok(r) => r,
716 Err(e) => {
717 println!(
718 " {YELLOW}⚠{RESET} [{}/{}] log {ln:03}: failed, retrying... ({e})",
719 idx + 1,
720 total_count
721 );
722 match gm.extract_from_archive(&content, &context, &*llm).await {
723 Ok(r) => r,
724 Err(e2) => {
725 println!(
726 " {YELLOW}✗{RESET} [{}/{}] log {ln:03}: quarantined ({e2})",
727 idx + 1,
728 total_count
729 );
730 totals.quarantined.push(*ln);
731 totals
732 .errors
733 .push(format!("log {ln:03}: quarantined after retry: {e2}"));
734 continue;
735 }
736 }
737 }
738 };
739
740 println!(
741 " {GREEN}✓{RESET} [{}/{}] log {ln:03}: +{} entities, ~{} merged, -{} skipped, {} rels ({})",
742 idx + 1,
743 total_count,
744 report.entities_created,
745 report.entities_merged,
746 report.entities_skipped,
747 report.relationships_created,
748 format_token_bill(report.measured_tokens, report.estimated_tokens),
749 );
750
751 gm.mark_extracted(*ln).await?;
752
753 totals.entities_created += report.entities_created;
754 totals.entities_merged += report.entities_merged;
755 totals.entities_skipped += report.entities_skipped;
756 totals.relationships += report.relationships_created;
757 totals.errors.extend(report.errors);
758 totals.processed += 1;
759 totals.estimated_tokens += report.estimated_tokens;
760 totals.measured_tokens += report.measured_tokens;
761 totals.dedup_llm_calls += report.dedup_llm_calls;
762 totals.dedup_fast_path += report.dedup_fast_path;
763
764 if delay_ms > 0 && *ln != *log_numbers.last().unwrap() {
766 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
767 }
768 }
769
770 if !totals.quarantined.is_empty() {
772 use std::io::Write;
773 let mut file = std::fs::OpenOptions::new()
774 .create(true)
775 .append(true)
776 .open(&quarantine_path)?;
777 for ln in &totals.quarantined {
778 writeln!(file, "{ln:03}")?;
779 }
780 println!(
781 "\n {YELLOW}Quarantined {} archives → {}{RESET}",
782 totals.quarantined.len(),
783 quarantine_path.display()
784 );
785 }
786
787 print_extract_summary(&totals);
788 Ok(())
789 })
790 .await
791}
792
793pub async fn vigil_sync(
797 memory_dir: &Path,
798 signals_path: Option<&Path>,
799 outcomes_path: Option<&Path>,
800) -> 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 let entity_root = memory_dir.parent().unwrap_or(memory_dir);
810
811 let default_signals = entity_root.join("vigil").join("signals.json");
812 let default_outcomes = entity_root.join("caliber").join("outcomes.json");
813
814 let sig_path = signals_path.unwrap_or(&default_signals);
815 let out_path = outcomes_path.unwrap_or(&default_outcomes);
816
817 serve_client::exclusive(memory_dir, |gm| async move {
818 let report = gm.sync_vigil(sig_path, out_path).await?;
819
820 println!("{BOLD}Vigil Sync{RESET}");
821 println!(" Measurements: +{}", report.measurements_created);
822 println!(" Outcomes: +{}", report.outcomes_created);
823 println!(" Relationships: +{}", report.relationships_created);
824 println!(" Skipped: {}", report.skipped);
825
826 if !report.errors.is_empty() {
827 println!("\n {YELLOW}Warnings:{RESET}");
828 for err in &report.errors {
829 println!(" {DIM}{err}{RESET}");
830 }
831 }
832
833 if report.measurements_created == 0 && report.outcomes_created == 0 {
834 println!("\n {DIM}No new data — graph is in sync.{RESET}");
835 }
836
837 Ok(())
838 })
839 .await
840}
841
842pub async fn pipeline_sync(
846 memory_dir: &Path,
847 docs_dir_override: Option<&Path>,
848) -> Result<(), RecallError> {
849 let graph_dir = memory_dir.join("graph");
850 if !graph_dir.exists() {
851 return Err(RecallError::NotInitialized(
852 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
853 ));
854 }
855
856 let docs_dir = if let Some(d) = docs_dir_override {
858 d.to_path_buf()
859 } else {
860 let cfg = crate::config::load_from_dir(memory_dir);
861 match cfg.pipeline.and_then(|p| p.docs_dir) {
862 Some(d) => {
863 let path = PathBuf::from(shellexpand(&d));
864 if !path.exists() {
865 return Err(RecallError::Config(format!(
866 "Configured docs_dir does not exist: {}",
867 path.display()
868 )));
869 }
870 path
871 }
872 None => {
873 return Err(
874 "No docs directory specified. Use --docs-dir or set [pipeline] docs_dir in config.".into(),
875 );
876 }
877 }
878 };
879
880 let docs = read_pipeline_docs(&docs_dir)?;
882
883 let report = crate::graph_bridge::sync_pipeline_into_graph(memory_dir, docs).await?;
884
885 println!("{BOLD}Pipeline Sync{RESET}");
886 println!(" Created: {}", report.entities_created);
887 println!(" Updated: {}", report.entities_updated);
888 println!(" Archived: {}", report.entities_archived);
889 println!(
890 " Relationships: +{} / ~{} skipped",
891 report.relationships_created, report.relationships_skipped
892 );
893
894 if !report.errors.is_empty() {
895 println!("\n {YELLOW}Warnings:{RESET}");
896 for err in &report.errors {
897 println!(" {DIM}{err}{RESET}");
898 }
899 }
900
901 if report.entities_created == 0 && report.entities_updated == 0 && report.entities_archived == 0
902 {
903 println!("\n {DIM}No changes — graph is in sync.{RESET}");
904 }
905
906 Ok(())
907}
908
909pub async fn pipeline_status(memory_dir: &Path, staleness_days: u32) -> Result<(), RecallError> {
911 let graph_dir = memory_dir.join("graph");
912 if !graph_dir.exists() {
913 return Err(RecallError::NotInitialized(
914 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
915 ));
916 }
917
918 serve_client::exclusive(memory_dir, |gm| async move {
919 let stats = gm.pipeline_stats(staleness_days).await?;
920
921 println!(
922 "{BOLD}Pipeline Status{RESET} ({} entities)",
923 stats.total_entities
924 );
925
926 if stats.by_stage.is_empty() {
927 println!(
928 " {DIM}No pipeline entities in graph. Run `graph pipeline sync` first.{RESET}"
929 );
930 return Ok(());
931 }
932
933 let stage_order = ["learning", "thoughts", "curiosity", "reflections", "praxis"];
935 for stage in &stage_order {
936 if let Some(statuses) = stats.by_stage.get(*stage) {
937 println!("\n {CYAN}{}{RESET}", stage.to_uppercase());
938 let mut items: Vec<_> = statuses.iter().collect();
939 items.sort_by_key(|(s, _)| (*s).clone());
940 for (status, count) in items {
941 println!(" {status}: {count}");
942 }
943 }
944 }
945
946 if !stats.stale_thoughts.is_empty() {
947 println!("\n {YELLOW}Stale thoughts (>{staleness_days}d):{RESET}");
948 for entity in &stats.stale_thoughts {
949 println!(" {DIM}•{RESET} {}", entity.name);
950 }
951 }
952
953 if !stats.stale_questions.is_empty() {
954 println!(
955 "\n {YELLOW}Stale questions (>{}d):{RESET}",
956 staleness_days * 2
957 );
958 for entity in &stats.stale_questions {
959 println!(" {DIM}•{RESET} {}", entity.name);
960 }
961 }
962
963 if let Some(ref last) = stats.last_movement {
964 println!("\n {DIM}Last movement: {last}{RESET}");
965 }
966
967 Ok(())
968 })
969 .await
970}
971
972pub async fn pipeline_flow(memory_dir: &Path, entity_name: &str) -> Result<(), RecallError> {
974 let graph_dir = memory_dir.join("graph");
975 if !graph_dir.exists() {
976 return Err(RecallError::NotInitialized(
977 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
978 ));
979 }
980
981 serve_client::exclusive(memory_dir, |gm| async move {
982 let chain = gm.pipeline_flow(entity_name).await?;
983
984 if chain.is_empty() {
985 println!("{YELLOW}No pipeline relationships found for \"{entity_name}\".{RESET}");
986 return Ok(());
987 }
988
989 println!("{BOLD}Pipeline Flow: {entity_name}{RESET}\n");
990 for (source, rel_type, target) in &chain {
991 println!(
992 " {} ({}) {CYAN}—[{rel_type}]→{RESET} {} ({})",
993 source.name, source.entity_type, target.name, target.entity_type
994 );
995 }
996
997 Ok(())
998 })
999 .await
1000}
1001
1002pub async fn pipeline_stale(memory_dir: &Path, staleness_days: u32) -> Result<(), RecallError> {
1004 let graph_dir = memory_dir.join("graph");
1005 if !graph_dir.exists() {
1006 return Err(RecallError::NotInitialized(
1007 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
1008 ));
1009 }
1010
1011 serve_client::exclusive(memory_dir, |gm| async move {
1012 let stats = gm.pipeline_stats(staleness_days).await?;
1013
1014 let total_stale = stats.stale_thoughts.len() + stats.stale_questions.len();
1015 if total_stale == 0 {
1016 println!("{GREEN}✓{RESET} No stale pipeline entities.");
1017 return Ok(());
1018 }
1019
1020 println!("{BOLD}Stale Pipeline Entities{RESET}\n");
1021
1022 if !stats.stale_thoughts.is_empty() {
1023 println!(" {YELLOW}Thoughts (>{staleness_days} days):{RESET}");
1024 for entity in &stats.stale_thoughts {
1025 println!(" • {} {DIM}({}){RESET}", entity.name, entity.entity_type);
1026 }
1027 }
1028
1029 if !stats.stale_questions.is_empty() {
1030 println!(" {YELLOW}Questions (>{} days):{RESET}", staleness_days * 2);
1031 for entity in &stats.stale_questions {
1032 println!(" • {} {DIM}({}){RESET}", entity.name, entity.entity_type);
1033 }
1034 }
1035
1036 Ok(())
1037 })
1038 .await
1039}
1040
1041fn read_pipeline_docs(dir: &Path) -> Result<PipelineDocuments, RecallError> {
1043 let read_or_empty = |name: &str| -> String {
1044 let path = dir.join(name);
1045 std::fs::read_to_string(&path).unwrap_or_default()
1046 };
1047
1048 Ok(PipelineDocuments {
1049 learning: read_or_empty("LEARNING.md"),
1050 thoughts: read_or_empty("THOUGHTS.md"),
1051 curiosity: read_or_empty("CURIOSITY.md"),
1052 reflections: read_or_empty("REFLECTIONS.md"),
1053 praxis: read_or_empty("PRAXIS.md"),
1054 })
1055}
1056
1057fn shellexpand(path: &str) -> String {
1059 if let Some(rest) = path.strip_prefix("~/") {
1060 if let Some(home) = dirs::home_dir() {
1061 return home.join(rest).to_string_lossy().to_string();
1062 }
1063 }
1064 path.to_string()
1065}
1066
1067pub(crate) fn find_conversations_dir(memory_dir: &Path) -> Result<PathBuf, RecallError> {
1069 let conv = memory_dir.join("conversations");
1070 if conv.exists() {
1071 return Ok(conv);
1072 }
1073 if let Some(parent) = memory_dir.parent() {
1074 let parent_conv = parent.join("conversations");
1075 if parent_conv.exists() {
1076 return Ok(parent_conv);
1077 }
1078 }
1079 Err(RecallError::NotInitialized(
1080 "conversations/ directory not found".into(),
1081 ))
1082}
1083
1084#[derive(Debug, Clone)]
1089pub struct GcOptions {
1090 pub execute: bool,
1092 pub stale_days: u64,
1093 pub stale_confidence: f64,
1094 pub dead_confidence: f64,
1095 pub dead_min_age_days: u64,
1096 pub episodes: bool,
1098 pub episode_max_age_days: u64,
1099 pub stats_only: bool,
1101}
1102
1103impl Default for GcOptions {
1104 fn default() -> Self {
1105 let defaults = crate::graph::gc::GcConfig::default();
1106 Self {
1107 execute: false,
1108 stale_days: defaults.stale_days,
1109 stale_confidence: defaults.stale_confidence,
1110 dead_confidence: defaults.dead_confidence,
1111 dead_min_age_days: defaults.dead_min_age_days,
1112 episodes: false,
1113 episode_max_age_days: defaults.episode_max_age_days,
1114 stats_only: false,
1115 }
1116 }
1117}
1118
1119impl GcOptions {
1120 fn to_config(&self) -> crate::graph::gc::GcConfig {
1121 crate::graph::gc::GcConfig {
1122 stale_days: self.stale_days,
1123 stale_confidence: self.stale_confidence,
1124 dead_confidence: self.dead_confidence,
1125 dead_min_age_days: self.dead_min_age_days,
1126 collect_episodes: self.episodes,
1127 episode_max_age_days: self.episode_max_age_days,
1128 dry_run: !self.execute,
1129 protect_pipeline: true,
1130 }
1131 }
1132}
1133
1134pub async fn gc(memory_dir: &Path, options: &GcOptions) -> Result<(), RecallError> {
1136 use crate::graph::gc::GcActionKind;
1137
1138 let stats_only = options.stats_only;
1139 let config = options.to_config();
1140
1141 let graph_dir = memory_dir.join("graph");
1142 if !graph_dir.exists() {
1143 return Err(RecallError::NotInitialized(
1144 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
1145 ));
1146 }
1147
1148 serve_client::exclusive(memory_dir, |gm| async move {
1149 if stats_only {
1150 let stats = gm.gc_stats().await?;
1151 println!("{BOLD}Graph Health{RESET}");
1152 println!(" Entities: {}", stats.total_entities);
1153 println!(" Relationships: {}", stats.total_relationships);
1154 println!(
1155 " Pipeline entities: {} {DIM}(protected){RESET}",
1156 stats.pipeline_entities
1157 );
1158 println!(" Zero-access entities: {}", stats.zero_access_entities);
1159 println!(
1160 " Low confidence rels: {} {DIM}(< 0.5){RESET}",
1161 stats.low_confidence_rels
1162 );
1163 println!(
1164 " Very low conf. rels: {} {DIM}(< 0.2){RESET}",
1165 stats.very_low_confidence_rels
1166 );
1167 println!(" Superseded rels: {}", stats.superseded_rels);
1168 return Ok(());
1169 }
1170
1171 let report = gm.run_gc(&config).await?;
1172
1173 if report.dry_run {
1175 println!(
1176 "{BOLD}{YELLOW}GC Dry Run{RESET} {DIM}(pass --execute to actually delete){RESET}"
1177 );
1178 } else {
1179 println!("{BOLD}{GREEN}GC Executed{RESET}");
1180 }
1181
1182 println!("\n{BOLD}Scan{RESET}");
1183 println!(" Entities scanned: {}", report.entities_scanned);
1184 println!(" Relationships scanned: {}", report.relationships_scanned);
1185 if config.collect_episodes {
1186 println!(" Episodes scanned: {}", report.episodes_scanned);
1187 }
1188
1189 println!("\n{BOLD}Results{RESET}");
1190 println!(" Stale relationships: {}", report.stale_relationships);
1191 println!(" Dead relationships: {}", report.dead_relationships);
1192 println!(" Orphaned entities: {}", report.orphaned_entities);
1193 if config.collect_episodes {
1194 println!(" Spent episodes: {}", report.spent_episodes);
1195 }
1196
1197 let verb = if report.dry_run {
1198 "would remove"
1199 } else {
1200 "removed"
1201 };
1202 println!(" Total {verb}: {}", report.total_removed);
1203
1204 if !report.actions.is_empty() {
1206 println!("\n{BOLD}Actions{RESET}");
1207 for action in &report.actions {
1208 let icon = match action.kind {
1209 GcActionKind::StaleRelationship => format!("{YELLOW}⚠{RESET}"),
1210 GcActionKind::DeadRelationship => format!("{YELLOW}✗{RESET}"),
1211 GcActionKind::OrphanedEntity => format!("{CYAN}○{RESET}"),
1212 GcActionKind::SpentEpisode => format!("{CYAN}◌{RESET}"),
1213 };
1214 println!(
1215 " {icon} [{kind}] {name}",
1216 kind = action.kind,
1217 name = action.target_name,
1218 );
1219 println!(" {DIM}{reason}{RESET}", reason = action.reason);
1220 }
1221 }
1222
1223 if !report.errors.is_empty() {
1224 println!("\n{BOLD}Errors{RESET}");
1225 for err in &report.errors {
1226 println!(" \x1b[31m✗\x1b[0m {err}");
1227 }
1228 }
1229
1230 Ok(())
1231 })
1232 .await
1233}
1234
1235pub async fn feedback(
1240 memory_dir: &Path,
1241 session_id: &str,
1242 outcome: &str,
1243) -> Result<(), RecallError> {
1244 use crate::graph::utility::OutcomeKind;
1245 use crate::serve::FeedbackArgs;
1246
1247 let outcome: OutcomeKind = outcome.parse().map_err(RecallError::Other)?;
1248
1249 let request = Request::Feedback(FeedbackArgs {
1250 session_id: session_id.to_string(),
1251 outcome,
1252 });
1253 let report: crate::graph::utility::FeedbackReport =
1254 serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
1255
1256 if report.entities_updated == 0 && report.utilities.is_empty() {
1257 println!(
1258 "{YELLOW}No entities recorded for session {session_id}.{RESET} \
1259 {DIM}Nothing to apply the outcome to.{RESET}"
1260 );
1261 return Ok(());
1262 }
1263
1264 println!(
1265 "{GREEN}✓{RESET} Session {BOLD}{session_id}{RESET} recorded as {BOLD}{outcome}{RESET} \
1266 — {} entities updated",
1267 report.entities_updated
1268 );
1269
1270 for entity in &report.utilities {
1271 println!(
1272 " {DIM}{}{RESET} utility {CYAN}{:.3}{RESET}",
1273 entity.entity_id, entity.utility_score
1274 );
1275 }
1276
1277 if !report.errors.is_empty() {
1278 println!("\n{YELLOW}Warnings:{RESET}");
1279 for err in &report.errors {
1280 println!(" {DIM}{err}{RESET}");
1281 }
1282 }
1283
1284 Ok(())
1285}
1286
1287#[derive(Debug, Clone)]
1295pub struct CorrectOptions {
1296 pub subject: String,
1298 pub rel_type: Option<String>,
1300 pub object: Option<String>,
1302 pub wrong: bool,
1304 pub forget: bool,
1306 pub all_edges: bool,
1308 pub yes: bool,
1310}
1311
1312impl CorrectOptions {
1313 fn target(&self) -> Result<CorrectTarget, RecallError> {
1315 match (&self.rel_type, &self.object) {
1316 (None, None) => Ok(CorrectTarget::Entity {
1317 name: self.subject.clone(),
1318 }),
1319 (Some(rel_type), Some(object)) => Ok(CorrectTarget::Edge {
1320 from: self.subject.clone(),
1321 rel_type: rel_type.clone(),
1322 to: object.clone(),
1323 }),
1324 _ => Err(RecallError::Other(
1325 "a relationship needs all three names: \
1326 `graph correct <from> <REL> <to> --wrong`"
1327 .into(),
1328 )),
1329 }
1330 }
1331
1332 fn correction(&self) -> Result<Correction, RecallError> {
1334 match (self.wrong, self.forget) {
1335 (true, false) => Ok(Correction::Wrong {
1336 all_edges: self.all_edges,
1337 }),
1338 (false, true) => Ok(Correction::Forget { confirmed: false }),
1339 (true, true) => Err(RecallError::Other(
1340 "--wrong and --forget are different corrections; pass one".into(),
1341 )),
1342 (false, false) => Err(RecallError::Other(
1343 "say what to do: --wrong records that it is mistaken (confidence falls with \
1344 evidence), --forget removes it outright"
1345 .into(),
1346 )),
1347 }
1348 }
1349}
1350
1351pub async fn correct(memory_dir: &Path, options: &CorrectOptions) -> Result<(), RecallError> {
1356 let target = options.target()?;
1357 let report = send_correction(memory_dir, &target, options.correction()?).await?;
1358
1359 match report {
1360 CorrectionReport::Planned { removal } => {
1363 print_removal_plan(&removal);
1364 if !options.yes && !confirm_removal(&removal)? {
1365 println!("{YELLOW}Nothing removed.{RESET}");
1366 return Ok(());
1367 }
1368 let applied =
1369 send_correction(memory_dir, &target, Correction::Forget { confirmed: true })
1370 .await?;
1371 print_correction(&applied);
1375 refusal(&applied)
1376 }
1377 other => {
1378 print_correction(&other);
1379 refusal(&other)
1380 }
1381 }
1382}
1383
1384async fn send_correction(
1385 memory_dir: &Path,
1386 target: &CorrectTarget,
1387 correction: Correction,
1388) -> Result<CorrectionReport, RecallError> {
1389 let request = Request::Correct(CorrectArgs {
1390 target: target.clone(),
1391 correction,
1392 });
1393 Ok(serde_json::from_value(
1394 serve_client::execute(memory_dir, &request).await?,
1395 )?)
1396}
1397
1398fn refusal(report: &CorrectionReport) -> Result<(), RecallError> {
1401 match report {
1402 CorrectionReport::UnknownEntity { query, .. } => Err(RecallError::Other(format!(
1403 "no entity named \"{query}\" — nothing was changed"
1404 ))),
1405 CorrectionReport::NoSuchEdge {
1406 from, rel_type, to, ..
1407 } => Err(RecallError::Other(format!(
1408 "no {rel_type} relationship between \"{from}\" and \"{to}\" — nothing was changed"
1409 ))),
1410 CorrectionReport::Ambiguous { entity, .. } => Err(RecallError::Other(format!(
1411 "\"{entity}\" takes part in several relationships — name the one that is wrong"
1412 ))),
1413 CorrectionReport::NothingToCorrect { entity } => Err(RecallError::Other(format!(
1414 "\"{entity}\" has no relationships to contradict"
1415 ))),
1416 _ => Ok(()),
1417 }
1418}
1419
1420fn print_correction(report: &CorrectionReport) {
1421 match report {
1422 CorrectionReport::UnknownEntity { query, candidates } => {
1423 println!("{YELLOW}No entity named{RESET} {BOLD}{query}{RESET}.");
1424 if candidates.is_empty() {
1425 println!(" {DIM}Nothing stored is close to that name.{RESET}");
1426 } else {
1427 println!("\n {DIM}Closest names in memory:{RESET}");
1428 for candidate in candidates {
1429 println!(
1430 " {BOLD}{}{RESET} {DIM}({}){RESET}",
1431 candidate.name, candidate.entity_type
1432 );
1433 }
1434 }
1435 }
1436 CorrectionReport::NoSuchEdge {
1437 from,
1438 rel_type,
1439 to,
1440 existing,
1441 } => {
1442 println!(
1443 "{YELLOW}Memory holds no{RESET} {BOLD}{rel_type}{RESET} \
1444 {YELLOW}relationship between{RESET} {BOLD}{from}{RESET} \
1445 {YELLOW}and{RESET} {BOLD}{to}{RESET}."
1446 );
1447 if existing.is_empty() {
1448 println!(" {DIM}They are not connected at all.{RESET}");
1449 } else {
1450 println!("\n {DIM}What does connect them:{RESET}");
1451 print_edges(existing);
1452 }
1453 }
1454 CorrectionReport::Ambiguous { entity, edges } => {
1455 println!(
1456 "{BOLD}{entity}{RESET} takes part in {} relationships. Which one is wrong?\n",
1457 edges.len()
1458 );
1459 print_edges(edges);
1460 println!("\n {DIM}Name it:{RESET}");
1461 if let Some(edge) = edges.first() {
1462 println!(
1463 " {DIM}recall-echo graph correct \"{}\" \"{}\" \"{}\" --wrong{RESET}",
1464 edge.from, edge.rel_type, edge.to
1465 );
1466 }
1467 println!(" {DIM}Or contradict every one of them:{RESET}");
1468 println!(" {DIM}recall-echo graph correct \"{entity}\" --wrong --all-edges{RESET}");
1469 }
1470 CorrectionReport::NothingToCorrect { entity } => {
1471 println!(
1472 "{BOLD}{entity}{RESET} is in memory but takes part in no relationships, \
1473 so there is no claim to contradict."
1474 );
1475 println!(
1476 " {DIM}To remove the entity itself: \
1477 recall-echo graph correct \"{entity}\" --forget{RESET}"
1478 );
1479 }
1480 CorrectionReport::Contradicted { edges } => print_contradictions(edges),
1481 CorrectionReport::Planned { removal } => print_removal_plan(removal),
1482 CorrectionReport::Removed { removal } => {
1483 let entity = removal
1484 .entity
1485 .as_ref()
1486 .map(|entity| format!("{BOLD}{}{RESET} and ", entity.name))
1487 .unwrap_or_default();
1488 println!(
1489 "{GREEN}✓{RESET} Removed {entity}{} {}.",
1490 removal.edges.len(),
1491 plural(removal.edges.len(), "relationship", "relationships")
1492 );
1493 }
1494 }
1495}
1496
1497fn print_contradictions(edges: &[EdgeCorrection]) {
1498 println!(
1499 "{GREEN}✓{RESET} Recorded your correction on {} {}.\n",
1500 edges.len(),
1501 plural(edges.len(), "relationship", "relationships")
1502 );
1503 for correction in edges {
1504 let edge = &correction.edge;
1505 println!(
1506 " {} {CYAN}—[{}]→{RESET} {}",
1507 edge.from, edge.rel_type, edge.to
1508 );
1509 println!(
1510 " confidence {YELLOW}{:.2} → {:.2}{RESET} {DIM}evidence {:.1} → {:.1}{RESET}",
1511 correction.confidence_before,
1512 edge.confidence,
1513 correction.evidence_before,
1514 edge.evidence,
1515 );
1516 }
1517 println!(
1518 "\n {DIM}Your correction is evidence, not a decree: confidence falls by the weight of \
1519 one observation you authored. Say it again if memory is still wrong.{RESET}"
1520 );
1521}
1522
1523fn print_removal_plan(removal: &Removal) {
1524 println!("{BOLD}{YELLOW}This would remove:{RESET}\n");
1525 if let Some(entity) = &removal.entity {
1526 println!(
1527 " {BOLD}{}{RESET} {DIM}({}){RESET}",
1528 entity.name, entity.entity_type
1529 );
1530 }
1531 if removal.edges.is_empty() {
1532 println!(" {DIM}and no relationships.{RESET}");
1533 } else {
1534 println!(
1535 " {} {}:",
1536 removal.edges.len(),
1537 plural(removal.edges.len(), "relationship", "relationships")
1538 );
1539 print_edges(&removal.edges);
1540 }
1541 println!(
1542 "\n {DIM}Removal is not evidence — it leaves no trace and cannot be re-weighed. \
1543 Prefer --wrong unless the memory should never have existed.{RESET}"
1544 );
1545}
1546
1547fn print_edges(edges: &[EdgeView]) {
1548 for edge in edges {
1549 let superseded = if edge.superseded {
1550 format!(" {DIM}[superseded]{RESET}")
1551 } else {
1552 String::new()
1553 };
1554 let coherence = if edge.self_reinforcements > 0 {
1555 format!(" {YELLOW}self×{}{RESET}", edge.self_reinforcements)
1556 } else {
1557 String::new()
1558 };
1559 println!(
1560 " {} {CYAN}—[{}]→{RESET} {} {:.0}%{coherence}{superseded}",
1561 edge.from,
1562 edge.rel_type,
1563 edge.to,
1564 edge.confidence * 100.0,
1565 );
1566 }
1567}
1568
1569fn confirm_removal(removal: &Removal) -> Result<bool, RecallError> {
1577 use std::io::{IsTerminal, Write};
1578
1579 if !std::io::stdin().is_terminal() {
1580 return Err(RecallError::Other(
1581 "refusing to remove memory without confirmation — re-run with --yes".into(),
1582 ));
1583 }
1584
1585 let what = removal
1586 .entity
1587 .as_ref()
1588 .map_or_else(|| "these relationships".to_string(), |e| e.name.clone());
1589 print!("{BOLD}Remove {what}?{RESET} [y/N] ");
1590 std::io::stdout().flush()?;
1591
1592 let mut answer = String::new();
1593 std::io::stdin().read_line(&mut answer)?;
1594 Ok(matches!(answer.trim().to_lowercase().as_str(), "y" | "yes"))
1595}
1596
1597fn plural(count: usize, one: &'static str, many: &'static str) -> &'static str {
1598 if count == 1 {
1599 one
1600 } else {
1601 many
1602 }
1603}
1604
1605pub async fn decay_report(
1607 memory_dir: &Path,
1608 entity_name: Option<&str>,
1609 show_all: bool,
1610) -> Result<(), RecallError> {
1611 use crate::graph::confidence;
1612 use crate::graph::types::Direction;
1613
1614 let graph_dir = memory_dir.join("graph");
1615 if !graph_dir.exists() {
1616 return Err(RecallError::NotInitialized(
1617 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
1618 ));
1619 }
1620
1621 serve_client::exclusive(memory_dir, |gm| async move {
1622 let now = chrono::Utc::now();
1623
1624 let rels = if let Some(name) = entity_name {
1625 gm.get_relationships(name, Direction::Both).await?
1626 } else {
1627 crate::graph::crud::list_all_relationships(gm.db()).await?
1628 };
1629
1630 if rels.is_empty() {
1631 println!("{YELLOW}No relationships found.{RESET}");
1632 return Ok(());
1633 }
1634
1635 println!(
1636 "{BOLD}Decay Report{RESET} ({} relationships, half-life: {} days)\n",
1637 rels.len(),
1638 confidence::DEFAULT_HALF_LIFE_DAYS
1639 );
1640
1641 let mut decayed_count = 0u32;
1642 let mut total_decay = 0.0_f64;
1643
1644 for rel in &rels {
1645 let effective = confidence::effective_confidence(
1646 rel.confidence,
1647 rel.last_reinforced.as_ref(),
1648 &rel.valid_from,
1649 &now,
1650 );
1651
1652 let decay_amount = rel.confidence - effective;
1653 if decay_amount > 0.001 {
1654 decayed_count += 1;
1655 }
1656 total_decay += decay_amount;
1657
1658 if !show_all && decay_amount < 0.001 {
1659 continue;
1660 }
1661
1662 let from_short = match &rel.from_id {
1663 serde_json::Value::String(s) => s.split(':').next_back().unwrap_or(s).to_string(),
1664 other => other.to_string(),
1665 };
1666 let to_short = match &rel.to_id {
1667 serde_json::Value::String(s) => s.split(':').next_back().unwrap_or(s).to_string(),
1668 other => other.to_string(),
1669 };
1670
1671 let reinforced_tag = match &rel.last_reinforced {
1672 Some(serde_json::Value::String(s)) => format!(" {DIM}(reinforced: {s}){RESET}"),
1673 _ => String::new(),
1674 };
1675
1676 let decay_indicator = if decay_amount > 0.2 {
1677 format!("\x1b[31m↓{:.0}%\x1b[0m", decay_amount * 100.0)
1678 } else if decay_amount > 0.05 {
1679 format!("{YELLOW}↓{:.0}%{RESET}", decay_amount * 100.0)
1680 } else {
1681 format!("{DIM}≈{RESET}")
1682 };
1683
1684 let edge_evidence = rel.edge_evidence();
1687 let coherence = edge_evidence.self_reinforcements();
1688 let evidence = rel.evidence();
1689 let evidence_tag = format!(
1690 " {DIM}[n={:.1} ±{:.2}{}]{RESET}",
1691 evidence.concentration(),
1692 evidence.variance().sqrt(),
1693 if coherence > 0 {
1694 format!(", self×{coherence}")
1695 } else {
1696 String::new()
1697 }
1698 );
1699
1700 println!(
1701 " {from_short} {CYAN}—[{}]→{RESET} {to_short} stored:{:.2} effective:{:.2} {decay_indicator}{evidence_tag}{reinforced_tag}",
1702 rel.rel_type, rel.confidence, effective,
1703 );
1704 }
1705
1706 println!(
1707 "\n{BOLD}Summary{RESET}: {decayed_count}/{} relationships decayed, avg decay: {:.3}",
1708 rels.len(),
1709 if rels.is_empty() {
1710 0.0
1711 } else {
1712 total_decay / rels.len() as f64
1713 }
1714 );
1715
1716 Ok(())
1717 })
1718 .await
1719}
1720
1721#[cfg(feature = "llm")]
1723pub(crate) fn find_archive_file(
1724 conversations_dir: &Path,
1725 log_number: u32,
1726) -> Result<PathBuf, RecallError> {
1727 let patterns = [
1729 format!("conversation-{log_number:03}.md"),
1730 format!("conversation-{log_number}.md"),
1731 format!("archive-log-{log_number:03}.md"),
1732 format!("archive-log-{log_number}.md"),
1733 ];
1734
1735 for name in &patterns {
1736 let path = conversations_dir.join(name);
1737 if path.exists() {
1738 return Ok(path);
1739 }
1740 }
1741
1742 Err(RecallError::Other(format!(
1743 "no archive file for log {log_number:03}",
1744 )))
1745}
1746
1747#[cfg(all(test, feature = "llm"))]
1748mod tests {
1749 use super::*;
1750
1751 #[test]
1753 fn a_bill_says_which_of_its_numbers_were_measured() {
1754 assert_eq!(format_token_bill(13_663, 0), "14K measured");
1755 assert_eq!(format_token_bill(0, 2_500), "~2K estimated");
1756 assert_eq!(
1757 format_token_bill(13_663, 2_500),
1758 "14K measured + ~2K estimated"
1759 );
1760 }
1761
1762 #[test]
1764 fn a_run_that_spent_nothing_says_zero() {
1765 assert_eq!(format_token_bill(0, 0), "0");
1766 }
1767}