OXYGENHUB

Coverage / crates/spectra-mcp/src/tools.rs 90.0%

370 of 411 lines run, 70 of 148 functions · run #19 on main · run missed

1
//! The tools agents call (spec §15.2). Each is a plain function, so the
2
//! list is fixed and deterministic, which keeps clients' prompt caches warm.
3
//! A failure is a result with `isError`, never a protocol error, so the
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.
11
12
use futures::future::BoxFuture;
13
use serde_json::{json, Value as Json};
14
use spectra_core::authn::Caller;
15
use spectra_core::conversation::{self as conv, Author, Post, Posted, Signer};
16
use spectra_core::daemon::Daemon;
17
use spectra_core::event::NewEvent;
18
use spectra_core::ids::now_ms;
19
use spectra_core::principals::Principal;
20
use std::sync::Arc;
21
use std::time::Duration;
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
27
/// What a tool sees: the daemon and who called it.
28
pub struct Ctx {
29
    pub daemon: Arc<Daemon>,
30
    pub caller: Caller,
31
    pub session: Option<String>,
32
}
33
34
impl Ctx {
3518×
    fn author(&self) -> Author<'static> {
3618×
        Author { principal: self.caller.principal.clone(), signer: Signer::Request(self.caller.auth.clone()) }
3718×
    }
3843×
    fn actor(&self) -> String {
3943×
        self.caller.principal.actor()
4043×
    }
4131×
    fn event(&self, kind: &str, payload: Json) -> NewEvent {
4231×
        let mut e = NewEvent::system(kind, payload).by(self.actor(), self.caller.auth.clone());
4331×
        e.session = self.session.clone();
4431×
        e
4531×
    }
46
}
47
48
/// A tool's result: text for the model, the same as JSON, and whether it
49
/// failed.
50
#[derive(Clone, Debug, Default)]
51
pub struct Output {
52
    pub text: String,
53
    pub structured: Option<Json>,
54
    pub is_error: bool,
55
    /// An empty `wait`: not recorded (spec §9.2).
56
    pub quiet: bool,
57
}
58
59
impl Output {
6026×
    pub fn ok(text: impl Into<String>, structured: Json) -> Output {
6126×
        Output { text: text.into(), structured: Some(structured), ..Default::default() }
6226×
    }
633×
    pub fn err(text: impl Into<String>) -> Output {
643×
        Output { text: text.into(), is_error: true, ..Default::default() }
653×
    }
66
}
67
682×
fn fail(e: anyhow::Error) -> Output {
692×
    Output::err(format!("{e:#}"))
702×
}
71
72
pub type Run = for<'a> fn(&'a Ctx, Json) -> BoxFuture<'a, Output>;
73
74
/// One tool as `tools/list` describes it.
75
pub struct Tool {
76
    pub name: &'static str,
77
    pub description: &'static str,
78
    pub schema: fn() -> Json,
79
    pub read_only: bool,
80
    pub destructive: bool,
81
    /// Answered as an SSE stream with keep-alives (`wait`).
82
    pub long: bool,
83
    pub run: Run,
84
}
85
86
impl Tool {
8755×
    pub fn describe(&self) -> Json {
8855×
        json!({
8955×
            "name": self.name,
9055×
            "description": self.description,
9155×
            "inputSchema": (self.schema)(),
9255×
            "annotations": { "readOnlyHint": self.read_only, "destructiveHint": self.destructive },
93
        })
9455×
    }
95
}
96
97
/// Every tool, in the order `tools/list` gives them.
985×
pub fn all() -> &'static [Tool] {
995×
    TOOLS
1005×
}
101
10229×
pub fn find(name: &str) -> Option<&'static Tool> {
103169×
    TOOLS.iter().find(|t| t.name == name)
