OXYGENHUB
◆ main spectra / crates / spectra-mcp / src / tools.rs
24.9 KiB · Rust · last changed Briefs and Radar's Constellation (#29, #30); spec Draft 0.6 1 day ago source blame history raw
571572f5 Andy McCall ↶ 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
037c45d8 Andy McCall ↶ 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.
571572f5 Andy McCall ↶ 11
12
use futures::future::BoxFuture;
13
use serde_json::{json, Value as Json};
14
use spectra_core::authn::Caller;
037c45d8 Andy McCall ↶ 15
use spectra_core::conversation::{self as conv, Author, Post, Posted, Signer};
571572f5 Andy McCall ↶ 16
use spectra_core::daemon::Daemon;
037c45d8 Andy McCall ↶ 17
use spectra_core::event::NewEvent;
18
use spectra_core::ids::now_ms;
571572f5 Andy McCall ↶ 19
use spectra_core::principals::Principal;
20
use std::sync::Arc;
037c45d8 Andy McCall ↶ 21
use std::time::Duration;
571572f5 Andy McCall ↶ 22
037c45d8 Andy McCall ↶ 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
571572f5 Andy McCall ↶ 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
037c45d8 Andy McCall ↶ 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
571572f5 Andy McCall ↶ 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,
037c45d8 Andy McCall ↶ 55
    /// An empty `wait`: not recorded (spec §9.2).
56
    pub quiet: bool,
571572f5 Andy McCall ↶ 57
}
58
59
impl Output {
60
    pub fn ok(text: impl Into<String>, structured: Json) -> Output {
037c45d8 Andy McCall ↶ 61
        Output { text: text.into(), structured: Some(structured), ..Default::default() }
571572f5 Andy McCall ↶ 62
    }
63
    pub fn err(text: impl Into<String>) -> Output {
037c45d8 Andy McCall ↶ 64
        Output { text: text.into(), is_error: true, ..Default::default() }
571572f5 Andy McCall ↶ 65
    }
037c45d8 Andy McCall ↶ 66
}
67
68
fn fail(e: anyhow::Error) -> Output {
69
    Output::err(format!("{e:#}"))
571572f5 Andy McCall ↶ 70
}
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 {
87
    pub fn describe(&self) -> Json {
88
        json!({
89
            "name": self.name,
90
            "description": self.description,
91
            "inputSchema": (self.schema)(),
92
            "annotations": { "readOnlyHint": self.read_only, "destructiveHint": self.destructive },
93
        })
94
    }
95
}
96
97
/// Every tool, in the order `tools/list` gives them.
98
pub fn all() -> &'static [Tool] {
99
    TOOLS
100
}
101
102
pub fn find(name: &str) -> Option<&'static Tool> {
103
    TOOLS.iter().find(|t| t.name == name)
037c45d8 Andy McCall ↶ 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.
b044d909 Andy McCall ↶ 113
        if let Some(rules) = spectra_core::rules::compile(ctx.daemon.ledger.pool(), project_of(&ctx.caller.principal))
114
            .await
115
            .ok()
116
            .filter(|r| !r.text.is_empty())
117
        {
037c45d8 Andy McCall ↶ 118
            let read = spectra_core::rules::last_read(ctx.daemon.ledger.pool(), name).await.ok().flatten();
119
            if read.as_deref() != Some(rules.version.as_str()) {
120
                let from = read.unwrap_or_else(|| "none".into());
121
                out.text = format!("{}\n\n{}", spectra_core::rules::changed_line(&from, &rules.version), out.text);
122
            }
123
        }
124
    }
125
    if out.quiet {
126
        return out;
127
    }
128
    let data = &ctx.daemon.data;
129
    let args_hash = spectra_core::attachments::put(data, args.to_string().as_bytes()).unwrap_or_default();
130
    let result_hash = spectra_core::attachments::put(data, out.text.as_bytes()).unwrap_or_default();
131
    let e = ctx.event(
132
        "tool.called",
133
        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 }),
134
    );
135
    let _ = ctx.daemon.ledger.append_one(e).await;
136
    out
