Files
stack/packages/discord/src/setspark.mjs
T
jason.woltjeandClaude Opus 5.5 20ea5a0b64 fix(discord): row 25 approvers are user names, never Discord ids in tool text (#1509)
Jason's live check after the 20:58Z restart posted no Approve button. The
Discord Sage wrote DEC-009's required_approvers as names; the SetSpark
service stores approvers as discord:<id> and accepted the names, and the
connector correctly refused the approval request ("bad approver id").

- binding.mjs derives setspark.approvers from the binding's users (name to
  id); a binding-set approvers key and duplicate names are refused. With
  setspark set, a user id or name change refuses the reload (pi's approvers
  are fixed at start).
- setspark.mjs: record_create/record_update map required_approvers names to
  discord:<id> and refuse unknown names, ids, duplicates and non-lists
  before any request, without echoing the value. hideIds turns mentions,
  discord: values and standalone 17-20 digit runs into the user's name or
  "unknown user" in every verb's text and refusal, including the service
  message and code before they are cut. The connector's approval request
  keeps the bare ids.
- tests: boundary test over nested, keyed, numeric, mention and cut ids;
  a local contract fixture from create through validateRequest, with the
  old name-stored shape still refused.

Rocko: R1 revise, R2 revise, R3 approve (81379830..., report da75219f...).
Suites on an index export: 24/90/43/17/14/15/63/18; Discord node tests 173/173.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:30:11 -05:00

648 lines
34 KiB
JavaScript

