viberoom 0.5.9 → 0.6.1
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 +2 -0
- package/dist/agent-health.js +52 -0
- package/dist/duration.js +12 -0
- package/dist/hub.js +9 -0
- package/dist/launcher.js +19 -2
- package/dist/main.js +78 -35
- package/dist/mcp-skills-server.js +24 -0
- package/dist/persona.js +69 -14
- package/dist/quotes.js +41 -0
- package/dist/recipes.js +30 -2
- package/dist/room.js +136 -33
- package/dist/rows.js +19 -0
- package/dist/server.js +251 -27
- package/dist/shortcuts.js +1 -1
- 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 +341 -251
- package/ui/app.js +1283 -278
- package/ui/avatars.js +6 -4
- package/ui/components.css +140 -0
- package/ui/components.js +342 -0
- package/ui/icons.js +9 -0
- package/ui/index.html +58 -36
- package/ui/theme.css +450 -183
- package/ui/tokens.js +620 -0
- package/ui/ui.js +113 -0
package/README.md
CHANGED
|
@@ -140,6 +140,8 @@ it in a browser tab. Later, `npm install -g viberoom@latest` and the next start
|
|
|
140
140
|
- **Nothing leaves your machine** except what each agent sends to its own provider. viberoom never
|
|
141
141
|
sees your keys; every agent keeps its own login.
|
|
142
142
|
- **Edit a message.** Fix what you said; the vibemates get the memo, or the conversation rewinds.
|
|
143
|
+
- **Quote a message.** Select a fragment of a bubble, or copy it with Ctrl+C, and it goes into your next
|
|
144
|
+
message as a quote — with who said it and when, so the vibemates read it as that person's words, not yours.
|
|
143
145
|
- **Your messages on a timeline.** A thin strip on the chat's right edge, one mark per message of yours:
|
|
144
146
|
hover for the message with its neighbours, click to jump there.
|
|
145
147
|
- **Pick a folder from a tree.** Browse the machine's folders when a room needs one; make a new one on the spot.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
export function loginState(probe, evidence) {
|
|
3
|
+
if (!probe)
|
|
4
|
+
return "unknown";
|
|
5
|
+
if (probe.env.some((name) => (evidence.env[name] ?? "").trim().length > 0))
|
|
6
|
+
return "ok";
|
|
7
|
+
if (probe.files.some((file) => evidence.exists(file)))
|
|
8
|
+
return "ok";
|
|
9
|
+
if (probe.fileless?.includes(evidence.platform))
|
|
10
|
+
return "unknown";
|
|
11
|
+
return probe.files.length ? "missing" : "unknown";
|
|
12
|
+
}
|
|
13
|
+
const LOGIN_WORDS = /\b(not logged in|log ?in required|login required|please log ?in|unauthori[sz]ed|authentication (failed|required|error)|invalid api key|api key (is )?(missing|not set|invalid)|no credentials|credentials not found|401|403|oauth|token (expired|invalid))\b/i;
|
|
14
|
+
const MISSING_WORDS = /\b(enoent|not found|no such file|is not recognized|command not found|spawn\w* (failed|error))\b/i;
|
|
15
|
+
const TIMEOUT_WORDS = /\b(timed out|timeout|took too long|did not answer|no response)\b/i;
|
|
16
|
+
export function classifyStartFailure(input) {
|
|
17
|
+
const text = [input.error, ...(input.stderr ?? [])].join("\n");
|
|
18
|
+
const login = input.loginCommand ? `run \`${input.loginCommand}\` in a terminal, then try again` : `log in to ${input.vendor} in a terminal, then try again`;
|
|
19
|
+
if (LOGIN_WORDS.test(text) || input.loginState === "missing") {
|
|
20
|
+
return {
|
|
21
|
+
kind: "login",
|
|
22
|
+
what: `${input.vendor} is installed here but the hub could not get past its login.`,
|
|
23
|
+
advice: `${login}. The hub never asks for credentials itself: it uses the login the vendor's own CLI keeps on this machine.`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (MISSING_WORDS.test(text)) {
|
|
27
|
+
return {
|
|
28
|
+
kind: "not-installed",
|
|
29
|
+
what: `The ${input.vendor} program could not be started: this machine could not find or run it.`,
|
|
30
|
+
advice: input.installHint ? `Install or repair it: ${input.installHint}. Then run \`viberoom doctor\`, which lists what was found.` : "Reinstall it, then run `viberoom doctor`, which lists what was found.",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (TIMEOUT_WORDS.test(text)) {
|
|
34
|
+
return {
|
|
35
|
+
kind: "timeout",
|
|
36
|
+
what: `${input.vendor} started but did not answer the hub in time.`,
|
|
37
|
+
advice: `Start it yourself once in a terminal (\`${input.loginCommand ?? input.vendor.toLowerCase()}\`): a first run that asks something — a login, a trust prompt, an update — blocks the protocol until it is answered.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (/\bexited\b|\bexit code\b|\bsignal\b|\bclosed\b/i.test(text)) {
|
|
41
|
+
return {
|
|
42
|
+
kind: "crash",
|
|
43
|
+
what: `${input.vendor} started and then stopped before the hub could talk to it.`,
|
|
44
|
+
advice: `Run it once in a terminal to see what it prints; \`viberoom logs\` has the hub's side, with the agent's own last lines.`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
kind: "unknown",
|
|
49
|
+
what: `${input.vendor} could not be started.`,
|
|
50
|
+
advice: `Run it once in a terminal to see what it says; \`viberoom logs\` has the hub's side, with the agent's own last lines.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
package/dist/duration.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
export function formatDuration(ms) {
|
|
3
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
4
|
+
if (total < 60)
|
|
5
|
+
return `${total} s`;
|
|
6
|
+
const seconds = String(total % 60).padStart(2, "0");
|
|
7
|
+
const minutes = Math.floor(total / 60) % 60;
|
|
8
|
+
const hours = Math.floor(total / 3600);
|
|
9
|
+
if (!hours)
|
|
10
|
+
return `${minutes}m ${seconds}s`;
|
|
11
|
+
return `${hours}h ${String(minutes).padStart(2, "0")}m ${seconds}s`;
|
|
12
|
+
}
|
package/dist/hub.js
CHANGED
|
@@ -116,6 +116,7 @@ export class Hub extends EventEmitter {
|
|
|
116
116
|
editor: { ...DEFAULT_EDITOR_SETTINGS },
|
|
117
117
|
appearance: { ...DEFAULT_APPEARANCE },
|
|
118
118
|
checkForUpdates: true,
|
|
119
|
+
reconnectMode: "replay",
|
|
119
120
|
roomDefaults: {},
|
|
120
121
|
vendorPresets: {},
|
|
121
122
|
};
|
|
@@ -185,6 +186,14 @@ export class Hub extends EventEmitter {
|
|
|
185
186
|
}
|
|
186
187
|
if (!next.diagrams)
|
|
187
188
|
next.diagrams = { preset: "pop", primary: null };
|
|
189
|
+
if (patch.reconnectMode !== undefined) {
|
|
190
|
+
const mode = String(patch.reconnectMode);
|
|
191
|
+
if (mode !== "replay" && mode !== "load")
|
|
192
|
+
throw new Error("reconnectMode must be replay or load");
|
|
193
|
+
next.reconnectMode = mode;
|
|
194
|
+
}
|
|
195
|
+
if (next.reconnectMode !== "load")
|
|
196
|
+
next.reconnectMode = "replay";
|
|
188
197
|
if (patch.editor !== undefined && typeof patch.editor === "object" && patch.editor) {
|
|
189
198
|
const e = patch.editor;
|
|
190
199
|
const mode = String(e.mode ?? next.editor?.mode ?? "auto");
|
package/dist/launcher.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
2
|
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join, posix, win32 } from "node:path";
|
|
3
|
+
import { join, posix, resolve, win32 } from "node:path";
|
|
4
4
|
const COMMANDS = new Set(["run", "serve", "start", "stop", "status", "open", "logs", "doctor", "help"]);
|
|
5
5
|
export function splitCommand(argv) {
|
|
6
6
|
const first = argv[0];
|
|
@@ -26,7 +26,7 @@ export function readPidFile(dataDir) {
|
|
|
26
26
|
const parsed = JSON.parse(raw);
|
|
27
27
|
if (typeof parsed.pid !== "number")
|
|
28
28
|
return null;
|
|
29
|
-
return { pid: parsed.pid, port: Number(parsed.port ?? 0), build: String(parsed.build ?? ""), startedAt: Number(parsed.startedAt ?? 0) };
|
|
29
|
+
return { pid: parsed.pid, port: Number(parsed.port ?? 0), build: String(parsed.build ?? ""), startedAt: Number(parsed.startedAt ?? 0), ...(parsed.foreground ? { foreground: true } : {}) };
|
|
30
30
|
}
|
|
31
31
|
catch {
|
|
32
32
|
return null;
|
|
@@ -41,6 +41,23 @@ export function isProcessAlive(pid) {
|
|
|
41
41
|
return error.code === "EPERM";
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
export function sameDataDir(a, b) {
|
|
45
|
+
const norm = (p) => {
|
|
46
|
+
const r = resolve(p).replace(/[\\/]+$/, "");
|
|
47
|
+
return process.platform === "win32" ? r.toLowerCase() : r;
|
|
48
|
+
};
|
|
49
|
+
return norm(a) === norm(b);
|
|
50
|
+
}
|
|
51
|
+
export function hubPortFor(port, portGiven, liveRecord) {
|
|
52
|
+
if (portGiven || !liveRecord || !liveRecord.port)
|
|
53
|
+
return port;
|
|
54
|
+
return liveRecord.port;
|
|
55
|
+
}
|
|
56
|
+
export function foreignHub(identity, dataDir) {
|
|
57
|
+
if (!identity || !identity.dataDir)
|
|
58
|
+
return null;
|
|
59
|
+
return sameDataDir(identity.dataDir, dataDir) ? null : identity.dataDir;
|
|
60
|
+
}
|
|
44
61
|
const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
45
62
|
export function rotateLog(path, limit = LOG_ROTATE_BYTES) {
|
|
46
63
|
try {
|
package/dist/main.js
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { Hub } from "./hub.js";
|
|
9
9
|
import { Logger } from "./log.js";
|
|
10
10
|
import { startServer } from "./server.js";
|
|
11
|
-
import { appWindowArgs, recordedWindowPlacement, savedWindowPlacement, browserAdvice, findChromium, isProcessAlive, logFilePath, openUrlCommand, pidFilePath, readPidFile, rotateLog, splitCommand, tailFile, writePidFile, } from "./launcher.js";
|
|
11
|
+
import { appWindowArgs, recordedWindowPlacement, savedWindowPlacement, browserAdvice, findChromium, foreignHub, hubPortFor, isProcessAlive, logFilePath, openUrlCommand, pidFilePath, readPidFile, rotateLog, splitCommand, tailFile, writePidFile, } from "./launcher.js";
|
|
12
12
|
import { aumidSyncScript, installShortcuts, windowsShortcutPaths } from "./shortcuts.js";
|
|
13
13
|
import { askEnter, renderInstalled, runMenu, unicodeSupported } from "./tui.js";
|
|
14
14
|
import { listRecipes } from "./recipes.js";
|
|
@@ -17,11 +17,13 @@ function parseArgs(argv) {
|
|
|
17
17
|
const options = {
|
|
18
18
|
command,
|
|
19
19
|
port: 4810,
|
|
20
|
+
portGiven: false,
|
|
20
21
|
dataDir: process.env.VIBEROOM_DATA_DIR ? resolve(process.env.VIBEROOM_DATA_DIR) : resolve(homedir(), ".viberoom"),
|
|
21
22
|
name: undefined,
|
|
22
23
|
open: true,
|
|
23
24
|
browser: false,
|
|
24
25
|
menu: true,
|
|
26
|
+
force: false,
|
|
25
27
|
};
|
|
26
28
|
for (let i = 0; i < rest.length; i++) {
|
|
27
29
|
const arg = rest[i];
|
|
@@ -34,6 +36,7 @@ function parseArgs(argv) {
|
|
|
34
36
|
switch (arg) {
|
|
35
37
|
case "--port":
|
|
36
38
|
options.port = Number(next());
|
|
39
|
+
options.portGiven = true;
|
|
37
40
|
break;
|
|
38
41
|
case "--name":
|
|
39
42
|
options.name = next();
|
|
@@ -49,6 +52,9 @@ function parseArgs(argv) {
|
|
|
49
52
|
options.open = false;
|
|
50
53
|
options.menu = false;
|
|
51
54
|
break;
|
|
55
|
+
case "--force":
|
|
56
|
+
options.force = true;
|
|
57
|
+
break;
|
|
52
58
|
case "--browser":
|
|
53
59
|
options.browser = true;
|
|
54
60
|
options.menu = false;
|
|
@@ -74,8 +80,9 @@ Commands
|
|
|
74
80
|
process and open the window; if a hub is already running, open it (or replace it
|
|
75
81
|
when this build is newer)
|
|
76
82
|
start run the hub hidden in the background (log in <data-dir>/hub.log) and open the window
|
|
77
|
-
stop stop
|
|
78
|
-
|
|
83
|
+
stop stop this data folder's hub (found through its pid file; a hub of another folder on
|
|
84
|
+
the port is left alone; --port names a port explicitly)
|
|
85
|
+
status show whether this data folder's hub is running, its build and address
|
|
79
86
|
open open the window of the running hub
|
|
80
87
|
logs print the last lines of the background hub's log
|
|
81
88
|
doctor check Node, the browser, the coding agents and the hub; say what is missing and why
|
|
@@ -88,11 +95,11 @@ Options
|
|
|
88
95
|
--browser open the default browser instead of a Chromium app window
|
|
89
96
|
`);
|
|
90
97
|
}
|
|
91
|
-
import { checkForUpdate } from "./update.js";
|
|
98
|
+
import { checkForUpdate, newerSourceThanBuild } from "./update.js";
|
|
92
99
|
function buildInfo() {
|
|
93
100
|
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
94
101
|
const built = statSync(fileURLToPath(import.meta.url)).mtime;
|
|
95
|
-
return { name: pkg.name, version: pkg.version, build: built.toISOString() };
|
|
102
|
+
return { name: pkg.name, version: pkg.version, build: built.toISOString(), staleSource: newerSourceThanBuild(import.meta.url) };
|
|
96
103
|
}
|
|
97
104
|
async function runningInstance(port) {
|
|
98
105
|
const url = `http://127.0.0.1:${port}/`;
|
|
@@ -110,14 +117,21 @@ async function runningInstance(port) {
|
|
|
110
117
|
try {
|
|
111
118
|
const res = await fetch(`${url}api/version`, { signal: AbortSignal.timeout(1500) });
|
|
112
119
|
if (!res.ok)
|
|
113
|
-
return { url, build: null };
|
|
120
|
+
return { url, build: null, dataDir: null, pid: null };
|
|
114
121
|
const info = (await res.json());
|
|
115
|
-
return { url, build: typeof info.build === "string" ? info.build : null };
|
|
122
|
+
return { url, build: typeof info.build === "string" ? info.build : null, dataDir: typeof info.dataDir === "string" ? info.dataDir : null, pid: typeof info.pid === "number" ? info.pid : null };
|
|
116
123
|
}
|
|
117
124
|
catch {
|
|
118
|
-
return { url, build: null };
|
|
125
|
+
return { url, build: null, dataDir: null, pid: null };
|
|
119
126
|
}
|
|
120
127
|
}
|
|
128
|
+
function liveRecord(dataDir) {
|
|
129
|
+
const record = readPidFile(dataDir);
|
|
130
|
+
return record && isProcessAlive(record.pid) ? record : null;
|
|
131
|
+
}
|
|
132
|
+
function otherHubMessage(port, other, mine) {
|
|
133
|
+
return `port ${port} is used by a viberoom hub whose rooms are in ${other}, not in ${mine}; it was left alone. Run this one on another port (--port ${port + 1}) or stop that hub first: viberoom stop --data-dir "${other}"`;
|
|
134
|
+
}
|
|
121
135
|
async function waitUntil(check, timeoutMs, stepMs = 250) {
|
|
122
136
|
const deadline = Date.now() + timeoutMs;
|
|
123
137
|
while (Date.now() < deadline) {
|
|
@@ -212,7 +226,7 @@ function openWindow(url, options, log) {
|
|
|
212
226
|
}
|
|
213
227
|
}
|
|
214
228
|
log.info("opening the default browser");
|
|
215
|
-
exec(openUrlCommand(url), () => undefined);
|
|
229
|
+
exec(openUrlCommand(url), { windowsHide: true }, () => undefined);
|
|
216
230
|
}
|
|
217
231
|
async function runDoctor(options, info) {
|
|
218
232
|
const lines = [];
|
|
@@ -225,7 +239,9 @@ async function runDoctor(options, info) {
|
|
|
225
239
|
const found = recipes.filter((r) => !r.unavailableReason);
|
|
226
240
|
lines.push(`${found.length ? "ok " : "warn"} coding agents: ${found.length ? found.map((r) => r.vendor).join(", ") : "none found"}${found.length ? "" : " (install and log in to at least one: Claude Code, Codex, Gemini CLI, Cursor, OpenCode or GitHub Copilot)"}`);
|
|
227
241
|
for (const r of recipes.filter((r) => r.unavailableReason))
|
|
228
|
-
lines.push(` ${r.vendor}: ${r.unavailableReason}`);
|
|
242
|
+
lines.push(` ${r.vendor}: ${r.unavailableReason} (${r.installHint})`);
|
|
243
|
+
for (const r of found.filter((r) => r.loginState === "missing"))
|
|
244
|
+
lines.push(`warn ${r.vendor}: installed, not logged in: run \`${r.loginCommand}\``);
|
|
229
245
|
const running = await runningInstance(options.port);
|
|
230
246
|
lines.push(running ? `ok hub running at ${running.url} (build ${running.build ?? "unknown"})` : `info no hub on port ${options.port}: start one with "viberoom start" (or "viberoom start --browser" without a Chromium browser)`);
|
|
231
247
|
lines.push(` data: ${options.dataDir}`);
|
|
@@ -238,6 +254,9 @@ async function runHub(options, log, info) {
|
|
|
238
254
|
const background = options.command === "serve";
|
|
239
255
|
if (!background) {
|
|
240
256
|
const running = await runningInstance(options.port);
|
|
257
|
+
const other = foreignHub(running, options.dataDir);
|
|
258
|
+
if (other)
|
|
259
|
+
throw new Error(otherHubMessage(options.port, other, options.dataDir));
|
|
241
260
|
if (running && running.build === info.build) {
|
|
242
261
|
log.info(`viberoom is already running at ${running.url} (same build); opening it.`);
|
|
243
262
|
process.stdout.write(`${running.url}\n`);
|
|
@@ -265,14 +284,13 @@ async function runHub(options, log, info) {
|
|
|
265
284
|
log.info("shutting down: closing agent sessions");
|
|
266
285
|
await hub.shutdown();
|
|
267
286
|
server?.close();
|
|
268
|
-
|
|
269
|
-
rmSync(pidFilePath(options.dataDir), { force: true });
|
|
287
|
+
rmSync(pidFilePath(options.dataDir), { force: true });
|
|
270
288
|
process.exit(0);
|
|
271
289
|
};
|
|
272
290
|
const listenDeadline = Date.now() + 15_000;
|
|
273
291
|
for (;;) {
|
|
274
292
|
try {
|
|
275
|
-
server = await startServer(hub, options.port, log.child("http"), info, () => void shutdown());
|
|
293
|
+
server = await startServer(hub, options.port, log.child("http"), info, () => void shutdown(), () => handOverToFreshHub(options, log));
|
|
276
294
|
break;
|
|
277
295
|
}
|
|
278
296
|
catch (error) {
|
|
@@ -294,9 +312,10 @@ async function runHub(options, log, info) {
|
|
|
294
312
|
log.warn(`update check failed: ${update.error}`);
|
|
295
313
|
});
|
|
296
314
|
}
|
|
297
|
-
|
|
298
|
-
writePidFile(options.dataDir, { pid: process.pid, port: options.port, build: info.build, startedAt: Date.now() });
|
|
315
|
+
writePidFile(options.dataDir, { pid: process.pid, port: options.port, build: info.build, startedAt: Date.now(), ...(background ? {} : { foreground: true }) });
|
|
299
316
|
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(", ")})`);
|
|
317
|
+
if (info.staleSource)
|
|
318
|
+
log.warn(`the code on disk is newer than this build (${info.staleSource} changed after dist/ was compiled): the UI is served live, the hub is not; rebuild and restart with: node scripts/update.mjs`);
|
|
300
319
|
process.stdout.write(`${server.url}\n`);
|
|
301
320
|
if (options.open && !background)
|
|
302
321
|
openWindow(server.url, options, log);
|
|
@@ -304,10 +323,22 @@ async function runHub(options, log, info) {
|
|
|
304
323
|
process.on("SIGTERM", () => void shutdown());
|
|
305
324
|
process.on("SIGHUP", () => void shutdown());
|
|
306
325
|
}
|
|
326
|
+
function handOverToFreshHub(options, log) {
|
|
327
|
+
const args = [fileURLToPath(import.meta.url), "start", "--force", "--port", String(options.port), "--data-dir", options.dataDir, "--no-open"];
|
|
328
|
+
if (options.name)
|
|
329
|
+
args.push("--name", options.name);
|
|
330
|
+
const child = spawn(process.execPath, args, { cwd: options.dataDir, detached: true, stdio: "ignore", windowsHide: true });
|
|
331
|
+
child.unref();
|
|
332
|
+
log.info(`restart requested from the window: a fresh hub is starting (pid ${child.pid})`);
|
|
333
|
+
}
|
|
307
334
|
async function startBackground(options, log, info) {
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
335
|
+
const port = hubPortFor(options.port, options.portGiven, liveRecord(options.dataDir));
|
|
336
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
337
|
+
const running = await runningInstance(port);
|
|
338
|
+
const other = foreignHub(running, options.dataDir);
|
|
339
|
+
if (other)
|
|
340
|
+
throw new Error(otherHubMessage(port, other, options.dataDir));
|
|
341
|
+
if (running && running.build === info.build && !options.force) {
|
|
311
342
|
log.info(`viberoom is already running at ${url} (same build).`);
|
|
312
343
|
process.stdout.write(`${url}\n`);
|
|
313
344
|
if (options.open)
|
|
@@ -317,20 +348,20 @@ async function startBackground(options, log, info) {
|
|
|
317
348
|
if (running) {
|
|
318
349
|
log.info(`an older viberoom build is running at ${url}; replacing it with the build from ${info.build}`);
|
|
319
350
|
if (!(await stopInstance(url, log)))
|
|
320
|
-
throw new Error(`the older viberoom hub on port ${
|
|
351
|
+
throw new Error(`the older viberoom hub on port ${port} did not stop; try: viberoom stop`);
|
|
321
352
|
}
|
|
322
353
|
mkdirSync(options.dataDir, { recursive: true });
|
|
323
354
|
const logPath = logFilePath(options.dataDir);
|
|
324
355
|
rotateLog(logPath);
|
|
325
356
|
const fd = openSync(logPath, "a");
|
|
326
|
-
const args = [fileURLToPath(import.meta.url), "serve", "--port", String(
|
|
357
|
+
const args = [fileURLToPath(import.meta.url), "serve", "--port", String(port), "--data-dir", options.dataDir, "--no-open"];
|
|
327
358
|
if (options.name)
|
|
328
359
|
args.push("--name", options.name);
|
|
329
360
|
const child = spawn(process.execPath, args, { cwd: options.dataDir, detached: true, stdio: ["ignore", fd, fd], windowsHide: true });
|
|
330
361
|
child.unref();
|
|
331
362
|
closeSync(fd);
|
|
332
363
|
log.info(`hub started in the background (pid ${child.pid}); log: ${logPath}`);
|
|
333
|
-
const up = await waitUntil(async () => (await runningInstance(
|
|
364
|
+
const up = await waitUntil(async () => (await runningInstance(port))?.build === info.build, 20_000);
|
|
334
365
|
if (!up)
|
|
335
366
|
throw new Error(`the hub did not come up within 20 s; see ${logPath}`);
|
|
336
367
|
process.stdout.write(`${url}\n`);
|
|
@@ -338,23 +369,27 @@ async function startBackground(options, log, info) {
|
|
|
338
369
|
openWindow(url, options, log);
|
|
339
370
|
}
|
|
340
371
|
async function stopBackground(options, log) {
|
|
341
|
-
const
|
|
342
|
-
const
|
|
372
|
+
const live = liveRecord(options.dataDir);
|
|
373
|
+
const port = hubPortFor(options.port, options.portGiven, live);
|
|
374
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
375
|
+
const other = foreignHub(await runningInstance(port), options.dataDir);
|
|
376
|
+
if (other)
|
|
377
|
+
throw new Error(`the hub on port ${port} keeps its rooms in ${other}, not in ${options.dataDir}, so it was left running. To stop that one: viberoom stop --data-dir "${other}"`);
|
|
343
378
|
if (await isUp(url)) {
|
|
344
379
|
const ok = await stopInstance(url, log);
|
|
345
380
|
if (ok) {
|
|
346
|
-
log.info(`hub on port ${
|
|
381
|
+
log.info(`hub on port ${port} stopped`);
|
|
347
382
|
rmSync(pidFilePath(options.dataDir), { force: true });
|
|
348
383
|
return;
|
|
349
384
|
}
|
|
350
385
|
}
|
|
351
|
-
if (
|
|
352
|
-
log.warn(`the hub did not answer on ${url}; terminating pid ${
|
|
386
|
+
if (live) {
|
|
387
|
+
log.warn(`the hub did not answer on ${url}; terminating pid ${live.pid}`);
|
|
353
388
|
try {
|
|
354
|
-
process.kill(
|
|
389
|
+
process.kill(live.pid);
|
|
355
390
|
}
|
|
356
391
|
catch (error) {
|
|
357
|
-
throw new Error(`could not terminate pid ${
|
|
392
|
+
throw new Error(`could not terminate pid ${live.pid}: ${String(error)}`);
|
|
358
393
|
}
|
|
359
394
|
rmSync(pidFilePath(options.dataDir), { force: true });
|
|
360
395
|
return;
|
|
@@ -363,15 +398,22 @@ async function stopBackground(options, log) {
|
|
|
363
398
|
log.info("no hub is running");
|
|
364
399
|
}
|
|
365
400
|
async function showStatus(options) {
|
|
366
|
-
const url = `http://127.0.0.1:${options.port}/`;
|
|
367
|
-
const running = await runningInstance(options.port);
|
|
368
401
|
const record = readPidFile(options.dataDir);
|
|
402
|
+
const live = record && isProcessAlive(record.pid) ? record : null;
|
|
403
|
+
const port = hubPortFor(options.port, options.portGiven, live);
|
|
404
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
405
|
+
const running = await runningInstance(port);
|
|
369
406
|
if (running) {
|
|
370
|
-
const
|
|
371
|
-
|
|
407
|
+
const other = foreignHub(running, options.dataDir);
|
|
408
|
+
const who = other
|
|
409
|
+
? ` (a hub of another data folder: ${other}; this folder's hub is not running)`
|
|
410
|
+
: live
|
|
411
|
+
? ` (${live.foreground ? "pid" : "background pid"} ${live.pid}, started ${new Date(live.startedAt).toLocaleString()})`
|
|
412
|
+
: " (foreground or another data folder)";
|
|
413
|
+
process.stdout.write(`running at ${url}${who}\nbuild: ${running.build ?? "unknown (older build)"}\ndata: ${other ?? options.dataDir}\nlog: ${logFilePath(other ?? options.dataDir)}\n`);
|
|
372
414
|
}
|
|
373
415
|
else {
|
|
374
|
-
process.stdout.write(`not running on port ${
|
|
416
|
+
process.stdout.write(`not running on port ${port}${record ? ` (stale pid file: ${record.pid})` : ""}\n`);
|
|
375
417
|
if (record && !isProcessAlive(record.pid))
|
|
376
418
|
rmSync(pidFilePath(options.dataDir), { force: true });
|
|
377
419
|
}
|
|
@@ -394,9 +436,10 @@ async function main() {
|
|
|
394
436
|
await showStatus(options);
|
|
395
437
|
return;
|
|
396
438
|
case "open": {
|
|
397
|
-
const
|
|
439
|
+
const port = hubPortFor(options.port, options.portGiven, liveRecord(options.dataDir));
|
|
440
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
398
441
|
if (!(await isUp(url)))
|
|
399
|
-
throw new Error(`no hub is running on port ${
|
|
442
|
+
throw new Error(`no hub is running on port ${port}; start one with: viberoom start`);
|
|
400
443
|
openWindow(url, options, log);
|
|
401
444
|
return;
|
|
402
445
|
}
|
|
@@ -124,6 +124,19 @@ const TOOLS = [
|
|
|
124
124
|
required: ["why"],
|
|
125
125
|
},
|
|
126
126
|
},
|
|
127
|
+
{
|
|
128
|
+
name: "read_message",
|
|
129
|
+
description: "One message of this room by its number: the whole of a message that was quoted to you as \"> Name (#N, time): …\", or any message whose #N you have seen. Returns who wrote it, to whom, when, its text, its images as file paths and, with around > 0, up to that many messages before and after it. Read-only; the human sees the call like any other tool call.",
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: "object",
|
|
132
|
+
properties: {
|
|
133
|
+
seq: { type: "integer", description: "the message number, the N of #N" },
|
|
134
|
+
around: { type: "integer", minimum: 0, maximum: 5, description: "optional: how many neighbouring messages to include on each side (default 0, at most 5)" },
|
|
135
|
+
},
|
|
136
|
+
required: ["seq"],
|
|
137
|
+
},
|
|
138
|
+
annotations: { readOnlyHint: true },
|
|
139
|
+
},
|
|
127
140
|
];
|
|
128
141
|
let readySent = false;
|
|
129
142
|
function send(message) {
|
|
@@ -263,6 +276,17 @@ async function handle(message) {
|
|
|
263
276
|
reply(id, { content: [{ type: "text", text: String(res.body.message ?? "proposed") }] });
|
|
264
277
|
return;
|
|
265
278
|
}
|
|
279
|
+
if (name === "read_message") {
|
|
280
|
+
const seq = Number(args.seq);
|
|
281
|
+
if (!Number.isInteger(seq))
|
|
282
|
+
return fail(id, -32602, "read_message needs seq: the message number, the N of #N");
|
|
283
|
+
const around = Number(args.around);
|
|
284
|
+
const res = await hub(`/api/mcp/message?token=${encodeURIComponent(TOKEN)}&seq=${seq}${Number.isInteger(around) && around > 0 ? `&around=${around}` : ""}`);
|
|
285
|
+
if (!res.ok)
|
|
286
|
+
return errorResult("the message could not be read", res);
|
|
287
|
+
reply(id, { content: [{ type: "text", text: JSON.stringify(res.body, null, 2) }] });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
266
290
|
fail(id, -32602, `unknown tool: ${name}`);
|
|
267
291
|
return;
|
|
268
292
|
}
|
package/dist/persona.js
CHANGED
|
@@ -108,6 +108,8 @@ export function describeSettings(current) {
|
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
110
|
export const IMAGE_MARKER_PATTERN = /\[img\s+(\d+)\]/gi;
|
|
111
|
+
export const QUOTE_MARKER_PATTERN = /\[quote\s+(\d+)\]/gi;
|
|
112
|
+
const MARKER_PATTERN = /\[(img|quote)\s+(\d+)\]/gi;
|
|
111
113
|
export function promptText(parts) {
|
|
112
114
|
return parts.map((p) => (p.type === "text" ? p.text : "")).join("");
|
|
113
115
|
}
|
|
@@ -117,6 +119,18 @@ function imageMarker(image) {
|
|
|
117
119
|
const who = image.forNames.length ? ` · for ${image.forNames.join(", ")}` : "";
|
|
118
120
|
return `[img ${image.n} · ${image.ref}${who} · ${image.path}]`;
|
|
119
121
|
}
|
|
122
|
+
export function formatQuoteTime(ts) {
|
|
123
|
+
const d = new Date(ts);
|
|
124
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
125
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
126
|
+
}
|
|
127
|
+
export function quoteBlock(quote) {
|
|
128
|
+
const head = `> ${quote.fromName} (#${quote.seq}, ${formatQuoteTime(quote.ts)}):`;
|
|
129
|
+
const lines = quote.text.split(/\r?\n/);
|
|
130
|
+
if (lines.length === 1)
|
|
131
|
+
return `${head} ${lines[0]}`;
|
|
132
|
+
return [head, ...lines.map((l) => `> ${l}`)].join("\n");
|
|
133
|
+
}
|
|
120
134
|
function messageParts(line) {
|
|
121
135
|
const parts = [];
|
|
122
136
|
let text = "";
|
|
@@ -125,32 +139,70 @@ function messageParts(line) {
|
|
|
125
139
|
parts.push({ type: "text", text });
|
|
126
140
|
text = "";
|
|
127
141
|
};
|
|
128
|
-
const
|
|
129
|
-
|
|
142
|
+
const images = line.images ?? [];
|
|
143
|
+
const quotes = line.quotes ?? [];
|
|
144
|
+
const placedImages = new Set();
|
|
145
|
+
const placedQuotes = new Set();
|
|
146
|
+
let breakAfterQuote = false;
|
|
147
|
+
const append = (s) => {
|
|
148
|
+
if (!s)
|
|
149
|
+
return;
|
|
150
|
+
if (breakAfterQuote) {
|
|
151
|
+
if (!s.startsWith("\n"))
|
|
152
|
+
text += "\n";
|
|
153
|
+
s = s.replace(/^[ \t]+/, "");
|
|
154
|
+
breakAfterQuote = false;
|
|
155
|
+
}
|
|
156
|
+
text += s;
|
|
157
|
+
};
|
|
158
|
+
const placeImage = (image) => {
|
|
159
|
+
append(imageMarker(image));
|
|
130
160
|
if (image.attached) {
|
|
131
161
|
flush();
|
|
132
162
|
parts.push({ type: "image", image });
|
|
133
163
|
}
|
|
134
164
|
};
|
|
135
|
-
const
|
|
136
|
-
|
|
165
|
+
const placeQuote = (quote) => {
|
|
166
|
+
if (breakAfterQuote)
|
|
167
|
+
text += "\n";
|
|
168
|
+
text = text.replace(/[ \t]+$/, "");
|
|
169
|
+
if (text && !text.endsWith("\n"))
|
|
170
|
+
text += "\n";
|
|
171
|
+
text += quoteBlock(quote);
|
|
172
|
+
breakAfterQuote = true;
|
|
173
|
+
};
|
|
137
174
|
let last = 0;
|
|
138
|
-
for (const match of line.text.matchAll(
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
175
|
+
for (const match of line.text.matchAll(MARKER_PATTERN)) {
|
|
176
|
+
const n = Number(match[2]);
|
|
177
|
+
if (match[1].toLowerCase() === "img") {
|
|
178
|
+
const image = images.find((i) => i.n === n);
|
|
179
|
+
if (!image || placedImages.has(n))
|
|
180
|
+
continue;
|
|
181
|
+
placedImages.add(n);
|
|
182
|
+
append(line.text.slice(last, match.index));
|
|
183
|
+
placeImage(image);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const quote = quotes.find((q) => q.n === n);
|
|
187
|
+
if (!quote || placedQuotes.has(n))
|
|
188
|
+
continue;
|
|
189
|
+
placedQuotes.add(n);
|
|
190
|
+
append(line.text.slice(last, match.index));
|
|
191
|
+
placeQuote(quote);
|
|
192
|
+
}
|
|
145
193
|
last = (match.index ?? 0) + match[0].length;
|
|
146
194
|
}
|
|
147
|
-
|
|
195
|
+
append(line.text.slice(last));
|
|
196
|
+
for (const quote of quotes)
|
|
197
|
+
if (!placedQuotes.has(quote.n))
|
|
198
|
+
placeQuote(quote);
|
|
199
|
+
breakAfterQuote = false;
|
|
148
200
|
for (const image of images) {
|
|
149
|
-
if (
|
|
201
|
+
if (placedImages.has(image.n))
|
|
150
202
|
continue;
|
|
151
203
|
if (!text.endsWith("\n") && (text || parts.length))
|
|
152
204
|
text += "\n";
|
|
153
|
-
|
|
205
|
+
placeImage(image);
|
|
154
206
|
}
|
|
155
207
|
flush();
|
|
156
208
|
return parts;
|
|
@@ -247,6 +299,9 @@ export function buildBrief(settings, persona, roster, previousNotes, skills) {
|
|
|
247
299
|
lines.push(...skillsSection(skills));
|
|
248
300
|
lines.push("");
|
|
249
301
|
lines.push(`How prompts look: <room-header> (who you are, who is here, the hop counter, hub notes), then <messages> (everything posted since your previous turn, oldest first, as "Name -> @Target: text"; room events as "· text"), then "Reply as ${persona.name}." Your own earlier messages are not repeated. Reply with the text of your message only.`);
|
|
302
|
+
lines.push(`A line "> Name (#N, date time): …" inside a message quotes an earlier message of this room, pasted by the writer: those are Name's words, not the writer's, and #N is the hub's number of that message. ${skills?.channel === "tool"
|
|
303
|
+
? "When the fragment is not enough, the viberoom tool read_message takes the number and returns the whole message (around: N adds its neighbours)."
|
|
304
|
+
: "When the fragment is not enough, ask in the room for the whole message."}`);
|
|
250
305
|
if (previousNotes && previousNotes.trim()) {
|
|
251
306
|
lines.push("");
|
|
252
307
|
lines.push(`Notes from your previous session (written by you): ${previousNotes.trim()}`);
|
package/dist/quotes.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
export const QUOTES_PER_MESSAGE = 6;
|
|
3
|
+
export const QUOTE_MAX_CHARS = 4000;
|
|
4
|
+
export const READ_AROUND_MAX = 5;
|
|
5
|
+
export function resolveQuotes(inputs, messages) {
|
|
6
|
+
const out = [];
|
|
7
|
+
const used = new Set();
|
|
8
|
+
let next = 1;
|
|
9
|
+
for (const input of inputs.slice(0, QUOTES_PER_MESSAGE)) {
|
|
10
|
+
const seq = Number(input?.seq);
|
|
11
|
+
const source = Number.isInteger(seq) ? messages.find((m) => m.seq === seq) : undefined;
|
|
12
|
+
if (!source)
|
|
13
|
+
throw new Error(`quoted message #${String(input?.seq)} is not in this room`);
|
|
14
|
+
if (source.kind !== "chat")
|
|
15
|
+
throw new Error(`message #${seq} is not a chat message; only those can be quoted`);
|
|
16
|
+
const text = String(input.text ?? "").trim().slice(0, QUOTE_MAX_CHARS) || source.text.trim().slice(0, QUOTE_MAX_CHARS);
|
|
17
|
+
if (!text)
|
|
18
|
+
throw new Error(`message #${seq} has no text to quote`);
|
|
19
|
+
const wanted = Number(input.n);
|
|
20
|
+
let n = Number.isInteger(wanted) && wanted > 0 && !used.has(wanted) ? wanted : 0;
|
|
21
|
+
if (!n) {
|
|
22
|
+
while (used.has(next))
|
|
23
|
+
next += 1;
|
|
24
|
+
n = next;
|
|
25
|
+
}
|
|
26
|
+
used.add(n);
|
|
27
|
+
out.push({ n, seq, from: source.from, fromName: source.fromName, ts: source.ts, text });
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
export function visibleToAgents(message) {
|
|
32
|
+
return message.kind !== "hidden" && message.audience !== "human";
|
|
33
|
+
}
|
|
34
|
+
export function agentReadableWindow(messages, seq, around) {
|
|
35
|
+
const visible = messages.filter(visibleToAgents);
|
|
36
|
+
const index = visible.findIndex((m) => m.seq === seq);
|
|
37
|
+
if (index < 0)
|
|
38
|
+
return null;
|
|
39
|
+
const span = Math.min(READ_AROUND_MAX, Math.max(0, Math.floor(Number(around) || 0)));
|
|
40
|
+
return { message: visible[index], before: visible.slice(Math.max(0, index - span), index), after: visible.slice(index + 1, index + 1 + span) };
|
|
41
|
+
}
|