viberoom 0.5.8 → 0.6.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/dist/persona.js CHANGED
@@ -9,40 +9,107 @@ export function skillPull(reply) {
9
9
  return match && match[0] === reply.trim() ? match[1] : null;
10
10
  }
11
11
  export const SKILL_WRITER_NAME = "skill-writer";
12
- export const DEFAULT_ROOM_SETTINGS = {
13
- topic: "",
14
- humanDescription: "",
15
- language: { mode: "follow-human" },
16
- tools: "on-request",
17
- maxSentences: null,
18
- hopLimit: 100,
19
- fullBriefEveryTurns: 8,
20
- fullBriefEveryTokens: 20_000,
21
- headerRules: true,
22
- replayAfterRestart: 10,
23
- backlogCap: 50,
24
- showVendorInRoster: false,
25
- customRules: "",
26
- emoji: "",
27
- humanDescriptionMode: "inherit",
28
- refereeAction: "next-header",
29
- turnTaking: "parallel",
30
- replyDelay: 4,
31
- waitWhileHumanTypes: true,
32
- agentsWakeEachOther: true,
12
+ export const ROOM_DESIGNER_NAME = "room-designer";
13
+ export const NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}_-]{0,23}$/u;
14
+ export const ROOM_SETTINGS_SPEC = {
15
+ name: { kind: "own-path", brief: true, agent: false, doc: "The room's name; changed with rename." },
16
+ humanName: { kind: "own-path", brief: true, agent: false, doc: "The human's name; a program-level setting." },
17
+ topic: { kind: "text", max: 2000, default: "", brief: true, agent: true, doc: "One line about what the room is for; the brief repeats it to every vibemate." },
18
+ emoji: { kind: "text", max: 8, default: "", brief: false, agent: true, doc: "The room's emoji, shown in its title and tile." },
19
+ humanDescription: { kind: "text", max: 200, default: "", brief: true, agent: false, doc: "This room's description of the human, composed with the program-level one by humanDescriptionMode." },
20
+ humanDescriptionMode: { kind: "enum", values: ["inherit", "override", "append", "none"], default: "inherit", brief: true, agent: false, doc: "How the human's description is composed: the program-level text, this room's, both, or nothing." },
21
+ language: { kind: "language", default: { mode: "follow-human" }, brief: true, agent: true, doc: "follow-human: reply in the language of the human's latest message; or a fixed language name." },
22
+ tools: { kind: "enum", values: ["on-request", "never"], default: "on-request", brief: true, agent: true, doc: "on-request: tools only when a participant explicitly asks for something that needs them; never: a talk-only room." },
23
+ maxSentences: { kind: "integer-or-null", min: 1, max: 100, default: null, brief: true, agent: true, doc: "A hard length rule for every reply, in sentences; empty for no rule." },
24
+ hopLimit: { kind: "integer", min: 0, max: 10_000, default: 100, brief: false, agent: true, doc: "Agent-to-agent turns allowed before the hub waits for the human; a chain of three vibemates needs about three times its length." },
25
+ fullBriefEveryTurns: { kind: "integer", min: 1, max: 10_000, default: 8, brief: false, agent: true, doc: "The full brief is re-sent to a vibemate after this many of its turns." },
26
+ fullBriefEveryTokens: { kind: "integer", min: 1000, max: 10_000_000, default: 20_000, brief: false, agent: true, doc: "The full brief is re-sent once a vibemate's context grew by this many tokens." },
27
+ headerRules: { kind: "boolean", default: true, brief: false, agent: true, doc: "The per-turn header repeats the three core rules (addressing, silent, character)." },
28
+ replayAfterRestart: { kind: "integer", min: 0, max: 200, default: 10, brief: false, agent: true, doc: "Messages replayed to a vibemate whose session restarts." },
29
+ backlogCap: { kind: "integer", min: 1, max: 1000, default: 50, brief: false, agent: true, doc: "Most missed messages a vibemate reads on its next turn; older ones are dropped with a note." },
30
+ showVendorInRoster: { kind: "boolean", default: false, brief: true, agent: true, doc: "The roster in the brief names each vibemate's vendor (Claude, Codex, ...)." },
31
+ customRules: { kind: "text", max: 4000, default: "", brief: true, agent: true, doc: "The room rules, one per line; every vibemate gets them under 'Room rules (set by the human)'. @Name inside a rule is a live reference." },
32
+ refereeAction: { kind: "enum", values: ["next-header", "retry-hidden"], default: "next-header", brief: false, agent: true, doc: "On a mechanical violation (wrong language, too long): remind in the next header, or hold the reply and ask for a corrected one in a hidden turn." },
33
+ turnTaking: { kind: "enum", values: ["parallel", "one-at-a-time"], default: "parallel", brief: false, agent: true, doc: "parallel: every addressed vibemate answers at once; one-at-a-time: one speaks, the others queue and see the earlier replies first." },
34
+ waitWhileHumanTypes: { kind: "boolean", default: true, brief: false, agent: true, doc: "A vibemate about to start a turn waits while the human is typing." },
35
+ agentsWakeEachOther: { kind: "boolean", default: true, brief: true, agent: true, doc: "A vibemate's message without @ wakes the others, as the human's does; off: only @Name wakes a vibemate." },
36
+ replyDelay: { kind: "number", min: 0, max: 120, default: 4, brief: false, agent: true, doc: "Seconds (a random 0..N) every vibemate waits before a turn, so replies cross less; a vibemate's own delay overrides it." },
33
37
  };
