viberoom 0.4.1 → 0.5.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 +2 -2
- 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 +88 -14
- package/dist/room.js +222 -30
- 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 +165 -20
- package/ui/app.js +818 -117
- package/ui/icons.js +13 -10
- package/ui/index.html +30 -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);
|
|
@@ -506,6 +567,13 @@ export class Room extends EventEmitter {
|
|
|
506
567
|
changed.push("turnTaking");
|
|
507
568
|
}
|
|
508
569
|
}
|
|
570
|
+
if (patch.agentsWakeEachOther !== undefined) {
|
|
571
|
+
const on = patch.agentsWakeEachOther === true || patch.agentsWakeEachOther === "true";
|
|
572
|
+
if (on !== next.agentsWakeEachOther) {
|
|
573
|
+
next.agentsWakeEachOther = on;
|
|
574
|
+
changed.push("agentsWakeEachOther");
|
|
575
|
+
}
|
|
576
|
+
}
|
|
509
577
|
if (patch.waitWhileHumanTypes !== undefined) {
|
|
510
578
|
const on = patch.waitWhileHumanTypes === true || patch.waitWhileHumanTypes === "true";
|
|
511
579
|
if (on !== next.waitWhileHumanTypes) {
|
|
@@ -701,9 +769,10 @@ export class Room extends EventEmitter {
|
|
|
701
769
|
const name = options.name.trim();
|
|
702
770
|
if (!NAME_PATTERN.test(name))
|
|
703
771
|
throw new Error("name must be 1-24 letters, digits, _ or - (no spaces)");
|
|
704
|
-
|
|
772
|
+
const taken = this.findByName(name);
|
|
773
|
+
if (taken && !(options.id && taken.id === options.id && taken.status === "unstaffed"))
|
|
705
774
|
throw new Error(`name "${name}" is already taken`);
|
|
706
|
-
const id = `${recipe.id}-${name.toLowerCase()}`;
|
|
775
|
+
const id = options.id ?? `${recipe.id}-${name.toLowerCase()}`;
|
|
707
776
|
const launch = {
|
|
708
777
|
model: options.model ?? recipe.defaultModel,
|
|
709
778
|
effort: options.effort ?? recipe.defaultEffort,
|
|
@@ -718,7 +787,7 @@ export class Room extends EventEmitter {
|
|
|
718
787
|
agentVendor: recipe.vendor,
|
|
719
788
|
status: "starting",
|
|
720
789
|
turns: 0,
|
|
721
|
-
color: COLORS[this.colorIndex++ % COLORS.length],
|
|
790
|
+
color: options.color ?? COLORS[this.colorIndex++ % COLORS.length],
|
|
722
791
|
tagline: (options.tagline ?? "").trim().slice(0, 80),
|
|
723
792
|
role: (options.role ?? "").trim().slice(0, 4000),
|
|
724
793
|
avatar: (options.avatar ?? "").trim().slice(0, 8) || undefined,
|
|
@@ -734,6 +803,51 @@ export class Room extends EventEmitter {
|
|
|
734
803
|
await this.startAgent(participant, launch, true);
|
|
735
804
|
return participant;
|
|
736
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
|
+
}
|
|
737
851
|
async reconnect(id, options = { mode: "replay" }) {
|
|
738
852
|
const participant = this.participants.get(id);
|
|
739
853
|
if (!participant || participant.kind !== "agent")
|
|
@@ -1019,13 +1133,18 @@ export class Room extends EventEmitter {
|
|
|
1019
1133
|
else if (this.focused) {
|
|
1020
1134
|
targets = [];
|
|
1021
1135
|
}
|
|
1022
|
-
else
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1136
|
+
else {
|
|
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)) : [];
|
|
1139
|
+
if (wanted.length) {
|
|
1140
|
+
if (this.hops >= this.hopLimit) {
|
|
1141
|
+
const who = agentTargets.length ? message.toNames.join(", ") : "the other vibemates";
|
|
1142
|
+
this.postSystem(`Hop limit ${this.hopLimit} reached: ${who} will not be prompted until ${this.humanName} writes again.`);
|
|
1143
|
+
}
|
|
1144
|
+
else {
|
|
1145
|
+
this.hops += 1;
|
|
1146
|
+
targets = this.settings.turnTaking === "one-at-a-time" && !agentTargets.length && wanted.length > 1 ? shuffle(wanted) : wanted;
|
|
1147
|
+
}
|
|
1029
1148
|
}
|
|
1030
1149
|
}
|
|
1031
1150
|
this.push(this.roomEvent());
|
|
@@ -1332,10 +1451,14 @@ export class Room extends EventEmitter {
|
|
|
1332
1451
|
}
|
|
1333
1452
|
this.floorQueue.push(id);
|
|
1334
1453
|
}
|
|
1454
|
+
static TYPING_HOLD_CAP_MS = 12_000;
|
|
1455
|
+
typingHoldSince = 0;
|
|
1335
1456
|
humanIsTyping() {
|
|
1336
|
-
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;
|
|
1337
1458
|
}
|
|
1338
1459
|
humanTyping() {
|
|
1460
|
+
if (Date.now() >= this.humanTypingUntil)
|
|
1461
|
+
this.typingHoldSince = Date.now();
|
|
1339
1462
|
this.humanTypingUntil = Date.now() + 4000;
|
|
1340
1463
|
this.armTypingTimer();
|
|
1341
1464
|
}
|
|
@@ -1417,7 +1540,7 @@ export class Room extends EventEmitter {
|
|
|
1417
1540
|
if (!runtime.agent.alive || participant.muted || this.focused)
|
|
1418
1541
|
return;
|
|
1419
1542
|
const unreadAll = this.messages.filter((m) => m.kind !== "hidden" && m.seq > runtime.lastSeenSeq && (m.kind === "system" || m.from !== id || m.seq <= runtime.replayOwnUntilSeq));
|
|
1420
|
-
if (!unreadAll.some((m) => m.kind === "chat"))
|
|
1543
|
+
if (!unreadAll.some((m) => m.kind === "chat" || m.wakes))
|
|
1421
1544
|
return;
|
|
1422
1545
|
const cap = this.settings.backlogCap;
|
|
1423
1546
|
const omitted = Math.max(0, unreadAll.length - cap);
|
|
@@ -1452,13 +1575,26 @@ export class Room extends EventEmitter {
|
|
|
1452
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`);
|
|
1453
1576
|
}
|
|
1454
1577
|
const skillsForPrompt = this.skillsForPrompt(participant, runtime);
|
|
1455
|
-
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({
|
|
1456
1586
|
brief: briefReason ? buildBrief(settings, persona, roster, undefined, skillsForPrompt) : undefined,
|
|
1457
1587
|
header: buildHeader(settings, persona, roster, this.hops, notes, skillsForPrompt),
|
|
1458
1588
|
skills: attached.map((s) => composeSkillBlock({ name: s.name, text: s.text, invokedBy: s.invokedBy, extraFiles: s.extraFiles })),
|
|
1459
1589
|
backlog: unread.map((m) => m.kind === "system"
|
|
1460
1590
|
? { kind: "event", text: m.text }
|
|
1461
|
-
: {
|
|
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
|
+
}),
|
|
1462
1598
|
omitted,
|
|
1463
1599
|
personaName: participant.name,
|
|
1464
1600
|
});
|
|
@@ -1471,12 +1607,29 @@ export class Room extends EventEmitter {
|
|
|
1471
1607
|
runtime.firstTurnDone = true;
|
|
1472
1608
|
runtime.replayOwnUntilSeq = -1;
|
|
1473
1609
|
runtime.log.info(`turn: ${unread.length} unread (${omitted} omitted), brief=${briefReason ?? "no"}, notes=${notes.length}`);
|
|
1474
|
-
const retry = await this.executeTurn(participant, runtime,
|
|
1610
|
+
const retry = await this.executeTurn(participant, runtime, this.promptBlocks(prompt, runtime), null);
|
|
1475
1611
|
if (retry) {
|
|
1476
|
-
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
|
+
}
|
|
1477
1629
|
}
|
|
1630
|
+
return blocks;
|
|
1478
1631
|
}
|
|
1479
|
-
async executeTurn(participant, runtime,
|
|
1632
|
+
async executeTurn(participant, runtime, blocks, retry) {
|
|
1480
1633
|
const id = participant.id;
|
|
1481
1634
|
runtime.turnActive = true;
|
|
1482
1635
|
participant.status = "thinking";
|
|
@@ -1500,7 +1653,7 @@ export class Room extends EventEmitter {
|
|
|
1500
1653
|
let result = null;
|
|
1501
1654
|
let failure = null;
|
|
1502
1655
|
try {
|
|
1503
|
-
result = await runtime.agent.prompt(runtime.sessionId,
|
|
1656
|
+
result = await runtime.agent.prompt(runtime.sessionId, blocks);
|
|
1504
1657
|
}
|
|
1505
1658
|
catch (error) {
|
|
1506
1659
|
failure = error instanceof Error ? error.message : String(error);
|
|
@@ -1558,10 +1711,10 @@ export class Room extends EventEmitter {
|
|
|
1558
1711
|
this.requestTurn(participant.id);
|
|
1559
1712
|
return null;
|
|
1560
1713
|
}
|
|
1561
|
-
const pull = !retry ? text
|
|
1714
|
+
const pull = !retry ? skillPull(text) : null;
|
|
1562
1715
|
if (pull) {
|
|
1563
1716
|
this.push({ type: "message.removed", id: draft.id });
|
|
1564
|
-
return this.handleSkillPull(participant, runtime, text, pull
|
|
1717
|
+
return this.handleSkillPull(participant, runtime, text, pull);
|
|
1565
1718
|
}
|
|
1566
1719
|
if (!text || text.toLowerCase() === SILENT_MARKER) {
|
|
1567
1720
|
if (published)
|
|
@@ -1692,6 +1845,8 @@ export class Room extends EventEmitter {
|
|
|
1692
1845
|
const corrections = [];
|
|
1693
1846
|
const unknown = [];
|
|
1694
1847
|
for (const match of text.matchAll(MENTION_PATTERN)) {
|
|
1848
|
+
if (match[1].toLowerCase() === "all")
|
|
1849
|
+
continue;
|
|
1695
1850
|
if (!this.findByName(match[1]) && !unknown.includes(match[1]))
|
|
1696
1851
|
unknown.push(match[1]);
|
|
1697
1852
|
}
|
|
@@ -1780,6 +1935,9 @@ export class Room extends EventEmitter {
|
|
|
1780
1935
|
view.status = u.status;
|
|
1781
1936
|
if (u.rawInput !== undefined)
|
|
1782
1937
|
view.rawInput = u.rawInput;
|
|
1938
|
+
const output = toolOutputText(u);
|
|
1939
|
+
if (output)
|
|
1940
|
+
view.output = output;
|
|
1783
1941
|
this.push({ type: "toolcall", id: turn.message.id, toolCall: view });
|
|
1784
1942
|
return;
|
|
1785
1943
|
}
|
|
@@ -1994,13 +2152,23 @@ export class Room extends EventEmitter {
|
|
|
1994
2152
|
}
|
|
1995
2153
|
roster() {
|
|
1996
2154
|
return [...this.participants.values()]
|
|
1997
|
-
.filter((p) => p.status !== "left")
|
|
1998
|
-
.map((p) => ({ name: p.name, kind: p.kind, vendor: p.agentVendor ?? p.agentLabel, tagline: p.tagline || undefined }));
|
|
2155
|
+
.filter((p) => p.status !== "left" && p.status !== "unstaffed")
|
|
2156
|
+
.map((p) => ({ name: p.name, kind: p.kind, vendor: p.agentVendor ?? p.agentLabel, tagline: p.tagline || undefined, muted: p.muted || undefined }));
|
|
1999
2157
|
}
|
|
2000
2158
|
parseMentions(text) {
|
|
2001
2159
|
const ids = [];
|
|
2002
2160
|
const names = [];
|
|
2003
2161
|
for (const match of text.matchAll(MENTION_PATTERN)) {
|
|
2162
|
+
if (match[1].toLowerCase() === "all") {
|
|
2163
|
+
for (const p of this.participants.values()) {
|
|
2164
|
+
if (p.kind !== "agent" || p.status === "left" || ids.includes(p.id))
|
|
2165
|
+
continue;
|
|
2166
|
+
ids.push(p.id);
|
|
2167
|
+
}
|
|
2168
|
+
if (!names.includes("All"))
|
|
2169
|
+
names.push("All");
|
|
2170
|
+
continue;
|
|
2171
|
+
}
|
|
2004
2172
|
const participant = this.findByName(match[1]);
|
|
2005
2173
|
if (participant && !ids.includes(participant.id)) {
|
|
2006
2174
|
ids.push(participant.id);
|
|
@@ -2016,7 +2184,7 @@ export class Room extends EventEmitter {
|
|
|
2016
2184
|
return p;
|
|
2017
2185
|
return undefined;
|
|
2018
2186
|
}
|
|
2019
|
-
postSystem(text, audience) {
|
|
2187
|
+
postSystem(text, audience, wakes) {
|
|
2020
2188
|
const message = {
|
|
2021
2189
|
id: randomUUID(),
|
|
2022
2190
|
seq: ++this.seq,
|
|
@@ -2030,6 +2198,8 @@ export class Room extends EventEmitter {
|
|
|
2030
2198
|
};
|
|
2031
2199
|
if (audience)
|
|
2032
2200
|
message.audience = audience;
|
|
2201
|
+
if (wakes)
|
|
2202
|
+
message.wakes = true;
|
|
2033
2203
|
this.commit(message);
|
|
2034
2204
|
}
|
|
2035
2205
|
async setDir(dir) {
|
|
@@ -2087,6 +2257,12 @@ export class Room extends EventEmitter {
|
|
|
2087
2257
|
this.push({ type: "notice", text, level, ts: Date.now() });
|
|
2088
2258
|
}
|
|
2089
2259
|
push(event) {
|
|
2260
|
+
if (event.type === "participant") {
|
|
2261
|
+
const id = event.participant.id;
|
|
2262
|
+
const lastSeenSeq = this.runtimes.get(id)?.lastSeenSeq ?? this.restoredSeen.get(id);
|
|
2263
|
+
this.emit("event", { ...event, participant: { ...event.participant, lastSeenSeq } });
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2090
2266
|
this.emit("event", event);
|
|
2091
2267
|
}
|
|
2092
2268
|
}
|
|
@@ -2102,6 +2278,22 @@ function looksSilent(text) {
|
|
|
2102
2278
|
const t = text.trim().toLowerCase();
|
|
2103
2279
|
return t.length <= SILENT_MARKER.length && SILENT_MARKER.startsWith(t);
|
|
2104
2280
|
}
|
|
2281
|
+
function toolOutputText(u) {
|
|
2282
|
+
const cap = (s) => (s.length > 4000 ? `${s.slice(0, 4000)}\n… (${s.length - 4000} more characters)` : s);
|
|
2283
|
+
if (u.content && u.content.length) {
|
|
2284
|
+
const parts = u.content.map((c) => {
|
|
2285
|
+
if (c.type === "content")
|
|
2286
|
+
return contentText(c.content);
|
|
2287
|
+
if (c.type === "diff")
|
|
2288
|
+
return `--- ${c.path}\n${c.oldText ? `- ${c.oldText}\n` : ""}+ ${c.newText}`;
|
|
2289
|
+
return `[terminal ${c.terminalId}]`;
|
|
2290
|
+
});
|
|
2291
|
+
return cap(parts.join("\n"));
|
|
2292
|
+
}
|
|
2293
|
+
if (u.rawOutput !== undefined && u.rawOutput !== null)
|
|
2294
|
+
return cap(typeof u.rawOutput === "string" ? u.rawOutput : JSON.stringify(u.rawOutput, null, 1));
|
|
2295
|
+
return "";
|
|
2296
|
+
}
|
|
2105
2297
|
function contentText(block) {
|
|
2106
2298
|
if (block.type === "text")
|
|
2107
2299
|
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
|
}
|