viberoom 0.5.2 → 0.5.4
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/NOTICE +8 -2
- package/README.md +1 -1
- package/dist/hub.js +55 -2
- package/dist/main.js +10 -0
- package/dist/room.js +43 -3
- package/dist/server.js +41 -1
- package/dist/templates.js +27 -3
- package/dist/update.js +95 -0
- package/package.json +1 -1
- package/templates/wren-and-quinn/template.json +28 -0
- package/ui/app.css +61 -19
- package/ui/app.js +306 -14
- package/ui/fonts/fira-code-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/fira-code-cyrillic.woff2 +0 -0
- package/ui/fonts/fira-code-latin-ext.woff2 +0 -0
- package/ui/fonts/fira-code-latin.woff2 +0 -0
- package/ui/fonts/fira-code.css +5 -0
- package/ui/fonts/inter-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/inter-cyrillic.woff2 +0 -0
- package/ui/fonts/inter-latin-ext.woff2 +0 -0
- package/ui/fonts/inter-latin.woff2 +0 -0
- package/ui/fonts/inter.css +5 -0
- package/ui/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
- package/ui/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- package/ui/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/ui/fonts/jetbrains-mono.css +5 -0
- package/ui/fonts/noto-sans-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/noto-sans-cyrillic.woff2 +0 -0
- package/ui/fonts/noto-sans-latin-ext.woff2 +0 -0
- package/ui/fonts/noto-sans-latin.woff2 +0 -0
- package/ui/fonts/noto-sans.css +5 -0
- package/ui/fonts/source-code-pro-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/source-code-pro-cyrillic.woff2 +0 -0
- package/ui/fonts/source-code-pro-latin-ext.woff2 +0 -0
- package/ui/fonts/source-code-pro-latin.woff2 +0 -0
- package/ui/fonts/source-code-pro.css +5 -0
- package/ui/index.html +11 -0
- package/ui/theme.css +44 -18
- package/templates/forge-and-lumen/template.json +0 -28
package/NOTICE
CHANGED
|
@@ -3,8 +3,14 @@ Copyright (c) 2026 Todor Rusev
|
|
|
3
3
|
|
|
4
4
|
This product bundles the following third-party material:
|
|
5
5
|
|
|
6
|
-
-
|
|
7
|
-
|
|
6
|
+
- Fonts (ui/fonts/), each licensed under the SIL Open Font License, Version 1.1
|
|
7
|
+
(ui/fonts/OFL.txt), fetched from Google Fonts by scripts/fetch-fonts.mjs:
|
|
8
|
+
- Nunito: Copyright 2014 The Nunito Project Authors.
|
|
9
|
+
- Inter: Copyright 2016 The Inter Project Authors.
|
|
10
|
+
- Noto Sans: Copyright 2022 The Noto Project Authors.
|
|
11
|
+
- JetBrains Mono: Copyright 2020 The JetBrains Mono Project Authors.
|
|
12
|
+
- Fira Code: Copyright 2014-2020 The Fira Code Project Authors.
|
|
13
|
+
- Source Code Pro: Copyright 2010-2019 Adobe (http://www.adobe.com/).
|
|
8
14
|
|
|
9
15
|
- Vendor icons (assets/vendors/): the logos of Claude, Codex, Gemini, Cursor,
|
|
10
16
|
OpenCode and GitHub Copilot, as published in the Agent Client Protocol
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Open a room, summon the agents you already have, give each one a role, and let t
|
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
16
|
<p align="center">
|
|
17
|
-
<img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/conversation.png" width="960" alt="A
|
|
17
|
+
<img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/conversation.png" width="960" alt="A Wren & Quinn room: Wren explains a git command from a screenshot, Quinn adds the part that changes whose problem it is">
|
|
18
18
|
</p>
|
|
19
19
|
|
|
20
20
|
<p align="center">
|
package/dist/hub.js
CHANGED
|
@@ -11,6 +11,9 @@ import { DEFAULT_ROOM_SETTINGS } from "./persona.js";
|
|
|
11
11
|
import { Room } from "./room.js";
|
|
12
12
|
import { SkillLibrary } from "./skills.js";
|
|
13
13
|
import { TemplateLibrary } from "./templates.js";
|
|
14
|
+
export const TEXT_FONTS = ["nunito", "inter", "noto-sans", "arial", "system"];
|
|
15
|
+
export const MONO_FONTS = ["jetbrains-mono", "fira-code", "source-code-pro", "system"];
|
|
16
|
+
export const DEFAULT_APPEARANCE = { chatFontSize: 14.5, font: "nunito", mono: "jetbrains-mono" };
|
|
14
17
|
export const DIAGRAM_PRESETS = ["pop", "lavender", "mint", "sunset", "slate"];
|
|
15
18
|
const ROOM_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
16
19
|
const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules"];
|
|
@@ -109,6 +112,8 @@ export class Hub extends EventEmitter {
|
|
|
109
112
|
agentSkillsNeedApproval: false,
|
|
110
113
|
diagrams: { preset: "pop", primary: null },
|
|
111
114
|
editor: { ...DEFAULT_EDITOR_SETTINGS },
|
|
115
|
+
appearance: { ...DEFAULT_APPEARANCE },
|
|
116
|
+
checkForUpdates: true,
|
|
112
117
|
roomDefaults: {},
|
|
113
118
|
vendorPresets: {},
|
|
114
119
|
};
|
|
@@ -161,6 +166,8 @@ export class Hub extends EventEmitter {
|
|
|
161
166
|
next.profileCompleted = patch.profileCompleted === true || patch.profileCompleted === "true";
|
|
162
167
|
if (patch.agentSkillsNeedApproval !== undefined)
|
|
163
168
|
next.agentSkillsNeedApproval = patch.agentSkillsNeedApproval === true || patch.agentSkillsNeedApproval === "true";
|
|
169
|
+
if (patch.checkForUpdates !== undefined)
|
|
170
|
+
next.checkForUpdates = patch.checkForUpdates === true || patch.checkForUpdates === "true";
|
|
164
171
|
if (patch.diagrams !== undefined && typeof patch.diagrams === "object" && patch.diagrams) {
|
|
165
172
|
const d = patch.diagrams;
|
|
166
173
|
const preset = String(d.preset ?? next.diagrams?.preset ?? "pop");
|
|
@@ -186,6 +193,20 @@ export class Hub extends EventEmitter {
|
|
|
186
193
|
throw new Error("editor.command must mention {file} (and usually {line})");
|
|
187
194
|
next.editor = { mode, command };
|
|
188
195
|
}
|
|
196
|
+
if (patch.appearance !== undefined && typeof patch.appearance === "object" && patch.appearance) {
|
|
197
|
+
const a = patch.appearance;
|
|
198
|
+
const chatFontSize = Number(a.chatFontSize ?? next.appearance?.chatFontSize ?? DEFAULT_APPEARANCE.chatFontSize);
|
|
199
|
+
if (!Number.isFinite(chatFontSize) || chatFontSize < 12 || chatFontSize > 24)
|
|
200
|
+
throw new Error("appearance.chatFontSize must be between 12 and 24");
|
|
201
|
+
const font = String(a.font ?? next.appearance?.font ?? DEFAULT_APPEARANCE.font);
|
|
202
|
+
if (!TEXT_FONTS.includes(font))
|
|
203
|
+
throw new Error(`appearance.font must be one of ${TEXT_FONTS.join(", ")}`);
|
|
204
|
+
const mono = String(a.mono ?? next.appearance?.mono ?? DEFAULT_APPEARANCE.mono);
|
|
205
|
+
if (!MONO_FONTS.includes(mono))
|
|
206
|
+
throw new Error(`appearance.mono must be one of ${MONO_FONTS.join(", ")}`);
|
|
207
|
+
next.appearance = { chatFontSize: Math.round(chatFontSize * 2) / 2, font, mono };
|
|
208
|
+
}
|
|
209
|
+
next.appearance = { ...DEFAULT_APPEARANCE, ...(next.appearance ?? {}) };
|
|
189
210
|
if (!next.editor)
|
|
190
211
|
next.editor = { ...DEFAULT_EDITOR_SETTINGS };
|
|
191
212
|
if (patch.roomDefaults !== undefined && typeof patch.roomDefaults === "object" && patch.roomDefaults) {
|
|
@@ -311,9 +332,16 @@ export class Hub extends EventEmitter {
|
|
|
311
332
|
const template = this.templates.get(input.templateId);
|
|
312
333
|
if (!template)
|
|
313
334
|
throw new Error(`no such template: ${input.templateId}`);
|
|
314
|
-
const { room, notices } = this.createRoom({ name: input.name, dir: input.dir, settings: template.settings });
|
|
335
|
+
const { room, notices } = this.createRoom({ name: input.name, dir: input.dir || template.dir || null, settings: template.settings });
|
|
336
|
+
const installed = new Set(listRecipes().filter((r) => !r.unavailableReason).map((r) => r.id));
|
|
315
337
|
for (const [i, tv] of template.vibemates.entries()) {
|
|
316
|
-
const choice = input.vibemates[i] ?? { name: tv.name, agentType: "" };
|
|
338
|
+
const choice = { ...(input.vibemates[i] ?? { name: tv.name, agentType: "" }) };
|
|
339
|
+
if (!choice.agentType && tv.agentType) {
|
|
340
|
+
if (installed.has(tv.agentType))
|
|
341
|
+
choice.agentType = tv.agentType;
|
|
342
|
+
else
|
|
343
|
+
notices.push(`${choice.name || tv.name}: the template runs it on ${tv.agentType}, which is not installed here; pick another agent in the roster.`);
|
|
344
|
+
}
|
|
317
345
|
const skills = (tv.skills ?? []).filter((name) => {
|
|
318
346
|
const ok = !!this.skills.get(name);
|
|
319
347
|
if (!ok)
|
|
@@ -337,6 +365,7 @@ export class Hub extends EventEmitter {
|
|
|
337
365
|
role: tv.role,
|
|
338
366
|
avatar: tv.avatar,
|
|
339
367
|
skills,
|
|
368
|
+
replyDelay: tv.replyDelay,
|
|
340
369
|
model: choice.model ?? tv.model,
|
|
341
370
|
effort: choice.effort ?? tv.effort,
|
|
342
371
|
mode: choice.mode ?? tv.mode,
|
|
@@ -349,6 +378,24 @@ export class Hub extends EventEmitter {
|
|
|
349
378
|
this.saveRooms();
|
|
350
379
|
return { room, notices };
|
|
351
380
|
}
|
|
381
|
+
saveRoomAsTemplate(roomId, input) {
|
|
382
|
+
const name = input.name.trim();
|
|
383
|
+
if (!name)
|
|
384
|
+
throw new Error("the template needs a name");
|
|
385
|
+
const room = this.getRoom(roomId);
|
|
386
|
+
const base = room.templateOf();
|
|
387
|
+
const edited = input.template ?? {};
|
|
388
|
+
const saved = this.templates.save({
|
|
389
|
+
name,
|
|
390
|
+
description: input.description.trim(),
|
|
391
|
+
emoji: (input.emoji ?? room.settings.emoji) || undefined,
|
|
392
|
+
dir: edited.dir?.trim() || base.dir,
|
|
393
|
+
settings: { ...base.settings, ...(edited.settings ?? {}) },
|
|
394
|
+
vibemates: edited.vibemates ?? base.vibemates,
|
|
395
|
+
});
|
|
396
|
+
this.emit("event", { type: "templates" });
|
|
397
|
+
return saved;
|
|
398
|
+
}
|
|
352
399
|
getRoom(id) {
|
|
353
400
|
const room = this.rooms.get(id);
|
|
354
401
|
if (!room)
|
|
@@ -388,9 +435,15 @@ export class Hub extends EventEmitter {
|
|
|
388
435
|
else
|
|
389
436
|
this.log.info(`removed room ${id}`);
|
|
390
437
|
}
|
|
438
|
+
update = null;
|
|
439
|
+
setUpdate(update) {
|
|
440
|
+
this.update = update;
|
|
441
|
+
this.emit("event", { type: "update", update });
|
|
442
|
+
}
|
|
391
443
|
snapshot() {
|
|
392
444
|
return {
|
|
393
445
|
settings: this.settings,
|
|
446
|
+
update: this.update,
|
|
394
447
|
recipes: listRecipes().map(({ build: _b, ...r }) => r),
|
|
395
448
|
skills: this.skills.list(),
|
|
396
449
|
roomDefaults: { ...DEFAULT_ROOM_SETTINGS, ...this.settings.roomDefaults },
|
package/dist/main.js
CHANGED
|
@@ -86,6 +86,7 @@ Options
|
|
|
86
86
|
--browser open the default browser instead of a Chromium app window
|
|
87
87
|
`);
|
|
88
88
|
}
|
|
89
|
+
import { checkForUpdate } from "./update.js";
|
|
89
90
|
function buildInfo() {
|
|
90
91
|
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
91
92
|
const built = statSync(fileURLToPath(import.meta.url)).mtime;
|
|
@@ -251,6 +252,15 @@ async function runHub(options, log, info) {
|
|
|
251
252
|
}
|
|
252
253
|
}
|
|
253
254
|
hub.setHubUrl(server.url);
|
|
255
|
+
if (hub.settings.checkForUpdates) {
|
|
256
|
+
void checkForUpdate(hub.dataDir, info.version).then((update) => {
|
|
257
|
+
hub.setUpdate(update);
|
|
258
|
+
if (update.available)
|
|
259
|
+
log.info(`viberoom ${update.latest} is available (this is ${update.current})`);
|
|
260
|
+
else if (update.error)
|
|
261
|
+
log.warn(`update check failed: ${update.error}`);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
254
264
|
if (background)
|
|
255
265
|
writePidFile(options.dataDir, { pid: process.pid, port: options.port, build: info.build, startedAt: Date.now() });
|
|
256
266
|
log.info(`viberoom ${info.version} (build ${info.build}) is open at ${server.url} (data: ${hub.dataDir}; rooms: ${[...hub.rooms.values()].map((r) => r.name).join(", ")})`);
|
package/dist/room.js
CHANGED
|
@@ -133,6 +133,40 @@ export class Room extends EventEmitter {
|
|
|
133
133
|
this.colorIndex++;
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
+
templateOf() {
|
|
137
|
+
const { name: _n, humanName: _h, ...rest } = this.settings;
|
|
138
|
+
const settings = { ...rest, customRules: this.renderRuleReferences(this.settings.customRules) };
|
|
139
|
+
const vibemates = [];
|
|
140
|
+
for (const p of this.participants.values()) {
|
|
141
|
+
if (p.kind !== "agent" || p.status === "left")
|
|
142
|
+
continue;
|
|
143
|
+
const v = { name: p.name };
|
|
144
|
+
if (p.tagline)
|
|
145
|
+
v.tagline = p.tagline;
|
|
146
|
+
if (p.role)
|
|
147
|
+
v.role = p.role;
|
|
148
|
+
if (p.avatar)
|
|
149
|
+
v.avatar = p.avatar;
|
|
150
|
+
if (p.skills?.length)
|
|
151
|
+
v.skills = [...p.skills];
|
|
152
|
+
if (p.replyDelay !== undefined)
|
|
153
|
+
v.replyDelay = p.replyDelay;
|
|
154
|
+
if (p.agentType) {
|
|
155
|
+
v.agentType = p.agentType;
|
|
156
|
+
const model = p.launch?.model ?? p.model;
|
|
157
|
+
const effort = p.launch?.effort ?? p.effort;
|
|
158
|
+
const mode = p.launch?.mode ?? p.mode;
|
|
159
|
+
if (model)
|
|
160
|
+
v.model = model;
|
|
161
|
+
if (effort)
|
|
162
|
+
v.effort = effort;
|
|
163
|
+
if (mode)
|
|
164
|
+
v.mode = mode;
|
|
165
|
+
}
|
|
166
|
+
vibemates.push(v);
|
|
167
|
+
}
|
|
168
|
+
return { dir: this.dir, settings, vibemates };
|
|
169
|
+
}
|
|
136
170
|
toStored() {
|
|
137
171
|
const { name: _n, humanName: _h, ...settings } = this.settings;
|
|
138
172
|
return {
|
|
@@ -931,6 +965,7 @@ export class Room extends EventEmitter {
|
|
|
931
965
|
turnActive: false,
|
|
932
966
|
pendingTurn: false,
|
|
933
967
|
turn: null,
|
|
968
|
+
strayMessageId: null,
|
|
934
969
|
turnsSinceBrief: 0,
|
|
935
970
|
usedAtBrief: 0,
|
|
936
971
|
briefSentThisTurn: false,
|
|
@@ -1886,11 +1921,16 @@ export class Room extends EventEmitter {
|
|
|
1886
1921
|
const turn = runtime.turn;
|
|
1887
1922
|
switch (update.sessionUpdate) {
|
|
1888
1923
|
case "agent_message_chunk": {
|
|
1889
|
-
if (!turn)
|
|
1890
|
-
return;
|
|
1891
1924
|
const u = update;
|
|
1892
|
-
let text = contentText(u.content);
|
|
1893
1925
|
const messageId = u.messageId ?? null;
|
|
1926
|
+
if (!turn) {
|
|
1927
|
+
if (messageId)
|
|
1928
|
+
runtime.strayMessageId = messageId;
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
if (messageId && messageId === runtime.strayMessageId)
|
|
1932
|
+
return;
|
|
1933
|
+
let text = contentText(u.content);
|
|
1894
1934
|
if (messageId && !turn.sawMessageId && turn.message.text) {
|
|
1895
1935
|
const notices = (turn.message.notices ??= []);
|
|
1896
1936
|
notices.push(turn.message.text.trim());
|
package/dist/server.js
CHANGED
|
@@ -40,6 +40,7 @@ const STATIC_FILES = {
|
|
|
40
40
|
"/vendor/mermaid.min.js": { file: "mermaid/dist/mermaid.min.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
|
|
41
41
|
"/vendor/marked.umd.js": { file: "marked/lib/marked.umd.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
|
|
42
42
|
};
|
|
43
|
+
import { checkForUpdate, installUpdate, restartWithNewBuild, runsFromSourceCheckout } from "./update.js";
|
|
43
44
|
export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
44
45
|
const uiDir = fileURLToPath(new URL("../ui/", import.meta.url));
|
|
45
46
|
const assetsDir = fileURLToPath(new URL("../assets/", import.meta.url));
|
|
@@ -178,6 +179,28 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
178
179
|
sendJson(res, 200, snapshot());
|
|
179
180
|
return;
|
|
180
181
|
}
|
|
182
|
+
if (req.method === "GET" && path === "/api/update") {
|
|
183
|
+
if (url.searchParams.get("check") === "1")
|
|
184
|
+
hub.setUpdate(await checkForUpdate(hub.dataDir, info.version, { force: true }));
|
|
185
|
+
sendJson(res, 200, hub.update ?? { current: info.version, latest: null, available: false, checkedAt: null, error: null });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (req.method === "POST" && path === "/api/update/install") {
|
|
189
|
+
const mainUrl = new URL("./main.js", import.meta.url).href;
|
|
190
|
+
if (runsFromSourceCheckout(mainUrl))
|
|
191
|
+
throw new Error("this viberoom runs from a source checkout; update it with git pull and npm run update");
|
|
192
|
+
const latest = hub.update?.available ? hub.update.latest : null;
|
|
193
|
+
if (!latest)
|
|
194
|
+
throw new Error("no newer version is known; check for updates first");
|
|
195
|
+
log.info(`installing viberoom ${latest} (npm install -g)`);
|
|
196
|
+
const result = await installUpdate(latest);
|
|
197
|
+
if (!result.ok)
|
|
198
|
+
throw new Error(`npm install failed: ${result.output.slice(-600) || "no output"}`);
|
|
199
|
+
log.info(`viberoom ${latest} installed; starting the new build, which replaces this hub`);
|
|
200
|
+
sendJson(res, 200, { ok: true, version: latest });
|
|
201
|
+
setTimeout(() => restartWithNewBuild(mainUrl, port, hub.dataDir), 300);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
181
204
|
if (req.method === "GET" && path === "/api/version") {
|
|
182
205
|
sendJson(res, 200, info);
|
|
183
206
|
return;
|
|
@@ -369,7 +392,7 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
369
392
|
sendJson(res, 200, { ok: true, room: room.snapshot(), notices });
|
|
370
393
|
return;
|
|
371
394
|
}
|
|
372
|
-
const roomAction = path.match(/^\/api\/rooms\/([^/]+)\/(send|typing|invite|settings|focus|rename|dir|delete|open)$/);
|
|
395
|
+
const roomAction = path.match(/^\/api\/rooms\/([^/]+)\/(send|typing|invite|settings|focus|rename|dir|delete|open|template-preview|save-template)$/);
|
|
373
396
|
if (roomAction) {
|
|
374
397
|
const room = hub.getRoom(decodeURIComponent(roomAction[1]));
|
|
375
398
|
const action = roomAction[2];
|
|
@@ -392,6 +415,23 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
392
415
|
const message = room.postHumanMessage(text, imageList(body.images));
|
|
393
416
|
sendJson(res, 200, { ok: true, id: message.id });
|
|
394
417
|
}
|
|
418
|
+
else if (action === "template-preview") {
|
|
419
|
+
sendJson(res, 200, { template: { name: room.name, emoji: room.settings.emoji || "", ...room.templateOf() } });
|
|
420
|
+
}
|
|
421
|
+
else if (action === "save-template") {
|
|
422
|
+
const edited = body.template && typeof body.template === "object" ? body.template : undefined;
|
|
423
|
+
const template = hub.saveRoomAsTemplate(room.id, {
|
|
424
|
+
name: String(body.name ?? ""),
|
|
425
|
+
description: String(body.description ?? ""),
|
|
426
|
+
emoji: body.emoji === undefined ? undefined : String(body.emoji),
|
|
427
|
+
template: edited && {
|
|
428
|
+
dir: optionalString(edited.dir) ?? undefined,
|
|
429
|
+
settings: edited.settings && typeof edited.settings === "object" ? edited.settings : undefined,
|
|
430
|
+
vibemates: Array.isArray(edited.vibemates) ? edited.vibemates : undefined,
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
sendJson(res, 200, { ok: true, template });
|
|
434
|
+
}
|
|
395
435
|
else if (action === "typing") {
|
|
396
436
|
room.humanTyping();
|
|
397
437
|
sendJson(res, 200, { ok: true });
|
package/dist/templates.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
6
7
|
export const SHIPPED_TEMPLATES_DIR = fileURLToPath(new URL("../templates/", import.meta.url));
|
|
@@ -22,6 +23,8 @@ export function cleanTemplate(raw, id) {
|
|
|
22
23
|
}
|
|
23
24
|
if (Array.isArray(o.skills))
|
|
24
25
|
out.skills = o.skills.map((s) => String(s).trim()).filter(Boolean);
|
|
26
|
+
if (typeof o.replyDelay === "number" && Number.isFinite(o.replyDelay))
|
|
27
|
+
out.replyDelay = o.replyDelay;
|
|
25
28
|
return out;
|
|
26
29
|
});
|
|
27
30
|
const settings = (t.settings && typeof t.settings === "object" ? t.settings : {});
|
|
@@ -32,8 +35,16 @@ export function cleanTemplate(raw, id) {
|
|
|
32
35
|
out.emoji = t.emoji.trim().slice(0, 8);
|
|
33
36
|
if (t.recommended === true)
|
|
34
37
|
out.recommended = true;
|
|
38
|
+
if (typeof t.dir === "string" && t.dir.trim())
|
|
39
|
+
out.dir = t.dir.trim();
|
|
40
|
+
if (typeof t.created === "string" && t.created.trim())
|
|
41
|
+
out.created = t.created.trim();
|
|
35
42
|
return out;
|
|
36
43
|
}
|
|
44
|
+
export function templateId(name) {
|
|
45
|
+
const id = name.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
46
|
+
return ID_PATTERN.test(id) ? id : "template";
|
|
47
|
+
}
|
|
37
48
|
function readTemplates(dir, log, builtin) {
|
|
38
49
|
const out = [];
|
|
39
50
|
if (!existsSync(dir))
|
|
@@ -54,7 +65,7 @@ function readTemplates(dir, log, builtin) {
|
|
|
54
65
|
log.warn(`template ${entry.name} in ${dir} skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
66
|
}
|
|
56
67
|
}
|
|
57
|
-
return out.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.name.localeCompare(b.name));
|
|
68
|
+
return out.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || (b.created ?? "").localeCompare(a.created ?? "") || a.name.localeCompare(b.name));
|
|
58
69
|
}
|
|
59
70
|
export class TemplateLibrary {
|
|
60
71
|
dir;
|
|
@@ -68,9 +79,22 @@ export class TemplateLibrary {
|
|
|
68
79
|
list() {
|
|
69
80
|
const own = readTemplates(this.dir, this.log, false);
|
|
70
81
|
const taken = new Set(own.map((t) => t.id));
|
|
71
|
-
return [...readTemplates(this.shippedDir, this.log, true).filter((t) => !taken.has(t.id))
|
|
82
|
+
return [...own, ...readTemplates(this.shippedDir, this.log, true).filter((t) => !taken.has(t.id))];
|
|
72
83
|
}
|
|
73
84
|
get(id) {
|
|
74
85
|
return this.list().find((t) => t.id === id);
|
|
75
86
|
}
|
|
87
|
+
save(template) {
|
|
88
|
+
const taken = new Set(this.list().map((t) => t.id));
|
|
89
|
+
const base = templateId(template.name);
|
|
90
|
+
let id = base;
|
|
91
|
+
for (let n = 2; taken.has(id); n++)
|
|
92
|
+
id = `${base.slice(0, 40 - String(n).length - 1)}-${n}`;
|
|
93
|
+
const clean = cleanTemplate({ ...template, created: new Date().toISOString() }, id);
|
|
94
|
+
const folder = join(this.dir, id);
|
|
95
|
+
mkdirSync(folder, { recursive: true });
|
|
96
|
+
writeFileAtomic(join(folder, "template.json"), JSON.stringify(clean, null, 2) + "\n");
|
|
97
|
+
this.log.info(`saved template "${clean.name}" (${id})`);
|
|
98
|
+
return clean;
|
|
99
|
+
}
|
|
76
100
|
}
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
export const REGISTRY_URL = "https://registry.npmjs.org/viberoom/latest";
|
|
7
|
+
export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const CHECK_FILE = "update-check.json";
|
|
9
|
+
export function parseVersion(v) {
|
|
10
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/.exec(v.trim());
|
|
11
|
+
if (!m)
|
|
12
|
+
return null;
|
|
13
|
+
return { parts: [Number(m[1]), Number(m[2]), Number(m[3])], prerelease: !!m[4] };
|
|
14
|
+
}
|
|
15
|
+
export function compareVersions(a, b) {
|
|
16
|
+
const pa = parseVersion(a);
|
|
17
|
+
const pb = parseVersion(b);
|
|
18
|
+
if (!pa || !pb)
|
|
19
|
+
return 0;
|
|
20
|
+
for (let i = 0; i < 3; i++)
|
|
21
|
+
if (pa.parts[i] !== pb.parts[i])
|
|
22
|
+
return pa.parts[i] - pb.parts[i];
|
|
23
|
+
if (pa.prerelease !== pb.prerelease)
|
|
24
|
+
return pa.prerelease ? -1 : 1;
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
export function readCheckRecord(dataDir) {
|
|
28
|
+
const file = join(dataDir, CHECK_FILE);
|
|
29
|
+
if (!existsSync(file))
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
33
|
+
if (typeof raw.checkedAt !== "string")
|
|
34
|
+
return null;
|
|
35
|
+
return { checkedAt: raw.checkedAt, latest: typeof raw.latest === "string" ? raw.latest : null, error: typeof raw.error === "string" ? raw.error : null };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function toInfo(current, record) {
|
|
42
|
+
const latest = record?.latest ?? null;
|
|
43
|
+
return { current, latest, available: latest !== null && compareVersions(latest, current) > 0, checkedAt: record?.checkedAt ?? null, error: record?.error ?? null };
|
|
44
|
+
}
|
|
45
|
+
export async function checkForUpdate(dataDir, current, options = {}) {
|
|
46
|
+
const now = options.now ?? Date.now;
|
|
47
|
+
const previous = readCheckRecord(dataDir);
|
|
48
|
+
if (!options.force && previous && now() - Date.parse(previous.checkedAt) < CHECK_INTERVAL_MS)
|
|
49
|
+
return toInfo(current, previous);
|
|
50
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
51
|
+
let record;
|
|
52
|
+
try {
|
|
53
|
+
const res = await doFetch(REGISTRY_URL, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(options.timeoutMs ?? 6000) });
|
|
54
|
+
if (!res.ok)
|
|
55
|
+
throw new Error(`registry answered ${res.status}`);
|
|
56
|
+
const body = (await res.json());
|
|
57
|
+
if (typeof body.version !== "string" || !parseVersion(body.version))
|
|
58
|
+
throw new Error("registry answer had no version");
|
|
59
|
+
record = { checkedAt: new Date(now()).toISOString(), latest: body.version, error: null };
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
record = { checkedAt: new Date(now()).toISOString(), latest: previous?.latest ?? null, error: message.replace(/\s+/g, " ").slice(0, 200) };
|
|
64
|
+
}
|
|
65
|
+
writeFileSync(join(dataDir, CHECK_FILE), JSON.stringify(record, null, 2));
|
|
66
|
+
return toInfo(current, record);
|
|
67
|
+
}
|
|
68
|
+
export function runsFromSourceCheckout(mainModuleUrl) {
|
|
69
|
+
const path = decodeURIComponent(new URL(mainModuleUrl).pathname);
|
|
70
|
+
return !/\/node_modules\/viberoom\//.test(path);
|
|
71
|
+
}
|
|
72
|
+
export function installCommandLine(version) {
|
|
73
|
+
if (!parseVersion(version))
|
|
74
|
+
throw new Error(`not a version: ${version}`);
|
|
75
|
+
return `npm install -g viberoom@${version} --no-audit --no-fund`;
|
|
76
|
+
}
|
|
77
|
+
export function installUpdate(version) {
|
|
78
|
+
const line = installCommandLine(version);
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const child = process.platform === "win32" ? spawn(line, { shell: true, windowsHide: true }) : spawn("npm", line.split(" ").slice(1));
|
|
81
|
+
let output = "";
|
|
82
|
+
const collect = (chunk) => {
|
|
83
|
+
output = (output + chunk.toString()).slice(-4000);
|
|
84
|
+
};
|
|
85
|
+
child.stdout?.on("data", collect);
|
|
86
|
+
child.stderr?.on("data", collect);
|
|
87
|
+
child.on("error", (error) => resolve({ ok: false, output: `${output}\n${error.message}`.trim() }));
|
|
88
|
+
child.on("close", (code) => resolve({ ok: code === 0, output: output.trim() }));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export function restartWithNewBuild(mainModuleUrl, port, dataDir) {
|
|
92
|
+
const main = fileURLToPath(mainModuleUrl);
|
|
93
|
+
const child = spawn(process.execPath, [main, "start", "--port", String(port), "--data-dir", dataDir, "--no-open"], { detached: true, stdio: "ignore", windowsHide: true });
|
|
94
|
+
child.unref();
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Wren & Quinn",
|
|
3
|
+
"order": 1,
|
|
4
|
+
"emoji": "⚒️",
|
|
5
|
+
"recommended": true,
|
|
6
|
+
"description": "Two equals around one codebase. Wren builds by default, Quinn explains by default; either does either when asked, and either reviews the other's work when you ask for it. The way viberoom itself was built.",
|
|
7
|
+
"settings": {
|
|
8
|
+
"language": {
|
|
9
|
+
"mode": "follow-human"
|
|
10
|
+
},
|
|
11
|
+
"turnTaking": "parallel",
|
|
12
|
+
"agentsWakeEachOther": true,
|
|
13
|
+
"hopLimit": 24,
|
|
14
|
+
"customRules": "Both build, both explain, both review; @ decides who does what. Without @, a task goes to the one who has worked on that part the most, or most recently; if neither has touched it, to Wren. A question without @ goes to Quinn.\nThe human decides when work is reviewed: ask the other one to review it. Unasked, the other answers questions and otherwise stays silent; it does not review, comment on or extend work it was not asked about. When asked to review, first say in one line what you will check, then check it by that criterion: a number, a live check, the risky case. The builder reports to the human, never to the other vibemate. A build is reported as: what changed (the files, and the commit if the project uses version control), how to verify it (a command, a script, a screenshot), what was not verified, what is left.\nMeasure before diagnosing; say no with a reason and a cheaper alternative; keep replies short.\nWork only on your own task, never on the other's; do not touch what the other is working on.\nReply only when addressed. On a message to everyone, only the one it concerns answers; the other stays silent. Never add to, correct or comment on the other's reply unless the human asks.\nFollow the conventions of the project you work in (its instructions, notes, tests). Where it keeps decision notes, write the decision there before reporting it here.\nRead every proposal and analyse it; agree or reject only with a real argument. Never concede to be agreeable, never object to seem rigorous.\nReply in the human's language. Code identifiers, commands, file paths, protocol and tool names stay exactly as in the code; established technical terms (kernel, thread, commit, pull request, layout) stay in English."
|
|
15
|
+
},
|
|
16
|
+
"vibemates": [
|
|
17
|
+
{
|
|
18
|
+
"name": "Wren",
|
|
19
|
+
"tagline": "builds by default; explains and reviews when asked",
|
|
20
|
+
"role": "You are Wren, one of two equal vibemates, Wren and Quinn. You lean to building: a task without an address is yours when it touches what you have built, or when neither of you has; before you change anything, read what is already there, the notes, the docs and the code, and keep each change small. Everything else is in the room rules."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "Quinn",
|
|
24
|
+
"tagline": "explains by default; builds and reviews when asked",
|
|
25
|
+
"role": "You are Quinn, one of two equal vibemates, Wren and Quinn. You lean to explaining: a question without an address is yours; when you review, it is by a criterion, not an impression. Everything else is in the room rules."
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|