viberoom 0.5.7 → 0.5.9

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/README.md CHANGED
@@ -14,7 +14,7 @@ Open a room, summon the agents you already have, give each one a role, and let t
14
14
  </p>
15
15
 
16
16
  <p align="center">
17
- <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/conversation.png" width="960" alt="A Wren & Quinn room: Wren explains a git command from a screenshot, Quinn adds the part that changes whose problem it is">
17
+ <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/conversation.png" width="960" alt="A room with two vibemates: one explains a git command from a screenshot, the other adds the part that changes whose problem it is">
18
18
  </p>
19
19
 
20
20
  <p align="center">
@@ -80,6 +80,16 @@ library marks it as theirs until you have read it.
80
80
 
81
81
  <br>
82
82
 
83
+ ## Let them design the room
84
+
85
+ A vibemate can design a room as well as work in it. It has a built-in skill on what makes rules and
86
+ roles good, and four hub tools: read the room's settings and rules, check a design and preview the
87
+ brief the others would receive, save a template for you to pick under New room, or propose a change to
88
+ the room you are in. A proposal is a card in the chat with the diff; nothing changes until you click
89
+ Apply, and the room is told what you decided.
90
+
91
+ <br>
92
+
83
93
  ## Talk to all, or to one
84
94
 
85
95
  <p align="center">
@@ -167,6 +177,14 @@ skills and the log.
167
177
 
168
178
  <br>
169
179
 
