| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | | use crate::event::Event; |
| 8 | | use anyhow::{bail, Result}; |
| 9 | |
|
| 10 | |
|
| 11 | | pub const MAX_BRIEF: 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 | 17× | pub fn check(text: &str) -> Result<()> { |
| 23 | 17× | if text.len() > MAX_BRIEF { |
| 24 | 2× | bail!("a brief is at most 64 KiB, so an agent can read it in one call ({} bytes)", text.len()); |
| 25 | 15× | } |
| 26 | 15× | Ok(()) |
| 27 | 17× | } |
| 28 | |
|
| 29 | |
|
| 30 | 267× | pub async fn layer(pool: &sqlx::SqlitePool, project: Option<&str>) -> Result<Option<String>> { |
| 31 | 267× | let (layer, p) = match project { |
| 32 | 130× | Some(p) => ("project", p), |
| 33 | 137× | None => ("overall", ""), |
| 34 | | }; |
| 35 | 267× | Ok(sqlx::query_scalar("SELECT text FROM briefs WHERE layer = ? AND project = ?").bind(layer).bind(p).fetch_optional(pool).await?) |
| 36 | 267× | } |
| 37 | |
|
| 38 | |
|
| 39 | |
|
| 40 | 132× | pub async fn compile(pool: &sqlx::SqlitePool, project: Option<&str>) -> Result<Rules> { |
| 41 | 132× | let mut text = String::new(); |
| 42 | 132× | if let Some(body) = layer(pool, None).await? { |
| 43 | 75× | text.push_str("# Overall brief\n\n"); |
| 44 | 75× | text.push_str(body.trim_end()); |
| 45 | 75× | text.push('\n'); |
| 46 | 75× | } |
| 47 | 132× | if let Some(p) = project { |
| 48 | 129× | if let Some(body) = layer(pool, Some(p)).await? { |
| 49 | 30× | if !text.is_empty() { |
| 50 | 27× | text.push('\n'); |
| 51 | 27× | } |
| 52 | 30× | text.push_str(&format!("# Project brief: {p}\n\n")); |
| 53 | 30× | text.push_str(body.trim_end()); |
| 54 | 30× | text.push('\n'); |
| 55 | 99× | } |
| 56 | 3× | } |
| 57 | 132× | let version = blake3::hash(text.as_bytes()).to_hex()[..12].to_string(); |
| 58 | 132× | Ok(Rules { text, version }) |
| 59 | 132× | } |
| 60 | |
|
| 61 | |
|
| 62 | 17× | pub async fn last_read(pool: &sqlx::SqlitePool, agent: &str) -> sqlx::Result<Option<String>> { |
| 63 | 17× | sqlx::query_scalar("SELECT version FROM rules_read WHERE agent = ?").bind(agent).fetch_optional(pool).await |
| 64 | 17× | } |
| 65 | |
|
| 66 | |
|
| 67 | 4× | pub fn changed_line(read: &str, now: &str) -> String { |
| 68 | 4× | format!("Your brief changed ({read} → {now}): call rules before continuing.") |
| 69 | 4× | } |
| 70 | |
|
| 71 | |
|
| 72 | 42757× | pub async fn project(conn: &mut sqlx::SqliteConnection, e: &Event) -> sqlx::Result<()> { |
| 73 | 42757× | let p = e.json(); |
| 74 | 42757× | let agent = e.actor.strip_prefix("agent:"); |
| 75 | 42757× | match e.kind.as_str() { |
| 76 | | |
| 77 | 42757× | "brief.set" if e.actor == "human" => { |
| 78 | 16× | let project = p.get("project").and_then(|v| v.as_str()).unwrap_or(""); |
| 79 | 16× | let layer = if project.is_empty() { "overall" } else { "project" }; |
| 80 | 16× | let text = p.get("text").and_then(|v| v.as_str()).unwrap_or(""); |
| 81 | 16× | if text.trim().is_empty() { |
| 82 | 3× | sqlx::query("DELETE FROM briefs WHERE layer = ? AND project = ?").bind(layer).bind(project).execute(&mut *conn).await?; |
| 83 | | } else { |
| 84 | 13× | sqlx::query("INSERT OR REPLACE INTO briefs (layer, project, text, seq) VALUES (?, ?, ?, ?)") |
| 85 | 13× | .bind(layer) |
| 86 | 13× | .bind(project) |
| 87 | 13× | .bind(text) |
| 88 | 13× | .bind(e.seq as i64) |
| 89 | 13× | .execute(&mut *conn) |
| 90 | 13× | .await?; |
| 91 | | } |
| 92 | | } |
| 93 | 42741× | "rules.read" => { |
| 94 | 21× | if let (Some(a), Some(v)) = (agent, p.get("version").and_then(|v| v.as_str())) { |
| 95 | 21× | 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") |
| 96 | 21× | .bind(a) |
| 97 | 21× | .bind(v) |
| 98 | 21× | .bind(e.at_ms as i64) |
| 99 | 21× | .execute(&mut *conn) |
| 100 | 21× | .await?; |
| 101 | 0× | } |
| 102 | | } |
| 103 | 42720× | "tool.called" => { |
| 104 | 3419× | let s = |k: &str| p.get(k).and_then(|v| v.as_str()).map(String::from); |
| 105 | 3321× | if let (Some(a), Some(tool)) = (agent, s("tool")) { |
| 106 | 49× | sqlx::query("INSERT OR IGNORE INTO tool_calls (seq, agent, tool, args, result, ms, is_error, read, session, at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") |
| 107 | 49× | .bind(e.seq as i64) |
| 108 | 49× | .bind(a) |
| 109 | 49× | .bind(tool) |
| 110 | 49× | .bind(s("args").unwrap_or_default()) |
| 111 | 49× | .bind(s("result").unwrap_or_default()) |
| 112 | 49× | .bind(p.get("ms").and_then(|v| v.as_i64()).unwrap_or(0)) |
| 113 | 49× | .bind(p.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false)) |
| 114 | 49× | .bind(p.get("read").and_then(|v| v.as_bool()).unwrap_or(false)) |
| 115 | 49× | .bind(&e.session) |
| 116 | 49× | .bind(e.at_ms as i64) |
| 117 | 49× | .execute(&mut *conn) |
| 118 | 49× | .await?; |
| 119 | 3272× | } |
| 120 | | } |
| 121 | 39399× | _ => {} |
| 122 | | } |
| 123 | 42757× | Ok(()) |
| 124 | 42757× | } |
| 125 | |
|
| 126 | | #[cfg(test)] |
| 127 | | mod tests { |
| 128 | | use super::*; |
| 129 | |
|
| 130 | | #[test] |
| 131 | 1× | fn a_brief_must_fit_in_one_call() { |
| 132 | 1× | assert!(check("Write for a curious twelve-year-old.").is_ok()); |
| 133 | 1× | assert!(check(&"x".repeat(MAX_BRIEF)).is_ok()); |
| 134 | 1× | assert!(check(&"x".repeat(MAX_BRIEF + 1)).is_err()); |
| 135 | 1× | assert!(changed_line("aaa", "bbb").starts_with("Your brief changed (aaa → bbb)")); |
| 136 | 1× | } |
| 137 | | } |