137
}
138
139
fn obj(props: Json, required: &[&str]) -> Json {
140
    json!({ "type": "object", "properties": props, "required": required, "additionalProperties": false })
141
}
142
143
static TOOLS: &[Tool] = &[
144
    Tool {
145
        name: "brief",
b044d909 Andy McCall ↶ 146
        description: "Call first: who you are, your project, your brief, your unread inbox, and who else is here.",
037c45d8 Andy McCall ↶ 147
        schema: || obj(json!({ "new_session": { "type": "boolean" } }), &[]),
148
        read_only: true,
149
        destructive: false,
150
        long: false,
151
        run: brief,
152
    },
153
    Tool {
154
        name: "rules",
b044d909 Andy McCall ↶ 155
        description: "Your brief (overall and your project's) and its version, in full.",
037c45d8 Andy McCall ↶ 156
        schema: || obj(json!({}), &[]),
157
        read_only: true,
158
        destructive: false,
159
        long: false,
160
        run: rules,
161
    },
162
    Tool {
163
        name: "agents",
164
        description: "Every agent with its owner, kind, project, presence and status.",
165
        schema: || obj(json!({ "project": { "type": "string" } }), &[]),
166
        read_only: true,
167
        destructive: false,
168
        long: false,
169
        run: agents,
170
    },
171
    Tool {
172
        name: "status",
173
        description: "Say what you are doing, so others can see.",
174
        schema: || obj(json!({ "state": { "enum": ["working", "idle", "blocked"] }, "text": { "type": "string" }, "task": { "type": "string" } }), &[]),
175
        read_only: false,
176
        destructive: false,
177
        long: false,
178
        run: status,
179
    },
180
    Tool {
181
        name: "say",
182
        description: "Post a message (default: your project's channel). @name mentions; reply_to answers in a thread.",
183
        schema: || {
184
            obj(
185
                json!({ "body": { "type": "string" }, "channel": { "type": "string" }, "reply_to": { "type": "string" },
186
                    "kind": { "enum": ["chat", "request", "answer", "handoff", "status", "decision"] }, "to": { "type": "string" } }),
187
                &["body"],
188
            )
189
        },
190
        read_only: false,
191
        destructive: false,
192
        long: false,
193
        run: say,
194
    },
195
    Tool {
196
        name: "read",
197
        description: "Messages of a channel (default: your project's) or a whole thread; marks nothing read.",
198
        schema: || obj(json!({ "channel": { "type": "string" }, "thread": { "type": "string" }, "since": { "type": "string" }, "limit": { "type": "integer" } }), &[]),
199
        read_only: true,
200
        destructive: false,
201
        long: false,
202
        run: read,
203
    },
204
    Tool {
205
        name: "inbox",
206
        description: "Your unread mentions and direct messages.",
207
        schema: || obj(json!({}), &[]),
208
        read_only: true,
209
        destructive: false,
210
        long: false,
211
        run: inbox,
212
    },
213
    Tool {
214
        name: "wait",
215
        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.",
216
        schema: || obj(json!({ "timeout_s": { "type": "integer", "minimum": 1, "maximum": 50 } }), &["timeout_s"]),
217
        read_only: true,
218
        destructive: false,
219
        long: true,
220
        run: wait,
221
    },
222
    Tool {
223
        name: "ack",
224
        description: "Mark inbox items read, with an optional reaction (ack, done, eyes, no).",
225
        schema: || obj(json!({ "ids": { "type": "array", "items": { "type": "string" } }, "reaction": { "enum": ["ack", "done", "eyes", "no"] } }), &["ids"]),
226
        read_only: false,
227
        destructive: false,
228
        long: false,
229
        run: ack,
230
    },
231
    Tool {
232
        name: "search",
233
        description: "Search history: words, \"phrases\", prefix*, AND/OR/NOT; filter by channel, author, since (7d, 3h).",
234
        schema: || {
235
            obj(json!({ "query": { "type": "string" }, "channel": { "type": "string" }, "author": { "type": "string" }, "since": { "type": "string" }, "limit": { "type": "integer" } }), &["query"])
236
        },
237
        read_only: true,
238
        destructive: false,
239
        long: false,
240
        run: search,
241
    },
242
    Tool {
243
        name: "redact",
244
        description: "Redact your own message, with the reason.",
245
        schema: || obj(json!({ "id": { "type": "string" }, "reason": { "type": "string" } }), &["id", "reason"]),
246
        read_only: false,
247
        destructive: true,
248
        long: false,
249
        run: redact,
250
    },
251
];
252
253
fn s<'a>(args: &'a Json, k: &str) -> Option<&'a str> {
254
    args.get(k).and_then(Json::as_str).filter(|v| !v.is_empty())
