Skip to main content

tj_core/
reminder.rs

1//! Compact, read-only reminder of the active task, re-injected after a
2//! compaction so the post-compaction agent retains its task + constraints.
3
4use rusqlite::Connection;
5
6pub const MAX_CONSTRAINTS: usize = 3;
7
8/// Most-recent OPEN task → "title + goal + up to MAX_CONSTRAINTS newest
9/// constraint texts". `None` when there is no open task. Read-only.
10pub fn active_task_reminder(conn: &Connection) -> anyhow::Result<Option<String>> {
11    let row: Option<(String, String)> = conn
12        .query_row(
13            "SELECT task_id, title FROM tasks \
14             WHERE status='open' ORDER BY last_event_at DESC LIMIT 1",
15            [],
16            |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
17        )
18        .ok();
19    let Some((task_id, title)) = row else {
20        return Ok(None);
21    };
22
23    let goal = crate::db::task_metadata(conn, &task_id)?
24        .and_then(|m| m.goal)
25        .filter(|g| !g.trim().is_empty());
26
27    // Same `events_index ei LEFT JOIN search_fts sf` shape the PreCompact
28    // marker query and the export-pr walk use; newest constraints first.
29    let mut stmt = conn.prepare(
30        "SELECT sf.text FROM events_index ei \
31         LEFT JOIN search_fts sf ON sf.event_id = ei.event_id \
32         WHERE ei.task_id = ?1 AND ei.type = 'constraint' \
33         ORDER BY ei.timestamp DESC LIMIT ?2",
34    )?;
35    let constraints: Vec<String> = stmt
36        .query_map(rusqlite::params![task_id, MAX_CONSTRAINTS as i64], |r| {
37            r.get::<_, Option<String>>(0)
38        })?
39        .filter_map(|r| r.ok().flatten())
40        .filter(|t| !t.trim().is_empty())
41        .collect();
42
43    let mut out = format!("[Active task after compaction] {task_id} — {title}");
44    if let Some(g) = goal {
45        out.push_str(&format!("\nGoal: {g}"));
46    }
47    if !constraints.is_empty() {
48        out.push_str("\nConstraints still in force:");
49        for c in &constraints {
50            out.push_str(&format!("\n  - {c}"));
51        }
52    }
53    Ok(Some(out))
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59    use crate::db;
60    use crate::event::{Author, Event, EventStatus, EventType, Source};
61
62    const PH: &str = "ph-test";
63
64    fn open_event(task: &str, title: &str) -> Event {
65        let mut e = Event::new(
66            task,
67            EventType::Open,
68            Author::User,
69            Source::Cli,
70            title.into(),
71        );
72        e.meta = serde_json::json!({ "title": title });
73        e
74    }
75
76    fn constraint_event(task: &str, text: &str, ts: &str) -> Event {
77        let mut e = Event::new(
78            task,
79            EventType::Constraint,
80            Author::Agent,
81            Source::Chat,
82            text.into(),
83        );
84        e.status = EventStatus::Confirmed;
85        e.timestamp = ts.into();
86        e
87    }
88
89    fn seed(events: &[Event]) -> (tempfile::TempDir, rusqlite::Connection) {
90        let d = tempfile::TempDir::new().unwrap();
91        let conn = db::open(d.path().join("s.sqlite")).unwrap();
92        for e in events {
93            db::upsert_task_from_event(&conn, e, PH).unwrap();
94            db::index_event(&conn, e).unwrap();
95        }
96        (d, conn)
97    }
98
99    #[test]
100    fn reminder_includes_title_goal_and_up_to_3_constraints() {
101        // Four constraints with ascending timestamps; only the 3 newest
102        // should appear, the oldest must be absent.
103        let events = vec![
104            open_event("tj-1", "Build the widget"),
105            constraint_event(
106                "tj-1",
107                "OLDEST: rate limit is 100/min",
108                "2026-06-01T00:00:00Z",
109            ),
110            constraint_event("tj-1", "API key rotates daily", "2026-06-02T00:00:00Z"),
111            constraint_event("tj-1", "Must support offline mode", "2026-06-03T00:00:00Z"),
112            constraint_event("tj-1", "NEWEST: ship before Friday", "2026-06-04T00:00:00Z"),
113        ];
114        let (_d, conn) = seed(&events);
115        db::set_task_goal(&conn, "tj-1", "Ship the dashboard widget").unwrap();
116
117        let r = active_task_reminder(&conn).unwrap().unwrap();
118        assert!(r.starts_with("[Active task after compaction]"), "got: {r}");
119        assert!(r.contains("Build the widget"), "got: {r}");
120        assert!(r.contains("Goal: Ship the dashboard widget"), "got: {r}");
121        assert!(r.contains("NEWEST: ship before Friday"), "got: {r}");
122        assert!(r.contains("Must support offline mode"), "got: {r}");
123        assert!(r.contains("API key rotates daily"), "got: {r}");
124        assert!(!r.contains("OLDEST"), "oldest constraint leaked: {r}");
125    }
126
127    #[test]
128    fn reminder_none_when_no_open_task() {
129        let (_d, conn) = seed(&[]);
130        assert!(active_task_reminder(&conn).unwrap().is_none());
131    }
132
133    #[test]
134    fn reminder_none_when_task_closed() {
135        let mut close = Event::new(
136            "tj-1",
137            EventType::Close,
138            Author::User,
139            Source::Cli,
140            "done".into(),
141        );
142        close.timestamp = "2026-06-05T00:00:00Z".into();
143        let events = vec![open_event("tj-1", "Build the widget"), close];
144        let (_d, conn) = seed(&events);
145        assert!(active_task_reminder(&conn).unwrap().is_none());
146    }
147}