180
+ ## Questions, ideas, bugs
181
+
182
+ - A question ("how do I ...?") goes to [Discussions → Q&A](https://github.com/todor-rusev/viberoom/discussions/categories/q-a).
183
+ - An idea goes to [Discussions → Ideas](https://github.com/todor-rusev/viberoom/discussions/categories/ideas).
184
+ - A bug goes to [Issues](https://github.com/todor-rusev/viberoom/issues/new/choose); the form asks for what a fix needs.
185
+
186
+ <br>
187
+
170
188
  ## Development
171
189
 
172
190
  ```sh
package/dist/context.js CHANGED
@@ -25,6 +25,12 @@ export function crossedThreshold(previousUsed, used, size, threshold = NOTES_THR
25
25
  return false;
26
26
  return used / size >= threshold && previousUsed / size < threshold;
27
27
  }
28
+ export function emptyUsageReport(previousUsed, used) {
29
+ return previousUsed > 0 && used === 0;
30
+ }
31
+ export function looksCompacted(previousUsed, used) {
32
+ return previousUsed > 0 && used > 0 && used < previousUsed * 0.7;
33
+ }
28
34
  export function overThreshold(used, size, threshold = NOTES_THRESHOLD) {
29
35
  return size > 0 && used / size >= threshold;
30
36
  }
package/dist/hub.js CHANGED
@@ -10,7 +10,7 @@ import { listRecipes } from "./recipes.js";
10
10
  import { DEFAULT_ROOM_SETTINGS } from "./persona.js";
11
11
  import { Room } from "./room.js";
12
12
  import { SkillLibrary } from "./skills.js";
13
- import { TemplateLibrary } from "./templates.js";
13
+ import { TemplateLibrary, roomSettingsFromTemplate } from "./templates.js";
14
14
  export const TEXT_FONTS = ["nunito", "inter", "noto-sans", "arial", "system"];
15
15
  export const MONO_FONTS = ["jetbrains-mono", "fira-code", "source-code-pro", "system"];
16
16
  export const DEFAULT_APPEARANCE = { chatFontSize: 14.5, font: "nunito", mono: "jetbrains-mono" };
@@ -45,6 +45,8 @@ export class Hub extends EventEmitter {
45
45
  library: this.skills,
46
46
  serverScript: fileURLToPath(new URL("./mcp-skills-server.js", import.meta.url)),
47
47
  hubUrl: () => this.hubUrl,
48
+ templates: this.templates,
49
+ templatesChanged: () => this.emit("event", { type: "templates" }),
48
50
  needApproval: () => this.settings.agentSkillsNeedApproval === true,
49
51
  save: (draft) => {
50
52
  const { body: _b, ...meta } = this.saveSkillInternal(draft);
@@ -332,7 +334,7 @@ export class Hub extends EventEmitter {
332
334
  const template = this.templates.get(input.templateId);
333
335
  if (!template)
334
336
  throw new Error(`no such template: ${input.templateId}`);
335
- const { room, notices } = this.createRoom({ name: input.name, dir: input.dir || template.dir || null, settings: template.settings });
337
+ const { room, notices } = this.createRoom({ name: input.name, dir: input.dir || template.dir || null, settings: roomSettingsFromTemplate(template) });
336
338
  const installed = new Set(listRecipes().filter((r) => !r.unavailableReason).map((r) => r.id));
337
339
  for (const [i, tv] of template.vibemates.entries()) {
338
340
  const choice = { ...(input.vibemates[i] ?? { name: tv.name, agentType: "" }) };
package/dist/main.js CHANGED
@@ -192,7 +192,8 @@ function openWindow(url, options, log) {
192
192
  }
193
193
  catch {
194
194
  }
195
- spawn(chromium, args, { detached: true, stdio: "ignore" }).unref();
195
+ mkdirSync(options.dataDir, { recursive: true });
196
+ spawn(chromium, args, { cwd: options.dataDir, detached: true, stdio: "ignore" }).unref();
196
197
  if (process.platform === "win32") {
197
198
  const shortcuts = windowsShortcutPaths(homedir(), process.env, true).filter((p) => existsSync(p));
198
199
  if (shortcuts.length)
@@ -325,7 +326,7 @@ async function startBackground(options, log, info) {
325
326
  const args = [fileURLToPath(import.meta.url), "serve", "--port", String(options.port), "--data-dir", options.dataDir, "--no-open"];
326
327
  if (options.name)
327
328
  args.push("--name", options.name);
328
- const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", fd, fd], windowsHide: true });
329
+ const child = spawn(process.execPath, args, { cwd: options.dataDir, detached: true, stdio: ["ignore", fd, fd], windowsHide: true });
329
330
  child.unref();
330
331
  closeSync(fd);
331
332
  log.info(`hub started in the background (pid ${child.pid}); log: ${logPath}`);
@@ -13,6 +13,33 @@ const SKILL_FIELDS = {
13
13
  agent_invocable: { type: "boolean", description: "optional (default true): agents may load it themselves" },
14
14
  dry_run: { type: "boolean", description: "optional: only lint, write nothing" },
15
15
  };
16
+ const DESIGN_FIELDS = {
17
+ kind: { type: "string", enum: ["template", "room"], description: "template: a whole template (name, description, vibemates); room: a change to this room, starting from its current settings" },
18
+ name: { type: "string", description: "the template's name (1-40 characters; the id is derived from it)" },
19
+ description: { type: "string", description: "what the room is for and how it feels, two sentences; shown in the picker" },
20
+ emoji: { type: "string", description: "optional: the room's emoji" },
21
+ settings: {
22
+ type: "object",
23
+ description: "room settings by key, only the ones you set; describe_room lists the keys with their meaning, bounds and defaults. Rules go in customRules, one per line.",
24
+ additionalProperties: true,
25
+ },
26
+ vibemates: {
27
+ type: "array",
28
+ description: "the vibemates: name (1-24 letters, digits, _ or -), tagline (the one line the others see, up to 80 characters), role (who this one is and which way it leans; private), avatar (one emoji), skills (names from the library)",
29
+ items: {
30
+ type: "object",
31
+ properties: {
32
+ name: { type: "string" },
33
+ tagline: { type: "string" },
34
+ role: { type: "string" },
35
+ avatar: { type: "string" },
36
+ skills: { type: "array", items: { type: "string" } },
37
+ replyDelay: { type: "number", description: "optional: seconds this vibemate waits before a turn, overriding the room's delay" },
38
+ },
39
+ required: ["name"],
40
+ },
41
+ },
42
+ };
16
43
  const TOOLS = [
17
44
  {
18
45
  name: TOOL_NAME,
@@ -45,6 +72,58 @@ const TOOLS = [
45
72
  required: ["name"],
46
73
  },
47
74
  },
75
+ {
76
+ name: "describe_room",
77
+ description: "The facts about this room before you design anything: its settings with their meaning, bounds, defaults and current values; the rules; the vibemates (name, tagline, role, avatar, skills); the skill library; the templates that exist; and the brief you yourself receive. Read-only. Load the built-in skill \"room-designer\" for what makes rules and roles good.",
78
+ inputSchema: { type: "object", properties: {} },
79
+ annotations: { readOnlyHint: true },
80
+ },
81
+ {
82
+ name: "lint_room_design",
83
+ description: "Check a room design without saving anything: the same errors and warnings create_template / propose_room_changes would give, plus a preview of the brief the first vibemate would receive (exactly what the room will read). kind \"template\" checks a whole template; kind \"room\" checks a change to this room, starting from its current settings.",
84
+ inputSchema: { type: "object", properties: DESIGN_FIELDS, required: ["kind"] },
85
+ annotations: { readOnlyHint: true },
86
+ },
87
+ {
88
+ name: "create_template",
89
+ description: "Save a room template into the human's library: a file the human picks under New room to create a room with these settings, rules and vibemates. No effect on any existing room. The hub checks the design first (errors stop the save, warnings come back with it). A taken name gets a numbered id unless replace is true and the template is one you or the human made.",
90
+ inputSchema: {
91
+ type: "object",
92
+ properties: {
93
+ name: DESIGN_FIELDS.name,
94
+ description: DESIGN_FIELDS.description,
95
+ emoji: DESIGN_FIELDS.emoji,
96
+ settings: DESIGN_FIELDS.settings,
97
+ vibemates: DESIGN_FIELDS.vibemates,
98
+ replace: { type: "boolean", description: "optional: overwrite the template with this name instead of saving a numbered copy (never a template viberoom ships)" },
99
+ },
100
+ required: ["name", "description", "vibemates"],
101
+ },
102
+ },
103
+ {
104
+ name: "propose_room_changes",
105
+ description: "Propose changes to this room: settings by key (rules in customRules, one per line) and vibemates to add, update or remove. The hub checks the change set like a template, then shows the human a card with the diff and the warnings; nothing changes until the human clicks Apply, and the room gets a line with the outcome. A new vibemate is added waiting for the human to pick its coding agent. Say in why what the change fixes.",
106
+ inputSchema: {
107
+ type: "object",
108
+ properties: {
109
+ why: { type: "string", description: "one or two sentences: what this change fixes or enables; shown on the card" },
110
+ settings: DESIGN_FIELDS.settings,
111
+ vibemates: {
112
+ type: "object",
113
+ description: "optional: vibemates to add (full entries), update (by name; give only the fields that change; newName renames) or remove (names)",
114
+ properties: {
115
+ add: DESIGN_FIELDS.vibemates,
116
+ update: {
117
+ type: "array",
118
+ items: { type: "object", properties: { name: { type: "string" }, newName: { type: "string" }, tagline: { type: "string" }, role: { type: "string" }, avatar: { type: "string" }, skills: { type: "array", items: { type: "string" } }, replyDelay: { type: "number" } }, required: ["name"] },
119
+ },
120
+ remove: { type: "array", items: { type: "string" } },
121
+ },
122
+ },
123
+ },
124
+ required: ["why"],
125
+ },
126
+ },
48
127
  ];
49
128
  let readySent = false;
50
129
  function send(message) {
@@ -144,6 +223,46 @@ async function handle(message) {
144
223
  reply(id, { content: [{ type: "text", text: String(res.body.message ?? "attached") }] });
145
224
  return;
146
225
  }
226
+ if (name === "describe_room") {
227
+ const res = await hub(`/api/mcp/room?token=${encodeURIComponent(TOKEN)}`);
228
+ if (!res.ok)
229
+ return errorResult("the room could not be described", res);
230
+ reply(id, { content: [{ type: "text", text: JSON.stringify(res.body, null, 2) }] });
231
+ return;
232
+ }
233
+ if (name === "lint_room_design") {
234
+ const res = await hub("/api/mcp/design/lint", {
235
+ method: "POST",
236
+ headers: { "content-type": "application/json" },
237
+ body: JSON.stringify({ token: TOKEN, ...args }),
238
+ });
239
+ if (!res.ok)
240
+ return errorResult("the design could not be checked", res);
241
+ reply(id, { content: [{ type: "text", text: JSON.stringify(res.body, null, 2) }], isError: res.body.ok === false ? true : undefined });
242
+ return;
243
+ }
244
+ if (name === "create_template") {
245
+ const res = await hub("/api/mcp/templates", {
246
+ method: "POST",
247
+ headers: { "content-type": "application/json" },
248
+ body: JSON.stringify({ token: TOKEN, ...args }),
249
+ });
250
+ if (!res.ok)
251
+ return errorResult("the template could not be saved", res);
252
+ reply(id, { content: [{ type: "text", text: String(res.body.message ?? "saved") }] });
253
+ return;
254
+ }
255
+ if (name === "propose_room_changes") {
256
+ const res = await hub("/api/mcp/propose", {
257
+ method: "POST",
258
+ headers: { "content-type": "application/json" },
259
+ body: JSON.stringify({ token: TOKEN, ...args }),
260
+ });
261
+ if (!res.ok)
262
+ return errorResult("the proposal could not be made", res);
263
+ reply(id, { content: [{ type: "text", text: String(res.body.message ?? "proposed") }] });
264
+ return;
265
+ }
147
266
  fail(id, -32602, `unknown tool: ${name}`);
148
267
  return;
149
268
  }
package/dist/persona.js CHANGED
@@ -9,39 +9,104 @@ 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;
46
111
  export function promptText(parts) {
47
112
  return parts.map((p) => (p.type === "text" ? p.text : "")).join("");
@@ -129,6 +194,7 @@ function skillsSection(skills) {
129
194
  }
130
195
  if (skills.canCreate) {
131
196
  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.`);
197
+ 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
198
  }
133
199
  else if (skills.items.length) {
134
200
  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.");
@@ -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
+ }