1use std::collections::HashSet;
35use std::path::{Path, PathBuf};
36use std::time::{Duration, SystemTime};
37
38use crate::archive::{self, ArchiveResult};
39use crate::config::CaptureSection;
40use crate::error::RecallError;
41use crate::summarize;
42use crate::transcript::{adapter_for, Source, Transcript, TranscriptRef};
43
44const WATERMARK_DIR: &str = "capture";
46
47#[derive(Debug, Clone, Copy)]
51pub struct CaptureOptions {
52 pub settle: Duration,
54 pub now: SystemTime,
57}
58
59impl CaptureOptions {
60 #[must_use]
62 pub fn from_config(config: &CaptureSection) -> Self {
63 Self {
64 settle: config.settle(),
65 now: SystemTime::now(),
66 }
67 }
68}
69
70impl Default for CaptureOptions {
71 fn default() -> Self {
72 Self::from_config(&CaptureSection::default())
73 }
74}
75
76#[derive(Debug, Default, Clone, PartialEq, Eq)]
80pub struct Pending {
81 pub ready: Vec<TranscriptRef>,
83 pub active: u32,
85 pub duplicates: u32,
87}
88
89#[derive(Debug, Default, Clone, PartialEq, Eq)]
91pub struct CaptureReport {
92 pub archived: Vec<u32>,
94 pub empty: u32,
96 pub duplicates: u32,
97 pub active: u32,
98 pub failed: u32,
99}
100
101impl CaptureReport {
102 #[must_use]
103 pub fn did_something(&self) -> bool {
104 !self.archived.is_empty()
105 }
106
107 #[must_use]
110 pub fn summary(&self, source: Source) -> Option<String> {
111 if self.archived.is_empty() && self.failed == 0 {
112 return None;
113 }
114 let numbers: Vec<String> = self
115 .archived
116 .iter()
117 .map(|number| format!("{number:03}"))
118 .collect();
119 let mut line = format!(
120 "captured {} {source} session{} ({})",
121 self.archived.len(),
122 if self.archived.len() == 1 { "" } else { "s" },
123 if numbers.is_empty() {
124 "\u{2014}".to_string()
125 } else {
126 numbers.join(", ")
127 }
128 );
129 if self.failed > 0 {
130 line.push_str(&format!(", {} failed", self.failed));
131 }
132 if self.duplicates > 0 {
133 line.push_str(&format!(", {} already archived", self.duplicates));
134 }
135 if self.active > 0 {
136 line.push_str(&format!(", {} still active", self.active));
137 }
138 Some(line)
139 }
140}
141
142pub fn pending(
149 memory_dir: &Path,
150 adapter: &dyn Transcript,
151 archived: &HashSet<String>,
152 options: CaptureOptions,
153) -> Result<Pending, RecallError> {
154 let watermark = read_watermark(memory_dir, adapter.source());
155
156 let mut pending = Pending::default();
157 for transcript in adapter.discover(watermark)? {
158 if transcript.age_at(options.now) < options.settle {
159 pending.active += 1;
160 } else if archived.contains(&transcript.session_id) {
161 pending.duplicates += 1;
162 } else {
163 pending.ready.push(transcript);
164 }
165 }
166 Ok(pending)
167}
168
169#[must_use]
171pub fn archived_sessions(memory_dir: &Path) -> HashSet<String> {
172 archive::collect_archived_sessions(&memory_dir.join("conversations"))
173}
174
175pub fn archive_transcript(
182 memory_dir: &Path,
183 adapter: &dyn Transcript,
184 transcript: &TranscriptRef,
185 archived: &HashSet<String>,
186) -> Result<Option<ArchiveResult>, RecallError> {
187 let conv = adapter.parse(transcript)?;
188 if archived.contains(&conv.session_id) {
189 return Ok(None);
190 }
191 let summary = summarize::algorithmic_summary(&conv);
192 let result =
193 archive::archive_conversation(memory_dir, &conv, &summary, adapter.source().as_str())?;
194 Ok(Some(result))
195}
196
197fn watermark_path(memory_dir: &Path, source: Source) -> PathBuf {
200 memory_dir
201 .join(WATERMARK_DIR)
202 .join(format!("{source}.watermark"))
203}
204
205#[must_use]
207pub fn read_watermark(memory_dir: &Path, source: Source) -> Option<SystemTime> {
208 let raw = std::fs::read_to_string(watermark_path(memory_dir, source)).ok()?;
209 let seconds: u64 = raw.trim().parse().ok()?;
210 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds))
211}
212
213pub fn write_watermark(memory_dir: &Path, source: Source, mark: SystemTime) {
218 let Ok(since_epoch) = mark.duration_since(SystemTime::UNIX_EPOCH) else {
219 return;
220 };
221 let path = watermark_path(memory_dir, source);
222 if let Some(parent) = path.parent() {
223 if std::fs::create_dir_all(parent).is_err() {
224 return;
225 }
226 }
227 let _ = std::fs::write(path, format!("{}\n", since_epoch.as_secs()));
228}
229
230#[derive(Debug, Default)]
237pub struct Watermark {
238 reached: Option<SystemTime>,
239 blocked: bool,
240}
241
242impl Watermark {
243 #[must_use]
244 pub fn new() -> Self {
245 Self::default()
246 }
247
248 pub fn handled(&mut self, transcript: &TranscriptRef) {
250 if !self.blocked {
251 self.reached = Some(transcript.modified);
252 }
253 }
254
255 pub fn failed(&mut self) {
257 self.blocked = true;
258 }
259
260 #[must_use]
262 pub fn reached(&self) -> Option<SystemTime> {
263 self.reached
264 }
265
266 pub fn commit(&self, memory_dir: &Path, source: Source) {
268 if let Some(mark) = self.reached {
269 write_watermark(memory_dir, source, mark);
270 }
271 }
272}
273
274pub fn sweep(
282 memory_dir: &Path,
283 adapter: &dyn Transcript,
284 options: CaptureOptions,
285) -> Result<CaptureReport, RecallError> {
286 let mut archived_ids = archived_sessions(memory_dir);
287 let found = pending(memory_dir, adapter, &archived_ids, options)?;
288 let mut report = CaptureReport {
289 active: found.active,
290 duplicates: found.duplicates,
291 ..CaptureReport::default()
292 };
293 let mut watermark = Watermark::new();
294
295 for transcript in &found.ready {
296 match archive_transcript(memory_dir, adapter, transcript, &archived_ids) {
297 Ok(None) => {
298 report.duplicates += 1;
299 watermark.handled(transcript);
300 }
301 Ok(Some(result)) => {
302 archived_ids.insert(result.session_id.clone());
303 if result.log_number == 0 {
304 report.empty += 1;
305 } else {
306 report.archived.push(result.log_number);
307 archive::graph_ingest(memory_dir, &result);
308 }
309 watermark.handled(transcript);
310 }
311 Err(err) => {
312 eprintln!(
313 "recall-echo: skipping {} session {} \u{2014} {err}",
314 adapter.source(),
315 transcript.session_id
316 );
317 report.failed += 1;
318 watermark.failed();
319 }
320 }
321 }
322
323 watermark.commit(memory_dir, adapter.source());
324 if report.did_something() {
325 archive::pipeline_sync_on_archive(memory_dir);
326 }
327 Ok(report)
328}
329
330pub fn ingest(memory_dir: &Path, sources: &[Source]) -> Result<(), RecallError> {
335 if !memory_dir.join("conversations").exists() {
336 return Err(RecallError::NotInitialized(
337 "conversations/ directory not found. Run `recall-echo init` first.".into(),
338 ));
339 }
340
341 let config = crate::config::load_from_dir(memory_dir);
342 let options = CaptureOptions::from_config(&config.capture);
343 let mut total = 0usize;
344
345 for source in sources {
346 let Some(adapter) = adapter_for(*source) else {
347 continue;
348 };
349 if !adapter.is_installed() {
350 eprintln!(
351 "recall-echo: {source} has no sessions at {}",
352 adapter.sessions_root().display()
353 );
354 continue;
355 }
356 let report = sweep(memory_dir, adapter.as_ref(), options)?;
357 total += report.archived.len();
358 match report.summary(*source) {
359 Some(line) => eprintln!("recall-echo: {line}"),
360 None => eprintln!("recall-echo: no new {source} sessions"),
361 }
362 }
363
364 if total == 0 {
365 eprintln!("recall-echo: nothing new to import");
366 }
367 Ok(())
368}
369
370#[must_use]
376pub fn configured_sources(config: &CaptureSection) -> Vec<Source> {
377 match config.sources {
378 Some(ref sources) if !sources.is_empty() => sources.clone(),
379 _ => crate::transcript::detect_installed()
380 .iter()
381 .map(|adapter| adapter.source())
382 .collect(),
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::transcript::CodexTranscripts;
390
391 const ROLLOUT: &str = concat!(
392 r#"{"timestamp":"2026-08-05T22:29:00.878Z","type":"session_meta","payload":{"session_id":"SESSION","cwd":"/tmp/probe"}}"#,
393 "\n",
394 r#"{"timestamp":"2026-08-05T22:29:02.329Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"a question about the parser"}]}}"#,
395 "\n",
396 r#"{"timestamp":"2026-08-05T22:29:04.028Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"an answer"}]}}"#,
397 "\n",
398 );
399
400 struct Fixture {
401 _tmp: tempfile::TempDir,
402 memory: PathBuf,
403 sessions: PathBuf,
404 }
405
406 impl Fixture {
407 fn new() -> Self {
408 let tmp = tempfile::tempdir().unwrap();
409 let memory = tmp.path().join("memory");
410 std::fs::create_dir_all(memory.join("conversations")).unwrap();
411 let sessions = tmp.path().join("sessions");
412 std::fs::create_dir_all(sessions.join("2026/08/05")).unwrap();
413 Self {
414 _tmp: tmp,
415 memory,
416 sessions,
417 }
418 }
419
420 fn write_session(&self, uuid: &str) -> PathBuf {
421 let path = self
422 .sessions
423 .join("2026/08/05")
424 .join(format!("rollout-2026-08-05T22-29-00-{uuid}.jsonl"));
425 std::fs::write(&path, ROLLOUT.replace("SESSION", uuid)).unwrap();
426 path
427 }
428
429 fn adapter(&self) -> CodexTranscripts {
430 CodexTranscripts::new(self.sessions.clone())
431 }
432 }
433
434 fn settled() -> CaptureOptions {
435 CaptureOptions {
436 settle: Duration::from_secs(0),
437 now: SystemTime::now(),
438 }
439 }
440
441 #[test]
442 fn a_finished_session_is_archived_once_and_never_again() {
443 let fixture = Fixture::new();
444 fixture.write_session("019fd40b-55d5-7a72-8ecb-611abc36879e");
445 let adapter = fixture.adapter();
446
447 let first = sweep(&fixture.memory, &adapter, settled()).unwrap();
448 assert_eq!(first.archived, vec![1]);
449 assert!(fixture
450 .memory
451 .join("conversations/conversation-001.md")
452 .exists());
453
454 let second = sweep(&fixture.memory, &adapter, settled()).unwrap();
455 assert!(second.archived.is_empty());
456 assert_eq!(
457 std::fs::read_dir(fixture.memory.join("conversations"))
458 .unwrap()
459 .count(),
460 1
461 );
462 }
463
464 #[test]
467 fn losing_the_watermark_does_not_cause_a_second_copy() {
468 let fixture = Fixture::new();
469 fixture.write_session("019fd40b-55d5-7a72-8ecb-611abc36879e");
470 let adapter = fixture.adapter();
471
472 sweep(&fixture.memory, &adapter, settled()).unwrap();
473 std::fs::remove_dir_all(fixture.memory.join(WATERMARK_DIR)).unwrap();
474
475 let again = sweep(&fixture.memory, &adapter, settled()).unwrap();
476 assert!(again.archived.is_empty());
477 assert_eq!(again.duplicates, 1);
478 }
479
480 #[test]
481 fn the_watermark_records_the_last_transcript_handled() {
482 let fixture = Fixture::new();
483 let path = fixture.write_session("019fd40b-55d5-7a72-8ecb-611abc36879e");
484 let adapter = fixture.adapter();
485
486 sweep(&fixture.memory, &adapter, settled()).unwrap();
487
488 let mark = read_watermark(&fixture.memory, Source::Codex).expect("a watermark");
489 let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
490 assert!(
492 modified.duration_since(mark).unwrap_or_default() < Duration::from_secs(1),
493 "watermark {mark:?} vs file {modified:?}"
494 );
495 }
496
497 #[test]
498 fn a_live_session_is_left_alone_until_it_settles() {
499 let fixture = Fixture::new();
500 fixture.write_session("019fd40b-55d5-7a72-8ecb-611abc36879e");
501 let adapter = fixture.adapter();
502
503 let options = CaptureOptions {
504 settle: Duration::from_secs(3600),
505 now: SystemTime::now(),
506 };
507 let report = sweep(&fixture.memory, &adapter, options).unwrap();
508 assert!(report.archived.is_empty());
509 assert_eq!(report.active, 1);
510 assert!(read_watermark(&fixture.memory, Source::Codex).is_none());
511 }
512
513 #[test]
514 fn each_session_becomes_its_own_archive() {
515 let fixture = Fixture::new();
516 fixture.write_session("019fd40b-55d5-7a72-8ecb-611abc36879e");
517 fixture.write_session("019fd40c-55d5-7a72-8ecb-611abc36879e");
518 let adapter = fixture.adapter();
519
520 let report = sweep(&fixture.memory, &adapter, settled()).unwrap();
521 assert_eq!(report.archived.len(), 2);
522
523 let index = std::fs::read_to_string(fixture.memory.join("ARCHIVE.md")).unwrap();
524 assert!(index.contains("| 001 |"), "{index}");
525 assert!(index.contains("| 002 |"), "{index}");
526 }
527
528 #[test]
529 fn the_archive_records_which_cli_it_came_from() {
530 let fixture = Fixture::new();
531 fixture.write_session("019fd40b-55d5-7a72-8ecb-611abc36879e");
532 sweep(&fixture.memory, &fixture.adapter(), settled()).unwrap();
533
534 let archive =
535 std::fs::read_to_string(fixture.memory.join("conversations/conversation-001.md"))
536 .unwrap();
537 assert!(archive.contains("source: \"codex\""), "{archive}");
538 assert!(
539 archive.contains("session_id: \"019fd40b-55d5-7a72-8ecb-611abc36879e\""),
540 "{archive}"
541 );
542 }
543
544 #[test]
545 fn a_failed_transcript_pins_the_watermark_before_it() {
546 let epoch = SystemTime::UNIX_EPOCH;
547 let at = |secs: u64| TranscriptRef {
548 source: Source::Codex,
549 session_id: format!("s{secs}"),
550 path: PathBuf::from("/tmp/x"),
551 modified: epoch + Duration::from_secs(secs),
552 cwd: None,
553 };
554
555 let mut watermark = Watermark::new();
556 watermark.handled(&at(10));
557 watermark.failed();
558 watermark.handled(&at(30));
559
560 assert_eq!(watermark.reached(), Some(epoch + Duration::from_secs(10)));
561 }
562
563 #[test]
564 fn a_watermark_survives_a_round_trip() {
565 let tmp = tempfile::tempdir().unwrap();
566 assert!(read_watermark(tmp.path(), Source::Grok).is_none());
567
568 let mark = SystemTime::UNIX_EPOCH + Duration::from_secs(1_754_432_940);
569 write_watermark(tmp.path(), Source::Grok, mark);
570 assert_eq!(read_watermark(tmp.path(), Source::Grok), Some(mark));
571 }
572
573 #[test]
574 fn configured_sources_prefer_the_config_over_detection() {
575 let config = CaptureSection {
576 sources: Some(vec![Source::Grok]),
577 ..CaptureSection::default()
578 };
579 assert_eq!(configured_sources(&config), vec![Source::Grok]);
580 }
581
582 #[test]
583 fn ingest_refuses_an_uninitialized_memory_directory() {
584 let tmp = tempfile::tempdir().unwrap();
585 assert!(ingest(tmp.path(), &[Source::Codex]).is_err());
586 }
587}