viberoom 0.4.1 → 0.5.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/room.js CHANGED
@@ -4,11 +4,12 @@ import { randomUUID } from "node:crypto";
4
4
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
5
5
  import { writeFileAtomic } from "./atomic.js";
6
6
  import { affectedByEdit, editNotice, partitionHistory, rewriteNotice } from "./edit.js";
7
+ import { saveImages } from "./files.js";
7
8
  import { join, resolve } from "node:path";
8
9
  import { AcpAgent } from "./acp-client.js";
9
10
  import { RemoteError } from "./jsonrpc.js";
10
11
  import { getRecipe, listRecipes } from "./recipes.js";
11
- import { composeSkillBlock, SKILL_MARKER_PATTERN, SKILL_TOOL_NAME } from "./persona.js";
12
+ import { composeSkillBlock, skillPull, SKILL_TOOL_NAME } from "./persona.js";
12
13
  import { BUILTIN_AUTHOR, parseSkillInvocation, renderSkillBody, SKILL_NAME_PATTERN, } from "./skills.js";
13
14
  import { BRIEF_AFFECTING_SETTINGS, DEFAULT_ROOM_SETTINGS, REQUEST_BRIEF_MARKER, SILENT_MARKER, buildBrief, buildHeader, composeCorrectionPrompt, composePrompt, countSentences, ensureDir, } from "./persona.js";
14
15
  import { Transcript } from "./log.js";
