clavarium 0.2.368-next.1 → 0.2.370-next.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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.368-next.1",
3
+ "version": "0.2.370-next.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.368-next.1",
9
+ "version": "0.2.370-next.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.368-next.1",
3
+ "version": "0.2.370-next.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
@@ -17,6 +17,20 @@
17
17
  * A conversation archived takes its lines with it (devices.forget). Its
18
18
  * terminal ("x") is never given to a key.
19
19
  *
20
+ * a room, given ONE BY ONE the same way:
21
+ * read read its thread, as the page reads it (names, never ids;
22
+ * secrets masked) room:<id> r
23
+ * talk and post in it as the person does -- to its host, or to the AIs
24
+ * named -- signed with the key's name and look room:<id> rw
25
+ *
26
+ * a voice box, given ONE BY ONE:
27
+ * state online, where it speaks, when it last heard, its volume --
28
+ * nothing it heard, never audio box:<id> r
29
+ * speak and make it say a short text aloud (SPEAK_MAX characters,
30
+ * SPEAK_PER_MIN a minute per key and box): never listen, record,
31
+ * change its settings nor update it box:<id> rw
32
+ * A room removed, a box withdrawn, takes its lines with it (main.mjs).
33
+ *
20
34
  * THE SECRET IS SHOWN ONCE. It is made here (32 random bytes, "clv_" before
21
35
  * them), given back by `create` and never again: only its sha256 is kept, as
22
36
  * a pairing's session is (devices.mjs). A 256-bit random secret needs no salt
@@ -40,6 +54,9 @@
40
54
  * POST /api/apikeys {label, scopes, conversations: {name: 'read'|'talk'}, days|null}
41
55
  * -> {key, secret}: the secret this once
42
56
  * POST /api/apikeys/<id>/conversations {conversation, level: 'read'|'talk'|null}: one given, changed, taken back
57
+ * POST /api/apikeys/<id>/rooms {room, level: 'read'|'talk'|null}
58
+ * POST /api/apikeys/<id>/boxes {box, level: 'state'|'speak'|null}
59
+ * (and on creation: rooms: {id: level}, boxes: {id: level})
43
60
  * POST /api/apikeys/<id>/revoke
44
61
  * GET /api/apikeys/<id>/journal its last uses
45
62
  *
@@ -48,9 +65,15 @@
48
65
  * GET /api/ext/v1/conversations those it may read: {conversations: [{id, name, level, chat}]}
49
66
  * GET /api/ext/v1/conversations/<name>/messages ?last=1..200 (50) | ?since=<cursor>: {chat, messages, cursor}
50
67
  * POST /api/ext/v1/conversations/<name>/messages {text}: posted in its chat, signed with the key's name
68
+ * GET /api/ext/v1/rooms those it may read: {rooms: [{id, name, level, host, members}]}
69
+ * GET /api/ext/v1/rooms/<id>/messages as a conversation's: {messages, cursor}
70
+ * POST /api/ext/v1/rooms/<id>/messages {text, to?: [names of its AIs]}: posted in the room, signed with the key
71
+ * GET /api/ext/v1/boxes {boxes: [{id, name, level, online, where, heardAt, volume}]}
72
+ * POST /api/ext/v1/boxes/<id>/say {text}: said aloud by the box
51
73
  * POST /api/ext/mcp the same as MCP tools (clavarium_status, clavarium_conversations,
52
- * clavarium_read, clavarium_say), JSON-RPC over HTTP
53
- * A conversation not given, or not there, is the same 403: a key learns nothing of what it was not given.
74
+ * clavarium_read, clavarium_say, clavarium_rooms, clavarium_room_read,
75
+ * clavarium_room_say, clavarium_boxes, clavarium_box_say), JSON-RPC over HTTP
76
+ * A conversation, room or box not given, or not there, is the same 403: a key learns nothing of what it was not given.
54
77
  *
55
78
  * The front lets /api/ext/ through without sign-on, as it does /c/
