Skip to main content

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