10429×
}
105
106
/// Run a tool for the caller: the idle guard, the rules line, and the record.
10729×
pub async fn invoke(ctx: &Ctx, tool: &Tool, args: Json) -> Output {
10829×
    let started = std::time::Instant::now();
10929×
    let mut out = (tool.run)(ctx, args.clone()).await;
11029×
    let Principal::Agent { name, .. } = &ctx.caller.principal else { return out };
11129×
    if tool.name != "rules" && tool.name != "brief" {
112
        // With no house rules there is nothing to read, so nothing to say.
11325×
        if let Some(rules) = spectra_core::rules::compile(&ctx.daemon.data).ok().filter(|r| !r.text.is_empty()) {
11415×
            let read = spectra_core::rules::last_read(ctx.daemon.ledger.pool(), name).await.ok().flatten();
11515×
            if read.as_deref() != Some(rules.version.as_str()) {
1162×
                let from = read.unwrap_or_else(|| "none".into());
1172×
                out.text = format!("{}\n\n{}", spectra_core::rules::changed_line(&from, &rules.version), out.text);
11813×
            }
11910×
        }
1204×
    }
12129×
    if out.quiet {
1223×
        return out;
12326×
    }
12426×
    let data = &ctx.daemon.data;
12526×
    let args_hash = spectra_core::attachments::put(data, args.to_string().as_bytes()).unwrap_or_default();
12626×
    let result_hash = spectra_core::attachments::put(data, out.text.as_bytes()).unwrap_or_default();
12726×
    let e = ctx.event(
12826×
        "tool.called",
12926×
        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
    );
13126×
    let _ = ctx.daemon.ledger.append_one(e).await;
13226×
    out
13329×
}
134
13555×
fn obj(props: Json, required: &[&str]) -> Json {
13655×
    json!({ "type": "object", "properties": props, "required": required, "additionalProperties": false })
13755×
}
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.",
1435×
        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.",
1525×
        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.",
1615×
        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.",
1705×
        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.",
1795×
        schema: || {
1805×
            obj(
1815×
                json!({ "body": { "type": "string" }, "channel": { "type": "string" }, "reply_to": { "type": "string" },
1825×
                    "kind": { "enum": ["chat", "request", "answer", "handoff", "status", "decision"] }, "to": { "type": "string" } }),
1835×
                &["body"],
184
            )
1855×
        },
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.",
1945×
        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.",
2035×
        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.",
2125×
        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).",
2215×
        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).",
2305×
        schema: || {
2315×
            obj(json!({ "query": { "type": "string" }, "channel": { "type": "string" }, "author": { "type": "string" }, "since": { "type": "string" }, "limit": { "type": "integer" } }), &["query"])
2325×
        },
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.",
2415×
        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
24956×
fn s<'a>(args: &'a Json, k: &str) -> Option<&'a str> {
25056×
    args.get(k).and_then(Json::as_str).filter(|v| !v.is_empty())
