clavarium 0.2.367-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/api.mjs +9 -0
- package/server/apikeys.mjs +263 -20
- package/server/boxes.mjs +21 -4
- package/server/grants.mjs +11 -5
- package/server/health.mjs +41 -4
- package/server/linkguard.mjs +191 -0
- package/server/main.mjs +34 -3
- package/server/resources.mjs +6 -0
- package/server/rooms.mjs +18 -8
- package/server/settingswords.mjs +10 -0
- package/server/talk.mjs +3 -2
- package/web/app.js +87 -8
- package/web/index.html +4 -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/api.mjs
CHANGED
|
@@ -700,6 +700,8 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
700
700
|
if (access !== 'write') gitKeeper?.forgetAttachment(name, conversation);
|
|
701
701
|
await resources.attach(conversation, name, access);
|
|
702
702
|
retell(conversation);
|
|
703
|
+
/* Newly attached: its links looked at (and its mounts), not waited for. */
|
|
704
|
+
if (access !== 'none') void health?.check?.(conversation, { asked: true })?.catch?.(() => {});
|
|
703
705
|
};
|
|
704
706
|
const handSecret = async (conversation, name, attached) => {
|
|
705
707
|
if (!conversations.list().some((c) => c.name === conversation)) throw new Refusal(404, 'No such conversation.');
|
|
@@ -868,6 +870,13 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
868
870
|
if (!conversations.list().some((c) => c.name === conversation)) throw new Refusal(404, 'No such conversation.');
|
|
869
871
|
return send(200, health.markSeen(conversation)), true;
|
|
870
872
|
}
|
|
873
|
+
/* "Check shared folders": each mounted and readable (put back when loose), each link the share cannot serve (health.mjs). */
|
|
874
|
+
const foldersOf = /^\/api\/conversations\/([^/]+)\/folders\/check$/.exec(pathname);
|
|
875
|
+
if (foldersOf && health) {
|
|
876
|
+
const conversation = decodeURIComponent(foldersOf[1]);
|
|
877
|
+
if (!conversations.list().some((c) => c.name === conversation)) throw new Refusal(404, 'No such conversation.');
|
|
878
|
+
return send(200, await health.checkFolders(conversation)), true;
|
|
879
|
+
}
|
|
871
880
|
const machine = /^\/api\/conversations\/([^/]+)\/(restart|machine|health)$/.exec(pathname);
|
|
872
881
|
if (machine) {
|
|
873
882
|
const conversation = decodeURIComponent(machine[1]);
|
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;
|