255
}
256
257
/// How a principal is named to a model: `claude-oxygen (Andy McCall's agent, claude-code)`.
258
async fn label(d: &Daemon, actor: &str) -> String {
259
    if actor == "human" {
260
        let name: Option<String> = sqlx::query_scalar("SELECT name FROM identities WHERE role = 'owner' AND current = 1")
261
            .fetch_optional(d.ledger.pool())
262
            .await
263
            .ok()
264
            .flatten();
265
        return format!("{} (the human)", name.unwrap_or_else(|| "the owner".into()));
266
    }
267
    if let Some(a) = actor.strip_prefix("agent:") {
268
        let row: Option<(String, String)> = sqlx::query_as("SELECT owner_name, kind FROM agents WHERE name = ?")
269
            .bind(a)
270
            .fetch_optional(d.ledger.pool())
271
            .await
272
            .ok()
273
            .flatten();
274
        if let Some((owner, kind)) = row {
275
            return format!("{a} ({owner}'s agent, {kind})");
276
        }
277
        return a.to_string();
278
    }
279
    "Spectra".into()
280
}
281
282
/// A message as a model reads it: delimited with its author (spec §20.3).
283
async fn delimited(d: &Daemon, m: &conv::Message, channel: &str) -> String {
284
    let at = spectra_core::ids::format_time(m.at_ms as u64);
285
    let thread = m.reply_to.as_deref().map(|r| format!(", reply in thread {r}")).unwrap_or_default();
286
    let replies = if m.replies > 0 { format!(" ({} replies)", m.replies) } else { String::new() };
287
    format!("[from {} at {at} in {channel}, id {}{thread}{replies}]\n{}\n[end]", label(d, &m.author).await, m.id, m.body)
571572f5 Andy McCall ↶ 288
}
289
037c45d8 Andy McCall ↶ 290
fn brief(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
291
    let _ = args;
292
    Box::pin(async move {
293
        let d = &ctx.daemon;
b044d909 Andy McCall ↶ 294
        let rules = match spectra_core::rules::compile(d.ledger.pool(), project_of(&ctx.caller.principal)).await {
037c45d8 Andy McCall ↶ 295
            Ok(r) => r,
296
            Err(e) => return fail(e),
297
        };
298
        let me = ctx.caller.principal.display();
299
        let project = match &ctx.caller.principal {
300
            Principal::Agent { project, .. } => Some(project.clone()),
301
            _ => None,
302
        };
303
        let mut relations = (Vec::<String>::new(), Vec::<String>::new());
304
        if let Some(p) = &project {
305
            relations.0 = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ?")
306
                .bind(p)
307
                .fetch_all(d.ledger.pool())
308
                .await
309
                .unwrap_or_default();
310
            relations.1 = sqlx::query_scalar("SELECT project FROM project_relations WHERE depends_on = ?")
311
                .bind(p)
312
                .fetch_all(d.ledger.pool())
313
                .await
314
                .unwrap_or_default();
315
        }
316
        let items = conv::inbox(&d.ledger, &ctx.actor(), Some(&ctx.author())).await.unwrap_or_default();
317
        let others = agents(ctx, json!({})).await;
318
        let _ = d.ledger.append_one(ctx.event("rules.read", json!({ "version": rules.version }))).await;
319
        let mut text = format!("You are {me}.");
320
        if let Some(p) = &project {
321
            text.push_str(&format!(" Your project is {p}; your channel is #{p}."));
322
            if !relations.0.is_empty() {
323
                text.push_str(&format!(" It pins {}.", relations.0.join(", ")));
324
            }
325
            if !relations.1.is_empty() {
326
                text.push_str(&format!(" {} pin it.", relations.1.join(", ")));
327
            }
328
        }
329
        if let Some(sess) = &ctx.session {
330
            text.push_str(&format!(" Session {sess}."));
331
        }
b044d909 Andy McCall ↶ 332
        text.push_str(&format!("\n\nYour brief (version {}):\n", rules.version));
333
        text.push_str(if rules.text.is_empty() { "(none set yet)\n" } else { &rules.text });
037c45d8 Andy McCall ↶ 334
        text.push_str(&format!("\nInbox: {} unread.\n", items.len()));
335
        for i in &items {
336
            text.push_str(&format!("- {} from {} in {} ({}): {}\n", i.kind, label(d, &i.from).await, i.channel, i.id, i.summary));
337
        }
338
        text.push_str(&format!("\nAgents:\n{}\n", others.text));
339
        text.push_str("\nWhen you have nothing to do, end your turn: Spectra starts you again when there is work.");
340
        Output::ok(
341
            text,
342
            json!({ "you": me, "project": project, "session": ctx.session, "rules": { "version": rules.version, "text": rules.text },
343
                "inbox": items, "agents": others.structured.and_then(|s| s.get("agents").cloned()), "pins": relations.0, "pinned_by": relations.1 }),
344
        )
345
    })
346
}
571572f5 Andy McCall ↶ 347
037c45d8 Andy McCall ↶ 348
fn rules(ctx: &Ctx, _args: Json) -> BoxFuture<'_, Output> {
349
    Box::pin(async move {
b044d909 Andy McCall ↶ 350
        match spectra_core::rules::compile(ctx.daemon.ledger.pool(), project_of(&ctx.caller.principal)).await {
037c45d8 Andy McCall ↶ 351
            Ok(r) => {
352
                let _ = ctx.daemon.ledger.append_one(ctx.event("rules.read", json!({ "version": r.version }))).await;
b044d909 Andy McCall ↶ 353
                let body = if r.text.is_empty() { "(no brief set yet)".to_string() } else { r.text.clone() };
354
                Output::ok(format!("Your brief, version {}:\n\n{body}", r.version), json!({ "version": r.version, "text": r.text }))
037c45d8 Andy McCall ↶ 355
            }
356
            Err(e) => fail(e),
357
        }
358
    })
