Skip to main content

recall_echo/
checkpoint.rs

1//! Checkpoint creation — saves a conversation snapshot before context compression.
2//!
3//! Supports two paths:
4//! 1. **JSONL hook** — called by Claude Code PreCompact hook (standalone)
5//! 2. **Pulse-null** — called with in-memory Messages (behind feature flag)
6
7use std::fs;
8use std::path::Path;
9
10use crate::archive;
11use crate::conversation;
12use crate::error::RecallError;
13use crate::frontmatter::Frontmatter;
14use crate::tags;
15
16// ---------------------------------------------------------------------------
17// JSONL path — for Claude Code PreCompact hook
18// ---------------------------------------------------------------------------
19
20/// Checkpoint from a JSONL transcript (Claude Code hook).
21/// Reads hook input from stdin.
22pub fn run_from_hook(trigger: &str) -> Result<(), RecallError> {
23    run_from_hook_with_paths(trigger, &crate::paths::claude_dir()?)
24}
25
26pub fn run_from_hook_with_paths(trigger: &str, base_dir: &Path) -> Result<(), RecallError> {
27    let conversations_dir = base_dir.join("conversations");
28    let archive_index = base_dir.join("ARCHIVE.md");
29
30    if !conversations_dir.exists() {
31        return Err(RecallError::NotInitialized(
32            "conversations/ directory not found. Run init first.".into(),
33        ));
34    }
35
36    // Try to read hook input from stdin (Claude Code passes transcript_path)
37    let hook_input = crate::jsonl::read_hook_input().ok();
38
39    let next_num = archive::highest_conversation_number(&conversations_dir) + 1;
40    let now = conversation::utc_now();
41    let date = conversation::date_from_timestamp(&now);
42
43    // If we have hook input with a transcript, parse it for metadata
44    let data = match &hook_input {
45        Some(input) => extract_from_transcript(input).unwrap_or_else(empty_checkpoint),
46        None => empty_checkpoint(),
47    };
48
49    let fm = Frontmatter {
50        log: next_num,
51        date: now,
52        session_id: data.session_id,
53        message_count: data.message_count,
54        duration: data.duration.clone(),
55        source: trigger.to_string(),
56        topics: data.topics.clone(),
57    };
58
59    let full_content = format!("{}\n\n{}{}", fm.render(), data.md_body, data.tags_section);
60
61    let conv_file = conversations_dir.join(format!("conversation-{next_num:03}.md"));
62    fs::write(&conv_file, &full_content)?;
63
64    // Graph ingestion
65    {
66        let result = archive::ArchiveResult {
67            log_number: next_num,
68            full_content: full_content.clone(),
69            session_id: fm.session_id.clone(),
70        };
71        archive::graph_ingest(base_dir, &result);
72    }
73
74    archive::append_index(
75        &archive_index,
76        next_num,
77        &date,
78        &fm.session_id,
79        &data.topics,
80        data.message_count,
81        &data.duration,
82    )?;
83
84    eprintln!(
85        "recall-echo: checkpoint conversation-{:03}.md ({} \u{2014} {} messages, {} topics)",
86        next_num,
87        trigger,
88        data.message_count,
89        data.topics.len()
90    );
91
92    Ok(())
93}
94
95struct CheckpointData {
96    session_id: String,
97    topics: Vec<String>,
98    message_count: u32,
99    duration: String,
100    md_body: String,
101    tags_section: String,
102}
103
104fn extract_from_transcript(input: &crate::jsonl::HookInput) -> Option<CheckpointData> {
105    let conv = crate::jsonl::parse_transcript(&input.transcript_path, &input.session_id).ok()?;
106
107    if conv.user_message_count == 0 {
108        return None;
109    }
110
111    let duration = match (&conv.first_timestamp, &conv.last_timestamp) {
112        (Some(first), Some(last)) => conversation::calculate_duration(first, last),
113        _ => "unknown".to_string(),
114    };
115    let total_messages = conv.total_messages();
116    let topics = conversation::extract_topics(&conv, 5);
117    let md_body = conversation::conversation_to_markdown(&conv, 0);
118    let conv_tags = tags::extract_tags(&conv.entries);
119    let tags_section = tags::format_tags_section(&conv_tags);
120
121    Some(CheckpointData {
122        session_id: input.session_id.clone(),
123        topics,
124        message_count: total_messages,
125        duration,
126        md_body,
127        tags_section,
128    })
129}
130
131fn empty_checkpoint() -> CheckpointData {
132    CheckpointData {
133        session_id: String::new(),
134        topics: vec![],
135        message_count: 0,
136        duration: String::new(),
137        md_body: "# Checkpoint\n\nNo transcript available.\n".to_string(),
138        tags_section: String::new(),
139    }
140}
141
142// ---------------------------------------------------------------------------
143// Pulse-null path — behind feature flag
144// ---------------------------------------------------------------------------
145
146/// Create a checkpoint from pulse-null in-memory messages.
147///
148/// Returns the conversation number of the created checkpoint.
149#[cfg(feature = "pulse-null")]
150pub async fn create_checkpoint(
151    memory_dir: &Path,
152    messages: &[pulse_system_types::llm::Message],
153    metadata: &archive::SessionMetadata,
154    provider: Option<&dyn pulse_system_types::llm::LmProvider>,
155) -> Result<u32, RecallError> {
156    let mut conv = crate::pulse_null::messages_to_conversation(messages, &metadata.session_id);
157    conv.first_timestamp = metadata.started_at.clone();
158    conv.last_timestamp = metadata.ended_at.clone();
159
160    let summary = crate::summarize::extract_with_fallback(provider, &conv).await;
161    let result = archive::archive_conversation(memory_dir, &conv, &summary, "checkpoint")?;
162    let log_number = result.log_number;
163
164    // Graph ingestion (async path)
165    if log_number > 0 {
166        if let Err(e) = crate::graph_bridge::ingest_into_graph(
167            memory_dir,
168            &result.full_content,
169            &result.session_id,
170            Some(log_number),
171        )
172        .await
173        {
174            eprintln!("recall-echo: graph ingestion warning: {e}");
175        }
176    }
177
178    Ok(log_number)
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use std::io::Write;
185
186    fn write_test_jsonl(dir: &Path) -> String {
187        let path = dir.join("test-session.jsonl");
188        let mut f = fs::File::create(&path).unwrap();
189        let lines = [
190            r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-03-05T14:30:00.000Z","sessionId":"test-ckpt"}"#,
191            r#"{"parentUuid":null,"type":"user","sessionId":"test-ckpt","timestamp":"2026-03-05T14:30:00.100Z","message":{"role":"user","content":"Let's refactor the auth module to use JWT"}}"#,
192            r#"{"parentUuid":"aaa","type":"assistant","sessionId":"test-ckpt","timestamp":"2026-03-05T14:30:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"I'll refactor the auth module to use JWT tokens."}]}}"#,
193            r#"{"parentUuid":"bbb","type":"assistant","sessionId":"test-ckpt","timestamp":"2026-03-05T14:30:06.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_abc","name":"Read","input":{"file_path":"/src/auth.rs"}}]}}"#,
194            r#"{"parentUuid":"ccc","type":"user","sessionId":"test-ckpt","timestamp":"2026-03-05T14:30:07.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_abc","content":"pub fn login() {}"}]}}"#,
195            r#"{"parentUuid":"ddd","type":"user","sessionId":"test-ckpt","timestamp":"2026-03-05T14:35:00.000Z","message":{"role":"user","content":"Now add token validation"}}"#,
196            r#"{"parentUuid":"eee","type":"assistant","sessionId":"test-ckpt","timestamp":"2026-03-05T14:35:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Adding token validation now."}]}}"#,
197        ];
198        for line in &lines {
199            writeln!(f, "{}", line).unwrap();
200        }
201        path.to_string_lossy().to_string()
202    }
203
204    #[test]
205    fn checkpoint_with_transcript_extracts_topics() {
206        let tmp = tempfile::tempdir().unwrap();
207        let p = write_test_jsonl(tmp.path());
208
209        let input = crate::jsonl::HookInput {
210            session_id: "test-ckpt".to_string(),
211            transcript_path: p,
212            _cwd: None,
213            _hook_event_name: Some("PreCompact".to_string()),
214        };
215
216        let data = extract_from_transcript(&input);
217        assert!(data.is_some());
218
219        let data = data.unwrap();
220        assert_eq!(data.session_id, "test-ckpt");
221        assert!(data.message_count > 0);
222        assert!(!data.topics.is_empty());
223    }
224
225    #[test]
226    fn empty_checkpoint_fallback() {
227        let data = empty_checkpoint();
228        assert!(data.session_id.is_empty());
229        assert!(data.topics.is_empty());
230        assert_eq!(data.message_count, 0);
231        assert!(data.md_body.contains("No transcript available"));
232    }
233}