Skip to main content

tj_core/dream/
mod.rs

1//! Dream — offline memory passes over session transcripts.
2//!
3//! Pass A (backfill): re-read a session transcript and append the
4//! significant typed events the realtime classifier missed. Additive —
5//! the JSONL source of truth is never mutated.
6
7pub mod agent_sdk;
8pub mod backend;
9pub mod backfill;
10pub mod http;
11pub mod prompt;
12pub mod scope;
13pub mod state;
14
15use crate::dream::backend::{BackfillInput, DreamBackend};
16
17pub struct DreamOptions {
18    pub project_hash: String,
19    /// If true, do not call the backend or write anything; report scope only.
20    pub dry_run: bool,
21}
22
23#[derive(Debug, Default, PartialEq)]
24pub struct DreamReport {
25    pub sessions_processed: usize,
26    pub events_backfilled: usize,
27}
28
29/// Run one dream Pass A over the given sessions, using the supplied
30/// backend. `sessions` is a list of (session_id, BackfillInput) the
31/// caller has already assembled from transcripts + existing events.
32pub fn run_dream(
33    conn: &rusqlite::Connection,
34    events_path: &std::path::Path,
35    opts: &DreamOptions,
36    backend: &dyn DreamBackend,
37    sessions: Vec<(String, BackfillInput)>,
38    run_id: &str,
39) -> anyhow::Result<DreamReport> {
40    let mut report = DreamReport::default();
41    for (session_id, input) in sessions {
42        report.sessions_processed += 1;
43        if opts.dry_run {
44            continue;
45        }
46        let proposed = backend.backfill(&input)?;
47        // Flatten existing texts across candidate tasks for the guard.
48        let existing: Vec<String> = input
49            .tasks
50            .iter()
51            .flat_map(|t| t.existing_events.clone())
52            .collect();
53        let kept = crate::dream::backfill::dedup_guard(proposed, &existing);
54        let mut writer = crate::storage::JsonlWriter::open(events_path)?;
55        for b in &kept {
56            let e = crate::dream::backfill::to_event(b, run_id, &session_id);
57            writer.append(&e)?;
58            crate::db::upsert_task_from_event(conn, &e, &opts.project_hash)?;
59            crate::db::index_event(conn, &e)?;
60            report.events_backfilled += 1;
61        }
62        writer.flush_durable()?;
63    }
64    Ok(report)
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use crate::dream::backend::{BackfillEvent, BackfillTaskContext, MockDreamBackend};
71    use crate::event::{Author, Event, EventType, Source};
72    use tempfile::TempDir;
73
74    fn task_input() -> (String, BackfillInput) {
75        (
76            "sess-1".to_string(),
77            BackfillInput {
78                tasks: vec![BackfillTaskContext {
79                    task_id: "tj-1".into(),
80                    title: "Demo".into(),
81                    existing_events: vec!["Already known fact.".into()],
82                }],
83                transcript: "user: ...\nassistant: ...".into(),
84            },
85        )
86    }
87
88    #[test]
89    fn run_dream_appends_novel_events_and_indexes() {
90        let d = TempDir::new().unwrap();
91        let conn = crate::db::open(d.path().join("s.sqlite")).unwrap();
92        let events_path = d.path().join("events.jsonl");
93
94        // Seed the task so upsert/index has a home (open event).
95        let open = Event::new(
96            "tj-1",
97            EventType::Open,
98            Author::User,
99            Source::Cli,
100            "Demo".into(),
101        );
102        crate::db::upsert_task_from_event(&conn, &open, "ph").unwrap();
103
104        let backend = MockDreamBackend {
105            events: vec![
106                BackfillEvent {
107                    event_type: EventType::Finding,
108                    task_id: "tj-1".into(),
109                    text: "A brand new finding.".into(),
110                    timestamp: "2026-06-08T10:00:00Z".into(),
111                },
112                BackfillEvent {
113                    event_type: EventType::Finding,
114                    task_id: "tj-1".into(),
115                    text: "Already known fact.".into(), // dup → dropped
116                    timestamp: "2026-06-08T10:01:00Z".into(),
117                },
118            ],
119        };
120        let opts = DreamOptions {
121            project_hash: "ph".into(),
122            dry_run: false,
123        };
124        let report = run_dream(
125            &conn,
126            &events_path,
127            &opts,
128            &backend,
129            vec![task_input()],
130            "run-1",
131        )
132        .unwrap();
133
134        assert_eq!(report.sessions_processed, 1);
135        assert_eq!(report.events_backfilled, 1); // dup dropped
136        let body = std::fs::read_to_string(&events_path).unwrap();
137        assert!(body.contains("A brand new finding."));
138        assert!(body.contains("\"source\":\"dream\""));
139        assert!(!body.contains("\"text\":\"Already known fact.\",\"refs\""));
140    }
141
142    #[test]
143    fn dry_run_writes_nothing_and_skips_backend() {
144        let d = TempDir::new().unwrap();
145        let conn = crate::db::open(d.path().join("s.sqlite")).unwrap();
146        let events_path = d.path().join("events.jsonl");
147        let backend = MockDreamBackend { events: vec![] };
148        let opts = DreamOptions {
149            project_hash: "ph".into(),
150            dry_run: true,
151        };
152        let report = run_dream(
153            &conn,
154            &events_path,
155            &opts,
156            &backend,
157            vec![task_input()],
158            "run-1",
159        )
160        .unwrap();
161        assert_eq!(report.sessions_processed, 1);
162        assert_eq!(report.events_backfilled, 0);
163        assert!(!events_path.exists());
164    }
165}