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            eprintln!("[atman] confession index insert failed (id={id}): {e}");
105        }
106        Ok(id)
107    }
108
109    fn redact_if_needed(&self, mut c: Confession) -> Confession {
110        let Some(r) = &self.redactor else {
111            return c;
112        };
113        c.trigger = r.redact(&c.trigger).0;
114        c.rule_violated = r.redact(&c.rule_violated).0;
115        c.what_i_did = r.redact(&c.what_i_did).0;
116        c.why = r.redact(&c.why).0;
117        c.mitigation = r.redact(&c.mitigation).0;
118        c
119    }
120
121    pub async fn find_by_trigger_fts(
122        &self,
123        query: &str,
124    ) -> Result<Option<Vec<Confession>>, RuntimeError> {
125        let Some(idx) = self.anchor_index.as_deref() else {
126            return Ok(None);
127        };
128        let conn = idx.conn();
129        let mut stmt = conn
130            .prepare(
131                "SELECT c.id, c.trigger, c.rule_violated, c.what_i_did, c.why, c.mitigation, c.created_at \
132                 FROM confessions c \
133                 JOIN confessions_fts f ON f.rowid = c.rowid \
134                 WHERE f.confessions_fts MATCH ? \
135                 ORDER BY c.rowid",
136            )
137            .map_err(|e| RuntimeError::ToolFailed(format!("fts prepare: {e}")))?;
138        let rows = stmt
139            .query_map(rusqlite::params![query], |row| {
140                let created_at: String = row.get(6)?;
141                let created = chrono::DateTime::parse_from_rfc3339(&created_at)
142                    .map(|d| d.with_timezone(&chrono::Utc))
143                    .unwrap_or_else(|_| chrono::Utc::now());
144                let id_str: String = row.get(0)?;
145                let id = uuid::Uuid::parse_str(&id_str)
146                    .map(MemoryId)
147                    .unwrap_or_else(|_| MemoryId::now());
148                Ok(Confession {
149                    id,
150                    trigger: row.get(1)?,
151                    rule_violated: row.get(2)?,
152                    what_i_did: row.get(3)?,
153                    why: row.get(4)?,
154                    mitigation: row.get(5)?,
155                    anchors: Vec::new(),
156                    created_at: created,
157                })
158            })
159            .map_err(|e| RuntimeError::ToolFailed(format!("fts query: {e}")))?;
160        let mut out = Vec::new();
161        for r in rows {
162            match r {
163                Ok(c) => out.push(c),
164                Err(e) => return Err(RuntimeError::ToolFailed(format!("fts row: {e}"))),
165            }
166        }
167        Ok(Some(out))
168    }
169
170    pub async fn list(&self) -> Result<Vec<Confession>, RuntimeError> {
171        read_jsonl(&self.index_path).await
172    }
173
174    pub async fn find_by_trigger(&self, needle: &str) -> Result<Vec<Confession>, RuntimeError> {
175        if let Ok(Some(hits)) = self.find_by_trigger_fts(needle).await
176            && !hits.is_empty()
177        {
178            return Ok(hits);
179        }
180        let all = self.list().await?;
181        Ok(all
182            .into_iter()
183            .filter(|c| c.trigger.contains(needle))
184            .collect())
185    }
186
187    pub fn dir(&self) -> &Path {
188        &self.dir
189    }
190
191    pub fn index_path(&self) -> &Path {
192        &self.index_path
193    }
194}
195
196fn insert_confession(index: &AnchorIndex, c: &Confession) -> rusqlite::Result<()> {
197    let conn = index.conn();
198    let body = c.render_md();
199    conn.execute(
200        "INSERT OR REPLACE INTO confessions \
201           (id, trigger, rule_violated, what_i_did, why, mitigation, body, created_at) \
202         VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
203        rusqlite::params![
204            c.id.to_string(),
205            c.trigger,
206            c.rule_violated,
207            c.what_i_did,
208            c.why,
209            c.mitigation,
210            body,
211            c.created_at.to_rfc3339(),
212        ],
213    )?;
214    let rowid: i64 = conn.last_insert_rowid();
215    conn.execute(
216        "INSERT OR REPLACE INTO confessions_fts \
217           (rowid, trigger, rule_violated, what_i_did, why, mitigation, body) \
218         VALUES (?, ?, ?, ?, ?, ?, ?)",
219        rusqlite::params![
220            rowid,
221            c.trigger,
222            c.rule_violated,
223            c.what_i_did,
224            c.why,
225            c.mitigation,
226            body,
227        ],
228    )?;
229    for anchor in &c.anchors {
230        if let Some((kind, r)) = anchor.split_once(':') {
231            conn.execute(
232                "INSERT INTO anchors (kind, ref, subject_kind, subject_id, session_id, created_at) \
233                 VALUES (?, ?, 'confession', ?, NULL, ?)",
234                rusqlite::params![kind, r, c.id.to_string(), c.created_at.to_rfc3339()],
235            )?;
236        }
237    }
238    Ok(())
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use tempfile::TempDir;
245
246    fn sample(trigger: &str, rule: &str) -> Confession {
247        Confession {
248            id: MemoryId::now(),
249            trigger: trigger.into(),
250            rule_violated: rule.into(),
251            what_i_did: "wrote `as any`".into(),
252            why: "was in a hurry".into(),
253            mitigation: "run cargo check on every edit".into(),
254            anchors: vec![],
255            created_at: chrono::Utc::now(),
256        }
257    }
258
259    #[tokio::test]
260    async fn append_then_list_returns_confession() {
261        let dir = TempDir::new().unwrap();
262        let store = ConfessionStore::at(dir.path());
263        let id = store
264            .append(sample("you keep doing X", "no-as-any"))
265            .await
266            .unwrap();
267        let items = store.list().await.unwrap();
268        assert_eq!(items.len(), 1);
269        assert_eq!(items[0].id, id);
270    }
271
272    #[tokio::test]
273    async fn find_by_trigger_filters() {
274        let dir = TempDir::new().unwrap();
275        let store = ConfessionStore::at(dir.path());
276        store
277            .append(sample("comment discipline", "no-narrative-comments"))
278            .await
279            .unwrap();
280        store
281            .append(sample("type safety", "no-as-any"))
282            .await
283            .unwrap();
284        let hits = store.find_by_trigger("comment").await.unwrap();
285        assert_eq!(hits.len(), 1);
286        assert_eq!(hits[0].rule_violated, "no-narrative-comments");
287    }
288
289    #[tokio::test]
290    async fn empty_returns_empty() {
291        let dir = TempDir::new().unwrap();
292        let store = ConfessionStore::at(dir.path());
293        assert!(store.list().await.unwrap().is_empty());
294    }
295
296    #[tokio::test]
297    async fn append_with_index_populates_confessions_and_fts() {
298        let dir = TempDir::new().unwrap();
299        let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
300        let store = ConfessionStore::at(dir.path()).with_index(index.clone());
301        let mut c = sample("comment discipline yet again", "no-narrative-comments");
302        c.anchors = vec!["flow_run:00000000-0000-0000-0000-000000000001".into()];
303        store.append(c).await.unwrap();
304
305        let conn = index.conn();
306        let count: i64 = conn
307            .query_row(
308                "SELECT COUNT(*) FROM confessions",
309                rusqlite::params![],
310                |r| r.get(0),
311            )
312            .unwrap();
313        assert_eq!(count, 1);
314
315        let fts_hit: i64 = conn
316            .query_row(
317                "SELECT COUNT(*) FROM confessions_fts WHERE confessions_fts MATCH ?",
318                rusqlite::params!["narrative"],
319                |r| r.get(0),
320            )
321            .unwrap();
322        assert_eq!(fts_hit, 1, "fts should find `narrative` in rule_violated");
323
324        let anchor_count: i64 = conn
325            .query_row(
326                "SELECT COUNT(*) FROM anchors WHERE kind='flow_run'",
327                rusqlite::params![],
328                |r| r.get(0),
329            )
330            .unwrap();
331        assert_eq!(anchor_count, 1);
332    }
333
334    #[tokio::test]
335    async fn find_by_trigger_fts_returns_none_without_index() {
336        let dir = TempDir::new().unwrap();
337        let store = ConfessionStore::at(dir.path());
338        assert!(store.find_by_trigger_fts("x").await.unwrap().is_none());
339    }
340
341    #[tokio::test]
342    async fn find_by_trigger_fts_returns_matching_rows() {
343        let dir = TempDir::new().unwrap();
344        let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
345        let store = ConfessionStore::at(dir.path()).with_index(index);
346        store
347            .append(sample("boot flow crash", "no-panic-in-boot"))
348            .await
349            .unwrap();
350        store
351            .append(sample("type safety again", "no-as-any"))
352            .await
353            .unwrap();
354        let hits = store.find_by_trigger_fts("boot").await.unwrap().unwrap();
355        assert_eq!(hits.len(), 1);
356        assert_eq!(hits[0].rule_violated, "no-panic-in-boot");
357    }
358
359    #[tokio::test]
360    async fn append_writes_md_body_alongside_index() {
361        let dir = TempDir::new().unwrap();
362        let store = ConfessionStore::at(dir.path());
363        store
364            .append(sample("comment discipline again", "no-narrative-comments"))
365            .await
366            .unwrap();
367        let mut md_files = tokio::fs::read_dir(dir.path()).await.unwrap();
368        let mut found_md = false;
369        while let Some(entry) = md_files.next_entry().await.unwrap() {
370            let name = entry.file_name();
371            if name.to_string_lossy().ends_with(".md") {
372                found_md = true;
373                let body = tokio::fs::read_to_string(entry.path()).await.unwrap();
374                assert!(body.starts_with("# comment discipline again"));
375                assert!(body.contains("no-narrative-comments"));
376            }
377        }
378        assert!(found_md, "expected a `.md` body file next to the index");
379    }
380}