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.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/apikeys.mjs +264 -20
- package/server/boxes.mjs +21 -4
- package/server/grants.mjs +11 -5
- package/server/main.mjs +20 -2
- package/server/rooms.mjs +32 -13
- package/server/settingswords.mjs +5 -0
- package/web/app.js +57 -9
- 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.370-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.370-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,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
|
|
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 (
|
|
464
|
-
if (name ===
|
|
465
|
-
const
|
|
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 }) =>
|
|
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);
|
|
@@ -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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
package/server/settingswords.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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>
|