1use std::fmt::Write as _;
15use std::fs;
16use std::path::Path;
17
18use crate::config;
19use crate::conversation::{self, Conversation};
20use crate::ephemeral::{self, EphemeralEntry};
21use crate::error::RecallError;
22use crate::frontmatter::Frontmatter;
23use crate::summarize;
24use crate::tags;
25
26#[derive(Debug, Clone)]
28pub struct SessionMetadata {
29 pub session_id: String,
30 pub started_at: Option<String>,
31 pub ended_at: Option<String>,
32 pub entity_name: String,
33}
34
35pub struct ArchiveResult {
37 pub log_number: u32,
38 pub full_content: String,
39 pub session_id: String,
40}
41
42#[must_use]
44pub fn highest_conversation_number(conversations_dir: &Path) -> u32 {
45 let entries = match fs::read_dir(conversations_dir) {
46 Ok(e) => e,
47 Err(_) => return 0,
48 };
49
50 let mut max = 0u32;
51 for entry in entries.flatten() {
52 let name = entry.file_name();
53 let name = name.to_string_lossy();
54 if let Some(num_str) = name
55 .strip_prefix("conversation-")
56 .and_then(|s| s.strip_suffix(".md"))
57 {
58 if let Ok(n) = num_str.parse::<u32>() {
59 if n > max {
60 max = n;
61 }
62 }
63 }
64 }
65
66 max
67}
68
69pub fn append_index(
71 archive_path: &Path,
72 log_num: u32,
73 date: &str,
74 session_id: &str,
75 topics: &[String],
76 message_count: u32,
77 duration: &str,
78) -> Result<(), RecallError> {
79 use std::io::Write;
80
81 let needs_header = if archive_path.exists() {
82 fs::read_to_string(archive_path)
83 .unwrap_or_default()
84 .trim()
85 .is_empty()
86 } else {
87 true
88 };
89
90 let mut file = fs::OpenOptions::new()
91 .create(true)
92 .append(true)
93 .open(archive_path)?;
94
95 if needs_header {
96 writeln!(file, "# Conversation Archive\n")?;
97 writeln!(
98 file,
99 "| # | Date | Session | Topics | Messages | Duration |"
100 )?;
101 writeln!(
102 file,
103 "|---|------|---------|--------|----------|----------|"
104 )?;
105 }
106
107 let topics_str = if topics.is_empty() {
108 "\u{2014}".to_string()
109 } else {
110 topics.join(", ")
111 };
112
113 writeln!(
114 file,
115 "| {log_num:03} | {date} | {session_id} | {topics_str} | {message_count} | {duration} |"
116 )?;
117
118 Ok(())
119}
120
121pub fn archive_conversation(
132 memory_dir: &Path,
133 conv: &Conversation,
134 summary: &summarize::ConversationSummary,
135 source: &str,
136) -> Result<ArchiveResult, RecallError> {
137 let conversations_dir = memory_dir.join("conversations");
138 let archive_index = memory_dir.join("ARCHIVE.md");
139 let ephemeral_path = memory_dir.join("EPHEMERAL.md");
140
141 if !conversations_dir.exists() {
142 return Err(RecallError::NotInitialized(
143 "conversations/ directory not found. Run init first.".into(),
144 ));
145 }
146
147 if conv.user_message_count == 0 {
149 return Ok(ArchiveResult {
150 log_number: 0,
151 full_content: String::new(),
152 session_id: conv.session_id.clone(),
153 });
154 }
155
156 let next_num = highest_conversation_number(&conversations_dir) + 1;
157
158 let now = conversation::utc_now();
159 let date = conversation::date_from_timestamp(&now);
160 let duration = match (&conv.first_timestamp, &conv.last_timestamp) {
161 (Some(start), Some(end)) => conversation::calculate_duration(start, end),
162 _ => "unknown".to_string(),
163 };
164 let total_messages = conv.total_messages();
165
166 let fm = Frontmatter {
168 log: next_num,
169 date: now.clone(),
170 session_id: conv.session_id.clone(),
171 message_count: total_messages,
172 duration: duration.clone(),
173 source: source.to_string(),
174 topics: summary.topics.clone(),
175 };
176
177 let md_body = conversation::conversation_to_markdown(conv, next_num);
179
180 let conv_tags = tags::extract_tags(&conv.entries);
182 let tags_section = tags::format_tags_section(&conv_tags);
183
184 let summary_section = if !summary.summary.is_empty() {
186 let mut s = format!("## Summary\n\n{}\n\n", summary.summary);
187 if !summary.decisions.is_empty() {
188 s.push_str("**Decisions**:\n");
189 for d in &summary.decisions {
190 let _ = writeln!(s, "- {d}");
191 }
192 s.push('\n');
193 }
194 if !summary.action_items.is_empty() {
195 s.push_str("**Action Items**:\n");
196 for a in &summary.action_items {
197 let _ = writeln!(s, "- {a}");
198 }
199 s.push('\n');
200 }
201 s
202 } else {
203 String::new()
204 };
205
206 let full_content = format!(
207 "{}\n\n{}{}\n{}",
208 fm.render(),
209 summary_section,
210 md_body,
211 tags_section
212 );
213
214 let conv_file = conversations_dir.join(format!("conversation-{next_num:03}.md"));
216 fs::write(&conv_file, &full_content)?;
217
218 append_index(
220 &archive_index,
221 next_num,
222 &date,
223 &conv.session_id,
224 &summary.topics,
225 total_messages,
226 &duration,
227 )?;
228
229 let entry = EphemeralEntry {
231 session_id: conv.session_id.clone(),
232 date: now,
233 duration,
234 message_count: total_messages,
235 archive_file: format!("conversation-{next_num:03}.md"),
236 summary: summary.summary.clone(),
237 };
238 ephemeral::append_entry(&ephemeral_path, &entry)?;
239 let cfg = config::load_from_dir(memory_dir);
240 ephemeral::trim_to_limit(&ephemeral_path, cfg.ephemeral.max_entries)?;
241
242 eprintln!("recall-echo: archived conversation-{next_num:03}.md ({total_messages} messages)");
243
244 Ok(ArchiveResult {
245 log_number: next_num,
246 full_content,
247 session_id: conv.session_id.clone(),
248 })
249}
250
251pub fn graph_ingest(memory_dir: &Path, result: &ArchiveResult) {
253 if result.log_number == 0 {
254 return;
255 }
256 let rt = match client_runtime() {
257 Ok(rt) => rt,
258 Err(e) => {
259 eprintln!("recall-echo: graph runtime error: {e}");
260 return;
261 }
262 };
263 if let Err(e) = rt.block_on(crate::graph_bridge::ingest_into_graph(
264 memory_dir,
265 &result.full_content,
266 &result.session_id,
267 Some(result.log_number),
268 )) {
269 eprintln!("recall-echo: graph ingestion warning: {e}");
270 }
271}
272
273fn client_runtime() -> std::io::Result<tokio::runtime::Runtime> {
277 tokio::runtime::Builder::new_current_thread()
278 .enable_all()
279 .build()
280}
281
282fn pipeline_docs_to_sync(memory_dir: &Path) -> Option<crate::graph::types::PipelineDocuments> {
285 let cfg = config::load_from_dir(memory_dir);
286 let pipeline = match cfg.pipeline {
287 Some(ref p) if p.auto_sync == Some(true) => p,
288 _ => return None,
289 };
290
291 let docs_dir = match pipeline.docs_dir {
292 Some(ref d) => {
293 let path = std::path::PathBuf::from(shellexpand_path(d));
294 if !path.exists() {
295 eprintln!(
296 "recall-echo: pipeline docs_dir not found: {}",
297 path.display()
298 );
299 return None;
300 }
301 path
302 }
303 None => {
304 eprintln!("recall-echo: pipeline auto_sync enabled but no docs_dir configured");
305 return None;
306 }
307 };
308
309 if !memory_dir.join("graph").exists() {
310 return None;
311 }
312 Some(read_pipeline_docs(&docs_dir))
313}
314
315pub fn pipeline_sync_on_archive(memory_dir: &Path) {
319 let Some(docs) = pipeline_docs_to_sync(memory_dir) else {
320 return;
321 };
322 let rt = match client_runtime() {
323 Ok(rt) => rt,
324 Err(e) => {
325 eprintln!("recall-echo: pipeline sync runtime error: {e}");
326 return;
327 }
328 };
329 report_pipeline_sync(rt.block_on(crate::graph_bridge::sync_pipeline_into_graph(
330 memory_dir, docs,
331 )));
332}
333
334#[cfg(feature = "pulse-null")]
336async fn pipeline_sync_on_archive_async(memory_dir: &Path) {
337 let Some(docs) = pipeline_docs_to_sync(memory_dir) else {
338 return;
339 };
340 report_pipeline_sync(crate::graph_bridge::sync_pipeline_into_graph(memory_dir, docs).await);
341}
342
343fn report_pipeline_sync(result: Result<crate::graph::types::PipelineSyncReport, RecallError>) {
344 match result {
345 Ok(report) => {
346 if report.entities_created > 0
347 || report.entities_updated > 0
348 || report.entities_archived > 0
349 {
350 eprintln!(
351 "recall-echo: pipeline synced — +{} created, ~{} updated, -{} archived",
352 report.entities_created, report.entities_updated, report.entities_archived
353 );
354 }
355 }
356 Err(e) => eprintln!("recall-echo: pipeline sync warning: {e}"),
357 }
358}
359
360fn read_pipeline_docs(docs_dir: &Path) -> crate::graph::types::PipelineDocuments {
361 crate::graph::types::PipelineDocuments {
362 learning: read_opt_file(docs_dir, "LEARNING.md"),
363 thoughts: read_opt_file(docs_dir, "THOUGHTS.md"),
364 curiosity: read_opt_file(docs_dir, "CURIOSITY.md"),
365 reflections: read_opt_file(docs_dir, "REFLECTIONS.md"),
366 praxis: read_opt_file(docs_dir, "PRAXIS.md"),
367 }
368}
369
370fn read_opt_file(dir: &Path, name: &str) -> String {
371 fs::read_to_string(dir.join(name)).unwrap_or_default()
372}
373
374fn shellexpand_path(path: &str) -> String {
375 if let Some(rest) = path.strip_prefix("~/") {
376 if let Ok(home) = std::env::var("HOME") {
377 return format!("{home}/{rest}");
378 }
379 }
380 path.to_string()
381}
382
383pub fn archive_from_jsonl(
393 base_dir: &Path,
394 session_id: &str,
395 transcript_path: &str,
396) -> Result<u32, RecallError> {
397 let conv = crate::jsonl::parse_transcript(transcript_path, session_id)?;
398 archive_and_ingest(base_dir, &conv, "jsonl")
399}
400
401fn archive_and_ingest(
405 base_dir: &Path,
406 conv: &Conversation,
407 source: &str,
408) -> Result<u32, RecallError> {
409 let summary = summarize::algorithmic_summary(conv);
410 let result = archive_conversation(base_dir, conv, &summary, source)?;
411 let log_number = result.log_number;
412
413 graph_ingest(base_dir, &result);
414 pipeline_sync_on_archive(base_dir);
415
416 Ok(log_number)
417}
418
419#[derive(Debug)]
425enum HookTarget {
426 Unnamed,
428 Absent,
430 ClaudeJsonl,
432 GeminiSession(Box<Conversation>),
434 Unreadable,
436}
437
438fn classify(transcript_path: &str, session_id: &str) -> HookTarget {
440 if transcript_path.is_empty() {
441 return HookTarget::Unnamed;
442 }
443 if !Path::new(transcript_path).exists() {
444 return HookTarget::Absent;
445 }
446 if crate::jsonl::is_jsonl_transcript(transcript_path) {
449 return HookTarget::ClaudeJsonl;
450 }
451
452 fs::read_to_string(transcript_path)
453 .ok()
454 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
455 .and_then(|document| crate::transcript::gemini::parse_session(&document, session_id))
456 .map_or(HookTarget::Unreadable, |conv| {
457 HookTarget::GeminiSession(Box::new(conv))
458 })
459}
460
461pub fn run_from_hook() -> Result<(), RecallError> {
464 let hook_input = crate::jsonl::read_hook_input()?;
465 run_with_hook_input(&hook_input)
466}
467
468pub fn run_with_hook_input(hook_input: &crate::jsonl::HookInput) -> Result<(), RecallError> {
490 let transcript_path = hook_input.transcript_path.trim();
491
492 match classify(transcript_path, &hook_input.session_id) {
493 HookTarget::Unnamed => {
494 eprintln!(
495 "recall-echo: the hook payload names no transcript, so there is nothing to \
496 archive. `archive-session` expects a SessionEnd payload on stdin, shaped \
497 {{\"session_id\": …, \"transcript_path\": …}}."
498 );
499 }
500 HookTarget::Absent => {
501 eprintln!(
502 "recall-echo: no transcript at {transcript_path} (session not persisted), \
503 nothing to archive"
504 );
505 }
506 HookTarget::ClaudeJsonl => {
507 let base_dir = crate::paths::claude_dir()?;
508 archive_from_jsonl(&base_dir, &hook_input.session_id, transcript_path)?;
509 }
510 HookTarget::GeminiSession(conv) => {
511 let base_dir = crate::paths::claude_dir()?;
512 archive_and_ingest(&base_dir, &conv, "gemini")?;
513 }
514 HookTarget::Unreadable => {
515 eprintln!(
516 "recall-echo: {transcript_path} is neither a Claude Code JSONL transcript nor \
517 a Gemini chat session, so nothing was archived. If this hook came from \
518 `gemini hooks migrate --from-claude`, recall-echo reads Gemini sessions at \
519 ~/.gemini/tmp/<hash>/chats/session-*.json — please report this file's shape \
520 at https://github.com/dnacenta/recall-echo/issues so it can be read too."
521 );
522 }
523 }
524 Ok(())
525}
526
527pub fn archive_all_unarchived() -> Result<(), RecallError> {
529 let base = crate::paths::claude_dir()?;
530 archive_all_with_base(&base)
531}
532
533pub fn archive_all_with_base(base: &Path) -> Result<(), RecallError> {
534 let conversations_dir = base.join("conversations");
535 if !conversations_dir.exists() {
536 return Err(RecallError::NotInitialized(
537 "conversations/ directory not found. Run `recall-echo init` first.".into(),
538 ));
539 }
540
541 let archived_sessions = collect_archived_sessions(&conversations_dir);
542
543 let projects_dir = base.join("projects");
544 if !projects_dir.exists() {
545 eprintln!("No projects directory found \u{2014} nothing to archive.");
546 return Ok(());
547 }
548
549 let mut jsonl_files = find_jsonl_files(&projects_dir);
550 jsonl_files.sort_by_key(|p| {
551 fs::metadata(p)
552 .and_then(|m| m.modified())
553 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
554 });
555
556 let mut archived_count = 0;
557 let mut skipped_count = 0;
558
559 for jsonl_path in &jsonl_files {
560 let session_id = match jsonl_path.file_stem().and_then(|s| s.to_str()) {
561 Some(id) => id.to_string(),
562 None => continue,
563 };
564
565 if archived_sessions.contains(&session_id) {
566 skipped_count += 1;
567 continue;
568 }
569
570 let path_str = jsonl_path.to_string_lossy().to_string();
571 match archive_from_jsonl(base, &session_id, &path_str) {
572 Ok(_) => archived_count += 1,
573 Err(e) => {
574 eprintln!("recall-echo: skipping {session_id} \u{2014} {e}");
575 }
576 }
577 }
578
579 eprintln!(
580 "recall-echo: archived {archived_count} conversation{}, skipped {skipped_count} already archived",
581 if archived_count == 1 { "" } else { "s" }
582 );
583
584 Ok(())
585}
586
587#[must_use]
593pub fn collect_archived_sessions(conversations_dir: &Path) -> std::collections::HashSet<String> {
594 let mut sessions = std::collections::HashSet::new();
595 if let Ok(entries) = fs::read_dir(conversations_dir) {
596 for entry in entries.flatten() {
597 let name = entry.file_name();
598 let name = name.to_string_lossy();
599 if name.starts_with("conversation-") && name.ends_with(".md") {
600 if let Ok(content) = fs::read_to_string(entry.path()) {
601 for line in content.lines().take(15) {
602 if let Some(sid) = line.strip_prefix("session_id: ") {
603 sessions.insert(sid.trim().trim_matches('"').to_string());
604 break;
605 }
606 }
607 }
608 }
609 }
610 }
611 sessions
612}
613
614fn find_jsonl_files(dir: &Path) -> Vec<std::path::PathBuf> {
615 let mut files = Vec::new();
616 if let Ok(entries) = fs::read_dir(dir) {
617 for entry in entries.flatten() {
618 let path = entry.path();
619 if path.is_dir() {
620 files.extend(find_jsonl_files(&path));
621 } else if path.extension().is_some_and(|e| e == "jsonl") {
622 files.push(path);
623 }
624 }
625 }
626 files
627}
628
629#[cfg(feature = "pulse-null")]
638pub async fn archive_session(
639 memory_dir: &Path,
640 messages: &[pulse_system_types::llm::Message],
641 metadata: &SessionMetadata,
642 provider: Option<&dyn pulse_system_types::llm::LmProvider>,
643) -> Result<u32, RecallError> {
644 let mut conv = crate::pulse_null::messages_to_conversation(messages, &metadata.session_id);
645 conv.first_timestamp = metadata.started_at.clone();
646 conv.last_timestamp = metadata.ended_at.clone();
647
648 let summary = summarize::extract_with_fallback(provider, &conv).await;
649 let result = archive_conversation(memory_dir, &conv, &summary, "session")?;
650 let log_number = result.log_number;
651
652 if log_number > 0 {
654 if let Err(e) = crate::graph_bridge::ingest_into_graph(
655 memory_dir,
656 &result.full_content,
657 &result.session_id,
658 Some(log_number),
659 )
660 .await
661 {
662 eprintln!("recall-echo: graph ingestion warning: {e}");
663 }
664 pipeline_sync_on_archive_async(memory_dir).await;
665 }
666
667 Ok(log_number)
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 #[test]
675 fn highest_from_empty_dir() {
676 let tmp = tempfile::tempdir().unwrap();
677 assert_eq!(highest_conversation_number(tmp.path()), 0);
678 }
679
680 #[test]
681 fn highest_from_sequential_files() {
682 let tmp = tempfile::tempdir().unwrap();
683 fs::write(tmp.path().join("conversation-001.md"), "").unwrap();
684 fs::write(tmp.path().join("conversation-002.md"), "").unwrap();
685 fs::write(tmp.path().join("conversation-003.md"), "").unwrap();
686 assert_eq!(highest_conversation_number(tmp.path()), 3);
687 }
688
689 #[test]
690 fn highest_with_gaps() {
691 let tmp = tempfile::tempdir().unwrap();
692 fs::write(tmp.path().join("conversation-001.md"), "").unwrap();
693 fs::write(tmp.path().join("conversation-010.md"), "").unwrap();
694 assert_eq!(highest_conversation_number(tmp.path()), 10);
695 }
696
697 #[test]
698 fn highest_ignores_non_matching() {
699 let tmp = tempfile::tempdir().unwrap();
700 fs::write(tmp.path().join("conversation-003.md"), "").unwrap();
701 fs::write(tmp.path().join("notes.md"), "").unwrap();
702 fs::write(tmp.path().join("conversation-bad.md"), "").unwrap();
703 assert_eq!(highest_conversation_number(tmp.path()), 3);
704 }
705
706 #[test]
707 fn append_index_creates_header_and_appends() {
708 let tmp = tempfile::tempdir().unwrap();
709 let index = tmp.path().join("ARCHIVE.md");
710
711 append_index(
712 &index,
713 1,
714 "2026-03-05",
715 "abc123",
716 &["auth".to_string()],
717 34,
718 "45m",
719 )
720 .unwrap();
721 append_index(
722 &index,
723 2,
724 "2026-03-05",
725 "def456",
726 &["ci".to_string(), "tests".to_string()],
727 22,
728 "20m",
729 )
730 .unwrap();
731
732 let content = fs::read_to_string(&index).unwrap();
733 assert!(content.contains("# Conversation Archive"));
734 assert!(content.contains("| 001 | 2026-03-05 | abc123 | auth | 34 | 45m |"));
735 assert!(content.contains("| 002 | 2026-03-05 | def456 | ci, tests | 22 | 20m |"));
736 }
737
738 #[test]
739 fn append_index_to_existing_file() {
740 let tmp = tempfile::tempdir().unwrap();
741 let index = tmp.path().join("ARCHIVE.md");
742 fs::write(
743 &index,
744 "# Conversation Archive\n\n| # | Date | Session | Topics | Messages | Duration |\n|---|------|---------|--------|----------|----------|\n| 001 | 2026-03-05 | abc | test | 10 | 5m |\n",
745 )
746 .unwrap();
747
748 append_index(&index, 2, "2026-03-05", "def", &[], 20, "10m").unwrap();
749
750 let content = fs::read_to_string(&index).unwrap();
751 assert!(content.contains("| 002 | 2026-03-05 | def | \u{2014} | 20 | 10m |"));
752 assert_eq!(content.matches("# Conversation Archive").count(), 1);
753 }
754
755 #[test]
756 fn archive_conversation_basic() {
757 let tmp = tempfile::tempdir().unwrap();
758 let memory = tmp.path();
759 fs::create_dir_all(memory.join("conversations")).unwrap();
760
761 let conv = Conversation {
762 session_id: "test-abc".to_string(),
763 first_timestamp: Some("2026-03-05T14:30:00Z".to_string()),
764 last_timestamp: Some("2026-03-05T15:00:00Z".to_string()),
765 user_message_count: 1,
766 assistant_message_count: 1,
767 entries: vec![
768 conversation::ConversationEntry::UserMessage("Let's build something".to_string()),
769 conversation::ConversationEntry::AssistantText("Sure, let's do it.".to_string()),
770 ],
771 };
772
773 let summary = summarize::ConversationSummary {
774 summary: "Built something cool".to_string(),
775 topics: vec!["building".to_string()],
776 decisions: vec![],
777 action_items: vec![],
778 };
779
780 let result = archive_conversation(memory, &conv, &summary, "test").unwrap();
781 assert_eq!(result.log_number, 1);
782 assert!(memory.join("conversations/conversation-001.md").exists());
783
784 let content = fs::read_to_string(memory.join("conversations/conversation-001.md")).unwrap();
785 assert!(content.contains("session_id: \"test-abc\""));
786 assert!(content.contains("source: \"test\""));
787 assert!(content.contains("Built something cool"));
788 }
789
790 #[test]
791 fn archive_conversation_skips_empty() {
792 let tmp = tempfile::tempdir().unwrap();
793 let memory = tmp.path();
794 fs::create_dir_all(memory.join("conversations")).unwrap();
795
796 let conv = Conversation::new("empty");
797 let summary = summarize::ConversationSummary::default();
798
799 let result = archive_conversation(memory, &conv, &summary, "test").unwrap();
800 assert_eq!(result.log_number, 0);
801 }
802
803 #[test]
804 fn hook_missing_transcript_exits_ok() {
805 let hook_input = crate::jsonl::HookInput {
806 session_id: "no-persist".into(),
807 transcript_path: "/nonexistent/path/transcript.jsonl".into(),
808 _cwd: None,
809 _hook_event_name: None,
810 };
811 assert!(run_with_hook_input(&hook_input).is_ok());
813 }
814
815 #[test]
818 fn hook_without_a_transcript_path_exits_ok() {
819 let hook_input = crate::jsonl::HookInput::default();
820 assert!(run_with_hook_input(&hook_input).is_ok());
821 assert!(matches!(classify("", ""), HookTarget::Unnamed));
822 }
823
824 fn written(name: &str, body: &str) -> (tempfile::TempDir, String) {
825 let dir = tempfile::tempdir().unwrap();
826 let path = dir.path().join(name);
827 fs::write(&path, body).unwrap();
828 let path = path.to_string_lossy().to_string();
829 (dir, path)
830 }
831
832 const CLAUDE_JSONL: &str = concat!(
833 r#"{"type":"user","message":{"role":"user","content":"hello"}}"#,
834 "\n",
835 r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}"#,
836 "\n",
837 );
838
839 #[test]
841 fn a_claude_code_transcript_is_recognised() {
842 let (_dir, path) = written("session.jsonl", CLAUDE_JSONL);
843 assert!(matches!(classify(&path, "sess"), HookTarget::ClaudeJsonl));
844 }
845
846 #[test]
849 fn a_gemini_session_document_is_read_rather_than_skipped() {
850 let document = serde_json::json!({
851 "sessionId": "sess-1",
852 "startTime": "2026-08-06T10:00:00Z",
853 "messages": [
854 {"type": "user", "content": "Where does recall-echo live?"},
855 {"type": "gemini", "content": "Under /opt/recall-echo."},
856 ],
857 });
858
859 for body in [
861 serde_json::to_string_pretty(&document).unwrap(),
862 serde_json::to_string(&document).unwrap(),
863 ] {
864 let (_dir, path) = written("session-1.json", &body);
865 let HookTarget::GeminiSession(conv) = classify(&path, "hook-session") else {
866 panic!("not recognised as a Gemini session: {body}");
867 };
868 assert_eq!(conv.user_message_count, 1);
869 assert_eq!(conv.assistant_message_count, 1);
870 }
871 }
872
873 #[test]
875 fn a_transcript_in_no_known_format_is_declined() {
876 let (_dir, path) = written("notes.txt", "not a transcript at all\n");
877 assert!(matches!(classify(&path, "sess"), HookTarget::Unreadable));
878
879 let (_dir, path) = written("empty.jsonl", "");
880 assert!(matches!(classify(&path, "sess"), HookTarget::Unreadable));
881
882 let hook_input = crate::jsonl::HookInput {
883 session_id: "sess".into(),
884 transcript_path: path,
885 _cwd: None,
886 _hook_event_name: None,
887 };
888 assert!(
889 run_with_hook_input(&hook_input).is_ok(),
890 "an unreadable transcript must not fail the session"
891 );
892 }
893}