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/README.md +20 -0
- package/dist/agent-health.js +52 -0
- package/dist/context.js +6 -0
- package/dist/duration.js +12 -0
- package/dist/hub.js +13 -2
- package/dist/launcher.js +19 -2
- package/dist/main.js +77 -34
- package/dist/mcp-skills-server.js +143 -0
- package/dist/persona.js +167 -46
- package/dist/quotes.js +41 -0
- package/dist/recipes.js +28 -1
- package/dist/room-design.js +205 -0
- package/dist/room.js +388 -150
- package/dist/rows.js +19 -0
- package/dist/server.js +304 -27
- package/dist/skills.js +28 -2
- package/dist/templates.js +17 -0
- package/dist/update.js +32 -2
- package/dist/viewer.js +69 -2
- package/dist/ws.js +175 -0
- package/package.json +2 -1
- package/ui/app.css +348 -246
- package/ui/app.js +1422 -345
- package/ui/avatars.js +131 -30
- package/ui/components.css +140 -0
- package/ui/components.js +342 -0
- package/ui/icons.js +11 -0
- package/ui/index.html +58 -36
- package/ui/theme.css +457 -180
- package/ui/tokens.js +620 -0
- package/ui/ui.js +113 -0
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
|
|
65
|
+
const sockets = new Set();
|
|
66
|
+
const snapshot = () => ({ ...hub.snapshot(), version: { ...info, pid: process.pid } });
|
|
57
67
|
const broadcast = (event) => {
|
|
58
|
-
const
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}`);
|