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 |