56
79
  * (deploy/install.sh, skip_auth_routes; deploy/updater.mjs for installs made
@@ -62,6 +85,7 @@ import { appendFileSync, readFileSync, writeFileSync, renameSync, statSync } fro
62
85
  import { Refusal } from './conversations.mjs';
63
86
  import { Grants, apiKeySubject } from './grants.mjs';
64
87
  import { isConversationName } from './names.mjs';
88
+ import { masked, lookOf } from './rooms.mjs';
65
89
 
66
90
  export const KEY_PREFIX = 'clv_';
67
91
  export const DEFAULT_DAYS = 90;
@@ -86,6 +110,14 @@ export const levelOf = (rights) => (String(rights).includes('w') ? 'talk' : Stri
86
110
  export const READ_DEFAULT = 50;
87
111
  export const READ_MAX = 200;
88
112
  export const SAY_MAX = 8000;
113
+ /* What a room and a voice box may be given: [rights in the table]. */
114
+ export const ROOM_LEVELS = Object.freeze({ read: 'r', talk: 'rw' });
115
+ export const BOX_LEVELS = Object.freeze({ state: 'r', speak: 'rw' });
116
+ export const boxLevelOf = (rights) => (String(rights).includes('w') ? 'speak' : String(rights).includes('r') ? 'state' : null);
117
+ /* Said aloud in the home: short, and a few a minute (per key and box). */
118
+ export const SPEAK_MAX = 300;
119
+ export const SPEAK_PER_MIN = 3;
120
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
89
121
 
90
122
  const digest = (text) => createHash('sha256').update(text).digest();
91
123
  const oneLine = (value) => typeof value === 'string' && value.trim() !== '' && value.trim().length <= LABEL_MAX
@@ -93,19 +125,25 @@ const oneLine = (value) => typeof value === 'string' && value.trim() !== '' && v
93
125
  const SECRET = /^clv_[A-Za-z0-9_-]{43}$/;
94
126
 
95
127
  export class ApiKeys {
96
- #store; #grants; #now; #journal; #exists;
128
+ #store; #grants; #now; #journal; #exists; #kinds;
97
129
  #used = new Map(); /* id -> { at, savedAt } */
98
130
  #calls = new Map(); /* id ('' for unknown keys) -> [times of the last minute] */
99
131
 
100
- /* `exists(name)`: whether a conversation is there (one is given only then). */
101
- constructor({ store, grants = new Grants(), journal = null, now = () => Date.now(), exists = () => true }) {
132
+ /* `exists(name)`: whether a conversation is there (one is given only then); `roomExists(id)`,
133
+ `boxExists(id)`: the same for a room, a voice box (none, without them). */
134
+ constructor({ store, grants = new Grants(), journal = null, now = () => Date.now(), exists = () => true, roomExists = () => false, boxExists = () => false }) {
102
135
  this.#store = store; this.#grants = grants; this.#journal = journal; this.#now = now; this.#exists = exists;
136
+ /* What is given one by one: its asset type, its levels, a valid name, whether it is there, its words. */
137
+ this.#kinds = {
138
+ room: { levels: ROOM_LEVELS, of: levelOf, valid: (v) => typeof v === 'string' && UUID.test(v), exists: roomExists, what: 'room', the: 'a room' },
139
+ box: { levels: BOX_LEVELS, of: boxLevelOf, valid: (v) => typeof v === 'string' && UUID.test(v), exists: boxExists, what: 'voice box', the: 'a voice box' },
140
+ };
103
141
  }
104
142
 
105
143
  #scopesOf(id) { return Object.entries(SCOPES).filter(([, s]) => this.#grants.can(apiKeySubject(id), s.asset, s.rights)).map(([name]) => name); }
106
144
  #shown(k) {
107
145
  const used = this.#used.get(k.name)?.at ?? k.usedAt ?? null;
108
- return { id: k.name, label: k.label, scopes: this.#scopesOf(k.name), conversations: this.conversationsOf(k.name), createdBy: k.createdBy ?? null, createdAt: k.createdAt, expiresAt: k.expiresAt ?? null,
146
+ return { id: k.name, label: k.label, scopes: this.#scopesOf(k.name), conversations: this.conversationsOf(k.name), rooms: this.roomsOf(k.name), boxes: this.boxesOf(k.name), createdBy: k.createdBy ?? null, createdAt: k.createdAt, expiresAt: k.expiresAt ?? null,
109
147
  usedAt: used, expired: Boolean(k.expiresAt && Date.parse(k.expiresAt) <= this.#now()) };
110
148
  }
111
149
 
@@ -119,6 +157,34 @@ export class ApiKeys {
119
157
  .sort((a, b) => a.name.localeCompare(b.name));
120
158
  }
121
159
 
160
+ /* The rooms, the voice boxes a key was given, each with its level: [{ id, level }] -- only those still there. */
161
+ #givenOf(id, type) {
162
+ const k = this.#kinds[type];
163
+ return this.#grants.of(apiKeySubject(id), type).map((g) => ({ id: g.name, level: k.of(g.rights) })).filter((x) => x.level && k.exists(x.id))
164
+ .sort((a, b) => a.id.localeCompare(b.id));
165
+ }
166
+ roomsOf(id) { return this.#givenOf(id, 'room'); }
167
+ boxesOf(id) { return this.#givenOf(id, 'box'); }
168
+ #oneAsked(type, ref, level) {
169
+ const k = this.#kinds[type];
170
+ if (!k.valid(ref) || !k.exists(ref)) throw new Refusal(404, `No such ${k.what}.`);
171
+ if (level !== null && !Object.hasOwn(k.levels, level)) throw new Refusal(400, `${k.the[0].toUpperCase()}${k.the.slice(1)} is given at one of: ${Object.keys(k.levels).join(', ')}.`);
172
+ }
173
+ #givenAll(type, given) {
174
+ if (!given || typeof given !== 'object' || Array.isArray(given)) throw new Refusal(400, `${type === 'room' ? 'Rooms' : 'Voice boxes'} are given by id: {id: level}.`);
175
+ for (const [ref, level] of Object.entries(given)) this.#oneAsked(type, ref, level);
176
+ }
177
+ /* One room or voice box given to a key, its level changed, or taken back (level null). */
178
+ #setOne(type, id, ref, level) {
179
+ if (typeof id !== 'string' || !this.#store.get(id)) throw new Refusal(404, 'No such key.');
180
+ this.#oneAsked(type, ref, level);
181
+ if (level === null) this.#grants.take(apiKeySubject(id), `${type}:${ref}`);
182
+ else this.#grants.give(apiKeySubject(id), `${type}:${ref}`, this.#kinds[type].levels[level]);
183
+ return this.get(id);
184
+ }
185
+ setRoom(id, room, level) { return this.#setOne('room', id, room, level); }
186
+ setBox(id, box, level) { return this.#setOne('box', id, box, level); }
187
+
122
188
  #conversationAsked(name, level) {
123
189
  if (!isConversationName(name) || !this.#exists(name)) throw new Refusal(404, 'No such conversation.');
124
190
  if (level !== null && !Object.hasOwn(LEVELS, level)) throw new Refusal(400, `A conversation is given to read or to talk: ${Object.keys(LEVELS).join(', ')}.`);
@@ -135,11 +201,12 @@ export class ApiKeys {
135
201
 
136
202
  /* A new key: its secret is in the answer, this once. `days`: its life (null: it does not expire).
137
203
  `conversations`: { name: 'read' | 'talk' }, each named -- there is no "all". */
138
- create({ label, scopes = [], conversations = {}, days = DEFAULT_DAYS } = {}, who = '') {
204
+ create({ label, scopes = [], conversations = {}, rooms = {}, boxes = {}, days = DEFAULT_DAYS } = {}, who = '') {
139
205
  if (!oneLine(label)) throw new Refusal(400, `A key has a name: one line, ${LABEL_MAX} characters at most.`);
140
206
  if (!Array.isArray(scopes) || scopes.some((s) => !Object.hasOwn(SCOPES, s))) throw new Refusal(400, `Scopes are among: ${Object.keys(SCOPES).join(', ')}.`);
141
207
  if (!conversations || typeof conversations !== 'object' || Array.isArray(conversations)) throw new Refusal(400, 'Conversations are given by name: {name: "read" | "talk"}.');
142
208
  for (const [name, level] of Object.entries(conversations)) this.#conversationAsked(name, level);
209
+ this.#givenAll('room', rooms); this.#givenAll('box', boxes);
143
210
  if (days !== null && !(Number.isInteger(days) && days >= 1 && days <= DAYS_MAX)) throw new Refusal(400, `A key lasts 1 to ${DAYS_MAX} days, or does not expire.`);
144
211
  if (this.#store.all().length >= KEYS_MAX) throw new Refusal(409, `At most ${KEYS_MAX} keys: revoke one first.`);
145
212
  const name = label.trim();
@@ -152,6 +219,8 @@ export class ApiKeys {
152
219
  this.#store.put(record);
153
220
  for (const s of new Set(scopes)) this.#grants.give(apiKeySubject(id), SCOPES[s].asset, SCOPES[s].rights);
154
221
  for (const [name, level] of Object.entries(conversations)) if (level !== null) this.#grants.give(apiKeySubject(id), `conversation:${name}`, LEVELS[level]);
222
+ for (const [ref, level] of Object.entries(rooms)) if (level !== null) this.#grants.give(apiKeySubject(id), `room:${ref}`, ROOM_LEVELS[level]);
223
+ for (const [ref, level] of Object.entries(boxes)) if (level !== null) this.#grants.give(apiKeySubject(id), `box:${ref}`, BOX_LEVELS[level]);
155
224
  return { key: this.#shown(record), secret };
156
225
  }
157
226
 
@@ -161,6 +230,7 @@ export class ApiKeys {
161
230
  this.#store.remove(id);
162
231
  this.#grants.forgetSubject(apiKeySubject(id));
163
232
  this.#used.delete(id); this.#calls.delete(id);
233
+ for (const c of [...this.#calls.keys()]) if (c.startsWith(`speak:${id}:`)) this.#calls.delete(c);
164
234
  }
165
235
 
166
236
  get(id) { const k = this.#store.get(id); return k ? this.#shown(k) : undefined; }
@@ -215,8 +285,31 @@ export class ApiKeys {
215
285
  return levelOf(rights);
216
286
  }
217
287
 
288
+ /* Whether a key (already checked) may read ('read') or post in ('talk') one room; 403 if not, the same for one
289
+ not given and one not there. */
290
+ mayRoom(id, room, level = 'read') {
291
+ const rights = typeof room === 'string' && UUID.test(room) && this.#kinds.room.exists(room) ? this.#grants.rightsOf(apiKeySubject(id), `room:${room}`) : '';
292
+ if (!rights.includes('r')) throw Object.assign(new Refusal(403, 'This key was not given this room.'), { key: id });
293
+ if (level === 'talk' && !rights.includes('w')) throw Object.assign(new Refusal(403, 'This key may read this room, not talk in it.'), { key: id });
294
+ return levelOf(rights);
295
+ }
296
+ /* Whether a key may see the state of ('state') or make speak ('speak') one voice box; 403 if not. */
297
+ mayBox(id, box, level = 'state') {
298
+ const rights = typeof box === 'string' && UUID.test(box) && this.#kinds.box.exists(box) ? this.#grants.rightsOf(apiKeySubject(id), `box:${box}`) : '';
299
+ if (!rights.includes('r')) throw Object.assign(new Refusal(403, 'This key was not given this voice box.'), { key: id });
300
+ if (level === 'speak' && !rights.includes('w')) throw Object.assign(new Refusal(403, 'This key may see this voice box, not make it speak.'), { key: id });
301
+ return boxLevelOf(rights);
302
+ }
303
+ /* One sentence said aloud, counted: SPEAK_PER_MIN a minute for this key and box, then 429. */
304
+ speakCounted(id, box) {
305
+ const wait = this.#counted(`speak:${id}:${box}`, SPEAK_PER_MIN);
306
+ if (wait) throw Object.assign(new Refusal(429, `Too many: ${SPEAK_PER_MIN} sentences a minute per voice box.`), { key: id, retry: wait });
307
+ }
308
+
218
309
  /* Its name, as its messages are signed in a chat (Parley's "by"). */
219
310
  signature(id) { const k = this.#store.get(id); return k ? { id: apiKeySubject(id), name: k.label, kind: 'ai', place: 'API key' } : null; }
311
+ /* In a room: its name and its look (the letter and colour the page draws it with). */
312
+ roomSignature(id) { const s = this.signature(id); return s ? { ...s, look: lookOf(s.name) } : null; }
220
313
 
221
314
  #seen(id) {
222
315
  const now = this.#now();
@@ -305,6 +398,23 @@ export function threadOf(read) {
305
398
  return { chat: true, messages, cursor: typeof read.cursor === 'string' ? read.cursor : null };
306
399
  }
307
400
 
401
+ /* A ROOM'S THREAD as a key reads it, as the page reads it: who said it by NAME (the person, an AI, a box, a key),
402
+ whom it is for by name, its words with any secret masked, a file by its name only. Never a member's id, a path,
403
+ nor what the page keeps for itself. `read`: what rooms.read gives. */
404
+ export function roomThreadOf(read) {
405
+ const messages = (Array.isArray(read?.messages) ? read.messages : []).filter((m) => m && (m.from === 'person' || m.from === 'agent')).map((m) => {
406
+ const files = [...(Array.isArray(m.files) ? m.files.map((f) => fileName(f?.name || f?.path)) : []), ...(m.kind === 'show' && m.path ? [fileName(m.path)] : [])].filter(Boolean).slice(0, 12);
407
+ const choices = Array.isArray(m.choices) ? m.choices.filter((c) => typeof c === 'string').slice(0, 8).map((c) => masked(c).slice(0, 200)) : [];
408
+ const to = Array.isArray(m.toNames) ? m.toNames.filter((x) => typeof x === 'string' && !UUID.test(x)).slice(0, 20).map((x) => x.slice(0, 80)) : [];
409
+ /* No name: the person (the room's own side), as in a conversation's thread. */
410
+ const by = typeof m.by?.name === 'string' && m.by.name ? m.by.name.slice(0, 80) : '';
411
+ return { id: String(m.id ?? '').slice(0, 80), at: String(m.at ?? '').slice(0, 40), from: m.from, kind: String(m.kind ?? 'say').slice(0, 20),
412
+ text: typeof m.text === 'string' ? masked(m.text).slice(0, TEXT_MAX) : '', ...(by ? { by } : {}),
413
+ ...(to.length ? { to } : {}), ...(m.toYou ? { toPerson: true } : {}), ...(choices.length ? { choices } : {}), ...(files.length ? { files } : {}) };
414
+ });
415
+ return { messages, cursor: typeof read?.cursor === 'string' ? read.cursor : null };
416
+ }
417
+
308
418
  /* What a read asks for: the newest `last` (READ_DEFAULT; READ_MAX at most), or what came `since` a cursor. */
309
419
  export function readPart(get) {
310
420
  const last = Number(get('last') ?? READ_DEFAULT);
@@ -345,13 +455,52 @@ export const SAY_TOOL = {
345
455
  inputSchema: { type: 'object', properties: { conversation: CONVERSATION_ARG, text: { type: 'string', minLength: 1, maxLength: SAY_MAX } }, required: ['conversation', 'text'], additionalProperties: false },
346
456
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
347
457
  };
348
- export const TOOLS = [STATUS_TOOL, CONVERSATIONS_TOOL, READ_TOOL, SAY_TOOL];
458
+ const ROOM_ARG = { type: 'string', description: 'The room\'s id, as clavarium_rooms gives it.' };
459
+ const BOX_ARG = { type: 'string', description: 'The voice box\'s id, as clavarium_boxes gives it.' };
460
+ export const ROOMS_TOOL = {
461
+ name: 'clavarium_rooms',
462
+ title: 'Rooms of this key',
463
+ description: 'The rooms this key was given, each with its id, name, level (read or talk), its host and its members by name.',
464
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
465
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
466
+ };
467
+ export const ROOM_READ_TOOL = {
468
+ name: 'clavarium_room_read',
469
+ title: 'Read a room',
470
+ description: `The newest messages of one room's thread (the person, its AIs, its voice boxes, keys), oldest first, by names; secrets masked. A cursor as for clavarium_read. ${READ_DEFAULT} by default, ${READ_MAX} at most.`,
471
+ inputSchema: { type: 'object', properties: { room: ROOM_ARG, last: { type: 'integer', minimum: 1, maximum: READ_MAX }, since: { type: 'string' } }, required: ['room'], additionalProperties: false },
472
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
473
+ };
474
+ export const ROOM_SAY_TOOL = {
475
+ name: 'clavarium_room_say',
476
+ title: 'Post a message in a room',
477
+ description: 'Post a message in one room, as the person would: it goes to the room\'s host, or to the AIs named in "to" (their names, as clavarium_rooms gives them). Shown signed with this key\'s name. The room must have been given to this key to talk.',
478
+ inputSchema: { type: 'object', properties: { room: ROOM_ARG, text: { type: 'string', minLength: 1, maxLength: SAY_MAX }, to: { type: 'array', items: { type: 'string' }, maxItems: 20 } }, required: ['room', 'text'], additionalProperties: false },
479
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
480
+ };
481
+ export const BOXES_TOOL = {
482
+ name: 'clavarium_boxes',
483
+ title: 'Voice boxes of this key',
484
+ description: 'The voice boxes this key was given, each with its id, name, level (state or speak), whether it is online, where it speaks, when it last heard someone, and its volume. Nothing it heard.',
485
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
486
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
487
+ };
488
+ export const BOX_SAY_TOOL = {
489
+ name: 'clavarium_box_say',
490
+ title: 'Say a short text aloud on a voice box',
491
+ description: `Make one voice box say a short text aloud, in the home: ${SPEAK_MAX} characters at most, ${SPEAK_PER_MIN} a minute. It does not listen after. The box must have been given to this key to speak.`,
492
+ inputSchema: { type: 'object', properties: { box: BOX_ARG, text: { type: 'string', minLength: 1, maxLength: SPEAK_MAX } }, required: ['box', 'text'], additionalProperties: false },
493
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
494
+ };
495
+ export const TOOLS = [STATUS_TOOL, CONVERSATIONS_TOOL, READ_TOOL, SAY_TOOL, ROOMS_TOOL, ROOM_READ_TOOL, ROOM_SAY_TOOL, BOXES_TOOL, BOX_SAY_TOOL];
349
496
 
350
497
  /* Returns true when the request was under /api/ext/ (answered here). `status()`: the state, as statusOf
351
498
  makes it. `chats`: { list() -> [{ id, name }], read(name, part) -> talk.of's answer, chatOn(name),
352
- say(name, text, by) -> { told } }. `originOk(headers)`: whether an Origin is this site's (gate.mjs); a
499
+ say(name, text, by) -> { told } }. `rooms`: { list() -> [{ id, name, host, members: [{ name, kind }] }],
500
+ read(id, part) -> rooms.read's answer, say(id, text, { as, to }) }. `boxes`: { list() -> [{ id, name, online, where,
501
+ heardAt, volume }], say(id, text, by) -> { seconds? } }. `originOk(headers)`: whether an Origin is this site's (gate.mjs); a
353
502
  call from a page of another site is refused. */
354
- export async function handleExternal(request, response, { keys, status, chats = null, headers = {}, originOk = () => false, version = null }) {
503
+ export async function handleExternal(request, response, { keys, status, chats = null, rooms = null, boxes = null, headers = {}, originOk = () => false, version = null }) {
355
504
  const url = new URL(request.url ?? '/', 'http://local');
356
505
  const { pathname } = url;
357
506
  if (!pathname.startsWith(EXT_PREFIX) && pathname !== EXT_PREFIX.slice(0, -1)) return false;
@@ -394,6 +543,40 @@ export async function handleExternal(request, response, { keys, status, chats =
394
543
  const said = await chats.say(name, text, keys.signature(id));
395
544
  return { ok: true, told: typeof said?.told === 'string' ? said.told : null };
396
545
  };
546
+ /* The same for rooms and voice boxes. */
547
+ const roomsListed = (id) => {
548
+ const there = new Map((rooms?.list() ?? []).map((r) => [r.id, r]));
549
+ return { rooms: keys.roomsOf(id).filter((g) => there.has(g.id)).map((g) => { const r = there.get(g.id);
550
+ return { id: g.id, name: String(r.name ?? ''), level: g.level, host: r.host ?? null, members: (Array.isArray(r.members) ? r.members : []).map((m) => ({ name: String(m.name ?? ''), kind: String(m.kind ?? '') })) }; }) };
551
+ };
552
+ const roomKnown = (room) => { if (!(rooms?.list() ?? []).some((r) => r.id === room)) throw new Refusal(403, 'This key was not given this room.'); };
553
+ const roomReading = (id, room, part) => {
554
+ keys.mayRoom(id, room, 'read'); roomKnown(room);
555
+ return roomThreadOf(rooms.read(room, part));
556
+ };
557
+ const roomSaying = async (id, room, text, to) => {
558
+ keys.mayRoom(id, room, 'talk'); roomKnown(room);
559
+ if (typeof text !== 'string' || !text.trim()) throw new Refusal(400, 'Say something: text.');
560
+ if (text.length > SAY_MAX) throw new Refusal(400, `${SAY_MAX} characters at most.`);
561
+ if (to !== undefined && to !== null && (!Array.isArray(to) || to.length > 20 || to.some((x) => typeof x !== 'string'))) throw new Refusal(400, 'to: the names of AIs of this room.');
562
+ const done = await rooms.say(room, text, { as: keys.roomSignature(id), to: Array.isArray(to) ? to : [] });
563
+ /* Posted; an AI it could not reach (its chat off) said by name. */
564
+ return done?.missed?.length ? { ok: true, notReached: done.missed.map((m) => m.name) } : { ok: true };
565
+ };
566
+ const boxesListed = (id) => {
567
+ const there = new Map((boxes?.list() ?? []).map((b) => [b.id, b]));
568
+ return { boxes: keys.boxesOf(id).filter((g) => there.has(g.id)).map((g) => { const b = there.get(g.id);
569
+ return { id: g.id, name: String(b.name ?? ''), level: g.level, online: Boolean(b.online), where: b.where ?? null, heardAt: b.heardAt ?? null, volume: Number.isFinite(b.volume) ? b.volume : null }; }) };
570
+ };
571
+ const boxSaying = async (id, box, text) => {
572
+ keys.mayBox(id, box, 'speak');
573
+ if (!(boxes?.list() ?? []).some((b) => b.id === box)) throw new Refusal(403, 'This key was not given this voice box.');
574
+ if (typeof text !== 'string' || !text.trim()) throw new Refusal(400, 'Say something: text.');
575
+ if (text.length > SPEAK_MAX) throw new Refusal(400, `${SPEAK_MAX} characters at most, said aloud.`);
576
+ keys.speakCounted(id, box);
577
+ const done = await boxes.say(box, text, keys.signature(id));
578
+ return { ok: true, ...(Number.isFinite(done?.seconds) ? { seconds: done.seconds } : {}) };
579
+ };
397
580
  const ownError = (error, id) => (error instanceof Refusal ? Object.assign(error, { key: error.key ?? id }) : error);
398
581
  try {
399
582
  if (route === '/v1/status') {
@@ -423,6 +606,43 @@ export async function handleExternal(request, response, { keys, status, chats =
423
606
  return send(201, done);
424
607
  } catch (error) { return refused(ownError(error, id)); }
425
608
  }
609
+ const room = /^\/v1\/rooms(?:\/([^/]+)\/messages)?$/.exec(route);
610
+ if (room) {
611
+ const allowed = room[1] ? 'GET, POST' : 'GET';
612
+ if (!allowed.split(', ').includes(request.method)) return send(405, { error: `Call this address with ${allowed}.` }, { allow: allowed });
613
+ let id;
614
+ try { id = keys.check(request.headers.authorization); } catch (error) { return refused(error); }
615
+ try {
616
+ if (!room[1]) { const body = roomsListed(id); note(id, 200); return send(200, body); }
617
+ let ref;
618
+ try { ref = decodeURIComponent(room[1]); } catch { ref = ''; }
619
+ if (request.method === 'GET') { const body = roomReading(id, ref, readPart((k) => url.searchParams.get(k))); note(id, 200); return send(200, body); }
620
+ if (!String(request.headers['content-type'] ?? '').startsWith('application/json')) throw new Refusal(415, 'Send JSON: {"text": "..."}.');
621
+ let body;
622
+ try { body = JSON.parse(await bodyOf(SAY_MAX * 4 + 4096)); } catch (error) { if (error instanceof Refusal) throw error; throw new Refusal(400, 'The request is not JSON.'); }
623
+ const done = await roomSaying(id, ref, body?.text, body?.to);
624
+ note(id, 201);
625
+ return send(201, done);
626
+ } catch (error) { return refused(ownError(error, id)); }
627
+ }
628
+ const box = /^\/v1\/boxes(?:\/([^/]+)\/say)?$/.exec(route);
629
+ if (box) {
630
+ const allowed = box[1] ? 'POST' : 'GET';
631
+ if (request.method !== allowed) return send(405, { error: `Call this address with ${allowed}.` }, { allow: allowed });
632
+ let id;
633
+ try { id = keys.check(request.headers.authorization); } catch (error) { return refused(error); }
634
+ try {
635
+ if (!box[1]) { const body = boxesListed(id); note(id, 200); return send(200, body); }
636
+ let ref;
637
+ try { ref = decodeURIComponent(box[1]); } catch { ref = ''; }
638
+ if (!String(request.headers['content-type'] ?? '').startsWith('application/json')) throw new Refusal(415, 'Send JSON: {"text": "..."}.');
639
+ let body;
640
+ try { body = JSON.parse(await bodyOf(SPEAK_MAX * 4 + 1024)); } catch (error) { if (error instanceof Refusal) throw error; throw new Refusal(400, 'The request is not JSON.'); }
641
+ const done = await boxSaying(id, ref, body?.text);
642
+ note(id, 200);
643
+ return send(200, done);
644
+ } catch (error) { return refused(ownError(error, id)); }
645
+ }
426
646
  if (route === '/mcp') {
427
647
  if (request.method !== 'POST') return send(405, { error: 'MCP is spoken with POST here (no event stream).' }, { allow: 'POST' });
428
648
  let id;
@@ -435,8 +655,9 @@ export async function handleExternal(request, response, { keys, status, chats =
435
655
  note(id, 400, '/mcp'); return send(400, { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } });
436
656
  }
437
657
  const args = msg.params?.arguments && typeof msg.params.arguments === 'object' && !Array.isArray(msg.params.arguments) ? msg.params.arguments : {};
438
- /* The journal says which tool, and on which conversation. */
439
- const what = `/mcp ${msg.method.slice(0, 40)}${msg.method === 'tools/call' ? ` ${String(msg.params?.name ?? '').slice(0, 40)}${typeof args.conversation === 'string' ? ` ${args.conversation.slice(0, 32)}` : ''}` : ''}`;
658
+ /* The journal says which tool, and on which conversation, room or box. */
659
+ const on = [args.conversation, args.room, args.box].find((v) => typeof v === 'string');
660
+ const what = `/mcp ${msg.method.slice(0, 40)}${msg.method === 'tools/call' ? ` ${String(msg.params?.name ?? '').slice(0, 40)}${on ? ` ${on.slice(0, 36)}` : ''}` : ''}`;
440
661
  /* A notification (no id) is taken, never answered. */
441
662
  if (msg.id === undefined || msg.id === null) { note(id, 202, what); response.writeHead(202, { ...headers, 'cache-control': 'no-store' }).end(); return true; }
442
663
  const answer = (result, code = 200) => { note(id, code, what); return send(200, { jsonrpc: '2.0', id: msg.id, result }); };
@@ -447,7 +668,7 @@ export async function handleExternal(request, response, { keys, status, chats =
447
668
  const asked = String(msg.params?.protocolVersion ?? '');
448
669
  return answer({ protocolVersion: MCP_VERSIONS.includes(asked) ? asked : MCP_VERSIONS[0], capabilities: { tools: { listChanged: false } },
449
670
  serverInfo: { name: 'clavarium', title: 'Clavarium', version: version ?? '0' },
450
- instructions: 'This Clavarium, as far as this key was given it: its state (clavarium_status), the conversations given to it (clavarium_conversations), to read (clavarium_read) or to talk in (clavarium_say).' });
671
+ instructions: 'This Clavarium, as far as this key was given it: its state (clavarium_status), the conversations given to it (clavarium_conversations), to read (clavarium_read) or to talk in (clavarium_say); its rooms (clavarium_rooms, clavarium_room_read, clavarium_room_say); its voice boxes (clavarium_boxes; clavarium_box_say says a short text aloud in the home).' });
451
672
  }
452
673
  case 'ping': return answer({});
453
674
  case 'tools/list': return answer({ tools: TOOLS });
@@ -460,14 +681,28 @@ export async function handleExternal(request, response, { keys, status, chats =
460
681
  return result(await status());
461
682
  }
462
683
  if (name === CONVERSATIONS_TOOL.name) return result(listed(id));
463
- if (typeof args.conversation !== 'string') return fail(-32602, 'conversation: the id of a conversation.');
464
- if (name === READ_TOOL.name) return result(reading(id, args.conversation, readPart((k) => (args[k] === undefined ? null : typeof args[k] === 'number' ? String(args[k]) : args[k]))));
465
- const done = await saying(id, args.conversation, args.text);
684
+ if (name === ROOMS_TOOL.name) return result(roomsListed(id));
685
+ if (name === BOXES_TOOL.name) return result(boxesListed(id));
686
+ const part = () => readPart((k) => (args[k] === undefined ? null : typeof args[k] === 'number' ? String(args[k]) : args[k]));
687
+ let done;
688
+ if (name === ROOM_READ_TOOL.name || name === ROOM_SAY_TOOL.name) {
689
+ if (typeof args.room !== 'string') return fail(-32602, 'room: the id of a room.');
690
+ if (name === ROOM_READ_TOOL.name) return result(roomReading(id, args.room, part()));
691
+ done = await roomSaying(id, args.room, args.text, args.to);
692
+ } else if (name === BOX_SAY_TOOL.name) {
693
+ if (typeof args.box !== 'string') return fail(-32602, 'box: the id of a voice box.');
694
+ done = await boxSaying(id, args.box, args.text);
695
+ } else {
696
+ if (typeof args.conversation !== 'string') return fail(-32602, 'conversation: the id of a conversation.');
697
+ if (name === READ_TOOL.name) return result(reading(id, args.conversation, part()));
698
+ done = await saying(id, args.conversation, args.text);
699
+ }
466
700
  note(id, 201, what);
467
701
  return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: JSON.stringify(done) }], structuredContent: done, isError: false } });
468
702
  } catch (error) {
469
703
  if (!(error instanceof Refusal)) throw error;
470
- /* Not given: refused as the API refuses it. Anything else is the tool's own failure, said to the caller. */
704
+ /* Not given: refused as the API refuses it (too many, too). Anything else is the tool's own failure, said to the caller. */
705
+ if (error.status === 429) { note(id, 429, what); return send(429, { jsonrpc: '2.0', id: msg.id, error: { code: -32002, message: error.message } }, { 'retry-after': String(error.retry ?? 60) }); }
471
706
  if (error.status === 403) { note(id, 403, what); return send(403, { jsonrpc: '2.0', id: msg.id, error: { code: -32001, message: error.message } }); }
472
707
  note(id, error.status, what);
473
708
  return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: error.message }], isError: true } });
@@ -477,7 +712,7 @@ export async function handleExternal(request, response, { keys, status, chats =
477
712
  }
478
713
  }
479
714
  note(null, 404);
480
- return send(404, { error: 'Not found. GET /api/ext/v1/status, /api/ext/v1/conversations, or MCP at /api/ext/mcp.' });
715
+ return send(404, { error: 'Not found. GET /api/ext/v1/status, /api/ext/v1/conversations, /api/ext/v1/rooms, /api/ext/v1/boxes, or MCP at /api/ext/mcp.' });
481
716
  } catch (error) {
482
717
  if (error instanceof Refusal) return refused(error);
483
718
  note(null, 500);
@@ -503,9 +738,9 @@ export async function handleApiKeys(request, response, keys, originOk, headers =
503
738
  try { body = text ? JSON.parse(text) : {}; } catch { return send(400, { error: 'The request is not JSON.' }); }
504
739
  if (!body || typeof body !== 'object') body = {};
505
740
  if (pathname === '/api/apikeys') {
506
- const made = keys.create({ label: body.label, scopes: body.scopes ?? [], conversations: body.conversations ?? {}, days: body.days === undefined ? DEFAULT_DAYS : body.days }, who);
741
+ const made = keys.create({ label: body.label, scopes: body.scopes ?? [], conversations: body.conversations ?? {}, rooms: body.rooms ?? {}, boxes: body.boxes ?? {}, days: body.days === undefined ? DEFAULT_DAYS : body.days }, who);
507
742
  /* Its name and scopes; never the secret. */
508
- const what = [...made.key.scopes, ...made.key.conversations.map((c) => `${c.name} ${c.level}`)];
743
+ const what = [...made.key.scopes, ...made.key.conversations.map((c) => `${c.name} ${c.level}`), ...made.key.rooms.map((r) => `room ${r.id.slice(0, 8)} ${r.level}`), ...made.key.boxes.map((b) => `voice box ${b.id.slice(0, 8)} ${b.level}`)];
509
744
  events?.add({ kind: 'access', text: `API key "${made.key.label}" made${what.length ? ` (${what.join(', ')})` : ' (no scope)'}`, who: who || null });
510
745
  return send(201, made);
511
746
  }
@@ -516,6 +751,15 @@ export async function handleApiKeys(request, response, keys, originOk, headers =
516
751
  events?.add({ kind: 'access', text: `API key "${key.label}": ${body.conversation} ${level ?? 'taken back'}`, who: who || null });
517
752
  return send(200, { key });
518
753
  }
754
+ const one = /^\/api\/apikeys\/([0-9a-f-]{36})\/(rooms|boxes)$/.exec(pathname);
755
+ if (one) {
756
+ const level = body.level === undefined ? null : body.level;
757
+ const room = one[2] === 'rooms';
758
+ const ref = room ? body.room : body.box;
759
+ const key = room ? keys.setRoom(one[1], ref, level) : keys.setBox(one[1], ref, level);
760
+ events?.add({ kind: 'access', text: `API key "${key.label}": ${room ? 'room' : 'voice box'} ${String(ref).slice(0, 8)} ${level ?? 'taken back'}`, who: who || null });
761
+ return send(200, { key });
762
+ }
519
763
  const revoke = /^\/api\/apikeys\/([0-9a-f-]{36})\/revoke$/.exec(pathname);
520
764
  if (revoke) {
521
765
  const label = keys.get(revoke[1])?.label;
package/server/boxes.mjs CHANGED
@@ -284,12 +284,12 @@ export function piecesOf(pcm, { least = 8, most = 14 } = {}) {
284
284
 
285
285
  export class Boxes {
286
286
  #file; #codes = new Map(); #links = new Map(); #tries = []; #now;
287
- #exists; #hear; #say; #note; #speak; #log; #changed; #images = new Map(); #origin; #spawn; #fetch; #streams = new Map(); #recordDir; #parts; #keep; #react; #learn; #collect; #stopSay = new Map();
287
+ #exists; #hear; #say; #note; #speak; #log; #removed; #changed; #images = new Map(); #origin; #spawn; #fetch; #streams = new Map(); #recordDir; #parts; #keep; #react; #learn; #collect; #stopSay = new Map();
288
288
 
289
289
  /* file: a JsonFile holding [{ id, name, conversation, keyHash, firmware, createdAt, seenAt }].
290
290
  exists(conversation), hear(wav) -> text, say(conversation, text, room), speak(text) -> PCM 16 kHz. */
291
- constructor({ file, exists = () => true, hear, say, note = null, speak, changed = () => {}, origin = () => null, now = () => Date.now(), log = () => {}, spawn = spawnProcess, fetcher = fetch, recordDir = null, parts = null, keep = null, react = async () => null, learn = null, collect = null }) {
292
- this.#react = react; this.#learn = learn; this.#collect = collect;
291
+ constructor({ file, removed = () => {}, exists = () => true, hear, say, note = null, speak, changed = () => {}, origin = () => null, now = () => Date.now(), log = () => {}, spawn = spawnProcess, fetcher = fetch, recordDir = null, parts = null, keep = null, react = async () => null, learn = null, collect = null }) {
292
+ this.#removed = removed; this.#react = react; this.#learn = learn; this.#collect = collect;
293
293
  this.#spawn = spawn; this.#fetch = fetcher; this.#recordDir = recordDir; this.#parts = parts; this.#keep = keep;
294
294
  this.#file = file; this.#changed = changed; this.#origin = origin; this.#exists = exists; this.#hear = hear; this.#say = say; this.#note = note; this.#speak = speak; this.#now = now; this.#log = log;
295
295
  }
@@ -302,7 +302,8 @@ export class Boxes {
302
302
 
303
303
  /* What the operator sees: never the key, nor its hash. */
304
304
  all() {
305
- return this.#all().map(({ keyHash, ...b }) => ({ ...b, online: this.#links.has(b.id), muted: this.#links.get(b.id)?.muted ?? null, volume: b.volume ?? this.#links.get(b.id)?.volume ?? VOLUME_DEFAULT }));
305
+ return this.#all().map(({ keyHash, ...b }) => { const l = this.#links.get(b.id);
306
+ return { ...b, online: Boolean(l), muted: l?.muted ?? null, volume: b.volume ?? l?.volume ?? VOLUME_DEFAULT, heardAt: l?.heardAt ? new Date(l.heardAt).toISOString() : null }; });
306
307
  }
307
308
 
308
309
  code(conversation, name, zone = '') {
@@ -436,6 +437,21 @@ export class Boxes {
436
437
  return { ok: true };
437
438
  }
438
439
 
440
+ /* SAID ALOUD FOR AN API KEY (apikeys.mjs, "speak"): a short text, as given -- never another assistant's call, the
441
+ box does not listen after, nothing of its settings touched. `by`: the key's signature, for the log. */
442
+ async announce(id, text, by = null) {
443
+ const link = this.#links.get(id);
444
+ if (!link) throw new Refusal(409, 'This voice box is not connected.');
445
+ /* A meeting being recorded is not spoken over. */
446
+ if (link.recording) throw new Refusal(409, 'This voice box is recording: not now.');
447
+ const words = forSpeech(text);
448
+ if (!words) throw new Refusal(400, 'Nothing to say.');
449
+ const pcm = await this.#speak(words);
450
+ link.play(pcm);
451
+ this.#log(`voice box ${link.box().name}: said aloud for the API key "${String(by?.name ?? '?').slice(0, 40)}"`);
452
+ return { ok: true, seconds: Math.round(pcm.length / 32_000) };
453
+ }
454
+
439
455
  /* LISTENING TO THE INTERNET: a radio, a podcast, a music file, a playlist, read by this server (ffmpeg,
440
456
  any format it knows) and sent on to the box at the pace it is played, a few seconds ahead -- until it
441
457
  ends, stop(), or the person presses the button or says the wake word (the box then starts a sentence). */
@@ -517,6 +533,7 @@ export class Boxes {
517
533
  this.#save(list.filter((b) => b.id !== id));
518
534
  this.#links.get(id)?.socket.close(4001, 'withdrawn');
519
535
  try { this.#learn?.forget(id); } catch { /* nothing kept */ }
536
+ try { this.#removed(id); } catch { /* the caller's */ }
520
537
  }
521
538
 
522
539
  /* THE WAKE WORD THAT LEARNS (wakelearn.mjs): "normal" keeps nothing, "learn" keeps each wake clip with
package/server/grants.mjs CHANGED
@@ -20,6 +20,8 @@
20
20
  * git see the files change them -
21
21
  * secret never - be handed to a conversation
22
22
  * server read its state - -
23
+ * room read its thread post in it -
24
+ * box read its state make it say a text aloud -
23
25
  *
24
26
  * Three kinds of subject: a CONVERSATION (what is attached to it reaches its
25
27
  * machine), a PAIRING (what a device was given by a code) and an API KEY
@@ -29,8 +31,11 @@
29
31
  * An API key is not a device and not a conversation: it is given only what
30
32
  * its scopes name -- "server:status" (read), and conversations one by one,
31
33
  * their chat to read ("r") or to talk in ("rw"), never their terminal ("x")
32
- * -- never a secret nor a folder in this version; and nothing else is ever
33
- * given "server:".
34
+ * -- rooms one by one, their thread to read ("r") or to post in ("rw"), and
35
+ * voice boxes one by one, their state ("r") or a short text said aloud
36
+ * ("rw": never to listen, record, set or update it) -- never a secret nor a
37
+ * folder in this version; nothing else is ever given "server:", "room:" or
38
+ * "box:".
34
39
  *
35
40
  * No groups, no inheritance. One rule sits on top, in the door: whoever was
36
41
  * given a conversation, its terminal or its chat, sees the files of that
@@ -49,9 +54,9 @@
49
54
  */
50
55
  import { Refusal } from './conversations.mjs';
51
56
 
52
- export const RIGHTS = Object.freeze({ conversation: 'rwx', folder: 'rw', mailbox: 'r', git: 'rw', secret: 'x', server: 'r' });
57
+ export const RIGHTS = Object.freeze({ conversation: 'rwx', folder: 'rw', mailbox: 'r', git: 'rw', secret: 'x', server: 'r', room: 'rw', box: 'rw' });
53
58
  const SUBJECT = /^(conversation:[a-z]([a-z0-9-]{0,30}[a-z0-9])?|(pairing|apikey):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
54
- const ASSET = /^(conversation|folder|mailbox|git):[a-z]([a-z0-9-]{0,30}[a-z0-9])?$|^secret:[A-Z][A-Z0-9_]{0,39}$|^server:status$/;
59
+ const ASSET = /^(conversation|folder|mailbox|git):[a-z]([a-z0-9-]{0,30}[a-z0-9])?$|^secret:[A-Z][A-Z0-9_]{0,39}$|^server:status$|^(room|box):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
55
60
 
56
61
  export const typeOf = (asset) => String(asset).split(':')[0];
57
62
  export const nameOf = (asset) => String(asset).slice(String(asset).indexOf(':') + 1);
@@ -98,7 +103,8 @@ export class Grants {
98
103
  /* The state of the server is given to an API key only; an API key is given only that, and
99
104
  conversations one by one -- their chat, to read or to write in, never their terminal. */
100
105
  if (typeOf(asset) === 'server' && !subject.startsWith('apikey:')) throw new Refusal(400, 'The state of the server is given to an API key only.');
101
- if (subject.startsWith('apikey:') && !['server', 'conversation'].includes(typeOf(asset))) throw new Refusal(400, 'An API key is given the state of the server and conversations, nothing else.');
106
+ if (['room', 'box'].includes(typeOf(asset)) && !subject.startsWith('apikey:')) throw new Refusal(400, 'A room or a voice box is given to an API key only.');
107
+ if (subject.startsWith('apikey:') && !['server', 'conversation', 'room', 'box'].includes(typeOf(asset))) throw new Refusal(400, 'An API key is given the state of the server, conversations, rooms and voice boxes, nothing else.');
102
108
  if (subject.startsWith('apikey:') && clean.includes('x')) throw new Refusal(400, 'An API key reads a conversation or talks in it, never uses its terminal.');
103
109
  this.#store.put({ name: this.#key(subject, asset), subject, asset, rights: clean });
104
110
  return clean;
package/server/main.mjs CHANGED
@@ -235,7 +235,7 @@ function serve(port, conversations, resources, { gate = () => 'local', originOk,
235
235
  /* THE DOOR FOR PROGRAMS (apikeys.mjs): an API key is the right, never the gate -- the front lets
236
236
  /api/ext/ through without sign-on, and strips the identity headers there. */
237
237
  if (apikeys && (pathname === '/api/ext' || pathname.startsWith('/api/ext/'))) {
238
- await handleExternal(request, response, { keys: apikeys.keys, status: apikeys.status, chats: apikeys.chats, headers: HEADERS, originOk, version: platform?.version ?? null });
238
+ await handleExternal(request, response, { keys: apikeys.keys, status: apikeys.status, chats: apikeys.chats, rooms: apikeys.rooms, boxes: apikeys.boxes, headers: HEADERS, originOk, version: platform?.version ?? null });
239
239
  return;
240
240
  }
241
241
  if (door && pathname.startsWith('/c/')) {
@@ -675,6 +675,8 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
675
675
  /* The voice by default: Kyutai's Pocket TTS, run here (pocket.mjs); CLAVARIUM_POCKET=0 to leave it out. */
676
676
  pocket: process.env.CLAVARIUM_POCKET === '0' ? null : new Pocket({ dir: `${dataDir}/voices`, log: speechLog }) });
677
677
  boxes = new Boxes({ file: new JsonFile(`${dataDir}/boxes.json`, []),
678
+ /* A box withdrawn: what an API key was given of it goes with it. */
679
+ removed: (id) => grants.forgetAsset(`box:${id}`),
678
680
  /* A box speaks in a conversation, or in a room ('room:<id>', #281). */
679
681
  exists: (name) => all.list().some((c) => c.name === name) || (/^room:/.test(name) && Boolean(rooms?.has(name.slice(5)))),
680
682
  /* A long recording (a meeting): transcribed in parts, kept beside the conversation for its assistant. */
@@ -696,6 +698,7 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
696
698
  log: (line) => events.add({ kind: 'server', text: line }) });
697
699
  /* Rooms (#268): a thread shared by several AIs, the person and boxes (rooms.mjs). */
698
700
  rooms = new Rooms({ file: new JsonFile(`${dataDir}/rooms.json`, []), dir: `${dataDir}/rooms`,
701
+ removed: (id) => grants.forgetAsset(`room:${id}`),
699
702
  say: (name, text, extra, files) => talk.say(name, text, undefined, files, undefined, extra),
700
703
  attach: (name, fileName, request) => talk.attach(name, fileName, request),
701
704
  rooms: (name, list) => talk.rooms(name, list), conversations: () => all.list(), boxes: () => boxes.all(),
@@ -724,7 +727,8 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
724
727
  for (const name of ['clavarium-parley', 'clavarium-tether', 'clavarium-binder', 'clavarium-docket']) {
725
728
  try { components[name.slice('clavarium-'.length)] = JSON.parse(readFileSync(packageFile(name, 'package.json'), 'utf8')).version; } catch { /* not installed here */ }
726
729
  }
727
- const apiKeys = new ApiKeys({ store: new Store(`${dataDir}/apikeys.json`), grants, journal: `${dataDir}/apikeys.log`, exists: (name) => all.list().some((c) => c.name === name) });
730
+ const apiKeys = new ApiKeys({ store: new Store(`${dataDir}/apikeys.json`), grants, journal: `${dataDir}/apikeys.log`, exists: (name) => all.list().some((c) => c.name === name),
731
+ roomExists: (id) => rooms.has(id), boxExists: (id) => boxes.all().some((b) => b.id === id) });
728
732
  /* What the host program says of the server is never waited for long: a key gets what is known. */
729
733
  const briefly = { brief: () => quick(capacity.brief(), 3000, null), room: () => quick(capacity.room(), 3000, null) };
730
734
  const apikeys = { keys: apiKeys, status: () => statusOf({ conversations: all, talk, health, answerer, capacity: briefly, history, platform, updates, components }),
@@ -735,6 +739,20 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
735
739
  chatOn: (name) => { try { return talk.of(name, { state: '1' }).on === true; } catch { return false; } },
736
740
  read: (name, part) => talk.of(name, part),
737
741
  say: (name, text, by) => talk.say(name, text, undefined, [], undefined, { by }),
742
+ },
743
+ /* Its rooms: read as the room's page reads them (names of the moment), never marked seen; posted as the person
744
+ posts there (rooms.mjs say, `as` the key). */
745
+ rooms: {
746
+ list: () => rooms.list().map((r) => ({ id: r.id, name: r.name, host: r.members.find((m) => m.host)?.name ?? null, members: r.members.map((m) => ({ name: m.name, kind: m.kind })) })),
747
+ read: (id, part) => rooms.read(id, { ...part, ...(part.last ? { last: Number(part.last) } : {}), seen: false }),
748
+ say: (id, text, how) => rooms.say(id, text, how),
749
+ },
750
+ /* Its voice boxes: their state only, and a short text said aloud (boxes.mjs announce). */
751
+ boxes: {
752
+ list: () => { const names = new Map([...all.list().map((c) => [c.name, c.title || c.name]), ...rooms.list().map((r) => [`room:${r.id}`, r.name])]);
753
+ return boxes.all().map((b) => ({ id: b.id, name: b.name, online: b.online, volume: b.volume, heardAt: b.heardAt ?? null,
754
+ where: /^room:/.test(String(b.conversation)) ? { kind: 'room', name: names.get(b.conversation) ?? null } : { kind: 'conversation', name: names.get(b.conversation) ?? null } })); },
755
+ say: (id, text, by) => boxes.announce(id, text, by),
738
756
  } };
739
757
  const features = new Features({ file: new JsonFile(`${dataDir}/features.json`, {}), context: async () => ({
740
758
  secrets: secrets.list(), dictation: voice.about(), boxes: boxes.all(), alerts: alerts.about().devices.length,
package/server/rooms.mjs CHANGED
@@ -60,7 +60,7 @@ export const masked = (text) => { const str = String(text ?? ''); const hits = s
60
60
  export const lookOf = (name) => { const text = String(name ?? ''); let h = 0; for (const ch of text) h = (h * 31 + ch.codePointAt(0)) >>> 0; return { initial: ([...text.trim()][0] ?? '?').toUpperCase(), hue: h % 360 }; };
61
61
 
62
62
  export class Rooms {
63
- #file; #dir; #say; #attach; #read; #rooms; #conversations; #boxes; #now; #speak; #reachOf; #shownIn; #hops = new Map(); #thinking = new Map();
63
+ #file; #dir; #say; #attach; #read; #rooms; #conversations; #boxes; #now; #speak; #reachOf; #shownIn; #removed; #hops = new Map(); #thinking = new Map();
64
64
  /* file: a JsonFile of the rooms; dir: where their threads are; say(name, text, extra): a message written in a
65
65
  conversation's own chat (talk.say; its 4th argument the paths of its files there); attach(name, fileName,
66
66
  request): a file put in a conversation's chat as the person attaches one (talk.attach); rooms(name, list): what a conversation is told of its rooms (talk.rooms);
@@ -68,9 +68,10 @@ export class Rooms {
68
68
  reach(name, path): how a conversation's chat opens a path of its own ("work:a.png"), or null (talk.reachOf);
69
69
  shown(name, ref): whether that chat has that file (talk.shown) -- what an AI shows in a room is read through them.
70
70
  read(name, ref): that file of that conversation ({ name, size, stream }), read with its rights -- what an AI
71
- shows or cites to another AI of the room is copied from it (#carried). */
72
- constructor({ file, dir, say, attach = async () => { throw new Refusal(409, 'Files cannot be given here.'); }, rooms = () => {}, conversations = () => [], boxes = () => [], speak = () => {}, now = () => new Date(), reach = () => null, shown = () => false, read = async () => { throw new Refusal(404, 'No such file.'); } }) {
73
- this.#speak = speak; this.#attach = attach; this.#read = read; this.#reachOf = reach; this.#shownIn = shown;
71
+ shows or cites to another AI of the room is copied from it (#carried).
72
+ removed(id): a room removed (what was given of it, to an API key, goes with it). */
73
+ constructor({ file, dir, say, removed = () => {}, attach = async () => { throw new Refusal(409, 'Files cannot be given here.'); }, rooms = () => {}, conversations = () => [], boxes = () => [], speak = () => {}, now = () => new Date(), reach = () => null, shown = () => false, read = async () => { throw new Refusal(404, 'No such file.'); } }) {
74
+ this.#removed = removed; this.#speak = speak; this.#attach = attach; this.#read = read; this.#reachOf = reach; this.#shownIn = shown;
74
75
  this.#file = file; this.#dir = dir; this.#say = say; this.#rooms = rooms; this.#conversations = conversations; this.#boxes = boxes; this.#now = now;
75
76
  }
76
77
  #all() { const v = this.#file.get(); return Array.isArray(v) ? v : []; }
@@ -158,7 +159,7 @@ export class Rooms {
158
159
  this.#save(list);
159
160
  return this.get(id);
160
161
  }
161
- remove(id) { this.#find(id); this.#save(this.#all().filter((r) => r.id !== id)); rmSync(`${this.#dir}/${id}`, { recursive: true, force: true }); }
162
+ remove(id) { this.#find(id); this.#save(this.#all().filter((r) => r.id !== id)); rmSync(`${this.#dir}/${id}`, { recursive: true, force: true }); try { this.#removed(id); } catch { /* the caller's */ } }
162
163
 
163
164
  /* ITS THREAD, as a screen reads it (Parley's read, the members' names of the moment). */
164
165
  read(id, options) {
@@ -247,11 +248,20 @@ export class Rooms {
247
248
  }
248
249
 
249
250
  /* SOMEONE OF THE PERSON'S SIDE SPEAKS (the person here, a box): kept in the room, given to the host -- or to the
250
- AIs named (to: their member ids). Its files (paths from attach): in the thread, and a copy to each of those AIs. */
251
- async say(id, text, { by, box, to, files } = {}) {
251
+ AIs named (to: their member ids). Its files (paths from attach): in the thread, and a copy to each of those AIs.
252
+ `as`: an API key ({ id, name, kind, look }, apikeys.mjs), not a member: signed with its own name, never the
253
+ person's -- and its `to` are the NAMES of the room's AIs (it is never told an id). */
254
+ async say(id, text, { by, box, to, files, as } = {}) {
252
255
  const room = this.#find(id); const members = this.#shown(room);
256
+ if (as && (files !== undefined || box || by)) throw new Refusal(400, 'An API key says words only.');
257
+ if (as && (typeof as.id !== 'string' || !as.id.startsWith('apikey:') || !as.name)) throw new Refusal(400, 'Not an API key.');
258
+ if (as && Array.isArray(to)) {
259
+ const ais = to.map((n) => members.find((m) => m.kind === 'ai' && m.name.toLowerCase() === String(n).trim().toLowerCase()));
260
+ if (ais.some((m) => !m)) throw new Refusal(400, 'to: the names of AIs of this room.');
261
+ to = ais.map((m) => m.id);
262
+ }
253
263
  /* A box bound to this room speaks in it in its own name (#281): found by its box id. */
254
- const who = box ? members.find((m) => m.kind === 'box' && m.ref === box) : by ? members.find((m) => m.id === by && m.kind !== 'ai') : members.find((m) => m.kind === 'person');
264
+ const who = as ? { id: as.id, name: String(as.name).slice(0, 40), kind: as.kind ?? 'ai', look: as.look ?? lookOf(as.name), ref: 'apikey' } : box ? members.find((m) => m.kind === 'box' && m.ref === box) : by ? members.find((m) => m.id === by && m.kind !== 'ai') : members.find((m) => m.kind === 'person');
255
265
  if (!who) throw new Refusal(400, 'Not a member of this room.');
256
266
  const attached = this.#filesSaid(room.id, files);
257
267
  const t = this.#thread(room.id);
@@ -262,16 +272,25 @@ export class Rooms {
262
272
  const said = now.messages.at(-1);
263
273
  const named = (Array.isArray(to) ? to : []).map((x) => members.find((m) => m.id === x && m.kind === 'ai')).filter(Boolean);
264
274
  const host = members.find((m) => m.id === room.host);
275
+ /* Kept in the room whatever happens next: an AI that cannot be reached (its chat off) is said back by name,
276
+ never as a failure of what was already posted (07-11). */
277
+ const missed = [];
265
278
  for (const ai of named.length ? named : host ? [host] : []) {
266
279
  this.#think(room.id, ai.id, true);
267
- /* Its own copy of each file, in its chat's files: as if attached there (a fresh read of the room's file). */
268
- const copies = [];
269
- for (const f of attached) { const stream = createReadStream(f.file); stream.headers = { 'content-length': String(f.size) }; copies.push((await this.#attach(ai.ref, f.name, stream)).path); }
270
- await this.#say(ai.ref, text, { by: { id: who.id, name: who.name, kind: who.kind, look: who.look }, room: { id: room.id, name: room.name, message: said.id } }, ...(copies.length ? [copies] : []));
280
+ try {
281
+ /* Its own copy of each file, in its chat's files: as if attached there (a fresh read of the room's file). */
282
+ const copies = [];
283
+ for (const f of attached) { const stream = createReadStream(f.file); stream.headers = { 'content-length': String(f.size) }; copies.push((await this.#attach(ai.ref, f.name, stream)).path); }
284
+ await this.#say(ai.ref, text, { by: { id: who.id, name: who.name, kind: who.kind, look: who.look }, room: { id: room.id, name: room.name, message: said.id } }, ...(copies.length ? [copies] : []));
285
+ } catch (error) {
286
+ if (!(error instanceof Refusal)) throw error;
287
+ this.#think(room.id, ai.id, false);
288
+ missed.push({ name: ai.name, why: error.message });
289
+ }
271
290
  }
272
291
  /* Handed to its AIs: read, as a conversation's agent would say it (06-11: the clock stayed on every message). */
273
292
  try { t.seen?.(); } catch { /* an older Parley */ }
274
- return this.read(id);
293
+ return missed.length ? { ...this.read(id), missed } : this.read(id);
275
294
  }
276
295
 
277
296
  /* AN AI ANSWERS IN A ROOM (what its own folder says, entry.room): only to a room it is in; signed by where it
@@ -24,6 +24,8 @@ export const SETTINGS_WORDS_FR = {
24
24
  'set-free-of': '{free} libres sur {total}', 'set-left-of': '{free} restants sur {total}', 'set-highest': 'au plus {n} % en 1 h',
25
25
  'set-core': '1 cœur', 'set-cores': '{n} cœurs',
26
26
  'set-update-out': '{v} est sortie', 'set-up-to-date': 'À jour · {v}', 'set-wifi-on': 'Wi-Fi · {name}', 'set-wifi-off': 'Wi-Fi non utilisé', 'set-cable': 'Câble',
27
+ /* A room message an AI could not get (its chat off): rooms.mjs say, missed. */
28
+ 'set-room-missed': '{names} ne l\'a pas reçu : discussion coupée',
27
29
  /* A box's learning session (boxcollect.mjs): the radio, its false wakes kept. */
28
30
  'set-collect': 'Séance d\'apprentissage (radio)', 'set-collect-from': 'de', 'set-collect-to': 'à',
29
31
  'set-collect-go': 'Programmer', 'set-collect-stop': 'Arrêter', 'set-collect-scheduled': 'Programmée de {from} à {to}',
@@ -80,6 +82,9 @@ export const SETTINGS_WORDS_FR = {
80
82
  'set-apikeys-convs': 'Conversations', 'set-apikeys-convs-hint': 'Une par une : lire ou parler', 'set-apikeys-off': 'Non',
81
83
  'set-apikeys-read': 'Lire', 'set-apikeys-talk': 'Parler', 'set-apikeys-no-convs': 'Aucune conversation pour l\'instant',
82
84
  'set-apikeys-journal': 'Journal', 'set-apikeys-no-use': 'Aucun appel pour l\'instant',
85
+ 'set-apikeys-rooms': 'Salons', 'set-apikeys-rooms-hint': 'Un par un : lire ou parler', 'set-apikeys-no-rooms': 'Aucun salon pour l\'instant',
86
+ 'set-apikeys-boxes': 'Boîtiers vocaux', 'set-apikeys-boxes-hint': 'Un par un : état, ou parler à voix haute', 'set-apikeys-no-boxes': 'Aucun boîtier pour l\'instant',
87
+ 'set-apikeys-state': 'État', 'set-apikeys-speak': 'Parler à voix haute',
83
88
  /* The rights of an asset (06-11, kit.js RIGHTS): what it has (a pill), a choice, its one line. */
84
89
  'set-rt-none': 'Aucun', 'set-rt-read': 'Lit', 'set-rt-write': 'Écrit', 'set-rt-use': 'Utilise', 'set-rt-watch': 'Surveille',
85
90
  'set-rt-propose': 'Propose', 'set-rt-frame': 'Encadré', 'set-rt-free': 'Libre', 'set-rt-terminal': 'Terminal', 'set-rt-chat': 'Chat',
package/web/app.js CHANGED
@@ -5613,7 +5613,13 @@
5613
5613
  if (known && JSON.stringify(thinkingOf(known)) !== JSON.stringify(who)) { known.thinking = who; paint(); }
5614
5614
  return d;
5615
5615
  }),
5616
- send: (text, answers, files) => post(`${API}/rooms/${id}/talk`, { text, ...(files?.length ? { files: files.map((f) => f.path) } : {}) }),
5616
+ /* Kept in the room even when an AI of it could not get it (its chat off, 07-11): said for a moment, by name. */
5617
+ send: async (text, answers, files) => {
5618
+ const d = await post(`${API}/rooms/${id}/talk`, { text, ...(files?.length ? { files: files.map((f) => f.path) } : {}) });
5619
+ const missed = Array.isArray(d?.missed) ? d.missed.map((m) => String(m?.name ?? '')).filter(Boolean) : [];
5620
+ if (missed.length) flash(uiSaid('set-room-missed', '{names} did not get it: chat off', { names: missed.join(', ') }));
5621
+ return d;
5622
+ },
5617
5623
  /* A file attached in a room (06-11): as in a conversation's chat; kept in the room, a copy to each AI the message goes to. */
5618
5624
  attach: { upload: async (file) => { const x = await fetch(`${API}/rooms/${id}/talk/file?${new URLSearchParams({ name: file.name })}`, { method: 'POST', headers: { 'content-type': 'application/octet-stream' }, body: file }); const j = await x.json().catch(() => ({})); if (!x.ok) throw new Error(j.error || 'The file could not be sent.'); return j; } },
5619
5625
  /* A file an AI of the room showed or cited (ai:<member>:work:a.png): read from its own conversation, in the same viewer as in its chat. */
@@ -6554,7 +6560,27 @@
6554
6560
  }
6555
6561
  return box;
6556
6562
  }
6563
+ /* ITS ROOMS AND VOICE BOXES (07-11), one by one the same way: a room Off / Read / Talk, a box Off / State /
6564
+ Speak aloud -- said plainly: it is heard in the home. `kind`: 'rooms' or 'boxes'; `chosen`: id -> level. */
6565
+ const apiKeyBoxWord = (level) => (level === 'speak' ? uiSaid('set-apikeys-speak', 'Speak aloud') : uiSaid('set-apikeys-state', 'State'));
6566
+ const apiKeyItems = (kind) => (kind === 'rooms' ? roomsNow().filter((r) => r && r.id).map((r) => ({ id: r.id, name: r.name })) : boxesNow().filter((b) => b && b.id).map((b) => ({ id: b.id, name: b.name })));
6567
+ function apiKeyOnePicker(kind, chosen, onChange) {
6568
+ const box = el('div', `apikey-${kind}`);
6569
+ const list = apiKeyItems(kind);
6570
+ if (!list.length) box.append(el('small', 'hint', kind === 'rooms' ? uiSaid('set-apikeys-no-rooms', 'No room yet') : uiSaid('set-apikeys-no-boxes', 'No voice box yet')));
6571
+ const options = kind === 'rooms'
6572
+ ? [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'read', label: apiKeyLevelWord('read') }, { value: 'talk', label: apiKeyLevelWord('talk') }]
6573
+ : [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'state', label: apiKeyBoxWord('state') }, { value: 'speak', label: apiKeyBoxWord('speak') }];
6574
+ for (const x of list) {
6575
+ const row = el('div', kind === 'rooms' ? 'apikey-conv apikey-room' : 'apikey-conv apikey-box'); row.dataset.id = x.id;
6576
+ row.append(el('span', '', x.name), KIT.Segmented({ options, value: chosen.get(x.id) ?? '', label: x.name, onChange: (v) => onChange(x.id, v || null) }));
6577
+ box.append(row);
6578
+ }
6579
+ return box;
6580
+ }
6557
6581
  const apiKeyNewConvs = new Map();
6582
+ const apiKeyNewRooms = new Map();
6583
+ const apiKeyNewBoxes = new Map();
6558
6584
  /* The key whose conversations or journal are open under it, kept across a repaint. */
6559
6585
  let apiKeyOpen = null;
6560
6586
  async function paintApiKeyJournal(box, id) {
@@ -6574,6 +6600,7 @@
6574
6600
  const got = DEVICE ? null : await fetch(`${API}/apikeys`).then((r) => (r.ok ? r.json() : null)).catch(() => null);
6575
6601
  $('settings-ch-apikeys').hidden = !got || !Array.isArray(got.keys);
6576
6602
  if (!got || !Array.isArray(got.keys)) return;
6603
+ await Promise.all([loadRooms(), loadBoxes()]);
6577
6604
  const scopeWord = (id) => uiSaid(`set-apikeys-scope-${id}`, got.scopes.find((x) => x.id === id)?.label ?? id);
6578
6605
  const live = got.keys.filter((k) => !k.expired).length;
6579
6606
  chapterNow('s-apikeys', got.keys.length ? uiSaid(live === 1 ? 'set-apikeys-n1' : 'set-apikeys-n', live === 1 ? '1 key' : '{n} keys', { n: live }) : uiSaid('set-apikeys-about', 'For a program or an AI.'));
@@ -6594,7 +6621,9 @@
6594
6621
  what.append(head);
6595
6622
  const tags = el('span', 'apikey-scopes');
6596
6623
  const given = [...k.scopes.map((sc) => ({ text: scopeWord(sc) })),
6597
- ...(k.conversations ?? []).map((c) => ({ text: `${shownName(conversations.find((x) => x.name === c.name) ?? { name: c.name })} · ${apiKeyLevelWord(c.level)}`, tone: c.level === 'talk' ? 'note' : undefined }))];
6624
+ ...(k.conversations ?? []).map((c) => ({ text: `${shownName(conversations.find((x) => x.name === c.name) ?? { name: c.name })} · ${apiKeyLevelWord(c.level)}`, tone: c.level === 'talk' ? 'note' : undefined })),
6625
+ ...(k.rooms ?? []).map((r) => ({ text: `${roomsNow().find((x) => x.id === r.id)?.name ?? r.id.slice(0, 8)} · ${apiKeyLevelWord(r.level)}`, icon: 'room', tone: r.level === 'talk' ? 'note' : undefined })),
6626
+ ...(k.boxes ?? []).map((b) => ({ text: `${boxesNow().find((x) => x.id === b.id)?.name ?? b.id.slice(0, 8)} · ${apiKeyBoxWord(b.level)}`, icon: 'box', tone: b.level === 'speak' ? 'note' : undefined }))];
6598
6627
  tags.append(KIT.Chips(given.length ? given : [{ text: uiSaid('set-apikeys-no-scope', 'No scope') }]));
6599
6628
  what.append(tags);
6600
6629
  const by = k.createdBy ? (k.createdBy === got.me ? uiSaid('set-apikeys-by-you', 'by you') : k.createdBy) : '';
@@ -6617,12 +6646,20 @@
6617
6646
  const more = el('div', 'apikey-more'); more.hidden = true;
6618
6647
  const open = async (kind) => {
6619
6648
  apiKeyOpen = apiKeyOpen?.id === k.id && apiKeyOpen.what === kind ? null : { id: k.id, what: kind };
6620
- for (const b of acts.querySelectorAll('button[data-act="convs"], button[data-act="journal"]')) b.setAttribute('aria-expanded', String(apiKeyOpen?.id === k.id && apiKeyOpen.what === b.dataset.act));
6649
+ for (const b of acts.querySelectorAll('button[data-act="convs"], button[data-act="rooms"], button[data-act="boxes"], button[data-act="journal"]')) b.setAttribute('aria-expanded', String(apiKeyOpen?.id === k.id && apiKeyOpen.what === b.dataset.act));
6621
6650
  more.hidden = apiKeyOpen?.id !== k.id;
6622
6651
  more.replaceChildren();
6623
6652
  if (!apiKeyOpen || apiKeyOpen.id !== k.id) return;
6624
6653
  if (kind === 'journal') { more.dataset.what = 'journal'; await paintApiKeyJournal(more, k.id); return; }
6625
- more.dataset.what = 'convs';
6654
+ more.dataset.what = kind;
6655
+ if (kind === 'rooms' || kind === 'boxes') {
6656
+ more.append(apiKeyOnePicker(kind, new Map((k[kind] ?? []).map((x) => [x.id, x.level])), async (ref, level) => {
6657
+ $('apikeys-error').hidden = true;
6658
+ try { await post(`${API}/apikeys/${k.id}/${kind}`, { [kind === 'rooms' ? 'room' : 'box']: ref, level }); } catch (error) { $('apikeys-error').textContent = error.message; $('apikeys-error').hidden = false; }
6659
+ await paintApiKeys();
6660
+ }));
6661
+ return;
6662
+ }
6626
6663
  more.append(apiKeyConvPicker(new Map((k.conversations ?? []).map((c) => [c.name, c.level])), async (name, level) => {
6627
6664
  $('apikeys-error').hidden = true;
6628
6665
  try { await post(`${API}/apikeys/${k.id}/conversations`, { conversation: name, level }); } catch (error) { $('apikeys-error').textContent = error.message; $('apikeys-error').hidden = false; }
@@ -6633,7 +6670,15 @@
6633
6670
  const journal = el('button', '', uiSaid('set-apikeys-journal', 'Journal')); journal.type = 'button'; journal.dataset.act = 'journal';
6634
6671
  convs.addEventListener('click', () => void open('convs'));
6635
6672
  journal.addEventListener('click', () => void open('journal'));
6636
- acts.append(convs, journal, revoke);
6673
+ acts.append(convs);
6674
+ /* Rooms, voice boxes: offered when there are some (or the key has some still). */
6675
+ for (const [kind, word] of [['rooms', uiSaid('set-apikeys-rooms', 'Rooms')], ['boxes', uiSaid('set-apikeys-boxes', 'Voice boxes')]]) {
6676
+ if (!apiKeyItems(kind).length && !(k[kind] ?? []).length) continue;
6677
+ const b = el('button', '', word); b.type = 'button'; b.dataset.act = kind;
6678
+ b.addEventListener('click', () => void open(kind));
6679
+ acts.append(b);
6680
+ }
6681
+ acts.append(journal, revoke);
6637
6682
  row.append(what, acts, more);
6638
6683
  rows.append(row);
6639
6684
  if (apiKeyOpen?.id === k.id) { const kind = apiKeyOpen.what; apiKeyOpen = null; void open(kind); }
@@ -6643,8 +6688,10 @@
6643
6688
  $('apikeys-form').hidden = !on; $('apikeys-new-row').hidden = on; $('apikeys-error').hidden = true;
6644
6689
  if (on) {
6645
6690
  $('apikeys-label').value = ''; $('apikeys-days').value = '90'; for (const b of $('apikeys-scopes').querySelectorAll('input')) b.checked = false;
6646
- apiKeyNewConvs.clear();
6691
+ apiKeyNewConvs.clear(); apiKeyNewRooms.clear(); apiKeyNewBoxes.clear();
6647
6692
  $('apikeys-convs').replaceChildren(apiKeyConvPicker(apiKeyNewConvs, (name, level) => { if (level) apiKeyNewConvs.set(name, level); else apiKeyNewConvs.delete(name); }));
6693
+ $('apikeys-rooms').replaceChildren(apiKeyOnePicker('rooms', apiKeyNewRooms, (id, level) => { if (level) apiKeyNewRooms.set(id, level); else apiKeyNewRooms.delete(id); }));
6694
+ $('apikeys-boxes').replaceChildren(apiKeyOnePicker('boxes', apiKeyNewBoxes, (id, level) => { if (level) apiKeyNewBoxes.set(id, level); else apiKeyNewBoxes.delete(id); }));
6648
6695
  $('apikeys-label').focus();
6649
6696
  }
6650
6697
  };
@@ -6656,14 +6703,15 @@
6656
6703
  const days = $('apikeys-days').value;
6657
6704
  try {
6658
6705
  const scopes = [...$('apikeys-scopes').querySelectorAll('input:checked')].map((b) => b.value);
6659
- const r = await post(`${API}/apikeys`, { label: $('apikeys-label').value.trim(), scopes, conversations: Object.fromEntries(apiKeyNewConvs), days: days === 'never' ? null : Number(days) });
6706
+ const r = await post(`${API}/apikeys`, { label: $('apikeys-label').value.trim(), scopes, conversations: Object.fromEntries(apiKeyNewConvs), rooms: Object.fromEntries(apiKeyNewRooms), boxes: Object.fromEntries(apiKeyNewBoxes), days: days === 'never' ? null : Number(days) });
6660
6707
  apiKeyFormShown(false);
6661
6708
  $('apikeys-secret').value = r.secret; $('apikeys-once').hidden = false;
6662
6709
  /* 06-11 (the owner): the configuration an AI tool takes, ready to paste -- its MCP door and the key, shown this once too. */
6663
6710
  const slug = (appName || 'clavarium').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'clavarium';
6664
6711
  $('apikeys-config').textContent = JSON.stringify({ mcpServers: { [slug]: { type: 'http', url: `${location.origin}/api/ext/mcp`, headers: { Authorization: `Bearer ${r.secret}` } } } }, null, 2);
6665
- /* The curl line reads what it was given: the status, or else its conversations. */
6666
- $('apikeys-curl').textContent = `curl -H "Authorization: Bearer ${r.secret}" ${location.origin}/api/ext/v1/${scopes.includes('status') || !apiKeyNewConvs.size ? 'status' : 'conversations'}`;
6712
+ /* The curl line reads what it was given: the status, or else its conversations, rooms or voice boxes. */
6713
+ const first = scopes.includes('status') ? 'status' : apiKeyNewConvs.size ? 'conversations' : apiKeyNewRooms.size ? 'rooms' : apiKeyNewBoxes.size ? 'boxes' : 'status';
6714
+ $('apikeys-curl').textContent = `curl -H "Authorization: Bearer ${r.secret}" ${location.origin}/api/ext/v1/${first}`;
6667
6715
  } catch (error) { $('apikeys-error').textContent = error.message; $('apikeys-error').hidden = false; return; }
6668
6716
  await paintApiKeys();
6669
6717
  });
package/web/index.html CHANGED
@@ -1053,6 +1053,8 @@
1053
1053
  <div class="set-row"><div class="set-what"><label for="apikeys-label" data-said="set-apikeys-name">Name</label><small data-said="set-apikeys-name-hint">What it is for</small></div><div class="set-control"><input id="apikeys-label" maxlength="40" spellcheck="false" required></div></div>
1054
1054
  <div class="set-row"><div class="set-what"><span data-said="set-apikeys-scopes">Server</span><small data-said="set-apikeys-scopes-hint">What the key sees of it</small></div><div class="set-control" id="apikeys-scopes"></div></div>
1055
1055
  <div class="set-row"><div class="set-what"><span data-said="set-apikeys-convs">Conversations</span><small data-said="set-apikeys-convs-hint">One by one: read or talk</small></div><div class="set-control" id="apikeys-convs"></div></div>
1056
+ <div class="set-row"><div class="set-what"><span data-said="set-apikeys-rooms">Rooms</span><small data-said="set-apikeys-rooms-hint">One by one: read or talk</small></div><div class="set-control" id="apikeys-rooms"></div></div>
1057
+ <div class="set-row"><div class="set-what"><span data-said="set-apikeys-boxes">Voice boxes</span><small data-said="set-apikeys-boxes-hint">One by one: state, or speak aloud</small></div><div class="set-control" id="apikeys-boxes"></div></div>
1056
1058
  <div class="set-row"><div class="set-what"><label for="apikeys-days" data-said="set-apikeys-expires">Expires</label></div><div class="set-control"><select id="apikeys-days"><option value="30" data-said="set-apikeys-30">In 30 days</option><option value="90" data-said="set-apikeys-90" selected>In 90 days</option><option value="365" data-said="set-apikeys-365">In a year</option><option value="never" data-said="set-apikeys-never">Never</option></select></div></div>
1057
1059
  <div class="action-row"><span class="error" id="apikeys-error" role="alert" hidden></span><button type="button" id="apikeys-cancel" data-said="set-apikeys-cancel">Cancel</button><button type="submit" class="primary" data-said="set-apikeys-create">Create</button></div>
1058
1060
  </form>