OXYGENHUB

Channels, threads, messages, the inbox and full-text search, shared by every interface (#6) 5eb57578f4ef

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 4aaf56bd074c
When
· 2026-11-02 22:12 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
5eb57578f4ef97e97917166b431298dad8dbff4df548186568da2530872f7b2e
Depends on
09752034b5a8 258bb3824813 2fc88a7b3a5c 36537112fcac
Depended on by
2d2dcb7a2778 fe6b29c66cd4 87f601ccce6d b044d90930d9 98a0fb9f0948 39f5c913f617 037c45d8093c 571572f5fb2c
In bookmarks
main
In tags
v0.1.0
Edits
15 atoms, 5 tree operations, 10 files

Browse the tree at this change

added crates/spectra-core/migrations/0004_conversation.sql +49 −0
1 +
-- Conversation (spec §10): channels, messages and threads, reactions, each
2 +
-- principal's inbox, and full-text search over messages (§13.1).
3 +
CREATE TABLE channels (
4 +
    id        TEXT PRIMARY KEY,
5 +
    name      TEXT NOT NULL UNIQUE,     -- general, humans, system, a project's name, or dm:A+B
6 +
    kind      TEXT NOT NULL,            -- project | shared | direct | system
7 +
    project   TEXT,
8 +
    purpose   TEXT,
9 +
    members   TEXT NOT NULL,            -- JSON array of actors; empty: everyone
10 +
    seq       INTEGER NOT NULL
11 +
);
12 +
13 +
CREATE TABLE messages (
14 +
    id              TEXT PRIMARY KEY,
15 +
    channel         TEXT NOT NULL,
16 +
    reply_to        TEXT,
17 +
    author          TEXT NOT NULL,      -- the event's actor
18 +
    kind            TEXT NOT NULL,
19 +
    body            TEXT NOT NULL,
20 +
    payload         TEXT,
21 +
    mentions        TEXT NOT NULL,      -- JSON array of actors
22 +
    refs            TEXT NOT NULL,      -- JSON array
23 +
    at_ms           INTEGER NOT NULL,
24 +
    seq             INTEGER NOT NULL,
25 +
    replies         INTEGER NOT NULL DEFAULT 0,
26 +
    redacted        TEXT                -- the reason, once redacted
27 +
);
28 +
CREATE INDEX messages_channel ON messages(channel, seq);
29 +
CREATE INDEX messages_thread ON messages(reply_to, seq);
30 +
31 +
CREATE TABLE reactions (
32 +
    message   TEXT NOT NULL,
33 +
    actor     TEXT NOT NULL,
34 +
    reaction  TEXT NOT NULL,
35 +
    PRIMARY KEY (message, actor, reaction)
36 +
);
37 +
38 +
-- What each principal has to look at: mentions and direct messages for now.
39 +
CREATE TABLE inbox (
40 +
    principal  TEXT NOT NULL,
41 +
    item       TEXT NOT NULL,           -- the message id
42 +
    kind       TEXT NOT NULL,           -- mention | dm
43 +
    seq        INTEGER NOT NULL,
44 +
    delivered  INTEGER NOT NULL DEFAULT 0,
45 +
    read       INTEGER NOT NULL DEFAULT 0,
46 +
    PRIMARY KEY (principal, item)
47 +
);
48 +
49 +
CREATE VIRTUAL TABLE messages_fts USING fts5(id UNINDEXED, channel UNINDEXED, author UNINDEXED, body, tokenize = 'porter unicode61');
added crates/spectra-core/src/conversation.rs +747 −0
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<'_> {
48 +
    fn event(&self, kind: &str, payload: Json) -> NewEvent {
49 +
        let actor = self.principal.actor();
50 +
        match &self.signer {
51 +
            Signer::Cli(pair) => signed_event(pair, &actor, kind, payload),
52 +
            Signer::Request(auth) => NewEvent::system(kind, payload).by(actor, auth.clone()),
53 +
            Signer::System => NewEvent::system(kind, payload),
54 +
        }
55 +
    }
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 {
69 +
    pub fn members(&self) -> Vec<String> {
70 +
        serde_json::from_str(&self.members).unwrap_or_default()
71 +
    }
72 +
    /// `#general`, `#oxygen`, or `@a+b` for a direct channel.
73 +
    pub fn display(&self) -> String {
74 +
        match self.name.strip_prefix("dm:") {
75 +
            Some(pair) => format!("@{pair}"),
76 +
            None => format!("#{}", self.name),
77 +
        }
78 +
    }
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`).
118 +
pub fn default_channel_events() -> Vec<NewEvent> {
119 +
    DEFAULT_CHANNELS.iter().map(|(name, kind, purpose)| channel_event(name, kind, None, Some(purpose), &[])).collect()
120 +
}
121 +
122 +
/// A project's channel, made when the project is added.
123 +
pub fn project_channel_event(project: &str) -> NewEvent {
124 +
    channel_event(project, "project", Some(project), Some(&format!("The {project} project")), &[])
125 +
}
126 +
127 +
fn channel_event(name: &str, kind: &str, project: Option<&str>, purpose: Option<&str>, members: &[String]) -> NewEvent {
128 +
    let id = crate::ids::ulid();
129 +
    NewEvent::system(
130 +
        "channel.created",
131 +
        json!({ "id": id, "name": name, "kind": kind, "project": project, "purpose": purpose, "members": members }),
132 +
    )
133 +
    .subject(id)
134 +
}
135 +
136 +
/// The name of the direct channel between two actors.
137 +
pub fn direct_name(a: &str, b: &str) -> String {
138 +
    let short = |x: &str| x.strip_prefix("agent:").unwrap_or(x).to_string();
139 +
    let mut v = [short(a), short(b)];
140 +
    v.sort();
141 +
    format!("dm:{}+{}", v[0], v[1])
142 +
}
143 +
144 +
/// Find a channel by `#name`, `name`, `@a+b` or id.
145 +
pub async fn channel(ledger: &Ledger, name: &str) -> Result<Option<Channel>> {
146 +
    let n = name.strip_prefix('#').unwrap_or(name);
147 +
    let n = match n.strip_prefix('@') {
148 +
        Some(pair) => format!("dm:{pair}"),
149 +
        None => n.to_string(),
150 +
    };
151 +
    Ok(sqlx::query_as("SELECT id, name, kind, project, purpose, members FROM channels WHERE name = ? OR id = ?")
152 +
        .bind(&n)
153 +
        .bind(&n)
154 +
        .fetch_optional(ledger.pool())
155 +
        .await?)
156 +
}
157 +
158 +
pub async fn channels(ledger: &Ledger) -> Result<Vec<Channel>> {
159 +
    Ok(sqlx::query_as("SELECT id, name, kind, project, purpose, members FROM channels ORDER BY kind = 'direct', name")
160 +
        .fetch_all(ledger.pool())
161 +
        .await?)
162 +
}
163 +
164 +
/// Whether `who` may read `ch`: direct channels are for their members.
165 +
pub fn can_read(who: &Principal, ch: &Channel) -> bool {
166 +
    ch.kind != "direct" || matches!(who, Principal::Owner { .. }) || ch.members().contains(&who.actor())
167 +
}
168 +
169 +
/// Whether `who` may post in `ch` (spec §10.1, §8.4).
170 +
pub fn can_post(who: &Principal, ch: &Channel) -> Result<(), String> {
171 +
    match (who, ch.kind.as_str()) {
172 +
        (Principal::Instance { .. }, _) => Ok(()),
173 +
        (_, "system") => Err("#system is Spectra's: guard rails, schedules and hooks post there".into()),
174 +
        (Principal::Owner { .. }, _) => Ok(()),
175 +
        (_, "direct") if !ch.members().contains(&who.actor()) => Err(format!("{} is a direct channel you are not in", ch.display())),
176 +
        (Principal::Agent { project, scopes, .. }, "project") => {
177 +
            if ch.project.as_deref() == Some(project) || scopes.iter().any(|s| s == "projects:all") {
178 +
                Ok(())
179 +
            } else {
180 +
                Err(format!("{} belongs to another project; post in #{project}, #general, or ask for projects:all", ch.display()))
181 +
            }
182 +
        }
183 +
        _ => Ok(()),
184 +
    }
185 +
}
186 +
187 +
/// Post a message, checking everything first (spec §10.3).
188 +
pub async fn post(ledger: &Ledger, who: &Author<'_>, mut p: Post, now_ms: u64) -> Result<Posted> {
189 +
    if p.body.trim().is_empty() {
190 +
        bail!("a message needs a body");
191 +
    }
192 +
    if p.body.len() > MAX_BODY {
193 +
        bail!("a message's body is at most 64 KiB; this is {} KiB", p.body.len() / 1024);
194 +
    }
195 +
    if p.payload.as_ref().is_some_and(|v| v.to_string().len() > MAX_BODY) {
196 +
        bail!("a message's payload is at most 64 KiB");
197 +
    }
198 +
    let kind = p.kind.take().unwrap_or_else(|| "chat".into());
199 +
    if !KINDS.contains(&kind.as_str()) {
200 +
        bail!("a message's kind is one of {}", KINDS.join(", "));
201 +
    }
202 +
    let ch = match (&p.channel, &p.reply_to, &who.principal) {
203 +
        (Some(c), _, _) => channel(ledger, c).await?.with_context(|| format!("no channel {c}"))?,
204 +
        (None, Some(r), _) => {
205 +
            let root = message(ledger, r).await?.with_context(|| format!("no message {r}"))?;
206 +
            channel(ledger, &root.channel).await?.context("the thread's channel")?
207 +
        }
208 +
        (None, None, Principal::Agent { project, .. }) => {
209 +
            channel(ledger, project).await?.with_context(|| format!("no channel for project {project}"))?
210 +
        }
211 +
        (None, None, _) => channel(ledger, "general").await?.context("no #general (spectra init makes it)")?,
212 +
    };
213 +
    can_post(&who.principal, &ch).map_err(anyhow::Error::msg)?;
214 +
    let reply_to = match &p.reply_to {
215 +
        Some(r) => {
216 +
            let m = message(ledger, r).await?.with_context(|| format!("no message {r}"))?;
217 +
            if m.channel != ch.id {
218 +
                bail!("{r} is in another channel");
219 +
            }
220 +
            // A reply to a reply joins the root's thread.
221 +
            Some(m.reply_to.unwrap_or(m.id))
222 +
        }
223 +
        None => None,
224 +
    };
225 +
    let author = who.principal.actor();
226 +
    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")
227 +
        .bind(&ch.id)
228 +
        .bind(&author)
229 +
        .bind(&p.body)
230 +
        .bind(now_ms.saturating_sub(DUPLICATE_MS) as i64)
231 +
        .fetch_optional(ledger.pool())
232 +
        .await?;
233 +
    if let Some(id) = dup {
234 +
        return Ok(Posted::Duplicate { id });
235 +
    }
236 +
    let mut mentions = resolve_mentions(ledger, &p.body, &ch).await?;
237 +
    for m in p.mentions.drain(..) {
238 +
        if let Some(a) = resolve_name(ledger, &m).await? {
239 +
            mentions.push(a);
240 +
        }
241 +
    }
242 +
    mentions.retain(|m| m != &author);
243 +
    mentions.sort();
244 +
    mentions.dedup();
245 +
    let mut refs = parse_refs(&p.body);
246 +
    refs.append(&mut p.refs);
247 +
    refs.sort();
248 +
    refs.dedup();
249 +
    let id = crate::ids::ulid();
250 +
    let payload = json!({
251 +
        "id": id, "channel": ch.id, "reply_to": reply_to, "kind": kind, "body": p.body,
252 +
        "payload": p.payload, "mentions": mentions, "refs": refs,
253 +
    });
254 +
    let mut e = who.event("message.posted", payload).subject(id.clone());
255 +
    e.project = ch.project.clone();
256 +
    ledger.append_one(e).await?;
257 +
    Ok(Posted::Message { id, channel: ch.display() })
258 +
}
259 +
260 +
/// Make the direct channel between `who` and `other` when there is none.
261 +
pub async fn ensure_direct(ledger: &Ledger, who: &Principal, other: &str) -> Result<Channel> {
262 +
    let other = resolve_name(ledger, other).await?.with_context(|| format!("nobody is called {other}"))?;
263 +
    let name = direct_name(&who.actor(), &other);
264 +
    if let Some(c) = channel(ledger, &name).await? {
265 +
        return Ok(c);
266 +
    }
267 +
    let mut members = vec![who.actor(), other];
268 +
    members.sort();
269 +
    ledger.append_one(channel_event(&name, "direct", None, None, &members)).await?;
270 +
    channel(ledger, &name).await?.context("the direct channel")
271 +
}
272 +
273 +
pub async fn message(ledger: &Ledger, id: &str) -> Result<Option<Message>> {
274 +
    Ok(sqlx::query_as(&format!("{SELECT_MESSAGE} WHERE id = ? OR (length(?) >= 6 AND id LIKE ? || '%') ORDER BY seq LIMIT 1"))
275 +
        .bind(id)
276 +
        .bind(id)
277 +
        .bind(id)
278 +
        .fetch_optional(ledger.pool())
279 +
        .await?)
280 +
}
281 +
282 +
const SELECT_MESSAGE: &str =
283 +
    "SELECT id, channel, reply_to, author, kind, body, payload, mentions, refs, at_ms, seq, replies, redacted FROM messages";
284 +
285 +
/// A channel's top-level messages (each with its reply count), or a whole
286 +
/// thread, oldest first; `since` is a message id or a time in ms.
287 +
pub async fn read(
288 +
    ledger: &Ledger,
289 +
    who: &Principal,
290 +
    channel_name: Option<&str>,
291 +
    thread: Option<&str>,
292 +
    since: Option<&str>,
293 +
    limit: u32,
294 +
) -> Result<(Channel, Vec<Message>)> {
295 +
    let limit = limit.clamp(1, 500) as i64;
296 +
    let since_seq = match since {
297 +
        None => 0,
298 +
        Some(s) => match s.parse::<i64>() {
299 +
            Ok(ms) if ms > 1_000_000_000_000 => {
300 +
                sqlx::query_scalar("SELECT COALESCE(MIN(seq), 1 << 62) - 1 FROM messages WHERE at_ms > ?")
301 +
                    .bind(ms)
302 +
                    .fetch_one(ledger.pool())
303 +
                    .await?
304 +
            }
305 +
            _ => message(ledger, s).await?.map(|m| m.seq).with_context(|| format!("no message {s}"))?,
306 +
        },
307 +
    };
308 +
    let (ch, rows) = match thread {
309 +
        Some(t) => {
310 +
            let root = message(ledger, t).await?.with_context(|| format!("no message {t}"))?;
311 +
            let root_id = root.reply_to.clone().unwrap_or(root.id.clone());
312 +
            let ch = channel(ledger, &root.channel).await?.context("the thread's channel")?;
313 +
            let rows: Vec<Message> =
314 +
                sqlx::query_as(&format!("{SELECT_MESSAGE} WHERE (id = ? OR reply_to = ?) AND seq > ? ORDER BY seq LIMIT ?"))
315 +
                    .bind(&root_id)
316 +
                    .bind(&root_id)
317 +
                    .bind(since_seq)
318 +
                    .bind(limit)
319 +
                    .fetch_all(ledger.pool())
320 +
                    .await?;
321 +
            (ch, rows)
322 +
        }
323 +
        None => {
324 +
            let name = match (channel_name, who) {
325 +
                (Some(c), _) => c.to_string(),
326 +
                (None, Principal::Agent { project, .. }) => project.clone(),
327 +
                (None, _) => "general".into(),
328 +
            };
329 +
            let ch = channel(ledger, &name).await?.with_context(|| format!("no channel {name}"))?;
330 +
            // The newest `limit`, returned oldest first.
331 +
            let mut rows: Vec<Message> =
332 +
                sqlx::query_as(&format!("{SELECT_MESSAGE} WHERE channel = ? AND reply_to IS NULL AND seq > ? ORDER BY seq DESC LIMIT ?"))
333 +
                    .bind(&ch.id)
334 +
                    .bind(since_seq)
335 +
                    .bind(limit)
336 +
                    .fetch_all(ledger.pool())
337 +
                    .await?;
338 +
            rows.reverse();
339 +
            (ch, rows)
340 +
        }
341 +
    };
342 +
    if !can_read(who, &ch) {
343 +
        bail!("{} is a direct channel you are not in", ch.display());
344 +
    }
345 +
    Ok((ch, rows.into_iter().map(shown).collect()))
346 +
}
347 +
348 +
/// A redacted message shows only that it was.
349 +
fn shown(mut m: Message) -> Message {
350 +
    if let Some(why) = &m.redacted {
351 +
        m.body = format!("[redacted: {why}]");
352 +
        m.payload = None;
353 +
    }
354 +
    m
355 +
}
356 +
357 +
/// One item in a principal's inbox.
358 +
#[derive(Clone, Debug, Serialize)]
359 +
pub struct Item {
360 +
    pub id: String,
361 +
    pub kind: String,
362 +
    pub at_ms: i64,
363 +
    pub from: String,
364 +
    pub channel: String,
365 +
    pub summary: String,
366 +
    pub action_hint: String,
367 +
}
368 +
369 +
/// The unread items of `actor`, oldest first; `deliver` records that they
370 +
/// were handed over (`inbox.delivered`).
371 +
pub async fn inbox(ledger: &Ledger, actor: &str, deliver: Option<&Author<'_>>) -> Result<Vec<Item>> {
372 +
    let rows: Vec<InboxRow> = sqlx::query_as(
373 +
        "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 \
374 +
         WHERE i.principal = ? AND i.read = 0 ORDER BY i.seq",
375 +
    )
376 +
    .bind(actor)
377 +
    .fetch_all(ledger.pool())
378 +
    .await?;
379 +
    let items: Vec<Item> = rows
380 +
        .into_iter()
381 +
        .map(|InboxRow { id, kind, at_ms, from_: from, channel: ch, body, redacted }| {
382 +
            let channel = match ch.strip_prefix("dm:") {
383 +
                Some(p) => format!("@{p}"),
384 +
                None => format!("#{ch}"),
385 +
            };
386 +
            let summary = if redacted.is_some() { "[redacted]".into() } else { summarise(&body, 500) };
387 +
            Item {
388 +
                action_hint: format!("read(thread: \"{id}\") for context, then say(reply_to: \"{id}\", …)"),
389 +
                id,
390 +
                kind,
391 +
                at_ms,
392 +
                from,
393 +
                channel,
394 +
                summary,
395 +
            }
396 +
        })
397 +
        .collect();
398 +
    if let (Some(who), Some(last)) = (deliver, items.last()) {
399 +
        let undelivered: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM inbox WHERE principal = ? AND delivered = 0 AND read = 0")
400 +
            .bind(actor)
401 +
            .fetch_one(ledger.pool())
402 +
            .await?;
403 +
        if undelivered > 0 {
404 +
            ledger.append_one(who.event("inbox.delivered", json!({ "principal": actor, "upto": last.id }))).await?;
405 +
        }
406 +
    }
407 +
    Ok(items)
408 +
}
409 +
410 +
/// The number of unread items, for `_meta`.
411 +
pub async fn unread(ledger: &Ledger, actor: &str) -> Result<i64> {
412 +
    Ok(sqlx::query_scalar("SELECT COUNT(*) FROM inbox WHERE principal = ? AND read = 0").bind(actor).fetch_one(ledger.pool()).await?)
413 +
}
414 +
415 +
/// Mark items read, with a reaction on each if given.
416 +
pub async fn ack(ledger: &Ledger, who: &Author<'_>, ids: &[String], reaction: Option<&str>) -> Result<usize> {
417 +
    if let Some(r) = reaction {
418 +
        if !REACTIONS.contains(&r) {
419 +
            bail!("a reaction is one of {}", REACTIONS.join(", "));
420 +
        }
421 +
    }
422 +
    let actor = who.principal.actor();
423 +
    let mut events = Vec::new();
424 +
    let mut found = Vec::new();
425 +
    for id in ids {
426 +
        let m = message(ledger, id).await?.with_context(|| format!("no message {id}"))?;
427 +
        found.push(m.id.clone());
428 +
        if let Some(r) = reaction {
429 +
            events.push(who.event("message.reaction", json!({ "id": m.id, "reaction": r })).subject(m.id.clone()));
430 +
        }
431 +
    }
432 +
    events.push(who.event("inbox.read", json!({ "principal": actor, "ids": found })));
433 +
    let n = found.len();
434 +
    ledger.append(events).await?;
435 +
    Ok(n)
436 +
}
437 +
438 +
/// Redact a message: its author's, or anyone's by the owner.
439 +
pub async fn redact(ledger: &Ledger, who: &Author<'_>, id: &str, reason: &str) -> Result<()> {
440 +
    let m = message(ledger, id).await?.with_context(|| format!("no message {id}"))?;
441 +
    if m.author != who.principal.actor() && !matches!(who.principal, Principal::Owner { .. }) {
442 +
        bail!("only its author or the owner may redact a message");
443 +
    }
444 +
    if reason.trim().is_empty() {
445 +
        bail!("say why it is redacted");
446 +
    }
447 +
    ledger.append_one(who.event("message.redacted", json!({ "id": m.id, "reason": reason })).subject(m.id)).await?;
448 +
    Ok(())
449 +
}
450 +
451 +
#[derive(sqlx::FromRow)]
452 +
struct InboxRow {
453 +
    id: String,
454 +
    kind: String,
455 +
    at_ms: i64,
456 +
    from_: String,
457 +
    channel: String,
458 +
    body: String,
459 +
    redacted: Option<String>,
460 +
}
461 +
462 +
#[derive(sqlx::FromRow)]
463 +
struct HitRow {
464 +
    id: String,
465 +
    name: String,
466 +
    author: String,
467 +
    at_ms: i64,
468 +
    snippet: String,
469 +
    reply_to: Option<String>,
470 +
    kind: String,
471 +
    members: String,
472 +
}
473 +
474 +
/// A search hit (spec §13.1).
475 +
#[derive(Clone, Debug, Serialize)]
476 +
pub struct Hit {
477 +
    pub id: String,
478 +
    pub channel: String,
479 +
    pub author: String,
480 +
    pub at_ms: i64,
481 +
    pub snippet: String,
482 +
    pub thread: Option<String>,
483 +
}
484 +
485 +
/// Full-text search over messages the caller may read, newest first.
486 +
pub async fn search(
487 +
    ledger: &Ledger,
488 +
    who: &Principal,
489 +
    query: &str,
490 +
    channel_name: Option<&str>,
491 +
    author: Option<&str>,
492 +
    since_ms: Option<i64>,
493 +
    limit: u32,
494 +
) -> Result<Vec<Hit>> {
495 +
    if query.trim().is_empty() {
496 +
        bail!("search needs a query");
497 +
    }
498 +
    let ch = match channel_name {
499 +
        Some(c) => Some(channel(ledger, c).await?.with_context(|| format!("no channel {c}"))?.id),
500 +
        None => None,
501 +
    };
502 +
    let author = match author {
503 +
        Some(a) => Some(resolve_name(ledger, a).await?.unwrap_or_else(|| a.to_string())),
504 +
        None => None,
505 +
    };
506 +
    let rows: Vec<HitRow> = sqlx::query_as(
507 +
        "SELECT m.id, c.name, m.author, m.at_ms, snippet(messages_fts, 3, '[', ']', '…', 16) AS snippet, m.reply_to, c.kind, c.members \
508 +
         FROM messages_fts f JOIN messages m ON m.id = f.id JOIN channels c ON c.id = m.channel \
509 +
         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 >= ?) \
510 +
         ORDER BY m.seq DESC LIMIT ?",
511 +
    )
512 +
    .bind(query)
513 +
    .bind(&ch)
514 +
    .bind(&ch)
515 +
    .bind(&author)
516 +
    .bind(&author)
517 +
    .bind(since_ms)
518 +
    .bind(since_ms)
519 +
    .bind(limit.clamp(1, 100) as i64)
520 +
    .fetch_all(ledger.pool())
521 +
    .await
522 +
    .map_err(|e| match e {
523 +
        sqlx::Error::Database(d) if d.message().contains("fts5") || d.message().contains("syntax") => {
524 +
            anyhow::anyhow!("the query does not parse: words, \"phrases\", prefix*, AND, OR, NOT ({})", d.message())
525 +
        }
526 +
        e => e.into(),
527 +
    })?;
528 +
    Ok(rows
529 +
        .into_iter()
530 +
        .filter(|r| {
531 +
            r.kind != "direct"
532 +
                || matches!(who, Principal::Owner { .. })
533 +
                || serde_json::from_str::<Vec<String>>(&r.members).unwrap_or_default().contains(&who.actor())
534 +
        })
535 +
        .map(|HitRow { id, name, author, at_ms, snippet, reply_to, .. }| Hit {
536 +
            channel: match name.strip_prefix("dm:") {
537 +
                Some(p) => format!("@{p}"),
538 +
                None => format!("#{name}"),
539 +
            },
540 +
            thread: reply_to.or(Some(id.clone())),
541 +
            id,
542 +
            author,
543 +
            at_ms,
544 +
            snippet,
545 +
        })
546 +
        .collect())
547 +
}
548 +
549 +
/// `@name` in a body, resolved to actors; `@here` is the channel's members
550 +
/// that are online, resolved now so the inbox stays a function of events.
551 +
async fn resolve_mentions(ledger: &Ledger, body: &str, ch: &Channel) -> Result<Vec<String>> {
552 +
    let mut out = Vec::new();
553 +
    for word in body.split(|c: char| c.is_whitespace() || ",.;:!?()[]".contains(c)) {
554 +
        let Some(name) = word.strip_prefix('@') else { continue };
555 +
        if name == "here" {
556 +
            let online: Vec<String> =
557 +
                sqlx::query_scalar("SELECT 'agent:' || agent FROM presence WHERE state IN ('online', 'busy', 'idle')")
558 +
                    .fetch_all(ledger.pool())
559 +
                    .await?;
560 +
            let members = ch.members();
561 +
            out.extend(online.into_iter().filter(|a| members.is_empty() || members.contains(a)));
562 +
        } else if let Some(a) = resolve_name(ledger, name).await? {
563 +
            out.push(a);
564 +
        }
565 +
    }
566 +
    Ok(out)
567 +
}
568 +
569 +
/// `claude-oxygen`, `agent:claude-oxygen`, `human`, `owner`, or the owner's
570 +
/// first name, lower-cased, to an actor.
571 +
pub async fn resolve_name(ledger: &Ledger, name: &str) -> Result<Option<String>> {
572 +
    let n = name.trim_start_matches('@').to_lowercase();
573 +
    if n == "human" || n == "owner" {
574 +
        return Ok(Some("human".into()));
575 +
    }
576 +
    let n = n.strip_prefix("agent:").unwrap_or(&n).to_string();
577 +
    let agent: Option<String> = sqlx::query_scalar("SELECT name FROM agents WHERE name = ?").bind(&n).fetch_optional(ledger.pool()).await?;
578 +
    if let Some(a) = agent {
579 +
        return Ok(Some(format!("agent:{a}")));
580 +
    }
581 +
    let owner: Option<String> =
582 +
        sqlx::query_scalar("SELECT name FROM identities WHERE role = 'owner' AND current = 1").fetch_optional(ledger.pool()).await?;
583 +
    let first = owner.and_then(|o| o.split_whitespace().next().map(str::to_lowercase));
584 +
    Ok((first.as_deref() == Some(n.as_str())).then(|| "human".into()))
585 +
}
586 +
587 +
/// `task:ID`, `decision:ID`, `oxy:change:HASH` and URLs in a body.
588 +
pub fn parse_refs(body: &str) -> Vec<String> {
589 +
    body.split(|c: char| c.is_whitespace() || "()[]<>,;\"'".contains(c))
590 +
        .map(|w| w.trim_end_matches(['.', ':', '!', '?']))
591 +
        .filter(|w| {
592 +
            w.starts_with("task:")
593 +
                || w.starts_with("decision:")
594 +
                || w.starts_with("oxy:change:")
595 +
                || w.starts_with("https://")
596 +
                || w.starts_with("http://")
597 +
        })
598 +
        .filter(|w| w.len() > 6)
599 +
        .map(String::from)
600 +
        .collect()
601 +
}
602 +
603 +
fn summarise(body: &str, max: usize) -> String {
604 +
    let one: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
605 +
    if one.chars().count() <= max {
606 +
        one
607 +
    } else {
608 +
        format!("{}…", one.chars().take(max - 1).collect::<String>())
609 +
    }
610 +
}
611 +
612 +
/// Apply conversation events to their tables.
613 +
pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> {
614 +
    let p = e.json();
615 +
    let s = |k: &str| p.get(k).and_then(Json::as_str).map(String::from);
616 +
    let list = |k: &str| p.get(k).cloned().filter(Json::is_array).unwrap_or(json!([]));
617 +
    match e.kind.as_str() {
618 +
        "channel.created" if s("id").is_some() && s("name").is_some() => {
619 +
            sqlx::query("INSERT OR IGNORE INTO channels (id, name, kind, project, purpose, members, seq) VALUES (?, ?, ?, ?, ?, ?, ?)")
620 +
                .bind(s("id"))
621 +
                .bind(s("name"))
622 +
                .bind(s("kind").unwrap_or_else(|| "shared".into()))
623 +
                .bind(s("project"))
624 +
                .bind(s("purpose"))
625 +
                .bind(list("members").to_string())
626 +
                .bind(e.seq as i64)
627 +
                .execute(&mut *conn)
628 +
                .await?;
629 +
        }
630 +
        "message.posted" if s("id").is_some() && s("channel").is_some() && s("body").is_some() => {
631 +
            let id = s("id").unwrap_or_default();
632 +
            let ch = s("channel").unwrap_or_default();
633 +
            let body = s("body").unwrap_or_default();
634 +
            let inserted = sqlx::query(
635 +
                "INSERT OR IGNORE INTO messages (id, channel, reply_to, author, kind, body, payload, mentions, refs, at_ms, seq) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
636 +
            )
637 +
            .bind(&id)
638 +
            .bind(&ch)
639 +
            .bind(s("reply_to"))
640 +
            .bind(&e.actor)
641 +
            .bind(s("kind").unwrap_or_else(|| "chat".into()))
642 +
            .bind(&body)
643 +
            .bind(p.get("payload").filter(|v| !v.is_null()).map(|v| v.to_string()))
644 +
            .bind(list("mentions").to_string())
645 +
            .bind(list("refs").to_string())
646 +
            .bind(e.at_ms as i64)
647 +
            .bind(e.seq as i64)
648 +
            .execute(&mut *conn)
649 +
            .await?
650 +
            .rows_affected();
651 +
            if inserted == 0 {
652 +
                return Ok(());
653 +
            }
654 +
            if let Some(root) = s("reply_to") {
655 +
                sqlx::query("UPDATE messages SET replies = replies + 1 WHERE id = ?").bind(root).execute(&mut *conn).await?;
656 +
            }
657 +
            sqlx::query("INSERT INTO messages_fts (id, channel, author, body) VALUES (?, ?, ?, ?)")
658 +
                .bind(&id)
659 +
                .bind(&ch)
660 +
                .bind(&e.actor)
661 +
                .bind(&body)
662 +
                .execute(&mut *conn)
663 +
                .await?;
664 +
            let mut targets: Vec<(String, &str)> =
665 +
                list("mentions").as_array().into_iter().flatten().filter_map(|m| m.as_str().map(|a| (a.to_string(), "mention"))).collect();
666 +
            let members: Option<(String, String)> =
667 +
                sqlx::query_as("SELECT kind, members FROM channels WHERE id = ?").bind(&ch).fetch_optional(&mut *conn).await?;
668 +
            if let Some((kind, members)) = members {
669 +
                if kind == "direct" {
670 +
                    for m in serde_json::from_str::<Vec<String>>(&members).unwrap_or_default() {
671 +
                        targets.push((m, "dm"));
672 +
                    }
673 +
                }
674 +
            }
675 +
            for (who, kind) in targets {
676 +
                if who == e.actor {
677 +
                    continue;
678 +
                }
679 +
                sqlx::query("INSERT OR IGNORE INTO inbox (principal, item, kind, seq) VALUES (?, ?, ?, ?)")
680 +
                    .bind(who)
681 +
                    .bind(&id)
682 +
                    .bind(kind)
683 +
                    .bind(e.seq as i64)
684 +
                    .execute(&mut *conn)
685 +
                    .await?;
686 +
            }
687 +
        }
688 +
        "message.redacted" if s("id").is_some() => {
689 +
            sqlx::query("UPDATE messages SET redacted = ? WHERE id = ?")
690 +
                .bind(s("reason").unwrap_or_else(|| "redacted".into()))
691 +
                .bind(s("id"))
692 +
                .execute(&mut *conn)
693 +
                .await?;
694 +
            sqlx::query("DELETE FROM messages_fts WHERE id = ?").bind(s("id")).execute(&mut *conn).await?;
695 +
        }
696 +
        "message.reaction" if s("id").is_some() && s("reaction").is_some() => {
697 +
            sqlx::query("INSERT OR IGNORE INTO reactions (message, actor, reaction) VALUES (?, ?, ?)")
698 +
                .bind(s("id"))
699 +
                .bind(&e.actor)
700 +
                .bind(s("reaction"))
701 +
                .execute(&mut *conn)
702 +
                .await?;
703 +
        }
704 +
        "inbox.delivered" if s("principal").is_some() => {
705 +
            let upto: Option<i64> = sqlx::query_scalar("SELECT seq FROM inbox WHERE principal = ? AND item = ?")
706 +
                .bind(s("principal"))
707 +
                .bind(s("upto"))
708 +
                .fetch_optional(&mut *conn)
709 +
                .await?;
710 +
            if let Some(seq) = upto {
711 +
                sqlx::query("UPDATE inbox SET delivered = 1 WHERE principal = ? AND seq <= ?")
712 +
                    .bind(s("principal"))
713 +
                    .bind(seq)
714 +
                    .execute(&mut *conn)
715 +
                    .await?;
716 +
            }
717 +
        }
718 +
        "inbox.read" if s("principal").is_some() => {
719 +
            for id in list("ids").as_array().into_iter().flatten().filter_map(Json::as_str) {
720 +
                sqlx::query("UPDATE inbox SET read = 1, delivered = 1 WHERE principal = ? AND item = ?")
721 +
                    .bind(s("principal"))
722 +
                    .bind(id)
723 +
                    .execute(&mut *conn)
724 +
                    .await?;
725 +
            }
726 +
        }
727 +
        _ => {}
728 +
    }
729 +
    Ok(())
730 +
}
731 +
732 +
#[cfg(test)]
733 +
mod tests {
734 +
    use super::*;
735 +
736 +
    #[test]
737 +
    fn refs_and_direct_names() {
738 +
        assert_eq!(
739 +
            parse_refs("see task:01ABC and oxy:change:abcdef12, https://oxygenhub.io/x."),
740 +
            ["task:01ABC", "oxy:change:abcdef12", "https://oxygenhub.io/x"]
741 +
        );
742 +
        assert_eq!(direct_name("agent:zed", "human"), "dm:human+zed");
743 +
        assert_eq!(direct_name("human", "agent:zed"), direct_name("agent:zed", "human"));
744 +
        assert_eq!(summarise("a\n  b", 10), "a b");
745 +
        assert_eq!(summarise("abcdefghijkl", 5), "abcd…");
746 +
    }
747 +
}
added crates/spectra-core/tests/common/mod.rs +90 −0
1 +
//! A world for scenario tests: an owner, the instance, the default channels,
2 +
//! two projects with their channels, and agents the owner delegated to, all
3 +
//! recorded as `spectra init`, `project add` and `agent add` record them.
4 +
#![allow(dead_code)]
5 +
6 +
use oxy_core::durable::{write_atomic, write_atomic_private};
7 +
use oxy_core::identity::{Delegate, SecretKey};
8 +
use spectra_core::conversation::{default_channel_events, project_channel_event, Author, Signer};
9 +
use spectra_core::ids::now_ms;
10 +
use spectra_core::keys::KeyPair;
11 +
use spectra_core::ledger::Ledger;
12 +
use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DELEGATION_SCOPE};
13 +
use std::path::PathBuf;
14 +
15 +
pub struct World {
16 +
    pub dir: tempfile::TempDir,
17 +
    pub owner_home: PathBuf,
18 +
    pub ledger: Ledger,
19 +
    pub owner: KeyPair,
20 +
}
21 +
22 +
impl World {
23 +
    pub async fn new(projects: &[&str]) -> World {
24 +
        let dir = tempfile::tempdir().unwrap();
25 +
        let owner_home = dir.path().join("owner");
26 +
        let owner = KeyPair::create_home(&owner_home, "Andy McCall", "[email protected]").unwrap();
27 +
        let data = dir.path().join("data");
28 +
        std::fs::create_dir_all(&data).unwrap();
29 +
        let instance = KeyPair::instance(&data, "laptop").unwrap();
30 +
        let ledger = Ledger::open(&data.join("spectra.db"), Some(instance.clone())).await.unwrap();
31 +
        let mut first = vec![
32 +
            signed_event(&owner, "human", "identity.owner", identity_payload(&owner.identity)),
33 +
            signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity)),
34 +
        ];
