@coda-console/protocol 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent.mjs +4 -50
- package/docs/agent-protocol.md +26 -12
- package/package.json +1 -1
- package/schemas/agent/display.json +13 -0
- package/validate.mjs +56 -10
package/agent.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Coda's extensions beside ACP: methods select schemas from a table; metadata selects a schema by reference.
|
|
2
2
|
// ACP itself stays with its SDK. These checks neither normalize values nor decide anything about a live session.
|
|
3
3
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
4
|
-
import { newAjv,
|
|
4
|
+
import { newAjv, explain, schemaAt } from './validate.mjs';
|
|
5
5
|
import { jsonFits, valueKeywords } from './workflow/keywords.mjs';
|
|
6
6
|
|
|
7
7
|
const dir = new URL('./schemas/agent/', import.meta.url), base = 'https://coda.pub/schemas/agent/v1/';
|
|
@@ -9,63 +9,17 @@ const read = file => JSON.parse(readFileSync(new URL(file, dir), 'utf8'));
|
|
|
9
9
|
export const AGENT_METHODS = read('messages.json').methods;
|
|
10
10
|
export const AGENT_SCHEMAS = Object.fromEntries(readdirSync(dir).filter(file => file.endsWith('.json') && file !== 'messages.json')
|
|
11
11
|
.sort().map(file => [file, read(file)]));
|
|
12
|
-
const schemas = Object.values(AGENT_SCHEMAS), ajv = newAjv();
|
|
13
12
|
// Wire objects have own keys, including names also found on Object.prototype.
|
|
14
|
-
|
|
13
|
+
const schemas = Object.values(AGENT_SCHEMAS), ajv = newAjv({ ownProperties: true });
|
|
15
14
|
valueKeywords(ajv, {});
|
|
16
15
|
ajv.addFormat('date-parse', value => Number.isFinite(Date.parse(value)));
|
|
17
16
|
for (const schema of schemas) ajv.addSchema(schema);
|
|
18
17
|
|
|
19
|
-
const unescape = step => step.replaceAll('~1', '/').replaceAll('~0', '~');
|
|
20
|
-
const escape = step => step.replaceAll('~', '~0').replaceAll('/', '~1');
|
|
21
|
-
const place = (path, where) => path.split('/').slice(1).map(unescape).reduce((at, step) =>
|
|
22
|
-
/^\d+$/.test(step) ? `${at}[${step}]` : /^[A-Za-z_$][\w$]*$/.test(step) ? `${at}.${step}` : `${at}「${step}」`, where);
|
|
23
|
-
function schemaAt(ref) {
|
|
24
|
-
if (typeof ref !== 'string') return undefined;
|
|
25
|
-
const [file, fragment = '', extra] = ref.split('#');
|
|
26
|
-
if (extra !== undefined || !Object.hasOwn(AGENT_SCHEMAS, file)) return undefined;
|
|
27
|
-
let pointer;
|
|
28
|
-
try { pointer = decodeURIComponent(fragment); } catch { return undefined; }
|
|
29
|
-
if (pointer && !pointer.startsWith('/')) return undefined;
|
|
30
|
-
let node = AGENT_SCHEMAS[file];
|
|
31
|
-
for (const token of pointer.split('/').slice(1)) {
|
|
32
|
-
if (/~(?:[^01]|$)/.test(token)) return undefined;
|
|
33
|
-
const key = unescape(token);
|
|
34
|
-
node = node && typeof node === 'object' && Object.hasOwn(node, key) ? node[key] : undefined;
|
|
35
|
-
}
|
|
36
|
-
return typeof node === 'boolean' || node && typeof node === 'object' && !Array.isArray(node) ? node : undefined;
|
|
37
|
-
}
|
|
38
|
-
// Resolve only schema references, never pointers through incoming data or prototype properties.
|
|
39
|
-
function resolve(schema) {
|
|
40
|
-
while (schema?.$ref) {
|
|
41
|
-
const url = new URL(schema.$ref, base), file = url.pathname.split('/').at(-1);
|
|
42
|
-
schema = schemaAt(`${file}${url.hash}`);
|
|
43
|
-
}
|
|
44
|
-
return schema;
|
|
45
|
-
}
|
|
46
|
-
function description(schema) {
|
|
47
|
-
const stack = [schema];
|
|
48
|
-
while (stack.length) {
|
|
49
|
-
const node = resolve(stack.pop());
|
|
50
|
-
if (node?.description) return node.description;
|
|
51
|
-
if (node?.allOf) for (let i = node.allOf.length - 1; i >= 0; i--) stack.push(node.allOf[i]);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
function problem(error, where) {
|
|
55
|
-
let path = error.instancePath, said;
|
|
56
|
-
if (error.keyword === 'required') {
|
|
57
|
-
const key = error.params.missingProperty, properties = error.parentSchema.properties;
|
|
58
|
-
path += `/${escape(key)}`;
|
|
59
|
-
said = properties && Object.hasOwn(properties, key) ? description(properties[key]) : undefined;
|
|
60
|
-
}
|
|
61
|
-
return `${place(path, where)}:${said ?? described(error, { schemas }) ?? error.message}`;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
18
|
// A reference is relative to schemas/agent/, optionally with a JSON Pointer fragment.
|
|
65
19
|
export function agentValueProblem(ref, value, where = ref) {
|
|
66
|
-
const validate = schemaAt(ref) !== undefined ? ajv.getSchema(`${base}${ref}`) : null;
|
|
20
|
+
const validate = schemaAt(ref, AGENT_SCHEMAS) !== undefined ? ajv.getSchema(`${base}${ref}`) : null;
|
|
67
21
|
if (!validate) return `${where}:没有这个 Agent schema`;
|
|
68
|
-
if (!validate(value)) return
|
|
22
|
+
if (!validate(value)) return explain(validate.errors, where, schemas, { style: 'description', required: 'property', schemaBase: base, leadingDot: true });
|
|
69
23
|
// Opaque remote payloads and future metadata are JSON too. An explicit stack handles deep/wide values and cycles.
|
|
70
24
|
return jsonFits(value, Infinity) ? null : `${where}:${AGENT_SCHEMAS['json.json'].description}`;
|
|
71
25
|
}
|
package/docs/agent-protocol.md
CHANGED
|
@@ -27,6 +27,7 @@ ACP 的消息、内容块、会话指令和能力由 ACP SDK 校验,这里只
|
|
|
27
27
|
| 工具更新的 `_meta.coda.review` | [review.json](../schemas/agent/review.json);已知风险值见 `$defs/risk` |
|
|
28
28
|
| 用量更新的 `_meta.coda` | [usage.json](../schemas/agent/usage.json) |
|
|
29
29
|
| 配置选项的 `_meta.coda.default` | [default.json](../schemas/agent/default.json)(传入 `_meta.coda` 对象) |
|
|
30
|
+
| 模型、模式、配置项及其选项的 `_meta.coda.shortName` | [display.json](../schemas/agent/display.json)(传入 `_meta.coda` 对象) |
|
|
30
31
|
|
|
31
32
|
`@coda-console/protocol/agent` 导出 `agentMessageProblem(method, params)`、`agentResultProblem(method, result)`、
|
|
32
33
|
`agentCapabilitiesProblem(meta, party = 'agent')` 和 `agentValueProblem(ref, value, where)`,合法返回 `null`,
|
|
@@ -105,6 +106,11 @@ ACP 只允许在一轮(`session/prompt`)进行时交互。Agent 在两轮之
|
|
|
105
106
|
`clientCapabilities.session.configOptions.boolean` 里声明接受开关型。会话设置里按配置项 ID 给的值,每轮开始前设给 Agent;当前模型没有这一项就不设。
|
|
106
107
|
Coda 设过、会话设置里又去掉的(用户选回「Agent 默认」),切回 Agent 标了 `_meta.coda.default` 的那一项,没标就切回 Coda 第一次设它之前、
|
|
107
108
|
同一模型下 Agent 报的值,两样都不知道就不改。Agent 自己改的 Coda 不去改回;用户在会话里用命令改的(如 Codex 的 `/fast`)记进会话设置。名字与说明照 Agent 给的显示。
|
|
109
|
+
模型、模式、配置项及其选项可在 `_meta.coda.shortName` 声明紧凑显示名([display.json](../schemas/agent/display.json)),
|
|
110
|
+
适用于 `configOptions` 的项与叶子选项,以及 `availableModels`、`availableModes` 的项。例如
|
|
111
|
+
`{ "value": "read-only", "name": "Ask for approval", "_meta": { "coda": { "shortName": "审批" } } }`。
|
|
112
|
+
工具栏优先短名,再取完整 `name`,最后取 ID;选择面板和辅助功能保留完整名称。短名由插件声明,客户端不猜测或改写模型 ID,
|
|
113
|
+
不影响设置、分组与执行;插件应保留版本、上下文变体等辨认所需的信息。没声明时保持原有显示。
|
|
108
114
|
- **未登录**:Agent 在 `initialize`、`session/new` 或 `session/load` 回错误 -32000(ACP 的 `auth_required`)时,Coda 告诉用户这个 Agent 未登录,
|
|
109
115
|
到「资源」里添加(面板的登录(Coda 的 docs/plugins.md「面板的登录」));Coda 不调用 `authenticate`,也不按错误文字猜。
|
|
110
116
|
- **Agent 版本**:`initialize` 结果的 `agentInfo`(ACP 的 `name`、`version`)填**实际运行的那个 Agent**:包装了别家 CLI 或 SDK 的插件,
|
|
@@ -409,26 +415,34 @@ Bot 需要随时进到自己的会话里来:说话、调 Coda 的工具、开
|
|
|
409
415
|
|
|
410
416
|
只给 Agent 声明了 `agentCapabilities._meta.coda.remote` 的会话提供 MCP 工具 `new_session`;
|
|
411
417
|
会话启动时生成工具列表,其他会话不列出它,直接调用也拒绝。
|
|
412
|
-
Bot
|
|
413
|
-
|
|
414
|
-
参数为 `
|
|
415
|
-
`
|
|
418
|
+
Bot 是工作的发起者:批准后开的是普通根会话,首条任务的回复权归发起会话;独立会话身份与回复对象分开记录。
|
|
419
|
+
|
|
420
|
+
参数为 `agent`、`prompt`、`requestId`、`turn`、`project`,以及可选的 `model`、`effort`、`mode`、`options`、`checkout`、`title`。
|
|
421
|
+
`agent` 是 Agent 的 ID,或用户给的 Agent 链接 `coda://agents/<UUID>?…`,原样照填:Coda 从链接取 Agent 与它所在的机器,
|
|
422
|
+
链接写的 `model`、`effort`、`mode`、`option.*`、`project`、`checkout` 当作调用写的。调用另写同名参数只能写成一样的,不同就拒绝;
|
|
423
|
+
链接没写的照常另写。会话开在 Agent 那台:目录要在那台,找不到链接里的 Agent(那台离开空间或卸掉了它)时拒绝。
|
|
424
|
+
`project` 按 `spawn` 的规则找:本机用名字或 ID,其他机器用 ID、经状态层定位;链接带了目录时可省。
|
|
425
|
+
`checkout` 为 `shared`(默认,目录本身)或 `worktree`(从目录的默认分支新建工作树)。
|
|
416
426
|
`turn` 是 `status` 给的轮次票据,新请求要有效;同一 `requestId`、相同内容重试恢复原请求,不重复出卡片或创建,
|
|
417
|
-
|
|
427
|
+
已存在请求的回放不再校验票据,不同内容报冲突;填链接的重试按受理时的 Agent 比对,不再查它现在是否可用。省略标题用 prompt 的前 80 字,省略配置项用目标 Agent 默认值。
|
|
418
428
|
|
|
419
|
-
|
|
420
|
-
|
|
429
|
+
每个请求都在发起会话里出授权卡片,包括在自己的目录里新建:注明机器、目录名与路径、工作位置(新工作树时写起点分支)、
|
|
430
|
+
Agent、模型、强度、审批档位、其他配置、标题和任务全文;其他配置逐项写进卡片说明,让两端在批准前都能看到。只有「允许」「拒绝」,没有「都允许」,也不沿用 `spawn` / `send` 的授权。
|
|
421
431
|
工具出卡片后立即返回「已请用户批准;结果会作为新消息告诉你。不要轮询或重复调用;没有其他工作就结束本轮。」
|
|
422
432
|
不在调用里等用户,卡片的提醒、归档失效沿用会话授权卡片。
|
|
423
433
|
|
|
424
|
-
|
|
425
|
-
没有 parent
|
|
426
|
-
|
|
434
|
+
允许后在目标目录建根会话(`shared` 用原目录,`worktree` 从卡片写明的默认分支新建工作树),第一条消息就是 `prompt`,审批档位保留卡片批准的参数,不跟随 Bot;标题固定为批准的标题。
|
|
435
|
+
没有 parent,但首条任务建立发起会话的一次调用(沿用 `send` 的会话间回复机制)。任务完成或失败后,结果自动送回并唤醒发起会话;
|
|
436
|
+
这次任务引发的后续沿用补交规则。用户在新会话里另发的消息仍回复用户。PR 事件的 `coda.session` 仍采用新会话自己的 Agent 与配置。
|
|
437
|
+
会话数据的 `initiatedBy` 保存 `{threadId, title, reference}`,表示「由哪个会话发起」,不附带旧上下文;回复关系由首条任务的调用保存。
|
|
427
438
|
`reference` 是发起会话的 `coda://threads/…`。两端客户端尚未显示这个来源字段。
|
|
428
439
|
|
|
429
440
|
决定以一条 Coda 通知唤醒发起会话:允许为「已开出:标题(Coda 的 docs/coda://threads/<新会话 ID>)」,拒绝为「用户拒绝了:<标题>」,
|
|
430
|
-
|
|
431
|
-
|
|
441
|
+
允许通知同时说明任务结果会自动送回并唤醒发起会话,不必轮询或等待。已决定请求的工具重试返回这句结果,仍保留应交的通知。
|
|
442
|
+
目标若声明外部生命周期,则首条消息附带发起会话地址与原 `requestId`,目标按委派约定用 `send` 的 `replyTo` 明确回传;自主发言不自动作为结果。
|
|
443
|
+
其他机器上的创建走状态层转发的普通首次发送接口;发送前持久化允许决定、提交标识与回复调用,卡片进入 `submitting`。
|
|
444
|
+
目标用固定会话标识创建,并在接受首条任务的同一事务里建立回话;发起机器取认证身份,不信任请求正文提供的机器。
|
|
445
|
+
结果通过现有跨机投递返回,即使任务在创建回执到达前完成也能交回;重复投递不重复唤醒。
|
|
432
446
|
目标暂不可达或应答丢失时,使用原提交标识与允许决定重试恢复同一次创建,不再接受拒绝或其他提交;
|
|
433
447
|
工具重试返回「用户已批准,正在确认新会话;结果会作为新消息告诉你。」,收到创建结果后才通知已开出。
|
|
434
448
|
目标明确拒绝(例如批准前目录已删除)则结算为失败,通知「未能开出:<标题>(<原因>)」;
|
package/package.json
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://coda.pub/schemas/agent/v1/display.json",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"description": "_meta.coda 须为对象",
|
|
6
|
+
"properties": {
|
|
7
|
+
"shortName": {
|
|
8
|
+
"type": "string",
|
|
9
|
+
"pattern": "\\S",
|
|
10
|
+
"description": "shortName 须为非空白文本,用于紧凑位置的显示,不改变选项 ID"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
package/validate.mjs
CHANGED
|
@@ -187,8 +187,8 @@ function keywords(ajv) {
|
|
|
187
187
|
|
|
188
188
|
// An Ajv as these schemas are checked with, the keywords above in it: also for the schemas of the protocol's other parts
|
|
189
189
|
// (workflow.mjs), which add their own keywords to it.
|
|
190
|
-
export function newAjv() {
|
|
191
|
-
const made = new Ajv2020({ allErrors: false, verbose: true, passContext: true, strict: true, strictTypes: false, strictRequired: false, allowUnionTypes: true });
|
|
190
|
+
export function newAjv(options = {}) {
|
|
191
|
+
const made = new Ajv2020({ allErrors: false, verbose: true, passContext: true, strict: true, strictTypes: false, strictRequired: false, allowUnionTypes: true, ...options });
|
|
192
192
|
keywords(made);
|
|
193
193
|
return made;
|
|
194
194
|
}
|
|
@@ -216,8 +216,8 @@ function declared(schema) {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
// Where in the manifest a value is, as a person writes it: `contributes.composerModes[0].in`.
|
|
219
|
-
const place = (path, base) => path.split('/').slice(1).map(unescape)
|
|
220
|
-
.reduce((at, step) => /^\d+$/.test(step) ? `${at}[${step}]` : !/^[A-Za-z_$][\w$]*$/.test(step) ? `${at}「${step}」` : at ? `${at}.${step}` : step, base);
|
|
219
|
+
const place = (path, base, leadingDot = false) => path.split('/').slice(1).map(unescape)
|
|
220
|
+
.reduce((at, step) => /^\d+$/.test(step) ? `${at}[${step}]` : !/^[A-Za-z_$][\w$]*$/.test(step) ? `${at}「${step}」` : at || leadingDot ? `${at}.${step}` : step, base);
|
|
221
221
|
const TYPE_NAMES = { array: '数组', object: '对象', string: '文本', number: '数字', integer: '整数', boolean: '开或关' };
|
|
222
222
|
// What the schema says of a failing value: the description nearest above the keyword that failed (a branch's `then`, a
|
|
223
223
|
// property's), found by where the error says it is, from the schema it failed in (the one a `$ref` names, any of these).
|
|
@@ -226,8 +226,49 @@ const nodes = function* (node) {
|
|
|
226
226
|
yield node;
|
|
227
227
|
for (const child of Object.values(node)) yield* nodes(child);
|
|
228
228
|
};
|
|
229
|
-
|
|
229
|
+
// A registered schema, optionally followed by a JSON Pointer: only own keys, never prototype properties.
|
|
230
|
+
export function schemaAt(ref, schemas) {
|
|
231
|
+
if (typeof ref !== 'string') return undefined;
|
|
232
|
+
const [file, fragment = '', extra] = ref.split('#');
|
|
233
|
+
if (extra !== undefined || !Object.hasOwn(schemas, file)) return undefined;
|
|
234
|
+
let pointer;
|
|
235
|
+
try { pointer = decodeURIComponent(fragment); } catch { return undefined; }
|
|
236
|
+
if (pointer && !pointer.startsWith('/')) return undefined;
|
|
237
|
+
let node = schemas[file];
|
|
238
|
+
for (const token of pointer.split('/').slice(1)) {
|
|
239
|
+
if (/~(?:[^01]|$)/.test(token)) return undefined;
|
|
240
|
+
const key = unescape(token);
|
|
241
|
+
node = node && typeof node === 'object' && Object.hasOwn(node, key) ? node[key] : undefined;
|
|
242
|
+
}
|
|
243
|
+
return typeof node === 'boolean' || node && typeof node === 'object' && !Array.isArray(node) ? node : undefined;
|
|
244
|
+
}
|
|
245
|
+
// A missing property's description may live behind a reference or in its first described allOf branch.
|
|
246
|
+
// Walk schemas, not input values, with a stack and a visited set so depth, width and reference cycles are bounded.
|
|
247
|
+
function propertyDescription(schema, schemas, base) {
|
|
248
|
+
const registered = Object.fromEntries(schemas.filter(schema => schema.$id).map(schema => [schema.$id, schema]));
|
|
249
|
+
const stack = [{ schema, base }], seen = new Set();
|
|
250
|
+
while (stack.length) {
|
|
251
|
+
const { schema: node, base: from } = stack.pop();
|
|
252
|
+
if (!node || typeof node !== 'object' || seen.has(node)) continue;
|
|
253
|
+
seen.add(node);
|
|
254
|
+
const at = node.$id ? new URL(node.$id, from).href : from;
|
|
255
|
+
if (node.$ref) {
|
|
256
|
+
const ref = new URL(node.$ref, at).href;
|
|
257
|
+
stack.push({ schema: schemaAt(ref, registered), base: ref });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (node.description) return node.description;
|
|
261
|
+
if (node.allOf) for (let i = node.allOf.length - 1; i >= 0; i--) stack.push({ schema: node.allOf[i], base: at });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
export function described(error, { roots = true, schemas = [], required = 'object', schemaBase } = {}) {
|
|
230
265
|
if (!error.parentSchema) return undefined;
|
|
266
|
+
if (required === 'property' && error.keyword === 'required') {
|
|
267
|
+
const key = error.params.missingProperty, properties = error.parentSchema.properties;
|
|
268
|
+
const own = properties && Object.hasOwn(properties, key)
|
|
269
|
+
? propertyDescription(properties[key], [...schemas, ...Object.values(SCHEMAS)], schemaBase) : undefined;
|
|
270
|
+
if (own !== undefined) return own;
|
|
271
|
+
}
|
|
231
272
|
if (error.parentSchema.description) return roots || !error.parentSchema.$id ? error.parentSchema.description : undefined;
|
|
232
273
|
const steps = error.schemaPath.replace(/^#\//, '').split('/').map(unescape).slice(0, -1);
|
|
233
274
|
for (const root of [...schemas, ...Object.values(SCHEMAS)]) for (const from of nodes(root)) {
|
|
@@ -242,16 +283,21 @@ export function described(error, { roots = true, schemas = [] } = {}) {
|
|
|
242
283
|
// What is wrong, said once: the first error, by its place and what the schema says of the value there.
|
|
243
284
|
// What a whole type says of itself is too much for a key it lacks or one written twice: those say only what is wrong.
|
|
244
285
|
// `schemas`: those besides these the value was checked against (an extension point's).
|
|
245
|
-
|
|
286
|
+
// `style: 'description'` uses schema wording for every keyword. `required: 'property'` names and describes the
|
|
287
|
+
// missing property; `schemaBase` resolves its references. `leadingDot` retains relative paths with an empty base.
|
|
288
|
+
export function explain(errors, base = '', schemas = [], { style = 'keyword', required = 'object', schemaBase, leadingDot = false } = {}) {
|
|
246
289
|
const [error] = errors;
|
|
247
|
-
const
|
|
290
|
+
const path = error.instancePath + (required === 'property' && error.keyword === 'required' ? `/${escape(error.params.missingProperty)}` : '');
|
|
291
|
+
const at = place(path, base, leadingDot) || (style === 'description' ? '' : 'package.json'), name = error.propertyName ? `「${error.propertyName}」` : '';
|
|
292
|
+
const options = { schemas, required, schemaBase };
|
|
293
|
+
if (style === 'description') return `${at}:${described(error, options) ?? error.message}`;
|
|
248
294
|
if (error.keyword === 'x-unique-by') return `${at}${name}:${error.message}`;
|
|
249
|
-
if (error.keyword === 'required') { const said = described(error, { roots: false
|
|
295
|
+
if (error.keyword === 'required') { const said = described(error, { ...options, roots: false }); return `${at} 缺 ${error.params.missingProperty}${said ? `:${said}` : ''}`; }
|
|
250
296
|
if (error.keyword === 'additionalProperties' || error.keyword === 'unevaluatedProperties')
|
|
251
297
|
return `${at} 里有不认识的键:${error.params.additionalProperty ?? error.params.unevaluatedProperty}`;
|
|
252
|
-
const said = described(error,
|
|
298
|
+
const said = described(error, options);
|
|
253
299
|
if (error.keyword === 'type') return `${at}${name} 须为${TYPE_NAMES[error.params.type] ?? error.params.type}${said ? `:${said}` : ''}`;
|
|
254
|
-
if (error.keyword === 'x-file') { const own = described(error, { roots: false
|
|
300
|
+
if (error.keyword === 'x-file') { const own = described(error, { ...options, roots: false }); return `${at}${name}:${error.message}${own ? `(${own})` : ''}`; }
|
|
255
301
|
return `${at}${name}:${said ?? error.message}`;
|
|
256
302
|
}
|
|
257
303
|
|