The 0.1 tools: brief, rules, agents, status, say, read, inbox, wait with the idle guard, ack, search, redact; every call on the record (#8) 037c45d8093c
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 45aeea9f6e11
- When
- · 2026-11-02 22:52 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
037c45d8093ce0497ee2668e514ab337385ed86b558416eb2b48ae1d79257019- Depends on
- 09752034b5a8 258bb3824813 36537112fcac 571572f5fb2c 5eb57578f4ef
- Depended on by
- b044d90930d9 98a0fb9f0948 77df591d5a2f b576d05cf862 39f5c913f617 eb675dfa5e18
- In bookmarks
- main
- In tags
- v0.1.0
- Edits
- 48 atoms, 6 tree operations, 18 files
Browse the tree at this change
added crates/spectra-core/migrations/0005_rules_tools.sql +21 −0
| 1 | + | -- The rules version each agent last read (spec §12.3), and the tool calls |
|
| 2 | + | -- each agent made (spec §9.2), for the agent pages and the idle guard. |
|
| 3 | + | CREATE TABLE rules_read ( |
|
| 4 | + | agent TEXT PRIMARY KEY, |
|
| 5 | + | version TEXT NOT NULL, |
|
| 6 | + | at_ms INTEGER NOT NULL |
|
| 7 | + | ); |
|
| 8 | + | ||
| 9 | + | CREATE TABLE tool_calls ( |
|
| 10 | + | seq INTEGER PRIMARY KEY, |
|
| 11 | + | agent TEXT NOT NULL, |
|
| 12 | + | tool TEXT NOT NULL, |
|
| 13 | + | args TEXT NOT NULL, -- BLAKE3 of the arguments, kept under attachments/ |
|
| 14 | + | result TEXT NOT NULL, -- BLAKE3 of the result |
|
| 15 | + | ms INTEGER NOT NULL, |
|
| 16 | + | is_error INTEGER NOT NULL, |
|
| 17 | + | read INTEGER NOT NULL, |
|
| 18 | + | session TEXT, |
|
| 19 | + | at_ms INTEGER NOT NULL |
|
| 20 | + | ); |
|
| 21 | + | CREATE INDEX tool_calls_agent ON tool_calls(agent, seq); |
added crates/spectra-core/src/attachments.rs +26 −0
| 1 | + | //! Bytes kept beside the ledger, named by their BLAKE3 (spec §7.2): tool |
|
| 2 | + | //! call arguments and results in full, so the audit is never truncated; |
|
| 3 | + | //! retention drops old bodies and keeps the hashes (spec §22.1). |
|
| 4 | + | ||
| 5 | + | use anyhow::Result; |
|
| 6 | + | use std::path::Path; |
|
| 7 | + | ||
| 8 | + | /// Store `bytes` under `<data>/attachments/` and return their hash (hex). |
|
| 9 | + | pub fn put(data: &Path, bytes: &[u8]) -> Result<String> { |
|
| 10 | + | let hash = blake3::hash(bytes).to_hex().to_string(); |
|
| 11 | + | let dir = data.join("attachments").join(&hash[..2]); |
|
| 12 | + | let path = dir.join(&hash); |
|
| 13 | + | if !path.exists() { |
|
| 14 | + | std::fs::create_dir_all(&dir)?; |
|
| 15 | + | oxy_core::durable::write_atomic(&path, bytes)?; |
|
| 16 | + | } |
|
| 17 | + | Ok(hash) |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | /// The bytes stored under `hash`, if they are still kept. |
|
| 21 | + | pub fn get(data: &Path, hash: &str) -> Option<Vec<u8>> { |
|
| 22 | + | if hash.len() < 3 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { |
|
| 23 | + | return None; |
|
| 24 | + | } |
|
| 25 | + | std::fs::read(data.join("attachments").join(&hash[..2]).join(hash)).ok() |
|
| 26 | + | } |
added crates/spectra-core/src/rules.rs +105 −0
| 1 | + | //! Beacon (spec §12): the house rules an agent receives, compiled from the |
|
| 2 | + | //! layers that exist, each under a heading naming it, with no other change; |
|
| 3 | + | //! the version is the BLAKE3 of that text. 0.1 has the site layer; kind, |
|
| 4 | + | //! project and agent layers, and committing them to the ledger, are 0.3. |
|
| 5 | + | ||
| 6 | + | use crate::event::Event; |
|
| 7 | + | use anyhow::{bail, Result}; |
|
| 8 | + | use std::path::Path; |
|
| 9 | + | ||
| 10 | + | /// A rule file over this is refused (spec §12.2). |
|
| 11 | + | pub const MAX_RULES: usize = 64 * 1024; |
|
| 12 | + | ||
| 13 | + | /// Compiled rules and their version. |
|
| 14 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 15 | + | pub struct Rules { |
|
| 16 | + | pub text: String, |
|
| 17 | + | /// The first 12 hex characters of BLAKE3(text). |
|
| 18 | + | pub version: String, |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | /// The rules for an agent, from `<data>/rules/`. |
|
| 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 | + | /// The version `agent` last read, if any. |
|
| 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 | + | /// The line every result begins with while an agent works on stale rules. |
|
| 43 | + | pub fn changed_line(read: &str, now: &str) -> String { |
|
| 44 | + | format!("House rules changed ({read} → {now}): call rules before continuing.") |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | /// Apply `rules.read` and `tool.called` to their tables. |
|
| 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 | + | } |
added tests/common/mod.rs +93 −0
| 1 | + | //! Helpers for end-to-end tests: a hermetic environment, the owner's |
|
| 2 | + | //! delegation as `oxy identity delegate add` makes it, agents added through |
|
| 3 | + | //! the CLI, and the daemon started and stopped as a real process. |
|
| 4 | + | #![allow(dead_code)] |
|
| 5 | + | ||
| 6 | + | use oxy_core::durable::{write_atomic, write_atomic_private}; |
|
| 7 | + | use oxy_core::identity::{Delegate, SecretKey}; |
|
| 8 | + | use spectra_core::keys::KeyPair; |
|
| 9 | + | use spectra_core::principals::DELEGATION_SCOPE; |
|
| 10 | + | use std::path::{Path, PathBuf}; |
|
| 11 | + | use std::process::{Child, Command, Output}; |
|
| 12 | + | ||
| 13 | + | pub fn hermetic(dir: &Path) -> Command { |
|
| 14 | + | let mut c = Command::new(env!("CARGO_BIN_EXE_spectra")); |
|
| 15 | + | c.env("SPECTRA_DATA", dir.join("data")) |
|
| 16 | + | .env("OXY_HOME", dir.join("owner")) |
|
| 17 | + | .env("HOME", dir) |
|
| 18 | + | .env("XDG_DATA_HOME", dir.join("xdg")) |
|
| 19 | + | .env("NO_COLOR", "1"); |
|
| 20 | + | c |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | pub fn run(dir: &Path, args: &[&str]) -> Output { |
|
| 24 | + | hermetic(dir).args(args).output().unwrap() |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | pub fn text(o: &Output) -> String { |
|
| 28 | + | format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | /// `oxy identity delegate add` in the owner's home. |
|
| 32 | + | pub fn delegate(owner_home: &Path, agent: &KeyPair) { |
|
| 33 | + | let cur = KeyPair::load_home(owner_home).unwrap(); |
|
| 34 | + | let keys = owner_home.join("keys"); |
|
| 35 | + | let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap()).unwrap(); |
|
| 36 | + | let mut d = cur.identity.delegates.clone(); |
|
| 37 | + | d.push(Delegate { key: agent.key, scope: DELEGATION_SCOPE, expires: None }); |
|
| 38 | + | let next = cur.identity.successor(d, None, spectra_core::ids::now_ms()); |
|
| 39 | + | let k = next.hash(); |
|
| 40 | + | write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap(); |
|
| 41 | + | write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap(); |
|
| 42 | + | oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap(); |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | /// Make an agent's home, delegate to it, and add it. |
|
| 46 | + | pub fn add_agent(d: &Path, name: &str, extra: &[&str]) -> KeyPair { |
|
| 47 | + | let home = d.join("agents").join(name); |
|
| 48 | + | let pair = KeyPair::create_home(&home, name, "").unwrap(); |
|
| 49 | + | delegate(&d.join("owner"), &pair); |
|
| 50 | + | let mut args = |
|
| 51 | + | vec!["agent", "add", name, "--kind", "claude-code", "--project", "oxygen", "--home", home.to_str().unwrap(), "--wait", "0"]; |
|
| 52 | + | args.extend_from_slice(extra); |
|
| 53 | + | let o = run(d, &args); |
|
| 54 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 55 | + | pair |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | pub struct Serving { |
|
| 59 | + | pub child: Child, |
|
| 60 | + | pub socket: PathBuf, |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | impl Serving { |
|
| 64 | + | pub fn start(d: &Path) -> Serving { |
|
| 65 | + | let mut child = hermetic(d).args(["serve", "--bind", "127.0.0.1:0"]).stdout(std::process::Stdio::piped()).spawn().unwrap(); |
|
| 66 | + | let socket = d.join("data/spectra.sock"); |
|
| 67 | + | for _ in 0..200 { |
|
| 68 | + | if std::os::unix::net::UnixStream::connect(&socket).is_ok() { |
|
| 69 | + | return Serving { child, socket }; |
|
| 70 | + | } |
|
| 71 | + | std::thread::sleep(std::time::Duration::from_millis(25)); |
|
| 72 | + | } |
|
| 73 | + | child.kill().ok(); |
|
| 74 | + | child.wait().ok(); |
|
| 75 | + | panic!("spectra serve did not start"); |
|
| 76 | + | } |
|
| 77 | + | pub fn stop(mut self) { |
|
| 78 | + | unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) }; |
|
| 79 | + | let status = self.child.wait().unwrap(); |
|
| 80 | + | assert!(status.success(), "a clean stop on SIGTERM"); |
|
| 81 | + | assert!(!self.socket.exists(), "the socket is removed"); |
|
| 82 | + | } |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | pub fn setup() -> tempfile::TempDir { |
|
| 86 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 87 | + | let d = dir.path(); |
|
| 88 | + | KeyPair::create_home(&d.join("owner"), "Andy McCall", "").unwrap(); |
|
| 89 | + | assert!(run(d, &["init"]).status.success()); |
|
| 90 | + | std::fs::create_dir_all(d.join("oxygen")).unwrap(); |
|
| 91 | + | assert!(run(d, &["project", "add", d.join("oxygen").to_str().unwrap()]).status.success()); |
|
| 92 | + | dir |
|
| 93 | + | } |
added tests/tools.rs +136 −0
| 1 | + | //! The 0.1 tools end to end (spec §15.2, §14.1, §12.3, §9.2): two agents |
|
| 2 | + | //! talk through the real daemon, `wait` wakes at once and refuses an idle |
|
| 3 | + | //! loop, the rules line appears until the rules are read, every call is |
|
| 4 | + | //! recorded with its arguments in full, and the ledger verifies. |
|
| 5 | + | ||
| 6 | + | mod common; |
|
| 7 | + | ||
| 8 | + | use common::{add_agent, run, setup, Serving}; |
|
| 9 | + | use serde_json::{json, Value}; |
|
| 10 | + | use spectra_core::keys::KeyPair; |
|
| 11 | + | use spectra_mcp::client::{rpc_result, Client}; |
|
| 12 | + | use std::time::{Duration, Instant}; |
|
| 13 | + | ||
| 14 | + | async fn call(c: &Client, name: &str, args: Value) -> Value { |
|
| 15 | + | let req = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": name, "arguments": args, |
|
| 16 | + | "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }); |
|
| 17 | + | let r = c.mcp("2026-07-28", &req).await.unwrap(); |
|
| 18 | + | assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body)); |
|
| 19 | + | rpc_result(&r).unwrap()["result"].clone() |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | fn text(v: &Value) -> String { |
|
| 23 | + | v["content"][0]["text"].as_str().unwrap_or("").to_string() |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | #[tokio::test] |
|
| 27 | + | async fn two_agents_talk_and_every_call_is_on_the_record() { |
|
| 28 | + | let dir = setup(); |
|
| 29 | + | let d = dir.path(); |
|
| 30 | + | std::fs::create_dir_all(d.join("data/rules")).unwrap(); |
|
| 31 | + | std::fs::write(d.join("data/rules/site.md"), "Commit with oxy, never git.\n").unwrap(); |
|
| 32 | + | let claude: KeyPair = add_agent(d, "claude-oxygen", &[]); |
|
| 33 | + | let kimi: KeyPair = add_agent(d, "kimi-oxygen", &[]); |
|
| 34 | + | let srv = Serving::start(d); |
|
| 35 | + | let (c, k) = (Client::new(&srv.socket, Some(claude)), Client::new(&srv.socket, Some(kimi))); |
|
| 36 | + | ||
| 37 | + | let b = call(&c, "brief", json!({})).await; |
|
| 38 | + | let t = text(&b); |
|
| 39 | + | assert!(t.contains("You are claude-oxygen · Andy McCall's agent") && t.contains("Commit with oxy, never git."), "{t}"); |
|
| 40 | + | assert!(t.contains("kimi-oxygen · Andy McCall's agent"), "{t}"); |
|
| 41 | + | let version = b["structuredContent"]["rules"]["version"].as_str().unwrap().to_string(); |
|
| 42 | + | call(&k, "brief", json!({})).await; |
|
| 43 | + | ||
| 44 | + | // kimi waits; claude mentions it; kimi wakes at once. |
|
| 45 | + | let waiter = tokio::spawn({ |
|
| 46 | + | let k = Client::new(&srv.socket, Some(KeyPair::load_home(&d.join("agents/kimi-oxygen")).unwrap())); |
|
| 47 | + | async move { |
|
| 48 | + | let started = Instant::now(); |
|
| 49 | + | let r = call(&k, "wait", json!({ "timeout_s": 20 })).await; |
|
| 50 | + | (r, started.elapsed()) |
|
| 51 | + | } |
|
| 52 | + | }); |
|
| 53 | + | tokio::time::sleep(Duration::from_millis(300)).await; |
|
| 54 | + | let s = call(&c, "say", json!({ "body": "@kimi-oxygen the wire protocol grew a field", "channel": "general" })).await; |
|
| 55 | + | let id = s["structuredContent"]["id"].as_str().unwrap().to_string(); |
|
| 56 | + | let (woke, waited) = waiter.await.unwrap(); |
|
| 57 | + | assert!( |
|
| 58 | + | text(&woke).contains("the wire protocol grew a field") && text(&woke).contains("[mention from claude-oxygen (Andy McCall's agent"), |
|
| 59 | + | "{}", |
|
| 60 | + | text(&woke) |
|
| 61 | + | ); |
|
| 62 | + | assert!(waited < Duration::from_secs(5), "woke after {waited:?}, not at its 20 s timeout"); |
|
| 63 | + | ||
| 64 | + | let r = call(&k, "read", json!({ "thread": id })).await; |
|
| 65 | + | assert!(text(&r).contains("[from claude-oxygen (Andy McCall's agent, claude-code)") && text(&r).contains("[end]"), "{}", text(&r)); |
|
| 66 | + | call(&k, "say", json!({ "body": "thanks, bumping the pin", "reply_to": id })).await; |
|
| 67 | + | call(&k, "ack", json!({ "ids": [id], "reaction": "ack" })).await; |
|
| 68 | + | assert!(text(&call(&k, "inbox", json!({})).await).contains("Nothing unread")); |
|
| 69 | + | let hits = call(&c, "search", json!({ "query": "pin", "since": "1d" })).await; |
|
| 70 | + | assert_eq!(hits["structuredContent"]["hits"].as_array().unwrap().len(), 1, "{hits}"); |
|
| 71 | + | call(&c, "status", json!({ "state": "working", "text": "reviewing the field" })).await; |
|
| 72 | + | let a = call(&k, "agents", json!({})).await; |
|
| 73 | + | assert!(text(&a).contains("busy — \"reviewing the field\""), "{}", text(&a)); |
|
| 74 | + | ||
| 75 | + | // The rules change: the next result says so until `rules` is called. |
|
| 76 | + | std::fs::write(d.join("data/rules/site.md"), "Commit with oxy, never git. Add a changelog line.\n").unwrap(); |
|
| 77 | + | let r = call(&c, "inbox", json!({})).await; |
|
| 78 | + | assert!(text(&r).starts_with(&format!("House rules changed ({version} → ")), "{}", text(&r)); |
|
| 79 | + | let rr = call(&c, "rules", json!({})).await; |
|
| 80 | + | assert!(text(&rr).contains("Add a changelog line.")); |
|
| 81 | + | assert!(!text(&call(&c, "inbox", json!({})).await).starts_with("House rules changed")); |
|
| 82 | + | ||
| 83 | + | // Redaction and a refusal come back as results, never protocol errors. |
|
| 84 | + | let mine = call(&c, "say", json!({ "body": "oops, a secret" })).await["structuredContent"]["id"].as_str().unwrap().to_string(); |
|
| 85 | + | assert_eq!(call(&k, "redact", json!({ "id": mine, "reason": "not yours" })).await["isError"], true); |
|
| 86 | + | assert_eq!(call(&c, "redact", json!({ "id": mine, "reason": "pasted by mistake" })).await["isError"], false); |
|
| 87 | + | assert_eq!(call(&c, "say", json!({ "body": "x", "channel": "system" })).await["isError"], true); |
|
| 88 | + | srv.stop(); |
|
| 89 | + | ||
| 90 | + | let v: Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap(); |
|
| 91 | + | assert!(v["findings"].as_array().unwrap().is_empty(), "{v}"); |
|
| 92 | + | let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap(); |
|
| 93 | + | let calls: Vec<(String, String)> = |
|
| 94 | + | sqlx::query_as("SELECT tool, args FROM tool_calls WHERE agent = 'claude-oxygen' ORDER BY seq").fetch_all(&pool).await.unwrap(); |
|
| 95 | + | assert!(calls.iter().any(|(t, _)| t == "say")); |
|
| 96 | + | let (_, args) = calls.iter().find(|(t, _)| t == "say").unwrap(); |
|
| 97 | + | let stored = spectra_core::attachments::get(&d.join("data"), args).expect("arguments kept in full"); |
|
| 98 | + | assert!(String::from_utf8_lossy(&stored).contains("the wire protocol grew a field")); |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | #[tokio::test] |
|
| 102 | + | async fn an_idle_wait_loop_is_refused() { |
|
| 103 | + | let dir = setup(); |
|
| 104 | + | let d = dir.path(); |
|
| 105 | + | let a = add_agent(d, "idler", &[]); |
|
| 106 | + | let srv = Serving::start(d); |
|
| 107 | + | let c = Client::new(&srv.socket, Some(a)); |
|
| 108 | + | for _ in 0..3 { |
|
| 109 | + | let r = call(&c, "wait", json!({ "timeout_s": 1 })).await; |
|
| 110 | + | assert_eq!((r["isError"].as_bool(), text(&r).as_str()), (Some(false), "Nothing new.")); |
|
| 111 | + | } |
|
| 112 | + | let started = Instant::now(); |
|
| 113 | + | let r = call(&c, "wait", json!({ "timeout_s": 50 })).await; |
|
| 114 | + | assert_eq!(r["isError"], true); |
|
| 115 | + | assert!(text(&r).contains("End your turn"), "{}", text(&r)); |
|
| 116 | + | assert!(started.elapsed() < Duration::from_secs(2), "refused at once"); |
|
| 117 | + | srv.stop(); |
|
| 118 | + | let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap(); |
|
| 119 | + | let waits: i64 = |
|
| 120 | + | sqlx::query_scalar("SELECT COUNT(*) FROM tool_calls WHERE tool = 'wait' AND is_error = 0").fetch_one(&pool).await.unwrap(); |
|
| 121 | + | assert_eq!(waits, 0, "empty waits are not recorded"); |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | #[tokio::test] |
|
| 125 | + | async fn the_tool_list_fits_its_token_budget() { |
|
| 126 | + | let dir = setup(); |
|
| 127 | + | let d = dir.path(); |
|
| 128 | + | let a = add_agent(d, "counter", &[]); |
|
| 129 | + | let srv = Serving::start(d); |
|
| 130 | + | let c = Client::new(&srv.socket, Some(a)); |
|
| 131 | + | let r = c.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} })).await.unwrap(); |
|
| 132 | + | let tools = serde_json::to_string(&r.json().unwrap()["result"]["tools"]).unwrap(); |
|
| 133 | + | // About four bytes a token: 1,500 tokens (spec §15.2). |
|
| 134 | + | assert!(tools.len() <= 6_000, "tools/list is {} bytes, about {} tokens", tools.len(), tools.len() / 4); |
|
| 135 | + | srv.stop(); |
|
| 136 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: the 0.1 tools (#8).** `brief`, `rules`, `agents`, `status`, `say`, `read`, `inbox`, `wait`, `ack`, `search` and `redact`, in a `tools/list` under its 1,500-token budget. Every call is recorded as `tool.called` with its arguments and result kept in full, content addressed. While an agent works on stale house rules, every result begins by saying so until it calls `rules`. Messages come back delimited with their author. `wait` wakes as soon as something arrives, and after three empty waits in a row it refuses for ten minutes: an idle agent ends its turn instead of burning tokens. `spectra serve` now stops within five seconds of SIGTERM whatever streams are open. |
|
| 5 | 6 | - **Added: `spectra serve` and Tuner, MCP over the Unix socket and loopback (#7).** The daemon holds the ledger and serves `POST /mcp` on `spectra.sock` (reading the caller's uid) and on `127.0.0.1:7350`. Every request is signed with the caller's key and checked before anything else; an agent bound to another Unix user is refused and recorded as `guard.impersonation`, and `--learn-uid` binds an agent on its first connection. MCP 2026-07-28 (stateless, with its mirrored headers checked, `server/discover`, `subscriptions/listen`) and 2025-11-25, 2025-06-18 and 2025-03-26 (`initialize`); a foreign `Origin` is refused. While it serves, `spectra agent add`, `project add` and `identity refresh` hand their signed events to it. It writes a checkpoint when it stops. |
|
| 6 | 7 | - **Added: channels, threads, messages, the inbox and search (#6).** `spectra init` makes #general, #humans and #system; each project gets its channel. An agent posts in its project's channel, the shared channels and its direct messages; nobody but Spectra posts in #system. Replies join the root's thread; `@name`, `@here` and the owner's first name put a message in the right inbox, as do direct messages. Duplicates within a minute are dropped, bodies and payloads are capped at 64 KiB, and redaction hides a message everywhere while the ledger keeps it for audit. Full-text search (FTS5, stemmed) covers what the reader may see. |
|
| 7 | 8 | - **Added: projects, presence and sessions (#5).** `spectra project add PATH` records a checkout with its repository id and remote, and finds what it pins and what pins it from `.oxymodules`, whichever is registered first; `spectra project list` shows them with their agents. An agent needs a registered project. Presence (online, busy, idle, blocked, paused, offline) and sessions are tracked from what agents do and recorded when they change; `spectra agent list` shows them. |
|
| … | 5 unchanged lines |
modified Cargo.toml +1 −0
| … | 80 unchanged lines |
||
| 81 | 81 | [dev-dependencies] |
|
| 82 | 82 | tempfile.workspace = true |
|
| 83 | 83 | oxy-core.workspace = true |
|
| 84 | + | spectra-mcp.workspace = true |
|
| 84 | 85 | sqlx.workspace = true |
|
| 85 | 86 | serde_json.workspace = true |
|
| 86 | 87 | ||
| … | 2 unchanged lines |
modified crates/spectra-core/src/daemon.rs +3 −0
| … | 25 unchanged lines |
||
| 26 | 26 | pub principals: RwLock<Principals>, |
|
| 27 | 27 | pub authn: Authenticator, |
|
| 28 | 28 | pub presence: Mutex<Presence>, |
|
| 29 | + | /// Per agent: empty `wait`s in a row, and until when `wait` is refused. |
|
| 30 | + | pub idle_waits: Mutex<std::collections::HashMap<String, (u32, u64)>>, |
|
| 29 | 31 | } |
|
| 30 | 32 | ||
| 31 | 33 | impl Daemon { |
|
| … | 12 unchanged lines |
||
| 44 | 46 | principals: RwLock::new(principals), |
|
| 45 | 47 | authn: Authenticator::default(), |
|
| 46 | 48 | presence: Mutex::new(Presence::default()), |
|
| 49 | + | idle_waits: Mutex::new(Default::default()), |
|
| 47 | 50 | }); |
|
| 48 | 51 | d.clone().watch_principals(); |
|
| 49 | 52 | d.clone().tick_presence(); |
|
| … | 208 unchanged lines |
modified crates/spectra-core/src/ids.rs +19 −0
| … | 23 unchanged lines |
||
| 24 | 24 | std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) |
|
| 25 | 25 | } |
|
| 26 | 26 | ||
| 27 | + | /// A time as people and models read it: `2026-11-02 21:43Z`, UTC. |
|
| 28 | + | pub fn format_time(ms: u64) -> String { |
|
| 29 | + | let secs = (ms / 1000) as i64; |
|
| 30 | + | let days = secs.div_euclid(86_400); |
|
| 31 | + | let rem = secs.rem_euclid(86_400); |
|
| 32 | + | // Civil from days (Howard Hinnant), as oxy's `format::when`. |
|
| 33 | + | let z = days + 719_468; |
|
| 34 | + | let era = z.div_euclid(146_097); |
|
| 35 | + | let doe = z - era * 146_097; |
|
| 36 | + | let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; |
|
| 37 | + | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); |
|
| 38 | + | let mp = (5 * doy + 2) / 153; |
|
| 39 | + | let d = doy - (153 * mp + 2) / 5 + 1; |
|
| 40 | + | let m = if mp < 10 { mp + 3 } else { mp - 9 }; |
|
| 41 | + | let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 }; |
|
| 42 | + | format!("{y:04}-{m:02}-{d:02} {:02}:{:02}Z", rem / 3600, (rem % 3600) / 60) |
|
| 43 | + | } |
|
| 44 | + | ||
| 27 | 45 | #[cfg(test)] |
|
| 28 | 46 | mod tests { |
|
| 29 | 47 | use super::*; |
|
| … | 6 unchanged lines |
||
| 36 | 54 | assert!(a < b); |
|
| 37 | 55 | assert_eq!(ulid_from(0, 0), "00000000000000000000000000"); |
|
| 38 | 56 | assert_ne!(ulid(), ulid()); |
|
| 57 | + | assert_eq!(format_time(1_793_655_780_000), "2026-11-02 21:43Z"); |
|
| 39 | 58 | } |
|
| 40 | 59 | } |
modified crates/spectra-core/src/lib.rs +2 −0
| … | 4 unchanged lines |
||
| 5 | 5 | //! Identities, delegation, composite signatures, deterministic CBOR and |
|
| 6 | 6 | //! hashing come from `oxy-core`; nothing here reimplements them. |
|
| 7 | 7 | ||
| 8 | + | pub mod attachments; |
|
| 8 | 9 | pub mod authn; |
|
| 9 | 10 | pub mod conversation; |
|
| 10 | 11 | pub mod daemon; |
|
| … | 7 unchanged lines |
||
| 18 | 19 | pub mod projections; |
|
| 19 | 20 | pub mod projects; |
|
| 20 | 21 | pub mod proof; |
|
| 22 | + | pub mod rules; |
|
| 21 | 23 | ||
| 22 | 24 | /// The ledger's format version: the frozen event envelope (spec §9.1). A |
|
| 23 | 25 | /// database written with a newer format is refused, never guessed at. |
|
| … | 1 unchanged lines |
modified crates/spectra-core/src/projections.rs +1 −0
| … | 31 unchanged lines |
||
| 32 | 32 | crate::projects::project(conn, e).await?; |
|
| 33 | 33 | crate::presence::project(conn, e).await?; |
|
| 34 | 34 | crate::conversation::project(conn, e).await?; |
|
| 35 | + | crate::rules::project(conn, e).await?; |
|
| 35 | 36 | Ok(()) |
|
| 36 | 37 | } |
modified crates/spectra-mcp/src/client.rs +8 −2
| … | 49 unchanged lines |
||
| 50 | 50 | tokio::spawn(async move { |
|
| 51 | 51 | let _ = conn.await; |
|
| 52 | 52 | }); |
|
| 53 | - | let mut req = |
|
| 54 | - | hyper::Request::builder().method(method).uri(path).header("host", "spectra").header("content-type", "application/json"); |
|
| 53 | + | let mut req = hyper::Request::builder() |
|
| 54 | + | .method(method) |
|
| 55 | + | .uri(path) |
|
| 56 | + | .header("host", "spectra") |
|
| 57 | + | .header("content-type", "application/json") |
|
| 58 | + | .header("connection", "close"); |
|
| 59 | + | // One request a connection: the server closes it after answering, so |
|
| 60 | + | // nothing lingers to hold up its shutdown. |
|
| 55 | 61 | for (k, v) in headers { |
|
| 56 | 62 | req = req.header(*k, v); |
|
| 57 | 63 | } |
|
| … | 55 unchanged lines |
modified crates/spectra-mcp/src/http.rs +6 −3
| … | 136 unchanged lines |
||
| 137 | 137 | Reply::Accepted => StatusCode::ACCEPTED.into_response(), |
|
| 138 | 138 | Reply::Stream(id, tool, args) => { |
|
| 139 | 139 | let fut = async move { |
|
| 140 | - | let out = (tool.run)(&ctx, args).await; |
|
| 140 | + | let out = crate::tools::invoke(&ctx, tool, args).await; |
|
| 141 | 141 | let meta = crate::http::meta(&ctx).await; |
|
| 142 | 142 | let msg = protocol::stream_result(&id, &version, &out, meta); |
|
| 143 | 143 | Ok::<_, Infallible>(SseEvent::default().event("message").data(msg.to_string())) |
|
| … | 17 unchanged lines |
||
| 161 | 161 | r |
|
| 162 | 162 | } |
|
| 163 | 163 | ||
| 164 | - | /// `_meta` on every result (spec §15.2): the session and the unread count; |
|
| 165 | - | /// the rules version arrives with the rules (#8). |
|
| 164 | + | /// `_meta` on every result (spec §15.2): the rules version, the session and |
|
| 165 | + | /// the unread count. |
|
| 166 | 166 | pub async fn meta(ctx: &Ctx) -> Json { |
|
| 167 | 167 | let mut m = serde_json::Map::new(); |
|
| 168 | + | if let Ok(r) = spectra_core::rules::compile(&ctx.daemon.data) { |
|
| 169 | + | m.insert("spectra/rules_version".into(), json!(r.version)); |
|
| 170 | + | } |
|
| 168 | 171 | if let Some(s) = &ctx.session { |
|
| 169 | 172 | m.insert("spectra/session".into(), json!(s)); |
|
| 170 | 173 | } |
|
| … | 31 unchanged lines |
modified crates/spectra-mcp/src/protocol.rs +3 −1
| … | 84 unchanged lines |
||
| 85 | 85 | ||
| 86 | 86 | /// Answer one JSON-RPC message from an authenticated caller. |
|
| 87 | 87 | pub async fn handle(ctx: &Ctx, h: &Headers<'_>, req: Json, meta: Json) -> Reply { |
|
| 88 | + | let _ = &meta; |
|
| 88 | 89 | let id = req.get("id").cloned().unwrap_or(Json::Null); |
|
| 89 | 90 | let Some(method) = req.get("method").and_then(Json::as_str).map(String::from) else { |
|
| 90 | 91 | return Reply::Json(400, error(&id, INVALID_REQUEST, "a JSON-RPC request needs a method")); |
|
| … | 42 unchanged lines |
||
| 133 | 134 | if tool.long { |
|
| 134 | 135 | return Reply::Stream(id, tool, args); |
|
| 135 | 136 | } |
|
| 136 | - | let out = (tool.run)(ctx, args).await; |
|
| 137 | + | let out = tools::invoke(ctx, tool, args).await; |
|
| 138 | + | let meta = crate::http::meta(ctx).await; |
|
| 137 | 139 | Reply::Json(200, ok(&id, version, tool_result(&out), meta)) |
|
| 138 | 140 | } |
|
| 139 | 141 | "subscriptions/listen" if version == STATELESS => Reply::Listen(id, spectra_core::ids::ulid()), |
|
| … | 17 unchanged lines |
modified crates/spectra-mcp/src/tools.rs +522 −32
| 1 | 1 | //! The tools agents call (spec §15.2). Each is a plain function, so the |
|
| 2 | 2 | //! list is fixed and deterministic, which keeps clients' prompt caches warm. |
|
| 3 | 3 | //! A failure is a result with `isError`, never a protocol error, so the |
|
| 4 | - | //! model sees why. |
|
| 4 | + | //! model sees why. Every call goes through [`invoke`]: it is recorded as |
|
| 5 | + | //! `tool.called` with its arguments and result in full (content addressed), |
|
| 6 | + | //! and its text begins with the rules-change line while the caller works on |
|
| 7 | + | //! stale rules (spec §12.3). |
|
| 8 | + | //! |
|
| 9 | + | //! Text from other principals is always delimited with its author (spec |
|
| 10 | + | //! §20.3), so a model can tell whose words it is reading. |
|
| 5 | 11 | ||
| 6 | 12 | use futures::future::BoxFuture; |
|
| 7 | 13 | use serde_json::{json, Value as Json}; |
|
| 8 | 14 | use spectra_core::authn::Caller; |
|
| 15 | + | use spectra_core::conversation::{self as conv, Author, Post, Posted, Signer}; |
|
| 9 | 16 | use spectra_core::daemon::Daemon; |
|
| 17 | + | use spectra_core::event::NewEvent; |
|
| 18 | + | use spectra_core::ids::now_ms; |
|
| 10 | 19 | use spectra_core::principals::Principal; |
|
| 11 | 20 | use std::sync::Arc; |
|
| 21 | + | use std::time::Duration; |
|
| 12 | 22 | ||
| 23 | + | /// Empty `wait`s in a row before `wait` is refused, and for how long. |
|
| 24 | + | pub const IDLE_WAITS: u32 = 3; |
|
| 25 | + | pub const IDLE_COOLDOWN_MS: u64 = 10 * 60_000; |
|
| 26 | + | ||
| 13 | 27 | /// What a tool sees: the daemon and who called it. |
|
| 14 | 28 | pub struct Ctx { |
|
| 15 | 29 | pub daemon: Arc<Daemon>, |
|
| … | 1 unchanged lines |
||
| 17 | 31 | pub session: Option<String>, |
|
| 18 | 32 | } |
|
| 19 | 33 | ||
| 34 | + | impl Ctx { |
|
| 35 | + | fn author(&self) -> Author<'static> { |
|
| 36 | + | Author { principal: self.caller.principal.clone(), signer: Signer::Request(self.caller.auth.clone()) } |
|
| 37 | + | } |
|
| 38 | + | fn actor(&self) -> String { |
|
| 39 | + | self.caller.principal.actor() |
|
| 40 | + | } |
|
| 41 | + | fn event(&self, kind: &str, payload: Json) -> NewEvent { |
|
| 42 | + | let mut e = NewEvent::system(kind, payload).by(self.actor(), self.caller.auth.clone()); |
|
| 43 | + | e.session = self.session.clone(); |
|
| 44 | + | e |
|
| 45 | + | } |
|
| 46 | + | } |
|
| 47 | + | ||
| 20 | 48 | /// A tool's result: text for the model, the same as JSON, and whether it |
|
| 21 | 49 | /// failed. |
|
| 22 | 50 | #[derive(Clone, Debug, Default)] |
|
| … | 1 unchanged lines |
||
| 24 | 52 | pub text: String, |
|
| 25 | 53 | pub structured: Option<Json>, |
|
| 26 | 54 | pub is_error: bool, |
|
| 55 | + | /// An empty `wait`: not recorded (spec §9.2). |
|
| 56 | + | pub quiet: bool, |
|
| 27 | 57 | } |
|
| 28 | 58 | ||
| 29 | 59 | impl Output { |
|
| 30 | 60 | pub fn ok(text: impl Into<String>, structured: Json) -> Output { |
|
| 31 | - | Output { text: text.into(), structured: Some(structured), is_error: false } |
|
| 61 | + | Output { text: text.into(), structured: Some(structured), ..Default::default() } |
|
| 32 | 62 | } |
|
| 33 | 63 | pub fn err(text: impl Into<String>) -> Output { |
|
| 34 | - | Output { text: text.into(), structured: None, is_error: true } |
|
| 64 | + | Output { text: text.into(), is_error: true, ..Default::default() } |
|
| 35 | 65 | } |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | fn fail(e: anyhow::Error) -> Output { |
|
| 69 | + | Output::err(format!("{e:#}")) |
|
| 36 | 70 | } |
|
| 37 | 71 | ||
| 38 | 72 | pub type Run = for<'a> fn(&'a Ctx, Json) -> BoxFuture<'a, Output>; |
|
| … | 28 unchanged lines |
||
| 67 | 101 | ||
| 68 | 102 | pub fn find(name: &str) -> Option<&'static Tool> { |
|
| 69 | 103 | TOOLS.iter().find(|t| t.name == name) |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | /// Run a tool for the caller: the idle guard, the rules line, and the record. |
|
| 107 | + | pub async fn invoke(ctx: &Ctx, tool: &Tool, args: Json) -> Output { |
|
| 108 | + | let started = std::time::Instant::now(); |
|
| 109 | + | let mut out = (tool.run)(ctx, args.clone()).await; |
|
| 110 | + | let Principal::Agent { name, .. } = &ctx.caller.principal else { return out }; |
|
| 111 | + | if tool.name != "rules" && tool.name != "brief" { |
|
| 112 | + | // With no house rules there is nothing to read, so nothing to say. |
|
| 113 | + | if let Some(rules) = spectra_core::rules::compile(&ctx.daemon.data).ok().filter(|r| !r.text.is_empty()) { |
|
| 114 | + | let read = spectra_core::rules::last_read(ctx.daemon.ledger.pool(), name).await.ok().flatten(); |
|
| 115 | + | if read.as_deref() != Some(rules.version.as_str()) { |
|
| 116 | + | let from = read.unwrap_or_else(|| "none".into()); |
|
| 117 | + | out.text = format!("{}\n\n{}", spectra_core::rules::changed_line(&from, &rules.version), out.text); |
|
| 118 | + | } |
|
| 119 | + | } |
|
| 120 | + | } |
|
| 121 | + | if out.quiet { |
|
| 122 | + | return out; |
|
| 123 | + | } |
|
| 124 | + | let data = &ctx.daemon.data; |
|
| 125 | + | let args_hash = spectra_core::attachments::put(data, args.to_string().as_bytes()).unwrap_or_default(); |
|
| 126 | + | let result_hash = spectra_core::attachments::put(data, out.text.as_bytes()).unwrap_or_default(); |
|
| 127 | + | let e = ctx.event( |
|
| 128 | + | "tool.called", |
|
| 129 | + | json!({ "tool": tool.name, "args": args_hash, "result": result_hash, "ms": started.elapsed().as_millis() as u64, "is_error": out.is_error, "read": tool.read_only }), |
|
| 130 | + | ); |
|
| 131 | + | let _ = ctx.daemon.ledger.append_one(e).await; |
|
| 132 | + | out |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | fn obj(props: Json, required: &[&str]) -> Json { |
|
| 136 | + | json!({ "type": "object", "properties": props, "required": required, "additionalProperties": false }) |
|
| 137 | + | } |
|
| 138 | + | ||
| 139 | + | static TOOLS: &[Tool] = &[ |
|
| 140 | + | Tool { |
|
| 141 | + | name: "brief", |
|
| 142 | + | description: "Call first: who you are, your project, the house rules, your unread inbox, and who else is here.", |
|
| 143 | + | schema: || obj(json!({ "new_session": { "type": "boolean" } }), &[]), |
|
| 144 | + | read_only: true, |
|
| 145 | + | destructive: false, |
|
| 146 | + | long: false, |
|
| 147 | + | run: brief, |
|
| 148 | + | }, |
|
| 149 | + | Tool { |
|
| 150 | + | name: "rules", |
|
| 151 | + | description: "The house rules and their version, in full.", |
|
| 152 | + | schema: || obj(json!({}), &[]), |
|
| 153 | + | read_only: true, |
|
| 154 | + | destructive: false, |
|
| 155 | + | long: false, |
|
| 156 | + | run: rules, |
|
| 157 | + | }, |
|
| 158 | + | Tool { |
|
| 159 | + | name: "agents", |
|
| 160 | + | description: "Every agent with its owner, kind, project, presence and status.", |
|
| 161 | + | schema: || obj(json!({ "project": { "type": "string" } }), &[]), |
|
| 162 | + | read_only: true, |
|
| 163 | + | destructive: false, |
|
| 164 | + | long: false, |
|
| 165 | + | run: agents, |
|
| 166 | + | }, |
|
| 167 | + | Tool { |
|
| 168 | + | name: "status", |
|
| 169 | + | description: "Say what you are doing, so others can see.", |
|
| 170 | + | schema: || obj(json!({ "state": { "enum": ["working", "idle", "blocked"] }, "text": { "type": "string" }, "task": { "type": "string" } }), &[]), |
|
| 171 | + | read_only: false, |
|
| 172 | + | destructive: false, |
|
| 173 | + | long: false, |
|
| 174 | + | run: status, |
|
| 175 | + | }, |
|
| 176 | + | Tool { |
|
| 177 | + | name: "say", |
|
| 178 | + | description: "Post a message (default: your project's channel). @name mentions; reply_to answers in a thread.", |
|
| 179 | + | schema: || { |
|
| 180 | + | obj( |
|
| 181 | + | json!({ "body": { "type": "string" }, "channel": { "type": "string" }, "reply_to": { "type": "string" }, |
|
| 182 | + | "kind": { "enum": ["chat", "request", "answer", "handoff", "status", "decision"] }, "to": { "type": "string" } }), |
|
| 183 | + | &["body"], |
|
| 184 | + | ) |
|
| 185 | + | }, |
|
| 186 | + | read_only: false, |
|
| 187 | + | destructive: false, |
|
| 188 | + | long: false, |
|
| 189 | + | run: say, |
|
| 190 | + | }, |
|
| 191 | + | Tool { |
|
| 192 | + | name: "read", |
|
| 193 | + | description: "Messages of a channel (default: your project's) or a whole thread; marks nothing read.", |
|
| 194 | + | schema: || obj(json!({ "channel": { "type": "string" }, "thread": { "type": "string" }, "since": { "type": "string" }, "limit": { "type": "integer" } }), &[]), |
|
| 195 | + | read_only: true, |
|
| 196 | + | destructive: false, |
|
| 197 | + | long: false, |
|
| 198 | + | run: read, |
|
| 199 | + | }, |
|
| 200 | + | Tool { |
|
| 201 | + | name: "inbox", |
|
| 202 | + | description: "Your unread mentions and direct messages.", |
|
| 203 | + | schema: || obj(json!({}), &[]), |
|
| 204 | + | read_only: true, |
|
| 205 | + | destructive: false, |
|
| 206 | + | long: false, |
|
| 207 | + | run: inbox, |
|
| 208 | + | }, |
|
| 209 | + | Tool { |
|
| 210 | + | name: "wait", |
|
| 211 | + | description: "Only while waiting for an answer inside a piece of work: blocks up to timeout_s for something new. If idle, end your turn instead.", |
|
| 212 | + | schema: || obj(json!({ "timeout_s": { "type": "integer", "minimum": 1, "maximum": 50 } }), &["timeout_s"]), |
|
| 213 | + | read_only: true, |
|
| 214 | + | destructive: false, |
|
| 215 | + | long: true, |
|
| 216 | + | run: wait, |
|
| 217 | + | }, |
|
| 218 | + | Tool { |
|
| 219 | + | name: "ack", |
|
| 220 | + | description: "Mark inbox items read, with an optional reaction (ack, done, eyes, no).", |
|
| 221 | + | schema: || obj(json!({ "ids": { "type": "array", "items": { "type": "string" } }, "reaction": { "enum": ["ack", "done", "eyes", "no"] } }), &["ids"]), |
|
| 222 | + | read_only: false, |
|
| 223 | + | destructive: false, |
|
| 224 | + | long: false, |
|
| 225 | + | run: ack, |
|
| 226 | + | }, |
|
| 227 | + | Tool { |
|
| 228 | + | name: "search", |
|
| 229 | + | description: "Search history: words, \"phrases\", prefix*, AND/OR/NOT; filter by channel, author, since (7d, 3h).", |
|
| 230 | + | schema: || { |
|
| 231 | + | obj(json!({ "query": { "type": "string" }, "channel": { "type": "string" }, "author": { "type": "string" }, "since": { "type": "string" }, "limit": { "type": "integer" } }), &["query"]) |
|
| 232 | + | }, |
|
| 233 | + | read_only: true, |
|
| 234 | + | destructive: false, |
|
| 235 | + | long: false, |
|
| 236 | + | run: search, |
|
| 237 | + | }, |
|
| 238 | + | Tool { |
|
| 239 | + | name: "redact", |
|
| 240 | + | description: "Redact your own message, with the reason.", |
|
| 241 | + | schema: || obj(json!({ "id": { "type": "string" }, "reason": { "type": "string" } }), &["id", "reason"]), |
|
| 242 | + | read_only: false, |
|
| 243 | + | destructive: true, |
|
| 244 | + | long: false, |
|
| 245 | + | run: redact, |
|
| 246 | + | }, |
|
| 247 | + | ]; |
|
| 248 | + | ||
| 249 | + | fn s<'a>(args: &'a Json, k: &str) -> Option<&'a str> { |
|
| 250 | + | args.get(k).and_then(Json::as_str).filter(|v| !v.is_empty()) |
|
| 251 | + | } |
|
| 252 | + | ||
| 253 | + | /// How a principal is named to a model: `claude-oxygen (Andy McCall's agent, claude-code)`. |
|
| 254 | + | async fn label(d: &Daemon, actor: &str) -> String { |
|
| 255 | + | if actor == "human" { |
|
| 256 | + | let name: Option<String> = sqlx::query_scalar("SELECT name FROM identities WHERE role = 'owner' AND current = 1") |
|
| 257 | + | .fetch_optional(d.ledger.pool()) |
|
| 258 | + | .await |
|
| 259 | + | .ok() |
|
| 260 | + | .flatten(); |
|
| 261 | + | return format!("{} (the human)", name.unwrap_or_else(|| "the owner".into())); |
|
| 262 | + | } |
|
| 263 | + | if let Some(a) = actor.strip_prefix("agent:") { |
|
| 264 | + | let row: Option<(String, String)> = sqlx::query_as("SELECT owner_name, kind FROM agents WHERE name = ?") |
|
| 265 | + | .bind(a) |
|
| 266 | + | .fetch_optional(d.ledger.pool()) |
|
| 267 | + | .await |
|
| 268 | + | .ok() |
|
| 269 | + | .flatten(); |
|
| 270 | + | if let Some((owner, kind)) = row { |
|
| 271 | + | return format!("{a} ({owner}'s agent, {kind})"); |
|
| 272 | + | } |
|
| 273 | + | return a.to_string(); |
|
| 274 | + | } |
|
| 275 | + | "Spectra".into() |
|
| 276 | + | } |
|
| 277 | + | ||
| 278 | + | /// A message as a model reads it: delimited with its author (spec §20.3). |
|
| 279 | + | async fn delimited(d: &Daemon, m: &conv::Message, channel: &str) -> String { |
|
| 280 | + | let at = spectra_core::ids::format_time(m.at_ms as u64); |
|
| 281 | + | let thread = m.reply_to.as_deref().map(|r| format!(", reply in thread {r}")).unwrap_or_default(); |
|
| 282 | + | let replies = if m.replies > 0 { format!(" ({} replies)", m.replies) } else { String::new() }; |
|
| 283 | + | format!("[from {} at {at} in {channel}, id {}{thread}{replies}]\n{}\n[end]", label(d, &m.author).await, m.id, m.body) |
|
| 70 | 284 | } |
|
| 71 | 285 | ||
| 72 | - | static TOOLS: &[Tool] = &[Tool { |
|
| 73 | - | name: "agents", |
|
| 74 | - | description: "Every agent with its owner, kind, project, presence and status.", |
|
| 75 | - | schema: || json!({ "type": "object", "properties": { "project": { "type": "string", "description": "Only this project's agents" } }, "additionalProperties": false }), |
|
| 76 | - | read_only: true, |
|
| 77 | - | destructive: false, |
|
| 78 | - | long: false, |
|
| 79 | - | run: agents, |
|
| 80 | - | }]; |
|
| 286 | + | fn brief(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> { |
|
| 287 | + | let _ = args; |
|
| 288 | + | Box::pin(async move { |
|
| 289 | + | let d = &ctx.daemon; |
|
| 290 | + | let rules = match spectra_core::rules::compile(&d.data) { |
|
| 291 | + | Ok(r) => r, |
|
| 292 | + | Err(e) => return fail(e), |
|
| 293 | + | }; |
|
| 294 | + | let me = ctx.caller.principal.display(); |
|
| 295 | + | let project = match &ctx.caller.principal { |
|
| 296 | + | Principal::Agent { project, .. } => Some(project.clone()), |
|
| 297 | + | _ => None, |
|
| 298 | + | }; |
|
| 299 | + | let mut relations = (Vec::<String>::new(), Vec::<String>::new()); |
|
| 300 | + | if let Some(p) = &project { |
|
| 301 | + | relations.0 = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ?") |
|
| 302 | + | .bind(p) |
|
| 303 | + | .fetch_all(d.ledger.pool()) |
|
| 304 | + | .await |
|
| 305 | + | .unwrap_or_default(); |
|
| 306 | + | relations.1 = sqlx::query_scalar("SELECT project FROM project_relations WHERE depends_on = ?") |
|
| 307 | + | .bind(p) |
|
| 308 | + | .fetch_all(d.ledger.pool()) |
|
| 309 | + | .await |
|
| 310 | + | .unwrap_or_default(); |
|
| 311 | + | } |
|
| 312 | + | let items = conv::inbox(&d.ledger, &ctx.actor(), Some(&ctx.author())).await.unwrap_or_default(); |
|
| 313 | + | let others = agents(ctx, json!({})).await; |
|
| 314 | + | let _ = d.ledger.append_one(ctx.event("rules.read", json!({ "version": rules.version }))).await; |
|
| 315 | + | let mut text = format!("You are {me}."); |
|
| 316 | + | if let Some(p) = &project { |
|
| 317 | + | text.push_str(&format!(" Your project is {p}; your channel is #{p}.")); |
|
| 318 | + | if !relations.0.is_empty() { |
|
| 319 | + | text.push_str(&format!(" It pins {}.", relations.0.join(", "))); |
|
| 320 | + | } |
|
| 321 | + | if !relations.1.is_empty() { |
|
| 322 | + | text.push_str(&format!(" {} pin it.", relations.1.join(", "))); |
|
| 323 | + | } |
|
| 324 | + | } |
|
| 325 | + | if let Some(sess) = &ctx.session { |
|
| 326 | + | text.push_str(&format!(" Session {sess}.")); |
|
| 327 | + | } |
|
| 328 | + | text.push_str(&format!("\n\nHouse rules (version {}):\n", rules.version)); |
|
| 329 | + | text.push_str(if rules.text.is_empty() { "(none yet)\n" } else { &rules.text }); |
|
| 330 | + | text.push_str(&format!("\nInbox: {} unread.\n", items.len())); |
|
| 331 | + | for i in &items { |
|
| 332 | + | text.push_str(&format!("- {} from {} in {} ({}): {}\n", i.kind, label(d, &i.from).await, i.channel, i.id, i.summary)); |
|
| 333 | + | } |
|
| 334 | + | text.push_str(&format!("\nAgents:\n{}\n", others.text)); |
|
| 335 | + | text.push_str("\nWhen you have nothing to do, end your turn: Spectra starts you again when there is work."); |
|
| 336 | + | Output::ok( |
|
| 337 | + | text, |
|
| 338 | + | json!({ "you": me, "project": project, "session": ctx.session, "rules": { "version": rules.version, "text": rules.text }, |
|
| 339 | + | "inbox": items, "agents": others.structured.and_then(|s| s.get("agents").cloned()), "pins": relations.0, "pinned_by": relations.1 }), |
|
| 340 | + | ) |
|
| 341 | + | }) |
|
| 342 | + | } |
|
| 81 | 343 | ||
| 82 | - | /// A principal as the model reads it. |
|
| 83 | - | pub fn who(p: &Principal) -> String { |
|
| 84 | - | p.display() |
|
| 344 | + | fn rules(ctx: &Ctx, _args: Json) -> BoxFuture<'_, Output> { |
|
| 345 | + | Box::pin(async move { |
|
| 346 | + | match spectra_core::rules::compile(&ctx.daemon.data) { |
|
| 347 | + | Ok(r) => { |
|
| 348 | + | let _ = ctx.daemon.ledger.append_one(ctx.event("rules.read", json!({ "version": r.version }))).await; |
|
| 349 | + | let body = if r.text.is_empty() { "(no house rules yet)".to_string() } else { r.text.clone() }; |
|
| 350 | + | Output::ok(format!("House rules, version {}:\n\n{body}", r.version), json!({ "version": r.version, "text": r.text })) |
|
| 351 | + | } |
|
| 352 | + | Err(e) => fail(e), |
|
| 353 | + | } |
|
| 354 | + | }) |
|
| 85 | 355 | } |
|
| 86 | 356 | ||
| 87 | 357 | fn agents(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> { |
|
| 88 | 358 | Box::pin(async move { |
|
| 89 | - | let project = args.get("project").and_then(Json::as_str); |
|
| 90 | - | let rows = sqlx_rows(&ctx.daemon, project).await; |
|
| 91 | - | let rows = match rows { |
|
| 359 | + | let project = s(&args, "project"); |
|
| 360 | + | let rows: Vec<AgentRow> = match sqlx::query_as( |
|
| 361 | + | "SELECT a.name, a.owner_name, a.kind, a.project, p.state, p.status_text FROM agents a LEFT JOIN presence p ON p.agent = a.name \ |
|
| 362 | + | WHERE a.disabled_ms IS NULL AND (? IS NULL OR a.project = ?) ORDER BY a.name", |
|
| 363 | + | ) |
|
| 364 | + | .bind(project) |
|
| 365 | + | .bind(project) |
|
| 366 | + | .fetch_all(ctx.daemon.ledger.pool()) |
|
| 367 | + | .await |
|
| 368 | + | { |
|
| 92 | 369 | Ok(r) => r, |
|
| 93 | - | Err(e) => return Output::err(format!("could not list agents: {e}")), |
|
| 370 | + | Err(e) => return fail(e.into()), |
|
| 94 | 371 | }; |
|
| 372 | + | // Presence as the daemon sees it now; the table is its recorded history. |
|
| 373 | + | let rows: Vec<_> = { |
|
| 374 | + | let p = ctx.daemon.presence.lock().await; |
|
| 375 | + | let now = now_ms(); |
|
| 376 | + | rows.into_iter() |
|
| 377 | + | .map(|(name, owner, kind, project, _, status)| { |
|
| 378 | + | let live = p.state(&name, now).to_string(); |
|
| 379 | + | (name, owner, kind, project, Some(live), status) |
|
| 380 | + | }) |
|
| 381 | + | .collect() |
|
| 382 | + | }; |
|
| 95 | 383 | let list: Vec<Json> = rows |
|
| 96 | 384 | .iter() |
|
| 97 | 385 | .map(|(name, owner, kind, project, presence, status)| { |
|
| … | 12 unchanged lines |
||
| 110 | 398 | .join("\n") |
|
| 111 | 399 | }; |
|
| 112 | 400 | Output::ok(text, json!({ "agents": list })) |
|
| 401 | + | }) |
|
| 402 | + | } |
|
| 403 | + | ||
| 404 | + | /// An agent as the `agents` tool reads it: name, owner, kind, project, |
|
| 405 | + | /// presence and status. |
|
| 406 | + | type AgentRow = (String, String, String, String, Option<String>, Option<String>); |
|
| 407 | + | ||
| 408 | + | fn status(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> { |
|
| 409 | + | Box::pin(async move { |
|
| 410 | + | let Principal::Agent { name, .. } = &ctx.caller.principal else { return Output::err("status is for agents") }; |
|
| 411 | + | let state = s(&args, "state").unwrap_or("working"); |
|
| 412 | + | let text = s(&args, "text"); |
|
| 413 | + | if text.is_some_and(|t| t.len() > 500) { |
|
| 414 | + | return Output::err("a status is at most 500 characters"); |
|
| 415 | + | } |
|
| 416 | + | let e = ctx.event("agent.status", json!({ "state": state, "text": text, "task": s(&args, "task") })).subject(name.clone()); |
|
| 417 | + | if let Err(e) = ctx.daemon.ledger.append_one(e).await { |
|
| 418 | + | return fail(e); |
|
| 419 | + | } |
|
| 420 | + | { |
|
| 421 | + | let mut p = ctx.daemon.presence.lock().await; |
|
| 422 | + | p.status(name, (state != "idle").then(now_ms)); |
|
| 423 | + | p.blocked(name, state == "blocked"); |
|
| 424 | + | } |
|
| 425 | + | Output::ok( |
|
| 426 | + | format!("Status set: {state}{}", text.map(|t| format!(" — {t}")).unwrap_or_default()), |
|
| 427 | + | json!({ "state": state, "text": text }), |
|
| 428 | + | ) |
|
| 429 | + | }) |
|
| 430 | + | } |
|
| 431 | + | ||
| 432 | + | fn say(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> { |
|
| 433 | + | Box::pin(async move { |
|
| 434 | + | let Some(body) = s(&args, "body") else { return Output::err("say needs a body") }; |
|
| 435 | + | let mut channel = s(&args, "channel").map(String::from); |
|
| 436 | + | if let Some(to) = s(&args, "to") { |
|
| 437 | + | match conv::ensure_direct(&ctx.daemon.ledger, &ctx.caller.principal, to).await { |
|
| 438 | + | Ok(c) => channel = Some(c.name), |
|
| 439 | + | Err(e) => return fail(e), |
|
| 440 | + | } |
|
| 441 | + | } |
|
| 442 | + | let post = Post { |
|
| 443 | + | channel, |
|
| 444 | + | body: body.into(), |
|
| 445 | + | reply_to: s(&args, "reply_to").map(String::from), |
|
| 446 | + | kind: s(&args, "kind").map(String::from), |
|
| 447 | + | ..Default::default() |
|
| 448 | + | }; |
|
| 449 | + | match conv::post(&ctx.daemon.ledger, &ctx.author(), post, now_ms()).await { |
|
| 450 | + | Ok(Posted::Message { id, channel }) => { |
|
| 451 | + | Output::ok(format!("Posted {id} in {channel}."), json!({ "id": id, "channel": channel })) |
|
| 452 | + | } |
|
| 453 | + | Ok(Posted::Duplicate { id }) => { |
|
| 454 | + | Output::ok(format!("Not posted: you said exactly this in the last minute ({id})."), json!({ "duplicate_of": id })) |
|
| 455 | + | } |
|
| 456 | + | Err(e) => fail(e), |
|
| 457 | + | } |
|
| 458 | + | }) |
|
| 459 | + | } |
|
| 460 | + | ||
| 461 | + | fn read(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> { |
|
| 462 | + | Box::pin(async move { |
|
| 463 | + | let limit = args.get("limit").and_then(Json::as_u64).unwrap_or(50) as u32; |
|
| 464 | + | match conv::read(&ctx.daemon.ledger, &ctx.caller.principal, s(&args, "channel"), s(&args, "thread"), s(&args, "since"), limit).await |
|
| 465 | + | { |
|
| 466 | + | Ok((ch, msgs)) => { |
|
| 467 | + | let mut text = format!("{} message(s) in {}:\n\n", msgs.len(), ch.display()); |
|
| 468 | + | for m in &msgs { |
|
| 469 | + | text.push_str(&delimited(&ctx.daemon, m, &ch.display()).await); |
|
| 470 | + | text.push_str("\n\n"); |
|
| 471 | + | } |
|
| 472 | + | Output::ok(text.trim_end().to_string(), json!({ "channel": ch.display(), "messages": msgs })) |
|
| 473 | + | } |
|
| 474 | + | Err(e) => fail(e), |
|
| 475 | + | } |
|
| 476 | + | }) |
|
| 477 | + | } |
|
| 478 | + | ||
| 479 | + | fn inbox(ctx: &Ctx, _args: Json) -> BoxFuture<'_, Output> { |
|
| 480 | + | Box::pin(async move { |
|
| 481 | + | match conv::inbox(&ctx.daemon.ledger, &ctx.actor(), Some(&ctx.author())).await { |
|
| 482 | + | Ok(items) => inbox_output(ctx, items).await, |
|
| 483 | + | Err(e) => fail(e), |
|
| 484 | + | } |
|
| 485 | + | }) |
|
| 486 | + | } |
|
| 487 | + | ||
| 488 | + | async fn inbox_output(ctx: &Ctx, items: Vec<conv::Item>) -> Output { |
|
| 489 | + | if items.is_empty() { |
|
| 490 | + | return Output::ok("Nothing unread.", json!({ "items": [] })); |
|
| 491 | + | } |
|
| 492 | + | let mut text = format!("{} unread:\n\n", items.len()); |
|
| 493 | + | for i in &items { |
|
| 494 | + | text.push_str(&format!( |
|
| 495 | + | "[{} from {} in {}, id {}]\n{}\n[end]\nAct on it: {}\n\n", |
|
| 496 | + | i.kind, |
|
| 497 | + | label(&ctx.daemon, &i.from).await, |
|
| 498 | + | i.channel, |
|
| 499 | + | i.id, |
|
| 500 | + | i.summary, |
|
| 501 | + | i.action_hint |
|
| 502 | + | )); |
|
| 503 | + | } |
|
| 504 | + | Output::ok(text.trim_end().to_string(), json!({ "items": items })) |
|
| 505 | + | } |
|
| 506 | + | ||
| 507 | + | fn wait(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> { |
|
| 508 | + | Box::pin(async move { |
|
| 509 | + | let actor = ctx.actor(); |
|
| 510 | + | let now = now_ms(); |
|
| 511 | + | { |
|
| 512 | + | let guard = ctx.daemon.idle_waits.lock().await; |
|
| 513 | + | if let Some((_, until)) = guard.get(&actor) { |
|
| 514 | + | if *until > now { |
|
| 515 | + | return Output::err("Nothing is waiting for you. End your turn; Spectra starts you again when there is work."); |
|
| 516 | + | } |
|
| 517 | + | } |
|
| 518 | + | } |
|
| 519 | + | let timeout = args.get("timeout_s").and_then(Json::as_u64).unwrap_or(30).clamp(1, 50); |
|
| 520 | + | let name = actor.strip_prefix("agent:").unwrap_or(&actor).to_string(); |
|
| 521 | + | // Subscribe before looking, so nothing is missed between the two. |
|
| 522 | + | let mut rx = ctx.daemon.ledger.subscribe(); |
|
| 523 | + | let ledger = &ctx.daemon.ledger; |
|
| 524 | + | ctx.daemon.presence.lock().await.wait_started(&name); |
|
| 525 | + | let found = async { |
|
| 526 | + | loop { |
|
| 527 | + | if conv::unread(ledger, &actor).await.unwrap_or(0) > 0 { |
|
| 528 | + | return true; |
|
| 529 | + | } |
|
| 530 | + | match rx.recv().await { |
|
| 531 | + | Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, |
|
| 532 | + | Err(_) => return false, |
|
| 533 | + | } |
|
| 534 | + | } |
|
| 535 | + | }; |
|
| 536 | + | let got = tokio::time::timeout(Duration::from_secs(timeout), found).await.unwrap_or(false); |
|
| 537 | + | ctx.daemon.presence.lock().await.wait_ended(&name); |
|
| 538 | + | let mut guard = ctx.daemon.idle_waits.lock().await; |
|
| 539 | + | if got { |
|
| 540 | + | guard.remove(&actor); |
|
| 541 | + | drop(guard); |
|
| 542 | + | return match conv::inbox(ledger, &actor, Some(&ctx.author())).await { |
|
| 543 | + | Ok(items) => inbox_output(ctx, items).await, |
|
| 544 | + | Err(e) => fail(e), |
|
| 545 | + | }; |
|
| 546 | + | } |
|
| 547 | + | let entry = guard.entry(actor).or_insert((0, 0)); |
|
| 548 | + | entry.0 += 1; |
|
| 549 | + | if entry.0 >= IDLE_WAITS { |
|
| 550 | + | entry.1 = now_ms() + IDLE_COOLDOWN_MS; |
|
| 551 | + | entry.0 = 0; |
|
| 552 | + | } |
|
| 553 | + | Output { text: "Nothing new.".into(), structured: Some(json!({ "items": [] })), is_error: false, quiet: true } |
|
| 554 | + | }) |
|
| 555 | + | } |
|
| 556 | + | ||
| 557 | + | fn ack(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> { |
|
| 558 | + | Box::pin(async move { |
|
| 559 | + | let ids: Vec<String> = args |
|
| 560 | + | .get("ids") |
|
| 561 | + | .and_then(Json::as_array) |
|
| 562 | + | .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) |
|
| 563 | + | .unwrap_or_default(); |
|
| 564 | + | if ids.is_empty() { |
|
| 565 | + | return Output::err("ack needs ids"); |
|
| 566 | + | } |
|
| 567 | + | match conv::ack(&ctx.daemon.ledger, &ctx.author(), &ids, s(&args, "reaction")).await { |
|
| 568 | + | Ok(n) => Output::ok(format!("Marked {n} read."), json!({ "read": n })), |
|
| 569 | + | Err(e) => fail(e), |
|
| 570 | + | } |
|
| 571 | + | }) |
|
| 572 | + | } |
|
| 573 | + | ||
| 574 | + | fn search(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> { |
|
| 575 | + | Box::pin(async move { |
|
| 576 | + | let Some(q) = s(&args, "query") else { return Output::err("search needs a query") }; |
|
| 577 | + | let since = s(&args, "since").and_then(|v| parse_since(v, now_ms())); |
|
| 578 | + | let limit = args.get("limit").and_then(Json::as_u64).unwrap_or(20) as u32; |
|
| 579 | + | match conv::search(&ctx.daemon.ledger, &ctx.caller.principal, q, s(&args, "channel"), s(&args, "author"), since, limit).await { |
|
| 580 | + | Ok(hits) => { |
|
| 581 | + | let mut text = format!("{} hit(s):\n", hits.len()); |
|
| 582 | + | for h in &hits { |
|
| 583 | + | text.push_str(&format!( |
|
| 584 | + | "- [{} in {} at {}, id {}] {}\n", |
|
| 585 | + | label(&ctx.daemon, &h.author).await, |
|
| 586 | + | h.channel, |
|
| 587 | + | spectra_core::ids::format_time(h.at_ms as u64), |
|
| 588 | + | h.id, |
|
| 589 | + | h.snippet |
|
| 590 | + | )); |
|
| 591 | + | } |
|
| 592 | + | Output::ok(text.trim_end().to_string(), json!({ "hits": hits })) |
|
| 593 | + | } |
|
| 594 | + | Err(e) => fail(e), |
|
| 595 | + | } |
|
| 113 | 596 | }) |
|
| 114 | 597 | } |
|
| 115 | 598 | ||
| 116 | - | #[allow(clippy::type_complexity)] |
|
| 117 | - | async fn sqlx_rows( |
|
| 118 | - | d: &Daemon, |
|
| 119 | - | project: Option<&str>, |
|
| 120 | - | ) -> Result<Vec<(String, String, String, String, Option<String>, Option<String>)>, sqlx::Error> { |
|
| 121 | - | sqlx::query_as( |
|
| 122 | - | "SELECT a.name, a.owner_name, a.kind, a.project, p.state, p.status_text FROM agents a LEFT JOIN presence p ON p.agent = a.name \ |
|
| 123 | - | WHERE a.disabled_ms IS NULL AND (? IS NULL OR a.project = ?) ORDER BY a.name", |
|
| 124 | - | ) |
|
| 125 | - | .bind(project) |
|
| 126 | - | .bind(project) |
|
| 127 | - | .fetch_all(d.ledger.pool()) |
|
| 128 | - | .await |
|
| 599 | + | /// `7d`, `3h`, `30m`, or milliseconds since the epoch. |
|
| 600 | + | pub fn parse_since(v: &str, now: u64) -> Option<i64> { |
|
| 601 | + | let (n, unit) = v.split_at(v.len().saturating_sub(1)); |
|
| 602 | + | let ms = match unit { |
|
| 603 | + | "d" => 86_400_000, |
|
| 604 | + | "h" => 3_600_000, |
|
| 605 | + | "m" => 60_000, |
|
| 606 | + | _ => return v.parse().ok(), |
|
| 607 | + | }; |
|
| 608 | + | n.parse::<u64>().ok().map(|n| now.saturating_sub(n * ms) as i64) |
|
| 609 | + | } |
|
| 610 | + | ||
| 611 | + | fn redact(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> { |
|
| 612 | + | Box::pin(async move { |
|
| 613 | + | let (Some(id), Some(reason)) = (s(&args, "id"), s(&args, "reason")) else { return Output::err("redact needs id and reason") }; |
|
| 614 | + | match conv::redact(&ctx.daemon.ledger, &ctx.author(), id, reason).await { |
|
| 615 | + | Ok(()) => Output::ok(format!("Redacted {id}."), json!({ "id": id })), |
|
| 616 | + | Err(e) => fail(e), |
|
| 617 | + | } |
|
| 618 | + | }) |
|
| 129 | 619 | } |
modified docs/plan.md +1 −1
| … | 20 unchanged lines |
||
| 21 | 21 | - [x] #5 Projects, presence and sessions — done 2026-11-02: project add/list with relations from .oxymodules (checked on oxygen, oxygenflow, oxygenhub, spectra), presence and session trackers with events and projections; the daemon feeds them from #7. |
|
| 22 | 22 | - [x] #6 Channels, threads, messages, inbox and search — done 2026-11-02: spectra-core::conversation (post, read, inbox, ack, redact, search, direct channels), shared by tools, API and CLI. |
|
| 23 | 23 | - [x] #7 Tuner: MCP over Streamable HTTP — done 2026-11-02: spectra serve, socket + loopback, signed requests, both revisions, refusals, owner events through the daemon; end-to-end tests drive the real binary. |
|
| 24 | - | - [ ] #8 The 0.1 tools |
|
| 24 | + | - [x] #8 The 0.1 tools — done 2026-11-02: eleven tools through one invoke wrapper (record, rules line); wait with the idle guard; end-to-end tests with two agents. Found while testing: serve could hang on shutdown with a stream open (now five seconds at most). |
|
| 25 | 25 | - [ ] #9 The stdio shim |
|
| 26 | 26 | - [ ] #10 Radar |
|
| 27 | 27 | - [ ] #11 JSON API and spectra-api types |
|
| … | 9 unchanged lines |
modified src/cli/serve.rs +20 −3
| … | 56 unchanged lines |
||
| 57 | 57 | shutdown_signal().await; |
|
| 58 | 58 | let _ = stop_tx.send(true); |
|
| 59 | 59 | }; |
|
| 60 | - | let (u, t, _) = tokio::join!(unix_srv, tcp_srv, signal); |
|
| 61 | - | u?; |
|
| 62 | - | t?; |
|
| 60 | + | // After the signal, open connections get five seconds to finish: a |
|
| 61 | + | // `subscriptions/listen` stream never does by itself. |
|
| 62 | + | let (stopping_tx, mut stopping) = tokio::sync::watch::channel(false); |
|
| 63 | + | tokio::spawn(async move { |
|
| 64 | + | signal.await; |
|
| 65 | + | let _ = stopping_tx.send(true); |
|
| 66 | + | }); |
|
| 67 | + | let servers = async { tokio::join!(unix_srv, tcp_srv) }; |
|
| 68 | + | tokio::select! { |
|
| 69 | + | (u, t) = servers => { |
|
| 70 | + | u?; |
|
| 71 | + | t?; |
|
| 72 | + | } |
|
| 73 | + | _ = async { |
|
| 74 | + | let _ = stopping.wait_for(|s| *s).await; |
|
| 75 | + | tokio::time::sleep(std::time::Duration::from_secs(5)).await; |
|
| 76 | + | } => { |
|
| 77 | + | tracing::info!("connections still open five seconds after the signal; stopping anyway"); |
|
| 78 | + | } |
|
| 79 | + | } |
|
| 63 | 80 | daemon.shutdown().await; |
|
| 64 | 81 | std::fs::remove_file(&socket).ok(); |
|
| 65 | 82 | println!("{} {}", ui::done("Stopped"), ui::dim(format!("ledger checkpointed at {} events", daemon.ledger.head().await.0))); |
|
| … | 15 unchanged lines |
modified tests/serve.rs +3 −87
| … | 1 unchanged lines |
||
| 2 | 2 | //! signing requests over the Unix socket, both MCP revisions, and every |
|
| 3 | 3 | //! refusal the spec promises. Hermetic: temporary homes, data and port 0. |
|
| 4 | 4 | ||
| 5 | - | use oxy_core::durable::{write_atomic, write_atomic_private}; |
|
| 6 | - | use oxy_core::identity::{Delegate, SecretKey}; |
|
| 5 | + | mod common; |
|
| 6 | + | ||
| 7 | + | use common::{add_agent, run, setup, text, Serving}; |
|
| 7 | 8 | use serde_json::json; |
|
| 8 | 9 | use spectra_core::keys::KeyPair; |
|
| 9 | - | use spectra_core::principals::DELEGATION_SCOPE; |
|
| 10 | 10 | use spectra_mcp::client::{rpc_result, Client}; |
|
| 11 | - | use std::path::{Path, PathBuf}; |
|
| 12 | - | use std::process::{Child, Command, Output}; |
|
| 13 | - | ||
| 14 | - | fn hermetic(dir: &Path) -> Command { |
|
| 15 | - | let mut c = Command::new(env!("CARGO_BIN_EXE_spectra")); |
|
| 16 | - | c.env("SPECTRA_DATA", dir.join("data")) |
|
| 17 | - | .env("OXY_HOME", dir.join("owner")) |
|
| 18 | - | .env("HOME", dir) |
|
| 19 | - | .env("XDG_DATA_HOME", dir.join("xdg")) |
|
| 20 | - | .env("NO_COLOR", "1"); |
|
| 21 | - | c |
|
| 22 | - | } |
|
| 23 | - | ||
| 24 | - | fn run(dir: &Path, args: &[&str]) -> Output { |
|
| 25 | - | hermetic(dir).args(args).output().unwrap() |
|
| 26 | - | } |
|
| 27 | - | ||
| 28 | - | fn text(o: &Output) -> String { |
|
| 29 | - | format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) |
|
| 30 | - | } |
|
| 31 | - | ||
| 32 | - | /// `oxy identity delegate add` in the owner's home. |
|
| 33 | - | fn delegate(owner_home: &Path, agent: &KeyPair) { |
|
| 34 | - | let cur = KeyPair::load_home(owner_home).unwrap(); |
|
| 35 | - | let keys = owner_home.join("keys"); |
|
| 36 | - | let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap()).unwrap(); |
|
| 37 | - | let mut d = cur.identity.delegates.clone(); |
|
| 38 | - | d.push(Delegate { key: agent.key, scope: DELEGATION_SCOPE, expires: None }); |
|
| 39 | - | let next = cur.identity.successor(d, None, spectra_core::ids::now_ms()); |
|
| 40 | - | let k = next.hash(); |
|
| 41 | - | write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap(); |
|
| 42 | - | write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap(); |
|
| 43 | - | oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap(); |
|
| 44 | - | } |
|
| 45 | - | ||
| 46 | - | /// Make an agent's home, delegate to it, and add it. |
|
| 47 | - | fn add_agent(d: &Path, name: &str, extra: &[&str]) -> KeyPair { |
|
| 48 | - | let home = d.join("agents").join(name); |
|
| 49 | - | let pair = KeyPair::create_home(&home, name, "").unwrap(); |
|
| 50 | - | delegate(&d.join("owner"), &pair); |
|
| 51 | - | let mut args = |
|
| 52 | - | vec!["agent", "add", name, "--kind", "claude-code", "--project", "oxygen", "--home", home.to_str().unwrap(), "--wait", "0"]; |
|
| 53 | - | args.extend_from_slice(extra); |
|
| 54 | - | let o = run(d, &args); |
|
| 55 | - | assert!(o.status.success(), "{}", text(&o)); |
|
| 56 | - | pair |
|
| 57 | - | } |
|
| 58 | - | ||
| 59 | - | struct Serving { |
|
| 60 | - | child: Child, |
|
| 61 | - | socket: PathBuf, |
|
| 62 | - | } |
|
| 63 | - | ||
| 64 | - | impl Serving { |
|
| 65 | - | fn start(d: &Path) -> Serving { |
|
| 66 | - | let mut child = hermetic(d).args(["serve", "--bind", "127.0.0.1:0"]).stdout(std::process::Stdio::piped()).spawn().unwrap(); |
|
| 67 | - | let socket = d.join("data/spectra.sock"); |
|
| 68 | - | for _ in 0..200 { |
|
| 69 | - | if std::os::unix::net::UnixStream::connect(&socket).is_ok() { |
|
| 70 | - | return Serving { child, socket }; |
|
| 71 | - | } |
|
| 72 | - | std::thread::sleep(std::time::Duration::from_millis(25)); |
|
| 73 | - | } |
|
| 74 | - | child.kill().ok(); |
|
| 75 | - | child.wait().ok(); |
|
| 76 | - | panic!("spectra serve did not start"); |
|
| 77 | - | } |
|
| 78 | - | fn stop(mut self) { |
|
| 79 | - | unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) }; |
|
| 80 | - | let status = self.child.wait().unwrap(); |
|
| 81 | - | assert!(status.success(), "a clean stop on SIGTERM"); |
|
| 82 | - | assert!(!self.socket.exists(), "the socket is removed"); |
|
| 83 | - | } |
|
| 84 | - | } |
|
| 85 | - | ||
| 86 | - | fn setup() -> tempfile::TempDir { |
|
| 87 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 88 | - | let d = dir.path(); |
|
| 89 | - | KeyPair::create_home(&d.join("owner"), "Andy McCall", "").unwrap(); |
|
| 90 | - | assert!(run(d, &["init"]).status.success()); |
|
| 91 | - | std::fs::create_dir_all(d.join("oxygen")).unwrap(); |
|
| 92 | - | assert!(run(d, &["project", "add", d.join("oxygen").to_str().unwrap()]).status.success()); |
|
| 93 | - | dir |
|
| 94 | - | } |
|
| 95 | 11 | ||
| 96 | 12 | fn call(id: u64, name: &str, args: serde_json::Value) -> serde_json::Value { |
|
| 97 | 13 | json!({ "jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { "name": name, "arguments": args, |
|
| … | 115 unchanged lines |