34
- export const BRIEF_AFFECTING_SETTINGS = [
35
- "topic",
36
- "humanDescription",
37
- "humanDescriptionMode",
38
- "language",
39
- "tools",
40
- "maxSentences",
41
- "showVendorInRoster",
42
- "customRules",
43
- "agentsWakeEachOther",
44
- ];
38
+ function defaultsFromSpec() {
39
+ const out = {};
40
+ for (const [key, spec] of Object.entries(ROOM_SETTINGS_SPEC))
41
+ if (spec.kind !== "own-path")
42
+ out[key] = spec.default;
43
+ return out;
44
+ }
45
+ export const DEFAULT_ROOM_SETTINGS = defaultsFromSpec();
46
+ export const BRIEF_AFFECTING_SETTINGS = Object.keys(ROOM_SETTINGS_SPEC).filter((key) => ROOM_SETTINGS_SPEC[key].brief);
47
+ export const AGENT_SETTINGS = Object.keys(ROOM_SETTINGS_SPEC).filter((key) => ROOM_SETTINGS_SPEC[key].agent);
48
+ export function coerceSetting(key, raw) {
49
+ const spec = ROOM_SETTINGS_SPEC[key];
50
+ switch (spec.kind) {
51
+ case "own-path":
52
+ throw new Error(`${key} is not a settings field`);
53
+ case "integer": {
54
+ const value = Number(raw);
55
+ if (!Number.isInteger(value) || value < spec.min || value > spec.max)
56
+ throw new Error(`${key} must be an integer between ${spec.min} and ${spec.max}`);
57
+ return value;
58
+ }
59
+ case "integer-or-null": {
60
+ const value = raw === null || raw === "" ? null : Number(raw);
61
+ if (value !== null && (!Number.isInteger(value) || value < spec.min || value > spec.max))
62
+ throw new Error(`${key} must be ${spec.min}-${spec.max} or empty`);
63
+ return value;
64
+ }
65
+ case "number": {
66
+ const value = Number(raw);
67
+ if (!Number.isFinite(value) || value < spec.min || value > spec.max)
68
+ throw new Error(`${key} must be between ${spec.min} and ${spec.max} seconds`);
69
+ return value;
70
+ }
71
+ case "boolean":
72
+ return (raw === true || raw === "true");
73
+ case "enum": {
74
+ const value = String(raw);
75
+ if (!spec.values.includes(value))
76
+ throw new Error(`${key} must be ${spec.values.join(" or ")}`);
77
+ return value;
78
+ }
79
+ case "text":
80
+ return String(raw).slice(0, spec.max);
81
+ case "language": {
82
+ if (raw && typeof raw === "object") {
83
+ const o = raw;
84
+ if (o.mode === "fixed" && typeof o.language === "string" && o.language.trim())
85
+ return { mode: "fixed", language: o.language.trim() };
86
+ return { mode: "follow-human" };
87
+ }
88
+ const text = String(raw ?? "").trim();
89
+ return (!text || text === "follow-human" ? { mode: "follow-human" } : { mode: "fixed", language: text });
90
+ }
91
+ }
92
+ }
93
+ export function describeSettings(current) {
94
+ return AGENT_SETTINGS.map((key) => {
95
+ const spec = ROOM_SETTINGS_SPEC[key];
96
+ const range = spec.kind === "integer" || spec.kind === "number"
97
+ ? `${spec.min}..${spec.max}`
98
+ : spec.kind === "integer-or-null"
99
+ ? `${spec.min}..${spec.max} or null`
100
+ : spec.kind === "enum"
101
+ ? spec.values.join(" | ")
102
+ : spec.kind === "text"
103
+ ? `up to ${spec.max} characters`
104
+ : spec.kind === "language"
105
+ ? '"follow-human" or a language name'
106
+ : undefined;
107
+ return { key, doc: spec.doc, kind: spec.kind, range, default: spec.kind === "own-path" ? undefined : spec.default, value: current[key], affectsBrief: spec.brief };
108
+ });
109
+ }
45
110
  export const IMAGE_MARKER_PATTERN = /\[img\s+(\d+)\]/gi;
