viberoom 0.5.8 → 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/dist/room.js CHANGED
@@ -3,7 +3,7 @@ import { EventEmitter } from "node:events";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
5
5
  import { writeFileAtomic } from "./atomic.js";
6
- import { NOTES_ONLY_PROMPT, NOTES_REQUEST, crossedThreshold, extractNotes, isBareContextFullError, isContextFullError, overThreshold, visibleChunk } from "./context.js";
6
+ import { NOTES_ONLY_PROMPT, NOTES_REQUEST, crossedThreshold, emptyUsageReport, extractNotes, isBareContextFullError, isContextFullError, looksCompacted, overThreshold, visibleChunk } from "./context.js";
7
7
  import { affectedByEdit, editNotice, partitionHistory, rewriteNotice } from "./edit.js";
8
8
  import { saveImages } from "./files.js";
9
9
  import { join, resolve } from "node:path";
@@ -12,11 +12,12 @@ import { RemoteError } from "./jsonrpc.js";
12
12
  import { getRecipe, listRecipes } from "./recipes.js";
13
13
  import { composeSkillBlock, skillPull, SKILL_TOOL_NAME } from "./persona.js";
14
14
  import { BUILTIN_AUTHOR, parseSkillInvocation, renderSkillBody, SKILL_NAME_PATTERN, } from "./skills.js";
15
- import { BRIEF_AFFECTING_SETTINGS, DEFAULT_ROOM_SETTINGS, REQUEST_BRIEF_MARKER, SILENT_MARKER, buildBrief, buildHeader, composeCorrectionPrompt, composePrompt, countSentences, ensureDir, } from "./persona.js";
15
+ import { templateId } from "./templates.js";
16
+ import { applyVibemateChanges, diffSettings, lintRoomDesign, ruleLines } from "./room-design.js";
17
+ 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";
16
18
  import { Transcript } from "./log.js";
17
19
  const SKILL_TOOL_READY_MS = 5000;
18
20
  const COLORS = ["#6d5dfc", "#16a34a", "#d97706", "#dc2626", "#0891b2", "#be185d", "#4d7c0f", "#7c3aed"];
19
- const NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}_-]{0,23}$/u;
20
21
  const MENTION_PATTERN = /@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu;
21
22
  const RULE_REF_TOKEN = /@\{p:([^}]+)\}/g;
22
23
  const ADAPTER_ERROR_PATTERN = /^(?:Warning: Falling back from WebSockets|unexpected status \d{3}|Error when talking to|API Error|You have exhausted your (?:daily )?quota|Rate limit|429 |5\d\d )/i;