35 +
        first.extend(default_channel_events());
36 +
        ledger.append(first).await.unwrap();
37 +
        for p in projects {
38 +
            let payload = serde_json::json!({ "name": p, "path": format!("/src/{p}"), "modules": [] });
39 +
            ledger.append(vec![signed_event(&owner, "human", "project.added", payload), project_channel_event(p)]).await.unwrap();
40 +
        }
41 +
        World { dir, owner_home, ledger, owner }
42 +
    }
43 +
44 +
    /// Delegate to a new agent and add it, as `spectra agent add` does.
45 +
    pub async fn agent(&mut self, name: &str, project: &str) -> KeyPair {
46 +
        let agent = KeyPair::generate(name, "");
47 +
        let keys = self.owner_home.join("keys");
48 +
        let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", self.owner.key.to_hex()))).unwrap()).unwrap();
49 +
        let mut d = self.owner.identity.delegates.clone();
50 +
        d.push(Delegate { key: agent.key, scope: DELEGATION_SCOPE, expires: None });
51 +
        let next = self.owner.identity.successor(d, None, now_ms());
52 +
        let k = next.hash();
53 +
        write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap();
54 +
        write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap();
55 +
        oxy_core::repo::Config::set_user(&self.owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap();
56 +
        self.owner = KeyPair::load_home(&self.owner_home).unwrap();
57 +
        let mut rotated = identity_payload(&self.owner.identity);
58 +
        rotated["chain"] = serde_json::json!([]);
59 +
        let mut p = identity_payload(&agent.identity);
60 +
        p["name"] = name.into();
61 +
        p["kind"] = "claude-code".into();
62 +
        p["project"] = project.into();
63 +
        p["owner_name"] = "Andy McCall".into();
64 +
        p["scopes"] = spectra_core::principals::DEFAULT_SCOPES.into();
65 +
        self.ledger
66 +
            .append(vec![
67 +
                signed_event(&self.owner, "human", "identity.rotated", rotated),
68 +
                signed_event(&self.owner, "human", "agent.added", p),
69 +
            ])
70 +
            .await
71 +
            .unwrap();
72 +
        agent
73 +
    }
74 +
75 +
    pub async fn principals(&self) -> Principals {
76 +
        Principals::load(&self.ledger).await.unwrap()
77 +
    }
78 +
79 +
    /// An author signing from the command line, as the CLI does.
80 +
    pub async fn author<'a>(&self, pair: &'a KeyPair) -> Author<'a> {
