Files
stack/docs/plans/2026-09-16_discord-write-and-web-tools.md
T
jason.woltjeandClaude Fable 5.1 1685deb423 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]>
2026-09-18 07:27:50 -05:00

109 lines
4.8 KiB
Markdown

# Discord Sage: writes into the strategy repository, and web research (#1509, QUEUE row 23)
Jason's word, 2026-09-16: "We need A + web. The agent needs to be able to
research." Option A was: writing confined to the `shared-signals` root,
no shell. This brief adds web reach to that.
## 1. Outcome
A Discord message to Sage can end with a file written or changed inside
the `shared-signals` repository, and Sage can read pages on the web while
answering. Nothing else on the host becomes reachable. Every write and
every web call is in the turn record.
## 2. What is built
Two more tools in the Mosaic extension (`packages/discord/extension/
readonly-tools.mjs`, to be renamed `tools.mjs` with the rules in
`src/tools.mjs`):
- `write_file(root, path, text)`: creates or replaces a file. Allowed only
in roots the binding marks `"write": true`. Same path rules as reads
(names not paths, no `..`, no dot segments, symlink walk refused), plus:
the parent directory must already exist under the root, the target is a
regular file or absent, `.git/` and any dot-prefixed path are refused,
the text is at most `maxFileBytes`, a temp file and rename so a
half-written file never exists, and the credential shapes refuse the
write the same way they refuse a read.
- `edit_file(root, path, old, new)`: one exact replacement of a string
that occurs exactly once. Same fences as `write_file`.
Two web tools:
- `web_fetch(url)`: GET only, `https` only, redirects followed at most
three times and re-checked, no private or link-local addresses after
name resolution, response capped at `maxFetchBytes`, HTML reduced to
text before the model sees it, 15 s timeout. No cookies, no auth
headers, a fixed User-Agent naming the bot.
- `web_search(query)`: a query to a SearXNG instance named in the
binding (D1), `GET /search?q=…&format=json`, no key. Returns title,
url and snippet, at most 10 results. The instance url must be
`http://127.0.0.1` or `https`; the query is sent as one parameter.
Binding changes (`tools` key, still fixed, needs a restart):
```json
"tools": {
"roots": [
{ "name": "stack-docs", "path": "…/mosaic-stack/docs" },
{ "name": "sage", "path": "…/mosaic-stack/agents/sage" },
{ "name": "shared-signals", "path": "…/shared-signals", "write": true }
],
"maxFileBytes": 262144,
"maxCallsPerTurn": 12,
"web": { "searxng": "http://127.0.0.1:8888", "maxFetchBytes": 1048576 }
}
```
Without `web`, no web tools are offered. Without any `write: true` root,
no write tools are offered, and the prompt paragraph stays as today.
The system prompt paragraph names which roots are writable and says a
write is real only once Jason commits it. Sage cannot run git, so the
answer names the file it changed.
## 3. What is not built
No shell. No git from Sage: Jason commits from the terminal after
`git diff`. No writes to the Mosaic repository roots. No POST or forms on
the web. No per-user tool gating (Carmen gets the same tools where she is
allowed to write; see D2).
## 4. Evidence
- `packages/discord/tests/tools.test.mjs`: refused write outside a
writable root, into `.git`, through a symlinked parent, over a FIFO,
past the cap, with a credential shape; a happy path that leaves the
exact bytes; an edit with zero or two matches refused; a temp file
never left behind after a refused rename.
- Web tests against a local `http.createServer`: redirect to a private
address refused, size cap, timeout, non-https refused, HTML to text.
- Suite check: the extension exposes exactly the tools the binding
enables, and a binding without `web` exposes none of the web tools.
- Live: Jason asks in #ideas for a naming shortlist written to
`vault/Businesses/…`; the file appears, the turn record shows the
write and the web calls, `git status` in shared-signals shows one
new file.
## 5. Jason's rulings (2026-09-16)
- D1. Search goes through SearXNG, a self-hosted metasearch with a JSON
API and no key, so Sage is tied to no search vendor. The binding names
the instance url (`"web": {"searxng": "http://127.0.0.1:8888"}`); the
tool calls `/search?q=…&format=json` and returns title, url and
snippet, at most 10. No instance runs on this host yet, so this piece
includes a SearXNG container under the user's podman or docker, bound
to localhost, `format=json` enabled in its settings. Jason's words:
"I hate to tie Sage to Z.ai. If I can't [switch] providers, we need
flexibility."
- D2. Only Jason and Carmen work with the repository and Sage. Both may
write. The binding's user list already enforces who reaches Sage.
- D3. Any https host; private and link-local addresses refused.
- D4. Reviewer: rev-code-02 on #1509, as row 21.
## 6. Order
Writes first (a day, with the tests), then web fetch and search, then
the SearXNG container and the live check. Each part is a separate local commit on `refactor`, no
push without Jason's word.