1use std::fs;
8use std::path::Path;
9
10use crate::archive;
11use crate::conversation;
12use crate::frontmatter::Frontmatter;
13use crate::tags;
14
15pub fn run_from_hook(trigger: &str) -> Result<(), String> {
22 run_from_hook_with_paths(trigger, &crate::paths::claude_dir()?)
23}
24
25pub fn run_from_hook_with_paths(trigger: &str, base_dir: &Path) -> Result<(), String> {
26 let conversations_dir = base_dir.join("conversations");
27 let archive_index = base_dir.join("ARCHIVE.md");
28
29 if !conversations_dir.exists() {
30 return Err("conversations/ directory not found. Run init first.".to_string());
31 }
32
33 let hook_input = crate::jsonl::read_hook_input().ok();
35
36 let next_num = archive::highest_conversation_number(&conversations_dir) + 1;
37 let now = conversation::utc_now();
38 let date = conversation::date_from_timestamp(&now);
39
40 let data = match &hook_input {
42 Some(input) => extract_from_transcript(input).unwrap_or_else(empty_checkpoint),
43 None => empty_checkpoint(),
44 };
45
46 let fm = Frontmatter {
47 log: next_num,
48 date: now,
49 session_id: data.session_id,
50 message_count: data.message_count,
51 duration: data.duration.clone(),
52 source: trigger.to_string(),
53 topics: data.topics.clone(),
54 };
55
56 let full_content = format!("{}\n\n{}{}", fm.render(), data.md_body, data.tags_section);
57
58 let conv_file = conversations_dir.join(format!("conversation-{next_num:03}.md"));
59 fs::write(&conv_file, &full_content)
60 .map_err(|e| format!("Failed to create checkpoint file: {e}"))?;
61
62 #[cfg(feature = "graph")]
64 {
65 let result = archive::ArchiveResult {
66 log_number: next_num,
67 full_content: full_content.clone(),
68 session_id: fm.session_id.clone(),
69 };
70 archive::graph_ingest(base_dir, &result);
71 }
72
73 archive::append_index(
74 &archive_index,
75 next_num,
76 &date,
77 &fm.session_id,
78 &data.topics,
79 data.message_count,
80 &data.duration,
81 )?;
82
83 eprintln!(
84 "recall-echo: checkpoint conversation-{:03}.md ({} \u{2014} {} messages, {} topics)",
85 next_num,
86 trigger,
87 data.message_count,
88 data.topics.len()
89 );
90
91 Ok(())
92}
93
94struct CheckpointData {
95 session_id: String,
96 topics: Vec<String>,
97 message_count: u32,
98 duration: String,
99 md_body: String,
100 tags_section: String,
101}
102
103fn extract_from_transcript(input: &crate::jsonl::HookInput) -> Option<CheckpointData> {
104 let conv = crate::jsonl::parse_transcript(&input.transcript_path, &input.session_id).ok()?;
105
106 if conv.user_message_count == 0 {
107 return None;
108 }
109
110 let duration = match (&conv.first_timestamp, &conv.last_timestamp) {
111 (Some(first), Some(last)) => conversation::calculate_duration(first, last),
112 _ => "unknown".to_string(),
113 };
114 let total_messages = conv.total_messages();
115 let topics = conversation::extract_topics(&conv, 5);
116 let md_body = conversation::conversation_to_markdown(&conv, 0);
117 let conv_tags = tags::extract_tags(&conv.entries);
118 let tags_section = tags::format_tags_section(&conv_tags);
119
120 Some(CheckpointData {
121 session_id: input.session_id.clone(),
122 topics,
123 message_count: total_messages,
124 duration,
125 md_body,
126 tags_section,
127 })
128}
129
130fn empty_checkpoint() -> CheckpointData {
131 CheckpointData {
132 session_id: String::new(),
133 topics: vec![],
134 message_count: 0,
135 duration: String::new(),
136 md_body: "# Checkpoint\n\nNo transcript available.\n".to_string(),
137 tags_section: String::new(),
138 }
139}
140
141#[cfg(feature = "pulse-null")]
149pub async fn create_checkpoint(
150 memory_dir: &Path,
151 messages: &[pulse_system_types::llm::Message],
152 metadata: &archive::SessionMetadata,
153 provider: Option<&dyn pulse_system_types::llm::LmProvider>,
154) -> Result<u32, String> {
155 let mut conv = crate::pulse_null::messages_to_conversation(messages, &metadata.session_id);
156 conv.first_timestamp = metadata.started_at.clone();
157 conv.last_timestamp = metadata.ended_at.clone();
158
159 let summary = crate::summarize::extract_with_fallback(provider, &conv).await;
160 let result = archive::archive_conversation(memory_dir, &conv, &summary, "checkpoint")?;
161 let log_number = result.log_number;
162
163 #[cfg(feature = "graph")]
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}