| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | | use crate::event::Event; |
| 7 | | use anyhow::{bail, Result}; |
| 8 | | use std::path::Path; |
| 9 | |
|
| 10 | |
|
| 11 | | pub const MAX_RULES: usize = 64 * 1024; |
| 12 | |
|
| 13 | |
|
| 14 | | #[derive(Clone, Debug, PartialEq, Eq)] |
| 15 | | pub struct Rules { |
| 16 | | pub text: String, |
| 17 | | |
| 18 | | pub version: String, |
| 19 | | } |
| 20 | |
|
| 21 | |
|
| 22 | 81× | pub fn compile(data: &Path) -> Result<Rules> { |
| 23 | 81× | let mut text = String::new(); |
| 24 | 81× | let site = data.join("rules").join("site.md"); |
| 25 | 81× | if let Ok(body) = std::fs::read_to_string(&site) { |
| 26 | 57× | if body.len() > MAX_RULES { |
| 27 | 1× | bail!("{} is over 64 KiB: the rules must stay readable in one call", site.display()); |
| 28 | 56× | } |
| 29 | 56× | text.push_str("# House rules: site (rules/site.md)\n\n"); |
| 30 | 56× | text.push_str(body.trim_end()); |
| 31 | 56× | text.push('\n'); |
| 32 | 24× | } |
| 33 | 80× | let version = blake3::hash(text.as_bytes()).to_hex()[..12].to_string(); |
| 34 | 80× | Ok(Rules { text, version }) |
| 35 | 81× | } |
| 36 | |
|
| 37 | |
|
| 38 | 15× | pub async fn last_read(pool: &sqlx::SqlitePool, agent: &str) -> sqlx::Result<Option<String>> { |
| 39 | 15× | sqlx::query_scalar("SELECT version FROM rules_read WHERE agent = ?").bind(agent).fetch_optional(pool).await |
| 40 | 15× | } |
| 41 | |
|
| 42 | |
|
| 43 | 2× | pub fn changed_line(read: &str, now: &str) -> String { |
| 44 | 2× | format!("House rules changed ({read} → {now}): call rules before continuing.") |
| 45 | 2× | } |
| 46 | |
|
| 47 | |
|
| 48 | 42088× | pub async fn project(conn: &mut sqlx::SqliteConnection, e: &Event) -> sqlx::Result<()> { |
| 49 | 42088× | let p = e.json(); |
| 50 | 42088× | let agent = e.actor.strip_prefix("agent:"); |
| 51 | 42088× | match e.kind.as_str() { |
| 52 | 42088× | "rules.read" => { |
| 53 | 3× | if let (Some(a), Some(v)) = (agent, p.get("version").and_then(|v| v.as_str())) { |
| 54 | 3× | sqlx::query("INSERT INTO rules_read (agent, version, at_ms) VALUES (?, ?, ?) ON CONFLICT(agent) DO UPDATE SET version = excluded.version, at_ms = excluded.at_ms") |
| 55 | 3× | .bind(a) |
| 56 | 3× | .bind(v) |
| 57 | 3× | .bind(e.at_ms as i64) |
| 58 | 3× | .execute(&mut *conn) |
| 59 | 3× | .await?; |
| 60 | 0× | } |
| 61 | | } |
| 62 | 42085× | "tool.called" => { |
| 63 | 3332× | let s = |k: &str| p.get(k).and_then(|v| v.as_str()).map(String::from); |
| 64 | 3292× | if let (Some(a), Some(tool)) = (agent, s("tool")) { |
| 65 | 20× | sqlx::query("INSERT OR IGNORE INTO tool_calls (seq, agent, tool, args, result, ms, is_error, read, session, at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") |
| 66 | 20× | .bind(e.seq as i64) |
| 67 | 20× | .bind(a) |
| 68 | 20× | .bind(tool) |
| 69 | 20× | .bind(s("args").unwrap_or_default()) |
| 70 | 20× | .bind(s("result").unwrap_or_default()) |
| 71 | 20× | .bind(p.get("ms").and_then(|v| v.as_i64()).unwrap_or(0)) |
| 72 | 20× | .bind(p.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false)) |
| 73 | 20× | .bind(p.get("read").and_then(|v| v.as_bool()).unwrap_or(false)) |
| 74 | 20× | .bind(&e.session) |
| 75 | 20× | .bind(e.at_ms as i64) |
| 76 | 20× | .execute(&mut *conn) |
| 77 | 20× | .await?; |
| 78 | 3272× | } |
| 79 | | } |
| 80 | 38793× | _ => {} |
| 81 | | } |
| 82 | 42088× | Ok(()) |
| 83 | 42088× | } |
| 84 | |
|
| 85 | | #[cfg(test)] |
| 86 | | mod tests { |
| 87 | | use super::*; |
| 88 | |
|
| 89 | | #[test] |
| 90 | 1× | fn the_version_follows_the_text_exactly() { |
| 91 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 92 | 1× | let empty = compile(dir.path()).unwrap(); |
| 93 | 1× | assert_eq!(empty.text, ""); |
| 94 | 1× | std::fs::create_dir_all(dir.path().join("rules")).unwrap(); |
| 95 | 1× | std::fs::write(dir.path().join("rules/site.md"), "Commit with oxy.\n").unwrap(); |
| 96 | 1× | let a = compile(dir.path()).unwrap(); |
| 97 | 1× | assert!(a.text.contains("Commit with oxy.") && a.text.starts_with("# House rules: site")); |
| 98 | 1× | std::fs::write(dir.path().join("rules/site.md"), "Commit with oxy!\n").unwrap(); |
| 99 | 1× | let b = compile(dir.path()).unwrap(); |
| 100 | 1× | assert_ne!(a.version, b.version); |
| 101 | 1× | assert_eq!(a.version.len(), 12); |
| 102 | 1× | std::fs::write(dir.path().join("rules/site.md"), "x".repeat(MAX_RULES + 1)).unwrap(); |
| 103 | 1× | assert!(compile(dir.path()).is_err()); |
| 104 | 1× | } |
| 105 | | } |