Skip to main content

recall_echo/
capture.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//! Capture — importing sessions from the agent CLIs a user actually runs.
6//!
7//! [`crate::transcript`] says where each CLI's sessions live and how to read
8//! one. This module decides *which* of them to read and turns them into
9//! archives, for the two callers that need it: `recall-echo ingest` on demand,
10//! and the daemon's background sweep once the machine is quiet.
11//!
12//! # Not ingesting anything twice
13//!
14//! Two filters, and the cheap one is not the authoritative one.
15//!
16//! - A **watermark** per CLI (`capture/<cli>.watermark`) holds the last write
17//!   time already dealt with, so a sweep does not re-read a year of transcripts
18//!   to learn it has nothing to do. It advances only over transcripts that were
19//!   actually handled, and stops advancing at the first failure — losing a
20//!   watermark costs a rescan, and that is the only thing it may ever cost.
21//! - The **archives themselves** decide. A session whose id already appears in
22//!   `conversations/` is skipped, and the check is made twice: once on the id
23//!   discovery derived from the file's name, and again on the id the parsed
24//!   transcript reports, in case a CLI's two answers ever disagree.
25//!
26//! # Sessions that are still going
27//!
28//! A transcript is a live file. Importing one mid-session would archive half a
29//! conversation and then mark that session captured forever, so a transcript is
30//! only imported once it has been untouched for `[capture] settle_secs`. That
31//! is also what stops `recall-echo ingest`, run from inside a Codex or Grok
32//! session, from capturing the session it is being run from.
33
34use 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
44/// Directory holding one watermark file per CLI.
45const WATERMARK_DIR: &str = "capture";
46
47// ── Options ──────────────────────────────────────────────────────────────
48
49/// How a sweep decides what is ready.
50#[derive(Debug, Clone, Copy)]
51pub struct CaptureOptions {
52    /// How long a transcript must have been untouched to count as finished.
53    pub settle: Duration,
54    /// The instant the sweep is reasoning about. Injectable so a test does not
55    /// have to wait for a file to age.
56    pub now: SystemTime,
57}
58
59impl CaptureOptions {
60    /// The options a `[capture]` section describes, as of now.
61    #[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// ── What a sweep found ───────────────────────────────────────────────────
77
78/// The transcripts of one CLI, sorted into what a sweep may do with them.
79#[derive(Debug, Default, Clone, PartialEq, Eq)]
80pub struct Pending {
81    /// Finished, unarchived transcripts, oldest first.
82    pub ready: Vec<TranscriptRef>,
83    /// Transcripts still being written.
84    pub active: u32,
85    /// Transcripts whose session is already archived.
86    pub duplicates: u32,
87}
88
89/// What one CLI's sweep did.
90#[derive(Debug, Default, Clone, PartialEq, Eq)]
91pub struct CaptureReport {
92    /// Log numbers written, in the order they were written.
93    pub archived: Vec<u32>,
94    /// Transcripts that held no user turn, so there was nothing to archive.
95    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    /// One line for a log or a terminal, or `None` when there is nothing to
108    /// say — a sweep that found no work stays silent.
109    #[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
142// ── Selection ────────────────────────────────────────────────────────────
143
144/// The transcripts of one CLI that are worth reading right now.
145///
146/// `archived` is passed in rather than read here so a sweep over several CLIs
147/// scans `conversations/` once instead of once per CLI.
148pub 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/// Session ids already represented in `conversations/`.
170#[must_use]
171pub fn archived_sessions(memory_dir: &Path) -> HashSet<String> {
172    archive::collect_archived_sessions(&memory_dir.join("conversations"))
173}
174
175// ── Archiving one transcript ─────────────────────────────────────────────
176
177/// Read one transcript and archive it.
178///
179/// `Ok(None)` means the transcript turned out to be already archived under the
180/// id its contents report — the second half of the double-ingest check.
181pub 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
197// ── Watermarks ───────────────────────────────────────────────────────────
198
199fn watermark_path(memory_dir: &Path, source: Source) -> PathBuf {
200    memory_dir
201        .join(WATERMARK_DIR)
202        .join(format!("{source}.watermark"))
203}
204
205/// The last write time this CLI has been swept up to, if any.
206#[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
213/// Record how far this CLI has been swept.
214///
215/// Best effort by design: an unwritable watermark means the next sweep rescans
216/// and finds the same archives already there, which is slow, not wrong.
217pub 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/// Tracks how far a sweep may claim to have got.
231///
232/// The rule is one sentence: never past a transcript that failed. A failure
233/// pins the watermark to just before it, so the next sweep tries that
234/// transcript again — and the archive check keeps the ones that succeeded
235/// after it from being imported twice.
236#[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    /// A transcript was dealt with — archived, empty, or a known duplicate.
249    pub fn handled(&mut self, transcript: &TranscriptRef) {
250        if !self.blocked {
251            self.reached = Some(transcript.modified);
252        }
253    }
254
255    /// A transcript failed; the watermark stops here.
256    pub fn failed(&mut self) {
257        self.blocked = true;
258    }
259
260    /// How far the sweep got, if anywhere.
261    #[must_use]
262    pub fn reached(&self) -> Option<SystemTime> {
263        self.reached
264    }
265
266    /// Persist the mark, when there is one.
267    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
274// ── The synchronous sweep (the `ingest` command) ─────────────────────────
275
276/// Import one CLI's finished, unarchived sessions.
277///
278/// Each transcript is archived and ingested into the graph before the next one
279/// is read, so an interrupted import leaves complete archives and a watermark
280/// that points at the last of them.
281pub 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
330/// Import every configured CLI's finished sessions, reporting to stderr.
331///
332/// This is `recall-echo ingest`. A CLI that is not installed is not an error:
333/// it has no sessions, which is exactly nothing to import.
334pub 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/// The CLIs `ingest` and the daemon sweep work on, given a config.
371///
372/// An explicit `[capture] sources` wins; otherwise every CLI that has recorded
373/// sessions on this machine is captured, which is the behaviour that makes the
374/// memory lifecycle mechanical for a user who never read the docs.
375#[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    /// Even with the watermark thrown away, the archives themselves must
465    /// prevent a second copy.
466    #[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        // Second granularity: the watermark may trail the file by under a second.
491        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}