Skip to main content

atman_runtime/memory/
confession.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::RuntimeError;
7use crate::index::AnchorIndex;
8use crate::memory::{MemoryId, append_jsonl, read_jsonl};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Confession {
12    pub id: MemoryId,
13    pub trigger: String,
14    pub rule_violated: String,
15    pub what_i_did: String,
16    pub why: String,
17    pub mitigation: String,
18    #[serde(default)]
19    pub anchors: Vec<String>,
20    pub created_at: chrono::DateTime<chrono::Utc>,
21}
22
23impl Confession {
24    fn md_slug(&self) -> String {
25        let date = self.created_at.format("%Y-%m-%d");
26        let slug: String = self
27            .trigger
28            .chars()
29            .filter(|c| c.is_alphanumeric() || *c == '-')
30            .take(48)
31            .collect::<String>()
32            .to_lowercase();
33        let slug = if slug.is_empty() {
34            "trigger".into()
35        } else {
36            slug
37        };
38        format!("{date}-{slug}-{}.md", &self.id.to_string()[..8])
39    }
40
41    fn render_md(&self) -> String {
42        format!(
43            "# {trigger}\n\n\
44             - **id**: `{id}`\n\
45             - **rule_violated**: {rule}\n\
46             - **created_at**: {ts}\n\n\
47             ## What I did\n\n{what}\n\n\
48             ## Why\n\n{why}\n\n\
49             ## Mitigation\n\n{mit}\n",
50            trigger = self.trigger,
51            id = self.id,
52            rule = self.rule_violated,
53            ts = self.created_at.to_rfc3339(),
54            what = self.what_i_did,
55            why = self.why,
56            mit = self.mitigation,
57        )
58    }
59}
60
61pub struct ConfessionStore {
62    dir: PathBuf,
63    index_path: PathBuf,
64    anchor_index: Option<Arc<AnchorIndex>>,
65    redactor: Option<Arc<crate::redact::Redactor>>,
66}
67
68impl ConfessionStore {
69    pub fn at(scope_dir: impl AsRef<Path>) -> Self {
70        let dir = scope_dir.as_ref().to_path_buf();
71        let index_path = dir.join("confessions.jsonl");
72        Self {
73            dir,
74            index_path,
75            anchor_index: None,
76            redactor: None,
77        }
78    }
79
80    pub fn with_index(mut self, index: Arc<AnchorIndex>) -> Self {
81        self.anchor_index = Some(index);
82        self
83    }
84
85    pub fn with_redactor(mut self, redactor: Arc<crate::redact::Redactor>) -> Self {
86        self.redactor = Some(redactor);
87        self
88    }
89
90    pub async fn append(&self, confession: Confession) -> Result<MemoryId, RuntimeError> {
91        let confession = self.redact_if_needed(confession);
92        let id = confession.id.clone();
93        tokio::fs::create_dir_all(&self.dir)
94            .await
95            .map_err(|e| RuntimeError::ToolFailed(format!("mkdir {}: {e}", self.dir.display())))?;
96        let md_path = self.dir.join(confession.md_slug());
97        tokio::fs::write(&md_path, confession.render_md())
98            .await
99            .map_err(|e| RuntimeError::ToolFailed(format!("write {}: {e}", md_path.display())))?;
100        append_jsonl(&self.index_path, &confession).await?;
101        if let Some(idx) = &self.anchor_index
102            && let Err(e) = insert_confession(idx, &confession)
103        {
104            let key = format!("confession.index:{id}");
105            crate::notify!(
106                warn,
107                location = Inline,
108                stack = dedupe(key, 60_000),
109                "confession index insert failed (id={id}): {e}"
110            );
111        }
112        Ok(id)
113    }
114
115    fn redact_if_needed(&self, mut c: Confession) -> Confession {
116        let Some(r) = &self.redactor else {
117            return c;
118        };
119        c.trigger = r.redact(&c.trigger).0;
120        c.rule_violated = r.redact(&c.rule_violated).0;
121        c.what_i_did = r.redact(&c.what_i_did).0;
122        c.why = r.redact(&c.why).0;
123        c.mitigation = r.redact(&c.mitigation).0;
124        c
125    }
126
127    pub async fn find_by_trigger_fts(
128        &self,
129        query: &str,
130    ) -> Result<Option<Vec<Confession>>, RuntimeError> {
131        let Some(idx) = self.anchor_index.as_deref() else {
132            return Ok(None);
133        };
134        let conn = idx.conn();
135        let mut stmt = conn
136            .prepare(
137                "SELECT c.id, c.trigger, c.rule_violated, c.what_i_did, c.why, c.mitigation, c.created_at \
138                 FROM confessions c \
139                 JOIN confessions_fts f ON f.rowid = c.rowid \
140                 WHERE f.confessions_fts MATCH ? \
141                 ORDER BY c.rowid",
142            )
143            .map_err(|e| RuntimeError::ToolFailed(format!("fts prepare: {e}")))?;
144        let rows = stmt
145            .query_map(rusqlite::params![query], |row| {
146                let created_at: String = row.get(6)?;
147                let created = chrono::DateTime::parse_from_rfc3339(&created_at)
148                    .map(|d| d.with_timezone(&chrono::Utc))
149                    .unwrap_or_else(|_| chrono::Utc::now());
150                let id_str: String = row.get(0)?;
151                let id = uuid::Uuid::parse_str(&id_str)
152                    .map(MemoryId)
153                    .unwrap_or_else(|_| MemoryId::now());
154                Ok(Confession {
155                    id,
156                    trigger: row.get(1)?,
157                    rule_violated: row.get(2)?,
158                    what_i_did: row.get(3)?,
159                    why: row.get(4)?,
160                    mitigation: row.get(5)?,
161                    anchors: Vec::new(),
162                    created_at: created,
163                })
164            })
165            .map_err(|e| RuntimeError::ToolFailed(format!("fts query: {e}")))?;
166        let mut out = Vec::new();
167        for r in rows {
168            match r {
169                Ok(c) => out.push(c),
170                Err(e) => return Err(RuntimeError::ToolFailed(format!("fts row: {e}"))),
171            }
172        }
173        Ok(Some(out))
174    }
175
176    pub async fn list(&self) -> Result<Vec<Confession>, RuntimeError> {
177        read_jsonl(&self.index_path).await
178    }
179
180    pub async fn find_by_trigger(&self, needle: &str) -> Result<Vec<Confession>, RuntimeError> {
181        if let Ok(Some(hits)) = self.find_by_trigger_fts(needle).await
182            && !hits.is_empty()
183        {
184            return Ok(hits);
185        }
186        let all = self.list().await?;
187        Ok(all
188            .into_iter()
189            .filter(|c| c.trigger.contains(needle))
190            .collect())
191    }
192
193    pub fn dir(&self) -> &Path {
194        &self.dir
195    }
196
197    pub fn index_path(&self) -> &Path {
198        &self.index_path
199    }
200}
201
202fn insert_confession(index: &AnchorIndex, c: &Confession) -> rusqlite::Result<()> {
203    let conn = index.conn();
204    let body = c.render_md();
205    conn.execute(
206        "INSERT OR REPLACE INTO confessions \
207           (id, trigger, rule_violated, what_i_did, why, mitigation, body, created_at) \
208         VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
209        rusqlite::params![
210            c.id.to_string(),
211            c.trigger,
212            c.rule_violated,
213            c.what_i_did,
214            c.why,
215            c.mitigation,
216            body,
217            c.created_at.to_rfc3339(),
218        ],
219    )?;
220    let rowid: i64 = conn.last_insert_rowid();
221    conn.execute(
222        "INSERT OR REPLACE INTO confessions_fts \
223           (rowid, trigger, rule_violated, what_i_did, why, mitigation, body) \
224         VALUES (?, ?, ?, ?, ?, ?, ?)",
225        rusqlite::params![
226            rowid,
227            c.trigger,
228            c.rule_violated,
229            c.what_i_did,
230            c.why,
231            c.mitigation,
232            body,
233        ],
234    )?;
235    for anchor in &c.anchors {
236        if let Some((kind, r)) = anchor.split_once(':') {
237            conn.execute(
238                "INSERT INTO anchors (kind, ref, subject_kind, subject_id, session_id, created_at) \
239                 VALUES (?, ?, 'confession', ?, NULL, ?)",
240                rusqlite::params![kind, r, c.id.to_string(), c.created_at.to_rfc3339()],
241            )?;
242        }
243    }
244    Ok(())
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use tempfile::TempDir;
251
252    fn sample(trigger: &str, rule: &str) -> Confession {
253        Confession {
254            id: MemoryId::now(),
255            trigger: trigger.into(),
256            rule_violated: rule.into(),
257            what_i_did: "wrote `as any`".into(),
258            why: "was in a hurry".into(),
259            mitigation: "run cargo check on every edit".into(),
260            anchors: vec![],
261            created_at: chrono::Utc::now(),
262        }
263    }
264
265    #[tokio::test]
266    async fn append_then_list_returns_confession() {
267        let dir = TempDir::new().unwrap();
268        let store = ConfessionStore::at(dir.path());
269        let id = store
270            .append(sample("you keep doing X", "no-as-any"))
271            .await
272            .unwrap();
273        let items = store.list().await.unwrap();
274        assert_eq!(items.len(), 1);
275        assert_eq!(items[0].id, id);
276    }
277
278    #[tokio::test]
279    async fn find_by_trigger_filters() {
280        let dir = TempDir::new().unwrap();
281        let store = ConfessionStore::at(dir.path());
282        store
283            .append(sample("comment discipline", "no-narrative-comments"))
284            .await
285            .unwrap();
286        store
287            .append(sample("type safety", "no-as-any"))
288            .await
289            .unwrap();
290        let hits = store.find_by_trigger("comment").await.unwrap();
291        assert_eq!(hits.len(), 1);
292        assert_eq!(hits[0].rule_violated, "no-narrative-comments");
293    }
294
295    #[tokio::test]
296    async fn empty_returns_empty() {
297        let dir = TempDir::new().unwrap();
298        let store = ConfessionStore::at(dir.path());
299        assert!(store.list().await.unwrap().is_empty());
300    }
301
302    #[tokio::test]
303    async fn append_with_index_populates_confessions_and_fts() {
304        let dir = TempDir::new().unwrap();
305        let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
306        let store = ConfessionStore::at(dir.path()).with_index(index.clone());
307        let mut c = sample("comment discipline yet again", "no-narrative-comments");
308        c.anchors = vec!["flow_run:00000000-0000-0000-0000-000000000001".into()];
309        store.append(c).await.unwrap();
310
311        let conn = index.conn();
312        let count: i64 = conn
313            .query_row(
314                "SELECT COUNT(*) FROM confessions",
315                rusqlite::params![],
316                |r| r.get(0),
317            )
318            .unwrap();
319        assert_eq!(count, 1);
320
321        let fts_hit: i64 = conn
322            .query_row(
323                "SELECT COUNT(*) FROM confessions_fts WHERE confessions_fts MATCH ?",
324                rusqlite::params!["narrative"],
325                |r| r.get(0),
326            )
327            .unwrap();
328        assert_eq!(fts_hit, 1, "fts should find `narrative` in rule_violated");
329
330        let anchor_count: i64 = conn
331            .query_row(
332                "SELECT COUNT(*) FROM anchors WHERE kind='flow_run'",
333                rusqlite::params![],
334                |r| r.get(0),
335            )
336            .unwrap();
337        assert_eq!(anchor_count, 1);
338    }
339
340    #[tokio::test]
341    async fn find_by_trigger_fts_returns_none_without_index() {
342        let dir = TempDir::new().unwrap();
343        let store = ConfessionStore::at(dir.path());
344        assert!(store.find_by_trigger_fts("x").await.unwrap().is_none());
345    }
346
347    #[tokio::test]
348    async fn find_by_trigger_fts_returns_matching_rows() {
349        let dir = TempDir::new().unwrap();
350        let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
351        let store = ConfessionStore::at(dir.path()).with_index(index);
352        store
353            .append(sample("boot flow crash", "no-panic-in-boot"))
354            .await
355            .unwrap();
356        store
357            .append(sample("type safety again", "no-as-any"))
358            .await
359            .unwrap();
360        let hits = store.find_by_trigger_fts("boot").await.unwrap().unwrap();
361        assert_eq!(hits.len(), 1);
362        assert_eq!(hits[0].rule_violated, "no-panic-in-boot");
363    }
364
365    #[tokio::test]
366    async fn append_writes_md_body_alongside_index() {
367        let dir = TempDir::new().unwrap();
368        let store = ConfessionStore::at(dir.path());
369        store
370            .append(sample("comment discipline again", "no-narrative-comments"))
371            .await
372            .unwrap();
373        let mut md_files = tokio::fs::read_dir(dir.path()).await.unwrap();
374        let mut found_md = false;
375        while let Some(entry) = md_files.next_entry().await.unwrap() {
376            let name = entry.file_name();
377            if name.to_string_lossy().ends_with(".md") {
378                found_md = true;
379                let body = tokio::fs::read_to_string(entry.path()).await.unwrap();
380                assert!(body.starts_with("# comment discipline again"));
381                assert!(body.contains("no-narrative-comments"));
382            }
383        }
384        assert!(found_md, "expected a `.md` body file next to the index");
385    }
386}