viberoom 0.5.8 → 0.6.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/rows.js ADDED
@@ -0,0 +1,19 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ const LEGACY = [
3
+ [/^(Hush|Focus):/, "hush"],
4
+ [/ could not answer: /, "error"],
5
+ [/'s agent reported an error instead of a reply/, "error"],
6
+ [/ ran out of context/, "error"],
7
+ [/ was stopped\b/, "attention"],
8
+ [/^Hop limit \d+ reached/, "attention"],
9
+ [/ is at \d+% of its context/, "attention"],
10
+ [/ compacted its context/, "attention"],
11
+ [/ proposes changes to the room/, "attention"],
12
+ [/ was respawned while offline/, "attention"],
13
+ ];
14
+ export function legacyRowTone(text) {
15
+ for (const [pattern, tone] of LEGACY)
16
+ if (pattern.test(text))
17
+ return tone;
18
+ return undefined;
19
+ }
package/dist/server.js CHANGED
@@ -1,16 +1,19 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
2
  import { spawn } from "node:child_process";
3
3
  import { createServer } from "node:http";
4
- import { readFile, stat } from "node:fs/promises";
4
+ import { readFile, realpath, stat } from "node:fs/promises";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createRequire } from "node:module";
7
- import { dirname, join } from "node:path";
8
- import { existsSync as fileExists } from "node:fs";
7
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
8
+ import { createReadStream, existsSync as fileExists } from "node:fs";
9
+ import { createInterface } from "node:readline";
9
10
  import { classifyOpenTarget, describeOpen, detectEditor, editorCommand, isExecutablePath, openCommand } from "./open.js";
10
- import { parseCsv, viewerKind, VIEWER_MAX_BYTES } from "./viewer.js";
11
+ import { imageMediaType, languageOf, looksBinary, parseCsv, sliceLines, viewerKind, IMAGE_VIEW_MAX_BYTES, STREAM_MAX_BYTES, VIEWER_MAX_BYTES, WINDOW_MAX_LINES } from "./viewer.js";
11
12
  import { createFolder, homeFolder, listFolders, listRoots } from "./fsbrowse.js";
12
13
  import { contentTypeOf, isStoredFileName, IMAGE_MAX_BYTES, IMAGES_PER_MESSAGE } from "./files.js";
14
+ import { QUOTES_PER_MESSAGE } from "./quotes.js";
13
15
  import { commandTarget, parseRoomCommand } from "./commands.js";
16
+ import { acceptUpgrade } from "./ws.js";
14
17
  let editorFound;