@@ -74,6 +75,12 @@ export class Room extends EventEmitter {
74
75
  historyPath() {
75
76
  return join(this.dataDir, "history.jsonl");
76
77
  }
78
+ filesDir() {
79
+ return join(this.dataDir, "files");
80
+ }
81
+ imagePath(attachment) {
82
+ return join(this.filesDir(), attachment.file);
83
+ }
77
84
  loadHistory() {
78
85
  if (!existsSync(this.historyPath()))
79
86
  return;
@@ -91,6 +98,10 @@ export class Room extends EventEmitter {
91
98
  }
92
99
  restore(stored) {
93
100
  for (const s of stored) {
101
+ if (!s.agentType) {
102
+ this.addUnstaffed({ name: s.name, tagline: s.tagline, role: s.role, avatar: s.avatar, skills: s.skills, color: s.color, id: s.id });
103
+ continue;
104
+ }
94
105
  const recipe = getRecipe(s.agentType);
95
106
  this.participants.set(s.id, {
96
107
  id: s.id,
@@ -131,11 +142,11 @@ export class Room extends EventEmitter {
131
142
  createdAt: this.createdAt,
132
143
  settings,
133
144
  participants: [...this.participants.values()]
134
- .filter((p) => p.kind === "agent" && p.agentType)
145
+ .filter((p) => p.kind === "agent" && (p.agentType || p.status === "unstaffed"))
135
146
  .map((p) => ({
136
147
  id: p.id,
137
148
  name: p.name,
138
- agentType: p.agentType,
149
+ agentType: p.agentType ?? "",
139
150
  tagline: p.tagline ?? "",
140
151
  role: p.role ?? "",
141
152
  avatar: p.avatar ?? "",
@@ -236,10 +247,17 @@ export class Room extends EventEmitter {
236
247
  return gone ? `@${gone} (no longer in the room)` : "@(a participant who left)";
237
248
  });
238
249
  }
239
- postHumanMessage(text) {
250
+ unstaffed() {
251
+ return [...this.participants.values()].filter((p) => p.kind === "agent" && p.status === "unstaffed");
252
+ }
253
+ postHumanMessage(text, images = []) {
254
+ const waiting = this.unstaffed();
255
+ if (waiting.length)
256
+ throw new Error(`${waiting.map((p) => p.name).join(", ")} ${waiting.length === 1 ? "has" : "have"} no coding agent yet: summon ${waiting.length === 1 ? "it" : "them"} from the roster to start the conversation`);
240
257
  const trimmed = text.trim();
241
- if (!trimmed)
258
+ if (!trimmed && !images.length)
242
259
  throw new Error("empty message");
260
+ const attachments = images.length ? saveImages(ensureDir(this.filesDir()), images) : [];
243
261
  this.humanTypingUntil = 0;
244
262
  const human = this.participants.get("human");
245
263
  const message = {
@@ -253,6 +271,8 @@ export class Room extends EventEmitter {
253
271
  ts: Date.now(),
254
272
  kind: "chat",
255
273
  };
274
+ if (attachments.length)
275
+ message.images = attachments;
256
276
  this.decorateHumanMessage(message);
257
277
  human.turns += 1;
258
278
  if (this.focused) {
@@ -284,7 +304,7 @@ export class Room extends EventEmitter {
284
304
  agentReadStates() {
285
305
  const out = [];
286
306
  for (const p of this.participants.values()) {
287
- if (p.kind !== "agent" || p.status === "left")
307
+ if (p.kind !== "agent" || p.status === "left" || p.status === "unstaffed")
288
308
  continue;
289
309
  const runtime = this.runtimes.get(p.id);
290
310
  if (runtime)
@@ -300,6 +320,20 @@ export class Room extends EventEmitter {
300
320
  throw new Error("only your own chat messages can be edited");
301
321
  return message;
302
322
  }
323
+ setPinned(messageId, pinned) {
324
+ const message = this.messages.find((m) => m.id === messageId);
325
+ if (!message || message.kind !== "chat")
326
+ throw new Error("only chat messages can be pinned");
327
+ if (!!message.pinned === pinned)
328
+ return message;
329
+ if (pinned)
330
+ message.pinned = true;
331
+ else
332
+ delete message.pinned;
333
+ this.rewriteHistory();
334
+ this.push({ type: "message", message });
335
+ return message;
336
+ }
303
337
  previewEdit(messageId) {
304
338
  const message = this.editableMessage(messageId);
305
339
  const { removed } = partitionHistory(this.messages, message.seq);
@@ -334,7 +368,9 @@ export class Room extends EventEmitter {
334
368
  this.rewriteHistory();
335
369
  this.push({ type: "message", message });
336
370
  if (restart.length || offline.length)
337
- this.postSystem(editNotice(this.humanName, previous, trimmed), "agents");
371
+ this.postSystem(editNotice(this.humanName, previous, trimmed), "agents", true);
372
+ for (const a of restart)
373
+ this.requestTurn(a.id);
338
374
  this.log.info(`edit (notify) of #${message.seq}: ${restart.length} agents had the old version`);
339
375
  this.route(message);
340
376
  return { restarted: [], removed: 0 };
@@ -380,6 +416,31 @@ export class Room extends EventEmitter {
380
416
  this.route(message);
381
417
  return { restarted, removed: removed.length };
382
418
  }
419
+ async respawnAgent(id) {
420
+ const participant = this.participants.get(id);
421
+ if (!participant || participant.kind !== "agent")
422
+ throw new Error("no such agent");
423
+ const online = this.runtimes.has(id);
424
+ this.dropScheduledTurn(id);
425
+ this.cancelPermissionsOf(id);
426
+ if (this.speaking === id)
427
+ this.speaking = null;
428
+ if (online)
429
+ await this.retireRuntime(id);
430
+ participant.sessionId = undefined;
431
+ this.restoredSeen.set(id, this.seq);
432
+ this.push({ type: "participant", participant });
433
+ if (!online) {
434
+ participant.statusDetail = "its context was cleared; a reconnect starts it with an empty head";
435
+ this.postSystem(`${participant.name} was respawned while offline: it comes back knowing nothing from before.`);
436
+ this.push({ type: "participant", participant });
437
+ this.log.info(`respawn of ${participant.name} (offline): stored session dropped`);
438
+ return participant;
439
+ }
440
+ await this.reconnect(id, { mode: "replay", replay: 0, reason: "its context was cleared, it remembers nothing from before" });
441
+ this.log.info(`respawn of ${participant.name}: fresh session, no replay`);
442
+ return participant;
443
+ }
383
444
  async retireRuntime(id) {
384
445
  const runtime = this.runtimes.get(id);
385
446
  const participant = this.participants.get(id);
@@ -506,6 +567,13 @@ export class Room extends EventEmitter {
506
567
  changed.push("turnTaking");
507
568
  }
508
569
  }
570
+ if (patch.agentsWakeEachOther !== undefined) {
571
+ const on = patch.agentsWakeEachOther === true || patch.agentsWakeEachOther === "true";
572
+ if (on !== next.agentsWakeEachOther) {
573
+ next.agentsWakeEachOther = on;
574
+ changed.push("agentsWakeEachOther");
575
+ }
576
+ }
509
577
  if (patch.waitWhileHumanTypes !== undefined) {
510
578
  const on = patch.waitWhileHumanTypes === true || patch.waitWhileHumanTypes === "true";
511
579
  if (on !== next.waitWhileHumanTypes) {
@@ -701,9 +769,10 @@ export class Room extends EventEmitter {
701
769
  const name = options.name.trim();
702
770
  if (!NAME_PATTERN.test(name))
703
771
  throw new Error("name must be 1-24 letters, digits, _ or - (no spaces)");
704
- if (this.findByName(name))
772
+ const taken = this.findByName(name);
773
+ if (taken && !(options.id && taken.id === options.id && taken.status === "unstaffed"))
705
774
  throw new Error(`name "${name}" is already taken`);
706
- const id = `${recipe.id}-${name.toLowerCase()}`;
775
+ const id = options.id ?? `${recipe.id}-${name.toLowerCase()}`;
707
776
  const launch = {
708
777
  model: options.model ?? recipe.defaultModel,
709
778
  effort: options.effort ?? recipe.defaultEffort,
@@ -718,7 +787,7 @@ export class Room extends EventEmitter {
718
787
  agentVendor: recipe.vendor,
719
788
  status: "starting",
720
789
  turns: 0,
721
- color: COLORS[this.colorIndex++ % COLORS.length],
790
+ color: options.color ?? COLORS[this.colorIndex++ % COLORS.length],
722
791
  tagline: (options.tagline ?? "").trim().slice(0, 80),
723
792
  role: (options.role ?? "").trim().slice(0, 4000),
724
793
  avatar: (options.avatar ?? "").trim().slice(0, 8) || undefined,
@@ -734,6 +803,51 @@ export class Room extends EventEmitter {
734
803
  await this.startAgent(participant, launch, true);
735
804
  return participant;
736
805
  }
806
+ addUnstaffed(input) {
807
+ const name = input.name.trim();
808
+ if (!NAME_PATTERN.test(name))
809
+ throw new Error("name must be 1-24 letters, digits, _ or - (no spaces)");
810
+ if (this.findByName(name))
811
+ throw new Error(`name "${name}" is already taken`);
812
+ const id = input.id ?? `vm-${name.toLowerCase()}`;
813
+ const participant = {
814
+ id,
815
+ name,
816
+ kind: "agent",
817
+ status: "unstaffed",
818
+ statusDetail: "awaiting a coding agent",
819
+ turns: 0,
820
+ color: input.color ?? COLORS[this.colorIndex++ % COLORS.length],
821
+ tagline: (input.tagline ?? "").trim().slice(0, 80),
822
+ role: (input.role ?? "").trim().slice(0, 4000),
823
+ avatar: (input.avatar ?? "").trim().slice(0, 8) || undefined,
824
+ skills: normalizeSkillList(input.skills),
825
+ violations: 0,
826
+ briefsSent: 0,
827
+ failedTurns: 0,
828
+ };
829
+ this.participants.set(id, participant);
830
+ this.push({ type: "participant", participant });
831
+ return participant;
832
+ }
833
+ async staff(id, choice) {
834
+ const placeholder = this.participants.get(id);
835
+ if (!placeholder || placeholder.kind !== "agent" || placeholder.status !== "unstaffed")
836
+ throw new Error("this vibemate is not awaiting a coding agent");
837
+ return this.inviteAgent({
838
+ id,
839
+ agentType: choice.agentType,
840
+ name: choice.name?.trim() || placeholder.name,
841
+ tagline: choice.tagline ?? placeholder.tagline,
842
+ role: choice.role ?? placeholder.role,
843
+ avatar: choice.avatar ?? placeholder.avatar,
844
+ skills: choice.skills ?? placeholder.skills,
845
+ color: placeholder.color,
846
+ model: choice.model,
847
+ effort: choice.effort,
848
+ mode: choice.mode,
849
+ });
850
+ }
737
851
  async reconnect(id, options = { mode: "replay" }) {
738
852
  const participant = this.participants.get(id);
739
853
  if (!participant || participant.kind !== "agent")
@@ -1019,13 +1133,18 @@ export class Room extends EventEmitter {
1019
1133
  else if (this.focused) {
1020
1134
  targets = [];
1021
1135
  }
1022
- else if (agentTargets.length) {
1023
- if (this.hops >= this.hopLimit) {
1024
- this.postSystem(`Hop limit ${this.hopLimit} reached: ${message.toNames.join(", ")} will not be prompted until ${this.humanName} writes again.`);
1025
- }
1026
- else {
1027
- this.hops += 1;
1028
- targets = agentTargets;
1136
+ else {
1137
+ const addressed = message.to.length > 0;
1138
+ const wanted = agentTargets.length ? agentTargets : addressed ? [] : this.settings.agentsWakeEachOther ? [...this.runtimes.keys()].filter((id) => id !== message.from && live(id)) : [];
1139
+ if (wanted.length) {
1140
+ if (this.hops >= this.hopLimit) {
1141
+ const who = agentTargets.length ? message.toNames.join(", ") : "the other vibemates";
1142
+ this.postSystem(`Hop limit ${this.hopLimit} reached: ${who} will not be prompted until ${this.humanName} writes again.`);
1143
+ }
1144
+ else {
1145
+ this.hops += 1;
1146
+ targets = this.settings.turnTaking === "one-at-a-time" && !agentTargets.length && wanted.length > 1 ? shuffle(wanted) : wanted;
1147
+ }
1029
1148
  }
1030
1149
  }
1031
1150
  this.push(this.roomEvent());
@@ -1332,10 +1451,14 @@ export class Room extends EventEmitter {
1332
1451
  }
1333
1452
  this.floorQueue.push(id);
1334
1453
  }
1454
+ static TYPING_HOLD_CAP_MS = 12_000;
1455
+ typingHoldSince = 0;
1335
1456
  humanIsTyping() {
1336
- return this.settings.waitWhileHumanTypes && Date.now() < this.humanTypingUntil;
1457
+ return this.settings.waitWhileHumanTypes && Date.now() < this.humanTypingUntil && Date.now() - this.typingHoldSince < Room.TYPING_HOLD_CAP_MS;
1337
1458
  }
1338
1459
  humanTyping() {
1460
+ if (Date.now() >= this.humanTypingUntil)
1461
+ this.typingHoldSince = Date.now();
1339
1462
  this.humanTypingUntil = Date.now() + 4000;
1340
1463
  this.armTypingTimer();
1341
1464
  }
@@ -1417,7 +1540,7 @@ export class Room extends EventEmitter {
1417
1540
  if (!runtime.agent.alive || participant.muted || this.focused)
1418
1541
  return;
1419
1542
  const unreadAll = this.messages.filter((m) => m.kind !== "hidden" && m.seq > runtime.lastSeenSeq && (m.kind === "system" || m.from !== id || m.seq <= runtime.replayOwnUntilSeq));
1420
- if (!unreadAll.some((m) => m.kind === "chat"))
1543
+ if (!unreadAll.some((m) => m.kind === "chat" || m.wakes))
1421
1544
  return;
1422
1545
  const cap = this.settings.backlogCap;
1423
1546
  const omitted = Math.max(0, unreadAll.length - cap);
@@ -1452,13 +1575,26 @@ export class Room extends EventEmitter {
1452
1575
  notes.push(s.invokedBy ? `${s.invokedBy} invoked your skill "${s.name}"; its instructions are attached below, follow them` : `skill "${s.name}" attached below as you asked; the same messages follow`);
1453
1576
  }
1454
1577
  const skillsForPrompt = this.skillsForPrompt(participant, runtime);
1455
- const promptText = composePrompt({
1578
+ const seesImages = runtime.agent.acceptsImages;
1579
+ const backlogImages = (m) => {
1580
+ if (!m.images || !m.images.length)
1581
+ return undefined;
1582
+ const attached = seesImages && (m.to.length === 0 || m.to.includes(id));
1583
+ return m.images.map((a, i) => ({ n: a.n ?? i + 1, ref: `#${m.seq}.${a.n ?? i + 1}`, name: a.name, path: this.imagePath(a), mimeType: a.mimeType, attached, forNames: m.to.length ? m.toNames : [] }));
1584
+ };
1585
+ const prompt = composePrompt({
1456
1586
  brief: briefReason ? buildBrief(settings, persona, roster, undefined, skillsForPrompt) : undefined,
1457
1587
  header: buildHeader(settings, persona, roster, this.hops, notes, skillsForPrompt),
1458
1588
  skills: attached.map((s) => composeSkillBlock({ name: s.name, text: s.text, invokedBy: s.invokedBy, extraFiles: s.extraFiles })),
1459
1589
  backlog: unread.map((m) => m.kind === "system"
1460
1590
  ? { kind: "event", text: m.text }
1461
- : { kind: "message", fromName: m.from === id ? `${m.fromName} (you, earlier)` : m.fromName, toNames: m.toNames, text: m.text }),
1591
+ : {
1592
+ kind: "message",
1593
+ fromName: m.from === id ? `${m.fromName} (you, earlier)` : m.fromName,
1594
+ toNames: m.toNames,
1595
+ text: m.text,
1596
+ images: backlogImages(m),
1597
+ }),
1462
1598
  omitted,
1463
1599
  personaName: participant.name,
1464
1600
  });
@@ -1471,12 +1607,29 @@ export class Room extends EventEmitter {
1471
1607
  runtime.firstTurnDone = true;
1472
1608
  runtime.replayOwnUntilSeq = -1;
1473
1609
  runtime.log.info(`turn: ${unread.length} unread (${omitted} omitted), brief=${briefReason ?? "no"}, notes=${notes.length}`);
1474
- const retry = await this.executeTurn(participant, runtime, promptText, null);
1610
+ const retry = await this.executeTurn(participant, runtime, this.promptBlocks(prompt, runtime), null);
1475
1611
  if (retry) {
1476
- await this.executeTurn(participant, runtime, retry.prompt, retry);
1612
+ await this.executeTurn(participant, runtime, [{ type: "text", text: retry.prompt }], retry);
1613
+ }
1614
+ }
1615
+ promptBlocks(parts, runtime) {
1616
+ const blocks = [];
1617
+ for (const part of parts) {
1618
+ if (part.type === "text") {
1619
+ blocks.push({ type: "text", text: part.text });
1620
+ continue;
1621
+ }
1622
+ try {
1623
+ blocks.push({ type: "image", data: readFileSync(part.image.path).toString("base64"), mimeType: part.image.mimeType });
1624
+ }
1625
+ catch (error) {
1626
+ runtime.log.warn(`image ${part.image.ref} could not be read: ${describeError(error)}`);
1627
+ blocks.push({ type: "text", text: ` (${part.image.ref} could not be read: ${part.image.path})` });
1628
+ }
1477
1629
  }
1630
+ return blocks;
1478
1631
  }
1479
- async executeTurn(participant, runtime, promptText, retry) {
1632
+ async executeTurn(participant, runtime, blocks, retry) {
1480
1633
  const id = participant.id;
1481
1634
  runtime.turnActive = true;
1482
1635
  participant.status = "thinking";
@@ -1500,7 +1653,7 @@ export class Room extends EventEmitter {
1500
1653
  let result = null;
1501
1654
  let failure = null;
1502
1655
  try {
1503
- result = await runtime.agent.prompt(runtime.sessionId, [{ type: "text", text: promptText }]);
1656
+ result = await runtime.agent.prompt(runtime.sessionId, blocks);
1504
1657
  }
1505
1658
  catch (error) {
1506
1659
  failure = error instanceof Error ? error.message : String(error);
@@ -1558,10 +1711,10 @@ export class Room extends EventEmitter {
1558
1711
  this.requestTurn(participant.id);
1559
1712
  return null;
1560
1713
  }
1561
- const pull = !retry ? text.match(SKILL_MARKER_PATTERN) : null;
1714
+ const pull = !retry ? skillPull(text) : null;
1562
1715
  if (pull) {
1563
1716
  this.push({ type: "message.removed", id: draft.id });
1564
- return this.handleSkillPull(participant, runtime, text, pull[1]);
1717
+ return this.handleSkillPull(participant, runtime, text, pull);
1565
1718
  }
1566
1719
  if (!text || text.toLowerCase() === SILENT_MARKER) {
1567
1720
  if (published)
@@ -1692,6 +1845,8 @@ export class Room extends EventEmitter {
1692
1845
  const corrections = [];
1693
1846
  const unknown = [];
1694
1847
  for (const match of text.matchAll(MENTION_PATTERN)) {
1848
+ if (match[1].toLowerCase() === "all")
1849
+ continue;
1695
1850
  if (!this.findByName(match[1]) && !unknown.includes(match[1]))
1696
1851
  unknown.push(match[1]);
1697
1852
  }
@@ -1780,6 +1935,9 @@ export class Room extends EventEmitter {
1780
1935
  view.status = u.status;
1781
1936
  if (u.rawInput !== undefined)
1782
1937
  view.rawInput = u.rawInput;
1938
+ const output = toolOutputText(u);
1939
+ if (output)
1940
+ view.output = output;
1783
1941
  this.push({ type: "toolcall", id: turn.message.id, toolCall: view });
1784
1942
  return;
1785
1943
  }
@@ -1994,13 +2152,23 @@ export class Room extends EventEmitter {
1994
2152
  }
1995
2153
  roster() {
1996
2154
  return [...this.participants.values()]
1997
- .filter((p) => p.status !== "left")
1998
- .map((p) => ({ name: p.name, kind: p.kind, vendor: p.agentVendor ?? p.agentLabel, tagline: p.tagline || undefined }));
2155
+ .filter((p) => p.status !== "left" && p.status !== "unstaffed")
2156
+ .map((p) => ({ name: p.name, kind: p.kind, vendor: p.agentVendor ?? p.agentLabel, tagline: p.tagline || undefined, muted: p.muted || undefined }));
1999
2157
  }
2000
2158
  parseMentions(text) {
2001
2159
  const ids = [];
2002
2160
  const names = [];
2003
2161
  for (const match of text.matchAll(MENTION_PATTERN)) {
2162
+ if (match[1].toLowerCase() === "all") {
2163
+ for (const p of this.participants.values()) {
2164
+ if (p.kind !== "agent" || p.status === "left" || ids.includes(p.id))
2165
+ continue;
2166
+ ids.push(p.id);
2167
+ }
2168
+ if (!names.includes("All"))
2169
+ names.push("All");
2170
+ continue;
2171
+ }
2004
2172
  const participant = this.findByName(match[1]);
2005
2173
  if (participant && !ids.includes(participant.id)) {
2006
2174
  ids.push(participant.id);
@@ -2016,7 +2184,7 @@ export class Room extends EventEmitter {
2016
2184
  return p;
2017
2185
  return undefined;
2018
2186
  }
2019
- postSystem(text, audience) {
2187
+ postSystem(text, audience, wakes) {
2020
2188
  const message = {
2021
2189
  id: randomUUID(),
2022
2190
  seq: ++this.seq,
@@ -2030,6 +2198,8 @@ export class Room extends EventEmitter {
2030
2198
  };
2031
2199
  if (audience)
2032
2200
  message.audience = audience;
2201
+ if (wakes)
2202
+ message.wakes = true;
2033
2203
  this.commit(message);
2034
2204
  }
2035
2205
  async setDir(dir) {
@@ -2087,6 +2257,12 @@ export class Room extends EventEmitter {
2087
2257
  this.push({ type: "notice", text, level, ts: Date.now() });
2088
2258
  }
2089
2259
  push(event) {
2260
+ if (event.type === "participant") {
2261
+ const id = event.participant.id;
2262
+ const lastSeenSeq = this.runtimes.get(id)?.lastSeenSeq ?? this.restoredSeen.get(id);
2263
+ this.emit("event", { ...event, participant: { ...event.participant, lastSeenSeq } });
2264
+ return;
2265
+ }
2090
2266
  this.emit("event", event);
2091
2267
  }
2092
2268
  }
@@ -2102,6 +2278,22 @@ function looksSilent(text) {
2102
2278
  const t = text.trim().toLowerCase();
2103
2279
  return t.length <= SILENT_MARKER.length && SILENT_MARKER.startsWith(t);
2104
2280
  }
2281
+ function toolOutputText(u) {
2282
+ const cap = (s) => (s.length > 4000 ? `${s.slice(0, 4000)}\n… (${s.length - 4000} more characters)` : s);
2283
+ if (u.content && u.content.length) {
2284
+ const parts = u.content.map((c) => {
2285
+ if (c.type === "content")
2286
+ return contentText(c.content);
2287
+ if (c.type === "diff")
2288
+ return `--- ${c.path}\n${c.oldText ? `- ${c.oldText}\n` : ""}+ ${c.newText}`;
2289
+ return `[terminal ${c.terminalId}]`;
2290
+ });
2291
+ return cap(parts.join("\n"));
2292
+ }
2293
+ if (u.rawOutput !== undefined && u.rawOutput !== null)
2294
+ return cap(typeof u.rawOutput === "string" ? u.rawOutput : JSON.stringify(u.rawOutput, null, 1));
2295
+ return "";
2296
+ }
2105
2297
  function contentText(block) {
2106
2298
  if (block.type === "text")
2107
2299
  return block.text;
package/dist/server.js CHANGED
@@ -9,6 +9,8 @@ import { existsSync as fileExists } from "node:fs";
9
9
  import { classifyOpenTarget, describeOpen, detectEditor, editorCommand, isExecutablePath, openCommand } from "./open.js";
10
10
  import { parseCsv, viewerKind, VIEWER_MAX_BYTES } from "./viewer.js";
11
11
  import { createFolder, homeFolder, listFolders, listRoots } from "./fsbrowse.js";
12
+ import { contentTypeOf, isStoredFileName, IMAGE_MAX_BYTES, IMAGES_PER_MESSAGE } from "./files.js";
13
+ import { commandTarget, parseRoomCommand } from "./commands.js";
12
14
  let editorFound;
13
15
  function currentEditor() {
14
16
  if (editorFound === undefined)
@@ -150,6 +152,24 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
150
152
  sendJson(res, 200, kind === "csv" ? { ok: true, kind, path: target.value, rows: parseCsv(text) } : { ok: true, kind, path: target.value, text });
151
153
  return;
152
154
  }
155
+ const roomFile = req.method === "GET" && path.match(/^\/api\/rooms\/([^/]+)\/files\/([^/]+)$/);
156
+ if (roomFile) {
157
+ const room = hub.getRoom(decodeURIComponent(roomFile[1]));
158
+ const name = decodeURIComponent(roomFile[2]);
159
+ if (!isStoredFileName(name)) {
160
+ sendJson(res, 400, { error: "not an attachment name" });
161
+ return;
162
+ }
163
+ try {
164
+ const bytes = await readFile(join(room.filesDir(), name));
165
+ res.writeHead(200, { "Content-Type": contentTypeOf(name), "Cache-Control": "public, max-age=31536000, immutable" });
166
+ res.end(bytes);
167
+ }
168
+ catch {
169
+ sendJson(res, 404, { error: "no such attachment" });
170
+ }
171
+ return;
172
+ }
153
173
  if (req.method === "GET" && path === "/api/editor") {
154
174
  sendJson(res, 200, { editor: currentEditor(), settings: hub.settings.editor });
155
175
  return;
@@ -210,6 +230,10 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
210
230
  sendJson(res, 200, hub.getRoom(decodeURIComponent(roomGet[1])).snapshot());
211
231
  return;
212
232
  }
233
+ if (req.method === "GET" && path === "/api/templates") {
234
+ sendJson(res, 200, { templates: hub.templates.list() });
235
+ return;
236
+ }
213
237
  if (req.method === "GET" && path === "/api/rooms") {
214
238
  sendJson(res, 200, [...hub.rooms.values()].map((r) => r.snapshot()));
215
239
  return;
@@ -322,6 +346,20 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
322
346
  setTimeout(onShutdownRequest, 50);
323
347
  return;
324
348
  }
349
+ if (path === "/api/rooms/from-template") {
350
+ const vibemates = Array.isArray(body.vibemates) ? body.vibemates : [];
351
+ const { room, notices } = await hub.createRoomFromTemplate({
352
+ templateId: String(body.template ?? ""),
353
+ name: String(body.name ?? ""),
354
+ dir: optionalString(body.dir),
355
+ vibemates: vibemates.map((v) => {
356
+ const o = (v ?? {});
357
+ return { name: String(o.name ?? ""), agentType: String(o.agentType ?? ""), model: optionalString(o.model), effort: optionalString(o.effort), mode: optionalString(o.mode) };
358
+ }),
359
+ });
360
+ sendJson(res, 200, { ok: true, room: room.snapshot(), notices });
361
+ return;
362
+ }
325
363
  if (path === "/api/rooms") {
326
364
  const { room, notices } = hub.createRoom({
327
365
  name: String(body.name ?? ""),
@@ -331,12 +369,27 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
331
369
  sendJson(res, 200, { ok: true, room: room.snapshot(), notices });
332
370
  return;
333
371
  }
334
- const roomAction = path.match(/^\/api\/rooms\/([^/]+)\/(send|typing|invite|settings|focus|rename|dir|delete)$/);
372
+ const roomAction = path.match(/^\/api\/rooms\/([^/]+)\/(send|typing|invite|settings|focus|rename|dir|delete|open)$/);
335
373
  if (roomAction) {
336
374
  const room = hub.getRoom(decodeURIComponent(roomAction[1]));
337
375
  const action = roomAction[2];
338
- if (action === "send") {
339
- const message = room.postHumanMessage(String(body.text ?? ""));
376
+ if (action === "open") {
377
+ hub.markOpened(room.id);
378
+ sendJson(res, 200, { ok: true, openRooms: hub.openRooms });
379
+ }
380
+ else if (action === "send") {
381
+ const text = String(body.text ?? "");
382
+ const command = parseRoomCommand(text);
383
+ if (command) {
384
+ const target = room.findByName(commandTarget(command.args));
385
+ if (!target)
386
+ throw new Error(`/${command.name} needs the name of a vibemate in this room, like /${command.name} @Name`);
387
+ await room.respawnAgent(target.id);
388
+ hub.saveRooms();
389
+ sendJson(res, 200, { ok: true, command: command.name, participant: target.name });
390
+ return;
391
+ }
392
+ const message = room.postHumanMessage(text, imageList(body.images));
340
393
  sendJson(res, 200, { ok: true, id: message.id });
341
394
  }
342
395
  else if (action === "typing") {
@@ -387,6 +440,13 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
387
440
  }
388
441
  return;
389
442
  }
443
+ const messagePin = path.match(/^\/api\/rooms\/([^/]+)\/messages\/([^/]+)\/pin$/);
444
+ if (messagePin) {
445
+ const room = hub.getRoom(decodeURIComponent(messagePin[1]));
446
+ const message = room.setPinned(decodeURIComponent(messagePin[2]), body.pinned === true || body.pinned === "true");
447
+ sendJson(res, 200, { ok: true, pinned: !!message.pinned });
448
+ return;
449
+ }
390
450
  const messageEdit = path.match(/^\/api\/rooms\/([^/]+)\/messages\/([^/]+)\/edit$/);
391
451
  if (messageEdit) {
392
452
  const room = hub.getRoom(decodeURIComponent(messageEdit[1]));
@@ -396,13 +456,31 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
396
456
  sendJson(res, 200, { ok: true, ...result });
397
457
  return;
398
458
  }
399
- const participantAction = path.match(/^\/api\/rooms\/([^/]+)\/participants\/([^/]+)\/(cancel|remove|config|persona|reconnect|mute|unmute)$/);
459
+ const participantAction = path.match(/^\/api\/rooms\/([^/]+)\/participants\/([^/]+)\/(cancel|remove|config|persona|reconnect|mute|unmute|respawn|staff)$/);
400
460
  if (participantAction) {
401
461
  const room = hub.getRoom(decodeURIComponent(participantAction[1]));
402
462
  const id = decodeURIComponent(participantAction[2]);
403
463
  const action = participantAction[3];
404
464
  if (action === "cancel")
405
465
  room.cancelTurn(id);
466
+ else if (action === "respawn")
467
+ await room.respawnAgent(id);
468
+ else if (action === "staff") {
469
+ const participant = await room.staff(id, {
470
+ agentType: String(body.agentType ?? ""),
471
+ model: optionalString(body.model),
472
+ effort: optionalString(body.effort),
473
+ mode: optionalString(body.mode),
474
+ name: optionalString(body.name),
475
+ tagline: optionalString(body.tagline),
476
+ role: optionalString(body.role),
477
+ avatar: optionalString(body.avatar),
478
+ skills: stringList(body.skills),
479
+ });
480
+ hub.saveRooms();
481
+ sendJson(res, 200, { ok: true, participant });
482
+ return;
483
+ }
406
484
  else if (action === "remove")
407
485
  await room.removeParticipant(id);
408
486
  else if (action === "reconnect") {
@@ -472,14 +550,33 @@ function stringList(value) {
472
550
  return [];
473
551
  return value.map((v) => String(v).trim()).filter((v) => v.length > 0);
474
552
  }
553
+ function imageList(value) {
554
+ if (!Array.isArray(value))
555
+ return [];
556
+ return value.slice(0, IMAGES_PER_MESSAGE).map((entry) => {
557
+ const image = (entry ?? {});
558
+ const n = Number(image.n);
559
+ return { name: optionalString(image.name) ?? undefined, mimeType: String(image.mimeType ?? ""), data: String(image.data ?? ""), n: Number.isInteger(n) && n > 0 ? n : undefined };
560
+ });
561
+ }
475
562
  function sendJson(res, status, body) {
476
563
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
477
564
  res.end(JSON.stringify(body));
478
565
  }
566
+ const BODY_MAX_BYTES = (IMAGE_MAX_BYTES * IMAGES_PER_MESSAGE * 4) / 3 + 64 * 1024;
479
567
  function readJson(req) {
480
568
  return new Promise((resolve, reject) => {
481
569
  const chunks = [];
482
- req.on("data", (chunk) => chunks.push(chunk));
570
+ let size = 0;
571
+ req.on("data", (chunk) => {
572
+ size += chunk.length;
573
+ if (size > BODY_MAX_BYTES) {
574
+ reject(new Error("the request is too large"));
575
+ req.destroy();
576
+ return;
577
+ }
578
+ chunks.push(chunk);
579
+ });
483
580
  req.on("end", () => {
484
581
  const raw = Buffer.concat(chunks).toString("utf8");
485
582
  if (!raw.trim())
package/dist/skills.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
+ import { isReservedSkillName, RESERVED_SKILL_NAMES } from "./commands.js";
4
5
  export const SKILL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
5
6
  export const SKILL_FILE = "SKILL.md";
6
7
  export const BUILTIN_AUTHOR = "viberoom";
@@ -32,6 +33,9 @@ export function lintSkill(input) {
32
33
  const hint = (input.argumentHint ?? "").trim();
33
34
  if (!SKILL_NAME_PATTERN.test(name))
34
35
  errors.push({ code: "name-invalid", message: `name "${name}" must be 1-32 letters, digits, _ or -` });
36
+ else if (isReservedSkillName(name)) {
37
+ errors.push({ code: "name-reserved", message: `"${name}" is a room command (reserved: ${RESERVED_SKILL_NAMES.join(", ")})` });
38
+ }
35
39
  if (input.folder && name.toLowerCase() !== input.folder.toLowerCase()) {
36
40
  errors.push({ code: "name-folder-mismatch", message: `name "${name}" differs from the folder "${input.folder}"` });
37
41
  }