// SetSpark record client for the Discord Sage (row 25, Jason's decision
// 2026-09-18: record authority moves from the Git vault to NocoDB plus
// Outline behind one write service, setspark-api). This module is the
// contract-independent half: the `setspark` key of the tools config, the
// seat's API key read from a 0600 file on every call, the idempotency key,
// and one HTTP core that every verb uses. The verbs themselves (paths,
// bodies, codes) are added when stack/api/openapi.json lands on
// shared-signals main; nothing here guesses a path.
//
// The fence:
// - one https base url from the binding, no path, query, user or password;
// every request goes to `${baseUrl}${path}` with a fixed path per verb
// - the key file is checked at load (regular, not a symlink, 0600,
// non-empty) and read on each call, so a rotated key takes effect
// without a restart; the key is never cached, printed or journaled
// - JSON in, JSON out; no redirects; the whole call ends within timeoutMs
// - the response is capped; a body over the cap is a refusal
// - an error body is `{code, message}` plus `current_revision` and
// `changed_fields` on 409; the refusal carries `code` and the fixed
// fields, and the server's message is data cut at MESSAGE_MAX_CHARS
// - the idempotency key is `<principal>:<turn id>:<call index>`; the turn
// id is the Discord message id from the envelope, the call index the
// tool set's counter for that turn; a call outside a turn is refused
//
// Refusals are SetsparkRefusal with a fixed `reason` from SETSPARK_REFUSAL
// and, when the server answered, `status` and `code`.
import { lstatSync, readFileSync } from "node:fs";
import { request as httpsRequest } from "node:https";
import { request as httpRequest } from "node:http";
export const SETSPARK_DEFAULTS = Object.freeze({ timeoutMs: 15000, maxResponseBytes: 262144 });
export const USER_AGENT = "mosaic-discord-sage/1 (Mosaic Stack Discord connector; setspark client)";
export const MESSAGE_MAX_CHARS = 400;
export const KEY_MAX_BYTES = 4096;
export const IDEMPOTENCY_HEADER = "idempotency-key";
export const PRINCIPAL = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const SNOWFLAKE = /^[0-9]{17,20}$/;
const KEY_SHAPE = /^[!-~]{16,512}$/; // printable ascii, no spaces
export const SETSPARK_REFUSAL = Object.freeze({
NO_TURN: "no turn is running, so no idempotency key can be formed",
KEY_FILE: "the api key file is missing, not private or empty",
KEY_SHAPE: "the api key file does not hold one key",
TIMEOUT: "no complete response from the record service within the time limit",
NETWORK: "the record service could not be reached",
TOO_BIG: "the record service answer is over the size cap",
NOT_JSON: "the record service answered with something other than json",
CONFLICT: "the record changed since it was read (stale revision)",
REPLAY: "the same idempotency key was already used with a different request",
REJECTED: "the record service refused the request",
UNAUTHORIZED: "the record service did not accept the seat's key",
NOT_FOUND: "no such record",
SERVER: "the record service failed",
BAD_ARGS: "the call's arguments are not valid",
});
export class SetsparkRefusal extends Error {
constructor(reason, extra = {}) {
super(reason);
this.reason = reason;
Object.assign(this, extra);
}
}
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
// The `setspark` key of the tools config. Fixed at pi start like the roots.
export function loadSetsparkConfig(raw, where = "setspark") {
if (!isObject(raw)) throw new Error(`${where}: not an object`);
for (const k of Object.keys(raw)) {
if (!["baseUrl", "keyFile", "principal", "timeoutMs", "approvers"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
}
if (typeof raw.baseUrl !== "string") throw new Error(`${where}.baseUrl: must be a url string`);
let u;
try {
u = new URL(raw.baseUrl);
} catch {
throw new Error(`${where}.baseUrl: not a valid url`);
}
const loopback = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "[::1]";
if (u.username || u.password || u.search || u.hash || (u.pathname !== "/" && u.pathname !== "")) throw new Error(`${where}.baseUrl: must be a bare origin with no path`);
if (!(u.protocol === "https:" || (u.protocol === "http:" && loopback))) throw new Error(`${where}.baseUrl: must be https, or http on loopback`);
if (typeof raw.keyFile !== "string" || !raw.keyFile.startsWith("/") || raw.keyFile.includes("\0")) throw new Error(`${where}.keyFile: must be an absolute path`);
checkPrivateFile(raw.keyFile, `${where}.keyFile`);
if (typeof raw.principal !== "string" || !PRINCIPAL.test(raw.principal)) throw new Error(`${where}.principal: must match ${PRINCIPAL}`);
const timeoutMs = raw.timeoutMs === undefined ? SETSPARK_DEFAULTS.timeoutMs : raw.timeoutMs;
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 60000) throw new Error(`${where}.timeoutMs: must be an integer between 1000 and 60000`);
const approvers = loadApprovers(raw.approvers === undefined ? {} : raw.approvers, `${where}.approvers`);
return Object.freeze({ baseUrl: u.origin, keyFile: raw.keyFile, principal: raw.principal, timeoutMs, maxResponseBytes: SETSPARK_DEFAULTS.maxResponseBytes, approvers });
}
// Lower-case user name to Discord user id. The binding derives it from its
// users; the verbs turn a decision's required_approvers from names into
// `discord:<id>` and back, so the model never handles an id.
function loadApprovers(raw, where) {
if (!isObject(raw) || Object.keys(raw).length > 64) throw new Error(`${where}: must be an object of at most 64 names`);
for (const [name, id] of Object.entries(raw)) {
if (name.length === 0 || name.length > 64 || name !== name.trim().toLowerCase() || /[\u0000-\u001f\u007f]/.test(name)) throw new Error(`${where}: ${JSON.stringify(name.slice(0, 64))} must be a trimmed lower-case name`);
if (typeof id !== "string" || !SNOWFLAKE.test(id)) throw new Error(`${where}.${name}: must be a Discord user id`);
}
if (new Set(Object.values(raw)).size !== Object.keys(raw).length) throw new Error(`${where}: one Discord user id under two names`);
return Object.freeze({ ...raw });
}
function checkPrivateFile(path, what) {
let st;
try {
st = lstatSync(path);
} catch {
throw new Error(`${what}: not found: ${path}`);
}
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink: ${path}`);
if (!st.isFile()) throw new Error(`${what}: not a regular file: ${path}`);
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600: ${path}`);
if (st.size === 0) throw new Error(`${what}: is empty: ${path}`);
}
// Read the key for one call. The file is re-checked every time, so a key
// that stops being private stops being used. The value never leaves this
// module except in the Authorization header.
export function readKey(config) {
try {
checkPrivateFile(config.keyFile, "keyFile");
} catch {
throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_FILE);
}
let st;
try {
st = lstatSync(config.keyFile);
} catch {
throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_FILE);
}
if (st.size > KEY_MAX_BYTES) throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_SHAPE);
const text = readFileSync(config.keyFile, "utf8");
// Either one bare key line, or the mint's own JSON output
// ({"key_id", "key", "note"}) stored as is; only "key" is used.
let key = text;
if (text.trimStart().startsWith("{")) {
try {
const obj = JSON.parse(text);
key = isObject(obj) && typeof obj.key === "string" ? obj.key : "";
} catch {
throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_SHAPE);
}
}
const lines = key.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
if (lines.length !== 1 || !KEY_SHAPE.test(lines[0])) throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_SHAPE);
return lines[0];
}
// `<principal>:<turn id>:<call index>`. The turn id is the Discord message
// id the connector wrote into the envelope; the call index counts this
// turn's tool calls from 1. A replay of the same key with the same body
// returns the stored result; a different body is refused by the service.
export function idempotencyKey(principal, turnId, callIndex) {
if (typeof principal !== "string" || !PRINCIPAL.test(principal)) throw new Error("idempotencyKey: bad principal");
if (typeof turnId !== "string" || !SNOWFLAKE.test(turnId)) throw new SetsparkRefusal(SETSPARK_REFUSAL.NO_TURN);
if (!Number.isInteger(callIndex) || callIndex < 1) throw new Error("idempotencyKey: call index must be a positive integer");
return `${principal}:${turnId}:${callIndex}`;
}
// A connector-side key for work that is not a model tool call: a message
// id or interaction id and a fixed step name.
export function connectorKey(principal, eventId, step) {
if (typeof principal !== "string" || !PRINCIPAL.test(principal)) throw new Error("connectorKey: bad principal");
if (typeof eventId !== "string" || !SNOWFLAKE.test(eventId)) throw new Error("connectorKey: bad event id");
if (typeof step !== "string" || !/^[a-z][a-z-]{0,31}$/.test(step)) throw new Error("connectorKey: bad step");
return `${principal}:${eventId}:${step}`;
}
const defaultDeps = Object.freeze({ httpsRequest, httpRequest });
function cutMessage(v) {
return typeof v === "string" ? v.replace(/\s+/g, " ").trim().slice(0, MESSAGE_MAX_CHARS) : "";
}
function reasonFor(status, code) {
if (status === 401 || status === 403) return SETSPARK_REFUSAL.UNAUTHORIZED;
if (status === 404) return SETSPARK_REFUSAL.NOT_FOUND;
if (status === 409) return SETSPARK_REFUSAL.CONFLICT;
if (status === 422 && code === "idempotency_mismatch") return SETSPARK_REFUSAL.REPLAY;
if (status >= 500) return SETSPARK_REFUSAL.SERVER;
return SETSPARK_REFUSAL.REJECTED;
}
// One request. Resolves the parsed JSON body of a 2xx. Rejects with
// SetsparkRefusal for everything else (a bad argument is a plain Error: a
// bug, not a refusal). The key is read here, per call, and goes into the
// header and nowhere else; `body` is sent as JSON.
export async function callApi(config, { method, path, body, idempotencyKey: key = null }, deps = defaultDeps) {
if (!["GET", "POST", "PATCH", "PUT"].includes(method)) throw new Error(`callApi: bad method ${method}`);
if (typeof path !== "string" || !path.startsWith("/") || path.includes("..") || /\s/.test(path)) throw new Error("callApi: bad path");
if (method === "GET" && body !== undefined) throw new Error("callApi: GET takes no body");
if (method !== "GET" && key === null) throw new Error("callApi: a write needs an idempotency key");
const secret = readKey(config);
const u = new URL(`${config.baseUrl}${path}`);
return new Promise((resolve, reject) => {
const mod = u.protocol === "https:" ? deps.httpsRequest : deps.httpRequest;
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body), "utf8");
const headers = {
host: u.host,
"user-agent": USER_AGENT,
accept: "application/json",
"accept-encoding": "identity",
authorization: `Bearer ${secret}`,
};
if (key !== null) headers[IDEMPOTENCY_HEADER] = key;
if (payload) {
headers["content-type"] = "application/json";
headers["content-length"] = String(payload.length);
}
const opts = {
method,
hostname: u.hostname.replace(/^\[|\]$/g, ""),
port: u.port || (u.protocol === "https:" ? 443 : 80),
path: `${u.pathname}${u.search}`,
servername: u.protocol === "https:" ? u.hostname.replace(/^\[|\]$/g, "") : undefined,
headers,
};
let done = false;
const finish = (fn, v) => {
if (done) return;
done = true;
clearTimeout(timer);
fn(v);
};
const req = mod(opts);
const timer = setTimeout(() => {
req.destroy();
finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.TIMEOUT));
}, config.timeoutMs);
req.on("error", () => finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.NETWORK)));
req.on("response", (res) => {
const chunks = [];
let size = 0;
res.on("data", (c) => {
if (done) return;
size += c.length;
if (size > config.maxResponseBytes) {
res.destroy();
req.destroy();
finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.TOO_BIG, { status: res.statusCode }));
return;
}
chunks.push(c);
});
res.on("error", () => finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.NETWORK)));
res.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
let json = null;
if (text.trim().length > 0) {
try {
json = JSON.parse(text);
} catch {
json = undefined;
}
}
const status = res.statusCode;
if (status >= 200 && status < 300) {
if (json === undefined) return finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.NOT_JSON, { status }));
return finish(resolve, { status, body: json });
}
const err = isObject(json) ? json : {};
const code = typeof err.code === "string" ? hideIds(config, err.code).slice(0, 64) : null;
const extra = { status, code, message: cutMessage(typeof err.message === "string" ? hideIds(config, err.message) : err.message) };
if (status === 409) {
if (err.current_revision !== undefined) extra.currentRevision = err.current_revision;
if (Array.isArray(err.changed_fields)) extra.changedFields = err.changed_fields.filter((f) => typeof f === "string").slice(0, 32);
}
finish(reject, new SetsparkRefusal(reasonFor(status, code), extra));
});
});
if (payload) req.write(payload);
req.end();
});
}
// How a refusal reads to the model and in the turn record: the fixed
// reason, the code, and on 409 the fields that changed. Never the raw body,
// and never a Discord user id (hideIds).
export function renderRefusal(err, config = null) {
let s = `refused: ${err.reason}`;
if (err.code) s += ` (code ${err.code})`;
if (err.currentRevision !== undefined) s += `; current revision ${err.currentRevision}`;
if (Array.isArray(err.changedFields) && err.changedFields.length > 0) s += `; changed: ${err.changedFields.join(", ")}`;
if (err.message && err.message !== err.reason) s += `\n${err.message}`;
return hideIds(config, s);
}
// --- the verbs (contract: shared-signals stack/api/openapi.json at a5425a2) ---
//
// Fixed verbs, one HTTP call each, registered by the extension when the
// tools config carries a setspark key. Names are prefixed so they cannot be
// confused with the file tools. Every write forms its idempotency key from
// the running turn and the tool set's call index, and carries the asserted
// requester in `context` (recorded by the service next to the verified key,
// never used for authorization). Output is rendered to fixed lines and
// capped; a record is shown as `key: value` lines.
export const SETSPARK_TOOL_NAMES = Object.freeze([
"record_list", "record_get", "record_create", "record_update", "resolve_id",
"open_approval_request", "get_approval_request", "create_document",
]);
export const RECORD_TYPES = Object.freeze(["business", "project", "work_item", "decision", "reference_note"]);
export const LIST_DEFAULT = 20;
export const LIST_MAX = 50;
export const FILTERS_MAX = 4;
export const RECORD_MAX_BYTES = 32768;
export const RENDER_MAX_CHARS = 6000;
export const DOCUMENT_MAX_CHARS = 20000;
export const RECORD_ID = /^[A-Z]{2,5}-[0-9]{1,8}$/;
const PROP_NAME = /^[a-z][a-z0-9_]{0,31}$/;
const HEX = /^[a-f0-9]{16,128}$/;
const HIDDEN_PROPS = new Set(["accepted_snapshot", "props", "import_pending"]);
function bad(what) {
return new SetsparkRefusal(SETSPARK_REFUSAL.BAD_ARGS, { message: what });
}
function needString(params, name, max, re = null) {
const v = params[name];
if (typeof v !== "string" || v.length === 0 || v.length > max || (re && !re.test(v))) throw bad(`${name} must be a string${re ? ` matching ${re}` : ""} of at most ${max} characters`);
return v;
}
function needId(params, name = "id") {
return needString(params, name, 16, RECORD_ID);
}
function needType(params) {
const t = params.record_type;
if (!RECORD_TYPES.includes(t)) throw bad(`record_type must be one of ${RECORD_TYPES.join(", ")}`);
return t;
}
function needObject(params, name) {
const v = params[name];
if (!isObject(v)) throw bad(`${name} must be an object`);
const size = Buffer.byteLength(JSON.stringify(v), "utf8");
if (size > RECORD_MAX_BYTES) throw bad(`${name} is over ${RECORD_MAX_BYTES} bytes`);
for (const k of Object.keys(v)) if (!PROP_NAME.test(k)) throw bad(`${name} has a property name that is not allowed: ${hideIds(null, k).slice(0, 32)}`);
return v;
}
function needInt(params, name, min, max) {
const v = params[name];
if (!Number.isInteger(v) || v < min || v > max) throw bad(`${name} must be an integer between ${min} and ${max}`);
return v;
}
// The write key and the asserted requester for this call. The turn id and
// call index come from the tool set's state; without a running turn the
// write is refused before any request is formed.
function writeParts(config, state) {
const key = idempotencyKey(config.principal, state && state.turnId, state && state.callIndex);
const context = { turn_id: state.turnId, client_version: USER_AGENT };
if (state.requester || state.authorId) context.requester = { ...(state.authorId ? { id: state.authorId } : {}), ...(state.requester ? { name: state.requester } : {}) };
return { key, context };
}
async function write(config, state, method, path, body, deps) {
const { key, context } = writeParts(config, state);
const r = await callApi(config, { method, path, body: { ...body, context }, idempotencyKey: key }, deps);
return { key, status: r.status, body: r.body };
}
function record(body) {
return isObject(body) ? body : {};
}
// A decision's required_approvers as the model gives them: names of the
// binding's users. Each becomes `discord:<id>`; anything else is refused
// before a request, since the service checks an approval's author against
// these values and a stored name could never be approved.
function approverIds(config, v) {
const map = config.approvers || {};
const names = Object.keys(map).join(", ") || "(none)";
if (!Array.isArray(v) || v.length === 0 || v.length > 16) throw bad(`required_approvers must be a list of 1 to 16 names; use names from: ${names}`);
// The refusal names the entry's position, never its value: a model that
// wrote an id must not get it echoed back.
const ids = v.map((a, i) => {
const k = typeof a === "string" ? a.trim().replace(/^@/, "").toLowerCase() : "";
if (k.length > 0 && Object.hasOwn(map, k)) return `discord:${map[k]}`;
throw bad(`required_approvers: entry ${i + 1} is not a known user name; use names from: ${names}`);
});
if (new Set(ids).size !== ids.length) throw bad("required_approvers names the same person twice");
return ids;
}
function withApprovers(config, props) {
return props.required_approvers === undefined ? props : { ...props, required_approvers: approverIds(config, props.required_approvers) };
}
// The way back, for everything the model reads: a Discord user id never
// appears in SetSpark tool text. A mention (<@id>), a `discord:` value or a
// 17 to 20 digit run standing alone becomes the binding's name for that id,
// or "unknown user". A digit run inside a longer token (a hex digest, SS-027)
// is left alone. The connector's own request keeps the bare ids; only the
// rendered text and refusals go through this.
const DISCORD_ID_TEXT = /<@!?([0-9]{17,20})>|(?<![0-9A-Za-z])(?:discord:)?([0-9]{17,20})(?![0-9A-Za-z])/g;
export function hideIds(config, text) {
const byId = new Map(Object.entries((config && config.approvers) || {}).map(([n, id]) => [id, n]));
return String(text).replace(DISCORD_ID_TEXT, (_, a, b) => byId.get(a || b) || "unknown user");
}
// The same rule over a service value before it is rendered, so a field cut
// at its length limit cannot leave part of an id. Keys and big integers too.
function hideDeep(config, v) {
if (typeof v === "string") return hideIds(config, v);
if (typeof v === "number") return /^[0-9]{17,20}$/.test(String(v)) ? hideIds(config, String(v)) : v;
if (Array.isArray(v)) return v.map((x) => hideDeep(config, x));
if (isObject(v)) return Object.fromEntries(Object.entries(v).map(([k, x]) => [hideIds(config, k), hideDeep(config, x)]));
return v;
}
export const setsparkVerbs = Object.freeze({
async record_list(config, params, state, deps) {
const type = needType(params);
const limit = params.limit === undefined ? LIST_DEFAULT : needInt(params, "limit", 1, LIST_MAX);
const offset = params.offset === undefined ? 0 : needInt(params, "offset", 0, 100000);
const q = new URLSearchParams({ record_type: type, limit: String(limit), offset: String(offset) });
if (params.filters !== undefined) {
if (!isObject(params.filters) || Object.keys(params.filters).length > FILTERS_MAX) throw bad(`filters must be an object of at most ${FILTERS_MAX} properties`);
for (const [k, v] of Object.entries(params.filters)) {
if (!PROP_NAME.test(k) || ["record_type", "type", "limit", "offset"].includes(k)) throw bad(`filters: property name not allowed: ${hideIds(null, k).slice(0, 32)}`);
if (typeof v !== "string" || v.length === 0 || v.length > 200) throw bad(`filters.${k} must be a short string`);
q.set(k, v);
}
}
const r = await callApi(config, { method: "GET", path: `/v1/records?${q}` }, deps);
const items = Array.isArray(record(r.body).items) ? record(r.body).items.filter(isObject) : [];
return { verb: "record_list", recordType: type, items, limit, offset };
},
async record_get(config, params, state, deps) {
const id = needId(params);
const r = await callApi(config, { method: "GET", path: `/v1/records/${id}` }, deps);
return { verb: "record_get", id, record: record(r.body) };
},
async record_create(config, params, state, deps) {
const type = needType(params);
const rec = withApprovers(config, needObject(params, "record"));
if (typeof rec.title !== "string" || rec.title.trim().length === 0) throw bad("record.title is required");
const r = await write(config, state, "POST", "/v1/records", { record_type: type, record: rec }, deps);
return { verb: "record_create", key: r.key, recordType: type, record: record(r.body) };
},
async record_update(config, params, state, deps) {
const id = needId(params);
const revision = needInt(params, "revision", 1, 1000000000);
const fields = withApprovers(config, needObject(params, "fields"));
if (Object.keys(fields).length === 0) throw bad("fields must name at least one property");
const r = await write(config, state, "PATCH", `/v1/records/${id}`, { revision, fields }, deps);
return { verb: "record_update", key: r.key, id, from: revision, record: record(r.body) };
},
async resolve_id(config, params, state, deps) {
const query = needString(params, "query", 200).trim();
if (query.length === 0) throw bad("query must not be blank");
const r = await callApi(config, { method: "GET", path: `/v1/resolve?${new URLSearchParams({ q: query })}` }, deps);
const matches = Array.isArray(record(r.body).matches) ? record(r.body).matches.filter(isObject).slice(0, LIST_MAX) : [];
return { verb: "resolve_id", query, matches };
},
async open_approval_request(config, params, state, deps) {
const decisionId = needId(params, "decision_id");
const version = needInt(params, "proposal_version", 1, 1000000);
const digest = needString(params, "proposal_digest", 128, HEX);
const r = await write(config, state, "POST", "/v1/approval-requests", { decision_id: decisionId, proposal_version: version, proposal_digest: digest }, deps);
const b = record(r.body);
const approvers = Array.isArray(b.required_approvers) ? b.required_approvers.filter((a) => typeof a === "string") : [];
return {
verb: "open_approval_request", key: r.key, view: b,
// what the connector needs to post the approval message
request: { requestId: String(b.request_id), decisionId: String(b.decision_id ?? decisionId), proposalVersion: b.proposal_version ?? version, digest: String(b.proposal_digest ?? digest), approvers },
};
},
async get_approval_request(config, params, state, deps) {
const id = needInt(params, "request_id", 1, 1000000000);
const r = await callApi(config, { method: "GET", path: `/v1/approval-requests/${id}` }, deps);
return { verb: "get_approval_request", requestId: String(id), view: record(r.body) };
},
async create_document(config, params, state, deps) {
const collection = needString(params, "collection", 64, /^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$/);
const title = needString(params, "title", 200).trim();
const text = params.text === undefined ? "" : params.text;
if (typeof text !== "string" || text.length > DOCUMENT_MAX_CHARS) throw bad(`text must be a string of at most ${DOCUMENT_MAX_CHARS} characters`);
const body = { collection, title, text };
if (params.source !== undefined) body.source = needString(params, "source", 500, /^[^\p{Zl}\p{Zp}\p{Cc}]+$/u);
const r = await write(config, state, "POST", "/v1/documents", body, deps);
return { verb: "create_document", key: r.key, document: record(r.body) };
},
});
// --- rendering ---
function scalar(v) {
if (v === null || v === undefined) return "";
if (typeof v === "string") return v.replace(/\s+/g, " ").trim();
if (typeof v === "number" || typeof v === "boolean") return String(v);
if (Array.isArray(v)) return v.map(scalar).filter((s) => s.length > 0).join(", ");
return JSON.stringify(v);
}
function cap(s) {
return s.length > RENDER_MAX_CHARS ? `${s.slice(0, RENDER_MAX_CHARS)}\n… cut at ${RENDER_MAX_CHARS} characters` : s;
}
// A record as `key: value` lines: id, type and revision first, the title,
// then the rest in the service's order, then the body last. The accepted
// snapshot and the round-trip props are not shown.
export function renderRecord(rec) {
const head = `${scalar(rec.id) || "(no id)"} (${scalar(rec.record_type) || "record"}) revision ${scalar(rec.revision) || "?"}`;
const lines = [head];
if (rec.title !== undefined) lines.push(`title: ${scalar(rec.title)}`);
let body = null;
for (const [k, v] of Object.entries(rec)) {
if (["id", "record_type", "revision", "title"].includes(k) || HIDDEN_PROPS.has(k)) continue;
if (k === "body" || k === "proposal_body") {
body = { k, v };
continue;
}
const s = scalar(v);
if (s.length > 0) lines.push(`${k}: ${s.slice(0, 500)}`);
}
if (body && typeof body.v === "string" && body.v.trim().length > 0) lines.push(`${body.k}:\n${body.v.trim()}`);
return cap(lines.join("\n"));
}
function summary(rec) {
const bits = [scalar(rec.title)];
for (const k of ["status", "priority", "kind"]) if (rec[k] !== undefined) bits.push(scalar(rec[k]));
return `${scalar(rec.id)}: ${bits.filter((b) => b.length > 0).join(" | ")} (rev ${scalar(rec.revision) || "?"})`;
}
function renderView(v) {
const approvals = Array.isArray(v.approvals) ? v.approvals.filter(isObject) : [];
const who = Array.isArray(v.required_approvers) ? v.required_approvers.length : "?";
return `request ${scalar(v.request_id)} for ${scalar(v.decision_id)} version ${scalar(v.proposal_version)}: ${scalar(v.state) || "?"}; ${approvals.length} of ${who} approvals recorded${v.message_id ? "; bound to a Discord message" : "; no message bound yet"}`;
}
// Tool text for the model. `out` is hidden (hideDeep) before any field is
// cut, and the whole text once more after.
export function renderSetspark(name, out, config = null) {
return hideIds(config, renderOut(name, hideDeep(config, out)));
}
function renderOut(name, out) {
if (name === "record_list") {
const body = out.items.map(summary).join("\n");
return `${out.items.length} ${out.recordType} record(s) from offset ${out.offset} (limit ${out.limit})\n${body || "(none)"}`;
}
if (name === "record_get") return renderRecord(out.record);
if (name === "record_create") return `created ${scalar(out.record.id)} (${out.recordType}) revision ${scalar(out.record.revision)}; the record is live in SetSpark, no file and no commit`;
if (name === "record_update") return `updated ${out.id} from revision ${out.from} to ${scalar(out.record.revision)}; the change is live in SetSpark`;
if (name === "resolve_id") {
const body = out.matches.map((m) => `${scalar(m.id)} (${scalar(m.record_type)}): ${scalar(m.title)}${m.exact ? " [exact]" : ""}`).join("\n");
return `${out.matches.length} match(es) for ${JSON.stringify(out.query)}\n${body || "(none)"}`;
}
if (name === "open_approval_request") return `${renderView(out.view)}. The approval message with its Approve button is posted for you after this reply; do not claim any approval yourself.`;
if (name === "get_approval_request") return renderView(out.view);
if (name === "create_document") return `created document ${scalar(out.document.title || out.document.id)}${out.document.url ? ` at ${scalar(out.document.url)}` : ""}`;
throw new Error(`renderSetspark: unknown verb ${name}`);
}
// What the turn record keeps about a call, beyond the tool set's base.
export function setsparkDetails(name, out) {
const d = { verb: name };
if (out.key) d.key = out.key;
if (out.record && out.record.id !== undefined) d.id = String(out.record.id);
if (out.record && out.record.revision !== undefined) d.revision = out.record.revision;
if (out.request) d.request = out.request;
if (out.view && out.view.request_id !== undefined) d.requestId = String(out.view.request_id);
if (name === "record_list") d.count = out.items.length;
if (name === "resolve_id") d.count = out.matches.length;
return d;
}
export const SETSPARK_TOOL_DESCRIPTIONS = Object.freeze({
record_list: {
label: "List records",
description: `List SetSpark records of one type (${RECORD_TYPES.join(", ")}), optionally filtered by exact property values, up to ${LIST_MAX} at a time. Read only.`,
snippet: "record_list lists SetSpark records of one type",
},
record_get: {
label: "Get record",
description: "Read one SetSpark record by id, with its revision. Read a record before updating it, and cite the id in your reply.",
snippet: "record_get reads one SetSpark record by id",
},
record_create: {
label: "Create record",
description: "Create one SetSpark record; the service allocates the id. Give record_type and the record's properties (title required). A decision's required_approvers is a list of user names (such as jason); the connector turns each into that user's Discord identity and refuses a name it does not know. Only when the user asked for a record to be created.",
snippet: "record_create creates one SetSpark record",
},
record_update: {
label: "Update record",
description: "Change named properties of one SetSpark record. Carry the revision from record_get; a stale revision is refused with what changed, then read again and retry.",
snippet: "record_update changes properties of one SetSpark record by revision",
},
resolve_id: {
label: "Resolve id",
description: "Find SetSpark records by exact id or a title substring.",
snippet: "resolve_id finds SetSpark records by id or title",
},
open_approval_request: {
label: "Open approval request",
description: "Ask the required approvers to approve a Proposed decision at its current version and digest (from record_get). The approval message and button are posted by the connector after your reply; you never record an approval yourself.",
snippet: "open_approval_request opens the approval of a Proposed decision",
},
get_approval_request: {
label: "Get approval request",
description: "Read the state of an approval request: open, approved or closed, and who has approved.",
snippet: "get_approval_request reads an approval request's state",
},
create_document: {
label: "Create document",
description: `Create one prose document in an allowed Outline collection with a title and Markdown text (at most ${DOCUMENT_MAX_CHARS} characters). Only when the user asked for a document.`,
snippet: "create_document creates one prose document in Outline",
},
});
// --- the connector's own client (bind and approvals) ---
// Built by the cli from the same config and passed to the connector like
// rest. Request ids are strings on the connector side and integers on the
// wire.
export function createSetsparkApi(config, deps = defaultDeps) {
const num = (id) => {
const n = Number(id);
if (!Number.isSafeInteger(n) || n < 1) throw new Error("setspark api: bad request id");
return n;
};
return {
async bindApprovalMessage({ requestId, messageId, channelId, idempotencyKey: key }) {
const r = await callApi(config, { method: "POST", path: `/v1/approval-requests/${num(requestId)}/message`, body: { message_id: messageId, channel_id: channelId, context: { client_version: USER_AGENT } }, idempotencyKey: key }, deps);
return r.body;
},
async addApproval({ requestId, kind, authorId, messageId, boundMessageId, sourceUrl, statement, idempotencyKey: key }) {
if (kind !== "button" && kind !== "reply") throw new Error("setspark api: kind must be button or reply");
const body = { request_id: num(requestId), kind, author_id: authorId, message_id: messageId, bound_message_id: boundMessageId, source_url: sourceUrl, statement, context: { source_url: sourceUrl, client_version: USER_AGENT } };
const r = await callApi(config, { method: "POST", path: "/v1/approvals", body, idempotencyKey: key }, deps);
return r.body;
},
async getApprovalRequest(requestId) {
const r = await callApi(config, { method: "GET", path: `/v1/approval-requests/${num(requestId)}` }, deps);
return r.body;
},
};
}