Skip to main content

recall_echo/
archive.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Conversation archival — converts conversations into persistent markdown archives.
6//!
7//! Supports two input paths:
8//! 1. **JSONL hook** — called directly by Claude Code SessionEnd hook (standalone)
9//! 2. **Pulse-null** — called with in-memory Messages (behind feature flag)
10//!
11//! Both converge into `archive_conversation()` which writes the markdown file,
12//! updates ARCHIVE.md, and appends to EPHEMERAL.md.
13
14use 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/// Session metadata provided by the caller.
27#[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
35/// Result of archiving a conversation — used by callers for graph ingestion.
36pub struct ArchiveResult {
37    pub log_number: u32,
38    pub full_content: String,
39    pub session_id: String,
40}
41
42/// Scan conversations/ for highest conversation-NNN number. Returns 0 if none.
43#[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
69/// Append an entry to ARCHIVE.md (markdown table row).
70pub 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
121// ---------------------------------------------------------------------------
122// Core archive function — works with Conversation (universal path)
123// ---------------------------------------------------------------------------
124
125/// Archive a conversation from internal types.
126///
127/// This is the core archive function. All input paths (JSONL, pulse-null)
128/// converge here after converting to a Conversation.
129///
130/// Returns an ArchiveResult with the log number and content (for graph ingestion).
131pub 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    // Skip empty sessions
148    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    // Build frontmatter
167    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    // Convert conversation to markdown
178    let md_body = conversation::conversation_to_markdown(conv, next_num);
179
180    // Extract tags
181    let conv_tags = tags::extract_tags(&conv.entries);
182    let tags_section = tags::format_tags_section(&conv_tags);
183
184    // Add summary section if available
185    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    // Write conversation file
215    let conv_file = conversations_dir.join(format!("conversation-{next_num:03}.md"));
216    fs::write(&conv_file, &full_content)?;
217
218    // Append to ARCHIVE.md index
219    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    // Append to EPHEMERAL.md
230    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
251/// Ingest an archive result into the knowledge graph.
252pub 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
273/// A runtime for a client-side command: a handful of socket round-trips, plus
274/// whatever the daemon does on our behalf. One thread is enough — the worker
275/// pool of a multi-thread runtime exists to be idle here.
276fn client_runtime() -> std::io::Result<tokio::runtime::Runtime> {
277    tokio::runtime::Builder::new_current_thread()
278        .enable_all()
279        .build()
280}
281
282/// The pipeline documents to sync, or `None` when auto-sync is off, no
283/// `docs_dir` is configured, or there is no graph to sync into.
284fn 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
315/// Sync pipeline documents into the graph (if auto_sync enabled).
316///
317/// Non-blocking: logs warnings on failure but never fails the caller.
318pub 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/// Async version of pipeline_sync_on_archive for use in async contexts (pulse-null).
335#[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
383// ---------------------------------------------------------------------------
384// JSONL path — for Claude Code hooks (standalone, no LLM)
385// ---------------------------------------------------------------------------
386
387/// Archive a session from a JSONL transcript file.
388///
389/// Parses JSONL, generates algorithmic summary, archives, and optionally
390/// ingests into the knowledge graph. Both graph steps are daemon requests, so
391/// the hook pays for one warm store and one embedding-model load, not two.
392pub 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
401/// Summarise, archive, and hand the result to the graph.
402///
403/// The tail every input path shares, whichever CLI's transcript it started as.
404fn 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/// What a hook pointed `archive-session` at.
420///
421/// Deciding this is separate from acting on it because the decision is the part
422/// worth testing: which transcripts are read, which are declined, and which are
423/// simply absent.
424#[derive(Debug)]
425enum HookTarget {
426    /// The payload named no transcript at all.
427    Unnamed,
428    /// A path with nothing at it — a session that was never persisted.
429    Absent,
430    /// Claude Code's JSON Lines, to be streamed from the path.
431    ClaudeJsonl,
432    /// A Gemini chat session document, already read.
433    GeminiSession(Box<Conversation>),
434    /// A file in neither format.
435    Unreadable,
436}
437
438/// Work out what is at the transcript path, reading it only as far as needed.
439fn 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    // Sniffed rather than assumed: a migrated Gemini hook hands this command a
447    // JSON document, and every line of one fails the JSON Lines parser.
448    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
461/// Main archive-session flow, called from the SessionEnd hook.
462/// Reads hook input from stdin.
463pub fn run_from_hook() -> Result<(), RecallError> {
464    let hook_input = crate::jsonl::read_hook_input()?;
465    run_with_hook_input(&hook_input)
466}
467
468/// Archive the session named by a hook input.
469///
470/// # Why nothing here returns an error
471///
472/// A hook that exits nonzero fails the invocation that ran it, every time. So
473/// the three ways this can have nothing to do all report themselves and exit
474/// zero:
475///
476/// - **No transcript path.** Some other harness's payload, spelled some other
477///   way. Named, with what to do about it.
478/// - **No file at the path.** Sessions run with `--no-session-persistence`
479///   never write one. Normal, and silent about anything but the fact.
480/// - **A transcript we cannot read.** Gemini's `hooks migrate --from-claude`
481///   copies this command into Gemini's own settings, where it is handed a JSON
482///   session document rather than Claude Code's JSON Lines. That shape is
483///   [read](crate::transcript::gemini) when it is recognisable; when it is not,
484///   the message says which file and which format, because a user who ran a
485///   migration command deserves to know it half-worked.
486///
487/// Everything written here goes to **stderr**: Gemini requires a hook's stdout
488/// to be JSON and nothing else, and stray prose there is swallowed at best.
489pub 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
527/// Archive all unarchived JSONL transcripts found under ~/.claude/projects/.
528pub 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/// Session ids that already have an archive in `conversations/`.
588///
589/// The archives themselves are the record of what has been captured — there is
590/// no separate ledger to fall out of step with them. Every importer checks this
591/// set before parsing anything, which is what makes re-running an import safe.
592#[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// ---------------------------------------------------------------------------
630// Pulse-null path — behind feature flag
631// ---------------------------------------------------------------------------
632
633/// Archive a session from pulse-null in-memory messages.
634///
635/// Converts Messages to Conversation, uses LLM for summarization if available,
636/// and optionally ingests into the knowledge graph.
637#[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    // Graph ingestion (async path — no need for Runtime)
653    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        // --no-session-persistence sessions have no transcript: must be Ok, not Err.
812        assert!(run_with_hook_input(&hook_input).is_ok());
813    }
814
815    /// A payload from a harness that spells its fields differently must not
816    /// fail the session it is attached to.
817    #[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    /// The format recall-echo has always read, still read the same way.
840    #[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    /// The defect: `gemini hooks migrate --from-claude` points this command at
847    /// a JSON *document*, which the JSON Lines parser reads as nothing at all.
848    #[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        // Pretty-printed and single-line are the same document.
860        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    /// Anything else is declined by name, not archived as an empty session.
874    #[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}