25156×
}
252
253
/// How a principal is named to a model: `claude-oxygen (Andy McCall's agent, claude-code)`.
2544×
async fn label(d: &Daemon, actor: &str) -> String {
2554×
    if actor == "human" {
2560×
        let name: Option<String> = sqlx::query_scalar("SELECT name FROM identities WHERE role = 'owner' AND current = 1")
2570×
            .fetch_optional(d.ledger.pool())
2580×
            .await
2590×
            .ok()
2600×
            .flatten();
2610×
        return format!("{} (the human)", name.unwrap_or_else(|| "the owner".into()));
2624×
    }
2634×
    if let Some(a) = actor.strip_prefix("agent:") {
2644×
        let row: Option<(String, String)> = sqlx::query_as("SELECT owner_name, kind FROM agents WHERE name = ?")
2654×
            .bind(a)
2664×
            .fetch_optional(d.ledger.pool())
2674×
            .await
2684×
            .ok()
2694×
            .flatten();
2704×
        if let Some((owner, kind)) = row {
2714×
            return format!("{a} ({owner}'s agent, {kind})");
2720×
        }
2730×
        return a.to_string();
2740×
    }
2750×
    "Spectra".into()
2764×
}
277
278
/// A message as a model reads it: delimited with its author (spec §20.3).
2791×
async fn delimited(d: &Daemon, m: &conv::Message, channel: &str) -> String {
2801×
    let at = spectra_core::ids::format_time(m.at_ms as u64);
2811×
    let thread = m.reply_to.as_deref().map(|r| format!(", reply in thread {r}")).unwrap_or_default();
2821×
    let replies = if m.replies > 0 { format!(" ({} replies)", m.replies) } else { String::new() };
2831×
    format!("[from {} at {at} in {channel}, id {}{thread}{replies}]\n{}\n[end]", label(d, &m.author).await, m.id, m.body)
2841×
}
285
2863×
fn brief(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
2873×
    let _ = args;
2883×
    Box::pin(async move {
2893×
        let d = &ctx.daemon;
2903×
        let rules = match spectra_core::rules::compile(&d.data) {
2913×
            Ok(r) => r,
2920×
            Err(e) => return fail(e),
293
        };
2943×
        let me = ctx.caller.principal.display();
2953×
        let project = match &ctx.caller.principal {
2963×
            Principal::Agent { project, .. } => Some(project.clone()),
2970×
            _ => None,
298
        };
2993×
        let mut relations = (Vec::<String>::new(), Vec::<String>::new());
3003×
        if let Some(p) = &project {
3013×
            relations.0 = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ?")
3023×
                .bind(p)
3033×
                .fetch_all(d.ledger.pool())
3043×
                .await
3053×
                .unwrap_or_default();
3063×
            relations.1 = sqlx::query_scalar("SELECT project FROM project_relations WHERE depends_on = ?")
3073×
                .bind(p)
3083×
                .fetch_all(d.ledger.pool())
3093×
                .await
3103×
                .unwrap_or_default();
3110×
        }
3123×
        let items = conv::inbox(&d.ledger, &ctx.actor(), Some(&ctx.author())).await.unwrap_or_default();
3133×
        let others = agents(ctx, json!({})).await;
3143×
        let _ = d.ledger.append_one(ctx.event("rules.read", json!({ "version": rules.version }))).await;
3153×
        let mut text = format!("You are {me}.");
3163×
        if let Some(p) = &project {
3173×
            text.push_str(&format!(" Your project is {p}; your channel is #{p}."));
3183×
            if !relations.0.is_empty() {
3190×
                text.push_str(&format!(" It pins {}.", relations.0.join(", ")));
3203×
            }
3213×
            if !relations.1.is_empty() {
3220×
                text.push_str(&format!(" {} pin it.", relations.1.join(", ")));
3233×
            }
3240×
        }
3253×
        if let Some(sess) = &ctx.session {
3263×
            text.push_str(&format!(" Session {sess}."));
3273×
        }
3283×
        text.push_str(&format!("\n\nHouse rules (version {}):\n", rules.version));
3293×
        text.push_str(if rules.text.is_empty() { "(none yet)\n" } else { &rules.text });
3303×
        text.push_str(&format!("\nInbox: {} unread.\n", items.len()));
3313×
        for i in &items {
3320×
            text.push_str(&format!("- {} from {} in {} ({}): {}\n", i.kind, label(d, &i.from).await, i.channel, i.id, i.summary));
333
        }
3343×
        text.push_str(&format!("\nAgents:\n{}\n", others.text));
3353×
        text.push_str("\nWhen you have nothing to do, end your turn: Spectra starts you again when there is work.");
3363×
        Output::ok(
3373×
            text,
3383×
            json!({ "you": me, "project": project, "session": ctx.session, "rules": { "version": rules.version, "text": rules.text },
3393×
                "inbox": items, "agents": others.structured.and_then(|s| s.get("agents").cloned()), "pins": relations.0, "pinned_by": relations.1 }),
340
        )
3413×
    })
3423×
}
343
3441×
fn rules(ctx: &Ctx, _args: Json) -> BoxFuture<'_, Output> {
3451×
    Box::pin(async move {
3461×
        match spectra_core::rules::compile(&ctx.daemon.data) {
3471×
            Ok(r) => {
3481×
                let _ = ctx.daemon.ledger.append_one(ctx.event("rules.read", json!({ "version": r.version }))).await;
3491×
                let body = if r.text.is_empty() { "(no house rules yet)".to_string() } else { r.text.clone() };
3501×
                Output::ok(format!("House rules, version {}:\n\n{body}", r.version), json!({ "version": r.version, "text": r.text }))
351
            }
3520×
            Err(e) => fail(e),
353
        }
3541×
    })
