OXYGENHUB

Coverage / crates/spectra-core/src/conversation.rs 90.6%

551 of 608 lines run, 73 of 167 functions · run #36 on main · run missed

1
//! Conversation (spec §10): channels, messages, threads, reactions, each
2
//! principal's inbox, and search (§13.1). The MCP tools, the API and the
3
//! CLI all go through here, so the rules are the same whoever asks.
4
//!
5
//! A message is redacted by its author or the owner: Spectra stops showing
6
//! it everywhere, while the ledger keeps the original for audit until a
7
//! prune removes it (§9.1).
8
9
use crate::event::{Auth, Event, NewEvent};
10
use crate::keys::KeyPair;
11
use crate::ledger::Ledger;
12
use crate::principals::{signed_event, Principal};
13
use anyhow::{bail, Context, Result};
14
use serde::Serialize;
15
use serde_json::{json, Value as Json};
16
use sqlx::SqliteConnection;
17
18
/// The most a body or a payload may be (spec §10.3).
19
pub const MAX_BODY: usize = 64 * 1024;
20
/// A message identical to one its author posted in the same channel within
21
/// this long is dropped (spec §20.4).
22
pub const DUPLICATE_MS: u64 = 60_000;
23
/// The channels every Spectra has (spec §10.1).
24
pub const DEFAULT_CHANNELS: &[(&str, &str, &str)] = &[
25
    ("general", "shared", "Everyone: agents and the human"),
26
    ("humans", "shared", "Where agents ask the human"),
27
    ("system", "system", "Guard rails, schedules and hooks; read-only for agents"),
28
];
29
/// Message kinds (spec §10.3).
30
pub const KINDS: &[&str] = &["chat", "request", "answer", "handoff", "status", "decision", "system"];
31
pub const REACTIONS: &[&str] = &["ack", "done", "eyes", "no"];
32
33
/// Who is acting, and how their events are signed.
34
pub struct Author<'a> {
35
    pub principal: Principal,
36
    pub signer: Signer<'a>,
37
}
38
39
/// The CLI signs each event over its payload; the daemon carries the proof
40
/// of the request that caused it.
41
pub enum Signer<'a> {
42
    Cli(&'a KeyPair),
43
    Request(Auth),
44
    System,
45
}
46
47
impl Author<'_> {
4848×
    fn event(&self, kind: &str, payload: Json) -> NewEvent {
4948×
        let actor = self.principal.actor();
5048×
        match &self.signer {
5119×
            Signer::Cli(pair) => signed_event(pair, &actor, kind, payload),
5229×
            Signer::Request(auth) => NewEvent::system(kind, payload).by(actor, auth.clone()),
530×
            Signer::System => NewEvent::system(kind, payload),
54
        }
5548×
    }
