viberoom 0.5.8 → 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 +20 -0
- package/dist/agent-health.js +52 -0
- package/dist/context.js +6 -0
- package/dist/duration.js +12 -0
- package/dist/hub.js +13 -2
- package/dist/launcher.js +19 -2
- package/dist/main.js +77 -34
- package/dist/mcp-skills-server.js +143 -0
- package/dist/persona.js +167 -46
- package/dist/quotes.js +41 -0
- package/dist/recipes.js +28 -1
- package/dist/room-design.js +205 -0
- package/dist/room.js +388 -150
- package/dist/rows.js +19 -0
- package/dist/server.js +304 -27
- package/dist/skills.js +28 -2
- package/dist/templates.js +17 -0
- package/dist/update.js +32 -2
- package/dist/viewer.js +69 -2
- package/dist/ws.js +175 -0
- package/package.json +2 -1
- package/ui/app.css +348 -246
- package/ui/app.js +1422 -345
- package/ui/avatars.js +131 -30
- package/ui/components.css +140 -0
- package/ui/components.js +342 -0
- package/ui/icons.js +11 -0
- package/ui/index.html +58 -36
- package/ui/theme.css +457 -180
- package/ui/tokens.js +620 -0
- package/ui/ui.js +113 -0
package/dist/persona.js
CHANGED
|
@@ -9,40 +9,107 @@ export function skillPull(reply) {
|
|
|
9
9
|
return match && match[0] === reply.trim() ? match[1] : null;
|
|
10
10
|
}
|
|
11
11
|
export const SKILL_WRITER_NAME = "skill-writer";
|
|
12
|
-
export const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
12
|
+
export const ROOM_DESIGNER_NAME = "room-designer";
|
|
13
|
+
export const NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}_-]{0,23}$/u;
|
|
14
|
+
export const ROOM_SETTINGS_SPEC = {
|
|
15
|
+
name: { kind: "own-path", brief: true, agent: false, doc: "The room's name; changed with rename." },
|
|
16
|
+
humanName: { kind: "own-path", brief: true, agent: false, doc: "The human's name; a program-level setting." },
|
|
17
|
+
topic: { kind: "text", max: 2000, default: "", brief: true, agent: true, doc: "One line about what the room is for; the brief repeats it to every vibemate." },
|
|
18
|
+
emoji: { kind: "text", max: 8, default: "", brief: false, agent: true, doc: "The room's emoji, shown in its title and tile." },
|
|
19
|
+
humanDescription: { kind: "text", max: 200, default: "", brief: true, agent: false, doc: "This room's description of the human, composed with the program-level one by humanDescriptionMode." },
|
|
20
|
+
humanDescriptionMode: { kind: "enum", values: ["inherit", "override", "append", "none"], default: "inherit", brief: true, agent: false, doc: "How the human's description is composed: the program-level text, this room's, both, or nothing." },
|
|
21
|
+
language: { kind: "language", default: { mode: "follow-human" }, brief: true, agent: true, doc: "follow-human: reply in the language of the human's latest message; or a fixed language name." },
|
|
22
|
+
tools: { kind: "enum", values: ["on-request", "never"], default: "on-request", brief: true, agent: true, doc: "on-request: tools only when a participant explicitly asks for something that needs them; never: a talk-only room." },
|
|
23
|
+
maxSentences: { kind: "integer-or-null", min: 1, max: 100, default: null, brief: true, agent: true, doc: "A hard length rule for every reply, in sentences; empty for no rule." },
|
|
24
|
+
hopLimit: { kind: "integer", min: 0, max: 10_000, default: 100, brief: false, agent: true, doc: "Agent-to-agent turns allowed before the hub waits for the human; a chain of three vibemates needs about three times its length." },
|
|
25
|
+
fullBriefEveryTurns: { kind: "integer", min: 1, max: 10_000, default: 8, brief: false, agent: true, doc: "The full brief is re-sent to a vibemate after this many of its turns." },
|
|
26
|
+
fullBriefEveryTokens: { kind: "integer", min: 1000, max: 10_000_000, default: 20_000, brief: false, agent: true, doc: "The full brief is re-sent once a vibemate's context grew by this many tokens." },
|
|
27
|
+
headerRules: { kind: "boolean", default: true, brief: false, agent: true, doc: "The per-turn header repeats the three core rules (addressing, silent, character)." },
|
|
28
|
+
replayAfterRestart: { kind: "integer", min: 0, max: 200, default: 10, brief: false, agent: true, doc: "Messages replayed to a vibemate whose session restarts." },
|
|
29
|
+
backlogCap: { kind: "integer", min: 1, max: 1000, default: 50, brief: false, agent: true, doc: "Most missed messages a vibemate reads on its next turn; older ones are dropped with a note." },
|
|
30
|
+
showVendorInRoster: { kind: "boolean", default: false, brief: true, agent: true, doc: "The roster in the brief names each vibemate's vendor (Claude, Codex, ...)." },
|
|
31
|
+
customRules: { kind: "text", max: 4000, default: "", brief: true, agent: true, doc: "The room rules, one per line; every vibemate gets them under 'Room rules (set by the human)'. @Name inside a rule is a live reference." },
|
|
32
|
+
refereeAction: { kind: "enum", values: ["next-header", "retry-hidden"], default: "next-header", brief: false, agent: true, doc: "On a mechanical violation (wrong language, too long): remind in the next header, or hold the reply and ask for a corrected one in a hidden turn." },
|
|
33
|
+
turnTaking: { kind: "enum", values: ["parallel", "one-at-a-time"], default: "parallel", brief: false, agent: true, doc: "parallel: every addressed vibemate answers at once; one-at-a-time: one speaks, the others queue and see the earlier replies first." },
|
|
34
|
+
waitWhileHumanTypes: { kind: "boolean", default: true, brief: false, agent: true, doc: "A vibemate about to start a turn waits while the human is typing." },
|
|
35
|
+
agentsWakeEachOther: { kind: "boolean", default: true, brief: true, agent: true, doc: "A vibemate's message without @ wakes the others, as the human's does; off: only @Name wakes a vibemate." },
|
|
36
|
+
replyDelay: { kind: "number", min: 0, max: 120, default: 4, brief: false, agent: true, doc: "Seconds (a random 0..N) every vibemate waits before a turn, so replies cross less; a vibemate's own delay overrides it." },
|
|
33
37
|
};
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
function defaultsFromSpec() {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const [key, spec] of Object.entries(ROOM_SETTINGS_SPEC))
|
|
41
|
+
if (spec.kind !== "own-path")
|
|
42
|
+
out[key] = spec.default;
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
export const DEFAULT_ROOM_SETTINGS = defaultsFromSpec();
|
|
46
|
+
export const BRIEF_AFFECTING_SETTINGS = Object.keys(ROOM_SETTINGS_SPEC).filter((key) => ROOM_SETTINGS_SPEC[key].brief);
|
|
47
|
+
export const AGENT_SETTINGS = Object.keys(ROOM_SETTINGS_SPEC).filter((key) => ROOM_SETTINGS_SPEC[key].agent);
|
|
48
|
+
export function coerceSetting(key, raw) {
|
|
49
|
+
const spec = ROOM_SETTINGS_SPEC[key];
|
|
50
|
+
switch (spec.kind) {
|
|
51
|
+
case "own-path":
|
|
52
|
+
throw new Error(`${key} is not a settings field`);
|
|
53
|
+
case "integer": {
|
|
54
|
+
const value = Number(raw);
|
|
55
|
+
if (!Number.isInteger(value) || value < spec.min || value > spec.max)
|
|
56
|
+
throw new Error(`${key} must be an integer between ${spec.min} and ${spec.max}`);
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
case "integer-or-null": {
|
|
60
|
+
const value = raw === null || raw === "" ? null : Number(raw);
|
|
61
|
+
if (value !== null && (!Number.isInteger(value) || value < spec.min || value > spec.max))
|
|
62
|
+
throw new Error(`${key} must be ${spec.min}-${spec.max} or empty`);
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
case "number": {
|
|
66
|
+
const value = Number(raw);
|
|
67
|
+
if (!Number.isFinite(value) || value < spec.min || value > spec.max)
|
|
68
|
+
throw new Error(`${key} must be between ${spec.min} and ${spec.max} seconds`);
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
case "boolean":
|
|
72
|
+
return (raw === true || raw === "true");
|
|
73
|
+
case "enum": {
|
|
74
|
+
const value = String(raw);
|
|
75
|
+
if (!spec.values.includes(value))
|
|
76
|
+
throw new Error(`${key} must be ${spec.values.join(" or ")}`);
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
case "text":
|
|
80
|
+
return String(raw).slice(0, spec.max);
|
|
81
|
+
case "language": {
|
|
82
|
+
if (raw && typeof raw === "object") {
|
|
83
|
+
const o = raw;
|
|
84
|
+
if (o.mode === "fixed" && typeof o.language === "string" && o.language.trim())
|
|
85
|
+
return { mode: "fixed", language: o.language.trim() };
|
|
86
|
+
return { mode: "follow-human" };
|
|
87
|
+
}
|
|
88
|
+
const text = String(raw ?? "").trim();
|
|
89
|
+
return (!text || text === "follow-human" ? { mode: "follow-human" } : { mode: "fixed", language: text });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function describeSettings(current) {
|
|
94
|
+
return AGENT_SETTINGS.map((key) => {
|
|
95
|
+
const spec = ROOM_SETTINGS_SPEC[key];
|
|
96
|
+
const range = spec.kind === "integer" || spec.kind === "number"
|
|
97
|
+
? `${spec.min}..${spec.max}`
|
|
98
|
+
: spec.kind === "integer-or-null"
|
|
99
|
+
? `${spec.min}..${spec.max} or null`
|
|
100
|
+
: spec.kind === "enum"
|
|
101
|
+
? spec.values.join(" | ")
|
|
102
|
+
: spec.kind === "text"
|
|
103
|
+
? `up to ${spec.max} characters`
|
|
104
|
+
: spec.kind === "language"
|
|
105
|
+
? '"follow-human" or a language name'
|
|
106
|
+
: undefined;
|
|
107
|
+
return { key, doc: spec.doc, kind: spec.kind, range, default: spec.kind === "own-path" ? undefined : spec.default, value: current[key], affectsBrief: spec.brief };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
45
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;
|
|
46
113
|
export function promptText(parts) {
|
|
47
114
|
return parts.map((p) => (p.type === "text" ? p.text : "")).join("");
|
|
48
115
|
}
|
|
@@ -52,6 +119,18 @@ function imageMarker(image) {
|
|
|
52
119
|
const who = image.forNames.length ? ` · for ${image.forNames.join(", ")}` : "";
|
|
53
120
|
return `[img ${image.n} · ${image.ref}${who} · ${image.path}]`;
|
|
54
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
|
+
}
|
|
55
134
|
function messageParts(line) {
|
|
56
135
|
const parts = [];
|
|
57
136
|
let text = "";
|
|
@@ -60,32 +139,70 @@ function messageParts(line) {
|
|
|
60
139
|
parts.push({ type: "text", text });
|
|
61
140
|
text = "";
|
|
62
141
|
};
|
|
63
|
-
const
|
|
64
|
-
|
|
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));
|
|
65
160
|
if (image.attached) {
|
|
66
161
|
flush();
|
|
67
162
|
parts.push({ type: "image", image });
|
|
68
163
|
}
|
|
69
164
|
};
|
|
70
|
-
const
|
|
71
|
-
|
|
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
|
+
};
|
|
72
174
|
let last = 0;
|
|
73
|
-
for (const match of line.text.matchAll(
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
}
|
|
80
193
|
last = (match.index ?? 0) + match[0].length;
|
|
81
194
|
}
|
|
82
|
-
|
|
195
|
+
append(line.text.slice(last));
|
|
196
|
+
for (const quote of quotes)
|
|
197
|
+
if (!placedQuotes.has(quote.n))
|
|
198
|
+
placeQuote(quote);
|
|
199
|
+
breakAfterQuote = false;
|
|
83
200
|
for (const image of images) {
|
|
84
|
-
if (
|
|
201
|
+
if (placedImages.has(image.n))
|
|
85
202
|
continue;
|
|
86
203
|
if (!text.endsWith("\n") && (text || parts.length))
|
|
87
204
|
text += "\n";
|
|
88
|
-
|
|
205
|
+
placeImage(image);
|
|
89
206
|
}
|
|
90
207
|
flush();
|
|
91
208
|
return parts;
|
|
@@ -129,6 +246,7 @@ function skillsSection(skills) {
|
|
|
129
246
|
}
|
|
130
247
|
if (skills.canCreate) {
|
|
131
248
|
lines.push(`You may also create skills for the shared library when a procedure is worth reusing (by you later, or by other agents): first load the built-in skill "${SKILL_WRITER_NAME}" with the viberoom ${SKILL_TOOL_NAME} tool for the rules of a good skill, then call the viberoom tools create_skill (name, description, instructions) and attach_skill to give it to yourself or to other agents. These are MCP tools of the "viberoom" server, not your own skill commands. The human sees every new skill in Settings.`);
|
|
249
|
+
lines.push(`You may also design rooms: load the built-in skill "${ROOM_DESIGNER_NAME}" first, then describe_room for the facts, lint_room_design to check a draft (it previews the brief the vibemates would read), create_template to save a template the human can pick under New room, and propose_room_changes to suggest a change to this room: it becomes a card the human applies or rejects, so nothing here changes without their click.`);
|
|
132
250
|
}
|
|
133
251
|
else if (skills.items.length) {
|
|
134
252
|
lines.push("Skills are created by the human or by agents that have the hub's tools; if you want a new one, describe it in the room.");
|
|
@@ -181,6 +299,9 @@ export function buildBrief(settings, persona, roster, previousNotes, skills) {
|
|
|
181
299
|
lines.push(...skillsSection(skills));
|
|
182
300
|
lines.push("");
|
|
183
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."}`);
|
|
184
305
|
if (previousNotes && previousNotes.trim()) {
|
|
185
306
|
lines.push("");
|
|
186
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
|
+
}
|
package/dist/recipes.js
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { loginState } from "./agent-health.js";
|
|
8
9
|
const isWindows = process.platform === "win32";
|
|
9
10
|
function resolvePackageEntry(packageName, relativeEntry) {
|
|
10
11
|
try {
|
|
@@ -167,6 +168,9 @@ const recipes = [
|
|
|
167
168
|
unavailableReason: claudeExe ? null : "Claude Code not found",
|
|
168
169
|
installedAt: claudeExe,
|
|
169
170
|
installHint: "install Claude Code (npm install -g @anthropic-ai/claude-code, or the native installer) and log in with `claude`",
|
|
171
|
+
loginCommand: "claude",
|
|
172
|
+
login: { env: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"], files: [".claude/.credentials.json"], command: "claude", fileless: ["darwin"] },
|
|
173
|
+
loginState: "unknown",
|
|
170
174
|
build: ({ model }) => ({
|
|
171
175
|
command: process.execPath,
|
|
172
176
|
args: [claudeAdapter],
|
|
@@ -190,6 +194,9 @@ const recipes = [
|
|
|
190
194
|
unavailableReason: codexExe ? null : "Codex CLI not found",
|
|
191
195
|
installedAt: codexExe,
|
|
192
196
|
installHint: "install Codex (npm install -g @openai/codex) and log in with `codex login`",
|
|
197
|
+
loginCommand: "codex login",
|
|
198
|
+
login: { env: ["CODEX_API_KEY", "OPENAI_API_KEY"], files: [".codex/auth.json"], command: "codex login" },
|
|
199
|
+
loginState: "unknown",
|
|
193
200
|
build: () => ({
|
|
194
201
|
command: process.execPath,
|
|
195
202
|
args: [codexAdapter],
|
|
@@ -213,6 +220,9 @@ const recipes = [
|
|
|
213
220
|
unavailableReason: geminiEntry ? null : "Gemini CLI not found",
|
|
214
221
|
installedAt: geminiEntry,
|
|
215
222
|
installHint: "npm install -g @google/gemini-cli, then sign in once (gemini)",
|
|
223
|
+
loginCommand: "gemini",
|
|
224
|
+
login: { env: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS"], files: [".gemini/google_accounts.json", ".gemini/oauth_creds.json"], command: "gemini" },
|
|
225
|
+
loginState: "unknown",
|
|
216
226
|
modelAtLaunch: true,
|
|
217
227
|
build: ({ model }) => ({
|
|
218
228
|
command: process.execPath,
|
|
@@ -237,6 +247,9 @@ const recipes = [
|
|
|
237
247
|
unavailableReason: cursorAgent ? null : "Cursor CLI not found",
|
|
238
248
|
installedAt: cursorAgent?.index ?? null,
|
|
239
249
|
installHint: "install cursor-agent (cursor.com/cli), then agent login",
|
|
250
|
+
loginCommand: "cursor-agent login",
|
|
251
|
+
login: { env: ["CURSOR_API_KEY"], files: [], command: "cursor-agent login" },
|
|
252
|
+
loginState: "unknown",
|
|
240
253
|
build: () => ({
|
|
241
254
|
command: cursorAgent?.node ?? "",
|
|
242
255
|
args: [cursorAgent?.index ?? "", "acp"],
|
|
@@ -260,6 +273,9 @@ const recipes = [
|
|
|
260
273
|
unavailableReason: openCodeExe ? null : "OpenCode not found",
|
|
261
274
|
installedAt: openCodeExe,
|
|
262
275
|
installHint: "npm install -g opencode-ai (or curl -fsSL https://opencode.ai/install | bash), then opencode providers",
|
|
276
|
+
loginCommand: "opencode auth login",
|
|
277
|
+
login: { env: [], files: [".local/share/opencode/auth.json", "AppData/Local/opencode/auth.json", ".config/opencode/auth.json"], command: "opencode auth login" },
|
|
278
|
+
loginState: "unknown",
|
|
263
279
|
build: () => ({
|
|
264
280
|
command: openCodeExe ?? "",
|
|
265
281
|
args: ["acp"],
|
|
@@ -286,6 +302,9 @@ const recipes = [
|
|
|
286
302
|
unavailableReason: copilotExe ? null : "GitHub Copilot CLI not found",
|
|
287
303
|
installedAt: copilotExe,
|
|
288
304
|
installHint: "winget install GitHub.Copilot / brew install copilot-cli / npm install -g @github/copilot, then copilot login",
|
|
305
|
+
loginCommand: "copilot",
|
|
306
|
+
login: { env: ["GITHUB_TOKEN", "GH_TOKEN", "COPILOT_API_KEY"], files: [], command: "copilot" },
|
|
307
|
+
loginState: "unknown",
|
|
289
308
|
modelAtLaunch: true,
|
|
290
309
|
build: ({ model }) => ({
|
|
291
310
|
command: copilotExe ?? "",
|
|
@@ -311,13 +330,21 @@ if (fakeAgent) {
|
|
|
311
330
|
unavailableReason: null,
|
|
312
331
|
installedAt: fakeAgent,
|
|
313
332
|
installHint: "",
|
|
333
|
+
loginCommand: "",
|
|
334
|
+
loginState: "ok",
|
|
314
335
|
bypassMode: null,
|
|
315
336
|
build: () => ({ command: process.execPath, args: [fakeAgent], env: {} }),
|
|
316
337
|
});
|
|
317
338
|
}
|
|
339
|
+
function loginEvidence() {
|
|
340
|
+
return { env: process.env, platform: process.platform, exists: (relative) => existsSync(join(homedir(), ...relative.split("/"))) };
|
|
341
|
+
}
|
|
318
342
|
export function listRecipes() {
|
|
343
|
+
const evidence = loginEvidence();
|
|
344
|
+
for (const recipe of recipes)
|
|
345
|
+
recipe.loginState = recipe.unavailableReason ? "unknown" : loginState(recipe.login, evidence);
|
|
319
346
|
return recipes;
|
|
320
347
|
}
|
|
321
348
|
export function getRecipe(id) {
|
|
322
|
-
return
|
|
349
|
+
return listRecipes().find((r) => r.id === id);
|
|
323
350
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
import { AGENT_SETTINGS, DEFAULT_ROOM_SETTINGS, NAME_PATTERN, ROOM_SETTINGS_SPEC, SILENT_MARKER, REQUEST_BRIEF_MARKER, buildBrief, coerceSetting } from "./persona.js";
|
|
3
|
+
export const TAGLINE_MAX = 80;
|
|
4
|
+
export const ROLE_MAX = 4000;
|
|
5
|
+
export const AVATAR_MAX = 8;
|
|
6
|
+
export const RULES_SOFT_MAX = 12;
|
|
7
|
+
export const ROLE_SOFT_MAX = 1200;
|
|
8
|
+
export const RULES_NEAR_LIMIT = 3000;
|
|
9
|
+
export function ruleLines(customRules) {
|
|
10
|
+
return customRules
|
|
11
|
+
.split(/\r?\n/)
|
|
12
|
+
.map((l) => l.trim().replace(/^[-*•]\s*/, ""))
|
|
13
|
+
.filter((l) => l.length > 0);
|
|
14
|
+
}
|
|
15
|
+
export const BRIEF_MECHANICS = [
|
|
16
|
+
{ pattern: /\[silent\]/i, what: `the ${SILENT_MARKER} reply` },
|
|
17
|
+
{ pattern: /\[request-brief\]/i, what: `the ${REQUEST_BRIEF_MARKER} reply` },
|
|
18
|
+
{ pattern: /\bmarkdown\b|\bmermaid\b/i, what: "the Markdown / mermaid format" },
|
|
19
|
+
{ pattern: /\buse @\w*\s*to address\b|\baddress .{0,20}with @/i, what: "how @Name addressing works" },
|
|
20
|
+
{ pattern: /\bstay in character\b/i, what: "staying in character" },
|
|
21
|
+
];
|
|
22
|
+
export function lintRoomDesign(design, context) {
|
|
23
|
+
const errors = [];
|
|
24
|
+
const warnings = [];
|
|
25
|
+
const error = (code, message) => errors.push({ code, message });
|
|
26
|
+
const warn = (code, message) => warnings.push({ code, message });
|
|
27
|
+
const base = context.base ?? { ...DEFAULT_ROOM_SETTINGS, name: (design.name ?? context.roomName ?? "Room").trim() || "Room", humanName: context.humanName };
|
|
28
|
+
const settings = { ...base };
|
|
29
|
+
const raw = design.settings ?? {};
|
|
30
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
continue;
|
|
33
|
+
if (!(key in ROOM_SETTINGS_SPEC)) {
|
|
34
|
+
error("unknown-setting", `unknown setting "${key}" (known: ${AGENT_SETTINGS.join(", ")})`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const k = key;
|
|
38
|
+
if (!AGENT_SETTINGS.includes(k)) {
|
|
39
|
+
error("setting-not-yours", `"${key}" is set by the human, not through a design`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
settings[k] = coerceSetting(k, value);
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
error("invalid-setting", e instanceof Error ? e.message : String(e));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (design.emoji !== undefined && !settings.emoji)
|
|
50
|
+
settings.emoji = String(design.emoji).trim().slice(0, AVATAR_MAX);
|
|
51
|
+
const vibemates = design.vibemates ?? [];
|
|
52
|
+
if (context.kind === "template") {
|
|
53
|
+
if (!(design.name ?? "").trim())
|
|
54
|
+
error("template-no-name", "a template needs a name");
|
|
55
|
+
if (!(design.description ?? "").trim())
|
|
56
|
+
warn("template-no-description", "a template without a description is a blank card in the picker: say what the room is for and how it feels");
|
|
57
|
+
if (!vibemates.length)
|
|
58
|
+
error("template-no-vibemates", "a template needs at least one vibemate");
|
|
59
|
+
}
|
|
60
|
+
const seen = new Map();
|
|
61
|
+
const touched = context.changedVibemates ? new Set(context.changedVibemates.map((n) => n.trim().toLowerCase())) : null;
|
|
62
|
+
for (const [i, v] of vibemates.entries()) {
|
|
63
|
+
const who = v?.name?.trim() ? `"${v.name.trim()}"` : `vibemate ${i + 1}`;
|
|
64
|
+
const name = (v?.name ?? "").trim();
|
|
65
|
+
const soft = !touched || touched.has(name.toLowerCase());
|
|
66
|
+
if (!name)
|
|
67
|
+
error("vibemate-no-name", `${who} has no name`);
|
|
68
|
+
else if (!NAME_PATTERN.test(name))
|
|
69
|
+
error("vibemate-bad-name", `${who}: a name is 1-24 letters, digits, _ or -, no spaces`);
|
|
70
|
+
else {
|
|
71
|
+
const lower = name.toLowerCase();
|
|
72
|
+
if (seen.has(lower))
|
|
73
|
+
error("vibemate-duplicate-name", `two vibemates are named ${who}`);
|
|
74
|
+
seen.set(lower, i);
|
|
75
|
+
if (lower === context.humanName.trim().toLowerCase())
|
|
76
|
+
error("vibemate-human-name", `${who} is the human's name`);
|
|
77
|
+
}
|
|
78
|
+
if ((v?.tagline ?? "").length > TAGLINE_MAX)
|
|
79
|
+
error("tagline-too-long", `${who}: the tagline is at most ${TAGLINE_MAX} characters (it is the one line the others see)`);
|
|
80
|
+
else if (soft && !(v?.tagline ?? "").trim())
|
|
81
|
+
warn("vibemate-no-tagline", `${who} has no tagline: the others read it in the roster to know what this one leans to`);
|
|
82
|
+
if ((v?.role ?? "").length > ROLE_MAX)
|
|
83
|
+
error("role-too-long", `${who}: the role is at most ${ROLE_MAX} characters`);
|
|
84
|
+
else if (soft && !(v?.role ?? "").trim())
|
|
85
|
+
warn("vibemate-no-role", `${who} has no role: without one it is the agent's default self, not a character`);
|
|
86
|
+
else if (soft && (v.role ?? "").length > ROLE_SOFT_MAX)
|
|
87
|
+
warn("role-restates-rules", `${who}: a role of ${v.role.length} characters is probably restating the protocol; a role says who this one is and which way it leans, the rules say how they work together`);
|
|
88
|
+
if ((v?.avatar ?? "").length > AVATAR_MAX)
|
|
89
|
+
error("avatar-too-long", `${who}: the avatar is one emoji`);
|
|
90
|
+
if (context.knownSkills) {
|
|
91
|
+
for (const skill of v?.skills ?? [])
|
|
92
|
+
if (!context.knownSkills.includes(skill))
|
|
93
|
+
error("unknown-skill", `${who}: no skill named "${skill}" in the library`);
|
|
94
|
+
}
|
|
95
|
+
if (context.kind === "template" && (v?.agentType || v?.model))
|
|
96
|
+
warn("vendor-pinned", `${who} names an agent or model: a template cannot know what this machine has, so they are suggestions at best; leave them out unless the design depends on a capability tier`);
|
|
97
|
+
}
|
|
98
|
+
const initials = new Map();
|
|
99
|
+
for (const v of vibemates) {
|
|
100
|
+
const name = (v?.name ?? "").trim();
|
|
101
|
+
if (!name)
|
|
102
|
+
continue;
|
|
103
|
+
const initial = name[0].toLowerCase();
|
|
104
|
+
initials.set(initial, [...(initials.get(initial) ?? []), name]);
|
|
105
|
+
}
|
|
106
|
+
for (const names of initials.values())
|
|
107
|
+
if (names.length > 1)
|
|
108
|
+
warn("names-share-initial", `${names.join(" and ")} start with the same letter: @-autocomplete and the human's eye tell them apart slower`);
|
|
109
|
+
const rules = ruleLines(settings.customRules);
|
|
110
|
+
if (rules.length > RULES_SOFT_MAX)
|
|
111
|
+
warn("too-many-rules", `${rules.length} rules: every vibemate carries them on every turn; ${RULES_SOFT_MAX} is a full protocol, longer belongs in a skill`);
|
|
112
|
+
if (settings.customRules.length > RULES_NEAR_LIMIT)
|
|
113
|
+
warn("rules-near-limit", `the rules are ${settings.customRules.length} characters; the hub stores at most 4000`);
|
|
114
|
+
for (const rule of rules) {
|
|
115
|
+
for (const m of BRIEF_MECHANICS)
|
|
116
|
+
if (m.pattern.test(rule))
|
|
117
|
+
warn("rule-repeats-brief", `the brief already explains ${m.what}; the rule "${rule.slice(0, 60)}${rule.length > 60 ? "…" : ""}" repeats it`);
|
|
118
|
+
}
|
|
119
|
+
if (vibemates.length >= 2 && !rules.length)
|
|
120
|
+
warn("no-rules", "two or more vibemates and no rules: name the situations this room will meet and answer them, for example who takes a task that names nobody, and when one of them stays quiet");
|
|
121
|
+
if (vibemates.length >= 2 && settings.agentsWakeEachOther && settings.hopLimit < 3 * vibemates.length)
|
|
122
|
+
warn("hop-limit-low", `hopLimit ${settings.hopLimit} with ${vibemates.length} vibemates who wake each other: one exchange around the room already uses ${vibemates.length}; about ${3 * vibemates.length} or more lets a handoff finish`);
|
|
123
|
+
if (errors.length)
|
|
124
|
+
return { errors, warnings };
|
|
125
|
+
const first = vibemates[0];
|
|
126
|
+
const persona = first ? { name: first.name.trim(), tagline: (first.tagline ?? "").trim(), role: (first.role ?? "").trim() } : { name: "Vibemate", tagline: "", role: "" };
|
|
127
|
+
const roster = [{ name: context.humanName, kind: "human" }, ...vibemates.map((v) => ({ name: v.name.trim(), kind: "agent", tagline: (v.tagline ?? "").trim() || undefined }))];
|
|
128
|
+
const skills = context.skills && {
|
|
129
|
+
items: (first?.skills ?? []).map((name) => context.skills.library.find((s) => s.name === name)).filter((s) => !!s),
|
|
130
|
+
channel: context.skills.channel,
|
|
131
|
+
canCreate: context.skills.canCreate,
|
|
132
|
+
};
|
|
133
|
+
return { errors, warnings, settings, preview: buildBrief(settings, persona, roster, undefined, skills) };
|
|
134
|
+
}
|
|
135
|
+
export function applyVibemateChanges(current, changes) {
|
|
136
|
+
const next = current.map((v) => ({ ...v }));
|
|
137
|
+
const ops = [];
|
|
138
|
+
const errors = [];
|
|
139
|
+
const find = (name) => next.findIndex((v) => v.name.toLowerCase() === name.trim().toLowerCase());
|
|
140
|
+
for (const name of changes?.remove ?? []) {
|
|
141
|
+
const i = find(String(name));
|
|
142
|
+
if (i < 0)
|
|
143
|
+
errors.push(`no vibemate named "${name}" to remove`);
|
|
144
|
+
else {
|
|
145
|
+
ops.push({ op: "remove", name: next[i].name });
|
|
146
|
+
next.splice(i, 1);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const u of changes?.update ?? []) {
|
|
150
|
+
const i = find(String(u?.name ?? ""));
|
|
151
|
+
if (i < 0) {
|
|
152
|
+
errors.push(`no vibemate named "${u?.name}" to update`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const before = next[i];
|
|
156
|
+
const after = { ...before };
|
|
157
|
+
const fields = [];
|
|
158
|
+
const set = (field, value) => {
|
|
159
|
+
if (value === undefined)
|
|
160
|
+
return;
|
|
161
|
+
const from = before[field];
|
|
162
|
+
const fromText = Array.isArray(from) ? from.join(", ") : String(from ?? "");
|
|
163
|
+
const toText = Array.isArray(value) ? value.join(", ") : String(value ?? "");
|
|
164
|
+
if (fromText === toText)
|
|
165
|
+
return;
|
|
166
|
+
after[field] = value;
|
|
167
|
+
fields.push({ field, from: fromText, to: toText });
|
|
168
|
+
};
|
|
169
|
+
if (u.newName !== undefined)
|
|
170
|
+
set("name", String(u.newName).trim());
|
|
171
|
+
set("tagline", u.tagline);
|
|
172
|
+
set("role", u.role);
|
|
173
|
+
set("avatar", u.avatar);
|
|
174
|
+
set("skills", u.skills);
|
|
175
|
+
set("replyDelay", u.replyDelay);
|
|
176
|
+
if (fields.length) {
|
|
177
|
+
next[i] = after;
|
|
178
|
+
ops.push({ op: "update", name: before.name, fields });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const a of changes?.add ?? []) {
|
|
182
|
+
const v = { name: String(a?.name ?? "").trim() };
|
|
183
|
+
if (a?.tagline)
|
|
184
|
+
v.tagline = a.tagline;
|
|
185
|
+
if (a?.role)
|
|
186
|
+
v.role = a.role;
|
|
187
|
+
if (a?.avatar)
|
|
188
|
+
v.avatar = a.avatar;
|
|
189
|
+
if (a?.skills)
|
|
190
|
+
v.skills = a.skills;
|
|
191
|
+
if (typeof a?.replyDelay === "number")
|
|
192
|
+
v.replyDelay = a.replyDelay;
|
|
193
|
+
next.push(v);
|
|
194
|
+
ops.push({ op: "add", name: v.name });
|
|
195
|
+
}
|
|
196
|
+
return { next, ops, errors };
|
|
197
|
+
}
|
|
198
|
+
export function diffSettings(current, next) {
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const key of AGENT_SETTINGS) {
|
|
201
|
+
if (JSON.stringify(current[key]) !== JSON.stringify(next[key]))
|
|
202
|
+
out.push({ key, from: current[key], to: next[key] });
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|