feat(discord): connector pilot for the Sage seat, reviewed candidate (#1509)
Zero-dependency Discord connector under packages/discord: binding
validation, REST and gateway clients, pi engine adapter, journal with
append-only inbox, outbox, admissions and notices, and a run.lock
ownership record {pid, start, boot} whose identity is checked three ways
and whose cleanup is gated by STOP. CLI check|run|stop|unlock via
scripts/discord.sh; offline suite scripts/test-discord.sh (28 checks,
87 node tests).
Reviewed by rev-code-02 on #1509 over nine rounds; approved exact tree
4e0feb6758c0a7e4a71483912a8e0d3e3ec95aef at comment 26170. Corrections
(1) to (12) recorded in BUILD-LOG. No listener started, no token read,
no Discord write; the live pilot follows this commit per the brief.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
# discord
|
||||
|
||||
The Discord connector: one seat's conversation reachable from listed
|
||||
channels of one Discord server, chat only. Issue #1509, brief
|
||||
`docs/plans/2026-09-13_discord-connector-pilot.md`. Plain ESM, no
|
||||
dependencies, Node 24 or newer, built-in WebSocket and fetch.
|
||||
|
||||
A Discord channel is one more interface onto a seat's conversation, the same
|
||||
class of thing as the terminal and the WebUI session chat. The connector owns
|
||||
a `pi --mode rpc` engine today; that module is the one to swap when the
|
||||
CHAT-03 conversation controller exists.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
scripts/discord.sh check <binding>
|
||||
scripts/discord.sh run <binding>
|
||||
scripts/discord.sh stop <binding>
|
||||
scripts/discord.sh unlock <binding>
|
||||
```
|
||||
|
||||
`<binding>` names `<dataRoot>/discord/<binding>.json`. The wrapper passes
|
||||
`--repo` for you; the CLI also takes `--config PATH`.
|
||||
|
||||
- `check` validates the binding, the token file (0600, regular, not a
|
||||
symlink), the context files and the pi install; reads the bot, the guild and
|
||||
every listed channel over REST; opens one gateway connection, waits for
|
||||
READY, closes it. Nothing is sent to a channel. Close code 4014 is reported
|
||||
as the message-content intent not being granted in the developer portal.
|
||||
- `run` refuses when `STOP` exists or an unresolved delivery cannot be
|
||||
reconciled. Otherwise it starts pi, connects, and serves turns until
|
||||
SIGTERM, SIGINT or `stop`. Run it in a tmux window; there is no service unit.
|
||||
- `stop` writes `STOP` and sends SIGTERM to the process in `run.lock`, only
|
||||
when that pid is alive and both its start time and the boot id match the
|
||||
recorded ones; a reused pid, a pid from a previous boot, or a pid whose
|
||||
identity cannot be read right now is never signaled. The current turn finishes or times out, then
|
||||
the process exits. Remove `STOP` to run again.
|
||||
- `unlock` writes `STOP`, then removes a `run.lock` whose owner is gone
|
||||
(crash, reboot, a start interrupted before it published its record). It
|
||||
refuses while the owner is running; use `stop` for that. It also refuses,
|
||||
removing nothing, when the pid is alive and its identity cannot be
|
||||
established: the record predates the boot id (an upgrade over a running
|
||||
connector), a recorded value is not a start tick or a boot id, or /proc
|
||||
cannot be read right now. Once that pid is dead,
|
||||
`unlock` clears it. A record file that exists but cannot be parsed is
|
||||
never removed or claimed over; inspect it by hand. `STOP` is the gate that serializes
|
||||
cleanup with starts: a `run` re-checks `STOP` after publishing its record
|
||||
and releases itself if it is there, so nothing that starts during an
|
||||
unlock can hold the binding. `run` never reclaims a stale lock on its own;
|
||||
it refuses and names this command. Remove `STOP` to run again.
|
||||
|
||||
Exit codes: 0 ok, 1 operation failed, 2 invalid data or configuration, 4 usage.
|
||||
|
||||
## The binding
|
||||
|
||||
Deployment policy for one seat on one server. It carries Discord ids of real
|
||||
people, so it lives under the data root at mode 0600 and is never committed.
|
||||
`fixtures/binding.example.json` is the shape with placeholder ids; the schema
|
||||
is `src/binding.mjs`.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `bindingVersion` | 1 |
|
||||
| `name`, `seat` | binding name (matches the file name) and the seat it serves |
|
||||
| `guildId`, `guildName`, `botUserId` | the one server and the bot identity `check` confirms |
|
||||
| `tokenFile` | absolute path to the bot token, 0600, read into memory at start, never printed or journaled |
|
||||
| `channels[]` | `{id, name, mode}`; `open` answers every message, `mention` only when the bot is mentioned; threads inherit the parent's mode |
|
||||
| `users[]` | `{id, name}`; the only authors that get a turn |
|
||||
| `engine` | `provider`, `model`, `thinking` for pi |
|
||||
| `limits` | `turnsPerDay` (200), `turnTimeoutSeconds` (180), `replyChunkChars` (1900), `inboundMaxChars` (4000) |
|
||||
| `context.files[]` | files appended to pi's system prompt in order, repository-relative and inside the repository (no absolute paths, `..` or symlinks); the Discord block is added after them |
|
||||
|
||||
Unknown keys, missing fields, wrong types, empty allowlists and a bot listed
|
||||
as a user all refuse with exit 2.
|
||||
|
||||
## What happens to a message
|
||||
|
||||
1. The gateway delivers `MESSAGE_CREATE`. `authorize` drops it unless the
|
||||
guild matches, the author is listed and is not a bot, webhook or the bot
|
||||
itself, the channel or the thread's parent is listed, and in `mention`
|
||||
mode the bot is in `mentions` (`@everyone` does not count). A drop is one
|
||||
line in `drops.jsonl` and no reply.
|
||||
2. The message id is appended to `inbox.jsonl` before anything else. On
|
||||
start the inbox is read back; a replayed id is dropped as `duplicate`.
|
||||
That is the restart guard.
|
||||
3. `STOP`, an oversize message and the daily ceiling are checked next. Over
|
||||
size gets one fixed line. Over the ceiling gets one fixed line per UTC
|
||||
day, then silence until midnight UTC; the process stays up.
|
||||
4. The prompt is an envelope, one bracketed line naming server, channel,
|
||||
thread, author id and message id, then the text. The system prompt says
|
||||
that text is data. A message that arrives during a turn is queued in pi
|
||||
as a follow-up, so it is neither lost nor run concurrently. A typing
|
||||
indicator is sent every 8 seconds while a turn runs.
|
||||
5. The reply is split at 1900 characters on paragraph boundaries. Each chunk
|
||||
is posted with `nonce` and `enforce_nonce: true`, `allowed_mentions`
|
||||
empty, and the first chunk as a reply to the inbound message. An intent
|
||||
line goes to `outbox.jsonl` before the POST and a `confirmed`, `refused`
|
||||
or `unknown` line after it. A chunk that is not confirmed stops the rest
|
||||
of that reply.
|
||||
6. One write-once record per turn lands in `turns/<message id>.json`: ids,
|
||||
timing, usage, delivery outcome, and the error on a failed turn. A failed
|
||||
turn posts one fixed line, never model output.
|
||||
|
||||
On start, every `intent` or `unknown` delivery is reconciled by sending the
|
||||
same nonce again; Discord returns the existing message instead of posting
|
||||
twice. An intent older than five minutes is outside Discord's dedupe window
|
||||
and is marked `refused` rather than re-sent, because a re-send could post a
|
||||
second reply. If anything is still `unknown` after that, `run` refuses to
|
||||
start and names the nonces.
|
||||
|
||||
## Runtime data
|
||||
|
||||
```
|
||||
<dataRoot>/discord/<binding>.json the binding, 0600
|
||||
<dataRoot>/discord/<binding>/inbox.jsonl accepted message ids
|
||||
<dataRoot>/discord/<binding>/outbox.jsonl delivery intent and receipts, by nonce
|
||||
<dataRoot>/discord/<binding>/drops.jsonl one line per dropped or refused message
|
||||
<dataRoot>/discord/<binding>/admissions.jsonl one line per admitted turn, before the engine runs
|
||||
<dataRoot>/discord/<binding>/turns/<id>.json write-once turn records
|
||||
<dataRoot>/discord/<binding>/launches/ context snapshot and sha256 per run
|
||||
<dataRoot>/discord/<binding>/STOP stop switch
|
||||
<dataRoot>/discord/<binding>/notices.jsonl once-per-day fixed lines already attempted
|
||||
<dataRoot>/discord/<binding>/run.lock/ ownership directory (atomic mkdir) with owner.json {pid, start, boot}; stale ones need `unlock`
|
||||
<dataRoot>/sessions/discord-<binding>/ the pi session, continued across runs
|
||||
```
|
||||
|
||||
Directories are 0700, files 0600. Logs are append-only; turn records are
|
||||
written with `O_EXCL` and never rewritten.
|
||||
|
||||
## Tests
|
||||
|
||||
`scripts/test-discord.sh` or `node --test packages/discord/tests/`. All
|
||||
offline: fake WebSocket and timers for the gateway, fake fetch for REST, a
|
||||
scripted stand-in for pi over stdio, a disposable data root. Groups: binding,
|
||||
authorization table, gateway (hello, identify, heartbeat, missed ack, op 7,
|
||||
op 9, close 4014), delivery and reconcile, engine (follow-up, timeout,
|
||||
malformed line), restart replay, stop and ceiling.
|
||||
|
||||
## Not in this piece
|
||||
|
||||
Tools, repository writes, announcements, attachments, slash commands, DMs,
|
||||
per-thread sessions, more than one server or seat, a service unit, a
|
||||
control-board row. Section 8 of the brief keeps the list.
|
||||
Reference in New Issue
Block a user