571572f5 Andy McCall ↶ 359
}
360
361
fn agents(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
362
    Box::pin(async move {
037c45d8 Andy McCall ↶ 363
        let project = s(&args, "project");
364
        let rows: Vec<AgentRow> = match sqlx::query_as(
365
            "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 \
366
             WHERE a.disabled_ms IS NULL AND (? IS NULL OR a.project = ?) ORDER BY a.name",
367
        )
368
        .bind(project)
369
        .bind(project)
370
        .fetch_all(ctx.daemon.ledger.pool())
371
        .await
372
        {
571572f5 Andy McCall ↶ 373
            Ok(r) => r,
037c45d8 Andy McCall ↶ 374
            Err(e) => return fail(e.into()),
571572f5 Andy McCall ↶ 375
        };
037c45d8 Andy McCall ↶ 376
        // Presence as the daemon sees it now; the table is its recorded history.
377
        let rows: Vec<_> = {
378
            let p = ctx.daemon.presence.lock().await;
379
            let now = now_ms();
380
            rows.into_iter()
381
                .map(|(name, owner, kind, project, _, status)| {
382
                    let live = p.state(&name, now).to_string();
383
                    (name, owner, kind, project, Some(live), status)
384
                })
385
                .collect()
386
        };
571572f5 Andy McCall ↶ 387
        let list: Vec<Json> = rows
388
            .iter()
389
            .map(|(name, owner, kind, project, presence, status)| {
390
                json!({ "name": name, "owner": owner, "kind": kind, "project": project, "presence": presence.clone().unwrap_or_else(|| "offline".into()), "status": status })
391
            })
392
            .collect();
393
        let text = if rows.is_empty() {
394
            "No agents.".to_string()
395
        } else {
396
            rows.iter()
397
                .map(|(name, owner, kind, project, presence, status)| {
398
                    let st = status.as_deref().map(|s| format!(" — \"{s}\"")).unwrap_or_default();
399
                    format!("- {name} · {owner}'s agent ({kind}, #{project}): {}{st}", presence.as_deref().unwrap_or("offline"))
400
                })
401
                .collect::<Vec<_>>()
402
                .join("\n")
403
        };
404
        Output::ok(text, json!({ "agents": list }))
037c45d8 Andy McCall ↶ 405
    })
406
}
407
408
/// An agent as the `agents` tool reads it: name, owner, kind, project,
409
/// presence and status.
410
type AgentRow = (String, String, String, String, Option<String>, Option<String>);
411
412
fn status(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
413
    Box::pin(async move {
414
        let Principal::Agent { name, .. } = &ctx.caller.principal else { return Output::err("status is for agents") };
415
        let state = s(&args, "state").unwrap_or("working");
416
        let text = s(&args, "text");
417
        if text.is_some_and(|t| t.len() > 500) {
418
            return Output::err("a status is at most 500 characters");
419
        }
420
        let e = ctx.event("agent.status", json!({ "state": state, "text": text, "task": s(&args, "task") })).subject(name.clone());
421
        if let Err(e) = ctx.daemon.ledger.append_one(e).await {
422
            return fail(e);
423
        }
424
        {
425
            let mut p = ctx.daemon.presence.lock().await;
426
            p.status(name, (state != "idle").then(now_ms));
427
            p.blocked(name, state == "blocked");
428
        }
429
        Output::ok(
430
            format!("Status set: {state}{}", text.map(|t| format!(" — {t}")).unwrap_or_default()),
431
            json!({ "state": state, "text": text }),
432
        )
433
    })
434
}
435
436
fn say(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
437
    Box::pin(async move {
438
        let Some(body) = s(&args, "body") else { return Output::err("say needs a body") };
439
        let mut channel = s(&args, "channel").map(String::from);
440
        if let Some(to) = s(&args, "to") {
441
            match conv::ensure_direct(&ctx.daemon.ledger, &ctx.caller.principal, to).await {
442
                Ok(c) => channel = Some(c.name),
443
                Err(e) => return fail(e),
444
            }
445
        }
446
        let post = Post {
447
            channel,
448
            body: body.into(),
449
            reply_to: s(&args, "reply_to").map(String::from),
450
            kind: s(&args, "kind").map(String::from),
451
            ..Default::default()
452
        };
453
        match conv::post(&ctx.daemon.ledger, &ctx.author(), post, now_ms()).await {
454
            Ok(Posted::Message { id, channel }) => {
455
                Output::ok(format!("Posted {id} in {channel}."), json!({ "id": id, "channel": channel }))
456
            }
457
            Ok(Posted::Duplicate { id }) => {
458
                Output::ok(format!("Not posted: you said exactly this in the last minute ({id})."), json!({ "duplicate_of": id }))
459
            }
460
            Err(e) => fail(e),
461
        }
462
    })
463
}
464
465
fn read(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
466
    Box::pin(async move {
467
        let limit = args.get("limit").and_then(Json::as_u64).unwrap_or(50) as u32;
468
        match conv::read(&ctx.daemon.ledger, &ctx.caller.principal, s(&args, "channel"), s(&args, "thread"), s(&args, "since"), limit).await
469
        {
470
            Ok((ch, msgs)) => {
471
                let mut text = format!("{} message(s) in {}:\n\n", msgs.len(), ch.display());
472
                for m in &msgs {
473
                    text.push_str(&delimited(&ctx.daemon, m, &ch.display()).await);
474
                    text.push_str("\n\n");
475
                }
476
                Output::ok(text.trim_end().to_string(), json!({ "channel": ch.display(), "messages": msgs }))
477
            }
478
            Err(e) => fail(e),
479
        }
480
    })
481
}
482
483
fn inbox(ctx: &Ctx, _args: Json) -> BoxFuture<'_, Output> {
484
    Box::pin(async move {
485
        match conv::inbox(&ctx.daemon.ledger, &ctx.actor(), Some(&ctx.author())).await {
486
            Ok(items) => inbox_output(ctx, items).await,
487
            Err(e) => fail(e),
488
        }
489
    })
