viberoom 0.4.2 → 0.5.1

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="Three vibemates arguing out a dedupe rule in a room">
17
+ <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/conversation.png" width="960" alt="A Forge & Lumen room: Forge explains a git command from a screenshot, Lumen adds the part that changes whose problem it is">
18
18
  </p>
19
19
 
20
20
  <p align="center">
@@ -85,6 +85,9 @@ export class AcpAgent {
85
85
  get supportsLoadSession() {
86
86
  return this.initResult?.agentCapabilities?.loadSession === true;
87
87
  }
88
+ get acceptsImages() {
89
+ return this.initResult?.agentCapabilities?.promptCapabilities?.image === true;
90
+ }
88
91
  loadSession(sessionId, cwd, mcpServers = []) {
89
92
  return this.peer.request("session/load", { sessionId, cwd, mcpServers }).then((result) => ({
90
93
  ...(result ?? {}),
@@ -0,0 +1,31 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ export const ROOM_COMMANDS = ["respawn"];
3
+ export const RESERVED_SKILL_NAMES = [
4
+ "respawn",
5
+ "clear",
6
+ "help",
7
+ "brief",
8
+ "mute",
9
+ "unmute",
10
+ "invite",
11
+ "kick",
12
+ "reset",
13
+ "stop",
14
+ "silent",
15
+ "request-brief",
16
+ ];
17
+ export function isReservedSkillName(name) {
18
+ return RESERVED_SKILL_NAMES.includes(name.trim().toLowerCase());
19
+ }
20
+ export function parseRoomCommand(text) {
21
+ const match = text.trim().match(/^\/([A-Za-z][A-Za-z0-9_-]{0,31})(?:\s+([\s\S]*))?$/);
22
+ if (!match)
23
+ return null;
24
+ const name = match[1].toLowerCase();
25
+ if (!ROOM_COMMANDS.includes(name))
26
+ return null;
27
+ return { name, args: (match[2] ?? "").trim() };
28
+ }
29
+ export function commandTarget(args) {
30
+ return args.trim().replace(/^@/, "").trim();
31
+ }
package/dist/edit.js CHANGED
@@ -20,12 +20,21 @@ export function affectedByEdit(agents, editedSeq) {
20
20
  return { restart, untouched, offline };
21
21
  }
22
22
  const QUOTE_MAX = 240;
23
+ const FULL_MAX = 4000;
23
24
  function quote(text) {
24
25
  const flat = text.replace(/\s+/g, " ").trim();
25
26
  return flat.length > QUOTE_MAX ? `${flat.slice(0, QUOTE_MAX)}…` : flat;
26
27
  }
28
+ function full(text) {
29
+ const trimmed = text.trim();
30
+ return trimmed.length > FULL_MAX ? `${trimmed.slice(0, FULL_MAX)}…` : trimmed;
31
+ }
27
32
  export function editNotice(humanName, previous, next) {
28
- return `${humanName} edited an earlier message: "${quote(previous)}" "${quote(next)}". Reply only if the change matters to you; otherwise [silent].`;
33
+ const added = next.startsWith(previous) ? next.slice(previous.length).trim() : "";
34
+ const body = added
35
+ ? `They added to the end of it:\n\n"${full(added)}"`
36
+ : `It now reads:\n\n"${full(next)}"\n\nBefore: "${quote(previous)}"`;
37
+ return `${humanName} edited an earlier message. ${body}\n\nReply only if the change matters to you; otherwise [silent].`;
29
38
  }
30
39
  export function rewriteNotice(humanName, removedCount, restarted) {
31
40
  const removed = removedCount === 1 ? "1 later message" : `${removedCount} later messages`;
package/dist/files.js ADDED
@@ -0,0 +1,60 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ export const IMAGE_TYPES = {
6
+ "image/png": "png",
7
+ "image/jpeg": "jpg",
8
+ "image/webp": "webp",
9
+ "image/gif": "gif",
10
+ };
11
+ export const IMAGE_MAX_BYTES = 8 * 1024 * 1024;
12
+ export const IMAGES_PER_MESSAGE = 6;
13
+ const STORED_NAME = /^[0-9a-f]{32}\.(png|jpg|webp|gif)$/;
14
+ export function isStoredFileName(name) {
15
+ return STORED_NAME.test(name);
16
+ }
17
+ export function contentTypeOf(file) {
18
+ const ext = file.slice(file.lastIndexOf(".") + 1);
19
+ for (const [type, e] of Object.entries(IMAGE_TYPES))
20
+ if (e === ext)
21
+ return type;
22
+ return "application/octet-stream";
23
+ }
24
+ function decode(data) {
25
+ const comma = data.startsWith("data:") ? data.indexOf(",") : -1;
26
+ const base64 = comma >= 0 ? data.slice(comma + 1) : data;
27
+ const buffer = Buffer.from(base64, "base64");
28
+ if (!buffer.length)
29
+ throw new Error("the image is empty");
30
+ return buffer;
31
+ }
32
+ function labelFor(name, ext, hash) {
33
+ const trimmed = (name ?? "").trim().replace(/[\r\n\t]/g, " ");
34
+ if (!trimmed)
35
+ return `pasted-${hash.slice(0, 6)}.${ext}`;
36
+ return trimmed.length > 80 ? `${trimmed.slice(0, 77)}…` : trimmed;
37
+ }
38
+ export function saveImage(dir, input) {
39
+ const ext = IMAGE_TYPES[input.mimeType];
40
+ if (!ext)
41
+ throw new Error(`unsupported image type: ${input.mimeType || "unknown"} (png, jpeg, webp and gif only)`);
42
+ const buffer = decode(input.data);
43
+ if (buffer.length > IMAGE_MAX_BYTES) {
44
+ throw new Error(`the image is too large (${Math.round(buffer.length / 1024)} kB; the limit is ${IMAGE_MAX_BYTES / 1024 / 1024} MB)`);
45
+ }
46
+ const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 32);
47
+ const file = `${hash}.${ext}`;
48
+ const target = join(dir, file);
49
+ if (!existsSync(target))
50
+ writeFileSync(target, buffer);
51
+ const attachment = { file, name: labelFor(input.name, ext, hash), mimeType: input.mimeType, bytes: buffer.length };
52
+ if (Number.isInteger(input.n) && input.n > 0)
53
+ attachment.n = input.n;
54
+ return attachment;
55
+ }
56
+ export function saveImages(dir, inputs) {
57
+ if (inputs.length > IMAGES_PER_MESSAGE)
58
+ throw new Error(`up to ${IMAGES_PER_MESSAGE} images per message`);
59
+ return inputs.map((input) => saveImage(dir, input));
60
+ }
package/dist/hub.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
2
  import { EventEmitter } from "node:events";
3
3
  import { randomBytes } from "node:crypto";
4
- import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
4
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
5
5
  import { writeFileAtomic } from "./atomic.js";
6
6
  import { join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -10,6 +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
14
  export const DIAGRAM_PRESETS = ["pop", "lavender", "mint", "sunset", "slate"];
14
15
  const ROOM_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
15
16
  const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules"];
@@ -17,6 +18,7 @@ export class Hub extends EventEmitter {
17
18
  dataDir;
18
19
  rooms = new Map();
19
20
  skills;
21
+ templates;
20
22
  settings;
21
23
  log;
22
24
  optionCache = new Map();
@@ -29,6 +31,7 @@ export class Hub extends EventEmitter {
29
31
  this.log = log;
30
32
  mkdirSync(join(this.dataDir, "rooms"), { recursive: true });
31
33
  this.skills = new SkillLibrary(join(this.dataDir, "skills"), log.child("skills"));
34
+ this.templates = new TemplateLibrary(join(this.dataDir, "templates"), log.child("templates"));
32
35
  try {
33
36
  this.skills.seedBuiltins();
34
37
  }
@@ -304,19 +307,86 @@ export class Hub extends EventEmitter {
304
307
  this.log.info(`created room "${name}" (${id}) with workspace ${dir}`);
305
308
  return { room, notices };
306
309
  }
310
+ async createRoomFromTemplate(input) {
311
+ const template = this.templates.get(input.templateId);
312
+ if (!template)
313
+ throw new Error(`no such template: ${input.templateId}`);
314
+ const { room, notices } = this.createRoom({ name: input.name, dir: input.dir, settings: template.settings });
315
+ for (const [i, tv] of template.vibemates.entries()) {
316
+ const choice = input.vibemates[i] ?? { name: tv.name, agentType: "" };
317
+ const skills = (tv.skills ?? []).filter((name) => {
318
+ const ok = !!this.skills.get(name);
319
+ if (!ok)
320
+ notices.push(`${choice.name || tv.name}: skill "${name}" is not in the library; not attached.`);
321
+ return ok;
322
+ });
323
+ if (!choice.agentType) {
324
+ try {
325
+ room.addUnstaffed({ name: choice.name || tv.name, tagline: tv.tagline, role: tv.role, avatar: tv.avatar, skills });
326
+ }
327
+ catch (error) {
328
+ notices.push(`${choice.name || tv.name} could not be added: ${error instanceof Error ? error.message : String(error)}`);
329
+ }
330
+ continue;
331
+ }
332
+ try {
333
+ await room.inviteAgent({
334
+ agentType: choice.agentType,
335
+ name: choice.name || tv.name,
336
+ tagline: tv.tagline,
337
+ role: tv.role,
338
+ avatar: tv.avatar,
339
+ skills,
340
+ model: choice.model ?? tv.model,
341
+ effort: choice.effort ?? tv.effort,
342
+ mode: choice.mode ?? tv.mode,
343
+ });
344
+ }
345
+ catch (error) {
346
+ notices.push(`${choice.name || tv.name} could not be summoned: ${error instanceof Error ? error.message : String(error)}`);
347
+ }
348
+ }
349
+ this.saveRooms();
350
+ return { room, notices };
351
+ }
307
352
  getRoom(id) {
308
353
  const room = this.rooms.get(id);
309
354
  if (!room)
310
355
  throw new Error(`no such room: ${id}`);
311
356
  return room;
312
357
  }
358
+ openRooms = [];
359
+ markOpened(id) {
360
+ this.getRoom(id);
361
+ if (this.openRooms.includes(id))
362
+ return;
363
+ this.openRooms.push(id);
364
+ this.emit("event", { type: "rooms.opened", roomIds: [...this.openRooms] });
365
+ }
313
366
  async removeRoom(id) {
314
367
  const room = this.getRoom(id);
315
368
  await room.shutdown();
316
369
  this.rooms.delete(id);
370
+ const at = this.openRooms.indexOf(id);
371
+ if (at >= 0)
372
+ this.openRooms.splice(at, 1);
317
373
  this.saveRooms();
318
374
  this.emit("event", { type: "room.removed", roomId: id });
319
- this.log.info(`removed room ${id} (history kept on disk)`);
375
+ const folder = join(this.dataDir, "rooms", id);
376
+ if (existsSync(folder)) {
377
+ const trash = join(this.dataDir, "trash");
378
+ mkdirSync(trash, { recursive: true });
379
+ const target = join(trash, `${id}-${new Date().toISOString().replace(/[:.]/g, "-")}`);
380
+ try {
381
+ renameSync(folder, target);
382
+ this.log.info(`removed room ${id}; its folder is in ${target}`);
383
+ }
384
+ catch (error) {
385
+ this.log.warn(`removed room ${id}, but its folder could not be moved to trash: ${error instanceof Error ? error.message : String(error)}`);
386
+ }
387
+ }
388
+ else
389
+ this.log.info(`removed room ${id}`);
320
390
  }
321
391
  snapshot() {
322
392
  return {
@@ -325,6 +395,7 @@ export class Hub extends EventEmitter {
325
395
  skills: this.skills.list(),
326
396
  roomDefaults: { ...DEFAULT_ROOM_SETTINGS, ...this.settings.roomDefaults },
327
397
  rooms: [...this.rooms.values()].map((room) => room.snapshot()),
398
+ openRooms: [...this.openRooms],
328
399
  };
329
400
  }
330
401
  async shutdown() {
package/dist/persona.js CHANGED
@@ -4,6 +4,10 @@ export const SILENT_MARKER = "[silent]";
4
4
  export const REQUEST_BRIEF_MARKER = "[request-brief]";
5
5
  export const SKILL_MARKER_PATTERN = /\[skill:\s*([A-Za-z0-9][A-Za-z0-9_-]{0,31})\s*\]/i;
6
6
  export const SKILL_TOOL_NAME = "load_skill";
7
+ export function skillPull(reply) {
8
+ const match = reply.trim().match(SKILL_MARKER_PATTERN);
9
+ return match && match[0] === reply.trim() ? match[1] : null;
10
+ }
7
11
  export const SKILL_WRITER_NAME = "skill-writer";
8
12
  export const DEFAULT_ROOM_SETTINGS = {
9
13
  topic: "",
@@ -38,6 +42,54 @@ export const BRIEF_AFFECTING_SETTINGS = [
38
42
  "customRules",
39
43
  "agentsWakeEachOther",
40
44
  ];
45
+ export const IMAGE_MARKER_PATTERN = /\[img\s+(\d+)\]/gi;
46
+ export function promptText(parts) {
47
+ return parts.map((p) => (p.type === "text" ? p.text : "")).join("");
48
+ }
49
+ function imageMarker(image) {
50
+ if (image.attached)
51
+ return `[img ${image.n}]`;
52
+ const who = image.forNames.length ? ` · for ${image.forNames.join(", ")}` : "";
53
+ return `[img ${image.n} · ${image.ref}${who} · ${image.path}]`;
54
+ }
55
+ function messageParts(line) {
56
+ const parts = [];
57
+ let text = "";
58
+ const flush = () => {
59
+ if (text)
60
+ parts.push({ type: "text", text });
61
+ text = "";
62
+ };
63
+ const place = (image) => {
64
+ text += imageMarker(image);
65
+ if (image.attached) {
66
+ flush();
67
+ parts.push({ type: "image", image });
68
+ }
69
+ };
70
+ const images = line.images ?? [];
71
+ const placed = new Set();
72
+ 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);
80
+ last = (match.index ?? 0) + match[0].length;
81
+ }
82
+ text += line.text.slice(last);
83
+ for (const image of images) {
84
+ if (placed.has(image.n))
85
+ continue;
86
+ if (!text.endsWith("\n") && (text || parts.length))
87
+ text += "\n";
88
+ place(image);
89
+ }
90
+ flush();
91
+ return parts;
92
+ }
41
93
  export function ensureDir(dir) {
42
94
  mkdirSync(dir, { recursive: true });
43
95
  return dir;
@@ -51,6 +103,8 @@ function describeEntry(entry, settings) {
51
103
  parts.push(entry.vendor);
52
104
  if (entry.tagline)
53
105
  parts.push(`"${entry.tagline}"`);
106
+ if (entry.muted)
107
+ parts.push("muted");
54
108
  return `${entry.name} (${parts.join(" · ")})`;
55
109
  }
56
110
  function skillsSection(skills) {
@@ -139,7 +193,9 @@ export function buildHeader(settings, persona, roster, hops, notes, skills) {
139
193
  .map((r) => {
140
194
  if (r.name === persona.name)
141
195
  return `${r.name} (you)`;
142
- return r.kind === "human" ? `${r.name} (human)` : r.name;
196
+ if (r.kind === "human")
197
+ return `${r.name} (human)`;
198
+ return r.muted ? `${r.name} (muted)` : r.name;
143
199
  })
144
200
  .join(", ");
145
201
  const who = persona.tagline.trim() ? `${persona.name} (${persona.tagline.trim()})` : persona.name;
@@ -173,27 +229,41 @@ export function composeSkillBlock(parts) {
173
229
  return lines.join("\n");
174
230
  }
175
231
  export function composePrompt(parts) {
176
- const lines = [];
232
+ const out = [];
233
+ const push = (text) => {
234
+ const lastPart = out[out.length - 1];
235
+ if (lastPart && lastPart.type === "text")
236
+ lastPart.text += text;
237
+ else
238
+ out.push({ type: "text", text });
239
+ };
177
240
  if (parts.brief)
178
- lines.push(parts.brief);
179
- lines.push(parts.header);
241
+ push(`${parts.brief}\n`);
242
+ push(`${parts.header}\n`);
180
243
  for (const block of parts.skills ?? [])
181
- lines.push(block);
182
- lines.push("<messages>");
244
+ push(`${block}\n`);
245
+ push("<messages>\n");
183
246
  if (parts.omitted > 0)
184
- lines.push(`… ${parts.omitted} earlier messages omitted`);
247
+ push(`… ${parts.omitted} earlier messages omitted\n`);
185
248
  for (const line of parts.backlog) {
186
249
  if (line.kind === "event") {
187
- lines.push(`· ${line.text}`);
250
+ push(`· ${line.text}\n`);
188
251
  }
189
252
  else {
190
253
  const target = line.toNames && line.toNames.length ? ` -> ${line.toNames.map((t) => `@${t}`).join(" ")}` : "";
191
- lines.push(`${line.fromName}${target}: ${line.text}`);
254
+ push(`${line.fromName}${target}: `);
255
+ for (const part of messageParts(line)) {
256
+ if (part.type === "text")
257
+ push(part.text);
258
+ else
259
+ out.push(part);
260
+ }
261
+ push("\n");
192
262
  }
193
263
  }
194
- lines.push("</messages>");
195
- lines.push(`Reply as ${parts.personaName} (or ${SILENT_MARKER}).`);
196
- return lines.join("\n");
264
+ push("</messages>\n");
265
+ push(`Reply as ${parts.personaName} (or ${SILENT_MARKER}).`);
266
+ return out;
197
267
  }
198
268
  export function composeCorrectionPrompt(parts) {
199
269
  const lines = [];