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:
@@ -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 } } : {}) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user