81 +
        let principal: Principal = self.principals().await.principal(&pair.key, now_ms()).unwrap();
82 +
        Author { principal, signer: Signer::Cli(pair) }
83 +
    }
84 +
85 +
    pub async fn verify_clean(&self) {
86 +
        let v = self.ledger.verify(&mut Principals::default()).await.unwrap();
87 +
        assert!(v.clean(), "{:?}", v.findings);
88 +
        assert!(self.ledger.check().await.unwrap().is_empty(), "projections drifted");
89 +
    }
90 +
}
added crates/spectra-core/tests/conversation.rs +156 −0
1 +
//! Conversation (spec §10, §13.1): who may post where, threads, mentions
2 +
//! and direct messages into the inbox, duplicates, limits, redaction, and
3 +
//! search limited to what the reader may see.
4 +
5 +
mod common;
6 +
7 +
use common::World;
8 +
use spectra_core::conversation::{self as conv, Post, Posted};
9 +
use spectra_core::ids::now_ms;
10 +
11 +
fn post(body: &str) -> Post {
12 +
    Post { body: body.into(), ..Default::default() }
13 +
}
14 +
15 +
fn id(p: Posted) -> String {
16 +
    match p {
17 +
        Posted::Message { id, .. } => id,
18 +
        Posted::Duplicate { id } => panic!("dropped as a duplicate of {id}"),
19 +
    }
20 +
}
21 +
22 +
#[tokio::test]
23 +
async fn agents_post_where_they_belong_and_nowhere_else() {
24 +
    let mut w = World::new(&["oxygen", "oxygenhub"]).await;
25 +
    let claude = w.agent("claude-oxygen", "oxygen").await;
26 +
    let a = w.author(&claude).await;
27 +
    let at = conv::post(&w.ledger, &a, post("default goes to my project"), now_ms()).await.unwrap();
28 +
    assert!(matches!(&at, Posted::Message { channel, .. } if channel == "#oxygen"), "{at:?}");
29 +
    for c in ["general", "humans", "#oxygen"] {
30 +
        conv::post(&w.ledger, &a, Post { channel: Some(c.into()), ..post(&format!("hello {c}")) }, now_ms()).await.unwrap();
31 +
    }
32 +
    for (c, why) in [("oxygenhub", "another project"), ("system", "Spectra's")] {
33 +
        let err = conv::post(&w.ledger, &a, Post { channel: Some(c.into()), ..post("nope") }, now_ms()).await.unwrap_err();
34 +
        assert!(err.to_string().contains(why), "{c}: {err}");
35 +
    }
36 +
    let owner_pair = w.owner.clone();
37 +
    let owner = w.author(&owner_pair).await;
38 +
    conv::post(&w.ledger, &owner, Post { channel: Some("oxygenhub".into()), ..post("the owner posts anywhere but #system") }, now_ms())
39 +
        .await
40 +
        .unwrap();
41 +
    w.verify_clean().await;
42 +
}
43 +
44 +
#[tokio::test]
45 +
async fn threads_mentions_and_direct_messages_reach_the_inbox() {
46 +
    let mut w = World::new(&["oxygen", "oxygenhub"]).await;
47 +
    let claude = w.agent("claude-oxygen", "oxygen").await;
48 +
    let kimi = w.agent("kimi-hub", "oxygenhub").await;
49 +
    let (c, k) = (w.author(&claude).await, w.author(&kimi).await);
50 +
    let root = id(conv::post(
51 +
        &w.ledger,
52 +
        &c,
53 +
        Post { channel: Some("general".into()), ..post("@kimi-hub the wire protocol grew a field; see oxy:change:abcdef12") },
54 +
        now_ms(),
55 +
    )
56 +
    .await
57 +
    .unwrap());
58 +
    let items = conv::inbox(&w.ledger, "agent:kimi-hub", Some(&k)).await.unwrap();
59 +
    assert_eq!(items.len(), 1);
60 +
    assert_eq!((items[0].kind.as_str(), items[0].from.as_str(), items[0].channel.as_str()), ("mention", "agent:claude-oxygen", "#general"));
61 +
    let reply = id(conv::post(&w.ledger, &k, Post { reply_to: Some(root.clone()), ..post("@andy should I bump the pin?") }, now_ms())
62 +
        .await
63 +
        .unwrap());
64 +
    let nested =
65 +
        id(conv::post(&w.ledger, &c, Post { reply_to: Some(reply.clone()), ..post("yes, after the release") }, now_ms()).await.unwrap());
66 +
    let (_, thread) = conv::read(&w.ledger, &c.principal, None, Some(&nested), None, 50).await.unwrap();
67 +
    assert_eq!(
68 +
        thread.iter().map(|m| m.id.clone()).collect::<Vec<_>>(),
69 +
        [root.clone(), reply.clone(), nested],
70 +
        "a reply to a reply joins the root's thread"
71 +
    );
72 +
    let (_, roots) = conv::read(&w.ledger, &c.principal, Some("general"), None, None, 50).await.unwrap();
73 +
    assert_eq!(roots.len(), 1);
74 +
    assert_eq!(roots[0].replies, 2);
75 +
    assert_eq!(conv::unread(&w.ledger, "human").await.unwrap(), 1, "@andy is the owner");
76 +
77 +
    assert_eq!(conv::ack(&w.ledger, &k, std::slice::from_ref(&root), Some("ack")).await.unwrap(), 1);
78 +
    assert!(conv::inbox(&w.ledger, "agent:kimi-hub", None).await.unwrap().is_empty());
79 +
80 +
    let dm = conv::ensure_direct(&w.ledger, &c.principal, "kimi-hub").await.unwrap();
81 +
    assert_eq!(dm.display(), "@claude-oxygen+kimi-hub");
82 +
    conv::post(&w.ledger, &c, Post { channel: Some(dm.name.clone()), ..post("just between us") }, now_ms()).await.unwrap();
83 +
    assert_eq!(conv::inbox(&w.ledger, "agent:kimi-hub", None).await.unwrap()[0].kind, "dm");
84 +
    let third = w.agent("third", "oxygen").await;
85 +
    let t = w.author(&third).await;
86 +
    assert!(conv::read(&w.ledger, &t.principal, Some(&dm.name), None, None, 10).await.is_err(), "not a member");
87 +
    assert!(conv::post(&w.ledger, &t, Post { channel: Some(dm.name.clone()), ..post("let me in") }, now_ms()).await.is_err());
88 +
    w.verify_clean().await;
89 +
}
90 +
91 +
#[tokio::test]
92 +
async fn duplicates_limits_and_redaction() {
93 +
    let mut w = World::new(&["oxygen"]).await;
94 +
    let claude = w.agent("claude-oxygen", "oxygen").await;
95 +
    let c = w.author(&claude).await;
96 +
    let first = id(conv::post(&w.ledger, &c, post("build failed on main"), now_ms()).await.unwrap());
97 +
    assert!(
98 +
        matches!(conv::post(&w.ledger, &c, post("build failed on main"), now_ms()).await.unwrap(), Posted::Duplicate { id } if id == first)
99 +
    );
100 +
    assert!(conv::post(&w.ledger, &c, post(&"x".repeat(conv::MAX_BODY + 1)), now_ms()).await.is_err());
101 +
    assert!(conv::post(&w.ledger, &c, post("   "), now_ms()).await.is_err());
102 +
    assert!(conv::post(&w.ledger, &c, Post { kind: Some("shout".into()), ..post("x") }, now_ms()).await.is_err());
103 +
104 +
    let secret = id(conv::post(&w.ledger, &c, post("the token is hunter2"), now_ms()).await.unwrap());
105 +
    assert_eq!(conv::search(&w.ledger, &c.principal, "hunter2", None, None, None, 10).await.unwrap().len(), 1);
106 +
    let kimi = w.agent("kimi", "oxygen").await;
107 +
    let k = w.author(&kimi).await;
108 +
    assert!(conv::redact(&w.ledger, &k, &secret, "not mine").await.is_err(), "only its author or the owner");
109 +
    conv::redact(&w.ledger, &c, &secret, "a secret pasted by mistake").await.unwrap();
110 +
    let (_, msgs) = conv::read(&w.ledger, &c.principal, None, None, None, 10).await.unwrap();
111 +
    let shown = msgs.iter().find(|m| m.id == secret).unwrap();
112 +
    assert_eq!(shown.body, "[redacted: a secret pasted by mistake]");
113 +
    assert!(conv::search(&w.ledger, &c.principal, "hunter2", None, None, None, 10).await.unwrap().is_empty(), "gone from search");
114 +
    w.verify_clean().await;
115 +
}
116 +
117 +
#[tokio::test]
118 +
async fn search_finds_stemmed_words_and_respects_who_may_read() {
119 +
    let mut w = World::new(&["oxygen"]).await;
120 +
    let claude = w.agent("claude-oxygen", "oxygen").await;
121 +
    let kimi = w.agent("kimi", "oxygen").await;
122 +
    let (c, k) = (w.author(&claude).await, w.author(&kimi).await);
123 +
    conv::post(
124 +
        &w.ledger,
125 +
        &c,
126 +
        Post { channel: Some("general".into()), ..post("We decided to bump the oxygen pin after releasing 0.11") },
127 +
        now_ms(),
128 +
    )
129 +
    .await
130 +
    .unwrap();
131 +
    conv::post(&w.ledger, &c, post("Unrelated chatter about lunch"), now_ms()).await.unwrap();
132 +
    let dm = conv::ensure_direct(&w.ledger, &c.principal, "human").await.unwrap();
133 +
    conv::post(&w.ledger, &c, Post { channel: Some(dm.name.clone()), ..post("privately: the pin bump worries me") }, now_ms())
134 +
        .await
135 +
        .unwrap();
136 +
137 +
    let hits = conv::search(&w.ledger, &k.principal, "bumping pins", None, None, None, 20).await.unwrap();
138 +
    assert_eq!(hits.len(), 1, "stemmed, and the direct message is not kimi's to see: {hits:?}");
139 +
    assert!(hits[0].snippet.contains("[bump]"), "{}", hits[0].snippet);
140 +
    assert_eq!(hits[0].channel, "#general");
141 +
    let owner_pair = w.owner.clone();
142 +
    let owner = w.author(&owner_pair).await;
143 +
    assert_eq!(
144 +
        conv::search(&w.ledger, &owner.principal, "pin", None, None, None, 20).await.unwrap().len(),
145 +
        2,
146 +
        "the owner reads everything"
147 +
    );
148 +
    assert_eq!(
149 +
        conv::search(&w.ledger, &k.principal, "\"oxygen pin\"", Some("general"), Some("claude-oxygen"), None, 20).await.unwrap().len(),
150 +
        1
151 +
    );
152 +
    assert!(conv::search(&w.ledger, &k.principal, "lunch", Some("general"), None, None, 20).await.unwrap().is_empty());
153 +
    let err = conv::search(&w.ledger, &k.principal, "AND OR (", None, None, None, 20).await.unwrap_err();
154 +
    assert!(err.to_string().contains("does not parse"), "{err}");
155 +
    w.verify_clean().await;
156 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Added: channels, threads, messages, the inbox and search (#6).** `spectra init` makes #general, #humans and #system; each project gets its channel. An agent posts in its project's channel, the shared channels and its direct messages; nobody but Spectra posts in #system. Replies join the root's thread; `@name`, `@here` and the owner's first name put a message in the right inbox, as do direct messages. Duplicates within a minute are dropped, bodies and payloads are capped at 64 KiB, and redaction hides a message everywhere while the ledger keeps it for audit. Full-text search (FTS5, stemmed) covers what the reader may see.
5 6
- **Added: projects, presence and sessions (#5).** `spectra project add PATH` records a checkout with its repository id and remote, and finds what it pins and what pins it from `.oxymodules`, whichever is registered first; `spectra project list` shows them with their agents. An agent needs a registered project. Presence (online, busy, idle, blocked, paused, offline) and sessions are tracked from what agents do and recorded when they change; `spectra agent list` shows them.
6 7
- **Changed: the command line looks like oxy's.** Every command reports in `oxy`'s style, through `oxy`'s own `ui` module: a green tick and verb, dimmed key-value rows, section bars, hints and warnings, author names and separators, and a spinner while it works; plain ASCII when piped, with `--plain` or with `NO_COLOR`.
7 8
- **Added: identities instead of tokens (#4).** `spectra init` records the owner's Oxygen identity, proven by signing with it, and makes the instance key. `spectra agent add` makes an agent's key pair in its own home and records the agent only once the owner's identity delegates to it (`oxy identity delegate add`), so every agent is somebody's: `claude-oxygen · Andy McCall's agent`. Expiry and `spectra agent disable` stop an agent's key. `spectra admin verify` replays identities and delegations and checks every signed event, refusing an event whose actor is not its signer. Requests are authenticated by signature, a five-minute window, single-use nonces and an optional Unix-user binding. One process writes a ledger at a time.
…
3 unchanged lines
modified crates/spectra-core/src/lib.rs +1 −0
…
5 unchanged lines
6 6
//! hashing come from `oxy-core`; nothing here reimplements them.
7 7
8 8
pub mod authn;
9 +
pub mod conversation;
9 10
pub mod event;
10 11
pub mod hlc;
11 12
pub mod ids;
…
11 unchanged lines
modified crates/spectra-core/src/projections.rs +15 −1
…
6 unchanged lines
7 7
use sqlx::SqliteConnection;
8 8
9 9
/// The projection tables, in the order they are cleared and compared.
10 -
pub const TABLES: &[&str] = &["event_counts", "identities", "agents", "projects", "project_relations", "presence", "sessions"];
10 +
pub const TABLES: &[&str] = &[
11 +
    "event_counts",
12 +
    "identities",
13 +
    "agents",
14 +
    "projects",
15 +
    "project_relations",
16 +
    "presence",
17 +
    "sessions",
18 +
    "channels",
19 +
    "messages",
20 +
    "reactions",
21 +
    "inbox",
22 +
    "messages_fts",
23 +
];
11 24
12 25
/// Apply one event to every projection.
13 26
pub async fn apply(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> {
…
4 unchanged lines
18 31
    crate::principals::project(conn, e).await?;
19 32
    crate::projects::project(conn, e).await?;
20 33
    crate::presence::project(conn, e).await?;
34 +
    crate::conversation::project(conn, e).await?;
21 35
    Ok(())
22 36
}
modified docs/plan.md +1 −1
…
18 unchanged lines
19 19
- [x] #3 The ledger — done 2026-11-02: chain, frozen envelope, HLC, signed checkpoints every 256 events, verify/check/rebuild; append 0.15 ms (release), 10,000 random events with rebuild and verify 1.3 s.
20 20
- [x] #4 Identities instead of tokens — done 2026-11-02: init, agent add (waits for the delegation), list, disable, identity show/refresh; Principals replay in verify; Authenticator (window, nonce, uid); write lock. Checked against the real `oxy identity delegate add`. The socket and its SO_PEERCRED arrive with #7, which uses the Authenticator.
21 21
- [x] #5 Projects, presence and sessions — done 2026-11-02: project add/list with relations from .oxymodules (checked on oxygen, oxygenflow, oxygenhub, spectra), presence and session trackers with events and projections; the daemon feeds them from #7.
22 -
- [ ] #6 Channels, threads, messages, inbox and search
22 +
- [x] #6 Channels, threads, messages, inbox and search — done 2026-11-02: spectra-core::conversation (post, read, inbox, ack, redact, search, direct channels), shared by tools, API and CLI.
23 23
- [ ] #7 Tuner: MCP over Streamable HTTP
24 24
- [ ] #8 The 0.1 tools
25 25
- [ ] #9 The stdio shim
…
11 unchanged lines
modified src/cli/identity.rs +6 −6
…
34 unchanged lines
35 35
    let name = name.unwrap_or_else(hostname);
36 36
    let instance = KeyPair::instance(data, &name)?;
37 37
    let ledger = Ledger::open(&db, Some(instance.clone())).await?;
38 -
    ledger
39 -
        .append(vec![
40 -
            signed_event(&owner, "human", "identity.owner", identity_payload(&owner.identity)),
41 -
            signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity)),
42 -
        ])
43 -
        .await?;
38 +
    let mut first = vec![
39 +
        signed_event(&owner, "human", "identity.owner", identity_payload(&owner.identity)),
40 +
        signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity)),
41 +
    ];
42 +
    first.extend(spectra_core::conversation::default_channel_events());
43 +
    ledger.append(first).await?;
44 44
    ledger.checkpoint().await?;
45 45
    let home_s = home.display().to_string();
46 46
    std::fs::write(
…
125 unchanged lines
modified src/cli/project.rs +6 −1
…
34 unchanged lines
35 35
    let payload = serde_json::json!({
36 36
        "name": name, "path": path.display().to_string(), "repo_id": checkout.repo_id, "remote": remote, "modules": checkout.modules,
37 37
    });
38 -
    ledger.append_one(signed_event(&owner, "human", "project.added", payload).project(name.clone()).subject(name.clone())).await?;
38 +
    ledger
39 +
        .append(vec![
40 +
            signed_event(&owner, "human", "project.added", payload).project(name.clone()).subject(name.clone()),
41 +
            spectra_core::conversation::project_channel_event(&name),
42 +
        ])
43 +
        .await?;
39 44
    ledger.checkpoint().await?;
40 45
    println!("{} {}  {}", ui::done("Added"), ui::subject(&name), ui::path(path.display()));
41 46
    if let Some(r) = &remote {
…
68 unchanged lines