viberoom 0.5.9 → 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 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
+ }
@@ -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 the background hub
78
- status show whether a hub is running, its build and address
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) {
@@ -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
- if (background)
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
- if (background)
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 url = `http://127.0.0.1:${options.port}/`;
309
- const running = await runningInstance(options.port);
310
- if (running && running.build === info.build) {
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 ${options.port} did not stop; try: viberoom stop`);
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(options.port), "--data-dir", options.dataDir, "--no-open"];
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(options.port))?.build === info.build, 20_000);
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 url = `http://127.0.0.1:${options.port}/`;
342
- const record = readPidFile(options.dataDir);
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 ${options.port} stopped`);
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 (record && isProcessAlive(record.pid)) {
352
- log.warn(`the hub did not answer on ${url}; terminating pid ${record.pid}`);
386
+ if (live) {
387
+ log.warn(`the hub did not answer on ${url}; terminating pid ${live.pid}`);
353
388
  try {
354
- process.kill(record.pid);
389
+ process.kill(live.pid);
355
390
  }
356
391
  catch (error) {
357
- throw new Error(`could not terminate pid ${record.pid}: ${String(error)}`);
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 pid = record && isProcessAlive(record.pid) ? ` (background pid ${record.pid}, started ${new Date(record.startedAt).toLocaleString()})` : " (foreground or another data folder)";
371
- process.stdout.write(`running at ${url}${pid}\nbuild: ${running.build ?? "unknown (older build)"}\ndata: ${options.dataDir}\nlog: ${logFilePath(options.dataDir)}\n`);
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 ${options.port}${record ? ` (stale pid file: ${record.pid})` : ""}\n`);
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 url = `http://127.0.0.1:${options.port}/`;
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 ${options.port}; start one with: viberoom start`);
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 place = (image) => {
129
- text += imageMarker(image);
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 images = line.images ?? [];
136
- const placed = new Set();
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(IMAGE_MARKER_PATTERN)) {
139
- const image = images.find((i) => i.n === Number(match[1]));
140
- if (!image || placed.has(image.n))
141
- continue;
142
- placed.add(image.n);
143
- text += line.text.slice(last, match.index);
144
- place(image);
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
- text += line.text.slice(last);
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 (placed.has(image.n))
201
+ if (placedImages.has(image.n))
150
202
  continue;
151
203
  if (!text.endsWith("\n") && (text || parts.length))
152
204
  text += "\n";
153
- place(image);
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
+ }