feat(discord): SetSpark record client for the Discord Sage, fixed verbs against setspark-api, connector-verified approvals (#1509)
Row 25, parts 2a and 2b, against the shared-signals contract a5425a2. Model side: eight fixed verbs in the pi extension (record_list, record_get, record_create, record_update, resolve_id, open_approval_request, get_approval_request, create_document), each one HTTP call with arguments checked before any request. Writes carry an idempotency key <principal>:<message id>:<call index> and an audit context. The seat key is read from a 0600 file on every call and never cached, printed or journaled. Connector side: append-only approval ledger, Approve button and exact "approve" reply resolved by the connector against the required approvers, confirmation message posted as button evidence, bind and add_approval through the service under connector keys, retry of unknown entries on start. Evidence: node tests 162 pass, scripts/test-discord.sh 63/63. Review by rev-code-02, round 1 approved (#1509 comment 26467, tree 7872d8c5). Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -49,13 +49,15 @@ import { isAbsolute, join, sep } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { WEB_TOOL_NAMES, WEB_TOOL_DESCRIPTIONS, FETCH_MAX_TEXT_CHARS, WebRefusal, loadWebConfig, webFetch, webSearch } from "./web.mjs";
|
||||
import { GIT_TOOL_NAMES, RESERVE_TOOL_NAME, GIT_REFUSAL, GitRefusal, COMMIT_MESSAGE_MAX, COMMIT_PATHS_MAX, VAULT_PREFIXES, VAULT_REGISTRY, loadGitConfig, gitStatus, gitCommit, gitPull, gitPush, reserveId, withVaultLock } from "./git.mjs";
|
||||
import { SETSPARK_TOOL_NAMES, SETSPARK_TOOL_DESCRIPTIONS, SetsparkRefusal, loadSetsparkConfig, setsparkVerbs, renderSetspark, renderRefusal as renderSetsparkRefusal, setsparkDetails } from "./setspark.mjs";
|
||||
|
||||
export const TOOL_NAMES = Object.freeze(["list_dir", "read_file", "search"]);
|
||||
export const WRITE_TOOL_NAMES = Object.freeze(["write_file", "edit_file"]);
|
||||
// The tools a config enables, in the order pi's --tools list names them:
|
||||
// the reads always, the writes with a writable root, the web pair with a
|
||||
// web key, the git verbs with a root that carries a git key, reserve_id
|
||||
// with a root whose git key names the vault protocol.
|
||||
// with a root whose git key names the vault protocol, the SetSpark verbs
|
||||
// with a setspark key.
|
||||
export function enabledToolNames(config) {
|
||||
const names = [...TOOL_NAMES];
|
||||
const roots = config && Array.isArray(config.roots) ? config.roots : [];
|
||||
@@ -63,6 +65,7 @@ export function enabledToolNames(config) {
|
||||
if (config && config.web) names.push(...WEB_TOOL_NAMES);
|
||||
if (roots.some((r) => r.git)) names.push(...GIT_TOOL_NAMES);
|
||||
if (roots.some((r) => r.git && r.git.protocol === "vault")) names.push(RESERVE_TOOL_NAME);
|
||||
if (config && config.setspark) names.push(...SETSPARK_TOOL_NAMES);
|
||||
return names;
|
||||
}
|
||||
export const TOOLS_ENV = "MOSAIC_DISCORD_TOOLS";
|
||||
@@ -135,7 +138,7 @@ const ROOT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
export function loadToolsConfig(raw, where = TOOLS_ENV) {
|
||||
if (!isObject(raw)) throw new Error(`${where}: not an object`);
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!["roots", "maxFileBytes", "maxCallsPerTurn", "web"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
|
||||
if (!["roots", "maxFileBytes", "maxCallsPerTurn", "web", "setspark"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
|
||||
}
|
||||
if (!Array.isArray(raw.roots) || raw.roots.length === 0) throw new Error(`${where}: roots must be a non-empty array`);
|
||||
const roots = raw.roots.map((r, i) => {
|
||||
@@ -174,6 +177,7 @@ export function loadToolsConfig(raw, where = TOOLS_ENV) {
|
||||
maxFileBytes: int("maxFileBytes", 1024, 4 * 1024 * 1024),
|
||||
maxCallsPerTurn: int("maxCallsPerTurn", 1, 64),
|
||||
web: raw.web === undefined ? null : loadWebConfig(raw.web, `${where}.web`),
|
||||
setspark: raw.setspark === undefined ? null : loadSetsparkConfig(raw.setspark, `${where}.setspark`),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -543,7 +547,10 @@ function commitPaths(root, paths) {
|
||||
// The web tools are asynchronous; call() returns a promise for them and a
|
||||
// plain result for the file tools, and the extension awaits either. The
|
||||
// git verbs read `state.requester`, which the extension sets from each
|
||||
// message's envelope before the run starts.
|
||||
// message's envelope before the run starts; the SetSpark writes also read
|
||||
// the turn id and the author id from there, and the call index from the
|
||||
// budget counter, to form their idempotency keys.
|
||||
const SETSPARK_FNS = Object.fromEntries(SETSPARK_TOOL_NAMES.map((name) => [name, (config, params, state) => setsparkVerbs[name](config.setspark, params, state)]));
|
||||
const TOOL_FNS = Object.freeze({
|
||||
list_dir: listDir, read_file: readFile, search, write_file: writeFile, edit_file: editFile,
|
||||
web_fetch: (config, params) => webFetch(config.web, params),
|
||||
@@ -556,9 +563,12 @@ const TOOL_FNS = Object.freeze({
|
||||
git_pull: (config, { root }) => gitPull(gitRoot(config, root)),
|
||||
git_push: (config, { root }) => gitPush(gitRoot(config, root)),
|
||||
reserve_id: (config, { root, prefix, title }) => reserveId(gitRoot(config, root), { prefix, title }),
|
||||
...SETSPARK_FNS,
|
||||
});
|
||||
const SETSPARK_SET = new Set(SETSPARK_TOOL_NAMES);
|
||||
|
||||
function render(name, out) {
|
||||
if (SETSPARK_SET.has(name)) return renderSetspark(name, out);
|
||||
if (name === "list_dir") {
|
||||
const head = `${out.root}/${out.path}`.replace(/\/$/, "");
|
||||
const body = out.entries.map((e) => (e.type === "dir" ? `${e.name}/` : `${e.name} (${e.bytes} bytes)`)).join("\n");
|
||||
@@ -614,7 +624,7 @@ function render(name, out) {
|
||||
// is a bug and propagates.
|
||||
export function createToolSet(config) {
|
||||
let calls = 0;
|
||||
const state = { requester: null };
|
||||
const state = { requester: null, turnId: null, authorId: null, callIndex: 0 };
|
||||
const enabled = new Set(enabledToolNames(config));
|
||||
const call = (name, params) => {
|
||||
const fn = enabled.has(name) ? TOOL_FNS[name] : undefined;
|
||||
@@ -626,9 +636,11 @@ export function createToolSet(config) {
|
||||
return { ok: false, text: `refused: ${REFUSAL.BUDGET}`, details: { ...base, ok: false, reason: REFUSAL.BUDGET, ms: 0 } };
|
||||
}
|
||||
calls += 1;
|
||||
state.callIndex = calls;
|
||||
const done = (out) => {
|
||||
const bytes = name === "list_dir" || name === "search" || name === "web_search" || name.startsWith("git_") || name === "reserve_id" ? undefined : out.bytes;
|
||||
const extra = name === "web_fetch" ? { url: out.finalUrl, status: out.status }
|
||||
const bytes = name === "list_dir" || name === "search" || name === "web_search" || name.startsWith("git_") || name === "reserve_id" || SETSPARK_SET.has(name) ? undefined : out.bytes;
|
||||
const extra = SETSPARK_SET.has(name) ? setsparkDetails(name, out)
|
||||
: name === "web_fetch" ? { url: out.finalUrl, status: out.status }
|
||||
: name === "web_search" ? { hits: out.results.length }
|
||||
: name === "git_commit" ? { hash: out.hash, pushed: out.pushed, paths: out.paths, requester: out.requester }
|
||||
: name === "git_push" ? { hash: out.hash, pushed: true }
|
||||
@@ -639,6 +651,9 @@ export function createToolSet(config) {
|
||||
return { ok: true, text: render(name, out), details: { ...base, ok: true, ...extra, ...(bytes === undefined ? {} : { bytes }), ms: Date.now() - t0 } };
|
||||
};
|
||||
const refused = (err) => {
|
||||
if (err instanceof SetsparkRefusal) {
|
||||
return { ok: false, text: renderSetsparkRefusal(err), details: { ...base, ok: false, reason: err.reason, ...(err.code ? { code: err.code } : {}), ...(err.status ? { status: err.status } : {}), ms: Date.now() - t0 } };
|
||||
}
|
||||
if (!(err instanceof Refusal) && !(err instanceof WebRefusal) && !(err instanceof GitRefusal)) throw err;
|
||||
const reason = err instanceof GitRefusal ? err.message : err.reason;
|
||||
return { ok: false, text: `refused: ${reason}`, details: { ...base, ok: false, reason, ...(err.status ? { status: err.status } : {}), ms: Date.now() - t0 } };
|
||||
@@ -662,6 +677,17 @@ export function createToolSet(config) {
|
||||
setRequester(name) {
|
||||
state.requester = typeof name === "string" && name.length > 0 ? name : null;
|
||||
},
|
||||
// The running message's id and author id, from the envelope, for the
|
||||
// SetSpark write keys and the audit context. Unset between messages, so
|
||||
// a write outside a message is refused.
|
||||
setTurn({ requester = null, turnId = null, authorId = null } = {}) {
|
||||
state.requester = typeof requester === "string" && requester.length > 0 ? requester : null;
|
||||
state.turnId = typeof turnId === "string" && /^[0-9]{15,20}$/.test(turnId) ? turnId : null;
|
||||
state.authorId = typeof authorId === "string" && /^[0-9]{15,20}$/.test(authorId) ? authorId : null;
|
||||
},
|
||||
get turnId() {
|
||||
return state.turnId;
|
||||
},
|
||||
get requester() {
|
||||
return state.requester;
|
||||
},
|
||||
@@ -693,6 +719,7 @@ export const TOOL_DESCRIPTIONS = Object.freeze({
|
||||
snippet: "write_file creates or replaces a text file under a writable root",
|
||||
},
|
||||
...WEB_TOOL_DESCRIPTIONS,
|
||||
...SETSPARK_TOOL_DESCRIPTIONS,
|
||||
edit_file: {
|
||||
label: "Edit file",
|
||||
description: "Replace one exact string that occurs exactly once in a text file under a root that allows writes. Read the file first so the old text is exact. The file is not committed: tell the user which file changed.",
|
||||
|
||||
Reference in New Issue
Block a user