1use std::fmt::Write as _;
11use std::fs;
12use std::path::Path;
13
14use crate::config;
15use crate::conversation::{self, Conversation};
16use crate::ephemeral::{self, EphemeralEntry};
17use crate::error::RecallError;
18use crate::frontmatter::Frontmatter;
19use crate::summarize;
20use crate::tags;
21
22#[derive(Debug, Clone)]
24pub struct SessionMetadata {
25 pub session_id: String,
26 pub started_at: Option<String>,
27 pub ended_at: Option<String>,
28 pub entity_name: String,
29}
30
31pub struct ArchiveResult {
33 pub log_number: u32,
34 pub full_content: String,
35 pub session_id: String,
36}
37
38#[must_use]
40pub fn highest_conversation_number(conversations_dir: &Path) -> u32 {
41 let entries = match fs::read_dir(conversations_dir) {
42 Ok(e) => e,
43 Err(_) => return 0,
44 };
45
46 let mut max = 0u32;
47 for entry in entries.flatten() {
48 let name = entry.file_name();
49 let name = name.to_string_lossy();
50 if let Some(num_str) = name
51 .strip_prefix("conversation-")
52 .and_then(|s| s.strip_suffix(".md"))
53 {
54 if let Ok(n) = num_str.parse::<u32>() {
55 if n > max {
56 max = n;
57 }
58 }
59 }
60 }
61
62 max
63}
64
65pub fn append_index(
67 archive_path: &Path,
68 log_num: u32,
69 date: &str,
70 session_id: &str,
71 topics: &[String],
72 message_count: u32,
73 duration: &str,
74) -> Result<(), RecallError> {
75 use std::io::Write;
76
77 let needs_header = if archive_path.exists() {
78 fs::read_to_string(archive_path)
79 .unwrap_or_default()
80 .trim()
81 .is_empty()
82 } else {
83 true
84 };
85
86 let mut file = fs::OpenOptions::new()
87 .create(true)
88 .append(true)
89 .open(archive_path)?;
90
91 if needs_header {
92 writeln!(file, "# Conversation Archive\n")?;
93 writeln!(
94 file,
95 "| # | Date | Session | Topics | Messages | Duration |"
96 )?;
97 writeln!(
98 file,
99 "|---|------|---------|--------|----------|----------|"
100 )?;
101 }
102
103 let topics_str = if topics.is_empty() {
104 "\u{2014}".to_string()
105 } else {
106 topics.join(", ")
107 };
108
109 writeln!(
110 file,
111 "| {log_num:03} | {date} | {session_id} | {topics_str} | {message_count} | {duration} |"
112 )?;
113
114 Ok(())
115}
116
117pub fn archive_conversation(
128 memory_dir: &Path,
129 conv: &Conversation,
130 summary: &summarize::ConversationSummary,
131 source: &str,
132) -> Result<ArchiveResult, RecallError> {
133 let conversations_dir = memory_dir.join("conversations");
134 let archive_index = memory_dir.join("ARCHIVE.md");
135 let ephemeral_path = memory_dir.join("EPHEMERAL.md");
136
137 if !conversations_dir.exists() {
138 return Err(RecallError::NotInitialized(
139 "conversations/ directory not found. Run init first.".into(),
140 ));
141 }
142
143 if conv.user_message_count == 0 {
145 return Ok(ArchiveResult {
146 log_number: 0,
147 full_content: String::new(),
148 session_id: conv.session_id.clone(),
149 });
150 }
151
152 let next_num = highest_conversation_number(&conversations_dir) + 1;
153
154 let now = conversation::utc_now();
155 let date = conversation::date_from_timestamp(&now);
156 let duration = match (&conv.first_timestamp, &conv.last_timestamp) {
157 (Some(start), Some(end)) => conversation::calculate_duration(start, end),
158 _ => "unknown".to_string(),
159 };
160 let total_messages = conv.total_messages();
161
162 let fm = Frontmatter {
164 log: next_num,
165 date: now.clone(),
166 session_id: conv.session_id.clone(),
167 message_count: total_messages,
168 duration: duration.clone(),
169 source: source.to_string(),
170 topics: summary.topics.clone(),
171 };
172
173 let md_body = conversation::conversation_to_markdown(conv, next_num);
175
176 let conv_tags = tags::extract_tags(&conv.entries);
178 let tags_section = tags::format_tags_section(&conv_tags);
179
180 let summary_section = if !summary.summary.is_empty() {
182 let mut s = format!("## Summary\n\n{}\n\n", summary.summary);
183 if !summary.decisions.is_empty() {
184 s.push_str("**Decisions**:\n");
185 for d in &summary.decisions {
186 let _ = writeln!(s, "- {d}");
187 }
188 s.push('\n');
189 }
190 if !summary.action_items.is_empty() {
191 s.push_str("**Action Items**:\n");
192 for a in &summary.action_items {
193 let _ = writeln!(s, "- {a}");
194 }
195 s.push('\n');
196 }
197 s
198 } else {
199 String::new()
200 };
201
202 let full_content = format!(
203 "{}\n\n{}{}\n{}",
204 fm.render(),
205 summary_section,
206 md_body,
207 tags_section
208 );
209
210 let conv_file = conversations_dir.join(format!("conversation-{next_num:03}.md"));
212 fs::write(&conv_file, &full_content)?;
213
214 append_index(
216 &archive_index,
217 next_num,
218 &date,
219 &conv.session_id,
220 &summary.topics,
221 total_messages,
222 &duration,
223 )?;
224
225 let entry = EphemeralEntry {
227 session_id: conv.session_id.clone(),
228 date: now,
229 duration,
230 message_count: total_messages,
231 archive_file: format!("conversation-{next_num:03}.md"),
232 summary: summary.summary.clone(),
233 };
234 ephemeral::append_entry(&ephemeral_path, &entry)?;
235 let cfg = config::load_from_dir(memory_dir);
236 ephemeral::trim_to_limit(&ephemeral_path, cfg.ephemeral.max_entries)?;
237
238 eprintln!("recall-echo: archived conversation-{next_num:03}.md ({total_messages} messages)");
239
240 Ok(ArchiveResult {
241 log_number: next_num,
242 full_content,
243 session_id: conv.session_id.clone(),
244 })
245}
246
247pub fn graph_ingest(memory_dir: &Path, result: &ArchiveResult) {
249 if result.log_number == 0 {
250 return;
251 }
252 let rt = match tokio::runtime::Runtime::new() {
253 Ok(rt) => rt,
254 Err(e) => {
255 eprintln!("recall-echo: graph runtime error: {e}");
256 return;
257 }
258 };
259 if let Err(e) = rt.block_on(crate::graph_bridge::ingest_into_graph(
260 memory_dir,
261 &result.full_content,
262 &result.session_id,
263 Some(result.log_number),
264 )) {
265 eprintln!("recall-echo: graph ingestion warning: {e}");
266 }
267}
268
269pub fn pipeline_sync_on_archive(memory_dir: &Path) {
273 let cfg = config::load_from_dir(memory_dir);
274 let pipeline = match cfg.pipeline {
275 Some(ref p) if p.auto_sync == Some(true) => p,
276 _ => return,
277 };
278
279 let docs_dir = match pipeline.docs_dir {
280 Some(ref d) => {
281 let path = std::path::PathBuf::from(shellexpand_path(d));
282 if !path.exists() {
283 eprintln!(
284 "recall-echo: pipeline docs_dir not found: {}",
285 path.display()
286 );
287 return;
288 }
289 path
290 }
291 None => {
292 eprintln!("recall-echo: pipeline auto_sync enabled but no docs_dir configured");
293 return;
294 }
295 };
296
297 let graph_dir = memory_dir.join("graph");
298 if !graph_dir.exists() {
299 return;
300 }
301
302 let rt = match tokio::runtime::Runtime::new() {
303 Ok(rt) => rt,
304 Err(e) => {
305 eprintln!("recall-echo: pipeline sync runtime error: {e}");
306 return;
307 }
308 };
309
310 if let Err(e) = rt.block_on(async {
311 let gm = crate::graph::GraphMemory::open(&graph_dir)
312 .await
313 .map_err(|e| format!("graph open: {e}"))?;
314
315 let docs = crate::graph::types::PipelineDocuments {
316 learning: read_opt_file(&docs_dir, "LEARNING.md"),
317 thoughts: read_opt_file(&docs_dir, "THOUGHTS.md"),
318 curiosity: read_opt_file(&docs_dir, "CURIOSITY.md"),
319 reflections: read_opt_file(&docs_dir, "REFLECTIONS.md"),
320 praxis: read_opt_file(&docs_dir, "PRAXIS.md"),
321 };
322
323 let report = gm.sync_pipeline(&docs).await.map_err(|e| format!("{e}"))?;
324
325 if report.entities_created > 0
326 || report.entities_updated > 0
327 || report.entities_archived > 0
328 {
329 eprintln!(
330 "recall-echo: pipeline synced — +{} created, ~{} updated, -{} archived",
331 report.entities_created, report.entities_updated, report.entities_archived
332 );
333 }
334
335 Ok::<(), String>(())
336 }) {
337 eprintln!("recall-echo: pipeline sync warning: {e}");
338 }
339}
340
341#[cfg(feature = "pulse-null")]
343async fn pipeline_sync_on_archive_async(memory_dir: &Path) {
344 let cfg = config::load_from_dir(memory_dir);
345 let pipeline = match cfg.pipeline {
346 Some(ref p) if p.auto_sync == Some(true) => p.clone(),
347 _ => return,
348 };
349
350 let docs_dir = match pipeline.docs_dir {
351 Some(ref d) => {
352 let path = std::path::PathBuf::from(shellexpand_path(d));
353 if !path.exists() {
354 eprintln!(
355 "recall-echo: pipeline docs_dir not found: {}",
356 path.display()
357 );
358 return;
359 }
360 path
361 }
362 None => {
363 eprintln!("recall-echo: pipeline auto_sync enabled but no docs_dir configured");
364 return;
365 }
366 };
367
368 let graph_dir = memory_dir.join("graph");
369 if !graph_dir.exists() {
370 return;
371 }
372
373 let gm = match crate::graph::GraphMemory::open(&graph_dir).await {
374 Ok(gm) => gm,
375 Err(e) => {
376 eprintln!("recall-echo: pipeline sync open error: {e}");
377 return;
378 }
379 };
380
381 let docs = crate::graph::types::PipelineDocuments {
382 learning: read_opt_file(&docs_dir, "LEARNING.md"),
383 thoughts: read_opt_file(&docs_dir, "THOUGHTS.md"),
384 curiosity: read_opt_file(&docs_dir, "CURIOSITY.md"),
385 reflections: read_opt_file(&docs_dir, "REFLECTIONS.md"),
386 praxis: read_opt_file(&docs_dir, "PRAXIS.md"),
387 };
388
389 match gm.sync_pipeline(&docs).await {
390 Ok(report) => {
391 if report.entities_created > 0
392 || report.entities_updated > 0
393 || report.entities_archived > 0
394 {
395 eprintln!(
396 "recall-echo: pipeline synced — +{} created, ~{} updated, -{} archived",
397 report.entities_created, report.entities_updated, report.entities_archived
398 );
399 }
400 }
401 Err(e) => eprintln!("recall-echo: pipeline sync warning: {e}"),
402 }
403}
404
405fn read_opt_file(dir: &Path, name: &str) -> String {
406 fs::read_to_string(dir.join(name)).unwrap_or_default()
407}
408
409fn shellexpand_path(path: &str) -> String {
410 if let Some(rest) = path.strip_prefix("~/") {
411 if let Ok(home) = std::env::var("HOME") {
412 return format!("{home}/{rest}");
413 }
414 }
415 path.to_string()
416}
417
418pub fn archive_from_jsonl(
427 base_dir: &Path,
428 session_id: &str,
429 transcript_path: &str,
430) -> Result<u32, RecallError> {
431 let conv = crate::jsonl::parse_transcript(transcript_path, session_id)?;
432 let summary = summarize::algorithmic_summary(&conv);
433 let result = archive_conversation(base_dir, &conv, &summary, "jsonl")?;
434 let log_number = result.log_number;
435
436 graph_ingest(base_dir, &result);
437 pipeline_sync_on_archive(base_dir);
438
439 Ok(log_number)
440}
441
442pub fn run_from_hook() -> Result<(), RecallError> {
445 let hook_input = crate::jsonl::read_hook_input()?;
446 run_with_hook_input(&hook_input)
447}
448
449pub fn run_with_hook_input(hook_input: &crate::jsonl::HookInput) -> Result<(), RecallError> {
455 if !Path::new(&hook_input.transcript_path).exists() {
456 eprintln!(
457 "recall-echo: no transcript at {} (session not persisted), nothing to archive",
458 hook_input.transcript_path
459 );
460 return Ok(());
461 }
462 let base_dir = crate::paths::claude_dir()?;
463 archive_from_jsonl(
464 &base_dir,
465 &hook_input.session_id,
466 &hook_input.transcript_path,
467 )?;
468 Ok(())
469}
470
471pub fn archive_all_unarchived() -> Result<(), RecallError> {
473 let base = crate::paths::claude_dir()?;
474 archive_all_with_base(&base)
475}
476
477pub fn archive_all_with_base(base: &Path) -> Result<(), RecallError> {
478 let conversations_dir = base.join("conversations");
479 if !conversations_dir.exists() {
480 return Err(RecallError::NotInitialized(
481 "conversations/ directory not found. Run `recall-echo init` first.".into(),
482 ));
483 }
484
485 let archived_sessions = collect_archived_sessions(&conversations_dir);
486
487 let projects_dir = base.join("projects");
488 if !projects_dir.exists() {
489 eprintln!("No projects directory found \u{2014} nothing to archive.");
490 return Ok(());
491 }
492
493 let mut jsonl_files = find_jsonl_files(&projects_dir);
494 jsonl_files.sort_by_key(|p| {
495 fs::metadata(p)
496 .and_then(|m| m.modified())
497 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
498 });
499
500 let mut archived_count = 0;
501 let mut skipped_count = 0;
502
503 for jsonl_path in &jsonl_files {
504 let session_id = match jsonl_path.file_stem().and_then(|s| s.to_str()) {
505 Some(id) => id.to_string(),
506 None => continue,
507 };
508
509 if archived_sessions.contains(&session_id) {
510 skipped_count += 1;
511 continue;
512 }
513
514 let path_str = jsonl_path.to_string_lossy().to_string();
515 match archive_from_jsonl(base, &session_id, &path_str) {
516 Ok(_) => archived_count += 1,
517 Err(e) => {
518 eprintln!("recall-echo: skipping {session_id} \u{2014} {e}");
519 }
520 }
521 }
522
523 eprintln!(
524 "recall-echo: archived {archived_count} conversation{}, skipped {skipped_count} already archived",
525 if archived_count == 1 { "" } else { "s" }
526 );
527
528 Ok(())
529}
530
531fn collect_archived_sessions(conversations_dir: &Path) -> std::collections::HashSet<String> {
532 let mut sessions = std::collections::HashSet::new();
533 if let Ok(entries) = fs::read_dir(conversations_dir) {
534 for entry in entries.flatten() {
535 let name = entry.file_name();
536 let name = name.to_string_lossy();
537 if name.starts_with("conversation-") && name.ends_with(".md") {
538 if let Ok(content) = fs::read_to_string(entry.path()) {
539 for line in content.lines().take(15) {
540 if let Some(sid) = line.strip_prefix("session_id: ") {
541 sessions.insert(sid.trim().trim_matches('"').to_string());
542 break;
543 }
544 }
545 }
546 }
547 }
548 }
549 sessions
550}
551
552fn find_jsonl_files(dir: &Path) -> Vec<std::path::PathBuf> {
553 let mut files = Vec::new();
554 if let Ok(entries) = fs::read_dir(dir) {
555 for entry in entries.flatten() {
556 let path = entry.path();
557 if path.is_dir() {
558 files.extend(find_jsonl_files(&path));
559 } else if path.extension().is_some_and(|e| e == "jsonl") {
560 files.push(path);
561 }
562 }
563 }
564 files
565}
566
567#[cfg(feature = "pulse-null")]
576pub async fn archive_session(
577 memory_dir: &Path,
578 messages: &[pulse_system_types::llm::Message],
579 metadata: &SessionMetadata,
580 provider: Option<&dyn pulse_system_types::llm::LmProvider>,
581) -> Result<u32, RecallError> {
582 let mut conv = crate::pulse_null::messages_to_conversation(messages, &metadata.session_id);
583 conv.first_timestamp = metadata.started_at.clone();
584 conv.last_timestamp = metadata.ended_at.clone();
585
586 let summary = summarize::extract_with_fallback(provider, &conv).await;
587 let result = archive_conversation(memory_dir, &conv, &summary, "session")?;
588 let log_number = result.log_number;
589
590 if log_number > 0 {
592 if let Err(e) = crate::graph_bridge::ingest_into_graph(
593 memory_dir,
594 &result.full_content,
595 &result.session_id,
596 Some(log_number),
597 )
598 .await
599 {
600 eprintln!("recall-echo: graph ingestion warning: {e}");
601 }
602 pipeline_sync_on_archive_async(memory_dir).await;
603 }
604
605 Ok(log_number)
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 #[test]
613 fn highest_from_empty_dir() {
614 let tmp = tempfile::tempdir().unwrap();
615 assert_eq!(highest_conversation_number(tmp.path()), 0);
616 }
617
618 #[test]
619 fn highest_from_sequential_files() {
620 let tmp = tempfile::tempdir().unwrap();
621 fs::write(tmp.path().join("conversation-001.md"), "").unwrap();
622 fs::write(tmp.path().join("conversation-002.md"), "").unwrap();
623 fs::write(tmp.path().join("conversation-003.md"), "").unwrap();
624 assert_eq!(highest_conversation_number(tmp.path()), 3);
625 }
626
627 #[test]
628 fn highest_with_gaps() {
629 let tmp = tempfile::tempdir().unwrap();
630 fs::write(tmp.path().join("conversation-001.md"), "").unwrap();
631 fs::write(tmp.path().join("conversation-010.md"), "").unwrap();
632 assert_eq!(highest_conversation_number(tmp.path()), 10);
633 }
634
635 #[test]
636 fn highest_ignores_non_matching() {
637 let tmp = tempfile::tempdir().unwrap();
638 fs::write(tmp.path().join("conversation-003.md"), "").unwrap();
639 fs::write(tmp.path().join("notes.md"), "").unwrap();
640 fs::write(tmp.path().join("conversation-bad.md"), "").unwrap();
641 assert_eq!(highest_conversation_number(tmp.path()), 3);
642 }
643
644 #[test]
645 fn append_index_creates_header_and_appends() {
646 let tmp = tempfile::tempdir().unwrap();
647 let index = tmp.path().join("ARCHIVE.md");
648
649 append_index(
650 &index,
651 1,
652 "2026-03-05",
653 "abc123",
654 &["auth".to_string()],
655 34,
656 "45m",
657 )
658 .unwrap();
659 append_index(
660 &index,
661 2,
662 "2026-03-05",
663 "def456",
664 &["ci".to_string(), "tests".to_string()],
665 22,
666 "20m",
667 )
668 .unwrap();
669
670 let content = fs::read_to_string(&index).unwrap();
671 assert!(content.contains("# Conversation Archive"));
672 assert!(content.contains("| 001 | 2026-03-05 | abc123 | auth | 34 | 45m |"));
673 assert!(content.contains("| 002 | 2026-03-05 | def456 | ci, tests | 22 | 20m |"));
674 }
675
676 #[test]
677 fn append_index_to_existing_file() {
678 let tmp = tempfile::tempdir().unwrap();
679 let index = tmp.path().join("ARCHIVE.md");
680 fs::write(
681 &index,
682 "# Conversation Archive\n\n| # | Date | Session | Topics | Messages | Duration |\n|---|------|---------|--------|----------|----------|\n| 001 | 2026-03-05 | abc | test | 10 | 5m |\n",
683 )
684 .unwrap();
685
686 append_index(&index, 2, "2026-03-05", "def", &[], 20, "10m").unwrap();
687
688 let content = fs::read_to_string(&index).unwrap();
689 assert!(content.contains("| 002 | 2026-03-05 | def | \u{2014} | 20 | 10m |"));
690 assert_eq!(content.matches("# Conversation Archive").count(), 1);
691 }
692
693 #[test]
694 fn archive_conversation_basic() {
695 let tmp = tempfile::tempdir().unwrap();
696 let memory = tmp.path();
697 fs::create_dir_all(memory.join("conversations")).unwrap();
698
699 let conv = Conversation {
700 session_id: "test-abc".to_string(),
701 first_timestamp: Some("2026-03-05T14:30:00Z".to_string()),
702 last_timestamp: Some("2026-03-05T15:00:00Z".to_string()),
703 user_message_count: 1,
704 assistant_message_count: 1,
705 entries: vec![
706 conversation::ConversationEntry::UserMessage("Let's build something".to_string()),
707 conversation::ConversationEntry::AssistantText("Sure, let's do it.".to_string()),
708 ],
709 };
710
711 let summary = summarize::ConversationSummary {
712 summary: "Built something cool".to_string(),
713 topics: vec!["building".to_string()],
714 decisions: vec![],
715 action_items: vec![],
716 };
717
718 let result = archive_conversation(memory, &conv, &summary, "test").unwrap();
719 assert_eq!(result.log_number, 1);
720 assert!(memory.join("conversations/conversation-001.md").exists());
721
722 let content = fs::read_to_string(memory.join("conversations/conversation-001.md")).unwrap();
723 assert!(content.contains("session_id: \"test-abc\""));
724 assert!(content.contains("source: \"test\""));
725 assert!(content.contains("Built something cool"));
726 }
727
728 #[test]
729 fn archive_conversation_skips_empty() {
730 let tmp = tempfile::tempdir().unwrap();
731 let memory = tmp.path();
732 fs::create_dir_all(memory.join("conversations")).unwrap();
733
734 let conv = Conversation::new("empty");
735 let summary = summarize::ConversationSummary::default();
736
737 let result = archive_conversation(memory, &conv, &summary, "test").unwrap();
738 assert_eq!(result.log_number, 0);
739 }
740
741 #[test]
742 fn hook_missing_transcript_exits_ok() {
743 let hook_input = crate::jsonl::HookInput {
744 session_id: "no-persist".into(),
745 transcript_path: "/nonexistent/path/transcript.jsonl".into(),
746 _cwd: None,
747 _hook_event_name: None,
748 };
749 assert!(run_with_hook_input(&hook_input).is_ok());
751 }
752}