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