1use std::path::{Path, PathBuf};
4
5use recall_graph::traverse::format_traversal;
6use recall_graph::types::*;
7use recall_graph::GraphMemory;
8
9const GREEN: &str = "\x1b[32m";
10const CYAN: &str = "\x1b[36m";
11const YELLOW: &str = "\x1b[33m";
12const BOLD: &str = "\x1b[1m";
13const DIM: &str = "\x1b[2m";
14const RESET: &str = "\x1b[0m";
15
16pub fn init(memory_dir: &Path) -> Result<(), String> {
18 let graph_dir = memory_dir.join("graph");
19 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
20 rt.block_on(async {
21 GraphMemory::open(&graph_dir)
22 .await
23 .map_err(|e| e.to_string())?;
24 println!(
25 "{GREEN}✓{RESET} Graph store initialized at {}",
26 graph_dir.display()
27 );
28 Ok(())
29 })
30}
31
32pub fn graph_status(memory_dir: &Path) -> Result<(), String> {
34 let graph_dir = memory_dir.join("graph");
35 if !graph_dir.exists() {
36 return Err("Graph store not initialized. Run `recall-echo graph init` first.".into());
37 }
38 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
39 rt.block_on(async {
40 let gm = GraphMemory::open(&graph_dir)
41 .await
42 .map_err(|e| e.to_string())?;
43 let stats = gm.stats().await.map_err(|e| e.to_string())?;
44
45 println!("{BOLD}Graph Memory Status{RESET}");
46 println!(" Entities: {}", stats.entity_count);
47 println!(" Relationships: {}", stats.relationship_count);
48 println!(" Episodes: {}", stats.episode_count);
49
50 if !stats.entity_type_counts.is_empty() {
51 println!("\n {DIM}By type:{RESET}");
52 let mut types: Vec<_> = stats.entity_type_counts.iter().collect();
53 types.sort_by(|a, b| b.1.cmp(a.1));
54 for (t, count) in types {
55 println!(" {t}: {count}");
56 }
57 }
58 Ok(())
59 })
60}
61
62pub fn add_entity(
64 memory_dir: &Path,
65 name: &str,
66 entity_type: &str,
67 abstract_text: &str,
68 overview: Option<&str>,
69 source: Option<&str>,
70) -> Result<(), String> {
71 let graph_dir = memory_dir.join("graph");
72 let et: EntityType = entity_type.parse().map_err(|e: String| e)?;
73
74 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
75 rt.block_on(async {
76 let gm = GraphMemory::open(&graph_dir)
77 .await
78 .map_err(|e| e.to_string())?;
79
80 let entity = gm
81 .add_entity(NewEntity {
82 name: name.to_string(),
83 entity_type: et,
84 abstract_text: abstract_text.to_string(),
85 overview: overview.map(String::from),
86 content: None,
87 attributes: None,
88 source: source.map(String::from),
89 })
90 .await
91 .map_err(|e| e.to_string())?;
92
93 println!(
94 "{GREEN}✓{RESET} Created entity: {BOLD}{}{RESET} ({}) [{}]",
95 entity.name,
96 entity.entity_type,
97 entity.id_string()
98 );
99 Ok(())
100 })
101}
102
103pub fn relate(
105 memory_dir: &Path,
106 from: &str,
107 rel_type: &str,
108 to: &str,
109 description: Option<&str>,
110 source: Option<&str>,
111) -> Result<(), String> {
112 let graph_dir = memory_dir.join("graph");
113 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
114 rt.block_on(async {
115 let gm = GraphMemory::open(&graph_dir)
116 .await
117 .map_err(|e| e.to_string())?;
118
119 let rel = gm
120 .add_relationship(NewRelationship {
121 from_entity: from.to_string(),
122 to_entity: to.to_string(),
123 rel_type: rel_type.to_string(),
124 description: description.map(String::from),
125 confidence: None,
126 source: source.map(String::from),
127 })
128 .await
129 .map_err(|e| e.to_string())?;
130
131 println!(
132 "{GREEN}✓{RESET} {from} {CYAN}—[{rel_type}]→{RESET} {to} [{}]",
133 rel.id_string()
134 );
135 Ok(())
136 })
137}
138
139pub fn search(
141 memory_dir: &Path,
142 query: &str,
143 limit: usize,
144 entity_type: Option<&str>,
145 keyword: Option<&str>,
146) -> Result<(), String> {
147 let graph_dir = memory_dir.join("graph");
148 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
149 rt.block_on(async {
150 let gm = GraphMemory::open(&graph_dir)
151 .await
152 .map_err(|e| e.to_string())?;
153
154 let options = SearchOptions {
155 limit,
156 entity_type: entity_type.map(String::from),
157 keyword: keyword.map(String::from),
158 };
159
160 let results = gm
161 .search_with_options(query, &options)
162 .await
163 .map_err(|e| e.to_string())?;
164
165 if results.is_empty() {
166 println!("{YELLOW}No results.{RESET}");
167 return Ok(());
168 }
169
170 for (i, r) in results.iter().enumerate() {
171 println!(
172 "{BOLD}{}. {}{RESET} ({}) — score: {:.3}",
173 i + 1,
174 r.entity.name,
175 r.entity.entity_type,
176 r.score
177 );
178 println!(" {DIM}{}{RESET}", r.entity.abstract_text);
179 }
180 Ok(())
181 })
182}
183
184pub fn ingest(memory_dir: &Path, archive_path: &Path) -> Result<(), String> {
186 let graph_dir = memory_dir.join("graph");
187 if !graph_dir.exists() {
188 return Err("Graph store not initialized. Run `recall-echo graph init` first.".into());
189 }
190
191 let content = std::fs::read_to_string(archive_path)
192 .map_err(|e| format!("Failed to read {}: {e}", archive_path.display()))?;
193
194 let (session_id, log_number) = extract_archive_metadata(&content, archive_path);
196
197 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
198 rt.block_on(async {
199 let gm = GraphMemory::open(&graph_dir)
200 .await
201 .map_err(|e| e.to_string())?;
202
203 let report = gm
204 .ingest_archive(&content, &session_id, log_number, None)
205 .await
206 .map_err(|e| e.to_string())?;
207
208 println!(
209 "{GREEN}✓{RESET} Ingested {}: {} episodes created",
210 archive_path.display(),
211 report.episodes_created
212 );
213 if !report.errors.is_empty() {
214 for err in &report.errors {
215 println!(" {YELLOW}warning:{RESET} {err}");
216 }
217 }
218 Ok(())
219 })
220}
221
222pub fn ingest_all(memory_dir: &Path) -> Result<(), String> {
224 let graph_dir = memory_dir.join("graph");
225 if !graph_dir.exists() {
226 return Err("Graph store not initialized. Run `recall-echo graph init` first.".into());
227 }
228
229 let conversations_dir = find_conversations_dir(memory_dir)?;
230
231 let mut files: Vec<_> = std::fs::read_dir(&conversations_dir)
233 .map_err(|e| e.to_string())?
234 .filter_map(|e| e.ok())
235 .filter(|e| {
236 let name = e.file_name().to_string_lossy().to_string();
237 name.starts_with("conversation-") || name.starts_with("archive-log-")
238 })
239 .collect();
240 files.sort_by_key(|e| e.file_name());
241
242 if files.is_empty() {
243 println!("{YELLOW}No conversation archives found.{RESET}");
244 return Ok(());
245 }
246
247 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
248 rt.block_on(async {
249 let gm = GraphMemory::open(&graph_dir)
250 .await
251 .map_err(|e| e.to_string())?;
252
253 let mut total_episodes = 0u32;
254 let mut ingested = 0u32;
255 let mut skipped = 0u32;
256
257 for entry in &files {
258 let path = entry.path();
259 let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
260
261 let (session_id, log_number) = extract_archive_metadata(&content, &path);
262
263 if let Some(ln) = log_number {
265 if let Ok(Some(_)) = gm.get_episode_by_log_number(ln).await {
266 skipped += 1;
267 continue;
268 }
269 }
270
271 let report = gm
272 .ingest_archive(&content, &session_id, log_number, None)
273 .await
274 .map_err(|e| e.to_string())?;
275
276 total_episodes += report.episodes_created;
277 ingested += 1;
278
279 println!(
280 " {GREEN}✓{RESET} {} — {} episodes",
281 path.file_name().unwrap_or_default().to_string_lossy(),
282 report.episodes_created
283 );
284 }
285
286 println!(
287 "\n{GREEN}✓{RESET} Ingested {ingested} archives ({total_episodes} episodes), skipped {skipped} already ingested"
288 );
289 Ok(())
290 })
291}
292
293fn extract_archive_metadata(content: &str, path: &Path) -> (String, Option<u32>) {
295 let mut session_id = "unknown".to_string();
296 let mut log_number: Option<u32> = None;
297
298 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
300 let num_str = name
301 .strip_prefix("conversation-")
302 .or_else(|| name.strip_prefix("archive-log-"));
303 if let Some(num_str) = num_str {
304 if let Ok(n) = num_str.parse::<u32>() {
305 log_number = Some(n);
306 }
307 }
308 }
309
310 if let Some(stripped) = content.strip_prefix("---") {
312 if let Some(end) = stripped.find("---") {
313 let frontmatter = &stripped[..end];
314 for line in frontmatter.lines() {
315 let line = line.trim();
316 if let Some(val) = line.strip_prefix("session_id:") {
317 session_id = val.trim().trim_matches('"').to_string();
318 }
319 }
320 }
321 }
322
323 (session_id, log_number)
324}
325
326pub fn traverse(
328 memory_dir: &Path,
329 entity_name: &str,
330 depth: u32,
331 type_filter: Option<&str>,
332) -> Result<(), String> {
333 let graph_dir = memory_dir.join("graph");
334 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
335 rt.block_on(async {
336 let gm = GraphMemory::open(&graph_dir)
337 .await
338 .map_err(|e| e.to_string())?;
339
340 let tree = gm
341 .traverse_filtered(entity_name, depth, type_filter)
342 .await
343 .map_err(|e| e.to_string())?;
344
345 let output = format_traversal(&tree, 0);
346 print!("{output}");
347 Ok(())
348 })
349}
350
351pub fn hybrid_query(
353 memory_dir: &Path,
354 query: &str,
355 limit: usize,
356 entity_type: Option<&str>,
357 keyword: Option<&str>,
358 depth: u32,
359 episodes: bool,
360) -> Result<(), String> {
361 let graph_dir = memory_dir.join("graph");
362 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
363 rt.block_on(async {
364 let gm = GraphMemory::open(&graph_dir)
365 .await
366 .map_err(|e| e.to_string())?;
367
368 let options = QueryOptions {
369 limit,
370 entity_type: entity_type.map(String::from),
371 keyword: keyword.map(String::from),
372 graph_depth: depth,
373 include_episodes: episodes,
374 };
375
376 let result = gm.query(query, &options).await.map_err(|e| e.to_string())?;
377
378 if result.entities.is_empty() && result.episodes.is_empty() {
379 println!("{YELLOW}No results.{RESET}");
380 return Ok(());
381 }
382
383 if !result.entities.is_empty() {
384 println!("{BOLD}Entities:{RESET}");
385 for (i, r) in result.entities.iter().enumerate() {
386 let source_tag = match &r.source {
387 MatchSource::Semantic => "semantic".to_string(),
388 MatchSource::Graph { parent, rel_type } => {
389 format!("graph: {parent} —[{rel_type}]")
390 }
391 MatchSource::Keyword => "keyword".to_string(),
392 };
393 println!(
394 " {BOLD}{}. {}{RESET} ({}) — {:.3} [{DIM}{source_tag}{RESET}]",
395 i + 1,
396 r.entity.name,
397 r.entity.entity_type,
398 r.score
399 );
400 println!(" {DIM}{}{RESET}", r.entity.abstract_text);
401 }
402 }
403
404 if !result.episodes.is_empty() {
405 println!("\n{BOLD}Episodes:{RESET}");
406 for (i, ep) in result.episodes.iter().enumerate() {
407 let log = ep
408 .episode
409 .log_number
410 .map(|n| format!("#{n}"))
411 .unwrap_or_default();
412 println!(
413 " {BOLD}{}. {}{RESET} ({}) — {:.3}",
414 i + 1,
415 ep.episode.session_id,
416 log,
417 ep.score
418 );
419 println!(" {DIM}{}{RESET}", ep.episode.abstract_text);
420 }
421 }
422
423 Ok(())
424 })
425}
426
427#[cfg(feature = "llm")]
429pub fn extract(
430 memory_dir: &Path,
431 log: Option<u32>,
432 all: bool,
433 dry_run: bool,
434 model_override: Option<String>,
435 provider_override: Option<String>,
436 delay_ms: u64,
437) -> Result<(), String> {
438 let graph_dir = memory_dir.join("graph");
439 if !graph_dir.exists() {
440 return Err("Graph store not initialized. Run `recall-echo graph init` first.".into());
441 }
442
443 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
444 rt.block_on(async {
445 let gm = GraphMemory::open(&graph_dir)
446 .await
447 .map_err(|e| e.to_string())?;
448
449 let log_numbers: Vec<u32> = if let Some(ln) = log {
451 vec![ln]
452 } else if all {
453 gm.unextracted_log_numbers()
454 .await
455 .map_err(|e| e.to_string())?
456 .into_iter()
457 .map(|n| n as u32)
458 .collect()
459 } else {
460 return Err("Specify --log <N> or --all".into());
461 };
462
463 if log_numbers.is_empty() {
464 println!("{YELLOW}No unextracted archives found.{RESET}");
465 return Ok(());
466 }
467
468 let conversations_dir = find_conversations_dir(memory_dir)?;
470
471 if dry_run {
472 println!(
473 "{BOLD}Dry run — {}{RESET} archives to extract",
474 log_numbers.len()
475 );
476 for ln in &log_numbers {
477 let path = find_archive_file(&conversations_dir, *ln);
478 let label = match &path {
479 Ok(p) => {
480 p.file_name()
481 .unwrap_or_default()
482 .to_string_lossy()
483 .to_string()
484 }
485 Err(_) => format!("log {ln:03} (file not found)"),
486 };
487 println!(" {label}");
488 }
489 return Ok(());
490 }
491
492 let (llm, model_name) = crate::llm_provider::create_provider(
494 memory_dir,
495 provider_override.as_deref(),
496 model_override.as_deref(),
497 )?;
498
499 println!(
500 "{BOLD}Extracting entities from {} archives using {model_name}{RESET}",
501 log_numbers.len(),
502 );
503
504 let mut total_entities_created = 0u32;
505 let mut total_entities_merged = 0u32;
506 let mut total_entities_skipped = 0u32;
507 let mut total_relationships = 0u32;
508 let mut total_errors = Vec::new();
509 let mut processed = 0u32;
510
511 for ln in &log_numbers {
512 let archive_path = match find_archive_file(&conversations_dir, *ln) {
513 Ok(p) => p,
514 Err(e) => {
515 println!(" {YELLOW}⚠{RESET} log {ln:03}: {e}");
516 total_errors.push(format!("log {ln:03}: {e}"));
517 continue;
518 }
519 };
520
521 let content = std::fs::read_to_string(&archive_path)
522 .map_err(|e| format!("read {}: {e}", archive_path.display()))?;
523
524 let (session_id, _) = extract_archive_metadata(&content, &archive_path);
525
526 let report = gm
527 .extract_from_archive(&content, &session_id, Some(*ln), &*llm)
528 .await
529 .map_err(|e| format!("extraction log {ln:03}: {e}"))?;
530
531 println!(
532 " {GREEN}✓{RESET} log {ln:03}: +{} entities, ~{} merged, -{} skipped, {} rels",
533 report.entities_created,
534 report.entities_merged,
535 report.entities_skipped,
536 report.relationships_created,
537 );
538
539 gm.mark_extracted(*ln).await.map_err(|e| e.to_string())?;
540
541 total_entities_created += report.entities_created;
542 total_entities_merged += report.entities_merged;
543 total_entities_skipped += report.entities_skipped;
544 total_relationships += report.relationships_created;
545 total_errors.extend(report.errors);
546 processed += 1;
547
548 if delay_ms > 0 && *ln != *log_numbers.last().unwrap() {
550 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
551 }
552 }
553
554 println!(
555 "\n{GREEN}✓{RESET} Done: {processed} archives — +{total_entities_created} created, ~{total_entities_merged} merged, -{total_entities_skipped} skipped, {total_relationships} relationships"
556 );
557
558 if !total_errors.is_empty() {
559 println!("\n{YELLOW}Warnings ({}):{RESET}", total_errors.len());
560 for err in total_errors.iter().take(10) {
561 println!(" {DIM}{err}{RESET}");
562 }
563 if total_errors.len() > 10 {
564 println!(" {DIM}... and {} more{RESET}", total_errors.len() - 10);
565 }
566 }
567
568 Ok(())
569 })
570}
571
572pub fn pipeline_sync(memory_dir: &Path, docs_dir_override: Option<&Path>) -> Result<(), String> {
576 let graph_dir = memory_dir.join("graph");
577 if !graph_dir.exists() {
578 return Err("Graph store not initialized. Run `recall-echo graph init` first.".into());
579 }
580
581 let docs_dir = if let Some(d) = docs_dir_override {
583 d.to_path_buf()
584 } else {
585 let cfg = crate::config::load_from_dir(memory_dir);
586 match cfg.pipeline.and_then(|p| p.docs_dir) {
587 Some(d) => {
588 let path = PathBuf::from(shellexpand(&d));
589 if !path.exists() {
590 return Err(format!(
591 "Configured docs_dir does not exist: {}",
592 path.display()
593 ));
594 }
595 path
596 }
597 None => {
598 return Err(
599 "No docs directory specified. Use --docs-dir or set [pipeline] docs_dir in config.".into(),
600 );
601 }
602 }
603 };
604
605 let docs = read_pipeline_docs(&docs_dir)?;
607
608 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
609 rt.block_on(async {
610 let gm = GraphMemory::open(&graph_dir)
611 .await
612 .map_err(|e| e.to_string())?;
613
614 let report = gm.sync_pipeline(&docs).await.map_err(|e| e.to_string())?;
615
616 println!("{BOLD}Pipeline Sync{RESET}");
617 println!(" Created: {}", report.entities_created);
618 println!(" Updated: {}", report.entities_updated);
619 println!(" Archived: {}", report.entities_archived);
620 println!(
621 " Relationships: +{} / ~{} skipped",
622 report.relationships_created, report.relationships_skipped
623 );
624
625 if !report.errors.is_empty() {
626 println!("\n {YELLOW}Warnings:{RESET}");
627 for err in &report.errors {
628 println!(" {DIM}{err}{RESET}");
629 }
630 }
631
632 if report.entities_created == 0
633 && report.entities_updated == 0
634 && report.entities_archived == 0
635 {
636 println!("\n {DIM}No changes — graph is in sync.{RESET}");
637 }
638
639 Ok(())
640 })
641}
642
643pub fn pipeline_status(memory_dir: &Path, staleness_days: u32) -> Result<(), String> {
645 let graph_dir = memory_dir.join("graph");
646 if !graph_dir.exists() {
647 return Err("Graph store not initialized. Run `recall-echo graph init` first.".into());
648 }
649
650 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
651 rt.block_on(async {
652 let gm = GraphMemory::open(&graph_dir)
653 .await
654 .map_err(|e| e.to_string())?;
655
656 let stats = gm
657 .pipeline_stats(staleness_days)
658 .await
659 .map_err(|e| e.to_string())?;
660
661 println!(
662 "{BOLD}Pipeline Status{RESET} ({} entities)",
663 stats.total_entities
664 );
665
666 if stats.by_stage.is_empty() {
667 println!(
668 " {DIM}No pipeline entities in graph. Run `graph pipeline sync` first.{RESET}"
669 );
670 return Ok(());
671 }
672
673 let stage_order = ["learning", "thoughts", "curiosity", "reflections", "praxis"];
675 for stage in &stage_order {
676 if let Some(statuses) = stats.by_stage.get(*stage) {
677 println!("\n {CYAN}{}{RESET}", stage.to_uppercase());
678 let mut items: Vec<_> = statuses.iter().collect();
679 items.sort_by_key(|(s, _)| (*s).clone());
680 for (status, count) in items {
681 println!(" {status}: {count}");
682 }
683 }
684 }
685
686 if !stats.stale_thoughts.is_empty() {
687 println!("\n {YELLOW}Stale thoughts (>{staleness_days}d):{RESET}");
688 for entity in &stats.stale_thoughts {
689 println!(" {DIM}•{RESET} {}", entity.name);
690 }
691 }
692
693 if !stats.stale_questions.is_empty() {
694 println!(
695 "\n {YELLOW}Stale questions (>{}d):{RESET}",
696 staleness_days * 2
697 );
698 for entity in &stats.stale_questions {
699 println!(" {DIM}•{RESET} {}", entity.name);
700 }
701 }
702
703 if let Some(ref last) = stats.last_movement {
704 println!("\n {DIM}Last movement: {last}{RESET}");
705 }
706
707 Ok(())
708 })
709}
710
711pub fn pipeline_flow(memory_dir: &Path, entity_name: &str) -> Result<(), String> {
713 let graph_dir = memory_dir.join("graph");
714 if !graph_dir.exists() {
715 return Err("Graph store not initialized. Run `recall-echo graph init` first.".into());
716 }
717
718 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
719 rt.block_on(async {
720 let gm = GraphMemory::open(&graph_dir)
721 .await
722 .map_err(|e| e.to_string())?;
723
724 let chain = gm
725 .pipeline_flow(entity_name)
726 .await
727 .map_err(|e| e.to_string())?;
728
729 if chain.is_empty() {
730 println!("{YELLOW}No pipeline relationships found for \"{entity_name}\".{RESET}");
731 return Ok(());
732 }
733
734 println!("{BOLD}Pipeline Flow: {entity_name}{RESET}\n");
735 for (source, rel_type, target) in &chain {
736 println!(
737 " {} ({}) {CYAN}—[{rel_type}]→{RESET} {} ({})",
738 source.name, source.entity_type, target.name, target.entity_type
739 );
740 }
741
742 Ok(())
743 })
744}
745
746pub fn pipeline_stale(memory_dir: &Path, staleness_days: u32) -> Result<(), String> {
748 let graph_dir = memory_dir.join("graph");
749 if !graph_dir.exists() {
750 return Err("Graph store not initialized. Run `recall-echo graph init` first.".into());
751 }
752
753 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
754 rt.block_on(async {
755 let gm = GraphMemory::open(&graph_dir)
756 .await
757 .map_err(|e| e.to_string())?;
758
759 let stats = gm
760 .pipeline_stats(staleness_days)
761 .await
762 .map_err(|e| e.to_string())?;
763
764 let total_stale = stats.stale_thoughts.len() + stats.stale_questions.len();
765 if total_stale == 0 {
766 println!("{GREEN}✓{RESET} No stale pipeline entities.");
767 return Ok(());
768 }
769
770 println!("{BOLD}Stale Pipeline Entities{RESET}\n");
771
772 if !stats.stale_thoughts.is_empty() {
773 println!(" {YELLOW}Thoughts (>{staleness_days} days):{RESET}");
774 for entity in &stats.stale_thoughts {
775 println!(" • {} {DIM}({}){RESET}", entity.name, entity.entity_type);
776 }
777 }
778
779 if !stats.stale_questions.is_empty() {
780 println!(" {YELLOW}Questions (>{} days):{RESET}", staleness_days * 2);
781 for entity in &stats.stale_questions {
782 println!(" • {} {DIM}({}){RESET}", entity.name, entity.entity_type);
783 }
784 }
785
786 Ok(())
787 })
788}
789
790fn read_pipeline_docs(dir: &Path) -> Result<PipelineDocuments, String> {
792 let read_or_empty = |name: &str| -> String {
793 let path = dir.join(name);
794 std::fs::read_to_string(&path).unwrap_or_default()
795 };
796
797 Ok(PipelineDocuments {
798 learning: read_or_empty("LEARNING.md"),
799 thoughts: read_or_empty("THOUGHTS.md"),
800 curiosity: read_or_empty("CURIOSITY.md"),
801 reflections: read_or_empty("REFLECTIONS.md"),
802 praxis: read_or_empty("PRAXIS.md"),
803 })
804}
805
806fn shellexpand(path: &str) -> String {
808 if let Some(rest) = path.strip_prefix("~/") {
809 if let Some(home) = dirs::home_dir() {
810 return home.join(rest).to_string_lossy().to_string();
811 }
812 }
813 path.to_string()
814}
815
816fn find_conversations_dir(memory_dir: &Path) -> Result<PathBuf, String> {
818 let conv = memory_dir.join("conversations");
819 if conv.exists() {
820 return Ok(conv);
821 }
822 if let Some(parent) = memory_dir.parent() {
823 let parent_conv = parent.join("conversations");
824 if parent_conv.exists() {
825 return Ok(parent_conv);
826 }
827 }
828 Err("conversations/ directory not found".into())
829}
830
831#[cfg(feature = "llm")]
833fn find_archive_file(conversations_dir: &Path, log_number: u32) -> Result<PathBuf, String> {
834 let patterns = [
836 format!("conversation-{log_number:03}.md"),
837 format!("conversation-{log_number}.md"),
838 format!("archive-log-{log_number:03}.md"),
839 format!("archive-log-{log_number}.md"),
840 ];
841
842 for name in &patterns {
843 let path = conversations_dir.join(name);
844 if path.exists() {
845 return Ok(path);
846 }
847 }
848
849 Err(format!("no archive file for log {log_number:03}"))
850}