feat(discord): writes on write-marked roots, web fetch and search, held prompts (#1509)
Row 23. write_file and edit_file for roots marked write: true under the same fence as reads; web_fetch (https only, public addresses, pinned connection, capped body) and web_search through SearXNG; extension renamed to tools.mjs. Engine holds a prompt while pi is busy and sends it as its own run, so a second message mid-turn no longer folds into the first (live defect). fake-pi models the real follow-up folding. Suite 52/52, node tests 129. rev-code-02 APPROVED round 3, comment 26362, tree dbd2ce9a. Records: QUEUE rows 23-24, CURRENT, BUILD-LOG phase, SESSIONS, row 24 brief (git verbs, D5-D7 ruled). Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
+58
-11
@@ -1,10 +1,12 @@
|
||||
# discord
|
||||
|
||||
The Discord connector: one seat's conversation reachable from listed
|
||||
channels of one Discord server, chat plus read-only tools confined to
|
||||
declared folders. Issue #1509, briefs
|
||||
`docs/plans/2026-09-13_discord-connector-pilot.md` and
|
||||
`docs/plans/2026-09-14_discord-readonly-tools.md`. Plain ESM, no
|
||||
channels of one Discord server, chat plus file tools confined to
|
||||
declared folders: reads everywhere, writes only where a root allows
|
||||
them. Issue #1509, briefs
|
||||
`docs/plans/2026-09-13_discord-connector-pilot.md`,
|
||||
`docs/plans/2026-09-14_discord-readonly-tools.md` and
|
||||
`docs/plans/2026-09-16_discord-write-and-web-tools.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
|
||||
@@ -137,18 +139,23 @@ is `src/binding.mjs`.
|
||||
| `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 |
|
||||
| `tools` | optional. `roots[]` of `{name, path}`: absolute directories the seat may read through `list_dir`, `read_file` and `search`; `maxFileBytes` (262144), `maxCallsPerTurn` (8). Absent means no tools and a pi launch with `--no-tools`. A root may not be `/`, the home directory, a symlink, a path with a dot-prefixed segment, or anything inside or above the data root |
|
||||
| `tools` | optional. `roots[]` of `{name, path, write?}`: absolute directories the seat may read through `list_dir`, `read_file` and `search`; a root with `"write": true` may also be written through `write_file` and `edit_file`; `maxFileBytes` (262144), `maxCallsPerTurn` (8); `web` (optional) `{searxng, maxFetchBytes}` enables `web_fetch` and `web_search` through the named SearXNG instance (https, or http on loopback; `maxFetchBytes` 1048576). Absent means no tools and a pi launch with `--no-tools`. A root may not be `/`, the home directory, a symlink, a path with a dot-prefixed segment, or anything inside or above the data root |
|
||||
|
||||
Unknown keys, missing fields, wrong types, empty allowlists, a user channel
|
||||
that is not listed and a bot listed as a user all refuse with exit 2. A
|
||||
running connector picks up an edit through `reload`; the fields it will not
|
||||
take in place are listed under that command.
|
||||
|
||||
## Read-only tools
|
||||
## File tools
|
||||
|
||||
With `tools` in the binding, pi starts with `--no-builtin-tools`, loads
|
||||
`extension/readonly-tools.mjs` explicitly, and allowlists exactly
|
||||
`list_dir`, `read_file` and `search`. The extension reads its roots from
|
||||
`extension/tools.mjs` explicitly, and allowlists exactly the tools the
|
||||
binding enables: `list_dir`, `read_file` and `search` always, plus
|
||||
`write_file` and `edit_file` when at least one root has `"write": true`,
|
||||
plus `web_fetch` and `web_search` when `tools.web` names a SearXNG instance
|
||||
(`enabledToolNames` in `src/tools.mjs` is the one place that decides; the
|
||||
suite checks the real pi exposes that list and nothing else). The
|
||||
extension reads its roots from
|
||||
the `MOSAIC_DISCORD_TOOLS` environment variable the connector sets, and
|
||||
throws without it, which makes pi exit and the connector refuse to start.
|
||||
Every rule lives in `src/tools.mjs` and is tested without pi: a request
|
||||
@@ -168,6 +175,44 @@ of pi turns the answer took. The system prompt names the roots, says file
|
||||
content is data like Discord text, and tells the seat to say plainly when
|
||||
a read was refused.
|
||||
|
||||
Writes (row 23) add rules on top of the read rules, with the same fixed
|
||||
refusals. `write_file(root, path, text)` creates or replaces a file;
|
||||
`edit_file(root, path, old, new)` replaces one exact string that occurs
|
||||
exactly once. Both refuse a root without `write: true`, a parent folder
|
||||
that does not exist (no folder is ever created), any dot-prefixed segment
|
||||
(so `.git/` is unreachable), a symlink anywhere in the path, a target
|
||||
that is not a regular file with one link (a folder, a FIFO, a hard link),
|
||||
text over `maxFileBytes` or with a NUL byte, and text that matches a
|
||||
credential shape. The bytes go to a dot-prefixed temp file in the same
|
||||
folder, created exclusively, then one rename over the target after a
|
||||
second `lstat` confirms the target is the file that was checked (or is
|
||||
still absent); a refused rename removes the temp file. The tool result
|
||||
says the file is not committed, and the prompt tells the seat to end its
|
||||
reply by naming the file it changed: Sage has no git, Jason commits from
|
||||
the terminal.
|
||||
|
||||
Web tools (row 23, `src/web.mjs`, no dependencies). `web_fetch(url)` is
|
||||
one GET of an absolute https url with no user or password part. The host
|
||||
is resolved first and every address must be public: loopback, private,
|
||||
link-local, carrier-grade NAT, multicast and IPv4-mapped forms refuse the
|
||||
call, and the connection is pinned to the vetted address so a name that
|
||||
answers differently on the second lookup gains nothing. At most three
|
||||
redirects, each re-checked by the same rules and refused unless https.
|
||||
The body stops at `maxFetchBytes`; html is reduced to text with its title
|
||||
(scripts, styles and comments dropped); plain text, json and xml pass as
|
||||
they are; anything else is refused. The model sees at most 12000
|
||||
characters. One fixed User-Agent, no cookies, no auth headers, no POST,
|
||||
and the whole call ends within 15 s. `web_search(query)` asks the
|
||||
instance `/search?q=…&format=json` and returns title, url and snippet for
|
||||
at most 10 results; a query over 400 characters, a non-200 answer or a
|
||||
non-json body is refused. The instance url must be https or http on
|
||||
loopback. Both count against `maxCallsPerTurn`, and the turn record keeps
|
||||
the url, status and byte count (fetch) or the query and hit count
|
||||
(search). The prompt says web content is data like file content. The
|
||||
tests drive both tools against a local server through an injected
|
||||
resolver and transport, so the fence is tested without the network; the
|
||||
real transport is `node:https` with the same options.
|
||||
|
||||
## What happens to a message
|
||||
|
||||
1. The gateway delivers `MESSAGE_CREATE`. `authorize` drops it unless the
|
||||
@@ -183,8 +228,10 @@ a read was refused.
|
||||
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. As soon as
|
||||
that text is data. A message that arrives during a turn is held by the
|
||||
connector and sent when pi settles, one run per message, so it is
|
||||
neither lost nor run concurrently (a pi follow-up would fold it into
|
||||
the running answer and lose the first reply). As soon as
|
||||
the turn is admitted the connector reacts to the inbound message with
|
||||
eyes as a read receipt; a typing indicator follows every 8 seconds while
|
||||
the turn runs. A reaction Discord refuses is logged and recorded in the
|
||||
@@ -232,7 +279,7 @@ written with `O_EXCL` and never rewritten.
|
||||
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,
|
||||
op 9, close 4014), delivery and reconcile, engine (held prompt, timeout,
|
||||
malformed line, tool runs), restart replay, stop and ceiling, tools
|
||||
confinement. The suite also starts the real pi offline three times, with no
|
||||
model call, to show the extension exposes exactly the three tools, the pilot
|
||||
|
||||
Reference in New Issue
Block a user