viberoom 0.7.0 → 0.9.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/NOTICE +5 -3
- package/README.md +90 -19
- package/assets/vendors/grok.svg +3 -0
- package/assets/vendors/hermes.svg +1 -0
- package/dist/agent-health.js +1 -1
- package/dist/hub.js +98 -49
- package/dist/jsonrpc.js +3 -1
- package/dist/look-lint.js +180 -0
- package/dist/looks.js +151 -0
- package/dist/main.js +4 -1
- package/dist/mcp-skills-server.js +87 -0
- package/dist/persona.js +10 -3
- package/dist/recipes.js +110 -8
- package/dist/room-design.js +9 -6
- package/dist/room.js +190 -26
- package/dist/server.js +73 -1
- package/dist/skills.js +23 -2
- package/package.json +1 -1
- package/ui/app.css +23 -16
- package/ui/app.js +198 -66
- package/ui/components.css +96 -115
- package/ui/components.js +8 -3
- package/ui/index.html +3 -2
- package/ui/looks.css +462 -77
- package/ui/theme.css +94 -13
- package/ui/tokens.js +526 -57
package/dist/recipes.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
2
|
import { execSync } from "node:child_process";
|
|
3
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
-
import { dirname, join } from "node:path";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { loginState } from "./agent-health.js";
|
|
9
9
|
const isWindows = process.platform === "win32";
|
|
10
|
+
const ICON_VERSION = (() => {
|
|
11
|
+
try {
|
|
12
|
+
return String(createRequire(import.meta.url)("../package.json").version ?? "0");
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return "0";
|
|
16
|
+
}
|
|
17
|
+
})();
|
|
18
|
+
const iconUrl = (id) => `/vendor-icons/${id}.svg?v=${ICON_VERSION}`;
|
|
10
19
|
function resolvePackageEntry(packageName, relativeEntry) {
|
|
11
20
|
try {
|
|
12
21
|
const require = createRequire(import.meta.url);
|
|
@@ -141,6 +150,46 @@ function resolveClaudeCode() {
|
|
|
141
150
|
function resolveCodex() {
|
|
142
151
|
return resolveGlobalNpmBin("codex") ?? resolveOnPath(["codex"]);
|
|
143
152
|
}
|
|
153
|
+
function realHome() {
|
|
154
|
+
try {
|
|
155
|
+
return realpathSync(homedir());
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function resolveGrok() {
|
|
162
|
+
const exe = isWindows ? "grok.exe" : "grok";
|
|
163
|
+
const home = realHome();
|
|
164
|
+
const dirs = [process.env.GROK_BIN_DIR, process.env.GROK_HOME && join(process.env.GROK_HOME, "bin"), join(homedir(), ".grok", "bin"), home && join(home, ".grok", "bin")];
|
|
165
|
+
for (const dir of dirs)
|
|
166
|
+
if (dir && existsSync(join(dir, exe)))
|
|
167
|
+
return join(dir, exe);
|
|
168
|
+
const onPath = resolveOnPath(["grok"]);
|
|
169
|
+
return onPath && !/\.(cmd|bat|ps1)$/i.test(onPath) ? onPath : null;
|
|
170
|
+
}
|
|
171
|
+
function resolveHermes() {
|
|
172
|
+
const hermesHome = process.env.HERMES_HOME || (isWindows ? process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "hermes") : join(homedir(), ".hermes"));
|
|
173
|
+
const scripts = isWindows ? "Scripts" : "bin";
|
|
174
|
+
const exe = (name) => (isWindows ? `${name}.exe` : name);
|
|
175
|
+
const candidates = [];
|
|
176
|
+
if (hermesHome) {
|
|
177
|
+
const venv = join(hermesHome, "hermes-agent", "venv");
|
|
178
|
+
candidates.push({ command: join(venv, scripts, exe("hermes-acp")), args: [] }, { command: join(venv, scripts, exe("hermes")), args: ["acp"] });
|
|
179
|
+
if (isWindows)
|
|
180
|
+
candidates.push({ command: join(hermesHome, "bin", "hermes-acp.exe"), args: [] }, { command: join(hermesHome, "bin", "hermes.exe"), args: ["acp"] });
|
|
181
|
+
}
|
|
182
|
+
if (!isWindows) {
|
|
183
|
+
candidates.push({ command: join(homedir(), ".local", "bin", "hermes"), args: ["acp"] }, { command: "/usr/local/lib/hermes-agent/venv/bin/hermes-acp", args: [] }, { command: "/usr/local/bin/hermes", args: ["acp"] });
|
|
184
|
+
}
|
|
185
|
+
for (const candidate of candidates)
|
|
186
|
+
if (existsSync(candidate.command))
|
|
187
|
+
return candidate;
|
|
188
|
+
const onPath = resolveOnPath(["hermes-acp", "hermes"]);
|
|
189
|
+
if (onPath && !/\.(cmd|bat|ps1)$/i.test(onPath))
|
|
190
|
+
return { command: onPath, args: /^hermes-acp/i.test(basename(onPath)) ? [] : ["acp"] };
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
144
193
|
const vendorDir = join(dirname(fileURLToPath(import.meta.url)), "..", "vendor", "acp");
|
|
145
194
|
const claudeAdapter = join(vendorDir, "claude-agent-acp", "dist", "index.js");
|
|
146
195
|
const codexAdapter = join(vendorDir, "codex-acp", "dist", "index.js");
|
|
@@ -151,12 +200,14 @@ const geminiEntry = resolvePackageEntry("@google/gemini-cli", join("bundle", "ge
|
|
|
151
200
|
const cursorAgent = resolveCursorAgent();
|
|
152
201
|
const openCodeExe = resolveOpenCode();
|
|
153
202
|
const copilotExe = resolveCopilot();
|
|
203
|
+
const grokExe = resolveGrok();
|
|
204
|
+
const hermesLaunch = resolveHermes();
|
|
154
205
|
const recipes = [
|
|
155
206
|
{
|
|
156
207
|
id: "claude",
|
|
157
208
|
label: "Claude (claude-agent-acp)",
|
|
158
209
|
vendor: "Claude",
|
|
159
|
-
icon: "
|
|
210
|
+
icon: iconUrl("claude"),
|
|
160
211
|
tested: true,
|
|
161
212
|
note: "Adapter around the Claude Agent SDK, driving the Claude Code installed on this machine with its login and settings.",
|
|
162
213
|
modelPresets: ["haiku", "sonnet", "opus", "default"],
|
|
@@ -182,7 +233,7 @@ const recipes = [
|
|
|
182
233
|
id: "codex",
|
|
183
234
|
label: "Codex (codex-acp)",
|
|
184
235
|
vendor: "Codex",
|
|
185
|
-
icon: "
|
|
236
|
+
icon: iconUrl("codex"),
|
|
186
237
|
tested: true,
|
|
187
238
|
note: "Adapter around the Codex App Server of the Codex CLI installed on this machine; uses its login (~/.codex) or CODEX_API_KEY. Mode 'agent' edits the working directory without asking; 'read-only' for a chat-only participant.",
|
|
188
239
|
modelPresets: ["gpt-5.4-mini", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"],
|
|
@@ -208,7 +259,7 @@ const recipes = [
|
|
|
208
259
|
id: "gemini",
|
|
209
260
|
label: "Gemini CLI (gemini --acp)",
|
|
210
261
|
vendor: "Gemini",
|
|
211
|
-
icon: "
|
|
262
|
+
icon: iconUrl("gemini"),
|
|
212
263
|
tested: true,
|
|
213
264
|
note: "Native ACP mode of the globally installed Gemini CLI; uses the machine's Gemini login / API key. Exposes no config options over ACP: the model is fixed at launch (--model).",
|
|
214
265
|
modelPresets: ["gemini-3.8-flash", "gemini-3.7-flash"],
|
|
@@ -235,7 +286,7 @@ const recipes = [
|
|
|
235
286
|
id: "cursor",
|
|
236
287
|
label: "Cursor (cursor-agent acp)",
|
|
237
288
|
vendor: "Cursor",
|
|
238
|
-
icon: "
|
|
289
|
+
icon: iconUrl("cursor"),
|
|
239
290
|
tested: true,
|
|
240
291
|
note: "Cursor's CLI agent in native ACP mode; uses the machine's Cursor login (agent login) or CURSOR_API_KEY. Mode 'agent' edits without asking; 'ask' is read-only Q&A. Models: see the session settings after joining.",
|
|
241
292
|
modelPresets: [],
|
|
@@ -261,7 +312,7 @@ const recipes = [
|
|
|
261
312
|
id: "opencode",
|
|
262
313
|
label: "OpenCode (opencode acp)",
|
|
263
314
|
vendor: "OpenCode",
|
|
264
|
-
icon: "
|
|
315
|
+
icon: iconUrl("opencode"),
|
|
265
316
|
tested: false,
|
|
266
317
|
note: "The open-source coding agent in native ACP mode; the model list comes from the providers configured in OpenCode (opencode providers). Mode 'plan' is read-only; 'build' edits the working directory (OpenCode's own permission config decides what still asks; the questions arrive here).",
|
|
267
318
|
modelPresets: [],
|
|
@@ -286,7 +337,7 @@ const recipes = [
|
|
|
286
337
|
id: "copilot",
|
|
287
338
|
label: "GitHub Copilot (copilot --acp)",
|
|
288
339
|
vendor: "Copilot",
|
|
289
|
-
icon: "
|
|
340
|
+
icon: iconUrl("copilot"),
|
|
290
341
|
tested: false,
|
|
291
342
|
note: "GitHub Copilot CLI in native ACP mode; uses the machine's Copilot login (copilot login). Session modes agent / plan / autopilot; the 'allow_all' option decides whether tool calls ask for permission. Exposes no model option over ACP: the model is fixed at launch (--model, e.g. auto).",
|
|
292
343
|
modelPresets: ["auto"],
|
|
@@ -312,6 +363,57 @@ const recipes = [
|
|
|
312
363
|
args: ["--acp", ...(model ? ["--model", model] : [])],
|
|
313
364
|
}),
|
|
314
365
|
},
|
|
366
|
+
{
|
|
367
|
+
id: "grok",
|
|
368
|
+
label: "Grok Build (grok agent stdio)",
|
|
369
|
+
vendor: "Grok",
|
|
370
|
+
icon: iconUrl("grok"),
|
|
371
|
+
tested: true,
|
|
372
|
+
note: "xAI's coding agent in its native ACP mode; uses this machine's Grok login (grok login) or XAI_API_KEY. It reports no session modes over ACP, so the mode is a launch flag: 'ask-first' asks before every tool call, 'always-approve' never does; a change restarts the session with its notes kept. Model and reasoning effort are session options (the model also goes on the launch command).",
|
|
373
|
+
modelPresets: ["grok-4.6", "grok-4.5"],
|
|
374
|
+
defaultModel: null,
|
|
375
|
+
effortPresets: ["xhigh", "high", "medium", "low"],
|
|
376
|
+
defaultEffort: null,
|
|
377
|
+
modePresets: ["ask-first", "always-approve"],
|
|
378
|
+
defaultMode: "ask-first",
|
|
379
|
+
bypassMode: "always-approve",
|
|
380
|
+
unavailableReason: grokExe ? null : "Grok Build not found",
|
|
381
|
+
installedAt: grokExe,
|
|
382
|
+
installHint: "curl -fsSL https://x.ai/cli/install.sh | bash (Windows: irm https://x.ai/cli/install.ps1 | iex), or npm install -g @xai-official/grok; then grok login",
|
|
383
|
+
loginCommand: "grok login",
|
|
384
|
+
login: { env: ["XAI_API_KEY", "GROK_DEPLOYMENT_KEY"], files: [".grok/auth.json"], command: "grok login" },
|
|
385
|
+
loginState: "unknown",
|
|
386
|
+
modeAtLaunch: true,
|
|
387
|
+
build: ({ model, mode }) => ({
|
|
388
|
+
command: grokExe ?? "",
|
|
389
|
+
args: ["agent", ...(model ? ["-m", model] : []), ...(mode === "always-approve" ? ["--always-approve"] : []), "stdio"],
|
|
390
|
+
}),
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
id: "hermes",
|
|
394
|
+
label: "Hermes Agent (hermes acp)",
|
|
395
|
+
vendor: "Hermes",
|
|
396
|
+
icon: iconUrl("hermes"),
|
|
397
|
+
tested: true,
|
|
398
|
+
note: "Nous Research's open-source agent in its native ACP mode; the provider and model are the ones configured in Hermes (hermes model). Mode 'default' asks before edits, 'accept_edits' auto-allows workspace and /tmp edits, 'dont_ask' auto-allows file edits except sensitive paths; a tool call that still needs permission arrives here with Hermes' own choices: once, this session, or always.",
|
|
399
|
+
modelPresets: [],
|
|
400
|
+
defaultModel: null,
|
|
401
|
+
effortPresets: [],
|
|
402
|
+
defaultEffort: null,
|
|
403
|
+
modePresets: ["default", "accept_edits", "dont_ask"],
|
|
404
|
+
defaultMode: "default",
|
|
405
|
+
bypassMode: "dont_ask",
|
|
406
|
+
unavailableReason: hermesLaunch ? null : "Hermes Agent not found",
|
|
407
|
+
installedAt: hermesLaunch?.command ?? null,
|
|
408
|
+
installHint: "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash (Windows: iex (irm https://hermes-agent.nousresearch.com/install.ps1)), then hermes model",
|
|
409
|
+
loginCommand: "hermes model",
|
|
410
|
+
login: { env: [], files: [], command: "hermes model" },
|
|
411
|
+
loginState: "unknown",
|
|
412
|
+
build: () => ({
|
|
413
|
+
command: hermesLaunch?.command ?? "",
|
|
414
|
+
args: hermesLaunch?.args ?? [],
|
|
415
|
+
}),
|
|
416
|
+
},
|
|
315
417
|
];
|
|
316
418
|
const fakeAgent = process.env.VIBEROOM_FAKE_AGENT;
|
|
317
419
|
if (fakeAgent) {
|
package/dist/room-design.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
2
|
import { AGENT_SETTINGS, DEFAULT_ROOM_SETTINGS, NAME_PATTERN, ROOM_SETTINGS_SPEC, SILENT_MARKER, REQUEST_BRIEF_MARKER, buildBrief, coerceSetting } from "./persona.js";
|
|
3
3
|
export const TAGLINE_MAX = 80;
|
|
4
|
-
export const ROLE_MAX =
|
|
4
|
+
export const ROLE_MAX = 8000;
|
|
5
5
|
export const AVATAR_MAX = 8;
|
|
6
6
|
export const RULES_SOFT_MAX = 12;
|
|
7
7
|
export const ROLE_SOFT_MAX = 1200;
|
|
8
|
-
export const RULES_NEAR_LIMIT =
|
|
8
|
+
export const RULES_NEAR_LIMIT = 0.75;
|
|
9
9
|
export function ruleLines(customRules) {
|
|
10
10
|
return customRules
|
|
11
11
|
.split(/\r?\n/)
|
|
@@ -57,6 +57,7 @@ export function lintRoomDesign(design, context) {
|
|
|
57
57
|
if (!vibemates.length)
|
|
58
58
|
error("template-no-vibemates", "a template needs at least one vibemate");
|
|
59
59
|
}
|
|
60
|
+
const roleMax = raw.briefTextLimit !== undefined ? settings.briefTextLimit : context.briefTextLimit ?? settings.briefTextLimit ?? ROLE_MAX;
|
|
60
61
|
const seen = new Map();
|
|
61
62
|
const touched = context.changedVibemates ? new Set(context.changedVibemates.map((n) => n.trim().toLowerCase())) : null;
|
|
62
63
|
for (const [i, v] of vibemates.entries()) {
|
|
@@ -79,8 +80,8 @@ export function lintRoomDesign(design, context) {
|
|
|
79
80
|
error("tagline-too-long", `${who}: the tagline is at most ${TAGLINE_MAX} characters (it is the one line the others see)`);
|
|
80
81
|
else if (soft && !(v?.tagline ?? "").trim())
|
|
81
82
|
warn("vibemate-no-tagline", `${who} has no tagline: the others read it in the roster to know what this one leans to`);
|
|
82
|
-
if ((v?.role ?? "").length >
|
|
83
|
-
error("role-too-long", `${who}: the role is
|
|
83
|
+
if ((v?.role ?? "").length > roleMax)
|
|
84
|
+
error("role-too-long", `${who}: the role is ${v.role.length} characters; the limit is ${roleMax} (the briefTextLimit setting)`);
|
|
84
85
|
else if (soft && !(v?.role ?? "").trim())
|
|
85
86
|
warn("vibemate-no-role", `${who} has no role: without one it is the agent's default self, not a character`);
|
|
86
87
|
else if (soft && (v.role ?? "").length > ROLE_SOFT_MAX)
|
|
@@ -109,8 +110,10 @@ export function lintRoomDesign(design, context) {
|
|
|
109
110
|
const rules = ruleLines(settings.customRules);
|
|
110
111
|
if (rules.length > RULES_SOFT_MAX)
|
|
111
112
|
warn("too-many-rules", `${rules.length} rules: every vibemate carries them on every turn; ${RULES_SOFT_MAX} is a full protocol, longer belongs in a skill`);
|
|
112
|
-
if (settings.customRules.length >
|
|
113
|
-
|
|
113
|
+
if (settings.customRules.length > roleMax)
|
|
114
|
+
error("rules-too-long", `the rules are ${settings.customRules.length} characters; the limit is ${roleMax} (the briefTextLimit setting)`);
|
|
115
|
+
else if (settings.customRules.length > roleMax * RULES_NEAR_LIMIT)
|
|
116
|
+
warn("rules-near-limit", `the rules are ${settings.customRules.length} characters; the limit is ${roleMax}`);
|
|
114
117
|
for (const rule of rules) {
|
|
115
118
|
for (const m of BRIEF_MECHANICS)
|
|
116
119
|
if (m.pattern.test(rule))
|
package/dist/room.js
CHANGED
|
@@ -21,6 +21,7 @@ import { applyVibemateChanges, diffSettings, lintRoomDesign, ruleLines } from ".
|
|
|
21
21
|
import { BRIEF_AFFECTING_SETTINGS, AGENT_SETTINGS, coerceSetting, describeSettings, DEFAULT_ROOM_SETTINGS, ROOM_SETTINGS_SPEC, REQUEST_BRIEF_MARKER, NAME_PATTERN, SILENT_MARKER, buildBrief, buildHeader, composeCorrectionPrompt, composePrompt, countSentences, ensureDir, } from "./persona.js";
|
|
22
22
|
import { Transcript } from "./log.js";
|
|
23
23
|
const SKILL_TOOL_READY_MS = 5000;
|
|
24
|
+
const AUTH_WAIT_MS = 8000;
|
|
24
25
|
const COLORS = ["#6d5dfc", "#16a34a", "#d97706", "#dc2626", "#0891b2", "#be185d", "#4d7c0f", "#7c3aed"];
|
|
25
26
|
const MENTION_PATTERN = /@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu;
|
|
26
27
|
const RULE_REF_TOKEN = /@\{p:([^}]+)\}/g;
|
|
@@ -113,7 +114,7 @@ export class Room extends EventEmitter {
|
|
|
113
114
|
restore(stored) {
|
|
114
115
|
for (const s of stored) {
|
|
115
116
|
if (!s.agentType) {
|
|
116
|
-
this.addUnstaffed({ name: s.name, tagline: s.tagline, role: s.role, avatar: s.avatar, skills: s.skills, color: s.color, id: s.id });
|
|
117
|
+
this.addUnstaffed({ name: s.name, tagline: s.tagline, role: s.role, avatar: s.avatar, skills: s.skills, color: s.color, id: s.id, textCheck: "keep" });
|
|
117
118
|
continue;
|
|
118
119
|
}
|
|
119
120
|
const recipe = getRecipe(s.agentType);
|
|
@@ -505,14 +506,8 @@ export class Room extends EventEmitter {
|
|
|
505
506
|
participant.sessionId = undefined;
|
|
506
507
|
this.restoredSeen.set(id, this.seq);
|
|
507
508
|
this.push({ type: "participant", participant });
|
|
508
|
-
if (!online)
|
|
509
|
-
|
|
510
|
-
const comesBack = withNotes ? "it comes back with its notes" : "it comes back knowing nothing from before";
|
|
511
|
-
this.postSystem(options.reason ? `${participant.name} was respawned while offline (${why}): ${comesBack}.` : `${participant.name} was respawned while offline: ${comesBack}.`);
|
|
512
|
-
this.push({ type: "participant", participant });
|
|
513
|
-
this.log.info(`respawn of ${participant.name} (offline): stored session dropped`);
|
|
514
|
-
return participant;
|
|
515
|
-
}
|
|
509
|
+
if (!online)
|
|
510
|
+
this.log.info(`respawn of ${participant.name} (offline): stored session dropped, starting it now`);
|
|
516
511
|
await this.reconnect(id, memory
|
|
517
512
|
? { mode: "replay", replay, memory: withNotes, reason: `${why}; it comes back with ${withNotes ? "its notes and " : ""}the last ${replay} messages` }
|
|
518
513
|
: { mode: "replay", replay: 0, reason: `${why}, it remembers nothing from before` });
|
|
@@ -676,7 +671,7 @@ export class Room extends EventEmitter {
|
|
|
676
671
|
continue;
|
|
677
672
|
let value = coerceSetting(key, patch[key]);
|
|
678
673
|
if (key === "customRules") {
|
|
679
|
-
const resolved = this.resolveRuleReferences(value);
|
|
674
|
+
const resolved = this.resolveRuleReferences(this.guardBriefText("The room rules text", value, "refuse", next.briefTextLimit));
|
|
680
675
|
unknownRefs = resolved.unknown;
|
|
681
676
|
value = resolved.stored;
|
|
682
677
|
}
|
|
@@ -699,6 +694,14 @@ export class Room extends EventEmitter {
|
|
|
699
694
|
}
|
|
700
695
|
return this.settings;
|
|
701
696
|
}
|
|
697
|
+
guardBriefText(what, text, check = "refuse", limit = this.settings.briefTextLimit) {
|
|
698
|
+
if (check === "keep" || text.length <= limit)
|
|
699
|
+
return text;
|
|
700
|
+
if (check === "refuse")
|
|
701
|
+
throw new Error(`${what} is ${text.length} characters; this room's limit is ${limit} (the briefTextLimit setting). Shorten it, move the instructions into a skill, or raise the limit.`);
|
|
702
|
+
this.postSystem(`${what} is ${text.length} characters, over this room's limit of ${limit}; kept as it is, but the next edit has to fit (shorten it or raise briefTextLimit).`, "human", false, { tone: "attention" });
|
|
703
|
+
return text;
|
|
704
|
+
}
|
|
702
705
|
updatePersona(id, patch) {
|
|
703
706
|
const participant = this.participants.get(id);
|
|
704
707
|
const runtime = this.runtimes.get(id);
|
|
@@ -727,7 +730,7 @@ export class Room extends EventEmitter {
|
|
|
727
730
|
changed.push("tagline");
|
|
728
731
|
}
|
|
729
732
|
if (patch.role !== undefined && patch.role.trim() !== (participant.role ?? "")) {
|
|
730
|
-
participant.role = patch.role.trim()
|
|
733
|
+
participant.role = this.guardBriefText(`${participant.name}'s vibio`, patch.role.trim());
|
|
731
734
|
changed.push("role");
|
|
732
735
|
}
|
|
733
736
|
if (patch.avatar !== undefined) {
|
|
@@ -786,7 +789,7 @@ export class Room extends EventEmitter {
|
|
|
786
789
|
return cached;
|
|
787
790
|
const cwd = ensureDir(join(this.dataDir, ".probe"));
|
|
788
791
|
const log = this.log.child(`probe:${recipeId}`);
|
|
789
|
-
const launch = recipe.build({ model: null });
|
|
792
|
+
const launch = recipe.build({ model: null, mode: null });
|
|
790
793
|
const agent = new AcpAgent({ ...launch, cwd }, {
|
|
791
794
|
onSessionUpdate: () => undefined,
|
|
792
795
|
onPermissionRequest: async () => ({ outcome: { outcome: "cancelled" } }),
|
|
@@ -798,7 +801,7 @@ export class Room extends EventEmitter {
|
|
|
798
801
|
const info = await Promise.race([
|
|
799
802
|
(async () => {
|
|
800
803
|
const init = await agent.initialize({ name: "viberoom", version: "0.2.0" });
|
|
801
|
-
const session = await this.openSession(agent, cwd, log);
|
|
804
|
+
const session = await this.openSession(agent, cwd, log, [], recipe);
|
|
802
805
|
const result = {
|
|
803
806
|
recipeId,
|
|
804
807
|
agentInfo: { name: init.agentInfo?.name ?? null, version: init.agentInfo?.version ?? null },
|
|
@@ -806,6 +809,7 @@ export class Room extends EventEmitter {
|
|
|
806
809
|
modes: session.modes ?? null,
|
|
807
810
|
configOptions: session.configOptions ?? [],
|
|
808
811
|
modelAtLaunch: !!recipe.modelAtLaunch,
|
|
812
|
+
modeAtLaunch: !!recipe.modeAtLaunch,
|
|
809
813
|
discoveredAt: Date.now(),
|
|
810
814
|
durationMs: 0,
|
|
811
815
|
};
|
|
@@ -858,7 +862,7 @@ export class Room extends EventEmitter {
|
|
|
858
862
|
turns: 0,
|
|
859
863
|
color: options.color ?? COLORS[this.colorIndex++ % COLORS.length],
|
|
860
864
|
tagline: (options.tagline ?? "").trim().slice(0, 80),
|
|
861
|
-
role: (options.role ?? "").trim()
|
|
865
|
+
role: this.guardBriefText(`${name}'s vibio`, (options.role ?? "").trim(), options.textCheck),
|
|
862
866
|
avatar: (options.avatar ?? "").trim().slice(0, 8) || undefined,
|
|
863
867
|
replyDelay: options.replyDelay === undefined || options.replyDelay === null ? undefined : Math.max(0, Math.min(120, Number(options.replyDelay) || 0)),
|
|
864
868
|
skills: normalizeSkillList(options.skills ?? undefined),
|
|
@@ -888,7 +892,7 @@ export class Room extends EventEmitter {
|
|
|
888
892
|
turns: 0,
|
|
889
893
|
color: input.color ?? COLORS[this.colorIndex++ % COLORS.length],
|
|
890
894
|
tagline: (input.tagline ?? "").trim().slice(0, 80),
|
|
891
|
-
role: (input.role ?? "").trim()
|
|
895
|
+
role: this.guardBriefText(`${name}'s vibio`, (input.role ?? "").trim(), input.textCheck),
|
|
892
896
|
avatar: (input.avatar ?? "").trim().slice(0, 8) || undefined,
|
|
893
897
|
skills: normalizeSkillList(input.skills),
|
|
894
898
|
violations: 0,
|
|
@@ -938,7 +942,7 @@ export class Room extends EventEmitter {
|
|
|
938
942
|
const name = participant.name;
|
|
939
943
|
const log = this.log.child(name);
|
|
940
944
|
const cwd = ensureDir(this.dir);
|
|
941
|
-
const spec = recipe.build({ model: launch.model });
|
|
945
|
+
const spec = recipe.build({ model: launch.model, mode: launch.mode });
|
|
942
946
|
const transcript = new Transcript(join(this.dataDir, "transcripts"), name);
|
|
943
947
|
log.info(`spawning ${spec.command} ${spec.args.join(" ")} (cwd ${cwd}); transcript ${transcript.path}`);
|
|
944
948
|
const stderrTail = [];
|
|
@@ -990,7 +994,7 @@ export class Room extends EventEmitter {
|
|
|
990
994
|
}
|
|
991
995
|
}
|
|
992
996
|
if (!session)
|
|
993
|
-
session = await this.openSession(agent, cwd, log, mcpServers);
|
|
997
|
+
session = await this.openSession(agent, cwd, log, mcpServers, recipe);
|
|
994
998
|
participant.sessionId = session.sessionId;
|
|
995
999
|
participant.sessionOrigin = origin;
|
|
996
1000
|
const storedSeen = this.restoredSeen.get(id);
|
|
@@ -1044,10 +1048,15 @@ export class Room extends EventEmitter {
|
|
|
1044
1048
|
const warnings = await this.applyConfig(runtime, participant, {
|
|
1045
1049
|
model: recipe.modelAtLaunch ? null : launch.model,
|
|
1046
1050
|
effort: launch.effort,
|
|
1047
|
-
mode: launch.mode,
|
|
1051
|
+
mode: recipe.modeAtLaunch ? null : launch.mode,
|
|
1048
1052
|
});
|
|
1049
1053
|
if (recipe.modelAtLaunch && launch.model)
|
|
1050
1054
|
participant.model = launch.model;
|
|
1055
|
+
if (recipe.modeAtLaunch) {
|
|
1056
|
+
if (!participant.modes?.length)
|
|
1057
|
+
participant.modes = recipe.modePresets.map((id) => ({ id, name: id }));
|
|
1058
|
+
participant.mode = launch.mode ?? recipe.defaultMode ?? participant.mode;
|
|
1059
|
+
}
|
|
1051
1060
|
for (const w of warnings)
|
|
1052
1061
|
this.notice(`${name}: ${w}`, "warn");
|
|
1053
1062
|
participant.status = "idle";
|
|
@@ -1148,6 +1157,16 @@ export class Room extends EventEmitter {
|
|
|
1148
1157
|
const participant = this.participants.get(id);
|
|
1149
1158
|
if (!runtime || !participant)
|
|
1150
1159
|
throw new Error("no such agent (offline?)");
|
|
1160
|
+
const recipe = getRecipe(participant.agentType ?? "");
|
|
1161
|
+
if (recipe?.modeAtLaunch && configId === "mode") {
|
|
1162
|
+
if (!recipe.modePresets.includes(String(value)))
|
|
1163
|
+
throw new Error(`${participant.name}: no mode "${value}" (${recipe.modePresets.join(", ")})`);
|
|
1164
|
+
participant.launch = { model: participant.launch?.model ?? null, effort: participant.launch?.effort ?? null, mode: String(value) };
|
|
1165
|
+
participant.mode = String(value);
|
|
1166
|
+
this.push({ type: "participant", participant });
|
|
1167
|
+
await this.respawnAgent(id, { memory: true, reason: "its mode changed" });
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1151
1170
|
const hasOption = participant.configOptions?.some((o) => o.id === configId);
|
|
1152
1171
|
if (!hasOption && configId === "mode" && participant.modes?.some((m) => m.id === value)) {
|
|
1153
1172
|
await runtime.agent.setMode(runtime.sessionId, String(value));
|
|
@@ -1205,6 +1224,11 @@ export class Room extends EventEmitter {
|
|
|
1205
1224
|
const skill = this.skills?.library.get(message.skill.name);
|
|
1206
1225
|
if (!message.to.length)
|
|
1207
1226
|
targets = targets.filter((id) => this.hasSkill(this.participants.get(id), message.skill.name));
|
|
1227
|
+
if (!targets.length && !message.to.length) {
|
|
1228
|
+
const alone = [...this.runtimes.keys()].filter(live);
|
|
1229
|
+
if (alone.length === 1)
|
|
1230
|
+
targets = alone;
|
|
1231
|
+
}
|
|
1208
1232
|
if (!targets.length)
|
|
1209
1233
|
this.notice(`Nobody in this room has the skill "${message.skill.name}"; attach it to an agent first, or address one with @.`, "warn");
|
|
1210
1234
|
if (skill) {
|
|
@@ -1241,13 +1265,19 @@ export class Room extends EventEmitter {
|
|
|
1241
1265
|
this.requestTurn(id, message.to.includes(id) || !!message.skill);
|
|
1242
1266
|
}
|
|
1243
1267
|
hasSkill(participant, name) {
|
|
1268
|
+
if (this.isBuiltinSkill(name))
|
|
1269
|
+
return true;
|
|
1244
1270
|
if (!participant?.skills)
|
|
1245
1271
|
return false;
|
|
1246
1272
|
const lower = name.toLowerCase();
|
|
1247
1273
|
return participant.skills.some((s) => s.toLowerCase() === lower);
|
|
1248
1274
|
}
|
|
1275
|
+
isBuiltinSkill(name) {
|
|
1276
|
+
const skill = this.skills?.library.get(name);
|
|
1277
|
+
return !!skill && skill.author === BUILTIN_AUTHOR && !skill.draft && !skill.problems.length;
|
|
1278
|
+
}
|
|
1249
1279
|
attachedSkills(participant) {
|
|
1250
|
-
if (!this.skills
|
|
1280
|
+
if (!this.skills)
|
|
1251
1281
|
return [];
|
|
1252
1282
|
return this.skills.library.list().filter((s) => !s.problems.length && !s.draft && this.hasSkill(participant, s.name));
|
|
1253
1283
|
}
|
|
@@ -1338,6 +1368,7 @@ export class Room extends EventEmitter {
|
|
|
1338
1368
|
humanName: this.settings.humanName,
|
|
1339
1369
|
roomName: this.settings.name,
|
|
1340
1370
|
base: kind === "room" ? { ...this.settings, customRules: this.renderRuleReferences(this.settings.customRules) } : undefined,
|
|
1371
|
+
briefTextLimit: this.settings.briefTextLimit,
|
|
1341
1372
|
knownSkills: this.skills ? this.skills.library.list().map((s) => s.name) : undefined,
|
|
1342
1373
|
};
|
|
1343
1374
|
}
|
|
@@ -1443,6 +1474,116 @@ export class Room extends EventEmitter {
|
|
|
1443
1474
|
warnings: proposal.warnings,
|
|
1444
1475
|
};
|
|
1445
1476
|
}
|
|
1477
|
+
async describeLooksForAgent(participantId) {
|
|
1478
|
+
const participant = this.agentInRoom(participantId);
|
|
1479
|
+
if (!this.skills?.looks || !this.skills.appearance)
|
|
1480
|
+
throw new Error("looks are not available in this hub");
|
|
1481
|
+
const described = await this.skills.looks.describe();
|
|
1482
|
+
return { you: participant.name, human: this.settings.humanName, appearance: this.skills.appearance.current(), ...described };
|
|
1483
|
+
}
|
|
1484
|
+
async lintLookForAgent(participantId, raw) {
|
|
1485
|
+
this.agentInRoom(participantId);
|
|
1486
|
+
if (!this.skills?.looks)
|
|
1487
|
+
throw new Error("looks are not available in this hub");
|
|
1488
|
+
try {
|
|
1489
|
+
const checked = await this.skills.looks.check(raw);
|
|
1490
|
+
return { ok: checked.lint.ok, id: checked.spec.id, errors: checked.lint.errors.map((e) => ({ key: e.key, message: e.message })), warnings: checked.lint.warnings.map((w) => ({ key: w.key, message: w.message })), report: checked.lint.report };
|
|
1491
|
+
}
|
|
1492
|
+
catch (error) {
|
|
1493
|
+
return { ok: false, errors: [{ key: "spec", message: error instanceof Error ? error.message : String(error) }], warnings: [], report: [] };
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
async createLookForAgent(participantId, raw, replace) {
|
|
1497
|
+
const participant = this.agentInRoom(participantId);
|
|
1498
|
+
if (!this.skills?.looks)
|
|
1499
|
+
throw new Error("looks are not available in this hub");
|
|
1500
|
+
let saved;
|
|
1501
|
+
try {
|
|
1502
|
+
saved = await this.skills.looks.save(raw, { author: participant.name, replace });
|
|
1503
|
+
}
|
|
1504
|
+
catch (error) {
|
|
1505
|
+
throw new Error(`not saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
1506
|
+
}
|
|
1507
|
+
const warnings = saved.lint.warnings.map((w) => w.message);
|
|
1508
|
+
this.postSystem(`${participant.name} saved the look "${saved.spec.label}" (Settings → Appearance).`);
|
|
1509
|
+
this.log.info(`${participant.name} saved look ${saved.spec.id}`);
|
|
1510
|
+
return {
|
|
1511
|
+
ok: true,
|
|
1512
|
+
message: `Saved the look "${saved.spec.label}" (id ${saved.spec.id}) among ${this.settings.humanName}'s own looks: it is in Settings → Appearance now, after the looks viberoom ships. Nothing is worn until ${this.settings.humanName} picks it; propose_look_changes with look "${saved.spec.id}" offers it as a card.${warnings.length ? ` Warnings: ${warnings.join("; ")}` : ""}`,
|
|
1513
|
+
id: saved.spec.id,
|
|
1514
|
+
warnings,
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
async proposeLookChanges(participantId, why, changes) {
|
|
1518
|
+
const participant = this.agentInRoom(participantId);
|
|
1519
|
+
if (!this.skills?.appearance || !this.skills.looks)
|
|
1520
|
+
throw new Error("looks are not available in this hub");
|
|
1521
|
+
const current = this.skills.appearance.current();
|
|
1522
|
+
const patch = {};
|
|
1523
|
+
const rows = [];
|
|
1524
|
+
if (changes.look !== undefined) {
|
|
1525
|
+
const lookId = String(changes.look).trim();
|
|
1526
|
+
const known = await this.skills.appearance.ownAdjustments(lookId);
|
|
1527
|
+
if (!known)
|
|
1528
|
+
throw new Error(`not proposed: no look "${lookId}" (the looks viberoom ships, or one of ${this.settings.humanName}'s own by its id)`);
|
|
1529
|
+
patch.look = lookId;
|
|
1530
|
+
if (lookId !== current.look)
|
|
1531
|
+
rows.push({ key: "look", from: current.look, to: lookId });
|
|
1532
|
+
}
|
|
1533
|
+
if (changes.adjust !== undefined) {
|
|
1534
|
+
if (!changes.adjust || typeof changes.adjust !== "object" || Array.isArray(changes.adjust))
|
|
1535
|
+
throw new Error("not proposed: adjust is an object of adjustable keys and values");
|
|
1536
|
+
const lookId = String(changes.look ?? current.look);
|
|
1537
|
+
const own = await this.skills.appearance.ownAdjustments(lookId);
|
|
1538
|
+
if (!own)
|
|
1539
|
+
throw new Error(`not proposed: no look "${lookId}" to fine-tune`);
|
|
1540
|
+
const merged = { ...(current.custom?.[lookId] ?? {}), ...changes.adjust };
|
|
1541
|
+
patch.custom = { [lookId]: merged };
|
|
1542
|
+
const previewed = this.skills.appearance.preview({ custom: { [lookId]: merged } });
|
|
1543
|
+
for (const key of Object.keys(changes.adjust)) {
|
|
1544
|
+
const from = current.custom?.[lookId]?.[key] ?? own.values[key] ?? "";
|
|
1545
|
+
const to = previewed.custom[lookId]?.[key] ?? "";
|
|
1546
|
+
if (String(from).toLowerCase() !== String(to).toLowerCase())
|
|
1547
|
+
rows.push({ key: `${key} (${own.label})`, from, to });
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
for (const key of ["chatFontSize", "font", "mono"]) {
|
|
1551
|
+
if (changes[key] === undefined)
|
|
1552
|
+
continue;
|
|
1553
|
+
patch[key] = changes[key];
|
|
1554
|
+
const previewed = this.skills.appearance.preview({ [key]: changes[key] });
|
|
1555
|
+
if (String(previewed[key]) !== String(current[key]))
|
|
1556
|
+
rows.push({ key: key === "chatFontSize" ? "text size" : key === "font" ? "font" : "code font", from: current[key], to: previewed[key] });
|
|
1557
|
+
}
|
|
1558
|
+
this.skills.appearance.preview(patch);
|
|
1559
|
+
if (!rows.length)
|
|
1560
|
+
throw new Error("not proposed: the change leaves the window as it is");
|
|
1561
|
+
const proposal = {
|
|
1562
|
+
key: randomUUID(),
|
|
1563
|
+
participantId,
|
|
1564
|
+
participantName: participant.name,
|
|
1565
|
+
ts: Date.now(),
|
|
1566
|
+
why: String(why ?? "").trim().slice(0, 600),
|
|
1567
|
+
settings: [],
|
|
1568
|
+
vibemates: [],
|
|
1569
|
+
appearance: rows,
|
|
1570
|
+
warnings: [],
|
|
1571
|
+
touchesOwn: false,
|
|
1572
|
+
status: "pending",
|
|
1573
|
+
};
|
|
1574
|
+
this.proposalPlans.set(proposal.key, { settings: [], vibemates: [], ops: [], ids: {}, appearance: patch });
|
|
1575
|
+
this.proposals.set(proposal.key, proposal);
|
|
1576
|
+
this.push({ type: "proposal", proposal });
|
|
1577
|
+
const what = rows.map((c) => c.key).join(", ");
|
|
1578
|
+
this.postSystem(`${participant.name} proposes a change to how the window looks (${what}); apply or reject it on the card.`, "human", false, { tone: "attention" });
|
|
1579
|
+
this.log.info(`look proposal ${proposal.key} from ${participant.name}: ${what}`);
|
|
1580
|
+
return {
|
|
1581
|
+
ok: true,
|
|
1582
|
+
message: `Proposal sent to ${this.settings.humanName} as a card in the room (${what}). It changes the whole window, not this room alone; nothing changes until they apply it, and you will see a room line with the outcome.`,
|
|
1583
|
+
key: proposal.key,
|
|
1584
|
+
warnings: [],
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1446
1587
|
async resolveProposal(key, accept) {
|
|
1447
1588
|
const proposal = this.proposals.get(key);
|
|
1448
1589
|
const plan = this.proposalPlans.get(key);
|
|
@@ -1450,7 +1591,7 @@ export class Room extends EventEmitter {
|
|
|
1450
1591
|
throw new Error("no such pending proposal");
|
|
1451
1592
|
if (proposal.status !== "pending")
|
|
1452
1593
|
return proposal;
|
|
1453
|
-
const what = [...proposal.settings.map((c) => c.key), ...proposal.vibemates.map((o) => `${o.op} ${o.name}`)].join(", ");
|
|
1594
|
+
const what = [...proposal.settings.map((c) => c.key), ...proposal.vibemates.map((o) => `${o.op} ${o.name}`), ...(proposal.appearance ?? []).map((c) => c.key)].join(", ");
|
|
1454
1595
|
if (!accept) {
|
|
1455
1596
|
proposal.status = "rejected";
|
|
1456
1597
|
this.proposalPlans.delete(key);
|
|
@@ -1505,6 +1646,16 @@ export class Room extends EventEmitter {
|
|
|
1505
1646
|
patch[c.key] = c.key === "language" ? c.to : c.to;
|
|
1506
1647
|
this.updateSettings(patch);
|
|
1507
1648
|
}
|
|
1649
|
+
if (plan.appearance) {
|
|
1650
|
+
try {
|
|
1651
|
+
if (!this.skills?.appearance)
|
|
1652
|
+
throw new Error("looks are not available in this hub");
|
|
1653
|
+
this.skills.appearance.apply(plan.appearance);
|
|
1654
|
+
}
|
|
1655
|
+
catch (error) {
|
|
1656
|
+
skipped.push(`appearance (${error instanceof Error ? error.message : String(error)})`);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1508
1659
|
proposal.status = "applied";
|
|
1509
1660
|
proposal.skipped = skipped;
|
|
1510
1661
|
this.proposalPlans.delete(key);
|
|
@@ -1697,10 +1848,9 @@ export class Room extends EventEmitter {
|
|
|
1697
1848
|
if (!this.skills)
|
|
1698
1849
|
return { ok: false, reason: "skills are not available in this hub" };
|
|
1699
1850
|
const skill = this.skills.library.get(name);
|
|
1700
|
-
const builtin = !!skill && skill.author === BUILTIN_AUTHOR && !skill.draft;
|
|
1701
1851
|
const mine = this.attachedSkills(participant).filter((s) => s.agentInvocable).map((s) => s.name);
|
|
1702
1852
|
const list = mine.length ? `your skills: ${mine.join(", ")}` : "you have no skills";
|
|
1703
|
-
if (!
|
|
1853
|
+
if (!this.hasSkill(participant, name) || !mine.some((s) => s.toLowerCase() === name.toLowerCase())) {
|
|
1704
1854
|
return { ok: false, reason: `"${name}" is not one of your skills (${list})` };
|
|
1705
1855
|
}
|
|
1706
1856
|
if (!skill || skill.problems.length)
|
|
@@ -2447,7 +2597,7 @@ export class Room extends EventEmitter {
|
|
|
2447
2597
|
this.notice(`${participant.name}: agent process exited.`, "error");
|
|
2448
2598
|
}
|
|
2449
2599
|
}
|
|
2450
|
-
async openSession(agent, cwd, log, mcpServers = []) {
|
|
2600
|
+
async openSession(agent, cwd, log, mcpServers = [], recipe) {
|
|
2451
2601
|
try {
|
|
2452
2602
|
return await agent.newSession(cwd, mcpServers);
|
|
2453
2603
|
}
|
|
@@ -2455,12 +2605,22 @@ export class Room extends EventEmitter {
|
|
|
2455
2605
|
if (!isAuthRequired(error) || !agent.authMethods.length)
|
|
2456
2606
|
throw error;
|
|
2457
2607
|
log.info(`session/new needs authentication: ${describeError(error)}`);
|
|
2608
|
+
if (recipe?.loginState === "missing")
|
|
2609
|
+
throw new Error(`authentication required; log in with the agent's own CLI first${recipe.loginCommand ? ` (${recipe.loginCommand})` : ""}`);
|
|
2458
2610
|
}
|
|
2459
2611
|
const failures = [];
|
|
2460
2612
|
for (const method of agent.authMethods) {
|
|
2613
|
+
if (method.type === "terminal") {
|
|
2614
|
+
failures.push(`${method.id}: needs a terminal`);
|
|
2615
|
+
continue;
|
|
2616
|
+
}
|
|
2461
2617
|
log.info(`authenticate with "${method.id}"${method.name ? ` (${method.name})` : ""}`);
|
|
2462
2618
|
try {
|
|
2463
|
-
await agent.authenticate(method.id);
|
|
2619
|
+
const done = await Promise.race([agent.authenticate(method.id).then(() => true), delay(AUTH_WAIT_MS).then(() => false)]);
|
|
2620
|
+
if (!done) {
|
|
2621
|
+
failures.push(`${method.id}: no answer in ${AUTH_WAIT_MS / 1000} s (a sign-in that needs you)`);
|
|
2622
|
+
continue;
|
|
2623
|
+
}
|
|
2464
2624
|
return await agent.newSession(cwd, mcpServers);
|
|
2465
2625
|
}
|
|
2466
2626
|
catch (error) {
|
|
@@ -2537,7 +2697,7 @@ export class Room extends EventEmitter {
|
|
|
2537
2697
|
participant.mode = pick("mode") ?? participant.mode;
|
|
2538
2698
|
}
|
|
2539
2699
|
failStart(participant, error, fresh, stderr = []) {
|
|
2540
|
-
participant.statusDetail =
|
|
2700
|
+
participant.statusDetail = describeError(error);
|
|
2541
2701
|
const recipe = getRecipe(participant.agentType ?? "");
|
|
2542
2702
|
participant.trouble = classifyStartFailure({
|
|
2543
2703
|
error: participant.statusDetail,
|
|
@@ -2696,7 +2856,11 @@ function isAuthRequired(error) {
|
|
|
2696
2856
|
return error instanceof Error && /auth/i.test(error.message);
|
|
2697
2857
|
}
|
|
2698
2858
|
function describeError(error) {
|
|
2699
|
-
|
|
2859
|
+
if (!(error instanceof Error))
|
|
2860
|
+
return String(error);
|
|
2861
|
+
const data = error.rpc?.data ?? error.data;
|
|
2862
|
+
const detail = typeof data === "string" ? data : data && typeof data === "object" && typeof data.details === "string" ? data.details : "";
|
|
2863
|
+
return detail && !error.message.includes(detail) ? `${error.message}: ${detail}` : error.message;
|
|
2700
2864
|
}
|
|
2701
2865
|
function looksSilent(text) {
|
|
2702
2866
|
const t = text.trim().toLowerCase();
|