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:
2026-09-18 07:27:50 -05:00
co-authored by Claude Fable 5.1
parent 1ac812d3d5
commit 1685deb423
24 changed files with 1519 additions and 113 deletions
+20 -8
View File
@@ -13,11 +13,13 @@
// file (`reload`): `reloadDiff` says which keys may change in place and
// refuses the rest.
//
// An optional `tools` key declares read-only roots for the Discord Sage's
// tools (see tools.mjs). Absent means no tools and a launch exactly as
// before. It is a fixed key: the extension reads it at pi start.
// An optional `tools` key declares the roots for the Discord Sage's file
// tools (see tools.mjs): read-only unless a root says `write: true`.
// Absent means no tools and a launch exactly as before. It is a fixed
// key: the extension reads it at pi start.
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
import { loadWebConfig } from "./web.mjs";
import { isAbsolute, join, resolve, sep } from "node:path";
import { homedir } from "node:os";
import { DiscordError } from "./errors.mjs";
@@ -42,8 +44,8 @@ const USER_KEYS = ["id", "name", "channels"];
const ENGINE_KEYS = ["provider", "model", "thinking"];
const LIMIT_KEYS = Object.keys(LIMIT_DEFAULTS);
const CONTEXT_KEYS = ["files"];
const TOOLS_KEYS = ["roots", "maxFileBytes", "maxCallsPerTurn"];
const ROOT_KEYS = ["name", "path"];
const TOOLS_KEYS = ["roots", "maxFileBytes", "maxCallsPerTurn", "web"];
const ROOT_KEYS = ["name", "path", "write"];
const ROOT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
export function defaultConfigPath(env = process.env) {
@@ -194,7 +196,8 @@ export function validateBinding(raw, where = "binding") {
if (!isAbsolute(rpath) || rpath.includes("\0")) throw new DiscordError(`${w}: path must be an absolute path`);
if (rpath.split(sep).some((seg) => seg.startsWith(".") && seg.length > 0)) throw new DiscordError(`${w}: path must not have a dot-prefixed segment (${rpath})`);
if (resolve(rpath) === sep || resolve(rpath) === homedir()) throw new DiscordError(`${w}: path must not be the filesystem root or the home directory`);
return Object.freeze({ name: rname, path: rpath });
if (r.write !== undefined && r.write !== true && r.write !== false) throw new DiscordError(`${w}: write must be true or false`);
return Object.freeze({ name: rname, path: rpath, write: r.write === true });
});
if (new Set(roots.map((r) => r.name)).size !== roots.length) throw new DiscordError(`${where}.tools: duplicate root name`);
const mergedTools = { ...TOOL_DEFAULTS, ...raw.tools, roots };
@@ -202,6 +205,7 @@ export function validateBinding(raw, where = "binding") {
roots: Object.freeze(roots),
maxFileBytes: requireInteger(mergedTools, "maxFileBytes", `${where}.tools`, { min: 1024, max: 4 * 1024 * 1024 }),
maxCallsPerTurn: requireInteger(mergedTools, "maxCallsPerTurn", `${where}.tools`, { min: 1, max: 64 }),
web: raw.tools.web === undefined ? null : webConfig(raw.tools.web, `${where}.tools.web`),
});
}
@@ -312,6 +316,14 @@ export function resolveContextFiles(binding, repo) {
// Tool roots must exist as real directories on this host, not symlinks, and
// must not sit inside the data root (bindings, tokens, journals) or contain
// it. Returns the resolved config the engine hands the extension.
function webConfig(raw, where) {
try {
return loadWebConfig(raw, where);
} catch (err) {
throw new DiscordError(err.message);
}
}
export function resolveToolRoots(binding, { dataRoot }) {
if (!binding.tools) return null;
const data = existsSync(dataRoot) ? realpathSync(dataRoot) : resolve(dataRoot);
@@ -326,7 +338,7 @@ export function resolveToolRoots(binding, { dataRoot }) {
if (!st.isDirectory()) throw new DiscordError(`tool root ${r.name} is not a directory: ${r.path}`);
const real = realpathSync(r.path);
if (real === data || real.startsWith(data + sep) || data.startsWith(real + sep)) throw new DiscordError(`tool root ${r.name} overlaps the data root: ${r.path}`);
return { name: r.name, path: real };
return { name: r.name, path: real, write: r.write };
});
return { roots, maxFileBytes: binding.tools.maxFileBytes, maxCallsPerTurn: binding.tools.maxCallsPerTurn };
return { roots, maxFileBytes: binding.tools.maxFileBytes, maxCallsPerTurn: binding.tools.maxCallsPerTurn, ...(binding.tools.web ? { web: { searxng: binding.tools.web.searxng, maxFetchBytes: binding.tools.web.maxFetchBytes } } : {}) };
}