viberoom 0.4.2 → 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);
@@ -708,9 +769,10 @@ export class Room extends EventEmitter {
708
769
  const name = options.name.trim();
709
770
  if (!NAME_PATTERN.test(name))
710
771
  throw new Error("name must be 1-24 letters, digits, _ or - (no spaces)");
711
- if (this.findByName(name))
772
+ const taken = this.findByName(name);
773
+ if (taken && !(options.id && taken.id === options.id && taken.status === "unstaffed"))
712
774
  throw new Error(`name "${name}" is already taken`);
713
- const id = `${recipe.id}-${name.toLowerCase()}`;
775
+ const id = options.id ?? `${recipe.id}-${name.toLowerCase()}`;
714
776
  const launch = {
715
777
  model: options.model ?? recipe.defaultModel,
716
778
  effort: options.effort ?? recipe.defaultEffort,
@@ -725,7 +787,7 @@ export class Room extends EventEmitter {
725
787
  agentVendor: recipe.vendor,
726
788
  status: "starting",
727
789
  turns: 0,
728
- color: COLORS[this.colorIndex++ % COLORS.length],
790
+ color: options.color ?? COLORS[this.colorIndex++ % COLORS.length],
729
791
  tagline: (options.tagline ?? "").trim().slice(0, 80),
730
792
  role: (options.role ?? "").trim().slice(0, 4000),
731
793
  avatar: (options.avatar ?? "").trim().slice(0, 8) || undefined,
@@ -741,6 +803,51 @@ export class Room extends EventEmitter {
741
803
  await this.startAgent(participant, launch, true);
742
804
  return participant;
743
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
+ }
744
851
  async reconnect(id, options = { mode: "replay" }) {
745
852
  const participant = this.participants.get(id);
746
853
  if (!participant || participant.kind !== "agent")
@@ -1027,7 +1134,8 @@ export class Room extends EventEmitter {
1027
1134
  targets = [];
1028
1135
  }
1029
1136
  else {
1030
- const wanted = agentTargets.length ? agentTargets : this.settings.agentsWakeEachOther ? [...this.runtimes.keys()].filter((id) => id !== message.from && live(id)) : [];
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)) : [];
1031
1139
  if (wanted.length) {
1032
1140
  if (this.hops >= this.hopLimit) {
1033
1141
  const who = agentTargets.length ? message.toNames.join(", ") : "the other vibemates";
@@ -1343,10 +1451,14 @@ export class Room extends EventEmitter {
1343
1451
  }
1344
1452
  this.floorQueue.push(id);
1345
1453
  }
1454
+ static TYPING_HOLD_CAP_MS = 12_000;
1455
+ typingHoldSince = 0;
1346
1456
  humanIsTyping() {
1347
- 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;
1348
1458
  }
1349
1459
  humanTyping() {
1460
+ if (Date.now() >= this.humanTypingUntil)
1461
+ this.typingHoldSince = Date.now();
1350
1462
  this.humanTypingUntil = Date.now() + 4000;
1351
1463
  this.armTypingTimer();
1352
1464
  }
@@ -1428,7 +1540,7 @@ export class Room extends EventEmitter {
1428
1540
  if (!runtime.agent.alive || participant.muted || this.focused)
1429
1541
  return;
1430
1542
  const unreadAll = this.messages.filter((m) => m.kind !== "hidden" && m.seq > runtime.lastSeenSeq && (m.kind === "system" || m.from !== id || m.seq <= runtime.replayOwnUntilSeq));
1431
- if (!unreadAll.some((m) => m.kind === "chat"))
1543
+ if (!unreadAll.some((m) => m.kind === "chat" || m.wakes))
1432
1544
  return;
1433
1545
  const cap = this.settings.backlogCap;
1434
1546
  const omitted = Math.max(0, unreadAll.length - cap);
@@ -1463,13 +1575,26 @@ export class Room extends EventEmitter {
1463
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`);
1464
1576
  }
1465
1577
  const skillsForPrompt = this.skillsForPrompt(participant, runtime);
1466
- 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({
1467
1586
  brief: briefReason ? buildBrief(settings, persona, roster, undefined, skillsForPrompt) : undefined,
1468
1587
  header: buildHeader(settings, persona, roster, this.hops, notes, skillsForPrompt),
1469
1588
  skills: attached.map((s) => composeSkillBlock({ name: s.name, text: s.text, invokedBy: s.invokedBy, extraFiles: s.extraFiles })),
1470
1589
  backlog: unread.map((m) => m.kind === "system"
1471
1590
  ? { kind: "event", text: m.text }
1472
- : { 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
+ }),
1473
1598
  omitted,
1474
1599
  personaName: participant.name,
1475
1600
  });
@@ -1482,12 +1607,29 @@ export class Room extends EventEmitter {
1482
1607
  runtime.firstTurnDone = true;
1483
1608
  runtime.replayOwnUntilSeq = -1;
1484
1609
  runtime.log.info(`turn: ${unread.length} unread (${omitted} omitted), brief=${briefReason ?? "no"}, notes=${notes.length}`);
1485
- const retry = await this.executeTurn(participant, runtime, promptText, null);
1610
+ const retry = await this.executeTurn(participant, runtime, this.promptBlocks(prompt, runtime), null);
1486
1611
  if (retry) {
1487
- 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
+ }
1488
1629
  }
1630
+ return blocks;
1489
1631
  }
1490
- async executeTurn(participant, runtime, promptText, retry) {
1632
+ async executeTurn(participant, runtime, blocks, retry) {
1491
1633
  const id = participant.id;
1492
1634
  runtime.turnActive = true;
1493
1635
  participant.status = "thinking";
@@ -1511,7 +1653,7 @@ export class Room extends EventEmitter {
1511
1653
  let result = null;
1512
1654
  let failure = null;
1513
1655
  try {
1514
- result = await runtime.agent.prompt(runtime.sessionId, [{ type: "text", text: promptText }]);
1656
+ result = await runtime.agent.prompt(runtime.sessionId, blocks);
1515
1657
  }
1516
1658
  catch (error) {
1517
1659
  failure = error instanceof Error ? error.message : String(error);
@@ -1569,10 +1711,10 @@ export class Room extends EventEmitter {
1569
1711
  this.requestTurn(participant.id);
1570
1712
  return null;
1571
1713
  }
1572
- const pull = !retry ? text.match(SKILL_MARKER_PATTERN) : null;
1714
+ const pull = !retry ? skillPull(text) : null;
1573
1715
  if (pull) {
1574
1716
  this.push({ type: "message.removed", id: draft.id });
1575
- return this.handleSkillPull(participant, runtime, text, pull[1]);
1717
+ return this.handleSkillPull(participant, runtime, text, pull);
1576
1718
  }
1577
1719
  if (!text || text.toLowerCase() === SILENT_MARKER) {
1578
1720
  if (published)
@@ -1703,6 +1845,8 @@ export class Room extends EventEmitter {
1703
1845
  const corrections = [];
1704
1846
  const unknown = [];
1705
1847
  for (const match of text.matchAll(MENTION_PATTERN)) {
1848
+ if (match[1].toLowerCase() === "all")
1849
+ continue;
1706
1850
  if (!this.findByName(match[1]) && !unknown.includes(match[1]))
1707
1851
  unknown.push(match[1]);
1708
1852
  }
@@ -1791,6 +1935,9 @@ export class Room extends EventEmitter {
1791
1935
  view.status = u.status;
1792
1936
  if (u.rawInput !== undefined)
1793
1937
  view.rawInput = u.rawInput;
1938
+ const output = toolOutputText(u);
1939
+ if (output)
1940
+ view.output = output;
1794
1941
  this.push({ type: "toolcall", id: turn.message.id, toolCall: view });
1795
1942
  return;
1796
1943
  }
@@ -2005,13 +2152,23 @@ export class Room extends EventEmitter {
2005
2152
  }
2006
2153
  roster() {
2007
2154
  return [...this.participants.values()]
2008
- .filter((p) => p.status !== "left")
2009
- .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 }));
2010
2157
  }
2011
2158
  parseMentions(text) {
2012
2159
  const ids = [];
2013
2160
  const names = [];
2014
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
+ }
2015
2172
  const participant = this.findByName(match[1]);
2016
2173
  if (participant && !ids.includes(participant.id)) {
2017
2174
  ids.push(participant.id);
@@ -2027,7 +2184,7 @@ export class Room extends EventEmitter {
2027
2184
  return p;
2028
2185
  return undefined;
2029
2186
  }
2030
- postSystem(text, audience) {
2187
+ postSystem(text, audience, wakes) {
2031
2188
  const message = {
2032
2189
  id: randomUUID(),
2033
2190
  seq: ++this.seq,
@@ -2041,6 +2198,8 @@ export class Room extends EventEmitter {
2041
2198
  };
2042
2199
  if (audience)
2043
2200
  message.audience = audience;
2201
+ if (wakes)
2202
+ message.wakes = true;
2044
2203
  this.commit(message);
2045
2204
  }
2046
2205
  async setDir(dir) {
@@ -2098,6 +2257,12 @@ export class Room extends EventEmitter {
2098
2257
  this.push({ type: "notice", text, level, ts: Date.now() });
2099
2258
  }
2100
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
+ }
2101
2266
  this.emit("event", event);
2102
2267
  }
2103
2268
  }
@@ -2113,6 +2278,22 @@ function looksSilent(text) {
2113
2278
  const t = text.trim().toLowerCase();
2114
2279
  return t.length <= SILENT_MARKER.length && SILENT_MARKER.startsWith(t);
2115
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
+ }
2116
2297
  function contentText(block) {
2117
2298
  if (block.type === "text")
2118
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
  }