490
}
491
492
async fn inbox_output(ctx: &Ctx, items: Vec<conv::Item>) -> Output {
493
    if items.is_empty() {
494
        return Output::ok("Nothing unread.", json!({ "items": [] }));
495
    }
496
    let mut text = format!("{} unread:\n\n", items.len());
497
    for i in &items {
498
        text.push_str(&format!(
499
            "[{} from {} in {}, id {}]\n{}\n[end]\nAct on it: {}\n\n",
500
            i.kind,
501
            label(&ctx.daemon, &i.from).await,
502
            i.channel,
503
            i.id,
504
            i.summary,
505
            i.action_hint
506
        ));
507
    }
508
    Output::ok(text.trim_end().to_string(), json!({ "items": items }))
509
}
510
511
fn wait(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
512
    Box::pin(async move {
513
        let actor = ctx.actor();
514
        let now = now_ms();
515
        {
516
            let guard = ctx.daemon.idle_waits.lock().await;
517
            if let Some((_, until)) = guard.get(&actor) {
518
                if *until > now {
519
                    return Output::err("Nothing is waiting for you. End your turn; Spectra starts you again when there is work.");
520
                }
521
            }
522
        }
523
        let timeout = args.get("timeout_s").and_then(Json::as_u64).unwrap_or(30).clamp(1, 50);
524
        let name = actor.strip_prefix("agent:").unwrap_or(&actor).to_string();
525
        // Subscribe before looking, so nothing is missed between the two.
526
        let mut rx = ctx.daemon.ledger.subscribe();
527
        let ledger = &ctx.daemon.ledger;
528
        ctx.daemon.presence.lock().await.wait_started(&name);
529
        let found = async {
530
            loop {
531
                if conv::unread(ledger, &actor).await.unwrap_or(0) > 0 {
532
                    return true;
533
                }
534
                match rx.recv().await {
535
                    Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
536
                    Err(_) => return false,
537
                }
538
            }
539
        };
540
        let got = tokio::time::timeout(Duration::from_secs(timeout), found).await.unwrap_or(false);
541
        ctx.daemon.presence.lock().await.wait_ended(&name);
542
        let mut guard = ctx.daemon.idle_waits.lock().await;
543
        if got {
544
            guard.remove(&actor);
545
            drop(guard);
546
            return match conv::inbox(ledger, &actor, Some(&ctx.author())).await {
547
                Ok(items) => inbox_output(ctx, items).await,
548
                Err(e) => fail(e),
549
            };
550
        }
551
        let entry = guard.entry(actor).or_insert((0, 0));
552
        entry.0 += 1;
553
        if entry.0 >= IDLE_WAITS {
554
            entry.1 = now_ms() + IDLE_COOLDOWN_MS;
555
            entry.0 = 0;
556
        }
557
        Output { text: "Nothing new.".into(), structured: Some(json!({ "items": [] })), is_error: false, quiet: true }
558
    })
559
}
560
561
fn ack(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
562
    Box::pin(async move {
563
        let ids: Vec<String> = args
564
            .get("ids")
565
            .and_then(Json::as_array)
566
            .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
567
            .unwrap_or_default();
568
        if ids.is_empty() {
569
            return Output::err("ack needs ids");
570
        }
571
        match conv::ack(&ctx.daemon.ledger, &ctx.author(), &ids, s(&args, "reaction")).await {
572
            Ok(n) => Output::ok(format!("Marked {n} read."), json!({ "read": n })),
573
            Err(e) => fail(e),
574
        }
575
    })
576
}
577
578
fn search(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
579
    Box::pin(async move {
580
        let Some(q) = s(&args, "query") else { return Output::err("search needs a query") };
581
        let since = s(&args, "since").and_then(|v| parse_since(v, now_ms()));
582
        let limit = args.get("limit").and_then(Json::as_u64).unwrap_or(20) as u32;
583
        match conv::search(&ctx.daemon.ledger, &ctx.caller.principal, q, s(&args, "channel"), s(&args, "author"), since, limit).await {
584
            Ok(hits) => {
585
                let mut text = format!("{} hit(s):\n", hits.len());
586
                for h in &hits {
587
                    text.push_str(&format!(
588
                        "- [{} in {} at {}, id {}] {}\n",
589
                        label(&ctx.daemon, &h.author).await,
590
                        h.channel,
591
                        spectra_core::ids::format_time(h.at_ms as u64),
592
                        h.id,
593
                        h.snippet
594
                    ));
595
                }
596
                Output::ok(text.trim_end().to_string(), json!({ "hits": hits }))
597
            }
598
            Err(e) => fail(e),
599
        }
571572f5 Andy McCall ↶ 600
    })
601
}
602
037c45d8 Andy McCall ↶ 603
/// `7d`, `3h`, `30m`, or milliseconds since the epoch.
604
pub fn parse_since(v: &str, now: u64) -> Option<i64> {
605
    let (n, unit) = v.split_at(v.len().saturating_sub(1));
606
    let ms = match unit {
607
        "d" => 86_400_000,
608
        "h" => 3_600_000,
609
        "m" => 60_000,
610
        _ => return v.parse().ok(),
611
    };
612
    n.parse::<u64>().ok().map(|n| now.saturating_sub(n * ms) as i64)
613
}
614
615
fn redact(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
616
    Box::pin(async move {
617
        let (Some(id), Some(reason)) = (s(&args, "id"), s(&args, "reason")) else { return Output::err("redact needs id and reason") };
618
        match conv::redact(&ctx.daemon.ledger, &ctx.author(), id, reason).await {
619
            Ok(()) => Output::ok(format!("Redacted {id}."), json!({ "id": id })),
620
            Err(e) => fail(e),
621
        }
622
    })
b044d909 Andy McCall ↶ 623
}
624
625
/// The project whose brief applies to `p`: an agent's own; none for the owner.
626
pub fn project_of(p: &Principal) -> Option<&str> {
627
    match p {
628
        Principal::Agent { project, .. } => Some(project.as_str()),
629
        _ => None,
630
    }
571572f5 Andy McCall ↶ 631
}