viberoom 0.4.2 → 0.5.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 +1 -1
- package/dist/acp-client.js +3 -0
- package/dist/commands.js +31 -0
- package/dist/edit.js +10 -1
- package/dist/files.js +60 -0
- package/dist/hub.js +73 -2
- package/dist/persona.js +82 -12
- package/dist/room.js +213 -25
- package/dist/server.js +102 -5
- package/dist/skills.js +4 -0
- package/dist/templates.js +76 -0
- package/package.json +2 -1
- package/templates/design-critique/template.json +24 -0
- package/templates/explainer/template.json +17 -0
- package/templates/forge-and-lumen/template.json +28 -0
- package/templates/pair-programmer/template.json +17 -0
- package/ui/app.css +175 -20
- package/ui/app.js +868 -116
- package/ui/icons.js +13 -10
- package/ui/index.html +31 -3
- package/ui/theme.css +2 -2
package/dist/room.js
CHANGED
|
@@ -4,11 +4,12 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
5
5
|
import { writeFileAtomic } from "./atomic.js";
|
|
6
6
|
import { affectedByEdit, editNotice, partitionHistory, rewriteNotice } from "./edit.js";
|
|
7
|
+
import { saveImages } from "./files.js";
|
|
7
8
|
import { join, resolve } from "node:path";
|
|
8
9
|
import { AcpAgent } from "./acp-client.js";
|
|
9
10
|
import { RemoteError } from "./jsonrpc.js";
|
|
10
11
|
import { getRecipe, listRecipes } from "./recipes.js";
|
|
11
|
-
import { composeSkillBlock,
|
|
12
|
+
import { composeSkillBlock, skillPull, SKILL_TOOL_NAME } from "./persona.js";
|
|
12
13
|
import { BUILTIN_AUTHOR, parseSkillInvocation, renderSkillBody, SKILL_NAME_PATTERN, } from "./skills.js";
|
|
13
14
|
import { BRIEF_AFFECTING_SETTINGS, DEFAULT_ROOM_SETTINGS, REQUEST_BRIEF_MARKER, SILENT_MARKER, buildBrief, buildHeader, composeCorrectionPrompt, composePrompt, countSentences, ensureDir, } from "./persona.js";
|
|
14
15
|
import { Transcript } from "./log.js";
|
|
@@ -74,6 +75,12 @@ export class Room extends EventEmitter {
|
|
|
74
75
|
historyPath() {
|
|
75
76
|
return join(this.dataDir, "history.jsonl");
|
|
76
77
|
}
|
|
78
|
+
filesDir() {
|
|
79
|
+
return join(this.dataDir, "files");
|
|
80
|
+
}
|
|
81
|
+
imagePath(attachment) {
|
|
82
|
+
return join(this.filesDir(), attachment.file);
|
|
83
|
+
}
|
|
77
84
|
loadHistory() {
|
|
78
85
|
if (!existsSync(this.historyPath()))
|
|
79
86
|
return;
|
|
@@ -91,6 +98,10 @@ export class Room extends EventEmitter {
|
|
|
91
98
|
}
|
|
92
99
|
restore(stored) {
|
|
93
100
|
for (const s of stored) {
|
|
101
|
+
if (!s.agentType) {
|
|
102
|
+
this.addUnstaffed({ name: s.name, tagline: s.tagline, role: s.role, avatar: s.avatar, skills: s.skills, color: s.color, id: s.id });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
94
105
|
const recipe = getRecipe(s.agentType);
|
|
95
106
|
this.participants.set(s.id, {
|
|
96
107
|
id: s.id,
|
|
@@ -131,11 +142,11 @@ export class Room extends EventEmitter {
|
|
|
131
142
|
createdAt: this.createdAt,
|
|
132
143
|
settings,
|
|
133
144
|
participants: [...this.participants.values()]
|
|
134
|
-
.filter((p) => p.kind === "agent" && p.agentType)
|
|
145
|
+
.filter((p) => p.kind === "agent" && (p.agentType || p.status === "unstaffed"))
|
|
135
146
|
.map((p) => ({
|
|
136
147
|
id: p.id,
|
|
137
148
|
name: p.name,
|
|
138
|
-
agentType: p.agentType,
|
|
149
|
+
agentType: p.agentType ?? "",
|
|
139
150
|
tagline: p.tagline ?? "",
|
|
140
151
|
role: p.role ?? "",
|
|
141
152
|
avatar: p.avatar ?? "",
|
|
@@ -236,10 +247,17 @@ export class Room extends EventEmitter {
|
|
|
236
247
|
return gone ? `@${gone} (no longer in the room)` : "@(a participant who left)";
|
|
237
248
|
});
|
|
238
249
|
}
|
|
239
|
-
|
|
250
|
+
unstaffed() {
|
|
251
|
+
return [...this.participants.values()].filter((p) => p.kind === "agent" && p.status === "unstaffed");
|
|
252
|
+
}
|
|
253
|
+
postHumanMessage(text, images = []) {
|
|
254
|
+
const waiting = this.unstaffed();
|
|
255
|
+
if (waiting.length)
|
|
256
|
+
throw new Error(`${waiting.map((p) => p.name).join(", ")} ${waiting.length === 1 ? "has" : "have"} no coding agent yet: summon ${waiting.length === 1 ? "it" : "them"} from the roster to start the conversation`);
|
|
240
257
|
const trimmed = text.trim();
|
|
241
|
-
if (!trimmed)
|
|
258
|
+
if (!trimmed && !images.length)
|
|
242
259
|
throw new Error("empty message");
|
|
260
|
+
const attachments = images.length ? saveImages(ensureDir(this.filesDir()), images) : [];
|
|
243
261
|
this.humanTypingUntil = 0;
|
|
244
262
|
const human = this.participants.get("human");
|
|
245
263
|
const message = {
|
|
@@ -253,6 +271,8 @@ export class Room extends EventEmitter {
|
|
|
253
271
|
ts: Date.now(),
|
|
254
272
|
kind: "chat",
|
|
255
273
|
};
|
|
274
|
+
if (attachments.length)
|
|
275
|
+
message.images = attachments;
|
|
256
276
|
this.decorateHumanMessage(message);
|
|
257
277
|
human.turns += 1;
|
|
258
278
|
if (this.focused) {
|
|
@@ -284,7 +304,7 @@ export class Room extends EventEmitter {
|
|
|
284
304
|
agentReadStates() {
|
|
285
305
|
const out = [];
|
|
286
306
|
for (const p of this.participants.values()) {
|
|
287
|
-
if (p.kind !== "agent" || p.status === "left")
|
|
307
|
+
if (p.kind !== "agent" || p.status === "left" || p.status === "unstaffed")
|
|
288
308
|
continue;
|
|
289
309
|
const runtime = this.runtimes.get(p.id);
|
|
290
310
|
if (runtime)
|
|
@@ -300,6 +320,20 @@ export class Room extends EventEmitter {
|
|
|
300
320
|
throw new Error("only your own chat messages can be edited");
|
|
301
321
|
return message;
|
|
302
322
|
}
|
|
323
|
+
setPinned(messageId, pinned) {
|
|
324
|
+
const message = this.messages.find((m) => m.id === messageId);
|
|
325
|
+
if (!message || message.kind !== "chat")
|
|
326
|
+
throw new Error("only chat messages can be pinned");
|
|
327
|
+
if (!!message.pinned === pinned)
|
|
328
|
+
return message;
|
|
329
|
+
if (pinned)
|
|
330
|
+
message.pinned = true;
|
|
331
|
+
else
|
|
332
|
+
delete message.pinned;
|
|
333
|
+
this.rewriteHistory();
|
|
334
|
+
this.push({ type: "message", message });
|
|
335
|
+
return message;
|
|
336
|
+
}
|
|
303
337
|
previewEdit(messageId) {
|
|
304
338
|
const message = this.editableMessage(messageId);
|
|
305
339
|
const { removed } = partitionHistory(this.messages, message.seq);
|
|
@@ -334,7 +368,9 @@ export class Room extends EventEmitter {
|
|
|
334
368
|
this.rewriteHistory();
|
|
335
369
|
this.push({ type: "message", message });
|
|
336
370
|
if (restart.length || offline.length)
|
|
337
|
-
this.postSystem(editNotice(this.humanName, previous, trimmed), "agents");
|
|
371
|
+
this.postSystem(editNotice(this.humanName, previous, trimmed), "agents", true);
|
|
372
|
+
for (const a of restart)
|
|
373
|
+
this.requestTurn(a.id);
|
|
338
374
|
this.log.info(`edit (notify) of #${message.seq}: ${restart.length} agents had the old version`);
|
|
339
375
|
this.route(message);
|
|
340
376
|
return { restarted: [], removed: 0 };
|
|
@@ -380,6 +416,31 @@ export class Room extends EventEmitter {
|
|
|
380
416
|
this.route(message);
|
|
381
417
|
return { restarted, removed: removed.length };
|
|
382
418
|
}
|
|
419
|
+
async respawnAgent(id) {
|
|
420
|
+
const participant = this.participants.get(id);
|
|
421
|
+
if (!participant || participant.kind !== "agent")
|
|
422
|
+
throw new Error("no such agent");
|
|
423
|
+
const online = this.runtimes.has(id);
|
|
424
|
+
this.dropScheduledTurn(id);
|
|
425
|
+
this.cancelPermissionsOf(id);
|
|
426
|
+
if (this.speaking === id)
|
|
427
|
+
this.speaking = null;
|
|
428
|
+
if (online)
|
|
429
|
+
await this.retireRuntime(id);
|
|
430
|
+
participant.sessionId = undefined;
|
|
431
|
+
this.restoredSeen.set(id, this.seq);
|
|
432
|
+
this.push({ type: "participant", participant });
|
|
433
|
+
if (!online) {
|
|
434
|
+
participant.statusDetail = "its context was cleared; a reconnect starts it with an empty head";
|
|
435
|
+
this.postSystem(`${participant.name} was respawned while offline: it comes back knowing nothing from before.`);
|
|
436
|
+
this.push({ type: "participant", participant });
|
|
437
|
+
this.log.info(`respawn of ${participant.name} (offline): stored session dropped`);
|
|
438
|
+
return participant;
|
|
439
|
+
}
|
|
440
|
+
await this.reconnect(id, { mode: "replay", replay: 0, reason: "its context was cleared, it remembers nothing from before" });
|
|
441
|
+
this.log.info(`respawn of ${participant.name}: fresh session, no replay`);
|
|
442
|
+
return participant;
|
|
443
|
+
}
|
|
383
444
|
async retireRuntime(id) {
|
|
384
445
|
const runtime = this.runtimes.get(id);
|
|
385
446
|
const participant = this.participants.get(id);
|
|
@@ -708,9 +769,10 @@ export class Room extends EventEmitter {
|
|
|
708
769
|
const name = options.name.trim();
|
|
709
770
|
if (!NAME_PATTERN.test(name))
|
|
710
771
|
throw new Error("name must be 1-24 letters, digits, _ or - (no spaces)");
|
|
711
|
-
|
|
772
|
+
const taken = this.findByName(name);
|
|
773
|
+
if (taken && !(options.id && taken.id === options.id && taken.status === "unstaffed"))
|
|
712
774
|
throw new Error(`name "${name}" is already taken`);
|
|
713
|
-
const id = `${recipe.id}-${name.toLowerCase()}`;
|
|
775
|
+
const id = options.id ?? `${recipe.id}-${name.toLowerCase()}`;
|
|
714
776
|
const launch = {
|
|
715
777
|
model: options.model ?? recipe.defaultModel,
|
|
716
778
|
effort: options.effort ?? recipe.defaultEffort,
|
|
@@ -725,7 +787,7 @@ export class Room extends EventEmitter {
|
|
|
725
787
|
agentVendor: recipe.vendor,
|
|
726
788
|
status: "starting",
|
|
727
789
|
turns: 0,
|
|
728
|
-
color: COLORS[this.colorIndex++ % COLORS.length],
|
|
790
|
+
color: options.color ?? COLORS[this.colorIndex++ % COLORS.length],
|
|
729
791
|
tagline: (options.tagline ?? "").trim().slice(0, 80),
|
|
730
792
|
role: (options.role ?? "").trim().slice(0, 4000),
|
|
731
793
|
avatar: (options.avatar ?? "").trim().slice(0, 8) || undefined,
|
|
@@ -741,6 +803,51 @@ export class Room extends EventEmitter {
|
|
|
741
803
|
await this.startAgent(participant, launch, true);
|
|
742
804
|
return participant;
|
|
743
805
|
}
|
|
806
|
+
addUnstaffed(input) {
|
|
807
|
+
const name = input.name.trim();
|
|
808
|
+
if (!NAME_PATTERN.test(name))
|
|
809
|
+
throw new Error("name must be 1-24 letters, digits, _ or - (no spaces)");
|
|
810
|
+
if (this.findByName(name))
|
|
811
|
+
throw new Error(`name "${name}" is already taken`);
|
|
812
|
+
const id = input.id ?? `vm-${name.toLowerCase()}`;
|
|
813
|
+
const participant = {
|
|
814
|
+
id,
|
|
815
|
+
name,
|
|
816
|
+
kind: "agent",
|
|
817
|
+
status: "unstaffed",
|
|
818
|
+
statusDetail: "awaiting a coding agent",
|
|
819
|
+
turns: 0,
|
|
820
|
+
color: input.color ?? COLORS[this.colorIndex++ % COLORS.length],
|
|
821
|
+
tagline: (input.tagline ?? "").trim().slice(0, 80),
|
|
822
|
+
role: (input.role ?? "").trim().slice(0, 4000),
|
|
823
|
+
avatar: (input.avatar ?? "").trim().slice(0, 8) || undefined,
|
|
824
|
+
skills: normalizeSkillList(input.skills),
|
|
825
|
+
violations: 0,
|
|
826
|
+
briefsSent: 0,
|
|
827
|
+
failedTurns: 0,
|
|
828
|
+
};
|
|
829
|
+
this.participants.set(id, participant);
|
|
830
|
+
this.push({ type: "participant", participant });
|
|
831
|
+
return participant;
|
|
832
|
+
}
|
|
833
|
+
async staff(id, choice) {
|
|
834
|
+
const placeholder = this.participants.get(id);
|
|
835
|
+
if (!placeholder || placeholder.kind !== "agent" || placeholder.status !== "unstaffed")
|
|
836
|
+
throw new Error("this vibemate is not awaiting a coding agent");
|
|
837
|
+
return this.inviteAgent({
|
|
838
|
+
id,
|
|
839
|
+
agentType: choice.agentType,
|
|
840
|
+
name: choice.name?.trim() || placeholder.name,
|
|
841
|
+
tagline: choice.tagline ?? placeholder.tagline,
|
|
842
|
+
role: choice.role ?? placeholder.role,
|
|
843
|
+
avatar: choice.avatar ?? placeholder.avatar,
|
|
844
|
+
skills: choice.skills ?? placeholder.skills,
|
|
845
|
+
color: placeholder.color,
|
|
846
|
+
model: choice.model,
|
|
847
|
+
effort: choice.effort,
|
|
848
|
+
mode: choice.mode,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
744
851
|
async reconnect(id, options = { mode: "replay" }) {
|
|
745
852
|
const participant = this.participants.get(id);
|
|
746
853
|
if (!participant || participant.kind !== "agent")
|
|
@@ -1027,7 +1134,8 @@ export class Room extends EventEmitter {
|
|
|
1027
1134
|
targets = [];
|
|
1028
1135
|
}
|
|
1029
1136
|
else {
|
|
1030
|
-
const
|
|
1137
|
+
const addressed = message.to.length > 0;
|
|
1138
|
+
const wanted = agentTargets.length ? agentTargets : addressed ? [] : this.settings.agentsWakeEachOther ? [...this.runtimes.keys()].filter((id) => id !== message.from && live(id)) : [];
|
|
1031
1139
|
if (wanted.length) {
|
|
1032
1140
|
if (this.hops >= this.hopLimit) {
|
|
1033
1141
|
const who = agentTargets.length ? message.toNames.join(", ") : "the other vibemates";
|
|
@@ -1343,10 +1451,14 @@ export class Room extends EventEmitter {
|
|
|
1343
1451
|
}
|
|
1344
1452
|
this.floorQueue.push(id);
|
|
1345
1453
|
}
|
|
1454
|
+
static TYPING_HOLD_CAP_MS = 12_000;
|
|
1455
|
+
typingHoldSince = 0;
|
|
1346
1456
|
humanIsTyping() {
|
|
1347
|
-
return this.settings.waitWhileHumanTypes && Date.now() < this.humanTypingUntil;
|
|
1457
|
+
return this.settings.waitWhileHumanTypes && Date.now() < this.humanTypingUntil && Date.now() - this.typingHoldSince < Room.TYPING_HOLD_CAP_MS;
|
|
1348
1458
|
}
|
|
1349
1459
|
humanTyping() {
|
|
1460
|
+
if (Date.now() >= this.humanTypingUntil)
|
|
1461
|
+
this.typingHoldSince = Date.now();
|
|
1350
1462
|
this.humanTypingUntil = Date.now() + 4000;
|
|
1351
1463
|
this.armTypingTimer();
|
|
1352
1464
|
}
|
|
@@ -1427,8 +1539,8 @@ export class Room extends EventEmitter {
|
|
|
1427
1539
|
await this.awaitSkillChannel(participant, runtime);
|
|
1428
1540
|
if (!runtime.agent.alive || participant.muted || this.focused)
|
|
1429
1541
|
return;
|
|
1430
|
-
const unreadAll = this.messages.filter((m) => m.kind !== "hidden" && m.seq > runtime.lastSeenSeq && (m.kind === "system" || m.from !== id || m.seq <= runtime.replayOwnUntilSeq));
|
|
1431
|
-
if (!unreadAll.some((m) => m.kind === "chat"))
|
|
1542
|
+
const unreadAll = this.messages.filter((m) => m.kind !== "hidden" && m.audience !== "human" && m.seq > runtime.lastSeenSeq && (m.kind === "system" || m.from !== id || m.seq <= runtime.replayOwnUntilSeq));
|
|
1543
|
+
if (!unreadAll.some((m) => m.kind === "chat" || m.wakes))
|
|
1432
1544
|
return;
|
|
1433
1545
|
const cap = this.settings.backlogCap;
|
|
1434
1546
|
const omitted = Math.max(0, unreadAll.length - cap);
|
|
@@ -1463,13 +1575,26 @@ export class Room extends EventEmitter {
|
|
|
1463
1575
|
notes.push(s.invokedBy ? `${s.invokedBy} invoked your skill "${s.name}"; its instructions are attached below, follow them` : `skill "${s.name}" attached below as you asked; the same messages follow`);
|
|
1464
1576
|
}
|
|
1465
1577
|
const skillsForPrompt = this.skillsForPrompt(participant, runtime);
|
|
1466
|
-
const
|
|
1578
|
+
const seesImages = runtime.agent.acceptsImages;
|
|
1579
|
+
const backlogImages = (m) => {
|
|
1580
|
+
if (!m.images || !m.images.length)
|
|
1581
|
+
return undefined;
|
|
1582
|
+
const attached = seesImages && (m.to.length === 0 || m.to.includes(id));
|
|
1583
|
+
return m.images.map((a, i) => ({ n: a.n ?? i + 1, ref: `#${m.seq}.${a.n ?? i + 1}`, name: a.name, path: this.imagePath(a), mimeType: a.mimeType, attached, forNames: m.to.length ? m.toNames : [] }));
|
|
1584
|
+
};
|
|
1585
|
+
const prompt = composePrompt({
|
|
1467
1586
|
brief: briefReason ? buildBrief(settings, persona, roster, undefined, skillsForPrompt) : undefined,
|
|
1468
1587
|
header: buildHeader(settings, persona, roster, this.hops, notes, skillsForPrompt),
|
|
1469
1588
|
skills: attached.map((s) => composeSkillBlock({ name: s.name, text: s.text, invokedBy: s.invokedBy, extraFiles: s.extraFiles })),
|
|
1470
1589
|
backlog: unread.map((m) => m.kind === "system"
|
|
1471
1590
|
? { kind: "event", text: m.text }
|
|
1472
|
-
: {
|
|
1591
|
+
: {
|
|
1592
|
+
kind: "message",
|
|
1593
|
+
fromName: m.from === id ? `${m.fromName} (you, earlier)` : m.fromName,
|
|
1594
|
+
toNames: m.toNames,
|
|
1595
|
+
text: m.text,
|
|
1596
|
+
images: backlogImages(m),
|
|
1597
|
+
}),
|
|
1473
1598
|
omitted,
|
|
1474
1599
|
personaName: participant.name,
|
|
1475
1600
|
});
|
|
@@ -1482,12 +1607,29 @@ export class Room extends EventEmitter {
|
|
|
1482
1607
|
runtime.firstTurnDone = true;
|
|
1483
1608
|
runtime.replayOwnUntilSeq = -1;
|
|
1484
1609
|
runtime.log.info(`turn: ${unread.length} unread (${omitted} omitted), brief=${briefReason ?? "no"}, notes=${notes.length}`);
|
|
1485
|
-
const retry = await this.executeTurn(participant, runtime,
|
|
1610
|
+
const retry = await this.executeTurn(participant, runtime, this.promptBlocks(prompt, runtime), null);
|
|
1486
1611
|
if (retry) {
|
|
1487
|
-
await this.executeTurn(participant, runtime, retry.prompt, retry);
|
|
1612
|
+
await this.executeTurn(participant, runtime, [{ type: "text", text: retry.prompt }], retry);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
promptBlocks(parts, runtime) {
|
|
1616
|
+
const blocks = [];
|
|
1617
|
+
for (const part of parts) {
|
|
1618
|
+
if (part.type === "text") {
|
|
1619
|
+
blocks.push({ type: "text", text: part.text });
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
try {
|
|
1623
|
+
blocks.push({ type: "image", data: readFileSync(part.image.path).toString("base64"), mimeType: part.image.mimeType });
|
|
1624
|
+
}
|
|
1625
|
+
catch (error) {
|
|
1626
|
+
runtime.log.warn(`image ${part.image.ref} could not be read: ${describeError(error)}`);
|
|
1627
|
+
blocks.push({ type: "text", text: ` (${part.image.ref} could not be read: ${part.image.path})` });
|
|
1628
|
+
}
|
|
1488
1629
|
}
|
|
1630
|
+
return blocks;
|
|
1489
1631
|
}
|
|
1490
|
-
async executeTurn(participant, runtime,
|
|
1632
|
+
async executeTurn(participant, runtime, blocks, retry) {
|
|
1491
1633
|
const id = participant.id;
|
|
1492
1634
|
runtime.turnActive = true;
|
|
1493
1635
|
participant.status = "thinking";
|
|
@@ -1511,7 +1653,7 @@ export class Room extends EventEmitter {
|
|
|
1511
1653
|
let result = null;
|
|
1512
1654
|
let failure = null;
|
|
1513
1655
|
try {
|
|
1514
|
-
result = await runtime.agent.prompt(runtime.sessionId,
|
|
1656
|
+
result = await runtime.agent.prompt(runtime.sessionId, blocks);
|
|
1515
1657
|
}
|
|
1516
1658
|
catch (error) {
|
|
1517
1659
|
failure = error instanceof Error ? error.message : String(error);
|
|
@@ -1569,10 +1711,10 @@ export class Room extends EventEmitter {
|
|
|
1569
1711
|
this.requestTurn(participant.id);
|
|
1570
1712
|
return null;
|
|
1571
1713
|
}
|
|
1572
|
-
const pull = !retry ? text
|
|
1714
|
+
const pull = !retry ? skillPull(text) : null;
|
|
1573
1715
|
if (pull) {
|
|
1574
1716
|
this.push({ type: "message.removed", id: draft.id });
|
|
1575
|
-
return this.handleSkillPull(participant, runtime, text, pull
|
|
1717
|
+
return this.handleSkillPull(participant, runtime, text, pull);
|
|
1576
1718
|
}
|
|
1577
1719
|
if (!text || text.toLowerCase() === SILENT_MARKER) {
|
|
1578
1720
|
if (published)
|
|
@@ -1645,6 +1787,11 @@ export class Room extends EventEmitter {
|
|
|
1645
1787
|
durationMs,
|
|
1646
1788
|
};
|
|
1647
1789
|
this.commit(message);
|
|
1790
|
+
if (this.messages.some((x) => x.kind === "chat" && x.id !== message.id && x.ts > draft.ts)) {
|
|
1791
|
+
const at = new Date(draft.ts);
|
|
1792
|
+
const hhmm = `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
|
|
1793
|
+
this.postSystem(`${participant.name} finished the reply started at ${hhmm} · ${Math.round(durationMs / 1000)} s`, "human", false, { refId: message.id, agentId: participant.id });
|
|
1794
|
+
}
|
|
1648
1795
|
if (retry) {
|
|
1649
1796
|
this.closeRetry(retry, corrections.length ? `corrected reply posted, but it still breaks: ${corrections.map((c) => c.replace(/^reminder:\s*/i, "")).join("; ")}` : "corrected reply posted");
|
|
1650
1797
|
}
|
|
@@ -1703,6 +1850,8 @@ export class Room extends EventEmitter {
|
|
|
1703
1850
|
const corrections = [];
|
|
1704
1851
|
const unknown = [];
|
|
1705
1852
|
for (const match of text.matchAll(MENTION_PATTERN)) {
|
|
1853
|
+
if (match[1].toLowerCase() === "all")
|
|
1854
|
+
continue;
|
|
1706
1855
|
if (!this.findByName(match[1]) && !unknown.includes(match[1]))
|
|
1707
1856
|
unknown.push(match[1]);
|
|
1708
1857
|
}
|
|
@@ -1791,6 +1940,9 @@ export class Room extends EventEmitter {
|
|
|
1791
1940
|
view.status = u.status;
|
|
1792
1941
|
if (u.rawInput !== undefined)
|
|
1793
1942
|
view.rawInput = u.rawInput;
|
|
1943
|
+
const output = toolOutputText(u);
|
|
1944
|
+
if (output)
|
|
1945
|
+
view.output = output;
|
|
1794
1946
|
this.push({ type: "toolcall", id: turn.message.id, toolCall: view });
|
|
1795
1947
|
return;
|
|
1796
1948
|
}
|
|
@@ -2005,13 +2157,23 @@ export class Room extends EventEmitter {
|
|
|
2005
2157
|
}
|
|
2006
2158
|
roster() {
|
|
2007
2159
|
return [...this.participants.values()]
|
|
2008
|
-
.filter((p) => p.status !== "left")
|
|
2009
|
-
.map((p) => ({ name: p.name, kind: p.kind, vendor: p.agentVendor ?? p.agentLabel, tagline: p.tagline || undefined }));
|
|
2160
|
+
.filter((p) => p.status !== "left" && p.status !== "unstaffed")
|
|
2161
|
+
.map((p) => ({ name: p.name, kind: p.kind, vendor: p.agentVendor ?? p.agentLabel, tagline: p.tagline || undefined, muted: p.muted || undefined }));
|
|
2010
2162
|
}
|
|
2011
2163
|
parseMentions(text) {
|
|
2012
2164
|
const ids = [];
|
|
2013
2165
|
const names = [];
|
|
2014
2166
|
for (const match of text.matchAll(MENTION_PATTERN)) {
|
|
2167
|
+
if (match[1].toLowerCase() === "all") {
|
|
2168
|
+
for (const p of this.participants.values()) {
|
|
2169
|
+
if (p.kind !== "agent" || p.status === "left" || ids.includes(p.id))
|
|
2170
|
+
continue;
|
|
2171
|
+
ids.push(p.id);
|
|
2172
|
+
}
|
|
2173
|
+
if (!names.includes("All"))
|
|
2174
|
+
names.push("All");
|
|
2175
|
+
continue;
|
|
2176
|
+
}
|
|
2015
2177
|
const participant = this.findByName(match[1]);
|
|
2016
2178
|
if (participant && !ids.includes(participant.id)) {
|
|
2017
2179
|
ids.push(participant.id);
|
|
@@ -2027,7 +2189,7 @@ export class Room extends EventEmitter {
|
|
|
2027
2189
|
return p;
|
|
2028
2190
|
return undefined;
|
|
2029
2191
|
}
|
|
2030
|
-
postSystem(text, audience) {
|
|
2192
|
+
postSystem(text, audience, wakes, details) {
|
|
2031
2193
|
const message = {
|
|
2032
2194
|
id: randomUUID(),
|
|
2033
2195
|
seq: ++this.seq,
|
|
@@ -2041,6 +2203,10 @@ export class Room extends EventEmitter {
|
|
|
2041
2203
|
};
|
|
2042
2204
|
if (audience)
|
|
2043
2205
|
message.audience = audience;
|
|
2206
|
+
if (wakes)
|
|
2207
|
+
message.wakes = true;
|
|
2208
|
+
if (details)
|
|
2209
|
+
message.details = details;
|
|
2044
2210
|
this.commit(message);
|
|
2045
2211
|
}
|
|
2046
2212
|
async setDir(dir) {
|
|
@@ -2098,6 +2264,12 @@ export class Room extends EventEmitter {
|
|
|
2098
2264
|
this.push({ type: "notice", text, level, ts: Date.now() });
|
|
2099
2265
|
}
|
|
2100
2266
|
push(event) {
|
|
2267
|
+
if (event.type === "participant") {
|
|
2268
|
+
const id = event.participant.id;
|
|
2269
|
+
const lastSeenSeq = this.runtimes.get(id)?.lastSeenSeq ?? this.restoredSeen.get(id);
|
|
2270
|
+
this.emit("event", { ...event, participant: { ...event.participant, lastSeenSeq } });
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2101
2273
|
this.emit("event", event);
|
|
2102
2274
|
}
|
|
2103
2275
|
}
|
|
@@ -2113,6 +2285,22 @@ function looksSilent(text) {
|
|
|
2113
2285
|
const t = text.trim().toLowerCase();
|
|
2114
2286
|
return t.length <= SILENT_MARKER.length && SILENT_MARKER.startsWith(t);
|
|
2115
2287
|
}
|
|
2288
|
+
function toolOutputText(u) {
|
|
2289
|
+
const cap = (s) => (s.length > 4000 ? `${s.slice(0, 4000)}\n… (${s.length - 4000} more characters)` : s);
|
|
2290
|
+
if (u.content && u.content.length) {
|
|
2291
|
+
const parts = u.content.map((c) => {
|
|
2292
|
+
if (c.type === "content")
|
|
2293
|
+
return contentText(c.content);
|
|
2294
|
+
if (c.type === "diff")
|
|
2295
|
+
return `--- ${c.path}\n${c.oldText ? `- ${c.oldText}\n` : ""}+ ${c.newText}`;
|
|
2296
|
+
return `[terminal ${c.terminalId}]`;
|
|
2297
|
+
});
|
|
2298
|
+
return cap(parts.join("\n"));
|
|
2299
|
+
}
|
|
2300
|
+
if (u.rawOutput !== undefined && u.rawOutput !== null)
|
|
2301
|
+
return cap(typeof u.rawOutput === "string" ? u.rawOutput : JSON.stringify(u.rawOutput, null, 1));
|
|
2302
|
+
return "";
|
|
2303
|
+
}
|
|
2116
2304
|
function contentText(block) {
|
|
2117
2305
|
if (block.type === "text")
|
|
2118
2306
|
return block.text;
|
package/dist/server.js
CHANGED
|
@@ -9,6 +9,8 @@ import { existsSync as fileExists } from "node:fs";
|
|
|
9
9
|
import { classifyOpenTarget, describeOpen, detectEditor, editorCommand, isExecutablePath, openCommand } from "./open.js";
|
|
10
10
|
import { parseCsv, viewerKind, VIEWER_MAX_BYTES } from "./viewer.js";
|
|
11
11
|
import { createFolder, homeFolder, listFolders, listRoots } from "./fsbrowse.js";
|
|
12
|
+
import { contentTypeOf, isStoredFileName, IMAGE_MAX_BYTES, IMAGES_PER_MESSAGE } from "./files.js";
|
|
13
|
+
import { commandTarget, parseRoomCommand } from "./commands.js";
|
|
12
14
|
let editorFound;
|
|
13
15
|
function currentEditor() {
|
|
14
16
|
if (editorFound === undefined)
|
|
@@ -150,6 +152,24 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
150
152
|
sendJson(res, 200, kind === "csv" ? { ok: true, kind, path: target.value, rows: parseCsv(text) } : { ok: true, kind, path: target.value, text });
|
|
151
153
|
return;
|
|
152
154
|
}
|
|
155
|
+
const roomFile = req.method === "GET" && path.match(/^\/api\/rooms\/([^/]+)\/files\/([^/]+)$/);
|
|
156
|
+
if (roomFile) {
|
|
157
|
+
const room = hub.getRoom(decodeURIComponent(roomFile[1]));
|
|
158
|
+
const name = decodeURIComponent(roomFile[2]);
|
|
159
|
+
if (!isStoredFileName(name)) {
|
|
160
|
+
sendJson(res, 400, { error: "not an attachment name" });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const bytes = await readFile(join(room.filesDir(), name));
|
|
165
|
+
res.writeHead(200, { "Content-Type": contentTypeOf(name), "Cache-Control": "public, max-age=31536000, immutable" });
|
|
166
|
+
res.end(bytes);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
sendJson(res, 404, { error: "no such attachment" });
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
153
173
|
if (req.method === "GET" && path === "/api/editor") {
|
|
154
174
|
sendJson(res, 200, { editor: currentEditor(), settings: hub.settings.editor });
|
|
155
175
|
return;
|
|
@@ -210,6 +230,10 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
210
230
|
sendJson(res, 200, hub.getRoom(decodeURIComponent(roomGet[1])).snapshot());
|
|
211
231
|
return;
|
|
212
232
|
}
|
|
233
|
+
if (req.method === "GET" && path === "/api/templates") {
|
|
234
|
+
sendJson(res, 200, { templates: hub.templates.list() });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
213
237
|
if (req.method === "GET" && path === "/api/rooms") {
|
|
214
238
|
sendJson(res, 200, [...hub.rooms.values()].map((r) => r.snapshot()));
|
|
215
239
|
return;
|
|
@@ -322,6 +346,20 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
322
346
|
setTimeout(onShutdownRequest, 50);
|
|
323
347
|
return;
|
|
324
348
|
}
|
|
349
|
+
if (path === "/api/rooms/from-template") {
|
|
350
|
+
const vibemates = Array.isArray(body.vibemates) ? body.vibemates : [];
|
|
351
|
+
const { room, notices } = await hub.createRoomFromTemplate({
|
|
352
|
+
templateId: String(body.template ?? ""),
|
|
353
|
+
name: String(body.name ?? ""),
|
|
354
|
+
dir: optionalString(body.dir),
|
|
355
|
+
vibemates: vibemates.map((v) => {
|
|
356
|
+
const o = (v ?? {});
|
|
357
|
+
return { name: String(o.name ?? ""), agentType: String(o.agentType ?? ""), model: optionalString(o.model), effort: optionalString(o.effort), mode: optionalString(o.mode) };
|
|
358
|
+
}),
|
|
359
|
+
});
|
|
360
|
+
sendJson(res, 200, { ok: true, room: room.snapshot(), notices });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
325
363
|
if (path === "/api/rooms") {
|
|
326
364
|
const { room, notices } = hub.createRoom({
|
|
327
365
|
name: String(body.name ?? ""),
|
|
@@ -331,12 +369,27 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
331
369
|
sendJson(res, 200, { ok: true, room: room.snapshot(), notices });
|
|
332
370
|
return;
|
|
333
371
|
}
|
|
334
|
-
const roomAction = path.match(/^\/api\/rooms\/([^/]+)\/(send|typing|invite|settings|focus|rename|dir|delete)$/);
|
|
372
|
+
const roomAction = path.match(/^\/api\/rooms\/([^/]+)\/(send|typing|invite|settings|focus|rename|dir|delete|open)$/);
|
|
335
373
|
if (roomAction) {
|
|
336
374
|
const room = hub.getRoom(decodeURIComponent(roomAction[1]));
|
|
337
375
|
const action = roomAction[2];
|
|
338
|
-
if (action === "
|
|
339
|
-
|
|
376
|
+
if (action === "open") {
|
|
377
|
+
hub.markOpened(room.id);
|
|
378
|
+
sendJson(res, 200, { ok: true, openRooms: hub.openRooms });
|
|
379
|
+
}
|
|
380
|
+
else if (action === "send") {
|
|
381
|
+
const text = String(body.text ?? "");
|
|
382
|
+
const command = parseRoomCommand(text);
|
|
383
|
+
if (command) {
|
|
384
|
+
const target = room.findByName(commandTarget(command.args));
|
|
385
|
+
if (!target)
|
|
386
|
+
throw new Error(`/${command.name} needs the name of a vibemate in this room, like /${command.name} @Name`);
|
|
387
|
+
await room.respawnAgent(target.id);
|
|
388
|
+
hub.saveRooms();
|
|
389
|
+
sendJson(res, 200, { ok: true, command: command.name, participant: target.name });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const message = room.postHumanMessage(text, imageList(body.images));
|
|
340
393
|
sendJson(res, 200, { ok: true, id: message.id });
|
|
341
394
|
}
|
|
342
395
|
else if (action === "typing") {
|
|
@@ -387,6 +440,13 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
387
440
|
}
|
|
388
441
|
return;
|
|
389
442
|
}
|
|
443
|
+
const messagePin = path.match(/^\/api\/rooms\/([^/]+)\/messages\/([^/]+)\/pin$/);
|
|
444
|
+
if (messagePin) {
|
|
445
|
+
const room = hub.getRoom(decodeURIComponent(messagePin[1]));
|
|
446
|
+
const message = room.setPinned(decodeURIComponent(messagePin[2]), body.pinned === true || body.pinned === "true");
|
|
447
|
+
sendJson(res, 200, { ok: true, pinned: !!message.pinned });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
390
450
|
const messageEdit = path.match(/^\/api\/rooms\/([^/]+)\/messages\/([^/]+)\/edit$/);
|
|
391
451
|
if (messageEdit) {
|
|
392
452
|
const room = hub.getRoom(decodeURIComponent(messageEdit[1]));
|
|
@@ -396,13 +456,31 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
396
456
|
sendJson(res, 200, { ok: true, ...result });
|
|
397
457
|
return;
|
|
398
458
|
}
|
|
399
|
-
const participantAction = path.match(/^\/api\/rooms\/([^/]+)\/participants\/([^/]+)\/(cancel|remove|config|persona|reconnect|mute|unmute)$/);
|
|
459
|
+
const participantAction = path.match(/^\/api\/rooms\/([^/]+)\/participants\/([^/]+)\/(cancel|remove|config|persona|reconnect|mute|unmute|respawn|staff)$/);
|
|
400
460
|
if (participantAction) {
|
|
401
461
|
const room = hub.getRoom(decodeURIComponent(participantAction[1]));
|
|
402
462
|
const id = decodeURIComponent(participantAction[2]);
|
|
403
463
|
const action = participantAction[3];
|
|
404
464
|
if (action === "cancel")
|
|
405
465
|
room.cancelTurn(id);
|
|
466
|
+
else if (action === "respawn")
|
|
467
|
+
await room.respawnAgent(id);
|
|
468
|
+
else if (action === "staff") {
|
|
469
|
+
const participant = await room.staff(id, {
|
|
470
|
+
agentType: String(body.agentType ?? ""),
|
|
471
|
+
model: optionalString(body.model),
|
|
472
|
+
effort: optionalString(body.effort),
|
|
473
|
+
mode: optionalString(body.mode),
|
|
474
|
+
name: optionalString(body.name),
|
|
475
|
+
tagline: optionalString(body.tagline),
|
|
476
|
+
role: optionalString(body.role),
|
|
477
|
+
avatar: optionalString(body.avatar),
|
|
478
|
+
skills: stringList(body.skills),
|
|
479
|
+
});
|
|
480
|
+
hub.saveRooms();
|
|
481
|
+
sendJson(res, 200, { ok: true, participant });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
406
484
|
else if (action === "remove")
|
|
407
485
|
await room.removeParticipant(id);
|
|
408
486
|
else if (action === "reconnect") {
|
|
@@ -472,14 +550,33 @@ function stringList(value) {
|
|
|
472
550
|
return [];
|
|
473
551
|
return value.map((v) => String(v).trim()).filter((v) => v.length > 0);
|
|
474
552
|
}
|
|
553
|
+
function imageList(value) {
|
|
554
|
+
if (!Array.isArray(value))
|
|
555
|
+
return [];
|
|
556
|
+
return value.slice(0, IMAGES_PER_MESSAGE).map((entry) => {
|
|
557
|
+
const image = (entry ?? {});
|
|
558
|
+
const n = Number(image.n);
|
|
559
|
+
return { name: optionalString(image.name) ?? undefined, mimeType: String(image.mimeType ?? ""), data: String(image.data ?? ""), n: Number.isInteger(n) && n > 0 ? n : undefined };
|
|
560
|
+
});
|
|
561
|
+
}
|
|
475
562
|
function sendJson(res, status, body) {
|
|
476
563
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
477
564
|
res.end(JSON.stringify(body));
|
|
478
565
|
}
|
|
566
|
+
const BODY_MAX_BYTES = (IMAGE_MAX_BYTES * IMAGES_PER_MESSAGE * 4) / 3 + 64 * 1024;
|
|
479
567
|
function readJson(req) {
|
|
480
568
|
return new Promise((resolve, reject) => {
|
|
481
569
|
const chunks = [];
|
|
482
|
-
|
|
570
|
+
let size = 0;
|
|
571
|
+
req.on("data", (chunk) => {
|
|
572
|
+
size += chunk.length;
|
|
573
|
+
if (size > BODY_MAX_BYTES) {
|
|
574
|
+
reject(new Error("the request is too large"));
|
|
575
|
+
req.destroy();
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
chunks.push(chunk);
|
|
579
|
+
});
|
|
483
580
|
req.on("end", () => {
|
|
484
581
|
const raw = Buffer.concat(chunks).toString("utf8");
|
|
485
582
|
if (!raw.trim())
|
package/dist/skills.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
+
import { isReservedSkillName, RESERVED_SKILL_NAMES } from "./commands.js";
|
|
4
5
|
export const SKILL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
|
|
5
6
|
export const SKILL_FILE = "SKILL.md";
|
|
6
7
|
export const BUILTIN_AUTHOR = "viberoom";
|
|
@@ -32,6 +33,9 @@ export function lintSkill(input) {
|
|
|
32
33
|
const hint = (input.argumentHint ?? "").trim();
|
|
33
34
|
if (!SKILL_NAME_PATTERN.test(name))
|
|
34
35
|
errors.push({ code: "name-invalid", message: `name "${name}" must be 1-32 letters, digits, _ or -` });
|
|
36
|
+
else if (isReservedSkillName(name)) {
|
|
37
|
+
errors.push({ code: "name-reserved", message: `"${name}" is a room command (reserved: ${RESERVED_SKILL_NAMES.join(", ")})` });
|
|
38
|
+
}
|
|
35
39
|
if (input.folder && name.toLowerCase() !== input.folder.toLowerCase()) {
|
|
36
40
|
errors.push({ code: "name-folder-mismatch", message: `name "${name}" differs from the folder "${input.folder}"` });
|
|
37
41
|
}
|