3551×
}
356
3576×
fn agents(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
3586×
    Box::pin(async move {
3596×
        let project = s(&args, "project");
3606×
        let rows: Vec<AgentRow> = match sqlx::query_as(
3616×
            "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 \
3626×
             WHERE a.disabled_ms IS NULL AND (? IS NULL OR a.project = ?) ORDER BY a.name",
363
        )
3646×
        .bind(project)
3656×
        .bind(project)
3666×
        .fetch_all(ctx.daemon.ledger.pool())
3676×
        .await
368
        {
3696×
            Ok(r) => r,
3700×
            Err(e) => return fail(e.into()),
371
        };
372
        // Presence as the daemon sees it now; the table is its recorded history.
3736×
        let rows: Vec<_> = {
3746×
            let p = ctx.daemon.presence.lock().await;
3756×
            let now = now_ms();
3766×
            rows.into_iter()
37710×
                .map(|(name, owner, kind, project, _, status)| {
37810×
                    let live = p.state(&name, now).to_string();
37910×
                    (name, owner, kind, project, Some(live), status)
38010×
                })
3816×
                .collect()
382
        };
3836×
        let list: Vec<Json> = rows
3846×
            .iter()
38510×
            .map(|(name, owner, kind, project, presence, status)| {
38610×
                json!({ "name": name, "owner": owner, "kind": kind, "project": project, "presence": presence.clone().unwrap_or_else(|| "offline".into()), "status": status })
38710×
            })
3886×
            .collect();
3896×
        let text = if rows.is_empty() {
3900×
            "No agents.".to_string()
391
        } else {
3926×
            rows.iter()
39310×
                .map(|(name, owner, kind, project, presence, status)| {
39410×
                    let st = status.as_deref().map(|s| format!(" — \"{s}\"")).unwrap_or_default();
39510×
                    format!("- {name} · {owner}'s agent ({kind}, #{project}): {}{st}", presence.as_deref().unwrap_or("offline"))
39610×
                })
3976×
                .collect::<Vec<_>>()
3986×
                .join("\n")
399
        };
4006×
        Output::ok(text, json!({ "agents": list }))
4016×
    })
4026×
}
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
4081×
fn status(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
4091×
    Box::pin(async move {
4101×
        let Principal::Agent { name, .. } = &ctx.caller.principal else { return Output::err("status is for agents") };
4111×
        let state = s(&args, "state").unwrap_or("working");
4121×
        let text = s(&args, "text");
4131×
        if text.is_some_and(|t| t.len() > 500) {
4140×
            return Output::err("a status is at most 500 characters");
4151×
        }
4161×
        let e = ctx.event("agent.status", json!({ "state": state, "text": text, "task": s(&args, "task") })).subject(name.clone());
4171×
        if let Err(e) = ctx.daemon.ledger.append_one(e).await {
4180×
            return fail(e);
4191×
        }
420
        {
4211×
            let mut p = ctx.daemon.presence.lock().await;
4221×
            p.status(name, (state != "idle").then(now_ms));
4231×
            p.blocked(name, state == "blocked");
424
        }
4251×
        Output::ok(
4261×
            format!("Status set: {state}{}", text.map(|t| format!(" — {t}")).unwrap_or_default()),
4271×
            json!({ "state": state, "text": text }),
428
        )
4291×
    })
4301×
}
431
4327×
fn say(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
4337×
    Box::pin(async move {
4347×
        let Some(body) = s(&args, "body") else { return Output::err("say needs a body") };
4357×
        let mut channel = s(&args, "channel").map(String::from);
4367×
        if let Some(to) = s(&args, "to") {
4370×
            match conv::ensure_direct(&ctx.daemon.ledger, &ctx.caller.principal, to).await {
4380×
                Ok(c) => channel = Some(c.name),
4390×
                Err(e) => return fail(e),
440
            }
4417×
        }
4427×
        let post = Post {
4437×
            channel,
4447×
            body: body.into(),
4457×
            reply_to: s(&args, "reply_to").map(String::from),
4467×
            kind: s(&args, "kind").map(String::from),
4477×
            ..Default::default()
4487×
        };
4497×
        match conv::post(&ctx.daemon.ledger, &ctx.author(), post, now_ms()).await {
4506×
            Ok(Posted::Message { id, channel }) => {
4516×
                Output::ok(format!("Posted {id} in {channel}."), json!({ "id": id, "channel": channel }))
452
            }
4530×
            Ok(Posted::Duplicate { id }) => {
4540×
                Output::ok(format!("Not posted: you said exactly this in the last minute ({id})."), json!({ "duplicate_of": id }))
455
            }
4561×
            Err(e) => fail(e),
457
        }
4587×
    })
4597×
}
460
4611×
fn read(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
4621×
    Box::pin(async move {
4631×
        let limit = args.get("limit").and_then(Json::as_u64).unwrap_or(50) as u32;
4641×
        match conv::read(&ctx.daemon.ledger, &ctx.caller.principal, s(&args, "channel"), s(&args, "thread"), s(&args, "since"), limit).await
465
        {
4661×
            Ok((ch, msgs)) => {
4671×
                let mut text = format!("{} message(s) in {}:\n\n", msgs.len(), ch.display());
4681×
                for m in &msgs {
4691×
                    text.push_str(&delimited(&ctx.daemon, m, &ch.display()).await);
4701×
                    text.push_str("\n\n");
471
                }
4721×
                Output::ok(text.trim_end().to_string(), json!({ "channel": ch.display(), "messages": msgs }))
473
            }
4740×
            Err(e) => fail(e),
475
        }
4761×
    })
4771×
}
478
4793×
fn inbox(ctx: &Ctx, _args: Json) -> BoxFuture<'_, Output> {
4803×
    Box::pin(async move {
4813×
        match conv::inbox(&ctx.daemon.ledger, &ctx.actor(), Some(&ctx.author())).await {
4823×
            Ok(items) => inbox_output(ctx, items).await,
4830×
            Err(e) => fail(e),
484
        }
4853×
    })