@@ -45,6 +46,8 @@ export class Room extends EventEmitter {
45
46
  runtimes = new Map();
46
47
  drafts = new Map();
47
48
  permissions = new Map();
49
+ proposals = new Map();
50
+ proposalPlans = new Map();
48
51
  optionCache;
49
52
  log;
50
53
  colorIndex = 0;
@@ -231,6 +234,7 @@ export class Room extends EventEmitter {
231
234
  participants: [...this.participants.values()],
232
235
  messages: [...this.messages, ...this.drafts.values()],
233
236
  permissions: [...this.permissions.values()].map(({ resolve: _r, ...p }) => p),
237
+ proposals: [...this.proposals.values()],
234
238
  recipes: listRecipes().map(({ build: _b, ...r }) => r),
235
239
  lastMessageAt: last?.ts ?? this.createdAt,
236
240
  };
@@ -612,124 +616,20 @@ export class Room extends EventEmitter {
612
616
  updateSettings(patch) {
613
617
  const next = { ...this.settings };
614
618
  const changed = [];
615
- const setNumber = (key, min, max) => {
616
- if (patch[key] === undefined)
617
- return;
618
- const value = Number(patch[key]);
619
- if (!Number.isInteger(value) || value < min || value > max)
620
- throw new Error(`${key} must be an integer between ${min} and ${max}`);
621
- if (value !== next[key]) {
622
- next[key] = value;
623
- changed.push(key);
619
+ let unknownRefs = [];
620
+ for (const key of Object.keys(ROOM_SETTINGS_SPEC)) {
621
+ if (patch[key] === undefined || ROOM_SETTINGS_SPEC[key].kind === "own-path")
622
+ continue;
623
+ let value = coerceSetting(key, patch[key]);
624
+ if (key === "customRules") {
625
+ const resolved = this.resolveRuleReferences(value);
626
+ unknownRefs = resolved.unknown;
627
+ value = resolved.stored;
624
628
  }
625
- };
626
- setNumber("hopLimit", 0, 10_000);
627
- setNumber("fullBriefEveryTurns", 1, 10_000);
628
- setNumber("fullBriefEveryTokens", 1000, 10_000_000);
629
- setNumber("replayAfterRestart", 0, 200);
630
- setNumber("backlogCap", 1, 1000);
631
- if (patch.replyDelay !== undefined) {
632
- const value = Number(patch.replyDelay);
633
- if (!Number.isFinite(value) || value < 0 || value > 120)
634
- throw new Error("replyDelay must be between 0 and 120 seconds");
635
- next.replyDelay = value;
636
- }
637
- const setText = (key, max) => {
638
- if (patch[key] === undefined)
639
- return;
640
- const value = String(patch[key]).slice(0, max);
641
- if (value !== next[key]) {
629
+ if (JSON.stringify(value) !== JSON.stringify(next[key])) {
642
630
  next[key] = value;
643
631
  changed.push(key);
644
632
  }
645
- };
646
- setText("topic", 2000);
647
- setText("emoji", 8);
648
- setText("humanDescription", 200);
649
- let unknownRefs = [];
650
- if (patch.customRules !== undefined) {
651
- const resolved = this.resolveRuleReferences(String(patch.customRules).slice(0, 4000));
652
- unknownRefs = resolved.unknown;
653
- if (resolved.stored !== next.customRules) {
654
- next.customRules = resolved.stored;
655
- changed.push("customRules");
656
- }
657
- }
658
- if (patch.humanDescriptionMode !== undefined) {
659
- const mode = String(patch.humanDescriptionMode);
660
- if (mode !== "inherit" && mode !== "override" && mode !== "append" && mode !== "none")
661
- throw new Error("humanDescriptionMode must be inherit, override, append or none");
662
- if (mode !== next.humanDescriptionMode) {
663
- next.humanDescriptionMode = mode;
664
- changed.push("humanDescriptionMode");
665
- }
666
- }
667
- if (patch.refereeAction !== undefined) {
668
- const action = String(patch.refereeAction);
669
- if (action !== "next-header" && action !== "retry-hidden")
670
- throw new Error("refereeAction must be next-header or retry-hidden");
671
- if (action !== next.refereeAction) {
672
- next.refereeAction = action;
673
- changed.push("refereeAction");
674
- }
675
- }
676
- if (patch.turnTaking !== undefined) {
677
- const mode = String(patch.turnTaking);
678
- if (mode !== "parallel" && mode !== "one-at-a-time")
679
- throw new Error("turnTaking must be parallel or one-at-a-time");
680
- if (mode !== next.turnTaking) {
681
- next.turnTaking = mode;
682
- changed.push("turnTaking");
683
- }
684
- }
685
- if (patch.agentsWakeEachOther !== undefined) {
686
- const on = patch.agentsWakeEachOther === true || patch.agentsWakeEachOther === "true";
687
- if (on !== next.agentsWakeEachOther) {
688
- next.agentsWakeEachOther = on;
689
- changed.push("agentsWakeEachOther");
690
- }
691
- }
692
- if (patch.waitWhileHumanTypes !== undefined) {
693
- const on = patch.waitWhileHumanTypes === true || patch.waitWhileHumanTypes === "true";
694
- if (on !== next.waitWhileHumanTypes) {
695
- next.waitWhileHumanTypes = on;
696
- changed.push("waitWhileHumanTypes");
697
- }
698
- }
699
- if (patch.language !== undefined) {
700
- const raw = String(patch.language).trim();
701
- const language = !raw || raw === "follow-human" ? { mode: "follow-human" } : { mode: "fixed", language: raw };
702
- if (JSON.stringify(language) !== JSON.stringify(next.language)) {
703
- next.language = language;
704
- changed.push("language");
705
- }
706
- }
707
- if (patch.tools !== undefined) {
708
- const tools = String(patch.tools);
709
- if (tools !== "on-request" && tools !== "never")
710
- throw new Error("tools must be on-request or never");
711
- if (tools !== next.tools) {
712
- next.tools = tools;
713
- changed.push("tools");
714
- }
715
- }
716
- if (patch.maxSentences !== undefined) {
717
- const value = patch.maxSentences === null || patch.maxSentences === "" ? null : Number(patch.maxSentences);
718
- if (value !== null && (!Number.isInteger(value) || value < 1 || value > 100))
719
- throw new Error("maxSentences must be 1-100 or empty");
720
- if (value !== next.maxSentences) {
721
- next.maxSentences = value;
722
- changed.push("maxSentences");
723
- }
724
- }
725
- for (const key of ["headerRules", "showVendorInRoster"]) {
726
- if (patch[key] !== undefined) {
727
- const value = patch[key] === true || patch[key] === "true";
728
- if (value !== next[key]) {
729
- next[key] = value;
730
- changed.push(key);
731
- }
732
- }
733
633
  }
734
634
  this.settings = next;
735
635
  this.push(this.roomEvent());
@@ -1294,6 +1194,237 @@ export class Room extends EventEmitter {
1294
1194
  return undefined;
1295
1195
  return { items, channel, canCreate: channel === "tool" };
1296
1196
  }
1197
+ skillsForDesign(participantId) {
1198
+ if (!this.skills)
1199
+ return undefined;
1200
+ const runtime = this.runtimes.get(participantId);
1201
+ const channel = runtime?.skillChannel === "tool" ? "tool" : "marker";
1202
+ return {
1203
+ library: this.skills.library.list().filter((s) => !s.problems.length && !s.draft && s.agentInvocable).map((s) => ({ name: s.name, description: s.description })),
1204
+ channel,
1205
+ canCreate: channel === "tool",
1206
+ };
1207
+ }
1208
+ agentInRoom(participantId) {
1209
+ const participant = this.participants.get(participantId);
1210
+ if (!participant || !this.runtimes.has(participantId))
1211
+ throw new Error("this agent is not in the room any more");
1212
+ return participant;
1213
+ }
1214
+ describeRoomForAgent(participantId) {
1215
+ const participant = this.agentInRoom(participantId);
1216
+ const shape = this.templateOf();
1217
+ const skills = this.skills ? this.skills.library.list().filter((s) => !s.problems.length && !s.draft).map((s) => ({ name: s.name, description: s.description })) : [];
1218
+ const templates = this.skills ? this.skills.templates.list().map((t) => ({ id: t.id, name: t.name, builtin: !!t.builtin })) : [];
1219
+ return {
1220
+ room: { name: this.settings.name, topic: this.settings.topic, emoji: this.settings.emoji, dir: this.dir },
1221
+ human: this.settings.humanName,
1222
+ you: participant.name,
1223
+ settings: describeSettings({ ...this.settings, customRules: this.renderRuleReferences(this.settings.customRules) }),
1224
+ rules: ruleLines(this.renderRuleReferences(this.settings.customRules)),
1225
+ vibemates: shape.vibemates.map((v) => {
1226
+ const role = v.role ?? "";
1227
+ const own = v.name === participant.name;
1228
+ return {
1229
+ name: v.name,
1230
+ tagline: v.tagline ?? "",
1231
+ ...(own ? { role } : { rolePrivate: true, roleLength: role.length }),
1232
+ avatar: v.avatar ?? "",
1233
+ skills: v.skills ?? [],
1234
+ agentType: v.agentType,
1235
+ };
1236
+ }),
1237
+ skills,
1238
+ templates,
1239
+ yourBrief: buildBrief(this.settings, this.personaOf(participant), this.roster(), undefined, this.skillsForPrompt(participant, this.runtimes.get(participant.id))),
1240
+ howTo: "Settings are proposed by key with the values above; rules are one per line in customRules; a vibemate is { name, tagline, role, avatar, skills }. Another vibemate's role is private: you learn only that it has one and how long it is, and you may still propose a new one, which the human reads in full on the card. Check a design with lint_room_design, then create_template (a file for the human to pick) or propose_room_changes (a card the human applies).",
1241
+ };
1242
+ }
1243
+ lintDesignForAgent(participantId, kind, design) {
1244
+ this.agentInRoom(participantId);
1245
+ const result = lintRoomDesign(design, { ...this.designContext(kind), skills: this.skillsForDesign(participantId) });
1246
+ return { ok: !result.errors.length, errors: result.errors.map((e) => e.message), warnings: result.warnings.map((w) => w.message), preview: result.preview };
1247
+ }
1248
+ designContext(kind) {
1249
+ return {
1250
+ kind,
1251
+ humanName: this.settings.humanName,
1252
+ roomName: this.settings.name,
1253
+ base: kind === "room" ? { ...this.settings, customRules: this.renderRuleReferences(this.settings.customRules) } : undefined,
1254
+ knownSkills: this.skills ? this.skills.library.list().map((s) => s.name) : undefined,
1255
+ };
1256
+ }
1257
+ createTemplateForAgent(participantId, design, replace) {
1258
+ const participant = this.agentInRoom(participantId);
1259
+ if (!this.skills)
1260
+ throw new Error("templates are not available in this hub");
1261
+ const result = lintRoomDesign(design, this.designContext("template"));
1262
+ if (result.errors.length)
1263
+ throw new Error(`not saved: ${result.errors.map((e) => e.message).join("; ")}`);
1264
+ const settings = result.settings;
1265
+ const rest = {};
1266
+ for (const key of Object.keys(design.settings ?? {}))
1267
+ if (AGENT_SETTINGS.includes(key))
1268
+ rest[key] = settings[key];
1269
+ const draft = {
1270
+ name: String(design.name).trim(),
1271
+ description: String(design.description ?? "").trim(),
1272
+ emoji: settings.emoji || undefined,
1273
+ settings: rest,
1274
+ vibemates: (design.vibemates ?? []).map((v) => {
1275
+ const out = { name: v.name.trim() };
1276
+ if (v.tagline?.trim())
1277
+ out.tagline = v.tagline.trim();
1278
+ if (v.role?.trim())
1279
+ out.role = v.role.trim();
1280
+ if (v.avatar?.trim())
1281
+ out.avatar = v.avatar.trim();
1282
+ if (v.skills?.length)
1283
+ out.skills = v.skills.map((s) => s.trim()).filter(Boolean);
1284
+ if (typeof v.replyDelay === "number")
1285
+ out.replyDelay = v.replyDelay;
1286
+ return out;
1287
+ }),
1288
+ };
1289
+ const library = this.skills.templates;
1290
+ const wanted = templateId(draft.name);
1291
+ const existing = library.list().find((t) => t.id === wanted);
1292
+ let saved;
1293
+ if (existing && replace) {
1294
+ if (existing.builtin)
1295
+ throw new Error(`"${existing.name}" is a template viberoom ships and cannot be replaced; pick another name`);
1296
+ saved = library.overwrite(wanted, draft);
1297
+ }
1298
+ else
1299
+ saved = library.save(draft);
1300
+ this.skills.templatesChanged();
1301
+ const warnings = result.warnings.map((w) => w.message);
1302
+ this.postSystem(`${participant.name} ${existing && replace ? "updated" : "created"} the room template "${saved.name}" (${saved.vibemates.map((v) => v.name).join(", ") || "no vibemates"}); it is in the picker under New room.`);
1303
+ this.log.info(`templates: ${participant.name} ${existing && replace ? "updated" : "created"} "${saved.name}" (${saved.id})`);
1304
+ return {
1305
+ ok: true,
1306
+ message: `Template "${saved.name}" saved as ${saved.id}${existing && !replace ? ` (the name was taken, so the id got a number; pass replace: true to update your own template instead)` : ""}. The human creates a room from it under New room; nothing in this room changed.${warnings.length ? ` Warnings: ${warnings.join("; ")}` : ""}`,
1307
+ id: saved.id,
1308
+ path: join(library.dir, saved.id, "template.json"),
1309
+ warnings,
1310
+ };
1311
+ }
1312
+ proposeRoomChanges(participantId, why, changes) {
1313
+ const participant = this.agentInRoom(participantId);
1314
+ const shape = this.templateOf();
1315
+ const current = shape.vibemates;
1316
+ const vibes = applyVibemateChanges(current, changes.vibemates);
1317
+ if (vibes.errors.length)
1318
+ throw new Error(`not proposed: ${vibes.errors.join("; ")}`);
1319
+ const touched = vibes.ops.flatMap((op) => [op.name, ...(op.fields ?? []).filter((f) => f.field === "name").map((f) => f.to)]);
1320
+ const result = lintRoomDesign({ settings: changes.settings, vibemates: vibes.next }, { ...this.designContext("room"), changedVibemates: touched });
1321
+ if (result.errors.length)
1322
+ throw new Error(`not proposed: ${result.errors.map((e) => e.message).join("; ")}`);
1323
+ const base = this.designContext("room").base;
1324
+ const settingChanges = diffSettings(base, result.settings);
1325
+ if (!settingChanges.length && !vibes.ops.length)
1326
+ throw new Error("not proposed: the change set leaves the room as it is");
1327
+ const touchesOwn = vibes.ops.some((op) => op.name.toLowerCase() === participant.name.toLowerCase()) || settingChanges.some((c) => c.key === "customRules");
1328
+ const proposal = {
1329
+ key: randomUUID(),
1330
+ participantId,
1331
+ participantName: participant.name,
1332
+ ts: Date.now(),
1333
+ why: String(why ?? "").trim().slice(0, 600),
1334
+ settings: settingChanges,
1335
+ vibemates: vibes.ops,
1336
+ warnings: result.warnings.map((w) => w.message),
1337
+ touchesOwn,
1338
+ status: "pending",
1339
+ };
1340
+ const ids = {};
1341
+ for (const op of vibes.ops) {
1342
+ const target = op.op === "add" ? undefined : this.findByName(op.name);
1343
+ if (target)
1344
+ ids[op.name] = target.id;
1345
+ }
1346
+ this.proposalPlans.set(proposal.key, { settings: settingChanges, vibemates: vibes.next, ops: vibes.ops, ids });
1347
+ this.proposals.set(proposal.key, proposal);
1348
+ this.push({ type: "proposal", proposal });
1349
+ const what = [...settingChanges.map((c) => c.key), ...vibes.ops.map((o) => `${o.op} ${o.name}`)].join(", ");
1350
+ this.postSystem(`${participant.name} proposes changes to the room (${what}); apply or reject them on the card.`, "human");
1351
+ this.log.info(`proposal ${proposal.key} from ${participant.name}: ${what}`);
1352
+ return {
1353
+ ok: true,
1354
+ message: `Proposal sent to ${this.settings.humanName} as a card in the room (${what}). Nothing changes until they apply it; you will see a room line with the outcome.${proposal.warnings.length ? ` Warnings shown on the card: ${proposal.warnings.join("; ")}` : ""}`,
1355
+ key: proposal.key,
1356
+ warnings: proposal.warnings,
1357
+ };
1358
+ }
1359
+ async resolveProposal(key, accept) {
1360
+ const proposal = this.proposals.get(key);
1361
+ const plan = this.proposalPlans.get(key);
1362
+ if (!proposal || !plan)
1363
+ throw new Error("no such pending proposal");
1364
+ if (proposal.status !== "pending")
1365
+ return proposal;
1366
+ const what = [...proposal.settings.map((c) => c.key), ...proposal.vibemates.map((o) => `${o.op} ${o.name}`)].join(", ");
1367
+ if (!accept) {
1368
+ proposal.status = "rejected";
1369
+ this.proposalPlans.delete(key);
1370
+ this.push({ type: "proposal.resolved", key, status: "rejected" });
1371
+ this.postSystem(`${this.settings.humanName} rejected ${proposal.participantName}'s proposal (${what}).`);
1372
+ return proposal;
1373
+ }
1374
+ const skipped = [];
1375
+ for (const op of plan.ops) {
1376
+ const known = plan.ids[op.name];
1377
+ const existing = known ? this.participants.get(known) : this.findByName(op.name);
1378
+ if (op.op !== "add" && (!existing || existing.kind !== "agent" || existing.status === "left")) {
1379
+ skipped.push(`${op.op} ${op.name} (no longer in the room)`);
1380
+ continue;
1381
+ }
1382
+ if (op.op === "remove") {
1383
+ if (existing && existing.kind === "agent")
1384
+ await this.removeParticipant(existing.id);
1385
+ }
1386
+ else if (op.op === "update") {
1387
+ if (!existing || existing.kind !== "agent")
1388
+ continue;
1389
+ const target = plan.vibemates.find((v) => v.name === op.name) ?? plan.vibemates.find((v) => op.fields?.some((f) => f.field === "name" && f.to === v.name));
1390
+ const patch = {};
1391
+ for (const f of op.fields ?? []) {
1392
+ if (f.field === "name")
1393
+ patch.name = f.to;
1394
+ else if (f.field === "tagline")
1395
+ patch.tagline = target?.tagline ?? f.to;
1396
+ else if (f.field === "role")
1397
+ patch.role = target?.role ?? f.to;
1398
+ else if (f.field === "avatar")
1399
+ patch.avatar = target?.avatar ?? f.to;
1400
+ else if (f.field === "skills")
1401
+ patch.skills = target?.skills ?? [];
1402
+ else if (f.field === "replyDelay")
1403
+ patch.replyDelay = target?.replyDelay ?? null;
1404
+ }
1405
+ this.updatePersona(existing.id, patch);
1406
+ }
1407
+ else {
1408
+ const v = plan.vibemates.find((x) => x.name === op.name);
1409
+ if (!v || this.findByName(op.name))
1410
+ skipped.push(`add ${op.name} (the name is taken now)`);
1411
+ else
1412
+ this.addUnstaffed({ name: v.name, tagline: v.tagline, role: v.role, avatar: v.avatar, skills: v.skills });
1413
+ }
1414
+ }
1415
+ if (plan.settings.length) {
1416
+ const patch = {};
1417
+ for (const c of plan.settings)
1418
+ patch[c.key] = c.key === "language" ? c.to : c.to;
1419
+ this.updateSettings(patch);
1420
+ }
1421
+ proposal.status = "applied";
1422
+ proposal.skipped = skipped;
1423
+ this.proposalPlans.delete(key);
1424
+ this.push({ type: "proposal.resolved", key, status: "applied", skipped });
1425
+ this.postSystem(`${this.settings.humanName} applied ${proposal.participantName}'s proposal (${what}).${skipped.length ? ` Not applied: ${skipped.join("; ")}.` : ""}`);
1426
+ return proposal;
1427
+ }
1297
1428
  createSkillForAgent(participantId, input) {
1298
1429
  const participant = this.participants.get(participantId);
1299
1430
  if (!participant || !this.runtimes.has(participantId))
@@ -2127,11 +2258,15 @@ export class Room extends EventEmitter {
2127
2258
  }
2128
2259
  case "usage_update": {
2129
2260
  const u = update;
2261
+ if (emptyUsageReport(runtime.lastUsed, u.used)) {
2262
+ runtime.log.info(`usage report of 0 after ${runtime.lastUsed} tokens ignored (failed request?)`);
2263
+ return;
2264
+ }
2130
2265
  participant.contextUsed = u.used;
2131
2266
  participant.contextSize = u.size;
2132
2267
  if (u.cost)
2133
2268
  participant.cost = { amount: u.cost.amount, currency: u.cost.currency };
2134
- if (runtime.lastUsed > 0 && u.used < runtime.lastUsed * 0.7 && !runtime.briefPending) {
2269
+ if (looksCompacted(runtime.lastUsed, u.used) && !runtime.briefPending) {
2135
2270
  runtime.briefPending = `context shrank from ${runtime.lastUsed} to ${u.used} tokens (compaction?)`;
2136
2271
  runtime.log.info(`usage dropped ${runtime.lastUsed} -> ${u.used}; brief scheduled`);
2137
2272
  participant.contextEvent = { kind: "compacted", at: Date.now(), used: u.used, size: u.size };
package/dist/server.js CHANGED
@@ -219,6 +219,15 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
219
219
  sendJson(res, 200, { skill });
220
220
  return;
221
221
  }
222
+ if (req.method === "GET" && path === "/api/mcp/room") {
223
+ const target = hub.resolveMcpToken(url.searchParams.get("token") ?? "");
224
+ if (!target) {
225
+ sendJson(res, 403, { error: "unknown skills token (the session it belonged to is gone)" });
226
+ return;
227
+ }
228
+ sendJson(res, 200, target.room.describeRoomForAgent(target.participantId));
229
+ return;
230
+ }
222
231
  if (req.method === "GET" && path === "/api/mcp/skill") {
223
232
  const target = hub.resolveMcpToken(url.searchParams.get("token") ?? "");
224
233
  if (!target) {
@@ -325,6 +334,50 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
325
334
  sendJson(res, 200, { ok: true, skill: hub.approveSkill(decodeURIComponent(skillApprove[1])) });
326
335
  return;
327
336
  }
337
+ if (path === "/api/mcp/design/lint" || path === "/api/mcp/templates") {
338
+ const target = hub.resolveMcpToken(String(body.token ?? ""));
339
+ if (!target) {
340
+ sendJson(res, 403, { error: "unknown skills token (the session it belonged to is gone)" });
341
+ return;
342
+ }
343
+ const design = {
344
+ name: optionalString(body.name) ?? undefined,
345
+ description: optionalString(body.description) ?? undefined,
346
+ emoji: optionalString(body.emoji) ?? undefined,
347
+ settings: body.settings && typeof body.settings === "object" && !Array.isArray(body.settings) ? body.settings : undefined,
348
+ vibemates: Array.isArray(body.vibemates) ? body.vibemates.map((v) => ({ ...v, name: String(v?.name ?? "") })) : undefined,
349
+ };
350
+ if (path === "/api/mcp/design/lint") {
351
+ sendJson(res, 200, target.room.lintDesignForAgent(target.participantId, body.kind === "room" ? "room" : "template", design));
352
+ return;
353
+ }
354
+ sendJson(res, 200, target.room.createTemplateForAgent(target.participantId, design, body.replace === true || body.replace === "true"));
355
+ return;
356
+ }
357
+ if (path === "/api/mcp/propose") {
358
+ const target = hub.resolveMcpToken(String(body.token ?? ""));
359
+ if (!target) {
360
+ sendJson(res, 403, { error: "unknown skills token (the session it belonged to is gone)" });
361
+ return;
362
+ }
363
+ const vib = body.vibemates && typeof body.vibemates === "object" ? body.vibemates : {};
364
+ const list = (v) => (Array.isArray(v) ? v.map((x) => ({ ...x, name: String(x?.name ?? "") })) : undefined);
365
+ sendJson(res, 200, target.room.proposeRoomChanges(target.participantId, String(body.why ?? ""), {
366
+ settings: body.settings && typeof body.settings === "object" && !Array.isArray(body.settings) ? body.settings : undefined,
367
+ vibemates: {
368
+ add: list(vib.add),
369
+ update: list(vib.update),
370
+ remove: Array.isArray(vib.remove) ? vib.remove.map((x) => String(x)) : undefined,
371
+ },
372
+ }));
373
+ return;
374
+ }
375
+ const proposal = path.match(/^\/api\/rooms\/([^/]+)\/proposals\/([^/]+)$/);
376
+ if (proposal) {
377
+ const room = hub.getRoom(decodeURIComponent(proposal[1]));
378
+ sendJson(res, 200, await room.resolveProposal(decodeURIComponent(proposal[2]), body.accept === true || body.accept === "true"));
379
+ return;
380
+ }
328
381
  if (path === "/api/mcp/skills" || path === "/api/mcp/attach") {
329
382
  const target = hub.resolveMcpToken(String(body.token ?? ""));
330
383
  if (!target) {
package/dist/skills.js CHANGED
@@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
4
4
  import { isReservedSkillName, RESERVED_SKILL_NAMES } from "./commands.js";
5
5
  export const SKILL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
6
6
  export const SKILL_FILE = "SKILL.md";
7
+ import { ROOM_DESIGNER_NAME } from "./persona.js";
7
8
  export const BUILTIN_AUTHOR = "viberoom";
8
9
  export const HUMAN_AUTHOR = "human";
9
10
  const DESCRIPTION_MAX = 300;
@@ -85,6 +86,29 @@ export const SKILL_WRITER = {
85
86
  reviewed: true,
86
87
  draft: false,
87
88
  };
89
+ export const ROOM_DESIGNER = {
90
+ name: ROOM_DESIGNER_NAME,
91
+ description: "How to design a good viberoom room: rules, vibemates and settings, as a template or as a change to this room. Load it before lint_room_design, create_template or propose_room_changes.",
92
+ argumentHint: "",
93
+ body: [
94
+ "A room is a protocol between one human and a few vibemates. The hub already tells every vibemate the mechanics (who it is, @Name addressing, the [silent] reply, tools, language, Markdown); your design adds only what the mechanics do not say. Facts about the settings (keys, bounds, defaults, current values) come from the describe_room tool; do not guess them.",
95
+ "",
96
+ "Rules are the protocol; roles are the people. Write how the vibemates work together once, in the room rules, where all of them read it. A role says who this one is and which way it leans, then ends with \"Everything else is in the room rules\". Two roles that each restate the protocol drift apart.",
97
+ "",
98
+ "Every rule answers a question this room will actually meet. Find the questions before you write the rules: walk through a working day of this particular room and stop wherever two answers are possible. Who acts, who waits, who decides, what \"done\" looks like, what happens when they disagree. The set differs per room: a pair sharing one codebase has to settle ownership and reporting; a room where one drafts and another critiques has to settle when the critique comes and what it is measured against; a room that only answers questions may need almost nothing. A rule that answers no foreseeable question is weight the vibemate carries on every turn.",
99
+ "",
100
+ "Explain, do not enumerate: a rule with its reason generalises (\"Reply only when addressed: every message to agents costs a turn\"); a list of cases fails at the first case not on it. Silence is a design tool: the most useful rule in a multi-mate room is the one that keeps a vibemate at [silent] when a message does not concern it. Pair it with the settings: agentsWakeEachOther off and a low hopLimit for rooms that report to the human; on and higher (about three times the number of vibemates) for rooms that work things out among themselves. Each vibemate works only on its own task and never touches the other's; when a report changes something the other relies on, it is addressed to the other with one line saying what is wanted.",
101
+ "",
102
+ "Names: short, distinct first letters, ideally a hint of the leaning that survives translation. The tagline is the one line the others see in the roster: what this one leans to and what it does when asked. Keep the whole thing short: eight to twelve rules, one per line, is a full protocol; a role is a few sentences; if a rule needs a paragraph it is a skill, not a rule. Do not pin an agent or model in a template: the human picks from what the machine has.",
103
+ "",
104
+ "Before you write anything: describe_room for the facts, then lint_room_design with your draft and read its warnings and the brief preview (that is exactly what the vibemates will read). Play the risky cases against the rules: an unaddressed task, both starting at once, one finding a bug in the other's work unasked, a question in the middle of a task. Then create_template (a file the human picks from; no effect on any room) or propose_room_changes (a card the human applies or rejects; nothing changes without the click). Say in the room what you made and why, in a few lines.",
105
+ ].join("\n"),
106
+ userInvocable: true,
107
+ agentInvocable: true,
108
+ author: BUILTIN_AUTHOR,
109
+ reviewed: true,
110
+ draft: false,
111
+ };
88
112
  export function parseFrontmatter(text) {
89
113
  const normalized = text.replace(/^/, "").replace(/\r\n/g, "\n");
90
114
  const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
@@ -151,8 +175,10 @@ export function renderSkillBody(body, args) {
151
175
  .replace(/\r\n/g, "\n")
152
176
  .trim();
153
177
  }
178
+ export const BUILTIN_SKILLS = [SKILL_WRITER, ROOM_DESIGNER];
154
179
  export function isBuiltinSkill(name) {
155
- return name.trim().toLowerCase() === SKILL_WRITER.name;
180
+ const lower = name.trim().toLowerCase();
181
+ return BUILTIN_SKILLS.some((b) => b.name === lower);
156
182
  }
157
183
  export class SkillLibrary {
158
184
  dir;
@@ -253,7 +279,7 @@ export class SkillLibrary {
253
279
  });
254
280
  }
255
281
  seedBuiltins() {
256
- for (const builtin of [SKILL_WRITER]) {
282
+ for (const builtin of BUILTIN_SKILLS) {
257
283
  const folder = this.folderFor(builtin.name);
258
284
  const current = folder ? this.load(folder) : undefined;
259
285
  if (current && current.description === builtin.description && current.body === builtin.body && (current.argumentHint ?? "") === (builtin.argumentHint ?? ""))
package/dist/templates.js CHANGED
@@ -41,6 +41,12 @@ export function cleanTemplate(raw, id) {
41
41
  out.created = t.created.trim();
42
42
  return out;
43
43
  }
44
+ export function roomSettingsFromTemplate(template) {
45
+ const settings = { ...template.settings };
46
+ if (!settings.emoji && template.emoji)
47
+ settings.emoji = template.emoji;
48
+ return settings;
49
+ }
44
50
  export function templateId(name) {
45
51
  const id = name.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
46
52
  return ID_PATTERN.test(id) ? id : "template";
@@ -97,4 +103,15 @@ export class TemplateLibrary {
97
103
  this.log.info(`saved template "${clean.name}" (${id})`);
98
104
  return clean;
99
105
  }
106
+ overwrite(id, template) {
107
+ if (!ID_PATTERN.test(id))
108
+ throw new Error(`bad template id "${id}"`);
109
+ const own = readTemplates(this.dir, this.log, false).find((t) => t.id === id);
110
+ if (!own)
111
+ throw new Error(`no own template "${id}" to overwrite`);
112
+ const clean = cleanTemplate({ ...template, created: own.created ?? new Date().toISOString() }, id);
113
+ writeFileAtomic(join(this.dir, id, "template.json"), `${JSON.stringify(clean, null, 2)}\n`);
114
+ this.log.info(`overwrote template "${clean.name}" (${id})`);
115
+ return clean;
116
+ }
100
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberoom",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "viberoom: group chat rooms for a human and several coding agents over the Agent Client Protocol",
5
5
  "type": "module",
6
6
  "engines": {
package/ui/app.css CHANGED
@@ -330,7 +330,7 @@
330
330
  .done-x:hover { opacity: 1; background: var(--done-hover); }
331
331
  .jump-latest .i { width: 16px; height: 16px; }
332
332
  .bubble { max-width: 100%; background: #fff; border-radius: 6px 18px 18px 18px; padding: 12px 16px; min-width: 0; font-size: 14.5px; font-weight: 600; line-height: 1.55; color: var(--ink-2); box-shadow: 0 2px 0 rgba(28, 27, 51, 0.06), 0 1px 3px rgba(28, 27, 51, 0.04); }
333
- .msg.mine .bubble { background: var(--grad-primary); color: #fff; border-radius: 18px 18px 6px 18px; box-shadow: 0 8px 18px -10px rgba(91, 91, 240, 0.7); padding: 11px 16px; }
333
+ .msg.mine .bubble { background: var(--grad-primary); color: #fff; border-radius: 18px 6px 18px 18px; box-shadow: 0 8px 18px -10px rgba(91, 91, 240, 0.7); padding: 11px 16px; }
334
334
  .msg.mine .bubble .text { color: #fff; }
335
335
  .msg.mine.waiting .bubble { background: var(--attention-grad); box-shadow: var(--attention-shadow); }
336
336
  .bubble .waiting { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; padding-top: 8px; border-top: 1px solid rgba(255, 255, 255, 0.35); font-size: 13px; font-weight: 600; color: rgba(255, 255, 255, 0.92); }
@@ -486,6 +486,18 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
486
486
  .perm-btn.kind-allow_once, .perm-btn.kind-allow_always { background: var(--mint); color: var(--mint-ink); }
487
487
  .perm-btn.kind-reject_once, .perm-btn.kind-reject_always { background: var(--rose); color: var(--rose-ink); }
488
488
  .perm-result { color: var(--muted); font-size: 12px; }
489
+ .proposal { max-width: 760px; }
490
+ .prop-why { font-weight: 500; margin: 4px 0 6px; }
491
+ .prop-diff { display: flex; flex-direction: column; gap: 4px; margin: 6px 0; font-size: 12px; font-weight: 500; }
492
+ .prop-row { background: rgba(255, 255, 255, 0.6); border-radius: 8px; padding: 6px 8px; }
493
+ .prop-row > b { font-weight: 800; margin-right: 6px; }
494
+ .prop-line { margin-top: 2px; white-space: pre-wrap; }
495
+ .prop-line.add { color: var(--mint-ink); }
496
+ .prop-line.del { color: var(--rose-ink); text-decoration: line-through; opacity: 0.8; }
497
+ .prop-from { color: var(--muted); }
498
+ .prop-to { font-weight: 800; }
499
+ .prop-warn { margin: 6px 0 0 16px; padding: 0; font-size: 12px; font-weight: 500; color: var(--warm-ink, inherit); }
500
+ .prop-own { display: flex; gap: 6px; align-items: center; font-size: 12px; font-weight: 600; margin-top: 6px; }
489
501
  .visibility-divider { display: flex; align-items: center; gap: 10px; margin: 4px 0; }
490
502
  .visibility-divider .vd-line { flex: 1; height: 8px; background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='8' viewBox='0 0 16 8'><path d='M0 4 Q4 0 8 4 T16 4' fill='none' stroke='%23f472b6' stroke-width='1.6'/></svg>") repeat-x center / 16px 8px; opacity: 0.7; }
491
503
  .visibility-divider .vd-label { color: var(--unseen-ink); background: var(--unseen-bg); border-radius: var(--r-pill); padding: 4px 12px; font-size: 11px; font-weight: 800; white-space: nowrap; max-width: 70%; overflow: hidden; text-overflow: ellipsis; }