Skip to main content

recall_echo/
archive.rs

1//! Conversation archival — converts conversations into persistent markdown archives.
2//!
3//! Supports two input paths:
4//! 1. **JSONL hook** — called directly by Claude Code SessionEnd hook (standalone)
5//! 2. **Pulse-null** — called with in-memory Messages (behind feature flag)
6//!
7//! Both converge into `archive_conversation()` which writes the markdown file,
8//! updates ARCHIVE.md, and appends to EPHEMERAL.md.
9
10use 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/// Session metadata provided by the caller.
23#[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
31/// Result of archiving a conversation — used by callers for graph ingestion.
32pub struct ArchiveResult {
33    pub log_number: u32,
34    pub full_content: String,
35    pub session_id: String,
36}
37
38/// Scan conversations/ for highest conversation-NNN number. Returns 0 if none.
39#[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
65/// Append an entry to ARCHIVE.md (markdown table row).
66pub 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
117// ---------------------------------------------------------------------------
118// Core archive function — works with Conversation (universal path)
119// ---------------------------------------------------------------------------
120
121/// Archive a conversation from internal types.
122///
123/// This is the core archive function. All input paths (JSONL, pulse-null)
124/// converge here after converting to a Conversation.
125///
126/// Returns an ArchiveResult with the log number and content (for graph ingestion).
127pub 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    // Skip empty sessions
144    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    // Build frontmatter
163    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    // Convert conversation to markdown
174    let md_body = conversation::conversation_to_markdown(conv, next_num);
175
176    // Extract tags
177    let conv_tags = tags::extract_tags(&conv.entries);
178    let tags_section = tags::format_tags_section(&conv_tags);
179
180    // Add summary section if available
181    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    // Write conversation file
211    let conv_file = conversations_dir.join(format!("conversation-{next_num:03}.md"));
212    fs::write(&conv_file, &full_content)?;
213
214    // Append to ARCHIVE.md index
215    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    // Append to EPHEMERAL.md
226    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
247/// Ingest an archive result into the knowledge graph.
248pub 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
269/// Sync pipeline documents into the graph (if auto_sync enabled).
270///
271/// Non-blocking: logs warnings on failure but never fails the caller.
272pub 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/// Async version of pipeline_sync_on_archive for use in async contexts (pulse-null).
342#[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
418// ---------------------------------------------------------------------------
419// JSONL path — for Claude Code hooks (standalone, no LLM)
420// ---------------------------------------------------------------------------
421
422/// Archive a session from a JSONL transcript file.
423///
424/// Parses JSONL, generates algorithmic summary, archives, and optionally
425/// ingests into the knowledge graph.
426pub 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
442/// Main archive-session flow, called from the SessionEnd hook.
443/// Reads hook input from stdin.
444pub fn run_from_hook() -> Result<(), RecallError> {
445    let hook_input = crate::jsonl::read_hook_input()?;
446    run_with_hook_input(&hook_input)
447}
448
449/// Archive the session named by a hook input.
450///
451/// Sessions run with --no-session-persistence never write a transcript.
452/// A missing file is a normal no-op for the hook, not an error — failing
453/// here makes the entire `claude -p` invocation exit nonzero.
454pub 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
471/// Archive all unarchived JSONL transcripts found under ~/.claude/projects/.
472pub 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// ---------------------------------------------------------------------------
568// Pulse-null path — behind feature flag
569// ---------------------------------------------------------------------------
570
571/// Archive a session from pulse-null in-memory messages.
572///
573/// Converts Messages to Conversation, uses LLM for summarization if available,
574/// and optionally ingests into the knowledge graph.
575#[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    // Graph ingestion (async path — no need for Runtime)
591    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        // --no-session-persistence sessions have no transcript: must be Ok, not Err.
750        assert!(run_with_hook_input(&hook_input).is_ok());
751    }
752}