4863×
}
487
4885×
async fn inbox_output(ctx: &Ctx, items: Vec<conv::Item>) -> Output {
4895×
    if items.is_empty() {
4903×
        return Output::ok("Nothing unread.", json!({ "items": [] }));
4912×
    }
4922×
    let mut text = format!("{} unread:\n\n", items.len());
4932×
    for i in &items {
4942×
        text.push_str(&format!(
495
            "[{} from {} in {}, id {}]\n{}\n[end]\nAct on it: {}\n\n",
496
            i.kind,
4972×
            label(&ctx.daemon, &i.from).await,
498
            i.channel,
499
            i.id,
500
            i.summary,
501
            i.action_hint
502
        ));
503
    }
5042×
    Output::ok(text.trim_end().to_string(), json!({ "items": items }))
5055×
}
506
5076×
fn wait(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
5086×
    Box::pin(async move {
5096×
        let actor = ctx.actor();
5106×
        let now = now_ms();
511
        {
5126×
            let guard = ctx.daemon.idle_waits.lock().await;
5136×
            if let Some((_, until)) = guard.get(&actor) {
5143×
                if *until > now {
5151×
                    return Output::err("Nothing is waiting for you. End your turn; Spectra starts you again when there is work.");
5162×
                }
5173×
            }
518
        }
5195×
        let timeout = args.get("timeout_s").and_then(Json::as_u64).unwrap_or(30).clamp(1, 50);
5205×
        let name = actor.strip_prefix("agent:").unwrap_or(&actor).to_string();
521
        // Subscribe before looking, so nothing is missed between the two.
5225×
        let mut rx = ctx.daemon.ledger.subscribe();
5235×
        let ledger = &ctx.daemon.ledger;
5245×
        ctx.daemon.presence.lock().await.wait_started(&name);
5255×
        let found = async {
526
            loop {
5278×
                if conv::unread(ledger, &actor).await.unwrap_or(0) > 0 {
5282×
                    return true;
5296×
                }
5306×
                match rx.recv().await {
5313×
                    Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
5320×
                    Err(_) => return false,
533
                }
534
            }
5352×
        };
5365×
        let got = tokio::time::timeout(Duration::from_secs(timeout), found).await.unwrap_or(false);
5375×
        ctx.daemon.presence.lock().await.wait_ended(&name);
5385×
        let mut guard = ctx.daemon.idle_waits.lock().await;
5395×
        if got {
5402×
            guard.remove(&actor);
5412×
            drop(guard);
5422×
            return match conv::inbox(ledger, &actor, Some(&ctx.author())).await {
5432×
                Ok(items) => inbox_output(ctx, items).await,
5440×
                Err(e) => fail(e),
545
            };
5463×
        }
5473×
        let entry = guard.entry(actor).or_insert((0, 0));
5483×
        entry.0 += 1;
5493×
        if entry.0 >= IDLE_WAITS {
5501×
            entry.1 = now_ms() + IDLE_COOLDOWN_MS;
5511×
            entry.0 = 0;
5522×
        }
5533×
        Output { text: "Nothing new.".into(), structured: Some(json!({ "items": [] })), is_error: false, quiet: true }
5546×
    })
5556×
}
556
5571×
fn ack(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
5581×
    Box::pin(async move {
5591×
        let ids: Vec<String> = args
5601×
            .get("ids")
5611×
            .and_then(Json::as_array)
5621×
            .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
5631×
            .unwrap_or_default();
5641×
        if ids.is_empty() {
5650×
            return Output::err("ack needs ids");
5661×
        }
5671×
        match conv::ack(&ctx.daemon.ledger, &ctx.author(), &ids, s(&args, "reaction")).await {
5681×
            Ok(n) => Output::ok(format!("Marked {n} read."), json!({ "read": n })),
5690×
            Err(e) => fail(e),
570
        }
5711×
    })
5721×
}
573
5741×
fn search(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
5751×
    Box::pin(async move {
5761×
        let Some(q) = s(&args, "query") else { return Output::err("search needs a query") };
5771×
        let since = s(&args, "since").and_then(|v| parse_since(v, now_ms()));
5781×
        let limit = args.get("limit").and_then(Json::as_u64).unwrap_or(20) as u32;
5791×
        match conv::search(&ctx.daemon.ledger, &ctx.caller.principal, q, s(&args, "channel"), s(&args, "author"), since, limit).await {
5801×
            Ok(hits) => {
5811×
                let mut text = format!("{} hit(s):\n", hits.len());
5821×
                for h in &hits {
5831×
                    text.push_str(&format!(
584
                        "- [{} in {} at {}, id {}] {}\n",
5851×
                        label(&ctx.daemon, &h.author).await,
586
                        h.channel,
5871×
                        spectra_core::ids::format_time(h.at_ms as u64),
588
                        h.id,
589
                        h.snippet
590
                    ));
591
                }
5921×
                Output::ok(text.trim_end().to_string(), json!({ "hits": hits }))
593
            }
5940×
            Err(e) => fail(e),
595
        }
5961×
    })
5971×
}
598
599
/// `7d`, `3h`, `30m`, or milliseconds since the epoch.
6001×
pub fn parse_since(v: &str, now: u64) -> Option<i64> {
6011×
    let (n, unit) = v.split_at(v.len().saturating_sub(1));
6021×
    let ms = match unit {
6031×
        "d" => 86_400_000,
6040×
        "h" => 3_600_000,
6050×
        "m" => 60_000,
6060×
        _ => return v.parse().ok(),
607
    };
6081×
    n.parse::<u64>().ok().map(|n| now.saturating_sub(n * ms) as i64)
6091×
}
610
6112×
fn redact(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
6122×
    Box::pin(async move {
6132×
        let (Some(id), Some(reason)) = (s(&args, "id"), s(&args, "reason")) else { return Output::err("redact needs id and reason") };
6142×
        match conv::redact(&ctx.daemon.ledger, &ctx.author(), id, reason).await {
6151×
            Ok(()) => Output::ok(format!("Redacted {id}."), json!({ "id": id })),
6161×
            Err(e) => fail(e),
617
        }
6182×
    })
6192×
}