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

4.8 KiB

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):

"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.