15
18
  function currentEditor() {
16
19
  if (editorFound === undefined)
@@ -19,12 +22,6 @@ function currentEditor() {
19
22
  }
20
23
  const STATIC_FILES = {
21
24
  "/": { file: "index.html", type: "text/html; charset=utf-8" },
22
- "/index.html": { file: "index.html", type: "text/html; charset=utf-8" },
23
- "/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" },
24
- "/avatars.js": { file: "avatars.js", type: "text/javascript; charset=utf-8" },
25
- "/icons.js": { file: "icons.js", type: "text/javascript; charset=utf-8" },
26
- "/theme.css": { file: "theme.css", type: "text/css; charset=utf-8" },
27
- "/app.css": { file: "app.css", type: "text/css; charset=utf-8" },
28
25
  "/styleguide": { file: "styleguide.html", type: "text/html; charset=utf-8" },
29
26
  "/manifest.json": { file: "manifest.json", type: "application/manifest+json; charset=utf-8" },
30
27
  "/icon.svg": { file: "icon.svg", type: "image/svg+xml", dir: "assets" },
@@ -39,9 +36,21 @@ const STATIC_FILES = {
39
36
  "/vendor-icons/copilot.svg": { file: "vendors/copilot.svg", type: "image/svg+xml", dir: "assets" },
40
37
  "/vendor/mermaid.min.js": { file: "mermaid/dist/mermaid.min.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
41
38
  "/vendor/marked.umd.js": { file: "marked/lib/marked.umd.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
39
+ "/vendor/prism.js": { file: "prismjs/prism.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
40
+ };
41
+ const PRISM_LANGUAGE = /^\/vendor\/prism-lang\/([a-z0-9-]{1,32})\.js$/;
42
+ const UI_FILE = /^\/([a-z0-9_-]+\.(js|css|html|svg|json|png|ico))$/i;
43
+ const UI_TYPES = {
44
+ js: "text/javascript; charset=utf-8",
45
+ css: "text/css; charset=utf-8",
46
+ html: "text/html; charset=utf-8",
47
+ svg: "image/svg+xml",
48
+ json: "application/json; charset=utf-8",
49
+ png: "image/png",
50
+ ico: "image/x-icon",
42
51
  };
43
52
  import { checkForUpdate, installUpdate, restartWithNewBuild, runsFromSourceCheckout } from "./update.js";
44
- export function startServer(hub, port, log, info, onShutdownRequest) {
53
+ export function startServer(hub, port, log, info, onShutdownRequest, onRestartRequest) {
45
54
  const uiDir = fileURLToPath(new URL("../ui/", import.meta.url));
46
55
  const assetsDir = fileURLToPath(new URL("../assets/", import.meta.url));
47
56
  const resolveModule = createRequire(import.meta.url).resolve;
@@ -53,16 +62,26 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
53
62
  return join(packageDir(entry.file.slice(0, slash)), entry.file.slice(slash + 1));
54
63
  };
55
64
  const clients = new Set();
56
- const snapshot = () => ({ ...hub.snapshot(), version: info });
65
+ const sockets = new Set();
66
+ const snapshot = () => ({ ...hub.snapshot(), version: { ...info, pid: process.pid } });
57
67
  const broadcast = (event) => {
58
- const payload = `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
68
+ const json = JSON.stringify(event);
69
+ const payload = `event: ${event.type}\ndata: ${json}\n\n`;
59
70
  for (const res of clients)
60
71
  res.write(payload);
72
+ for (const peer of sockets)
73
+ peer.send(json);
61
74
  };
62
75
  hub.on("event", broadcast);
63
76
  const heartbeat = setInterval(() => {
64
77
  for (const res of clients)
65
78
  res.write(": ping\n\n");
79
+ for (const peer of sockets) {
80
+ if (!peer.alive)
81
+ peer.close(1001, "no pong");
82
+ else
83
+ peer.ping();
84
+ }
66
85
  }, 20_000);
67
86
  const server = createServer(async (req, res) => {
68
87
  try {
@@ -77,6 +96,20 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
77
96
  res.end();
78
97
  }
79
98
  });
99
+ server.on("upgrade", (req, socket, head) => {
100
+ const path = new URL(req.url ?? "/", "http://localhost").pathname;
101
+ if (path !== "/ws") {
102
+ socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
103
+ socket.destroy();
104
+ return;
105
+ }
106
+ const peer = acceptUpgrade(req, socket, head);
107
+ if (!peer)
108
+ return;
109
+ sockets.add(peer);
110
+ peer.onClose(() => sockets.delete(peer));
111
+ peer.send(JSON.stringify({ type: "snapshot", snapshot: snapshot() }));
112
+ });
80
113
  async function handle(req, res) {
81
114
  const url = new URL(req.url ?? "/", "http://localhost");
82
115
  const path = url.pathname;
@@ -92,6 +125,18 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
92
125
  }
93
126
  return;
94
127
  }
128
+ const prismLanguage = req.method === "GET" && path.match(PRISM_LANGUAGE);
129
+ if (prismLanguage) {
130
+ try {
131
+ const body = await readFile(staticPath({ file: `prismjs/components/prism-${prismLanguage[1]}.min.js`, dir: "node_modules" }));
132
+ res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "public, max-age=3600" });
133
+ res.end(body);
134
+ }
135
+ catch {
136
+ sendJson(res, 404, { error: `no highlighting for "${prismLanguage[1]}"` });
137
+ }
138
+ return;
139
+ }
95
140
  if (req.method === "GET" && STATIC_FILES[path]) {
96
141
  const entry = STATIC_FILES[path];
97
142
  const body = await readFile(staticPath(entry));
@@ -99,6 +144,20 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
99
144
  res.end(body);
100
145
  return;
101
146
  }
147
+ const uiFile = req.method === "GET" && path.match(UI_FILE);
148
+ if (uiFile) {
149
+ let body;
150
+ try {
151
+ body = await readFile(`${uiDir}${uiFile[1]}`);
152
+ }
153
+ catch {
154
+ sendJson(res, 404, { error: `no such file: ${uiFile[1]}` });
155
+ return;
156
+ }
157
+ res.writeHead(200, { "Content-Type": UI_TYPES[uiFile[2].toLowerCase()], "Cache-Control": "no-cache" });
158
+ res.end(body);
159
+ return;
160
+ }
102
161
  if (req.method === "GET" && path === "/events") {
103
162
  res.writeHead(200, {
104
163
  "Content-Type": "text/event-stream",
@@ -130,13 +189,55 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
130
189
  }
131
190
  return;
132
191
  }
192
+ if (req.method === "GET" && path === "/api/resolve") {
193
+ const asked = url.searchParams.get("path") ?? "";
194
+ const inRoom = await resolveInRoom(hub, url.searchParams.get("room"), asked);
195
+ const target = inRoom ?? (classifyOpenTarget(asked)?.kind === "path" ? classifyOpenTarget(asked).value : null);
196
+ if (!target) {
197
+ sendJson(res, 404, { error: "not a file of this room's folder" });
198
+ return;
199
+ }
200
+ try {
201
+ const info = await stat(target);
202
+ sendJson(res, 200, { ok: true, path: target, kind: info.isFile() ? "file" : info.isDirectory() ? "dir" : "other" });
203
+ }
204
+ catch {
205
+ sendJson(res, 404, { error: `no such file or folder: ${target}` });
206
+ }
207
+ return;
208
+ }
209
+ if (req.method === "GET" && path === "/api/image") {
210
+ const asked = url.searchParams.get("path") ?? "";
211
+ const inRoom = await resolveInRoom(hub, url.searchParams.get("room"), asked);
212
+ const target = classifyOpenTarget(inRoom ?? asked);
213
+ if (!target || target.kind !== "path")
214
+ throw new Error("only absolute paths, or a path inside the room's folder, can be shown");
215
+ const media = imageMediaType(target.value);
216
+ if (!media)
217
+ throw new Error("not a picture the room can show");
218
+ let info;
219
+ try {
220
+ info = await stat(target.value);
221
+ }
222
+ catch {
223
+ sendJson(res, 404, { error: `no such file: ${target.value}` });
224
+ return;
225
+ }
226
+ if (!info.isFile())
227
+ throw new Error(`not a file: ${target.value}`);
228
+ if (info.size > IMAGE_VIEW_MAX_BYTES)
229
+ throw new Error(`too big to show here (${Math.round(info.size / 1024 / 1024)} MB); it opens with the default app`);
230
+ res.writeHead(200, { "Content-Type": media, "Content-Length": String(info.size), "Cache-Control": "no-cache" });
231
+ createReadStream(target.value).pipe(res);
232
+ return;
233
+ }
133
234
  if (req.method === "GET" && path === "/api/file") {
134
- const target = classifyOpenTarget(url.searchParams.get("path") ?? "");
235
+ const asked = url.searchParams.get("path") ?? "";
236
+ const relative = await resolveInRoom(hub, url.searchParams.get("room"), asked);
237
+ const target = classifyOpenTarget(relative ?? asked);
135
238
  if (!target || target.kind !== "path")
136
- throw new Error("only absolute paths can be viewed");
239
+ throw new Error("only absolute paths, or a path inside the room's folder, can be viewed");
137
240
  const kind = viewerKind(target.value);
138
- if (!kind)
139
- throw new Error("only Markdown and CSV files can be viewed in the room");
140
241
  let info;
141
242
  try {
142
243
  info = await stat(target.value);
@@ -147,10 +248,41 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
147
248
  }
148
249
  if (!info.isFile())
149
250
  throw new Error(`not a file: ${target.value}`);
150
- if (info.size > VIEWER_MAX_BYTES)
151
- throw new Error(`too big to view here (${Math.round(info.size / 1024)} kB); open it in an editor`);
152
- const text = await readFile(target.value, "utf8");
153
- sendJson(res, 200, kind === "csv" ? { ok: true, kind, path: target.value, rows: parseCsv(text) } : { ok: true, kind, path: target.value, text });
251
+ const asNumber = (name) => {
252
+ const raw = url.searchParams.get(name);
253
+ if (raw === null || raw === "")
254
+ return undefined;
255
+ const value = Number(raw);
256
+ if (!Number.isFinite(value))
257
+ throw new Error(`${name} must be a line number`);
258
+ return Math.max(1, Math.floor(value));
259
+ };
260
+ const from = asNumber("from");
261
+ const to = asNumber("to");
262
+ const language = languageOf(target.value);
263
+ if (info.size > VIEWER_MAX_BYTES) {
264
+ if (kind !== "text")
265
+ throw new Error(`too big to view here (${Math.round(info.size / 1024)} kB); open it in an editor`);
266
+ const window = await readLineWindow(target.value, from ?? 1, to);
267
+ if (window.binary)
268
+ throw new Error("not a text file; opening it with the default app instead");
269
+ sendJson(res, 200, { ok: true, kind, path: target.value, language, text: window.text, from: window.from, to: window.to, lines: window.lines, more: true, bytes: info.size });
270
+ return;
271
+ }
272
+ const buffer = await readFile(target.value);
273
+ if (looksBinary(buffer.subarray(0, 8192)))
274
+ throw new Error("not a text file; opening it with the default app instead");
275
+ const text = buffer.toString("utf8");
276
+ if (kind === "csv") {
277
+ sendJson(res, 200, { ok: true, kind, path: target.value, rows: parseCsv(text) });
278
+ return;
279
+ }
280
+ if (kind === "markdown" && from === undefined) {
281
+ sendJson(res, 200, { ok: true, kind, path: target.value, text, lines: text.split(/\r?\n/).length });
282
+ return;
283
+ }
284
+ const window = sliceLines(text, from, to);
285
+ sendJson(res, 200, { ok: true, kind, path: target.value, language, ...window, bytes: info.size });
154
286
  return;
155
287
  }
156
288
  const roomFile = req.method === "GET" && path.match(/^\/api\/rooms\/([^/]+)\/files\/([^/]+)$/);
@@ -202,7 +334,7 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
202
334
  return;
203
335
  }
204
336
  if (req.method === "GET" && path === "/api/version") {
205
- sendJson(res, 200, info);
337
+ sendJson(res, 200, { ...info, dataDir: hub.dataDir, pid: process.pid });
206
338
  return;
207
339
  }
208
340
  if (req.method === "GET" && path === "/api/skills") {
@@ -219,6 +351,27 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
219
351
  sendJson(res, 200, { skill });
220
352
  return;
221
353
  }
354
+ if (req.method === "GET" && path === "/api/mcp/room") {
355
+ const target = hub.resolveMcpToken(url.searchParams.get("token") ?? "");
356
+ if (!target) {
357
+ sendJson(res, 403, { error: "unknown skills token (the session it belonged to is gone)" });
358
+ return;
359
+ }
360
+ sendJson(res, 200, target.room.describeRoomForAgent(target.participantId));
361
+ return;
362
+ }
363
+ if (req.method === "GET" && path === "/api/mcp/message") {
364
+ const target = hub.resolveMcpToken(url.searchParams.get("token") ?? "");
365
+ if (!target) {
366
+ sendJson(res, 403, { error: "unknown skills token (the session it belonged to is gone)" });
367
+ return;
368
+ }
369
+ const seq = Number(url.searchParams.get("seq"));
370
+ if (!Number.isInteger(seq))
371
+ throw new Error("seq must be the message number, the N of #N");
372
+ sendJson(res, 200, target.room.readMessageForAgent(target.participantId, seq, Number(url.searchParams.get("around") ?? 0)));
373
+ return;
374
+ }
222
375
  if (req.method === "GET" && path === "/api/mcp/skill") {
223
376
  const target = hub.resolveMcpToken(url.searchParams.get("token") ?? "");
224
377
  if (!target) {
@@ -325,6 +478,50 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
325
478
  sendJson(res, 200, { ok: true, skill: hub.approveSkill(decodeURIComponent(skillApprove[1])) });
326
479
  return;
327
480
  }
481
+ if (path === "/api/mcp/design/lint" || path === "/api/mcp/templates") {
482
+ const target = hub.resolveMcpToken(String(body.token ?? ""));
483
+ if (!target) {
484
+ sendJson(res, 403, { error: "unknown skills token (the session it belonged to is gone)" });
485
+ return;
486
+ }
487
+ const design = {
488
+ name: optionalString(body.name) ?? undefined,
489
+ description: optionalString(body.description) ?? undefined,
490
+ emoji: optionalString(body.emoji) ?? undefined,
491
+ settings: body.settings && typeof body.settings === "object" && !Array.isArray(body.settings) ? body.settings : undefined,
492
+ vibemates: Array.isArray(body.vibemates) ? body.vibemates.map((v) => ({ ...v, name: String(v?.name ?? "") })) : undefined,
493
+ };
494
+ if (path === "/api/mcp/design/lint") {
495
+ sendJson(res, 200, target.room.lintDesignForAgent(target.participantId, body.kind === "room" ? "room" : "template", design));
496
+ return;
497
+ }
498
+ sendJson(res, 200, target.room.createTemplateForAgent(target.participantId, design, body.replace === true || body.replace === "true"));
499
+ return;
500
+ }
501
+ if (path === "/api/mcp/propose") {
502
+ const target = hub.resolveMcpToken(String(body.token ?? ""));
503
+ if (!target) {
504
+ sendJson(res, 403, { error: "unknown skills token (the session it belonged to is gone)" });
505
+ return;
506
+ }
507
+ const vib = body.vibemates && typeof body.vibemates === "object" ? body.vibemates : {};
508
+ const list = (v) => (Array.isArray(v) ? v.map((x) => ({ ...x, name: String(x?.name ?? "") })) : undefined);
509
+ sendJson(res, 200, target.room.proposeRoomChanges(target.participantId, String(body.why ?? ""), {
510
+ settings: body.settings && typeof body.settings === "object" && !Array.isArray(body.settings) ? body.settings : undefined,
511
+ vibemates: {
512
+ add: list(vib.add),
513
+ update: list(vib.update),
514
+ remove: Array.isArray(vib.remove) ? vib.remove.map((x) => String(x)) : undefined,
515
+ },
516
+ }));
517
+ return;
518
+ }
519
+ const proposal = path.match(/^\/api\/rooms\/([^/]+)\/proposals\/([^/]+)$/);
520
+ if (proposal) {
521
+ const room = hub.getRoom(decodeURIComponent(proposal[1]));
522
+ sendJson(res, 200, await room.resolveProposal(decodeURIComponent(proposal[2]), body.accept === true || body.accept === "true"));
523
+ return;
524
+ }
328
525
  if (path === "/api/mcp/skills" || path === "/api/mcp/attach") {
329
526
  const target = hub.resolveMcpToken(String(body.token ?? ""));
330
527
  if (!target) {
@@ -363,6 +560,16 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
363
560
  sendJson(res, 200, { ok: true });
364
561
  return;
365
562
  }
563
+ if (path === "/api/restart") {
564
+ if (!onRestartRequest) {
565
+ sendJson(res, 400, { error: "this hub cannot restart itself" });
566
+ return;
567
+ }
568
+ log.info("restart requested from the window");
569
+ sendJson(res, 200, { ok: true });
570
+ setTimeout(onRestartRequest, 50);
571
+ return;
572
+ }
366
573
  if (path === "/api/shutdown") {
367
574
  log.info("shutdown requested over the API");
368
575
  sendJson(res, 200, { ok: true });
@@ -412,7 +619,7 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
412
619
  sendJson(res, 200, { ok: true, command: command.name, participant: target.name });
413
620
  return;
414
621
  }
415
- const message = room.postHumanMessage(text, imageList(body.images));
622
+ const message = room.postHumanMessage(text, imageList(body.images), quoteList(body.quotes));
416
623
  sendJson(res, 200, { ok: true, id: message.id });
417
624
  }
418
625
  else if (action === "template-preview") {
@@ -501,8 +708,10 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
501
708
  const room = hub.getRoom(decodeURIComponent(participantAction[1]));
502
709
  const id = decodeURIComponent(participantAction[2]);
503
710
  const action = participantAction[3];
504
- if (action === "cancel")
505
- room.cancelTurn(id);
711
+ if (action === "cancel") {
712
+ sendJson(res, 200, { ok: true, stopped: room.cancelTurn(id) });
713
+ return;
714
+ }
506
715
  else if (action === "respawn") {
507
716
  const replay = body.replay === undefined || body.replay === null || body.replay === "" ? undefined : Number(body.replay);
508
717
  await room.respawnAgent(id, { memory: body.memory === true || body.memory === "true", replay: replay !== undefined && Number.isFinite(replay) ? Math.max(0, Math.min(500, Math.round(replay))) : undefined });
@@ -539,14 +748,18 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
539
748
  else if (action === "unmute")
540
749
  room.setMuted(id, false);
541
750
  else if (action === "persona") {
542
- room.updatePersona(id, {
751
+ const patch = {
543
752
  name: body.name === undefined ? undefined : String(body.name),
544
753
  tagline: body.tagline === undefined ? undefined : String(body.tagline),
545
754
  role: body.role === undefined ? undefined : String(body.role),
546
755
  avatar: body.avatar === undefined ? undefined : String(body.avatar),
547
756
  replyDelay: body.replyDelay === undefined ? undefined : body.replyDelay === null || body.replyDelay === "" ? null : Number(body.replyDelay),
548
757
  skills: stringList(body.skills),
549
- });
758
+ };
759
+ if (body.restart === true || body.restart === "true")
760
+ await room.restartWithPersona(id, patch);
761
+ else
762
+ room.updatePersona(id, patch);
550
763
  }
551
764
  else
552
765
  await room.setConfig(id, String(body.configId ?? ""), body.value);
@@ -576,6 +789,8 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
576
789
  clearInterval(heartbeat);
577
790
  for (const res of clients)
578
791
  res.end();
792
+ for (const peer of sockets)
793
+ peer.close(1001, "hub closing");
579
794
  server.close();
580
795
  },
581
796
  });
@@ -605,6 +820,68 @@ function imageList(value) {
605
820
  return { name: optionalString(image.name) ?? undefined, mimeType: String(image.mimeType ?? ""), data: String(image.data ?? ""), n: Number.isInteger(n) && n > 0 ? n : undefined };
606
821
  });
607
822
  }
823
+ function inside(child, root) {
824
+ const [c, r] = process.platform === "linux" ? [child, root] : [child.toLowerCase(), root.toLowerCase()];
825
+ return c === r || c.startsWith(r.endsWith(sep) ? r : r + sep);
826
+ }
827
+ async function resolveInRoom(hub, roomId, asked) {
828
+ const text = String(asked ?? "").trim();
829
+ if (!roomId || !text || isAbsolute(text) || /^[a-z]:[\\/]/i.test(text) || /^[a-z][a-z0-9+.-]*:/i.test(text) || text.startsWith("~"))
830
+ return null;
831
+ const room = hub.getRoom(roomId);
832
+ const dir = room?.dir;
833
+ if (!dir)
834
+ return null;
835
+ const full = resolve(dir, text);
836
+ const root = resolve(dir);
837
+ if (!inside(full, root))
838
+ return null;
839
+ try {
840
+ const realFull = await realpath(full);
841
+ const realRoot = await realpath(root);
842
+ return inside(realFull, realRoot) ? full : null;
843
+ }
844
+ catch {
845
+ return full;
846
+ }
847
+ }
848
+ async function readLineWindow(path, from, to) {
849
+ const end = Math.max(from, Math.min(to ?? from + WINDOW_MAX_LINES - 1, from + WINDOW_MAX_LINES - 1));
850
+ const stream = createReadStream(path, { encoding: "utf8", highWaterMark: 256 * 1024 });
851
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
852
+ const kept = [];
853
+ let n = 0;
854
+ let bytes = 0;
855
+ let binary = false;
856
+ try {
857
+ for await (const line of lines) {
858
+ n++;
859
+ bytes += line.length + 1;
860
+ if (n === 1 && looksBinary(Buffer.from(line.slice(0, 4096), "utf8"))) {
861
+ binary = true;
862
+ break;
863
+ }
864
+ if (n >= from && n <= end)
865
+ kept.push(line);
866
+ if (n >= end || bytes > STREAM_MAX_BYTES)
867
+ break;
868
+ }
869
+ }
870
+ finally {
871
+ lines.close();
872
+ stream.destroy();
873
+ }
874
+ return { text: kept.join("\n"), from, to: Math.min(end, n), lines: n, binary };
875
+ }
876
+ function quoteList(value) {
877
+ if (!Array.isArray(value))
878
+ return [];
879
+ return value.slice(0, QUOTES_PER_MESSAGE).map((entry) => {
880
+ const quote = (entry ?? {});
881
+ const n = Number(quote.n);
882
+ return { seq: Number(quote.seq), text: String(quote.text ?? ""), n: Number.isInteger(n) && n > 0 ? n : undefined };
883
+ });
884
+ }
608
885
  function sendJson(res, status, body) {
609
886
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
610
887
  res.end(JSON.stringify(body));
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/dist/update.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
2
  import { spawn } from "node:child_process";
3
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
- import { join } from "node:path";
3
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
+ import { join, relative } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  export const REGISTRY_URL = "https://registry.npmjs.org/viberoom/latest";
7
7
  export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -69,6 +69,36 @@ export function runsFromSourceCheckout(mainModuleUrl) {
69
69
  const path = decodeURIComponent(new URL(mainModuleUrl).pathname);
70
70
  return !/\/node_modules\/viberoom\//.test(path);
71
71
  }
72
+ export function newerSourceThanBuild(mainModuleUrl) {
73
+ if (!runsFromSourceCheckout(mainModuleUrl))
74
+ return null;
75
+ const root = fileURLToPath(new URL("../", mainModuleUrl));
76
+ const src = join(root, "src");
77
+ if (!existsSync(src))
78
+ return null;
79
+ let built;
80
+ try {
81
+ built = statSync(fileURLToPath(mainModuleUrl)).mtimeMs;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ let newest = null;
87
+ const walk = (dir) => {
88
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
89
+ const path = join(dir, entry.name);
90
+ if (entry.isDirectory())
91
+ walk(path);
92
+ else if (entry.name.endsWith(".ts")) {
93
+ const mtime = statSync(path).mtimeMs;
94
+ if (mtime > built + 1000 && (!newest || mtime > newest.mtime))
95
+ newest = { path, mtime };
96
+ }
97
+ }
98
+ };
99
+ walk(src);
100
+ return newest ? relative(root, newest.path).split("\\").join("/") : null;
101
+ }
72
102
  export function installCommandLine(version) {
73
103
  if (!parseVersion(version))
74
104
  throw new Error(`not a version: ${version}`);