111
+ export const QUOTE_MARKER_PATTERN = /\[quote\s+(\d+)\]/gi;
112
+ const MARKER_PATTERN = /\[(img|quote)\s+(\d+)\]/gi;
46
113
  export function promptText(parts) {
47
114
  return parts.map((p) => (p.type === "text" ? p.text : "")).join("");
48
115
  }
@@ -52,6 +119,18 @@ function imageMarker(image) {
52
119
  const who = image.forNames.length ? ` · for ${image.forNames.join(", ")}` : "";
53
120
  return `[img ${image.n} · ${image.ref}${who} · ${image.path}]`;
54
121
  }
122
+ export function formatQuoteTime(ts) {
123
+ const d = new Date(ts);
124
+ const p = (n) => String(n).padStart(2, "0");
125
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
126
+ }
127
+ export function quoteBlock(quote) {
128
+ const head = `> ${quote.fromName} (#${quote.seq}, ${formatQuoteTime(quote.ts)}):`;
129
+ const lines = quote.text.split(/\r?\n/);
130
+ if (lines.length === 1)
131
+ return `${head} ${lines[0]}`;
132
+ return [head, ...lines.map((l) => `> ${l}`)].join("\n");
133
+ }
55
134
  function messageParts(line) {
56
135
  const parts = [];
57
136
  let text = "";
@@ -60,32 +139,70 @@ function messageParts(line) {
60
139
  parts.push({ type: "text", text });
61
140
  text = "";
62
141
  };
63
- const place = (image) => {
64
- text += imageMarker(image);
142
+ const images = line.images ?? [];
143
+ const quotes = line.quotes ?? [];
144
+ const placedImages = new Set();
145
+ const placedQuotes = new Set();
146
+ let breakAfterQuote = false;
147
+ const append = (s) => {
148
+ if (!s)
149
+ return;
150
+ if (breakAfterQuote) {
151
+ if (!s.startsWith("\n"))
152
+ text += "\n";
153
+ s = s.replace(/^[ \t]+/, "");
154
+ breakAfterQuote = false;
155
+ }
156
+ text += s;
157
+ };
158
+ const placeImage = (image) => {
159
+ append(imageMarker(image));
65
160
  if (image.attached) {
66
161
  flush();
67
162
  parts.push({ type: "image", image });
68
163
  }
69
164
  };
70
- const images = line.images ?? [];
71
- const placed = new Set();
165
+ const placeQuote = (quote) => {
166
+ if (breakAfterQuote)
167
+ text += "\n";
168
+ text = text.replace(/[ \t]+$/, "");
169
+ if (text && !text.endsWith("\n"))
170
+ text += "\n";
171
+ text += quoteBlock(quote);
172
+ breakAfterQuote = true;
173
+ };
72
174
  let last = 0;
73
- for (const match of line.text.matchAll(IMAGE_MARKER_PATTERN)) {
74
- const image = images.find((i) => i.n === Number(match[1]));
75
- if (!image || placed.has(image.n))
76
- continue;
77
- placed.add(image.n);
78
- text += line.text.slice(last, match.index);
79
- place(image);
175
+ for (const match of line.text.matchAll(MARKER_PATTERN)) {
176
+ const n = Number(match[2]);
177
+ if (match[1].toLowerCase() === "img") {
178
+ const image = images.find((i) => i.n === n);
179
+ if (!image || placedImages.has(n))
180
+ continue;
181
+ placedImages.add(n);
182
+ append(line.text.slice(last, match.index));
183
+ placeImage(image);
184
+ }
185
+ else {
186
+ const quote = quotes.find((q) => q.n === n);
187
+ if (!quote || placedQuotes.has(n))
188
+ continue;
189
+ placedQuotes.add(n);
190
+ append(line.text.slice(last, match.index));
191
+ placeQuote(quote);
192
+ }
80
193
  last = (match.index ?? 0) + match[0].length;
81
194
  }
82
- text += line.text.slice(last);
195
+ append(line.text.slice(last));
196
+ for (const quote of quotes)
197
+ if (!placedQuotes.has(quote.n))
198
+ placeQuote(quote);
199
+ breakAfterQuote = false;
83
200
  for (const image of images) {
84
- if (placed.has(image.n))
201
+ if (placedImages.has(image.n))
85
202
  continue;
86
203
  if (!text.endsWith("\n") && (text || parts.length))
87
204
  text += "\n";
88
- place(image);
205
+ placeImage(image);
89
206
  }
90
207
  flush();
91
208
  return parts;
@@ -129,6 +246,7 @@ function skillsSection(skills) {
129
246
  }
130
247
  if (skills.canCreate) {
131
248
  lines.push(`You may also create skills for the shared library when a procedure is worth reusing (by you later, or by other agents): first load the built-in skill "${SKILL_WRITER_NAME}" with the viberoom ${SKILL_TOOL_NAME} tool for the rules of a good skill, then call the viberoom tools create_skill (name, description, instructions) and attach_skill to give it to yourself or to other agents. These are MCP tools of the "viberoom" server, not your own skill commands. The human sees every new skill in Settings.`);
249
+ lines.push(`You may also design rooms: load the built-in skill "${ROOM_DESIGNER_NAME}" first, then describe_room for the facts, lint_room_design to check a draft (it previews the brief the vibemates would read), create_template to save a template the human can pick under New room, and propose_room_changes to suggest a change to this room: it becomes a card the human applies or rejects, so nothing here changes without their click.`);
132
250
  }
133
251
  else if (skills.items.length) {
134
252
  lines.push("Skills are created by the human or by agents that have the hub's tools; if you want a new one, describe it in the room.");
@@ -181,6 +299,9 @@ export function buildBrief(settings, persona, roster, previousNotes, skills) {
181
299
  lines.push(...skillsSection(skills));
182
300
  lines.push("");
183
301
  lines.push(`How prompts look: <room-header> (who you are, who is here, the hop counter, hub notes), then <messages> (everything posted since your previous turn, oldest first, as "Name -> @Target: text"; room events as "· text"), then "Reply as ${persona.name}." Your own earlier messages are not repeated. Reply with the text of your message only.`);
302
+ lines.push(`A line "> Name (#N, date time): …" inside a message quotes an earlier message of this room, pasted by the writer: those are Name's words, not the writer's, and #N is the hub's number of that message. ${skills?.channel === "tool"
303
+ ? "When the fragment is not enough, the viberoom tool read_message takes the number and returns the whole message (around: N adds its neighbours)."
304
+ : "When the fragment is not enough, ask in the room for the whole message."}`);
184
305
  if (previousNotes && previousNotes.trim()) {
185
306
  lines.push("");
186
307
  lines.push(`Notes from your previous session (written by you): ${previousNotes.trim()}`);
package/dist/quotes.js ADDED
@@ -0,0 +1,41 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ export const QUOTES_PER_MESSAGE = 6;
3
+ export const QUOTE_MAX_CHARS = 4000;
4
+ export const READ_AROUND_MAX = 5;
5
+ export function resolveQuotes(inputs, messages) {
6
+ const out = [];
7
+ const used = new Set();
8
+ let next = 1;
9
+ for (const input of inputs.slice(0, QUOTES_PER_MESSAGE)) {
10
+ const seq = Number(input?.seq);
11
+ const source = Number.isInteger(seq) ? messages.find((m) => m.seq === seq) : undefined;
12
+ if (!source)
13
+ throw new Error(`quoted message #${String(input?.seq)} is not in this room`);
14
+ if (source.kind !== "chat")
15
+ throw new Error(`message #${seq} is not a chat message; only those can be quoted`);
16
+ const text = String(input.text ?? "").trim().slice(0, QUOTE_MAX_CHARS) || source.text.trim().slice(0, QUOTE_MAX_CHARS);
17
+ if (!text)
18
+ throw new Error(`message #${seq} has no text to quote`);
19
+ const wanted = Number(input.n);
20
+ let n = Number.isInteger(wanted) && wanted > 0 && !used.has(wanted) ? wanted : 0;
21
+ if (!n) {
22
+ while (used.has(next))
23
+ next += 1;
24
+ n = next;
25
+ }
26
+ used.add(n);
27
+ out.push({ n, seq, from: source.from, fromName: source.fromName, ts: source.ts, text });
28
+ }
29
+ return out;
30
+ }
31
+ export function visibleToAgents(message) {
32
+ return message.kind !== "hidden" && message.audience !== "human";
33
+ }
34
+ export function agentReadableWindow(messages, seq, around) {
35
+ const visible = messages.filter(visibleToAgents);
36
+ const index = visible.findIndex((m) => m.seq === seq);
37
+ if (index < 0)
38
+ return null;
39
+ const span = Math.min(READ_AROUND_MAX, Math.max(0, Math.floor(Number(around) || 0)));
40
+ return { message: visible[index], before: visible.slice(Math.max(0, index - span), index), after: visible.slice(index + 1, index + 1 + span) };
41
+ }
package/dist/recipes.js CHANGED
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
5
5
  import { createRequire } from "node:module";
6
6
  import { dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { loginState } from "./agent-health.js";
8
9
  const isWindows = process.platform === "win32";
9
10
  function resolvePackageEntry(packageName, relativeEntry) {
10
11
  try {
@@ -167,6 +168,9 @@ const recipes = [
167
168
  unavailableReason: claudeExe ? null : "Claude Code not found",
168
169
  installedAt: claudeExe,
169
170
  installHint: "install Claude Code (npm install -g @anthropic-ai/claude-code, or the native installer) and log in with `claude`",
171
+ loginCommand: "claude",
172
+ login: { env: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"], files: [".claude/.credentials.json"], command: "claude", fileless: ["darwin"] },
173
+ loginState: "unknown",
170
174
  build: ({ model }) => ({
171
175
  command: process.execPath,
172
176
  args: [claudeAdapter],
@@ -190,6 +194,9 @@ const recipes = [
190
194
  unavailableReason: codexExe ? null : "Codex CLI not found",
191
195
  installedAt: codexExe,
192
196
  installHint: "install Codex (npm install -g @openai/codex) and log in with `codex login`",
197
+ loginCommand: "codex login",
198
+ login: { env: ["CODEX_API_KEY", "OPENAI_API_KEY"], files: [".codex/auth.json"], command: "codex login" },
199
+ loginState: "unknown",
193
200
  build: () => ({
194
201
  command: process.execPath,
195
202
  args: [codexAdapter],
@@ -213,6 +220,9 @@ const recipes = [
213
220
  unavailableReason: geminiEntry ? null : "Gemini CLI not found",
214
221
  installedAt: geminiEntry,
215
222
  installHint: "npm install -g @google/gemini-cli, then sign in once (gemini)",
223
+ loginCommand: "gemini",
224
+ login: { env: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS"], files: [".gemini/google_accounts.json", ".gemini/oauth_creds.json"], command: "gemini" },
225
+ loginState: "unknown",
216
226
  modelAtLaunch: true,
217
227
  build: ({ model }) => ({
218
228
  command: process.execPath,
@@ -237,6 +247,9 @@ const recipes = [
237
247
  unavailableReason: cursorAgent ? null : "Cursor CLI not found",
238
248
  installedAt: cursorAgent?.index ?? null,
239
249
  installHint: "install cursor-agent (cursor.com/cli), then agent login",
250
+ loginCommand: "cursor-agent login",
251
+ login: { env: ["CURSOR_API_KEY"], files: [], command: "cursor-agent login" },
252
+ loginState: "unknown",
240
253
  build: () => ({
241
254
  command: cursorAgent?.node ?? "",
242
255
  args: [cursorAgent?.index ?? "", "acp"],
@@ -260,6 +273,9 @@ const recipes = [
260
273
  unavailableReason: openCodeExe ? null : "OpenCode not found",
261
274
  installedAt: openCodeExe,
262
275
  installHint: "npm install -g opencode-ai (or curl -fsSL https://opencode.ai/install | bash), then opencode providers",
276
+ loginCommand: "opencode auth login",
277
+ login: { env: [], files: [".local/share/opencode/auth.json", "AppData/Local/opencode/auth.json", ".config/opencode/auth.json"], command: "opencode auth login" },
278
+ loginState: "unknown",
263
279
  build: () => ({
264
280
  command: openCodeExe ?? "",
265
281
  args: ["acp"],
@@ -286,6 +302,9 @@ const recipes = [
286
302
  unavailableReason: copilotExe ? null : "GitHub Copilot CLI not found",
287
303
  installedAt: copilotExe,
288
304
  installHint: "winget install GitHub.Copilot / brew install copilot-cli / npm install -g @github/copilot, then copilot login",
305
+ loginCommand: "copilot",
306
+ login: { env: ["GITHUB_TOKEN", "GH_TOKEN", "COPILOT_API_KEY"], files: [], command: "copilot" },
307
+ loginState: "unknown",
289
308
  modelAtLaunch: true,
290
309
  build: ({ model }) => ({
291
310
  command: copilotExe ?? "",
@@ -311,13 +330,21 @@ if (fakeAgent) {
311
330
  unavailableReason: null,
312
331
  installedAt: fakeAgent,
313
332
  installHint: "",
333
+ loginCommand: "",
334
+ loginState: "ok",
314
335
  bypassMode: null,
315
336
  build: () => ({ command: process.execPath, args: [fakeAgent], env: {} }),
316
337
  });
317
338
  }
339
+ function loginEvidence() {
340
+ return { env: process.env, platform: process.platform, exists: (relative) => existsSync(join(homedir(), ...relative.split("/"))) };
341
+ }
318
342
  export function listRecipes() {
343
+ const evidence = loginEvidence();
344
+ for (const recipe of recipes)
345
+ recipe.loginState = recipe.unavailableReason ? "unknown" : loginState(recipe.login, evidence);
319
346
  return recipes;
320
347
  }
321
348
  export function getRecipe(id) {
322
- return recipes.find((r) => r.id === id);
349
+ return listRecipes().find((r) => r.id === id);
323
350
  }
@@ -0,0 +1,205 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ import { AGENT_SETTINGS, DEFAULT_ROOM_SETTINGS, NAME_PATTERN, ROOM_SETTINGS_SPEC, SILENT_MARKER, REQUEST_BRIEF_MARKER, buildBrief, coerceSetting } from "./persona.js";
3
+ export const TAGLINE_MAX = 80;
4
+ export const ROLE_MAX = 4000;
5
+ export const AVATAR_MAX = 8;
6
+ export const RULES_SOFT_MAX = 12;
7
+ export const ROLE_SOFT_MAX = 1200;
8
+ export const RULES_NEAR_LIMIT = 3000;
9
+ export function ruleLines(customRules) {
10
+ return customRules
11
+ .split(/\r?\n/)
12
+ .map((l) => l.trim().replace(/^[-*•]\s*/, ""))
13
+ .filter((l) => l.length > 0);
14
+ }
15
+ export const BRIEF_MECHANICS = [
16
+ { pattern: /\[silent\]/i, what: `the ${SILENT_MARKER} reply` },
17
+ { pattern: /\[request-brief\]/i, what: `the ${REQUEST_BRIEF_MARKER} reply` },
18
+ { pattern: /\bmarkdown\b|\bmermaid\b/i, what: "the Markdown / mermaid format" },
19
+ { pattern: /\buse @\w*\s*to address\b|\baddress .{0,20}with @/i, what: "how @Name addressing works" },
20
+ { pattern: /\bstay in character\b/i, what: "staying in character" },
21
+ ];
22
+ export function lintRoomDesign(design, context) {
23
+ const errors = [];
24
+ const warnings = [];
25
+ const error = (code, message) => errors.push({ code, message });
26
+ const warn = (code, message) => warnings.push({ code, message });
27
+ const base = context.base ?? { ...DEFAULT_ROOM_SETTINGS, name: (design.name ?? context.roomName ?? "Room").trim() || "Room", humanName: context.humanName };
28
+ const settings = { ...base };
29
+ const raw = design.settings ?? {};
30
+ for (const [key, value] of Object.entries(raw)) {
31
+ if (value === undefined)
32
+ continue;
33
+ if (!(key in ROOM_SETTINGS_SPEC)) {
34
+ error("unknown-setting", `unknown setting "${key}" (known: ${AGENT_SETTINGS.join(", ")})`);
35
+ continue;
36
+ }
37
+ const k = key;
38
+ if (!AGENT_SETTINGS.includes(k)) {
39
+ error("setting-not-yours", `"${key}" is set by the human, not through a design`);
40
+ continue;
41
+ }
42
+ try {
43
+ settings[k] = coerceSetting(k, value);
44
+ }
45
+ catch (e) {
46
+ error("invalid-setting", e instanceof Error ? e.message : String(e));
47
+ }
48
+ }
49
+ if (design.emoji !== undefined && !settings.emoji)
50
+ settings.emoji = String(design.emoji).trim().slice(0, AVATAR_MAX);
51
+ const vibemates = design.vibemates ?? [];
52
+ if (context.kind === "template") {
53
+ if (!(design.name ?? "").trim())
54
+ error("template-no-name", "a template needs a name");
55
+ if (!(design.description ?? "").trim())
56
+ warn("template-no-description", "a template without a description is a blank card in the picker: say what the room is for and how it feels");
57
+ if (!vibemates.length)
58
+ error("template-no-vibemates", "a template needs at least one vibemate");
59
+ }
60
+ const seen = new Map();
61
+ const touched = context.changedVibemates ? new Set(context.changedVibemates.map((n) => n.trim().toLowerCase())) : null;
62
+ for (const [i, v] of vibemates.entries()) {
63
+ const who = v?.name?.trim() ? `"${v.name.trim()}"` : `vibemate ${i + 1}`;
64
+ const name = (v?.name ?? "").trim();
65
+ const soft = !touched || touched.has(name.toLowerCase());
66
+ if (!name)
67
+ error("vibemate-no-name", `${who} has no name`);
68
+ else if (!NAME_PATTERN.test(name))
69
+ error("vibemate-bad-name", `${who}: a name is 1-24 letters, digits, _ or -, no spaces`);
70
+ else {
71
+ const lower = name.toLowerCase();
72
+ if (seen.has(lower))
73
+ error("vibemate-duplicate-name", `two vibemates are named ${who}`);
74
+ seen.set(lower, i);
75
+ if (lower === context.humanName.trim().toLowerCase())
76
+ error("vibemate-human-name", `${who} is the human's name`);
77
+ }
78
+ if ((v?.tagline ?? "").length > TAGLINE_MAX)
79
+ error("tagline-too-long", `${who}: the tagline is at most ${TAGLINE_MAX} characters (it is the one line the others see)`);
80
+ else if (soft && !(v?.tagline ?? "").trim())
81
+ 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 > ROLE_MAX)
83
+ error("role-too-long", `${who}: the role is at most ${ROLE_MAX} characters`);
84
+ else if (soft && !(v?.role ?? "").trim())
85
+ warn("vibemate-no-role", `${who} has no role: without one it is the agent's default self, not a character`);
86
+ else if (soft && (v.role ?? "").length > ROLE_SOFT_MAX)
87
+ warn("role-restates-rules", `${who}: a role of ${v.role.length} characters is probably restating the protocol; a role says who this one is and which way it leans, the rules say how they work together`);
88
+ if ((v?.avatar ?? "").length > AVATAR_MAX)
89
+ error("avatar-too-long", `${who}: the avatar is one emoji`);
90
+ if (context.knownSkills) {
91
+ for (const skill of v?.skills ?? [])
92
+ if (!context.knownSkills.includes(skill))
93
+ error("unknown-skill", `${who}: no skill named "${skill}" in the library`);
94
+ }
95
+ if (context.kind === "template" && (v?.agentType || v?.model))
96
+ warn("vendor-pinned", `${who} names an agent or model: a template cannot know what this machine has, so they are suggestions at best; leave them out unless the design depends on a capability tier`);
97
+ }
98
+ const initials = new Map();
99
+ for (const v of vibemates) {
100
+ const name = (v?.name ?? "").trim();
101
+ if (!name)
102
+ continue;
103
+ const initial = name[0].toLowerCase();
104
+ initials.set(initial, [...(initials.get(initial) ?? []), name]);
105
+ }
106
+ for (const names of initials.values())
107
+ if (names.length > 1)
108
+ warn("names-share-initial", `${names.join(" and ")} start with the same letter: @-autocomplete and the human's eye tell them apart slower`);
109
+ const rules = ruleLines(settings.customRules);
110
+ if (rules.length > RULES_SOFT_MAX)
111
+ 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 > RULES_NEAR_LIMIT)
113
+ warn("rules-near-limit", `the rules are ${settings.customRules.length} characters; the hub stores at most 4000`);
114
+ for (const rule of rules) {
115
+ for (const m of BRIEF_MECHANICS)
116
+ if (m.pattern.test(rule))
117
+ warn("rule-repeats-brief", `the brief already explains ${m.what}; the rule "${rule.slice(0, 60)}${rule.length > 60 ? "…" : ""}" repeats it`);
118
+ }
119
+ if (vibemates.length >= 2 && !rules.length)
120
+ warn("no-rules", "two or more vibemates and no rules: name the situations this room will meet and answer them, for example who takes a task that names nobody, and when one of them stays quiet");
121
+ if (vibemates.length >= 2 && settings.agentsWakeEachOther && settings.hopLimit < 3 * vibemates.length)
122
+ warn("hop-limit-low", `hopLimit ${settings.hopLimit} with ${vibemates.length} vibemates who wake each other: one exchange around the room already uses ${vibemates.length}; about ${3 * vibemates.length} or more lets a handoff finish`);
123
+ if (errors.length)
124
+ return { errors, warnings };
125
+ const first = vibemates[0];
126
+ const persona = first ? { name: first.name.trim(), tagline: (first.tagline ?? "").trim(), role: (first.role ?? "").trim() } : { name: "Vibemate", tagline: "", role: "" };
127
+ const roster = [{ name: context.humanName, kind: "human" }, ...vibemates.map((v) => ({ name: v.name.trim(), kind: "agent", tagline: (v.tagline ?? "").trim() || undefined }))];
128
+ const skills = context.skills && {
129
+ items: (first?.skills ?? []).map((name) => context.skills.library.find((s) => s.name === name)).filter((s) => !!s),
130
+ channel: context.skills.channel,
131
+ canCreate: context.skills.canCreate,
132
+ };
133
+ return { errors, warnings, settings, preview: buildBrief(settings, persona, roster, undefined, skills) };
134
+ }
135
+ export function applyVibemateChanges(current, changes) {
136
+ const next = current.map((v) => ({ ...v }));
137
+ const ops = [];
138
+ const errors = [];
139
+ const find = (name) => next.findIndex((v) => v.name.toLowerCase() === name.trim().toLowerCase());
140
+ for (const name of changes?.remove ?? []) {
141
+ const i = find(String(name));
142
+ if (i < 0)
143
+ errors.push(`no vibemate named "${name}" to remove`);
144
+ else {
145
+ ops.push({ op: "remove", name: next[i].name });
146
+ next.splice(i, 1);
147
+ }
148
+ }
149
+ for (const u of changes?.update ?? []) {
150
+ const i = find(String(u?.name ?? ""));
151
+ if (i < 0) {
152
+ errors.push(`no vibemate named "${u?.name}" to update`);
153
+ continue;
154
+ }
155
+ const before = next[i];
156
+ const after = { ...before };
157
+ const fields = [];
158
+ const set = (field, value) => {
159
+ if (value === undefined)
160
+ return;
161
+ const from = before[field];
162
+ const fromText = Array.isArray(from) ? from.join(", ") : String(from ?? "");
163
+ const toText = Array.isArray(value) ? value.join(", ") : String(value ?? "");
164
+ if (fromText === toText)
165
+ return;
166
+ after[field] = value;
167
+ fields.push({ field, from: fromText, to: toText });
168
+ };
169
+ if (u.newName !== undefined)
170
+ set("name", String(u.newName).trim());
171
+ set("tagline", u.tagline);
172
+ set("role", u.role);
173
+ set("avatar", u.avatar);
174
+ set("skills", u.skills);
175
+ set("replyDelay", u.replyDelay);
176
+ if (fields.length) {
177
+ next[i] = after;
178
+ ops.push({ op: "update", name: before.name, fields });
179
+ }
180
+ }
181
+ for (const a of changes?.add ?? []) {
182
+ const v = { name: String(a?.name ?? "").trim() };
183
+ if (a?.tagline)
184
+ v.tagline = a.tagline;
185
+ if (a?.role)
186
+ v.role = a.role;
187
+ if (a?.avatar)
188
+ v.avatar = a.avatar;
189
+ if (a?.skills)
190
+ v.skills = a.skills;
191
+ if (typeof a?.replyDelay === "number")
192
+ v.replyDelay = a.replyDelay;
193
+ next.push(v);
194
+ ops.push({ op: "add", name: v.name });
195
+ }
196
+ return { next, ops, errors };
197
+ }
198
+ export function diffSettings(current, next) {
199
+ const out = [];
200
+ for (const key of AGENT_SETTINGS) {
201
+ if (JSON.stringify(current[key]) !== JSON.stringify(next[key]))
202
+ out.push({ key, from: current[key], to: next[key] });
203
+ }
204
+ return out;
205
+ }