clavarium 0.2.368-next.1 → 0.2.369-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.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/apikeys.mjs +263 -20
- package/server/boxes.mjs +21 -4
- package/server/grants.mjs +11 -5
- package/server/main.mjs +20 -2
- package/server/rooms.mjs +18 -8
- package/server/settingswords.mjs +3 -0
- package/web/app.js +50 -8
- package/web/index.html +2 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.369-next.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "clavarium",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.369-next.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/package.json
CHANGED
package/server/apikeys.mjs
CHANGED
|
@@ -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
|
|
53
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 } }. `
|
|
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,39 @@ 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
|
+
await rooms.say(room, text, { as: keys.roomSignature(id), to: Array.isArray(to) ? to : [] });
|
|
563
|
+
return { ok: true };
|
|
564
|
+
};
|
|
565
|
+
const boxesListed = (id) => {
|
|
566
|
+
const there = new Map((boxes?.list() ?? []).map((b) => [b.id, b]));
|
|
567
|
+
return { boxes: keys.boxesOf(id).filter((g) => there.has(g.id)).map((g) => { const b = there.get(g.id);
|
|
568
|
+
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 }; }) };
|
|
569
|
+
};
|
|
570
|
+
const boxSaying = async (id, box, text) => {
|
|
571
|
+
keys.mayBox(id, box, 'speak');
|
|
572
|
+
if (!(boxes?.list() ?? []).some((b) => b.id === box)) throw new Refusal(403, 'This key was not given this voice box.');
|
|
573
|
+
if (typeof text !== 'string' || !text.trim()) throw new Refusal(400, 'Say something: text.');
|
|
574
|
+
if (text.length > SPEAK_MAX) throw new Refusal(400, `${SPEAK_MAX} characters at most, said aloud.`);
|
|
575
|
+
keys.speakCounted(id, box);
|
|
576
|
+
const done = await boxes.say(box, text, keys.signature(id));
|
|
577
|
+
return { ok: true, ...(Number.isFinite(done?.seconds) ? { seconds: done.seconds } : {}) };
|
|
578
|
+
};
|
|
397
579
|
const ownError = (error, id) => (error instanceof Refusal ? Object.assign(error, { key: error.key ?? id }) : error);
|
|
398
580
|
try {
|
|
399
581
|
if (route === '/v1/status') {
|
|
@@ -423,6 +605,43 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
423
605
|
return send(201, done);
|
|
424
606
|
} catch (error) { return refused(ownError(error, id)); }
|
|
425
607
|
}
|
|
608
|
+
const room = /^\/v1\/rooms(?:\/([^/]+)\/messages)?$/.exec(route);
|
|
609
|
+
if (room) {
|
|
610
|
+
const allowed = room[1] ? 'GET, POST' : 'GET';
|
|
611
|
+
if (!allowed.split(', ').includes(request.method)) return send(405, { error: `Call this address with ${allowed}.` }, { allow: allowed });
|
|
612
|
+
let id;
|
|
613
|
+
try { id = keys.check(request.headers.authorization); } catch (error) { return refused(error); }
|
|
614
|
+
try {
|
|
615
|
+
if (!room[1]) { const body = roomsListed(id); note(id, 200); return send(200, body); }
|
|
616
|
+
let ref;
|
|
617
|
+
try { ref = decodeURIComponent(room[1]); } catch { ref = ''; }
|
|
618
|
+
if (request.method === 'GET') { const body = roomReading(id, ref, readPart((k) => url.searchParams.get(k))); note(id, 200); return send(200, body); }
|
|
619
|
+
if (!String(request.headers['content-type'] ?? '').startsWith('application/json')) throw new Refusal(415, 'Send JSON: {"text": "..."}.');
|
|
620
|
+
let body;
|
|
621
|
+
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.'); }
|
|
622
|
+
const done = await roomSaying(id, ref, body?.text, body?.to);
|
|
623
|
+
note(id, 201);
|
|
624
|
+
return send(201, done);
|
|
625
|
+
} catch (error) { return refused(ownError(error, id)); }
|
|
626
|
+
}
|
|
627
|
+
const box = /^\/v1\/boxes(?:\/([^/]+)\/say)?$/.exec(route);
|
|
628
|
+
if (box) {
|
|
629
|
+
const allowed = box[1] ? 'POST' : 'GET';
|
|
630
|
+
if (request.method !== allowed) return send(405, { error: `Call this address with ${allowed}.` }, { allow: allowed });
|
|
631
|
+
let id;
|
|
632
|
+
try { id = keys.check(request.headers.authorization); } catch (error) { return refused(error); }
|
|
633
|
+
try {
|
|
634
|
+
if (!box[1]) { const body = boxesListed(id); note(id, 200); return send(200, body); }
|
|
635
|
+
let ref;
|
|
636
|
+
try { ref = decodeURIComponent(box[1]); } catch { ref = ''; }
|
|
637
|
+
if (!String(request.headers['content-type'] ?? '').startsWith('application/json')) throw new Refusal(415, 'Send JSON: {"text": "..."}.');
|
|
638
|
+
let body;
|
|
639
|
+
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.'); }
|
|
640
|
+
const done = await boxSaying(id, ref, body?.text);
|
|
641
|
+
note(id, 200);
|
|
642
|
+
return send(200, done);
|
|
643
|
+
} catch (error) { return refused(ownError(error, id)); }
|
|
644
|
+
}
|
|
426
645
|
if (route === '/mcp') {
|
|
427
646
|
if (request.method !== 'POST') return send(405, { error: 'MCP is spoken with POST here (no event stream).' }, { allow: 'POST' });
|
|
428
647
|
let id;
|
|
@@ -435,8 +654,9 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
435
654
|
note(id, 400, '/mcp'); return send(400, { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } });
|
|
436
655
|
}
|
|
437
656
|
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
|
|
657
|
+
/* The journal says which tool, and on which conversation, room or box. */
|
|
658
|
+
const on = [args.conversation, args.room, args.box].find((v) => typeof v === 'string');
|
|
659
|
+
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
660
|
/* A notification (no id) is taken, never answered. */
|
|
441
661
|
if (msg.id === undefined || msg.id === null) { note(id, 202, what); response.writeHead(202, { ...headers, 'cache-control': 'no-store' }).end(); return true; }
|
|
442
662
|
const answer = (result, code = 200) => { note(id, code, what); return send(200, { jsonrpc: '2.0', id: msg.id, result }); };
|
|
@@ -447,7 +667,7 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
447
667
|
const asked = String(msg.params?.protocolVersion ?? '');
|
|
448
668
|
return answer({ protocolVersion: MCP_VERSIONS.includes(asked) ? asked : MCP_VERSIONS[0], capabilities: { tools: { listChanged: false } },
|
|
449
669
|
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).' });
|
|
670
|
+
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
671
|
}
|
|
452
672
|
case 'ping': return answer({});
|
|
453
673
|
case 'tools/list': return answer({ tools: TOOLS });
|
|
@@ -460,14 +680,28 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
460
680
|
return result(await status());
|
|
461
681
|
}
|
|
462
682
|
if (name === CONVERSATIONS_TOOL.name) return result(listed(id));
|
|
463
|
-
if (
|
|
464
|
-
if (name ===
|
|
465
|
-
const
|
|
683
|
+
if (name === ROOMS_TOOL.name) return result(roomsListed(id));
|
|
684
|
+
if (name === BOXES_TOOL.name) return result(boxesListed(id));
|
|
685
|
+
const part = () => readPart((k) => (args[k] === undefined ? null : typeof args[k] === 'number' ? String(args[k]) : args[k]));
|
|
686
|
+
let done;
|
|
687
|
+
if (name === ROOM_READ_TOOL.name || name === ROOM_SAY_TOOL.name) {
|
|
688
|
+
if (typeof args.room !== 'string') return fail(-32602, 'room: the id of a room.');
|
|
689
|
+
if (name === ROOM_READ_TOOL.name) return result(roomReading(id, args.room, part()));
|
|
690
|
+
done = await roomSaying(id, args.room, args.text, args.to);
|
|
691
|
+
} else if (name === BOX_SAY_TOOL.name) {
|
|
692
|
+
if (typeof args.box !== 'string') return fail(-32602, 'box: the id of a voice box.');
|
|
693
|
+
done = await boxSaying(id, args.box, args.text);
|
|
694
|
+
} else {
|
|
695
|
+
if (typeof args.conversation !== 'string') return fail(-32602, 'conversation: the id of a conversation.');
|
|
696
|
+
if (name === READ_TOOL.name) return result(reading(id, args.conversation, part()));
|
|
697
|
+
done = await saying(id, args.conversation, args.text);
|
|
698
|
+
}
|
|
466
699
|
note(id, 201, what);
|
|
467
700
|
return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: JSON.stringify(done) }], structuredContent: done, isError: false } });
|
|
468
701
|
} catch (error) {
|
|
469
702
|
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. */
|
|
703
|
+
/* Not given: refused as the API refuses it (too many, too). Anything else is the tool's own failure, said to the caller. */
|
|
704
|
+
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
705
|
if (error.status === 403) { note(id, 403, what); return send(403, { jsonrpc: '2.0', id: msg.id, error: { code: -32001, message: error.message } }); }
|
|
472
706
|
note(id, error.status, what);
|
|
473
707
|
return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: error.message }], isError: true } });
|
|
@@ -477,7 +711,7 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
477
711
|
}
|
|
478
712
|
}
|
|
479
713
|
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.' });
|
|
714
|
+
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
715
|
} catch (error) {
|
|
482
716
|
if (error instanceof Refusal) return refused(error);
|
|
483
717
|
note(null, 500);
|
|
@@ -503,9 +737,9 @@ export async function handleApiKeys(request, response, keys, originOk, headers =
|
|
|
503
737
|
try { body = text ? JSON.parse(text) : {}; } catch { return send(400, { error: 'The request is not JSON.' }); }
|
|
504
738
|
if (!body || typeof body !== 'object') body = {};
|
|
505
739
|
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);
|
|
740
|
+
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
741
|
/* Its name and scopes; never the secret. */
|
|
508
|
-
const what = [...made.key.scopes, ...made.key.conversations.map((c) => `${c.name} ${c.level}`)];
|
|
742
|
+
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
743
|
events?.add({ kind: 'access', text: `API key "${made.key.label}" made${what.length ? ` (${what.join(', ')})` : ' (no scope)'}`, who: who || null });
|
|
510
744
|
return send(201, made);
|
|
511
745
|
}
|
|
@@ -516,6 +750,15 @@ export async function handleApiKeys(request, response, keys, originOk, headers =
|
|
|
516
750
|
events?.add({ kind: 'access', text: `API key "${key.label}": ${body.conversation} ${level ?? 'taken back'}`, who: who || null });
|
|
517
751
|
return send(200, { key });
|
|
518
752
|
}
|
|
753
|
+
const one = /^\/api\/apikeys\/([0-9a-f-]{36})\/(rooms|boxes)$/.exec(pathname);
|
|
754
|
+
if (one) {
|
|
755
|
+
const level = body.level === undefined ? null : body.level;
|
|
756
|
+
const room = one[2] === 'rooms';
|
|
757
|
+
const ref = room ? body.room : body.box;
|
|
758
|
+
const key = room ? keys.setRoom(one[1], ref, level) : keys.setBox(one[1], ref, level);
|
|
759
|
+
events?.add({ kind: 'access', text: `API key "${key.label}": ${room ? 'room' : 'voice box'} ${String(ref).slice(0, 8)} ${level ?? 'taken back'}`, who: who || null });
|
|
760
|
+
return send(200, { key });
|
|
761
|
+
}
|
|
519
762
|
const revoke = /^\/api\/apikeys\/([0-9a-f-]{36})\/revoke$/.exec(pathname);
|
|
520
763
|
if (revoke) {
|
|
521
764
|
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 }) =>
|
|
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
|
-
* --
|
|
33
|
-
*
|
|
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 (
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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);
|
package/server/settingswords.mjs
CHANGED
|
@@ -80,6 +80,9 @@ export const SETTINGS_WORDS_FR = {
|
|
|
80
80
|
'set-apikeys-convs': 'Conversations', 'set-apikeys-convs-hint': 'Une par une : lire ou parler', 'set-apikeys-off': 'Non',
|
|
81
81
|
'set-apikeys-read': 'Lire', 'set-apikeys-talk': 'Parler', 'set-apikeys-no-convs': 'Aucune conversation pour l\'instant',
|
|
82
82
|
'set-apikeys-journal': 'Journal', 'set-apikeys-no-use': 'Aucun appel pour l\'instant',
|
|
83
|
+
'set-apikeys-rooms': 'Salons', 'set-apikeys-rooms-hint': 'Un par un : lire ou parler', 'set-apikeys-no-rooms': 'Aucun salon pour l\'instant',
|
|
84
|
+
'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',
|
|
85
|
+
'set-apikeys-state': 'État', 'set-apikeys-speak': 'Parler à voix haute',
|
|
83
86
|
/* The rights of an asset (06-11, kit.js RIGHTS): what it has (a pill), a choice, its one line. */
|
|
84
87
|
'set-rt-none': 'Aucun', 'set-rt-read': 'Lit', 'set-rt-write': 'Écrit', 'set-rt-use': 'Utilise', 'set-rt-watch': 'Surveille',
|
|
85
88
|
'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
|
@@ -6554,7 +6554,27 @@
|
|
|
6554
6554
|
}
|
|
6555
6555
|
return box;
|
|
6556
6556
|
}
|
|
6557
|
+
/* ITS ROOMS AND VOICE BOXES (07-11), one by one the same way: a room Off / Read / Talk, a box Off / State /
|
|
6558
|
+
Speak aloud -- said plainly: it is heard in the home. `kind`: 'rooms' or 'boxes'; `chosen`: id -> level. */
|
|
6559
|
+
const apiKeyBoxWord = (level) => (level === 'speak' ? uiSaid('set-apikeys-speak', 'Speak aloud') : uiSaid('set-apikeys-state', 'State'));
|
|
6560
|
+
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 })));
|
|
6561
|
+
function apiKeyOnePicker(kind, chosen, onChange) {
|
|
6562
|
+
const box = el('div', `apikey-${kind}`);
|
|
6563
|
+
const list = apiKeyItems(kind);
|
|
6564
|
+
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')));
|
|
6565
|
+
const options = kind === 'rooms'
|
|
6566
|
+
? [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'read', label: apiKeyLevelWord('read') }, { value: 'talk', label: apiKeyLevelWord('talk') }]
|
|
6567
|
+
: [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'state', label: apiKeyBoxWord('state') }, { value: 'speak', label: apiKeyBoxWord('speak') }];
|
|
6568
|
+
for (const x of list) {
|
|
6569
|
+
const row = el('div', kind === 'rooms' ? 'apikey-conv apikey-room' : 'apikey-conv apikey-box'); row.dataset.id = x.id;
|
|
6570
|
+
row.append(el('span', '', x.name), KIT.Segmented({ options, value: chosen.get(x.id) ?? '', label: x.name, onChange: (v) => onChange(x.id, v || null) }));
|
|
6571
|
+
box.append(row);
|
|
6572
|
+
}
|
|
6573
|
+
return box;
|
|
6574
|
+
}
|
|
6557
6575
|
const apiKeyNewConvs = new Map();
|
|
6576
|
+
const apiKeyNewRooms = new Map();
|
|
6577
|
+
const apiKeyNewBoxes = new Map();
|
|
6558
6578
|
/* The key whose conversations or journal are open under it, kept across a repaint. */
|
|
6559
6579
|
let apiKeyOpen = null;
|
|
6560
6580
|
async function paintApiKeyJournal(box, id) {
|
|
@@ -6574,6 +6594,7 @@
|
|
|
6574
6594
|
const got = DEVICE ? null : await fetch(`${API}/apikeys`).then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
|
6575
6595
|
$('settings-ch-apikeys').hidden = !got || !Array.isArray(got.keys);
|
|
6576
6596
|
if (!got || !Array.isArray(got.keys)) return;
|
|
6597
|
+
await Promise.all([loadRooms(), loadBoxes()]);
|
|
6577
6598
|
const scopeWord = (id) => uiSaid(`set-apikeys-scope-${id}`, got.scopes.find((x) => x.id === id)?.label ?? id);
|
|
6578
6599
|
const live = got.keys.filter((k) => !k.expired).length;
|
|
6579
6600
|
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 +6615,9 @@
|
|
|
6594
6615
|
what.append(head);
|
|
6595
6616
|
const tags = el('span', 'apikey-scopes');
|
|
6596
6617
|
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 }))
|
|
6618
|
+
...(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 })),
|
|
6619
|
+
...(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 })),
|
|
6620
|
+
...(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
6621
|
tags.append(KIT.Chips(given.length ? given : [{ text: uiSaid('set-apikeys-no-scope', 'No scope') }]));
|
|
6599
6622
|
what.append(tags);
|
|
6600
6623
|
const by = k.createdBy ? (k.createdBy === got.me ? uiSaid('set-apikeys-by-you', 'by you') : k.createdBy) : '';
|
|
@@ -6617,12 +6640,20 @@
|
|
|
6617
6640
|
const more = el('div', 'apikey-more'); more.hidden = true;
|
|
6618
6641
|
const open = async (kind) => {
|
|
6619
6642
|
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));
|
|
6643
|
+
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
6644
|
more.hidden = apiKeyOpen?.id !== k.id;
|
|
6622
6645
|
more.replaceChildren();
|
|
6623
6646
|
if (!apiKeyOpen || apiKeyOpen.id !== k.id) return;
|
|
6624
6647
|
if (kind === 'journal') { more.dataset.what = 'journal'; await paintApiKeyJournal(more, k.id); return; }
|
|
6625
|
-
more.dataset.what =
|
|
6648
|
+
more.dataset.what = kind;
|
|
6649
|
+
if (kind === 'rooms' || kind === 'boxes') {
|
|
6650
|
+
more.append(apiKeyOnePicker(kind, new Map((k[kind] ?? []).map((x) => [x.id, x.level])), async (ref, level) => {
|
|
6651
|
+
$('apikeys-error').hidden = true;
|
|
6652
|
+
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; }
|
|
6653
|
+
await paintApiKeys();
|
|
6654
|
+
}));
|
|
6655
|
+
return;
|
|
6656
|
+
}
|
|
6626
6657
|
more.append(apiKeyConvPicker(new Map((k.conversations ?? []).map((c) => [c.name, c.level])), async (name, level) => {
|
|
6627
6658
|
$('apikeys-error').hidden = true;
|
|
6628
6659
|
try { await post(`${API}/apikeys/${k.id}/conversations`, { conversation: name, level }); } catch (error) { $('apikeys-error').textContent = error.message; $('apikeys-error').hidden = false; }
|
|
@@ -6633,7 +6664,15 @@
|
|
|
6633
6664
|
const journal = el('button', '', uiSaid('set-apikeys-journal', 'Journal')); journal.type = 'button'; journal.dataset.act = 'journal';
|
|
6634
6665
|
convs.addEventListener('click', () => void open('convs'));
|
|
6635
6666
|
journal.addEventListener('click', () => void open('journal'));
|
|
6636
|
-
acts.append(convs
|
|
6667
|
+
acts.append(convs);
|
|
6668
|
+
/* Rooms, voice boxes: offered when there are some (or the key has some still). */
|
|
6669
|
+
for (const [kind, word] of [['rooms', uiSaid('set-apikeys-rooms', 'Rooms')], ['boxes', uiSaid('set-apikeys-boxes', 'Voice boxes')]]) {
|
|
6670
|
+
if (!apiKeyItems(kind).length && !(k[kind] ?? []).length) continue;
|
|
6671
|
+
const b = el('button', '', word); b.type = 'button'; b.dataset.act = kind;
|
|
6672
|
+
b.addEventListener('click', () => void open(kind));
|
|
6673
|
+
acts.append(b);
|
|
6674
|
+
}
|
|
6675
|
+
acts.append(journal, revoke);
|
|
6637
6676
|
row.append(what, acts, more);
|
|
6638
6677
|
rows.append(row);
|
|
6639
6678
|
if (apiKeyOpen?.id === k.id) { const kind = apiKeyOpen.what; apiKeyOpen = null; void open(kind); }
|
|
@@ -6643,8 +6682,10 @@
|
|
|
6643
6682
|
$('apikeys-form').hidden = !on; $('apikeys-new-row').hidden = on; $('apikeys-error').hidden = true;
|
|
6644
6683
|
if (on) {
|
|
6645
6684
|
$('apikeys-label').value = ''; $('apikeys-days').value = '90'; for (const b of $('apikeys-scopes').querySelectorAll('input')) b.checked = false;
|
|
6646
|
-
apiKeyNewConvs.clear();
|
|
6685
|
+
apiKeyNewConvs.clear(); apiKeyNewRooms.clear(); apiKeyNewBoxes.clear();
|
|
6647
6686
|
$('apikeys-convs').replaceChildren(apiKeyConvPicker(apiKeyNewConvs, (name, level) => { if (level) apiKeyNewConvs.set(name, level); else apiKeyNewConvs.delete(name); }));
|
|
6687
|
+
$('apikeys-rooms').replaceChildren(apiKeyOnePicker('rooms', apiKeyNewRooms, (id, level) => { if (level) apiKeyNewRooms.set(id, level); else apiKeyNewRooms.delete(id); }));
|
|
6688
|
+
$('apikeys-boxes').replaceChildren(apiKeyOnePicker('boxes', apiKeyNewBoxes, (id, level) => { if (level) apiKeyNewBoxes.set(id, level); else apiKeyNewBoxes.delete(id); }));
|
|
6648
6689
|
$('apikeys-label').focus();
|
|
6649
6690
|
}
|
|
6650
6691
|
};
|
|
@@ -6656,14 +6697,15 @@
|
|
|
6656
6697
|
const days = $('apikeys-days').value;
|
|
6657
6698
|
try {
|
|
6658
6699
|
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) });
|
|
6700
|
+
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
6701
|
apiKeyFormShown(false);
|
|
6661
6702
|
$('apikeys-secret').value = r.secret; $('apikeys-once').hidden = false;
|
|
6662
6703
|
/* 06-11 (the owner): the configuration an AI tool takes, ready to paste -- its MCP door and the key, shown this once too. */
|
|
6663
6704
|
const slug = (appName || 'clavarium').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'clavarium';
|
|
6664
6705
|
$('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
|
-
|
|
6706
|
+
/* The curl line reads what it was given: the status, or else its conversations, rooms or voice boxes. */
|
|
6707
|
+
const first = scopes.includes('status') ? 'status' : apiKeyNewConvs.size ? 'conversations' : apiKeyNewRooms.size ? 'rooms' : apiKeyNewBoxes.size ? 'boxes' : 'status';
|
|
6708
|
+
$('apikeys-curl').textContent = `curl -H "Authorization: Bearer ${r.secret}" ${location.origin}/api/ext/v1/${first}`;
|
|
6667
6709
|
} catch (error) { $('apikeys-error').textContent = error.message; $('apikeys-error').hidden = false; return; }
|
|
6668
6710
|
await paintApiKeys();
|
|
6669
6711
|
});
|
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>
|