56
}
57
58
#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
59
pub struct Channel {
60
    pub id: String,
61
    pub name: String,
62
    pub kind: String,
63
    pub project: Option<String>,
64
    pub purpose: Option<String>,
65
    pub members: String,
66
}
67
68
impl Channel {
695×
    pub fn members(&self) -> Vec<String> {
705×
        serde_json::from_str(&self.members).unwrap_or_default()
715×
    }
72
    /// `#general`, `#oxygen`, or `@a+b` for a direct channel.
7354×
    pub fn display(&self) -> String {
7454×
        match self.name.strip_prefix("dm:") {
755×
            Some(pair) => format!("@{pair}"),
7649×
            None => format!("#{}", self.name),
77
        }
7854×
    }
79
}
80
81
#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
82
pub struct Message {
83
    pub id: String,
84
    pub channel: String,
85
    pub reply_to: Option<String>,
86
    pub author: String,
87
    pub kind: String,
88
    pub body: String,
89
    pub payload: Option<String>,
90
    pub mentions: String,
91
    pub refs: String,
92
    pub at_ms: i64,
93
    pub seq: i64,
94
    pub replies: i64,
95
    pub redacted: Option<String>,
96
}
97
98
/// What a post asks for.
99
#[derive(Clone, Debug, Default)]
100
pub struct Post {
101
    pub channel: Option<String>,
102
    pub body: String,
103
    pub reply_to: Option<String>,
104
    pub kind: Option<String>,
105
    pub payload: Option<Json>,
106
    pub mentions: Vec<String>,
107
    pub refs: Vec<String>,
108
}
109
110
/// The outcome of a post: the message, or why it was dropped.
111
#[derive(Clone, Debug, Serialize)]
112
pub enum Posted {
113
    Message { id: String, channel: String },
114
    Duplicate { id: String },
115
}
116
117
/// Make the default channels (at `spectra init`).
11832×
pub fn default_channel_events() -> Vec<NewEvent> {
11996×
    DEFAULT_CHANNELS.iter().map(|(name, kind, purpose)| channel_event(name, kind, None, Some(purpose), &[])).collect()
12032×
}
121
122
/// A project's channel, made when the project is added.
12337×
pub fn project_channel_event(project: &str) -> NewEvent {
12437×
    channel_event(project, "project", Some(project), Some(&format!("The {project} project")), &[])
12537×
}
126
127135×
fn channel_event(name: &str, kind: &str, project: Option<&str>, purpose: Option<&str>, members: &[String]) -> NewEvent {
128135×
    let id = crate::ids::ulid();
129135×
    NewEvent::system(
130135×
        "channel.created",
131135×
        json!({ "id": id, "name": name, "kind": kind, "project": project, "purpose": purpose, "members": members }),
132
    )
133135×
    .subject(id)
134135×
}
135
136
/// The name of the direct channel between two actors.
1375×
pub fn direct_name(a: &str, b: &str) -> String {
13810×
    let short = |x: &str| x.strip_prefix("agent:").unwrap_or(x).to_string();
1395×
    let mut v = [short(a), short(b)];
1405×
    v.sort();
1415×
    format!("dm:{}+{}", v[0], v[1])
1425×
}
143
144
/// Find a channel by `#name`, `name`, `@a+b` or id.
14556×
pub async fn channel(ledger: &Ledger, name: &str) -> Result<Option<Channel>> {
14656×
    let n = name.strip_prefix('#').unwrap_or(name);
14756×
    let n = match n.strip_prefix('@') {
1480×
        Some(pair) => format!("dm:{pair}"),
14956×
        None => n.to_string(),
150
    };
15156×
    Ok(sqlx::query_as("SELECT id, name, kind, project, purpose, members FROM channels WHERE name = ? OR id = ?")
15256×
        .bind(&n)
15356×
        .bind(&n)
15456×
        .fetch_optional(ledger.pool())
15556×
        .await?)
15656×
}
157
1581×
pub async fn channels(ledger: &Ledger) -> Result<Vec<Channel>> {
1591×
    Ok(sqlx::query_as("SELECT id, name, kind, project, purpose, members FROM channels ORDER BY kind = 'direct', name")
1601×
        .fetch_all(ledger.pool())
1611×
        .await?)
1621×
}
163
164
/// Whether `who` may read `ch`: direct channels are for their members.
16511×
pub fn can_read(who: &Principal, ch: &Channel) -> bool {
16611×
    ch.kind != "direct" || matches!(who, Principal::Owner { .. }) || ch.members().contains(&who.actor())
16711×
}
168
169
/// Whether `who` may post in `ch` (spec §10.1, §8.4).
17043×
pub fn can_post(who: &Principal, ch: &Channel) -> Result<(), String> {
17143×
    match (who, ch.kind.as_str()) {
1720×
        (Principal::Instance { .. }, _) => Ok(()),
17343×
        (_, "system") => Err("#system is Spectra's: guard rails, schedules and hooks post there".into()),
1744×
        (Principal::Owner { .. }, _) => Ok(()),
17536×
        (_, "direct") if !ch.members().contains(&who.actor()) => Err(format!("{} is a direct channel you are not in", ch.display())),
17633×
        (Principal::Agent { project, scopes, .. }, "project") => {
17714×
            let granted = format!("channel:{}", ch.name);
17850×
            if ch.project.as_deref() == Some(project) || scopes.iter().any(|s| s == "projects:all" || *s == granted) {
17910×
                Ok(())
180
            } else {
1814×
                Err(format!(
1824×
                    "{} belongs to another project; post in #{project} or #general, mention its agents from there, or ask the owner to run `spectra agent allow NAME --channel {}`",
1834×
                    ch.display(),
1844×
                    ch.name
1854×
                ))
186
            }
187
        }
18821×
        _ => Ok(()),
189
    }
19043×
}
191
192
/// Post a message, checking everything first (spec §10.3).
19346×
pub async fn post(ledger: &Ledger, who: &Author<'_>, mut p: Post, now_ms: u64) -> Result<Posted> {
19446×
    if p.body.trim().is_empty() {
1951×
        bail!("a message needs a body");
19645×
    }
19745×
    if p.body.len() > MAX_BODY {
1981×
        bail!("a message's body is at most 64 KiB; this is {} KiB", p.body.len() / 1024);
19944×
    }
20044×
    if p.payload.as_ref().is_some_and(|v| v.to_string().len() > MAX_BODY) {
2010×
        bail!("a message's payload is at most 64 KiB");
20244×
    }
20344×
    let kind = p.kind.take().unwrap_or_else(|| "chat".into());
20444×
    if !KINDS.contains(&kind.as_str()) {
2051×
        bail!("a message's kind is one of {}", KINDS.join(", "));
20643×
    }
20743×
    let ch = match (&p.channel, &p.reply_to, &who.principal) {
20833×
        (Some(c), _, _) => channel(ledger, c).await?.with_context(|| format!("no channel {c}"))?,
2094×
        (None, Some(r), _) => {
2104×
            let root = message(ledger, r).await?.with_context(|| format!("no message {r}"))?;
2114×
            channel(ledger, &root.channel).await?.context("the thread's channel")?
212
        }
2136×
        (None, None, Principal::Agent { project, .. }) => {
2146×
            channel(ledger, project).await?.with_context(|| format!("no channel for project {project}"))?
215
        }
2160×
        (None, None, _) => channel(ledger, "general").await?.context("no #general (spectra init makes it)")?,
217
    };
21843×
    can_post(&who.principal, &ch).map_err(anyhow::Error::msg)?;
21935×
    let reply_to = match &p.reply_to {
2204×
        Some(r) => {
2214×
            let m = message(ledger, r).await?.with_context(|| format!("no message {r}"))?;
2224×
            if m.channel != ch.id {
2230×
                bail!("{r} is in another channel");
2244×
            }
225
            // A reply to a reply joins the root's thread.
2264×
            Some(m.reply_to.unwrap_or(m.id))
227
        }
22831×
        None => None,
229
    };
23035×
    let author = who.principal.actor();
23135×
    let dup: Option<String> = sqlx::query_scalar("SELECT id FROM messages WHERE channel = ? AND author = ? AND body = ? AND at_ms >= ? AND redacted IS NULL ORDER BY seq DESC LIMIT 1")
23235×
        .bind(&ch.id)
23335×
        .bind(&author)
23435×
        .bind(&p.body)
23535×
        .bind(now_ms.saturating_sub(DUPLICATE_MS) as i64)
23635×
        .fetch_optional(ledger.pool())
23735×
        .await?;
23835×
    if let Some(id) = dup {
2391×
        return Ok(Posted::Duplicate { id });
24034×
    }
24134×
    let mut mentions = resolve_mentions(ledger, &p.body, &ch).await?;
24234×
    for m in p.mentions.drain(..) {
2430×
        if let Some(a) = resolve_name(ledger, &m).await? {
2440×
            mentions.push(a);
2450×
        }
246
    }
24734×
    mentions.retain(|m| m != &author);
24834×
    mentions.sort();
24934×
    mentions.dedup();
25034×
    let mut refs = parse_refs(&p.body);
25134×
    refs.append(&mut p.refs);
25234×
    refs.sort();
25334×
    refs.dedup();
25434×
    let id = crate::ids::ulid();
25534×
    let payload = json!({
25634×
        "id": id, "channel": ch.id, "reply_to": reply_to, "kind": kind, "body": p.body,
25734×
        "payload": p.payload, "mentions": mentions, "refs": refs,
258
    });
25934×
    let mut e = who.event("message.posted", payload).subject(id.clone());
26034×
    e.project = ch.project.clone();
26134×
    ledger.append_one(e).await?;
26234×
    Ok(Posted::Message { id, channel: ch.display() })
26346×
}
264
265
/// Make the direct channel between `who` and `other` when there is none.
2662×
pub async fn ensure_direct(ledger: &Ledger, who: &Principal, other: &str) -> Result<Channel> {
2672×
    let other = resolve_name(ledger, other).await?.with_context(|| format!("nobody is called {other}"))?;
2682×
    let name = direct_name(&who.actor(), &other);
2692×
    if let Some(c) = channel(ledger, &name).await? {
2700×
        return Ok(c);
2712×
    }
2722×
    let mut members = vec![who.actor(), other];
2732×
    members.sort();
2742×
    ledger.append_one(channel_event(&name, "direct", None, None, &members)).await?;
2752×
    channel(ledger, &name).await?.context("the direct channel")
2762×
}
277
27822×
pub async fn message(ledger: &Ledger, id: &str) -> Result<Option<Message>> {
27922×
    Ok(sqlx::query_as(&format!("{SELECT_MESSAGE} WHERE id = ? OR (length(?) >= 6 AND id LIKE ? || '%') ORDER BY seq LIMIT 1"))
28022×
        .bind(id)
28122×
        .bind(id)
28222×
        .bind(id)
28322×
        .fetch_optional(ledger.pool())
28422×
        .await?)
28522×
}
286
287
const SELECT_MESSAGE: &str =
288
    "SELECT id, channel, reply_to, author, kind, body, payload, mentions, refs, at_ms, seq, replies, redacted FROM messages";
289
290
/// A channel's top-level messages (each with its reply count), or a whole
291
/// thread, oldest first; `since` is a message id or a time in ms.
2927×
pub async fn read(
2937×
    ledger: &Ledger,
2947×
    who: &Principal,
2957×
    channel_name: Option<&str>,
2967×
    thread: Option<&str>,
2977×
    since: Option<&str>,
2987×
    limit: u32,
2997×
) -> Result<(Channel, Vec<Message>)> {
3007×
    let limit = limit.clamp(1, 500) as i64;
3017×
    let since_seq = match since {
3027×
        None => 0,
3030×
        Some(s) => match s.parse::<i64>() {
3040×
            Ok(ms) if ms > 1_000_000_000_000 => {
3050×
                sqlx::query_scalar("SELECT COALESCE(MIN(seq), 1 << 62) - 1 FROM messages WHERE at_ms > ?")
3060×
                    .bind(ms)
3070×
                    .fetch_one(ledger.pool())
3080×
                    .await?
309
            }
3100×
            _ => message(ledger, s).await?.map(|m| m.seq).with_context(|| format!("no message {s}"))?,
311
        },
312
    };
3137×
    let (ch, rows) = match thread {
3143×
        Some(t) => {
3153×
            let root = message(ledger, t).await?.with_context(|| format!("no message {t}"))?;
3163×
            let root_id = root.reply_to.clone().unwrap_or(root.id.clone());
3173×
            let ch = channel(ledger, &root.channel).await?.context("the thread's channel")?;
3183×
            let rows: Vec<Message> =
3193×
                sqlx::query_as(&format!("{SELECT_MESSAGE} WHERE (id = ? OR reply_to = ?) AND seq > ? ORDER BY seq LIMIT ?"))
3203×
                    .bind(&root_id)
3213×
                    .bind(&root_id)
3223×
                    .bind(since_seq)
3233×
                    .bind(limit)
3243×
                    .fetch_all(ledger.pool())
3253×
                    .await?;
3263×
            (ch, rows)
327
        }
328
        None => {
3294×
            let name = match (channel_name, who) {
3303×
                (Some(c), _) => c.to_string(),
3311×
                (None, Principal::Agent { project, .. }) => project.clone(),
3320×
                (None, _) => "general".into(),
333
            };
3344×
            let ch = channel(ledger, &name).await?.with_context(|| format!("no channel {name}"))?;
335
            // The newest `limit`, returned oldest first.
3364×
            let mut rows: Vec<Message> =
3374×
                sqlx::query_as(&format!("{SELECT_MESSAGE} WHERE channel = ? AND reply_to IS NULL AND seq > ? ORDER BY seq DESC LIMIT ?"))
3384×
                    .bind(&ch.id)
3394×
                    .bind(since_seq)
3404×
                    .bind(limit)
3414×
                    .fetch_all(ledger.pool())
3424×
                    .await?;
3434×
            rows.reverse();
3444×
            (ch, rows)
345
        }
346
    };
3477×
    if !can_read(who, &ch) {
3481×
        bail!("{} is a direct channel you are not in", ch.display());
3496×
    }
3506×
    Ok((ch, rows.into_iter().map(shown).collect()))
3517×
}
352
353
/// A redacted message shows only that it was.
35411×
fn shown(mut m: Message) -> Message {
35511×
    if let Some(why) = &m.redacted {
3561×
        m.body = format!("[redacted: {why}]");
3571×
        m.payload = None;
35810×
    }
35911×
    m
36011×
}
361
362
/// One item in a principal's inbox.
363
#[derive(Clone, Debug, Serialize)]
364
pub struct Item {
365
    pub id: String,
366
    pub kind: String,
367
    pub at_ms: i64,
368
    pub from: String,
369
    pub channel: String,
370
    pub summary: String,
371
    pub action_hint: String,
372
}
373
374
/// The unread items of `actor`, oldest first; `deliver` records that they
375
/// were handed over (`inbox.delivered`).
37631×
pub async fn inbox(ledger: &Ledger, actor: &str, deliver: Option<&Author<'_>>) -> Result<Vec<Item>> {
37731×
    let rows: Vec<InboxRow> = sqlx::query_as(
37831×
        "SELECT i.item AS id, i.kind, m.at_ms, m.author AS from_, c.name AS channel, m.body, m.redacted FROM inbox i JOIN messages m ON m.id = i.item JOIN channels c ON c.id = m.channel \
37931×
         WHERE i.principal = ? AND i.read = 0 ORDER BY i.seq",
38031×
    )
38131×
    .bind(actor)
38231×
    .fetch_all(ledger.pool())
38331×
    .await?;
38431×
    let items: Vec<Item> = rows
38531×
        .into_iter()
38631×
        .map(|InboxRow { id, kind, at_ms, from_: from, channel: ch, body, redacted }| {
38715×
            let channel = match ch.strip_prefix("dm:") {
3881×
                Some(p) => format!("@{p}"),
38914×
                None => format!("#{ch}"),
390
            };
39115×
            let summary = if redacted.is_some() { "[redacted]".into() } else { summarise(&body, 500) };
39215×
            Item {
39315×
                action_hint: format!("read(thread: \"{id}\") for context, then say(reply_to: \"{id}\", …)"),
39415×
                id,
39515×
                kind,
39615×
                at_ms,
39715×
                from,
39815×
                channel,
39915×
                summary,
40015×
            }
40115×
        })
40231×
        .collect();
40331×
    if let (Some(who), Some(last)) = (deliver, items.last()) {
4047×
        let undelivered: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM inbox WHERE principal = ? AND delivered = 0 AND read = 0")
4057×
            .bind(actor)
4067×
            .fetch_one(ledger.pool())
4077×
            .await?;
4087×
        if undelivered > 0 {
4097×
            ledger.append_one(who.event("inbox.delivered", json!({ "principal": actor, "upto": last.id }))).await?;
4100×
        }
41124×
    }
41231×
    Ok(items)
41331×
}
414
415
/// The number of unread items, for `_meta`.
416127×
pub async fn unread(ledger: &Ledger, actor: &str) -> Result<i64> {
417127×
    Ok(sqlx::query_scalar("SELECT COUNT(*) FROM inbox WHERE principal = ? AND read = 0").bind(actor).fetch_one(ledger.pool()).await?)
418127×
}
419
420
/// Mark items read, with a reaction on each if given.
4213×
pub async fn ack(ledger: &Ledger, who: &Author<'_>, ids: &[String], reaction: Option<&str>) -> Result<usize> {
4223×
    if let Some(r) = reaction {
4232×
        if !REACTIONS.contains(&r) {
4240×
            bail!("a reaction is one of {}", REACTIONS.join(", "));
4252×
        }
4261×
    }
4273×
    let actor = who.principal.actor();
4283×
    let mut events = Vec::new();
4293×
    let mut found = Vec::new();
4307×
    for id in ids {
4317×
        let m = message(ledger, id).await?.with_context(|| format!("no message {id}"))?;
4327×
        found.push(m.id.clone());
4337×
        if let Some(r) = reaction {
4342×
            events.push(who.event("message.reaction", json!({ "id": m.id, "reaction": r })).subject(m.id.clone()));
4355×
        }
436
    }
4373×
    events.push(who.event("inbox.read", json!({ "principal": actor, "ids": found })));
4383×
    let n = found.len();
4393×
    ledger.append(events).await?;
4403×
    Ok(n)
4413×
}
442
443
/// Redact a message: its author's, or anyone's by the owner.
4444×
pub async fn redact(ledger: &Ledger, who: &Author<'_>, id: &str, reason: &str) -> Result<()> {
4454×
    let m = message(ledger, id).await?.with_context(|| format!("no message {id}"))?;
4464×
    if m.author != who.principal.actor() && !matches!(who.principal, Principal::Owner { .. }) {
4472×
        bail!("only its author or the owner may redact a message");
4482×
    }
4492×
    if reason.trim().is_empty() {
4500×
        bail!("say why it is redacted");
4512×
    }
4522×
    ledger.append_one(who.event("message.redacted", json!({ "id": m.id, "reason": reason })).subject(m.id)).await?;
4532×
    Ok(())
4544×
}
455
456
#[derive(sqlx::FromRow)]
457
struct InboxRow {
458
    id: String,
459
    kind: String,
460
    at_ms: i64,
461
    from_: String,
462
    channel: String,
463
    body: String,
464
    redacted: Option<String>,
465
}
466
467
#[derive(sqlx::FromRow)]
468
struct HitRow {
469
    id: String,
470
    name: String,
471
    author: String,
472
    at_ms: i64,
473
    snippet: String,
474
    reply_to: Option<String>,
475
    kind: String,
476
    members: String,
477
}
478
479
/// A search hit (spec §13.1).
480
#[derive(Clone, Debug, Serialize)]
481
pub struct Hit {
482
    pub id: String,
483
    pub channel: String,
484
    pub author: String,
485
    pub at_ms: i64,
486
    pub snippet: String,
487
    pub thread: Option<String>,
488
}
489
490
/// Full-text search over messages the caller may read, newest first.
49111×
pub async fn search(
49211×
    ledger: &Ledger,
49311×
    who: &Principal,
49411×
    query: &str,
49511×
    channel_name: Option<&str>,
49611×
    author: Option<&str>,
49711×
    since_ms: Option<i64>,
49811×
    limit: u32,
49911×
) -> Result<Vec<Hit>> {
50011×
    if query.trim().is_empty() {
5010×
        bail!("search needs a query");
50211×
    }
50311×
    let ch = match channel_name {
5042×
        Some(c) => Some(channel(ledger, c).await?.with_context(|| format!("no channel {c}"))?.id),
5059×
        None => None,
506
    };
50711×
    let author = match author {
5081×
        Some(a) => Some(resolve_name(ledger, a).await?.unwrap_or_else(|| a.to_string())),
50910×
        None => None,
510
    };
51111×
    let rows: Vec<HitRow> = sqlx::query_as(
51211×
        "SELECT m.id, c.name, m.author, m.at_ms, snippet(messages_fts, 3, '[', ']', '…', 16) AS snippet, m.reply_to, c.kind, c.members \
51311×
         FROM messages_fts f JOIN messages m ON m.id = f.id JOIN channels c ON c.id = m.channel \
51411×
         WHERE messages_fts MATCH ? AND m.redacted IS NULL AND (? IS NULL OR m.channel = ?) AND (? IS NULL OR m.author = ?) AND (? IS NULL OR m.at_ms >= ?) \
51511×
         ORDER BY m.seq DESC LIMIT ?",
51611×
    )
51711×
    .bind(query)
51811×
    .bind(&ch)
51911×
    .bind(&ch)
52011×
    .bind(&author)
52111×
    .bind(&author)
52211×
    .bind(since_ms)
52311×
    .bind(since_ms)
52411×
    .bind(limit.clamp(1, 100) as i64)
52511×
    .fetch_all(ledger.pool())
52611×
    .await
52711×
    .map_err(|e| match e {
5281×
        sqlx::Error::Database(d) if d.message().contains("fts5") || d.message().contains("syntax") => {
5291×
            anyhow::anyhow!("the query does not parse: words, \"phrases\", prefix*, AND, OR, NOT ({})", d.message())
530
        }
5310×
        e => e.into(),
5321×
    })?;
53310×
    Ok(rows
53410×
        .into_iter()
53512×
        .filter(|r| {
53612×
            r.kind != "direct"
5372×
                || matches!(who, Principal::Owner { .. })
5381×
                || serde_json::from_str::<Vec<String>>(&r.members).unwrap_or_default().contains(&who.actor())
53912×
        })
54010×
        .map(|HitRow { id, name, author, at_ms, snippet, reply_to, .. }| Hit {
54111×
            channel: match name.strip_prefix("dm:") {
5421×
                Some(p) => format!("@{p}"),
54310×
                None => format!("#{name}"),
544
            },
54511×
            thread: reply_to.or(Some(id.clone())),
54611×
            id,
54711×
            author,
54811×
            at_ms,
54911×
            snippet,
55011×
        })
55110×
        .collect())
55211×
}
553
554
/// `@name` in a body, resolved to actors; `@here` is the channel's members
555
/// that are online, resolved now so the inbox stays a function of events.
55634×
async fn resolve_mentions(ledger: &Ledger, body: &str, ch: &Channel) -> Result<Vec<String>> {
55734×
    let mut out = Vec::new();
558802×
    for word in body.split(|c: char| c.is_whitespace() || ",.;:!?()[]".contains(c)) {
559155×
        let Some(name) = word.strip_prefix('@') else { continue };
56016×
        if name == "here" {
5610×
            let online: Vec<String> =
5620×
                sqlx::query_scalar("SELECT 'agent:' || agent FROM presence WHERE state IN ('online', 'busy', 'idle')")
5630×
                    .fetch_all(ledger.pool())
5640×
                    .await?;
5650×
            let members = ch.members();
5660×
            out.extend(online.into_iter().filter(|a| members.is_empty() || members.contains(a)));
56716×
        } else if name == "all" {
568
            // Every agent, wherever it works.
5691×
            let all: Vec<String> =
5701×
                sqlx::query_scalar("SELECT 'agent:' || name FROM agents WHERE disabled_ms IS NULL").fetch_all(ledger.pool()).await?;
5711×
            out.extend(all);
57215×
        } else if name == "channel" {
573
            // Every agent that belongs in this channel: a project's own agents
574
            // and those let in; a direct channel's members; everyone in a shared one.
5751×
            let enabled: Vec<(String, String, String)> =
5761×
                sqlx::query_as("SELECT name, project, scopes FROM agents WHERE disabled_ms IS NULL").fetch_all(ledger.pool()).await?;
5771×
            let members = ch.members();
5781×
            let granted = format!("channel:{}", ch.name);
5793×
            for (a, project, scopes) in enabled {
5803×
                let actor = format!("agent:{a}");
5813×
                let belongs = match ch.kind.as_str() {
5823×
                    "project" => ch.project.as_deref() == Some(project.as_str()) || scopes.contains(&format!("\"{granted}\"")),
5830×
                    "direct" => members.contains(&actor),
5840×
                    _ => true,
585
                };
5863×
                if belongs {
5873×
                    out.push(actor);
5883×
                }
589
            }
59014×
        } else if let Some(a) = resolve_name(ledger, name).await? {
59113×
            out.push(a);
59213×
        }
593
    }
59434×
    Ok(out)
59534×
}
596
597
/// `claude-oxygen`, `agent:claude-oxygen`, `human`, `owner`, or the owner's
598
/// first name, lower-cased, to an actor.
59917×
pub async fn resolve_name(ledger: &Ledger, name: &str) -> Result<Option<String>> {
60017×
    let n = name.trim_start_matches('@').to_lowercase();
60117×
    if n == "human" || n == "owner" {
6021×
        return Ok(Some("human".into()));
60316×
    }
60416×
    let n = n.strip_prefix("agent:").unwrap_or(&n).to_string();
60516×
    let agent: Option<String> = sqlx::query_scalar("SELECT name FROM agents WHERE name = ?").bind(&n).fetch_optional(ledger.pool()).await?;
60616×
    if let Some(a) = agent {
6076×
        return Ok(Some(format!("agent:{a}")));
60810×
    }
60910×
    let owner: Option<String> =
61010×
        sqlx::query_scalar("SELECT name FROM identities WHERE role = 'owner' AND current = 1").fetch_optional(ledger.pool()).await?;
61110×
    let first = owner.and_then(|o| o.split_whitespace().next().map(str::to_lowercase));
61210×
    if first.as_deref() == Some(n.as_str()) {
6137×
        return Ok(Some("human".into()));
6143×
    }
615
    // A part of one agent's name (`@kimi` for kimi-bubblebobble,
616
    // `@metalgear2` for claude-metalgear2) when exactly one agent has it.
6173×
    if n.is_empty() || n.contains('-') {
6180×
        return Ok(None);
6193×
    }
6203×
    let names: Vec<String> = sqlx::query_scalar("SELECT name FROM agents WHERE disabled_ms IS NULL").fetch_all(ledger.pool()).await?;
62115×
    let mut hits = names.into_iter().filter(|a| a.split('-').any(|part| part == n));
6223×
    Ok(match (hits.next(), hits.next()) {
6232×
        (Some(a), None) => Some(format!("agent:{a}")),
6241×
        _ => None,
625
    })
62617×
}
627
628
/// How a principal is shown to people: `claude-oxygen · Andy McCall's agent`,
629
/// the owner's name, or `Spectra`.
63015×
pub async fn display_name(ledger: &Ledger, actor: &str) -> String {
63115×
    if actor == "human" {
6322×
        let name: Option<String> = sqlx::query_scalar("SELECT name FROM identities WHERE role = 'owner' AND current = 1")
6332×
            .fetch_optional(ledger.pool())
6342×
            .await
6352×
            .ok()
6362×
            .flatten();
6372×
        return name.unwrap_or_else(|| "the owner".into());
63813×
    }
63913×
    if let Some(a) = actor.strip_prefix("agent:") {
64013×
        let owner: Option<String> =
64113×
            sqlx::query_scalar("SELECT owner_name FROM agents WHERE name = ?").bind(a).fetch_optional(ledger.pool()).await.ok().flatten();
64213×
        return match owner {
64313×
            Some(o) => format!("{a} · {o}'s agent"),
6440×
            None => a.to_string(),
645
        };
6460×
    }
6470×
    "Spectra".into()
64815×
}
649
650
/// `task:ID`, `decision:ID`, `oxy:change:HASH` and URLs in a body.
65135×
pub fn parse_refs(body: &str) -> Vec<String> {
652865×
    body.split(|c: char| c.is_whitespace() || "()[]<>,;\"'".contains(c))
653155×
        .map(|w| w.trim_end_matches(['.', ':', '!', '?']))
654155×
        .filter(|w| {
655155×
            w.starts_with("task:")
656154×
                || w.starts_with("decision:")
657154×
                || w.starts_with("oxy:change:")
658152×
                || w.starts_with("https://")
659151×
                || w.starts_with("http://")
660155×
        })
66135×
        .filter(|w| w.len() > 6)
66235×
        .map(String::from)
66335×
        .collect()
66435×
}
665
66617×
fn summarise(body: &str, max: usize) -> String {
66717×
    let one: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
66817×
    if one.chars().count() <= max {
66916×
        one
670
    } else {
6711×
        format!("{}…", one.chars().take(max - 1).collect::<String>())
672
    }
67317×
}
674
675
/// Apply conversation events to their tables.
67642882×
pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> {
67742882×
    let p = e.json();
67842882×
    let s = |k: &str| p.get(k).and_then(Json::as_str).map(String::from);
67942882×
    let list = |k: &str| p.get(k).cloned().filter(Json::is_array).unwrap_or(json!([]));
68042882×
    match e.kind.as_str() {
68142882×
        "channel.created" if s("id").is_some() && s("name").is_some() => {
682190×
            sqlx::query("INSERT OR IGNORE INTO channels (id, name, kind, project, purpose, members, seq) VALUES (?, ?, ?, ?, ?, ?, ?)")
683190×
                .bind(s("id"))
684190×
                .bind(s("name"))
685190×
                .bind(s("kind").unwrap_or_else(|| "shared".into()))
686190×
                .bind(s("project"))
687190×
                .bind(s("purpose"))
688190×
                .bind(list("members").to_string())
689190×
                .bind(e.seq as i64)
690190×
                .execute(&mut *conn)
691190×
                .await?;
692
        }
69342692×
        "message.posted" if s("id").is_some() && s("channel").is_some() && s("body").is_some() => {
69452×
            let id = s("id").unwrap_or_default();
69552×
            let ch = s("channel").unwrap_or_default();
69652×
            let body = s("body").unwrap_or_default();
69752×
            let inserted = sqlx::query(
69852×
                "INSERT OR IGNORE INTO messages (id, channel, reply_to, author, kind, body, payload, mentions, refs, at_ms, seq) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
699
            )
70052×
            .bind(&id)
70152×
            .bind(&ch)
70252×
            .bind(s("reply_to"))
70352×
            .bind(&e.actor)
70452×
            .bind(s("kind").unwrap_or_else(|| "chat".into()))
70552×
            .bind(&body)
70652×
            .bind(p.get("payload").filter(|v| !v.is_null()).map(|v| v.to_string()))
70752×
            .bind(list("mentions").to_string())
70852×
            .bind(list("refs").to_string())
70952×
            .bind(e.at_ms as i64)
71052×
            .bind(e.seq as i64)
71152×
            .execute(&mut *conn)
71252×
            .await?
71352×
            .rows_affected();
71452×
            if inserted == 0 {
7150×
                return Ok(());
71652×
            }
71752×
            if let Some(root) = s("reply_to") {
7186×
                sqlx::query("UPDATE messages SET replies = replies + 1 WHERE id = ?").bind(root).execute(&mut *conn).await?;
71946×
            }
72052×
            sqlx::query("INSERT INTO messages_fts (id, channel, author, body) VALUES (?, ?, ?, ?)")
72152×
                .bind(&id)
72252×
                .bind(&ch)
72352×
                .bind(&e.actor)
72452×
                .bind(&body)
72552×
                .execute(&mut *conn)
72652×
                .await?;
72752×
            let mut targets: Vec<(String, &str)> =
72852×
                list("mentions").as_array().into_iter().flatten().filter_map(|m| m.as_str().map(|a| (a.to_string(), "mention"))).collect();
72952×
            let members: Option<(String, String)> =
73052×
                sqlx::query_as("SELECT kind, members FROM channels WHERE id = ?").bind(&ch).fetch_optional(&mut *conn).await?;
73152×
            if let Some((kind, members)) = members {
73252×
                if kind == "direct" {
7338×
                    for m in serde_json::from_str::<Vec<String>>(&members).unwrap_or_default() {
7348×
                        targets.push((m, "dm"));
7358×
                    }
73648×
                }
7370×
            }
73852×
            for (who, kind) in targets {
73926×
                if who == e.actor {
7404×
                    continue;
74122×
                }
74222×
                sqlx::query("INSERT OR IGNORE INTO inbox (principal, item, kind, seq) VALUES (?, ?, ?, ?)")
74322×
                    .bind(who)
74422×
                    .bind(&id)
74522×
                    .bind(kind)
74622×
                    .bind(e.seq as i64)
74722×
                    .execute(&mut *conn)
74822×
                    .await?;
749
            }
750
        }
75139476×
        "message.redacted" if s("id").is_some() => {
7523×
            sqlx::query("UPDATE messages SET redacted = ? WHERE id = ?")
7533×
                .bind(s("reason").unwrap_or_else(|| "redacted".into()))
7543×
                .bind(s("id"))
7553×
                .execute(&mut *conn)
7563×
                .await?;
7573×
            sqlx::query("DELETE FROM messages_fts WHERE id = ?").bind(s("id")).execute(&mut *conn).await?;
758
        }
75939473×
        "message.reaction" if s("id").is_some() && s("reaction").is_some() => {
7603×
            sqlx::query("INSERT OR IGNORE INTO reactions (message, actor, reaction) VALUES (?, ?, ?)")
7613×
                .bind(s("id"))
7623×
                .bind(&e.actor)
7633×
                .bind(s("reaction"))
7643×
                .execute(&mut *conn)
7653×
                .await?;
766
        }
76739470×
        "inbox.delivered" if s("principal").is_some() => {
7688×
            let upto: Option<i64> = sqlx::query_scalar("SELECT seq FROM inbox WHERE principal = ? AND item = ?")
7698×
                .bind(s("principal"))
7708×
                .bind(s("upto"))
7718×
                .fetch_optional(&mut *conn)
7728×
                .await?;
7738×
            if let Some(seq) = upto {
7748×
                sqlx::query("UPDATE inbox SET delivered = 1 WHERE principal = ? AND seq <= ?")
7758×
                    .bind(s("principal"))
7768×
                    .bind(seq)
7778×
                    .execute(&mut *conn)
7788×
                    .await?;
7790×
            }
780
        }
78139462×
        "inbox.read" if s("principal").is_some() => {
7828×
            for id in list("ids").as_array().into_iter().flatten().filter_map(Json::as_str) {
7838×
                sqlx::query("UPDATE inbox SET read = 1, delivered = 1 WHERE principal = ? AND item = ?")
7848×
                    .bind(s("principal"))
7858×
                    .bind(id)
7868×
                    .execute(&mut *conn)
7878×
                    .await?;
788
            }
789
        }
79042622×
        _ => {}
791
    }
79242882×
    Ok(())
79342882×
}
794
795
#[cfg(test)]
796
mod tests {
797
    use super::*;
798
799
    #[test]
8001×
    fn refs_and_direct_names() {
8011×
        assert_eq!(
8021×
            parse_refs("see task:01ABC and oxy:change:abcdef12, https://oxygenhub.io/x."),
803
            ["task:01ABC", "oxy:change:abcdef12", "https://oxygenhub.io/x"]
804
        );
8051×
        assert_eq!(direct_name("agent:zed", "human"), "dm:human+zed");
8061×
        assert_eq!(direct_name("human", "agent:zed"), direct_name("agent:zed", "human"));
8071×
        assert_eq!(summarise("a\n  b", 10), "a b");
8081×
        assert_eq!(summarise("abcdefghijkl", 5), "abcd…");
8091×
    }
810
}