clavarium 0.2.384-next.1 → 0.2.385-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 +319 -18
- package/server/binders.mjs +12 -0
- package/server/dockets.mjs +20 -1
- package/server/grants.mjs +2 -0
- package/server/main.mjs +8 -2
- package/server/settingswords.mjs +1 -0
- package/web/app.js +23 -13
- package/web/index.html +1 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.385-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.385-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
|
@@ -38,6 +38,23 @@
|
|
|
38
38
|
* change its settings nor update it box:<id> rw
|
|
39
39
|
* A room removed, a box withdrawn, takes its lines with it (main.mjs).
|
|
40
40
|
*
|
|
41
|
+
* a binder list (Binder), given ONE BY ONE (07-11):
|
|
42
|
+
* read list its records, read one with its notes (secrets masked)
|
|
43
|
+
* write and add a record, change its title, description, fields and
|
|
44
|
+
* due date, move it between states, add a note, archive or
|
|
45
|
+
* restore it -- as a conversation given the list to change may
|
|
46
|
+
* Kept where Binder keeps a conversation's lists: the list's own access
|
|
47
|
+
* ("apikey:<id>": read | write), not this table. The key asks Binder as an
|
|
48
|
+
* agent, signed with its name: Binder's own rules hold -- past a gate only
|
|
49
|
+
* what a person approved; never approve, delete, give access, change a
|
|
50
|
+
* list. Revoked, a key's lists go with it.
|
|
51
|
+
*
|
|
52
|
+
* a conversation's REMINDERS (its tool's Docket), folded into its level:
|
|
53
|
+
* read sees them
|
|
54
|
+
* talk and sets one: at a time, or a cron line, with a short text
|
|
55
|
+
* act and takes one back
|
|
56
|
+
* Its chat must be on (a docket is there only then).
|
|
57
|
+
*
|
|
41
58
|
* THE SECRET IS SHOWN ONCE. It is made here (32 random bytes, "clv_" before
|
|
42
59
|
* them), given back by `create` and never again: only its sha256 is kept, as
|
|
43
60
|
* a pairing's session is (devices.mjs). A 256-bit random secret needs no salt
|
|
@@ -70,7 +87,8 @@
|
|
|
70
87
|
* POST /api/apikeys/<id>/conversations {conversation, level: 'read'|'talk'|null}: one given, changed, taken back
|
|
71
88
|
* POST /api/apikeys/<id>/rooms {room, level: 'read'|'talk'|null}
|
|
72
89
|
* POST /api/apikeys/<id>/boxes {box, level: 'state'|'speak'|null}
|
|
73
|
-
*
|
|
90
|
+
* POST /api/apikeys/<id>/binders {binder, level: 'read'|'write'|null}
|
|
91
|
+
* (and on creation: rooms: {id: level}, boxes: {id: level}, binders: {id: level})
|
|
74
92
|
* POST /api/apikeys/<id>/revoke
|
|
75
93
|
* GET /api/apikeys/<id>/journal its last uses
|
|
76
94
|
*
|
|
@@ -88,12 +106,26 @@
|
|
|
88
106
|
* POST /api/ext/v1/rooms/<id>/remove (manage) the room removed
|
|
89
107
|
* GET /api/ext/v1/boxes {boxes: [{id, name, level, online, where, heardAt, volume}]}
|
|
90
108
|
* POST /api/ext/v1/boxes/<id>/say {text}: said aloud by the box
|
|
109
|
+
* GET /api/ext/v1/conversations/<name>/reminders (read) {reminders: [{id, text, cron, next}]}
|
|
110
|
+
* POST /api/ext/v1/conversations/<name>/reminders (talk) {text, at: "2026-11-20 09:00" | cron: "0 9 * * 1"}
|
|
111
|
+
* POST /api/ext/v1/conversations/<name>/reminders/<id>/cancel (act) taken back
|
|
112
|
+
* GET /api/ext/v1/binders {binders: [{id, title, level, states, fields, count}]}
|
|
113
|
+
* GET /api/ext/v1/binders/<list>/records ?state=&archived=1: {records}
|
|
114
|
+
* GET /api/ext/v1/binders/<list>/records/<id> {record}: with its notes
|
|
115
|
+
* POST /api/ext/v1/binders/<list>/records (write) {title, description?, state?, fields?, due?}
|
|
116
|
+
* POST /api/ext/v1/binders/<list>/records/<id> (write) {title?, description?, fields?, due?}
|
|
117
|
+
* POST /api/ext/v1/binders/<list>/records/<id>/move (write) {state}: never past a gate a person did not tick
|
|
118
|
+
* POST /api/ext/v1/binders/<list>/records/<id>/notes (write) {text}
|
|
119
|
+
* POST /api/ext/v1/binders/<list>/records/<id>/archive|restore (write)
|
|
91
120
|
* POST /api/ext/mcp the same as MCP tools (clavarium_status, clavarium_conversations,
|
|
92
121
|
* clavarium_read, clavarium_say, clavarium_rooms, clavarium_room_read,
|
|
93
122
|
* clavarium_room_say, clavarium_boxes, clavarium_box_say, clavarium_conversation_wake,
|
|
94
123
|
* clavarium_conversation_sleep, clavarium_conversation_stop, clavarium_room_create,
|
|
95
|
-
* clavarium_room_change, clavarium_room_remove
|
|
96
|
-
*
|
|
124
|
+
* clavarium_room_change, clavarium_room_remove, clavarium_reminders, clavarium_remind,
|
|
125
|
+
* clavarium_reminder_cancel, clavarium_binders, clavarium_binder_records,
|
|
126
|
+
* clavarium_binder_record, clavarium_binder_add, clavarium_binder_edit,
|
|
127
|
+
* clavarium_binder_move, clavarium_binder_note, clavarium_binder_archive), JSON-RPC over HTTP
|
|
128
|
+
* A conversation, room, box or list not given, or not there, is the same 403: a key learns nothing of what it was not given.
|
|
97
129
|
*
|
|
98
130
|
* The front lets /api/ext/ through without sign-on, as it does /c/
|
|
99
131
|
* (deploy/install.sh, skip_auth_routes; deploy/updater.mjs for installs made
|
|
@@ -136,6 +168,12 @@ export const ROOM_LEVELS = Object.freeze({ read: 'r', talk: 'rw', manage: 'rwm'
|
|
|
136
168
|
export const roomLevelOf = (rights) => (String(rights).includes('m') ? 'manage' : String(rights).includes('w') ? 'talk' : String(rights).includes('r') ? 'read' : null);
|
|
137
169
|
export const BOX_LEVELS = Object.freeze({ state: 'r', speak: 'rw' });
|
|
138
170
|
export const boxLevelOf = (rights) => (String(rights).includes('w') ? 'speak' : String(rights).includes('r') ? 'state' : null);
|
|
171
|
+
/* What a binder list may be given: Binder's own access words. */
|
|
172
|
+
export const BINDER_LEVELS = Object.freeze({ read: 'read', write: 'write' });
|
|
173
|
+
/* A reminder a key sets: a short text. */
|
|
174
|
+
export const REMINDER_MAX = 200;
|
|
175
|
+
/* A binder list's id, as Binder makes it. */
|
|
176
|
+
const LIST_ID = /^[a-z0-9][a-z0-9-]{0,99}$/;
|
|
139
177
|
/* Said aloud in the home: short, and a few a minute (per key and box). */
|
|
140
178
|
export const SPEAK_MAX = 300;
|
|
141
179
|
export const SPEAK_PER_MIN = 3;
|
|
@@ -147,14 +185,15 @@ const oneLine = (value) => typeof value === 'string' && value.trim() !== '' && v
|
|
|
147
185
|
const SECRET = /^clv_[A-Za-z0-9_-]{43}$/;
|
|
148
186
|
|
|
149
187
|
export class ApiKeys {
|
|
150
|
-
#store; #grants; #now; #journal; #exists; #kinds;
|
|
188
|
+
#store; #grants; #now; #journal; #exists; #kinds; #binders;
|
|
151
189
|
#used = new Map(); /* id -> { at, savedAt } */
|
|
152
190
|
#calls = new Map(); /* id ('' for unknown keys) -> [times of the last minute] */
|
|
153
191
|
|
|
154
192
|
/* `exists(name)`: whether a conversation is there (one is given only then); `roomExists(id)`,
|
|
155
|
-
`boxExists(id)`: the same for a room, a voice box (none, without them).
|
|
156
|
-
|
|
157
|
-
|
|
193
|
+
`boxExists(id)`: the same for a room, a voice box (none, without them). `binders`: Binder's lists, as a person
|
|
194
|
+
sees them -- { lists() -> [{ id, title, archived, grants }], grant(list, subject, level | null, who) } (none, without). */
|
|
195
|
+
constructor({ store, grants = new Grants(), journal = null, now = () => Date.now(), exists = () => true, roomExists = () => false, boxExists = () => false, binders = null }) {
|
|
196
|
+
this.#store = store; this.#grants = grants; this.#journal = journal; this.#now = now; this.#exists = exists; this.#binders = binders;
|
|
158
197
|
/* What is given one by one: its asset type, its levels, a valid name, whether it is there, its words. */
|
|
159
198
|
this.#kinds = {
|
|
160
199
|
room: { levels: ROOM_LEVELS, of: roomLevelOf, valid: (v) => typeof v === 'string' && UUID.test(v), exists: roomExists, what: 'room', the: 'a room' },
|
|
@@ -165,7 +204,7 @@ export class ApiKeys {
|
|
|
165
204
|
#scopesOf(id) { return Object.entries(SCOPES).filter(([, s]) => this.#grants.can(apiKeySubject(id), s.asset, s.rights)).map(([name]) => name); }
|
|
166
205
|
#shown(k) {
|
|
167
206
|
const used = this.#used.get(k.name)?.at ?? k.usedAt ?? null;
|
|
168
|
-
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,
|
|
207
|
+
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), binders: this.bindersOf(k.name), createdBy: k.createdBy ?? null, createdAt: k.createdAt, expiresAt: k.expiresAt ?? null,
|
|
169
208
|
usedAt: used, expired: Boolean(k.expiresAt && Date.parse(k.expiresAt) <= this.#now()) };
|
|
170
209
|
}
|
|
171
210
|
|
|
@@ -207,6 +246,37 @@ export class ApiKeys {
|
|
|
207
246
|
setRoom(id, room, level) { return this.#setOne('room', id, room, level); }
|
|
208
247
|
setBox(id, box, level) { return this.#setOne('box', id, box, level); }
|
|
209
248
|
|
|
249
|
+
/* ITS BINDER LISTS: kept in Binder, on each list (see above). Archived lists are not given (nor shown). */
|
|
250
|
+
#lists() { try { return (this.#binders?.lists() ?? []).filter((l) => l && typeof l.id === 'string'); } catch { return []; } }
|
|
251
|
+
bindersOf(id) {
|
|
252
|
+
const subject = apiKeySubject(id);
|
|
253
|
+
return this.#lists().filter((l) => !l.archived && Object.hasOwn(BINDER_LEVELS, l.grants?.[subject] ?? '')).map((l) => ({ id: l.id, level: l.grants[subject] }))
|
|
254
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
255
|
+
}
|
|
256
|
+
#binderAsked(list, level) {
|
|
257
|
+
if (!this.#binders || typeof list !== 'string' || !LIST_ID.test(list) || !this.#lists().some((l) => l.id === list && !l.archived)) throw new Refusal(404, 'No such binder.');
|
|
258
|
+
if (level !== null && !Object.hasOwn(BINDER_LEVELS, level)) throw new Refusal(400, `A binder is given at one of: ${Object.keys(BINDER_LEVELS).join(', ')}.`);
|
|
259
|
+
}
|
|
260
|
+
/* One list given to a key, its level changed, or taken back (level null). `who`: the person, as Binder's history says it. */
|
|
261
|
+
setBinder(id, list, level, who = '') {
|
|
262
|
+
if (typeof id !== 'string' || !this.#store.get(id)) throw new Refusal(404, 'No such key.');
|
|
263
|
+
this.#binderAsked(list, level);
|
|
264
|
+
this.#binders.grant(list, apiKeySubject(id), level, who);
|
|
265
|
+
return this.get(id);
|
|
266
|
+
}
|
|
267
|
+
/* Whether a key (already checked) may read ('read') or change ('write') one list; 403 if not -- the same for one
|
|
268
|
+
not given, archived, or not there. */
|
|
269
|
+
mayBinder(id, list, level = 'read') {
|
|
270
|
+
const l = typeof list === 'string' && LIST_ID.test(list) ? this.#lists().find((x) => x.id === list && !x.archived) : undefined;
|
|
271
|
+
const has = l?.grants?.[apiKeySubject(id)];
|
|
272
|
+
if (!Object.hasOwn(BINDER_LEVELS, has ?? '')) throw Object.assign(new Refusal(403, 'This key was not given this binder.'), { key: id });
|
|
273
|
+
if (level === 'write' && has !== 'write') throw Object.assign(new Refusal(403, 'This key may read this binder, not change it.'), { key: id });
|
|
274
|
+
return has;
|
|
275
|
+
}
|
|
276
|
+
binderTitle(list) { return this.#lists().find((l) => l.id === list)?.title ?? list; }
|
|
277
|
+
/* Who the key is to Binder: an agent, signed with its name -- never a person (a person approves, deletes, gives). */
|
|
278
|
+
binderWho(id) { const k = this.#store.get(id); return k ? { id: apiKeySubject(id), kind: 'agent', name: k.label } : null; }
|
|
279
|
+
|
|
210
280
|
#conversationAsked(name, level) {
|
|
211
281
|
if (!isConversationName(name) || !this.#exists(name)) throw new Refusal(404, 'No such conversation.');
|
|
212
282
|
if (level !== null && !Object.hasOwn(LEVELS, level)) throw new Refusal(400, `A conversation is given to read, to talk or to act: ${Object.keys(LEVELS).join(', ')}.`);
|
|
@@ -223,12 +293,14 @@ export class ApiKeys {
|
|
|
223
293
|
|
|
224
294
|
/* A new key: its secret is in the answer, this once. `days`: its life (null: it does not expire).
|
|
225
295
|
`conversations`: { name: 'read' | 'talk' }, each named -- there is no "all". */
|
|
226
|
-
create({ label, scopes = [], conversations = {}, rooms = {}, boxes = {}, days = DEFAULT_DAYS } = {}, who = '') {
|
|
296
|
+
create({ label, scopes = [], conversations = {}, rooms = {}, boxes = {}, binders = {}, days = DEFAULT_DAYS } = {}, who = '') {
|
|
227
297
|
if (!oneLine(label)) throw new Refusal(400, `A key has a name: one line, ${LABEL_MAX} characters at most.`);
|
|
228
298
|
if (!Array.isArray(scopes) || scopes.some((s) => !Object.hasOwn(SCOPES, s))) throw new Refusal(400, `Scopes are among: ${Object.keys(SCOPES).join(', ')}.`);
|
|
229
299
|
if (!conversations || typeof conversations !== 'object' || Array.isArray(conversations)) throw new Refusal(400, 'Conversations are given by name: {name: "read" | "talk"}.');
|
|
230
300
|
for (const [name, level] of Object.entries(conversations)) this.#conversationAsked(name, level);
|
|
231
301
|
this.#givenAll('room', rooms); this.#givenAll('box', boxes);
|
|
302
|
+
if (!binders || typeof binders !== 'object' || Array.isArray(binders)) throw new Refusal(400, 'Binders are given by id: {id: level}.');
|
|
303
|
+
for (const [list, level] of Object.entries(binders)) this.#binderAsked(list, level);
|
|
232
304
|
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.`);
|
|
233
305
|
if (this.#store.all().length >= KEYS_MAX) throw new Refusal(409, `At most ${KEYS_MAX} keys: revoke one first.`);
|
|
234
306
|
const name = label.trim();
|
|
@@ -243,6 +315,7 @@ export class ApiKeys {
|
|
|
243
315
|
for (const [name, level] of Object.entries(conversations)) if (level !== null) this.#grants.give(apiKeySubject(id), `conversation:${name}`, LEVELS[level]);
|
|
244
316
|
for (const [ref, level] of Object.entries(rooms)) if (level !== null) this.#grants.give(apiKeySubject(id), `room:${ref}`, ROOM_LEVELS[level]);
|
|
245
317
|
for (const [ref, level] of Object.entries(boxes)) if (level !== null) this.#grants.give(apiKeySubject(id), `box:${ref}`, BOX_LEVELS[level]);
|
|
318
|
+
for (const [list, level] of Object.entries(binders)) if (level !== null) this.#binders.grant(list, apiKeySubject(id), level, who);
|
|
246
319
|
return { key: this.#shown(record), secret };
|
|
247
320
|
}
|
|
248
321
|
|
|
@@ -251,6 +324,7 @@ export class ApiKeys {
|
|
|
251
324
|
if (typeof id !== 'string' || !this.#store.get(id)) throw new Refusal(404, 'No such key.');
|
|
252
325
|
this.#store.remove(id);
|
|
253
326
|
this.#grants.forgetSubject(apiKeySubject(id));
|
|
327
|
+
for (const l of this.#lists()) if (l.grants?.[apiKeySubject(id)]) { try { this.#binders.grant(l.id, apiKeySubject(id), null); } catch { /* gone already */ } }
|
|
254
328
|
this.#used.delete(id); this.#calls.delete(id);
|
|
255
329
|
for (const c of [...this.#calls.keys()]) if (c.startsWith(`speak:${id}:`)) this.#calls.delete(c);
|
|
256
330
|
}
|
|
@@ -557,8 +631,48 @@ export const ROOM_REMOVE_TOOL = {
|
|
|
557
631
|
inputSchema: { type: 'object', properties: { room: ROOM_ARG }, required: ['room'], additionalProperties: false },
|
|
558
632
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
559
633
|
};
|
|
560
|
-
|
|
561
|
-
|
|
634
|
+
/* A conversation's reminders (its tool's Docket): seen at Read, set at Talk, taken back at Act. */
|
|
635
|
+
const tool = (name, title, description, properties, required, write, destructive = false) => ({ name, title, description,
|
|
636
|
+
inputSchema: { type: 'object', properties, required, additionalProperties: false },
|
|
637
|
+
annotations: { readOnlyHint: !write, destructiveHint: destructive, idempotentHint: !write, openWorldHint: false } });
|
|
638
|
+
export const REMINDERS_TOOL = tool('clavarium_reminders', 'Reminders of a conversation',
|
|
639
|
+
'The reminders set in one conversation\'s tool (its Docket): each with its id, text, cron line (if it recurs) and when it is next due. The conversation must have been given to this key, its chat on.',
|
|
640
|
+
{ conversation: CONVERSATION_ARG }, ['conversation'], false);
|
|
641
|
+
export const REMIND_TOOL = tool('clavarium_remind', 'Set a reminder in a conversation',
|
|
642
|
+
`Set a reminder for one conversation's AI: once at a time ("at": "2026-11-20 09:00", in this server's time zone) or recurring ("cron": "0 9 * * 1", Mondays at 9:00), with a short text (${REMINDER_MAX} characters at most). It wakes the AI when due. The conversation must have been given to this key to talk.`,
|
|
643
|
+
{ conversation: CONVERSATION_ARG, text: { type: 'string', minLength: 1, maxLength: REMINDER_MAX }, at: { type: 'string' }, cron: { type: 'string' } }, ['conversation', 'text'], true);
|
|
644
|
+
export const REMINDER_CANCEL_TOOL = tool('clavarium_reminder_cancel', 'Take back a reminder',
|
|
645
|
+
'Take back one reminder of a conversation, by its id (as clavarium_reminders gives it). The conversation must have been given to this key to act.',
|
|
646
|
+
{ conversation: CONVERSATION_ARG, reminder: { type: 'string' } }, ['conversation', 'reminder'], true, true);
|
|
647
|
+
const BINDER_ARG = { type: 'string', description: 'The binder list\'s id, as clavarium_binders gives it.' };
|
|
648
|
+
const RECORD_ARG = { type: 'string', description: 'The record\'s id, as clavarium_binder_records gives it.' };
|
|
649
|
+
const FIELDS_ARG = { type: 'object', description: 'Field values by field id or name, as clavarium_binders gives them; null clears one.', additionalProperties: { type: ['string', 'number', 'null'] } };
|
|
650
|
+
const DUE_ARG = { type: ['string', 'null'], description: 'A due date: "2026-11-20", or "2026-11-20 09:00"; null clears it.' };
|
|
651
|
+
export const BINDERS_TOOL = tool('clavarium_binders', 'Binders of this key',
|
|
652
|
+
'The binder lists this key was given, each with its id, title, level (read or write), its states (a gate is one only a person approves past) and its fields.', {}, [], false);
|
|
653
|
+
export const BINDER_RECORDS_TOOL = tool('clavarium_binder_records', 'Records of a binder',
|
|
654
|
+
'The records of one binder list, in the order of its states: id, title, state, fields, owners, due date. "state" keeps one state; "archived": true lists the archived ones instead.',
|
|
655
|
+
{ binder: BINDER_ARG, state: { type: 'string' }, archived: { type: 'boolean' } }, ['binder'], false);
|
|
656
|
+
export const BINDER_RECORD_TOOL = tool('clavarium_binder_record', 'Read a record',
|
|
657
|
+
'One record of a binder list, with its description and its notes.', { binder: BINDER_ARG, record: RECORD_ARG }, ['binder', 'record'], false);
|
|
658
|
+
export const BINDER_ADD_TOOL = tool('clavarium_binder_add', 'Add a record',
|
|
659
|
+
'Add a record to a binder list, signed with this key\'s name: a title, and a description, a state (not past a gate), fields, a due date. The list must have been given to this key to write.',
|
|
660
|
+
{ binder: BINDER_ARG, title: { type: 'string', minLength: 1, maxLength: 200 }, description: { type: 'string' }, state: { type: 'string' }, fields: FIELDS_ARG, due: DUE_ARG }, ['binder', 'title'], true);
|
|
661
|
+
export const BINDER_EDIT_TOOL = tool('clavarium_binder_edit', 'Change a record',
|
|
662
|
+
'Change a record\'s title, description, fields or due date. The list must have been given to this key to write.',
|
|
663
|
+
{ binder: BINDER_ARG, record: RECORD_ARG, title: { type: 'string', minLength: 1, maxLength: 200 }, description: { type: ['string', 'null'] }, fields: FIELDS_ARG, due: DUE_ARG }, ['binder', 'record'], true);
|
|
664
|
+
export const BINDER_MOVE_TOOL = tool('clavarium_binder_move', 'Move a record',
|
|
665
|
+
'Move a record to another state of its list. Past a gate, only a record a person approved: a key never approves. The list must have been given to this key to write.',
|
|
666
|
+
{ binder: BINDER_ARG, record: RECORD_ARG, state: { type: 'string', description: 'A state\'s id or name.' } }, ['binder', 'record', 'state'], true);
|
|
667
|
+
export const BINDER_NOTE_TOOL = tool('clavarium_binder_note', 'Add a note to a record',
|
|
668
|
+
'Add a note to a record, signed with this key\'s name. The list must have been given to this key to write.',
|
|
669
|
+
{ binder: BINDER_ARG, record: RECORD_ARG, text: { type: 'string', minLength: 1, maxLength: 2000 } }, ['binder', 'record', 'text'], true);
|
|
670
|
+
export const BINDER_ARCHIVE_TOOL = tool('clavarium_binder_archive', 'Archive a record',
|
|
671
|
+
'Archive a record (or restore it: "restore": true). Never deleted: only a person deletes. The list must have been given to this key to write.',
|
|
672
|
+
{ binder: BINDER_ARG, record: RECORD_ARG, restore: { type: 'boolean' } }, ['binder', 'record'], true);
|
|
673
|
+
const BINDER_TOOLS = [BINDERS_TOOL, BINDER_RECORDS_TOOL, BINDER_RECORD_TOOL, BINDER_ADD_TOOL, BINDER_EDIT_TOOL, BINDER_MOVE_TOOL, BINDER_NOTE_TOOL, BINDER_ARCHIVE_TOOL];
|
|
674
|
+
export const TOOLS = [STATUS_TOOL, CONVERSATIONS_TOOL, READ_TOOL, SAY_TOOL, WAKE_TOOL, SLEEP_TOOL, STOP_TOOL, REMINDERS_TOOL, REMIND_TOOL, REMINDER_CANCEL_TOOL,
|
|
675
|
+
ROOMS_TOOL, ROOM_READ_TOOL, ROOM_SAY_TOOL, ROOM_CREATE_TOOL, ROOM_CHANGE_TOOL, ROOM_REMOVE_TOOL, BOXES_TOOL, BOX_SAY_TOOL, ...BINDER_TOOLS];
|
|
562
676
|
|
|
563
677
|
/* Returns true when the request was under /api/ext/ (answered here). `status()`: the state, as statusOf
|
|
564
678
|
makes it. `chats`: { list() -> [{ id, name }], read(name, part) -> talk.of's answer, chatOn(name),
|
|
@@ -569,8 +683,11 @@ export const TOOLS = [STATUS_TOOL, CONVERSATIONS_TOOL, READ_TOOL, SAY_TOOL, WAKE
|
|
|
569
683
|
To act (07-11): `chats` also { wake(name) -> { state }, sleep(name), stop(name) } -- the owner's own calls; `rooms` also
|
|
570
684
|
{ create({ name, conversations, boxes, host }) -> { id }, change(id, { name, host, add, remove }) -- by member ids, as the
|
|
571
685
|
page asks --, remove(id) }, and its list's members carry `ref` (a conversation's name, a box's id) and `id` (the member's).
|
|
572
|
-
`events`: What happened (events.mjs), where each act is said with the key's name.
|
|
573
|
-
|
|
686
|
+
`events`: What happened (events.mjs), where each act is said with the key's name.
|
|
687
|
+
07-11: `reminders`: { of(name) -> { on, plans: [{ id, text, cron, next }] }, set(name, { at, cron, text }, by) -> plan,
|
|
688
|
+
cancel(name, plan) -> bool } -- a conversation's Docket (dockets.mjs). `binder`: { act(op, args, who) -> { ok, text, data } }
|
|
689
|
+
-- Binder's keeper, asked as the key (an agent): its rules are Binder's. */
|
|
690
|
+
export async function handleExternal(request, response, { keys, status, chats = null, rooms = null, boxes = null, reminders = null, binder = null, events = null, headers = {}, originOk = () => false, version = null }) {
|
|
574
691
|
const url = new URL(request.url ?? '/', 'http://local');
|
|
575
692
|
const { pathname } = url;
|
|
576
693
|
if (!pathname.startsWith(EXT_PREFIX) && pathname !== EXT_PREFIX.slice(0, -1)) return false;
|
|
@@ -739,6 +856,120 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
739
856
|
said(id, 'conversation', `Room "${String(name).slice(0, 60)}" removed`);
|
|
740
857
|
return { ok: true, removed: room };
|
|
741
858
|
};
|
|
859
|
+
/* A CONVERSATION'S REMINDERS (its tool's Docket), folded into its level: seen at Read, set at Talk, taken back at Act. */
|
|
860
|
+
const docketOf = (name) => {
|
|
861
|
+
const d = reminders?.of(name);
|
|
862
|
+
if (!d?.on) throw new Refusal(409, 'Its chat is off: it has no reminders.');
|
|
863
|
+
return d;
|
|
864
|
+
};
|
|
865
|
+
const remindersListed = (id, name) => {
|
|
866
|
+
keys.mayConversation(id, name, 'read'); known(name);
|
|
867
|
+
return { reminders: (docketOf(name).plans ?? []).map((p) => ({ id: String(p.id), text: String(p.text ?? ''), cron: typeof p.cron === 'string' ? p.cron : null, next: p.next ?? null })) };
|
|
868
|
+
};
|
|
869
|
+
const reminding = (id, name, body) => {
|
|
870
|
+
keys.mayConversation(id, name, 'talk'); known(name);
|
|
871
|
+
onlyFields(body, ['text', 'at', 'cron']);
|
|
872
|
+
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
|
873
|
+
if (!text) throw new Refusal(400, 'Say what it is for: text.');
|
|
874
|
+
if (text.length > REMINDER_MAX) throw new Refusal(400, `${REMINDER_MAX} characters at most: a short text.`);
|
|
875
|
+
const at = typeof body.at === 'string' && body.at.trim() ? body.at.trim() : null;
|
|
876
|
+
const cron = typeof body.cron === 'string' && body.cron.trim() ? body.cron.trim() : null;
|
|
877
|
+
if (Boolean(at) === Boolean(cron)) throw new Refusal(400, 'Give one of: at ("2026-11-20 09:00") or cron ("0 9 * * 1").');
|
|
878
|
+
docketOf(name);
|
|
879
|
+
let plan;
|
|
880
|
+
try { plan = reminders.set(name, { ...(at ? { at } : { cron }), text }, keys.signature(id)); } catch (error) { if (error instanceof Refusal) throw error; throw new Refusal(400, String(error?.message ?? 'Not a reminder.').slice(0, 200)); }
|
|
881
|
+
said(id, 'conversation', `${name}: reminder set "${text.slice(0, 60)}"`, name);
|
|
882
|
+
return { ok: true, reminder: { id: String(plan.id), text: String(plan.text ?? text), cron: plan.cron ?? null, next: plan.next ?? null } };
|
|
883
|
+
};
|
|
884
|
+
const unreminding = (id, name, plan) => {
|
|
885
|
+
keys.mayConversation(id, name, 'act'); known(name);
|
|
886
|
+
const d = docketOf(name);
|
|
887
|
+
const p = typeof plan === 'string' ? (d.plans ?? []).find((x) => x.id === plan) : undefined;
|
|
888
|
+
if (!p || !reminders.cancel(name, plan)) throw new Refusal(404, 'No such reminder.');
|
|
889
|
+
said(id, 'conversation', `${name}: reminder taken back "${String(p.text ?? '').slice(0, 60)}"`, name);
|
|
890
|
+
return { ok: true, cancelled: plan };
|
|
891
|
+
};
|
|
892
|
+
/* ITS BINDER LISTS: Binder asked as the key -- an agent, its name on what it writes. Only these asks, never a
|
|
893
|
+
person's (approve, delete, grant, settings, a list's columns). What Binder refuses is refused: a gate a person
|
|
894
|
+
did not tick says so (403). A record is reached through its own list only. */
|
|
895
|
+
const asked = (id, op, args) => {
|
|
896
|
+
if (typeof binder?.act !== 'function') throw new Refusal(409, 'Not possible here.');
|
|
897
|
+
const r = binder.act(op, args, keys.binderWho(id));
|
|
898
|
+
if (r?.ok) return r.data;
|
|
899
|
+
const why = String(r?.text ?? 'Not done.').slice(0, 300);
|
|
900
|
+
throw Object.assign(new Refusal(/^only a person/.test(why) ? 403 : 400, why), { key: id });
|
|
901
|
+
};
|
|
902
|
+
const clean = (v) => (typeof v === 'string' ? masked(v) : v);
|
|
903
|
+
const stateOf = (l, sid) => l?.states?.find((x) => x.id === sid)?.name ?? null;
|
|
904
|
+
const recordShown = (r, l, whole = false) => ({ id: String(r.id), title: masked(String(r.title ?? '')), state: String(r.state ?? ''), stateName: stateOf(l, r.state),
|
|
905
|
+
fields: Object.fromEntries(Object.entries(r.fields ?? {}).map(([k, v]) => [k, clean(v)])), owners: Array.isArray(r.owners) ? r.owners.slice(0, 8) : [],
|
|
906
|
+
due: r.due ?? null, ...(r.approved ? { approved: true } : {}), ...(r.archived ? { archived: true } : {}), changed: r.changed ?? null,
|
|
907
|
+
...(whole ? { description: masked(String(r.description ?? '')), created: r.created ?? null,
|
|
908
|
+
notes: (Array.isArray(r.notes) ? r.notes : []).map((n) => ({ at: n.at, by: String(n.by?.name ?? ''), text: masked(String(n.text ?? '')) })),
|
|
909
|
+
attached: (Array.isArray(r.attached) ? r.attached : []).map((a) => ({ kind: String(a.kind ?? ''), title: masked(String(a.title ?? '')) })) } : {}) });
|
|
910
|
+
const bindersListed = (id) => {
|
|
911
|
+
const given = keys.bindersOf(id);
|
|
912
|
+
const shown = given.length ? asked(id, 'lists', {}) : [];
|
|
913
|
+
const lists = new Map((Array.isArray(shown) ? shown : []).map((l) => [l.id, l]));
|
|
914
|
+
return { binders: given.filter((g) => lists.has(g.id)).map((g) => { const l = lists.get(g.id);
|
|
915
|
+
return { id: g.id, title: String(l.title ?? ''), level: g.level, count: l.count ?? 0,
|
|
916
|
+
states: (l.states ?? []).map((x) => ({ id: x.id, name: x.name, ...(x.gate ? { gate: true } : {}), ...(x.done ? { done: true } : {}) })),
|
|
917
|
+
fields: (l.fields ?? []).map((f) => ({ id: f.id, name: f.name, type: f.type })) }; }) };
|
|
918
|
+
};
|
|
919
|
+
const recordsListed = (id, list, { state, archived } = {}) => {
|
|
920
|
+
keys.mayBinder(id, list, 'read');
|
|
921
|
+
if (state !== undefined && state !== null && typeof state !== 'string') throw new Refusal(400, 'state: a state\'s id or name.');
|
|
922
|
+
const d = asked(id, 'show', { list, ...(state ? { state } : {}), archived: archived === true });
|
|
923
|
+
return { records: (d.records ?? []).map((r) => recordShown(r, d.list)) };
|
|
924
|
+
};
|
|
925
|
+
/* The record, as the key may reach it: in this list, the list given. */
|
|
926
|
+
const recordIn = (id, list, record, level) => {
|
|
927
|
+
keys.mayBinder(id, list, level);
|
|
928
|
+
if (typeof record !== 'string' || !/^\d{1,9}$/.test(record)) throw new Refusal(404, 'No such record in this binder.');
|
|
929
|
+
let d;
|
|
930
|
+
try { d = asked(id, 'get', { id: record }); } catch (error) { if (error instanceof Refusal && error.status === 400) throw new Refusal(404, 'No such record in this binder.'); throw error; }
|
|
931
|
+
if (d?.record?.list !== list) throw new Refusal(404, 'No such record in this binder.');
|
|
932
|
+
return d;
|
|
933
|
+
};
|
|
934
|
+
const recordRead = (id, list, record) => { const d = recordIn(id, list, record, 'read'); return { record: recordShown(d.record, d.list, true) }; };
|
|
935
|
+
const binderSaid = (id, list, what) => said(id, 'asset', `Binder "${String(keys.binderTitle(list)).slice(0, 60)}": ${what}`);
|
|
936
|
+
const RECORD_FIELDS = ['title', 'description', 'fields', 'due'];
|
|
937
|
+
const recordAdding = (id, list, body) => {
|
|
938
|
+
keys.mayBinder(id, list, 'write');
|
|
939
|
+
onlyFields(body, ['title', 'description', 'state', 'fields', 'due']);
|
|
940
|
+
const r = asked(id, 'add', { list, title: body.title, description: body.description, state: body.state, fields: body.fields, due: body.due });
|
|
941
|
+
binderSaid(id, list, `#${r.id} added`);
|
|
942
|
+
return { ok: true, record: recordRead(id, list, String(r.id)).record };
|
|
943
|
+
};
|
|
944
|
+
const recordEditing = (id, list, record, body) => {
|
|
945
|
+
recordIn(id, list, record, 'write');
|
|
946
|
+
onlyFields(body, RECORD_FIELDS);
|
|
947
|
+
asked(id, 'edit', { id: record, ...Object.fromEntries(RECORD_FIELDS.filter((k) => body[k] !== undefined).map((k) => [k, body[k]])) });
|
|
948
|
+
binderSaid(id, list, `#${record} changed`);
|
|
949
|
+
return { ok: true, record: recordRead(id, list, record).record };
|
|
950
|
+
};
|
|
951
|
+
const recordMoving = (id, list, record, body) => {
|
|
952
|
+
recordIn(id, list, record, 'write');
|
|
953
|
+
onlyFields(body, ['state']);
|
|
954
|
+
if (typeof body.state !== 'string' || !body.state) throw new Refusal(400, 'state: a state\'s id or name.');
|
|
955
|
+
const r = asked(id, 'move', { id: record, state: body.state });
|
|
956
|
+
binderSaid(id, list, `#${record} moved to ${String(r.stateName ?? r.state).slice(0, 40)}`);
|
|
957
|
+
return { ok: true, record: recordRead(id, list, record).record };
|
|
958
|
+
};
|
|
959
|
+
const recordNoting = (id, list, record, body) => {
|
|
960
|
+
recordIn(id, list, record, 'write');
|
|
961
|
+
onlyFields(body, ['text']);
|
|
962
|
+
if (typeof body.text !== 'string' || !body.text.trim()) throw new Refusal(400, 'Say something: text.');
|
|
963
|
+
asked(id, 'note', { id: record, text: body.text });
|
|
964
|
+
binderSaid(id, list, `#${record} noted`);
|
|
965
|
+
return { ok: true, record: recordRead(id, list, record).record };
|
|
966
|
+
};
|
|
967
|
+
const recordArchiving = (id, list, record, restore) => {
|
|
968
|
+
recordIn(id, list, record, 'write');
|
|
969
|
+
asked(id, 'archive', { id: record, restore: restore === true });
|
|
970
|
+
binderSaid(id, list, `#${record} ${restore === true ? 'restored' : 'archived'}`);
|
|
971
|
+
return { ok: true, record: recordRead(id, list, record).record };
|
|
972
|
+
};
|
|
742
973
|
const ownError = (error, id) => (error instanceof Refusal ? Object.assign(error, { key: error.key ?? id }) : error);
|
|
743
974
|
try {
|
|
744
975
|
if (route === '/v1/status') {
|
|
@@ -749,6 +980,51 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
749
980
|
note(id, 200);
|
|
750
981
|
return send(200, body);
|
|
751
982
|
}
|
|
983
|
+
/* A JSON body, or a refusal. */
|
|
984
|
+
const jsonOf = async (max) => {
|
|
985
|
+
if (!String(request.headers['content-type'] ?? '').startsWith('application/json')) throw new Refusal(415, 'Send JSON.');
|
|
986
|
+
const text = await bodyOf(max);
|
|
987
|
+
if (!text) return {};
|
|
988
|
+
try { return JSON.parse(text); } catch { throw new Refusal(400, 'The request is not JSON.'); }
|
|
989
|
+
};
|
|
990
|
+
const reminder = /^\/v1\/conversations\/([^/]+)\/reminders(?:\/([^/]+)\/cancel)?$/.exec(route);
|
|
991
|
+
if (reminder) {
|
|
992
|
+
const allowed = reminder[2] ? 'POST' : 'GET, POST';
|
|
993
|
+
if (!allowed.split(', ').includes(request.method)) return send(405, { error: `Call this address with ${allowed}.` }, { allow: allowed });
|
|
994
|
+
let id;
|
|
995
|
+
try { id = keys.check(request.headers.authorization); } catch (error) { return refused(error); }
|
|
996
|
+
try {
|
|
997
|
+
let name; let plan = '';
|
|
998
|
+
try { name = decodeURIComponent(reminder[1]); plan = reminder[2] ? decodeURIComponent(reminder[2]) : ''; } catch { name = ''; }
|
|
999
|
+
if (reminder[2]) { const done = unreminding(id, name, plan); note(id, 200); return send(200, done); }
|
|
1000
|
+
if (request.method === 'GET') { const body = remindersListed(id, name); note(id, 200); return send(200, body); }
|
|
1001
|
+
const done = reminding(id, name, await jsonOf(4096));
|
|
1002
|
+
note(id, 201); return send(201, done);
|
|
1003
|
+
} catch (error) { return refused(ownError(error, id)); }
|
|
1004
|
+
}
|
|
1005
|
+
const binderRoute = /^\/v1\/binders(?:\/([^/]+)\/records(?:\/([^/]+)(?:\/(move|notes|archive|restore))?)?)?$/.exec(route);
|
|
1006
|
+
if (binderRoute) {
|
|
1007
|
+
const [, listRef, recordRef, verb] = binderRoute;
|
|
1008
|
+
const allowed = !listRef ? 'GET' : verb ? 'POST' : 'GET, POST';
|
|
1009
|
+
if (!allowed.split(', ').includes(request.method)) return send(405, { error: `Call this address with ${allowed}.` }, { allow: allowed });
|
|
1010
|
+
let id;
|
|
1011
|
+
try { id = keys.check(request.headers.authorization); } catch (error) { return refused(error); }
|
|
1012
|
+
try {
|
|
1013
|
+
if (!listRef) { const body = bindersListed(id); note(id, 200); return send(200, body); }
|
|
1014
|
+
let list; let record;
|
|
1015
|
+
try { list = decodeURIComponent(listRef); record = recordRef === undefined ? undefined : decodeURIComponent(recordRef); } catch { list = ''; }
|
|
1016
|
+
if (request.method === 'GET') {
|
|
1017
|
+
const body = record === undefined ? recordsListed(id, list, { state: url.searchParams.get('state') || undefined, archived: url.searchParams.get('archived') === '1' }) : recordRead(id, list, record);
|
|
1018
|
+
note(id, 200); return send(200, body);
|
|
1019
|
+
}
|
|
1020
|
+
if (verb === 'archive' || verb === 'restore') { const done = recordArchiving(id, list, record, verb === 'restore'); note(id, 200); return send(200, done); }
|
|
1021
|
+
const body = await jsonOf(16_384);
|
|
1022
|
+
const done = record === undefined ? recordAdding(id, list, body) : verb === 'move' ? recordMoving(id, list, record, body)
|
|
1023
|
+
: verb === 'notes' ? recordNoting(id, list, record, body) : recordEditing(id, list, record, body);
|
|
1024
|
+
const code = record === undefined || verb === 'notes' ? 201 : 200;
|
|
1025
|
+
note(id, code); return send(code, done);
|
|
1026
|
+
} catch (error) { return refused(ownError(error, id)); }
|
|
1027
|
+
}
|
|
752
1028
|
const one = /^\/v1\/conversations(?:\/([^/]+)\/(messages|wake|sleep|stop))?$/.exec(route);
|
|
753
1029
|
if (one) {
|
|
754
1030
|
const allowed = one[2] && one[2] !== 'messages' ? 'POST' : one[1] ? 'GET, POST' : 'GET';
|
|
@@ -828,7 +1104,7 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
828
1104
|
}
|
|
829
1105
|
const args = msg.params?.arguments && typeof msg.params.arguments === 'object' && !Array.isArray(msg.params.arguments) ? msg.params.arguments : {};
|
|
830
1106
|
/* The journal says which tool, and on which conversation, room or box. */
|
|
831
|
-
const on = [args.conversation, args.room, args.box].find((v) => typeof v === 'string');
|
|
1107
|
+
const on = [args.conversation, args.room, args.box, args.binder].find((v) => typeof v === 'string');
|
|
832
1108
|
const what = `/mcp ${msg.method.slice(0, 40)}${msg.method === 'tools/call' ? ` ${String(msg.params?.name ?? '').slice(0, 40)}${on ? ` ${on.slice(0, 36)}` : ''}` : ''}`;
|
|
833
1109
|
/* A notification (no id) is taken, never answered. */
|
|
834
1110
|
if (msg.id === undefined || msg.id === null) { note(id, 202, what); response.writeHead(202, { ...headers, 'cache-control': 'no-store' }).end(); return true; }
|
|
@@ -840,7 +1116,7 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
840
1116
|
const asked = String(msg.params?.protocolVersion ?? '');
|
|
841
1117
|
return answer({ protocolVersion: MCP_VERSIONS.includes(asked) ? asked : MCP_VERSIONS[0], capabilities: { tools: { listChanged: false } },
|
|
842
1118
|
serverInfo: { name: 'clavarium', title: 'Clavarium', version: version ?? '0' },
|
|
843
|
-
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). Where it was given more: a conversation woken, put to sleep, its turn stopped (clavarium_conversation_wake, _sleep, _stop); rooms made, changed, removed (clavarium_room_create, clavarium_room_change, clavarium_room_remove).' });
|
|
1119
|
+
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). Where it was given more: a conversation woken, put to sleep, its turn stopped (clavarium_conversation_wake, _sleep, _stop); rooms made, changed, removed (clavarium_room_create, clavarium_room_change, clavarium_room_remove); a conversation\'s reminders seen, set, taken back (clavarium_reminders, clavarium_remind, clavarium_reminder_cancel); binder lists read and written (clavarium_binders, clavarium_binder_records, clavarium_binder_record, clavarium_binder_add, _edit, _move, _note, _archive).' });
|
|
844
1120
|
}
|
|
845
1121
|
case 'ping': return answer({});
|
|
846
1122
|
case 'tools/list': return answer({ tools: TOOLS });
|
|
@@ -855,6 +1131,19 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
855
1131
|
if (name === CONVERSATIONS_TOOL.name) return result(listed(id));
|
|
856
1132
|
if (name === ROOMS_TOOL.name) return result(roomsListed(id));
|
|
857
1133
|
if (name === BOXES_TOOL.name) return result(boxesListed(id));
|
|
1134
|
+
if (name === BINDERS_TOOL.name) return result(bindersListed(id));
|
|
1135
|
+
if (BINDER_TOOLS.some((x) => x.name === name)) {
|
|
1136
|
+
if (typeof args.binder !== 'string') return fail(-32602, 'binder: the id of a binder list.');
|
|
1137
|
+
if (name === BINDER_RECORDS_TOOL.name) return result(recordsListed(id, args.binder, { state: args.state, archived: args.archived === true }));
|
|
1138
|
+
if (name !== BINDER_ADD_TOOL.name && typeof args.record !== 'string') return fail(-32602, 'record: the id of a record.');
|
|
1139
|
+
if (name === BINDER_RECORD_TOOL.name) return result(recordRead(id, args.binder, args.record));
|
|
1140
|
+
const { binder: list, record, ...rest } = args;
|
|
1141
|
+
const done = name === BINDER_ADD_TOOL.name ? recordAdding(id, list, rest) : name === BINDER_EDIT_TOOL.name ? recordEditing(id, list, record, rest)
|
|
1142
|
+
: name === BINDER_MOVE_TOOL.name ? recordMoving(id, list, record, rest) : name === BINDER_NOTE_TOOL.name ? recordNoting(id, list, record, rest)
|
|
1143
|
+
: recordArchiving(id, list, record, rest.restore === true);
|
|
1144
|
+
note(id, 201, what);
|
|
1145
|
+
return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: JSON.stringify(done) }], structuredContent: done, isError: false } });
|
|
1146
|
+
}
|
|
858
1147
|
const part = () => readPart((k) => (args[k] === undefined ? null : typeof args[k] === 'number' ? String(args[k]) : args[k]));
|
|
859
1148
|
let done;
|
|
860
1149
|
if (name === ROOM_CREATE_TOOL.name) {
|
|
@@ -871,8 +1160,13 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
871
1160
|
} else {
|
|
872
1161
|
if (typeof args.conversation !== 'string') return fail(-32602, 'conversation: the id of a conversation.');
|
|
873
1162
|
if (name === READ_TOOL.name) return result(reading(id, args.conversation, part()));
|
|
1163
|
+
if (name === REMINDERS_TOOL.name) return result(remindersListed(id, args.conversation));
|
|
1164
|
+
if (name === REMIND_TOOL.name) { const { conversation: c, ...rest } = args; done = reminding(id, c, rest); }
|
|
1165
|
+
else if (name === REMINDER_CANCEL_TOOL.name) done = unreminding(id, args.conversation, args.reminder);
|
|
1166
|
+
else {
|
|
874
1167
|
const act = { [WAKE_TOOL.name]: 'wake', [SLEEP_TOOL.name]: 'sleep', [STOP_TOOL.name]: 'stop' }[name];
|
|
875
1168
|
done = act ? await acting(id, args.conversation, act) : await saying(id, args.conversation, args.text);
|
|
1169
|
+
}
|
|
876
1170
|
}
|
|
877
1171
|
note(id, 201, what);
|
|
878
1172
|
return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: JSON.stringify(done) }], structuredContent: done, isError: false } });
|
|
@@ -889,7 +1183,7 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
889
1183
|
}
|
|
890
1184
|
}
|
|
891
1185
|
note(null, 404);
|
|
892
|
-
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.' });
|
|
1186
|
+
return send(404, { error: 'Not found. GET /api/ext/v1/status, /api/ext/v1/conversations, /api/ext/v1/rooms, /api/ext/v1/boxes, /api/ext/v1/binders, or MCP at /api/ext/mcp.' });
|
|
893
1187
|
} catch (error) {
|
|
894
1188
|
if (error instanceof Refusal) return refused(error);
|
|
895
1189
|
note(null, 500);
|
|
@@ -915,9 +1209,9 @@ export async function handleApiKeys(request, response, keys, originOk, headers =
|
|
|
915
1209
|
try { body = text ? JSON.parse(text) : {}; } catch { return send(400, { error: 'The request is not JSON.' }); }
|
|
916
1210
|
if (!body || typeof body !== 'object') body = {};
|
|
917
1211
|
if (pathname === '/api/apikeys') {
|
|
918
|
-
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);
|
|
1212
|
+
const made = keys.create({ label: body.label, scopes: body.scopes ?? [], conversations: body.conversations ?? {}, rooms: body.rooms ?? {}, boxes: body.boxes ?? {}, binders: body.binders ?? {}, days: body.days === undefined ? DEFAULT_DAYS : body.days }, who);
|
|
919
1213
|
/* Its name and scopes; never the secret. */
|
|
920
|
-
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}`)];
|
|
1214
|
+
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}`), ...made.key.binders.map((l) => `binder ${l.id.slice(0, 40)} ${l.level}`)];
|
|
921
1215
|
events?.add({ kind: 'access', text: `API key "${made.key.label}" made${what.length ? ` (${what.join(', ')})` : ' (no scope)'}`, who: who || null });
|
|
922
1216
|
return send(201, made);
|
|
923
1217
|
}
|
|
@@ -937,6 +1231,13 @@ export async function handleApiKeys(request, response, keys, originOk, headers =
|
|
|
937
1231
|
events?.add({ kind: 'access', text: `API key "${key.label}": ${room ? 'room' : 'voice box'} ${String(ref).slice(0, 8)} ${level ?? 'taken back'}`, who: who || null });
|
|
938
1232
|
return send(200, { key });
|
|
939
1233
|
}
|
|
1234
|
+
const binding = /^\/api\/apikeys\/([0-9a-f-]{36})\/binders$/.exec(pathname);
|
|
1235
|
+
if (binding) {
|
|
1236
|
+
const level = body.level === undefined ? null : body.level;
|
|
1237
|
+
const key = keys.setBinder(binding[1], body.binder, level, who);
|
|
1238
|
+
events?.add({ kind: 'access', text: `API key "${key.label}": binder ${String(body.binder).slice(0, 40)} ${level ?? 'taken back'}`, who: who || null });
|
|
1239
|
+
return send(200, { key });
|
|
1240
|
+
}
|
|
940
1241
|
const revoke = /^\/api\/apikeys\/([0-9a-f-]{36})\/revoke$/.exec(pathname);
|
|
941
1242
|
if (revoke) {
|
|
942
1243
|
const label = keys.get(revoke[1])?.label;
|
package/server/binders.mjs
CHANGED
|
@@ -130,6 +130,18 @@ export class Binders {
|
|
|
130
130
|
}
|
|
131
131
|
return out;
|
|
132
132
|
}
|
|
133
|
+
/* FOR API KEYS (apikeys.mjs): every list with who was given it, as a person sees them; a key given a list, or
|
|
134
|
+
taken back, on the list's own access -- as a conversation is. `identity`: the person, as its history says. */
|
|
135
|
+
lists() { return this.#keeper.store.lists({ id: 'person:keeper', kind: 'person', name: 'keeper' }).map((l) => ({ id: l.id, title: l.title, archived: Boolean(l.archived), grants: { ...(l.grants ?? {}) } })); }
|
|
136
|
+
grant(list, subject, level, identity = '') {
|
|
137
|
+
if (!/^apikey:[0-9a-f-]{36}$/.test(String(subject))) throw new Error('A list is given here to an API key only.');
|
|
138
|
+
this.#keeper.store.grant(list, subject, level, { id: `person:${identity || 'owner'}`, kind: 'person', name: identity || 'Owner' });
|
|
139
|
+
}
|
|
140
|
+
/* An API key asks, as an agent (never a person: Binder's gates hold). */
|
|
141
|
+
asKey(op, args, who) {
|
|
142
|
+
if (!who || who.kind !== 'agent' || !/^apikey:/.test(String(who.id))) throw new Error('Not an API key.');
|
|
143
|
+
return this.#keeper.act(String(op ?? ''), args && typeof args === 'object' ? args : {}, who);
|
|
144
|
+
}
|
|
133
145
|
/* A number that moves with every change: the page reads again when it moved. */
|
|
134
146
|
changes() { return this.#changes; }
|
|
135
147
|
}
|
package/server/dockets.mjs
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* events here (tick), and told the same way. Its owner sees them in the
|
|
16
16
|
* conversation's settings, and may take one back.
|
|
17
17
|
*
|
|
18
|
+
* AN API KEY (apikeys.mjs) given the conversation to talk may set it one too,
|
|
19
|
+
* as the tool does (remind): its chat says which key set it.
|
|
20
|
+
*
|
|
18
21
|
* ITS CHAT SAYS what was handled, what failed (and why: the owner is alerted)
|
|
19
22
|
* and what was never handled; a reminder the tool set is a trace.
|
|
20
23
|
*
|
|
@@ -23,6 +26,7 @@
|
|
|
23
26
|
*/
|
|
24
27
|
import { mkdirSync, lstatSync } from 'node:fs';
|
|
25
28
|
import { Docket, WAKE } from 'clavarium-docket/host';
|
|
29
|
+
import { verbs } from 'clavarium-docket/server';
|
|
26
30
|
|
|
27
31
|
export { WAKE };
|
|
28
32
|
export const FOLDER = '.docket';
|
|
@@ -38,6 +42,8 @@ const cut = (text, max) => { const s = String(text ?? '').replace(/\s+/g, ' ').t
|
|
|
38
42
|
export class Dockets {
|
|
39
43
|
#resources; #store; #say; #event; #alert; #idle; #awake; #chatOn; #now; #every; #zone;
|
|
40
44
|
#listening = new Map(); #zoned = new Set(); #busy = false;
|
|
45
|
+
/* The reminders a key set: plan id -> its name, for the line its chat shows. */
|
|
46
|
+
#setBy = new Map();
|
|
41
47
|
|
|
42
48
|
/* `say(name, line)`: types one line into its terminal (true when typed). `event(name, line)`: a line
|
|
43
49
|
in its chat (Talk.event). `alert({title, body, url})`: the owner's devices. `idle(name)`: not in a
|
|
@@ -87,6 +93,19 @@ export class Dockets {
|
|
|
87
93
|
waiting: s.waiting,
|
|
88
94
|
};
|
|
89
95
|
}
|
|
96
|
+
/* AN API KEY sets a reminder, as the tool would (Docket's own rules: one of `at` or `cron`, ahead, within a year).
|
|
97
|
+
`by`: its signature ({ name }). Returns the plan: { id, text, cron, next }; throws a RangeError-like Error when not. */
|
|
98
|
+
remind(name, { at, cron, text }, by = null) {
|
|
99
|
+
if (!this.on(name)) throw new Error('Its chat is off: it has no docket.');
|
|
100
|
+
const d = this.#docket(name);
|
|
101
|
+
const before = new Set(d.read().plans.map((p) => p.id));
|
|
102
|
+
this.#listen(name);
|
|
103
|
+
verbs(this.#dir(name), { env: {}, now: this.#now }).remind({ ...(at ? { at } : {}), ...(cron ? { cron } : {}), text });
|
|
104
|
+
const plan = d.read().plans.find((p) => !before.has(p.id));
|
|
105
|
+
if (!plan) throw new Error('The reminder was not set.');
|
|
106
|
+
if (by?.name) this.#setBy.set(plan.id, String(by.name).slice(0, 80));
|
|
107
|
+
return { id: plan.id, text: plan.text, cron: plan.cron ?? null, next: plan.next === null ? null : new Date(plan.next).toISOString() };
|
|
108
|
+
}
|
|
90
109
|
/* The owner takes back a reminder of the tool. */
|
|
91
110
|
cancel(name, plan) {
|
|
92
111
|
const d = this.#docket(name);
|
|
@@ -105,7 +124,7 @@ export class Dockets {
|
|
|
105
124
|
this.#line(name, { kind: 'docket-failed', text: `Could not be handled: ${e.event.text} -- ${e.ack.note}`, tone: 'warn', topic: 'Not handled' });
|
|
106
125
|
try { this.#alert({ title: `${name}: could not be handled`, body: `${e.event.text}: ${e.ack.note}`, url: `/?c=${name}` }); } catch { /* as said */ }
|
|
107
126
|
}
|
|
108
|
-
if (e.type === 'planned') this.#line(name, { kind: 'docket-reminder', text: `Reminder set by the tool: ${e.plan.text} (${e.plan.cron ? `"${e.plan.cron}", next ` : ''}${new Date(e.plan.next ?? e.plan.due).toISOString().slice(0, 16).replace('T', ' ')} UTC)`, topic: 'Reminder' });
|
|
127
|
+
if (e.type === 'planned') this.#line(name, { kind: 'docket-reminder', text: `Reminder set by ${this.#setBy.has(e.plan.id) ? `API key "${this.#setBy.get(e.plan.id)}"` : 'the tool'}: ${e.plan.text} (${e.plan.cron ? `"${e.plan.cron}", next ` : ''}${new Date(e.plan.next ?? e.plan.due).toISOString().slice(0, 16).replace('T', ' ')} UTC)`, topic: 'Reminder' });
|
|
109
128
|
}
|
|
110
129
|
|
|
111
130
|
/* Every few seconds: the reminders due posted; what waits told, or told again; what was never handled said. */
|
package/server/grants.mjs
CHANGED
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
* ("rw": never to listen, record, set or update it) -- never a secret nor a
|
|
43
43
|
* folder in this version; nothing else is ever given "server:", "room:" or
|
|
44
44
|
* "box:".
|
|
45
|
+
* Its binder lists (07-11) are not lines here: Binder keeps who was given a
|
|
46
|
+
* list on the list itself, for a key as for a conversation (apikeys.mjs).
|
|
45
47
|
*
|
|
46
48
|
* No groups, no inheritance. One rule sits on top, in the door: whoever was
|
|
47
49
|
* given a conversation, its terminal or its chat, sees the files of that
|
package/server/main.mjs
CHANGED
|
@@ -238,7 +238,7 @@ function serve(port, conversations, resources, { gate = () => 'local', originOk,
|
|
|
238
238
|
/* THE DOOR FOR PROGRAMS (apikeys.mjs): an API key is the right, never the gate -- the front lets
|
|
239
239
|
/api/ext/ through without sign-on, and strips the identity headers there. */
|
|
240
240
|
if (apikeys && (pathname === '/api/ext' || pathname.startsWith('/api/ext/'))) {
|
|
241
|
-
await handleExternal(request, response, { keys: apikeys.keys, status: apikeys.status, chats: apikeys.chats, rooms: apikeys.rooms, boxes: apikeys.boxes, events, headers: HEADERS, originOk, version: platform?.version ?? null });
|
|
241
|
+
await handleExternal(request, response, { keys: apikeys.keys, status: apikeys.status, chats: apikeys.chats, rooms: apikeys.rooms, boxes: apikeys.boxes, reminders: apikeys.reminders, binder: apikeys.binder, events, headers: HEADERS, originOk, version: platform?.version ?? null });
|
|
242
242
|
return;
|
|
243
243
|
}
|
|
244
244
|
if (door && pathname.startsWith('/c/')) {
|
|
@@ -733,7 +733,9 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
|
|
|
733
733
|
try { components[name.slice('clavarium-'.length)] = JSON.parse(readFileSync(packageFile(name, 'package.json'), 'utf8')).version; } catch { /* not installed here */ }
|
|
734
734
|
}
|
|
735
735
|
const apiKeys = new ApiKeys({ store: new Store(`${dataDir}/apikeys.json`), grants, journal: `${dataDir}/apikeys.log`, exists: (name) => all.list().some((c) => c.name === name),
|
|
736
|
-
roomExists: (id) => rooms.has(id), boxExists: (id) => boxes.all().some((b) => b.id === id)
|
|
736
|
+
roomExists: (id) => rooms.has(id), boxExists: (id) => boxes.all().some((b) => b.id === id),
|
|
737
|
+
/* Its binder lists, kept on each list by Binder (binders.mjs), as a conversation's are. */
|
|
738
|
+
binders: { lists: () => binders.lists(), grant: (list, subject, level, who) => binders.grant(list, subject, level, who) } });
|
|
737
739
|
/* What the host program says of the server is never waited for long: a key gets what is known. */
|
|
738
740
|
const briefly = { brief: () => quick(capacity.brief(), 3000, null), room: () => quick(capacity.room(), 3000, null) };
|
|
739
741
|
const apikeys = { keys: apiKeys, status: () => statusOf({ conversations: all, talk, health, answerer, capacity: briefly, history, platform, updates, components }),
|
|
@@ -770,6 +772,10 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
|
|
|
770
772
|
/* To manage (level "manage", or "create rooms"): as the page asks (api.mjs /api/rooms). */
|
|
771
773
|
create: (asked) => rooms.create(asked), change: (id, asked) => rooms.change(id, asked), remove: (id) => rooms.remove(id),
|
|
772
774
|
},
|
|
775
|
+
/* A conversation's reminders, its Docket (dockets.mjs): what the owner sees of it, one set as the tool sets one. */
|
|
776
|
+
reminders: { of: (name) => dockets.of(name), set: (name, asked, by) => dockets.remind(name, asked, by), cancel: (name, plan) => dockets.cancel(name, plan) },
|
|
777
|
+
/* Its binder lists: Binder asked as the key, an agent (binders.mjs asKey). */
|
|
778
|
+
binder: { act: (op, args, who) => binders.asKey(op, args, who) },
|
|
773
779
|
/* Its voice boxes: their state only, and a short text said aloud (boxes.mjs announce). */
|
|
774
780
|
boxes: {
|
|
775
781
|
list: () => { const names = new Map([...all.list().map((c) => [c.name, c.title || c.name]), ...rooms.list().map((r) => [`room:${r.id}`, r.name])]);
|
package/server/settingswords.mjs
CHANGED
|
@@ -98,6 +98,7 @@ export const SETTINGS_WORDS_FR = {
|
|
|
98
98
|
'set-apikeys-rooms': 'Salons', 'set-apikeys-rooms-hint': 'Un par un : lire, parler ou gérer', 'set-apikeys-no-rooms': 'Aucun salon pour l\'instant',
|
|
99
99
|
'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',
|
|
100
100
|
'set-apikeys-state': 'État', 'set-apikeys-speak': 'Parler à voix haute',
|
|
101
|
+
'set-apikeys-binders': 'Classeurs', 'set-apikeys-binders-hint': 'Un par un : lire ou écrire', 'set-apikeys-write': 'Écrire',
|
|
101
102
|
/* The rights of an asset (06-11, kit.js RIGHTS): what it has (a pill), a choice, its one line. */
|
|
102
103
|
'set-rt-none': 'Aucun', 'set-rt-read': 'Lit', 'set-rt-write': 'Écrit', 'set-rt-use': 'Utilise', 'set-rt-watch': 'Surveille',
|
|
103
104
|
'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
|
@@ -6632,16 +6632,21 @@
|
|
|
6632
6632
|
/* ITS ROOMS AND VOICE BOXES (07-11), one by one the same way: a room Off / Read / Talk, a box Off / State /
|
|
6633
6633
|
Speak aloud -- said plainly: it is heard in the home. `kind`: 'rooms' or 'boxes'; `chosen`: id -> level. */
|
|
6634
6634
|
const apiKeyBoxWord = (level) => (level === 'speak' ? uiSaid('set-apikeys-speak', 'Speak aloud') : uiSaid('set-apikeys-state', 'State'));
|
|
6635
|
-
|
|
6635
|
+
/* ITS BINDERS (07-11): each list Off / Read / Write; offered only when there are lists. */
|
|
6636
|
+
let apiKeyLists = [];
|
|
6637
|
+
const apiKeyItems = (kind) => (kind === 'binders' ? apiKeyLists : 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 })));
|
|
6638
|
+
const apiKeyBinderWord = (level) => (level === 'write' ? uiSaid('set-apikeys-write', 'Write') : uiSaid('set-apikeys-read', 'Read'));
|
|
6636
6639
|
function apiKeyOnePicker(kind, chosen, onChange) {
|
|
6637
6640
|
const box = el('div', `apikey-${kind}`);
|
|
6638
6641
|
const list = apiKeyItems(kind);
|
|
6639
6642
|
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')));
|
|
6640
|
-
const options = kind === '
|
|
6643
|
+
const options = kind === 'binders'
|
|
6644
|
+
? [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'read', label: apiKeyBinderWord('read') }, { value: 'write', label: apiKeyBinderWord('write') }]
|
|
6645
|
+
: kind === 'rooms'
|
|
6641
6646
|
? [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'read', label: apiKeyLevelWord('read') }, { value: 'talk', label: apiKeyLevelWord('talk') }, { value: 'manage', label: apiKeyLevelWord('manage') }]
|
|
6642
6647
|
: [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'state', label: apiKeyBoxWord('state') }, { value: 'speak', label: apiKeyBoxWord('speak') }];
|
|
6643
6648
|
for (const x of list) {
|
|
6644
|
-
const row = el('div', kind === 'rooms' ? 'apikey-conv apikey-room' : 'apikey-conv apikey-box'); row.dataset.id = x.id;
|
|
6649
|
+
const row = el('div', kind === 'binders' ? 'apikey-conv apikey-binder' : kind === 'rooms' ? 'apikey-conv apikey-room' : 'apikey-conv apikey-box'); row.dataset.id = x.id;
|
|
6645
6650
|
row.append(el('span', '', x.name), KIT.Segmented({ options, value: chosen.get(x.id) ?? '', label: x.name, onChange: (v) => onChange(x.id, v || null) }));
|
|
6646
6651
|
box.append(row);
|
|
6647
6652
|
}
|
|
@@ -6650,6 +6655,7 @@
|
|
|
6650
6655
|
const apiKeyNewConvs = new Map();
|
|
6651
6656
|
const apiKeyNewRooms = new Map();
|
|
6652
6657
|
const apiKeyNewBoxes = new Map();
|
|
6658
|
+
const apiKeyNewBinders = new Map();
|
|
6653
6659
|
/* The key whose conversations or journal are open under it, kept across a repaint. */
|
|
6654
6660
|
let apiKeyOpen = null;
|
|
6655
6661
|
async function paintApiKeyJournal(box, id) {
|
|
@@ -6669,7 +6675,9 @@
|
|
|
6669
6675
|
const got = DEVICE ? null : await fetch(`${API}/apikeys`).then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
|
6670
6676
|
$('settings-ch-apikeys').hidden = !got || !Array.isArray(got.keys);
|
|
6671
6677
|
if (!got || !Array.isArray(got.keys)) return;
|
|
6672
|
-
await Promise.all([loadRooms(), loadBoxes()]);
|
|
6678
|
+
const [, , lists] = await Promise.all([loadRooms(), loadBoxes(), binderCall('lists', {})]);
|
|
6679
|
+
apiKeyLists = lists?.ok && Array.isArray(lists.data) ? lists.data.filter((l) => l && l.id && !l.archived).map((l) => ({ id: l.id, name: l.title })) : [];
|
|
6680
|
+
$('apikeys-binders-row').hidden = !apiKeyLists.length;
|
|
6673
6681
|
const scopeWord = (id) => uiSaid(`set-apikeys-scope-${id}`, got.scopes.find((x) => x.id === id)?.label ?? id);
|
|
6674
6682
|
const live = got.keys.filter((k) => !k.expired).length;
|
|
6675
6683
|
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.'));
|
|
@@ -6692,7 +6700,8 @@
|
|
|
6692
6700
|
const given = [...k.scopes.map((sc) => ({ text: scopeWord(sc) })),
|
|
6693
6701
|
...(k.conversations ?? []).map((c) => ({ text: `${shownName(conversations.find((x) => x.name === c.name) ?? { name: c.name })} · ${apiKeyLevelWord(c.level)}`, tone: c.level === 'read' ? undefined : 'note' })),
|
|
6694
6702
|
...(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 === 'read' ? undefined : 'note' })),
|
|
6695
|
-
...(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 }))
|
|
6703
|
+
...(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 })),
|
|
6704
|
+
...(k.binders ?? []).map((l) => ({ text: `${apiKeyLists.find((x) => x.id === l.id)?.name ?? l.id} · ${apiKeyBinderWord(l.level)}`, tone: l.level === 'write' ? 'note' : undefined }))];
|
|
6696
6705
|
tags.append(KIT.Chips(given.length ? given : [{ text: uiSaid('set-apikeys-no-scope', 'No scope') }]));
|
|
6697
6706
|
what.append(tags);
|
|
6698
6707
|
const by = k.createdBy ? (k.createdBy === got.me ? uiSaid('set-apikeys-by-you', 'by you') : k.createdBy) : '';
|
|
@@ -6715,16 +6724,16 @@
|
|
|
6715
6724
|
const more = el('div', 'apikey-more'); more.hidden = true;
|
|
6716
6725
|
const open = async (kind) => {
|
|
6717
6726
|
apiKeyOpen = apiKeyOpen?.id === k.id && apiKeyOpen.what === kind ? null : { id: k.id, what: kind };
|
|
6718
|
-
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));
|
|
6727
|
+
for (const b of acts.querySelectorAll('button[data-act="convs"], button[data-act="rooms"], button[data-act="boxes"], button[data-act="binders"], button[data-act="journal"]')) b.setAttribute('aria-expanded', String(apiKeyOpen?.id === k.id && apiKeyOpen.what === b.dataset.act));
|
|
6719
6728
|
more.hidden = apiKeyOpen?.id !== k.id;
|
|
6720
6729
|
more.replaceChildren();
|
|
6721
6730
|
if (!apiKeyOpen || apiKeyOpen.id !== k.id) return;
|
|
6722
6731
|
if (kind === 'journal') { more.dataset.what = 'journal'; await paintApiKeyJournal(more, k.id); return; }
|
|
6723
6732
|
more.dataset.what = kind;
|
|
6724
|
-
if (kind === 'rooms' || kind === 'boxes') {
|
|
6733
|
+
if (kind === 'rooms' || kind === 'boxes' || kind === 'binders') {
|
|
6725
6734
|
more.append(apiKeyOnePicker(kind, new Map((k[kind] ?? []).map((x) => [x.id, x.level])), async (ref, level) => {
|
|
6726
6735
|
$('apikeys-error').hidden = true;
|
|
6727
|
-
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; }
|
|
6736
|
+
try { await post(`${API}/apikeys/${k.id}/${kind}`, { [kind === 'rooms' ? 'room' : kind === 'boxes' ? 'box' : 'binder']: ref, level }); } catch (error) { $('apikeys-error').textContent = error.message; $('apikeys-error').hidden = false; }
|
|
6728
6737
|
await paintApiKeys();
|
|
6729
6738
|
}));
|
|
6730
6739
|
return;
|
|
@@ -6740,8 +6749,8 @@
|
|
|
6740
6749
|
convs.addEventListener('click', () => void open('convs'));
|
|
6741
6750
|
journal.addEventListener('click', () => void open('journal'));
|
|
6742
6751
|
acts.append(convs);
|
|
6743
|
-
/* Rooms, voice boxes: offered when there are some (or the key has some still). */
|
|
6744
|
-
for (const [kind, word] of [['rooms', uiSaid('set-apikeys-rooms', 'Rooms')], ['boxes', uiSaid('set-apikeys-boxes', 'Voice boxes')]]) {
|
|
6752
|
+
/* Rooms, voice boxes, binders: offered when there are some (or the key has some still). */
|
|
6753
|
+
for (const [kind, word] of [['rooms', uiSaid('set-apikeys-rooms', 'Rooms')], ['boxes', uiSaid('set-apikeys-boxes', 'Voice boxes')], ['binders', uiSaid('set-apikeys-binders', 'Binders')]]) {
|
|
6745
6754
|
if (!apiKeyItems(kind).length && !(k[kind] ?? []).length) continue;
|
|
6746
6755
|
const b = el('button', '', word); b.type = 'button'; b.dataset.act = kind;
|
|
6747
6756
|
b.addEventListener('click', () => void open(kind));
|
|
@@ -6757,10 +6766,11 @@
|
|
|
6757
6766
|
$('apikeys-form').hidden = !on; $('apikeys-new-row').hidden = on; $('apikeys-error').hidden = true;
|
|
6758
6767
|
if (on) {
|
|
6759
6768
|
$('apikeys-label').value = ''; $('apikeys-days').value = '90'; for (const b of $('apikeys-scopes').querySelectorAll('input')) b.checked = false;
|
|
6760
|
-
apiKeyNewConvs.clear(); apiKeyNewRooms.clear(); apiKeyNewBoxes.clear();
|
|
6769
|
+
apiKeyNewConvs.clear(); apiKeyNewRooms.clear(); apiKeyNewBoxes.clear(); apiKeyNewBinders.clear();
|
|
6761
6770
|
$('apikeys-convs').replaceChildren(apiKeyConvPicker(apiKeyNewConvs, (name, level) => { if (level) apiKeyNewConvs.set(name, level); else apiKeyNewConvs.delete(name); }));
|
|
6762
6771
|
$('apikeys-rooms').replaceChildren(apiKeyOnePicker('rooms', apiKeyNewRooms, (id, level) => { if (level) apiKeyNewRooms.set(id, level); else apiKeyNewRooms.delete(id); }));
|
|
6763
6772
|
$('apikeys-boxes').replaceChildren(apiKeyOnePicker('boxes', apiKeyNewBoxes, (id, level) => { if (level) apiKeyNewBoxes.set(id, level); else apiKeyNewBoxes.delete(id); }));
|
|
6773
|
+
$('apikeys-binders').replaceChildren(apiKeyOnePicker('binders', apiKeyNewBinders, (id, level) => { if (level) apiKeyNewBinders.set(id, level); else apiKeyNewBinders.delete(id); }));
|
|
6764
6774
|
$('apikeys-label').focus();
|
|
6765
6775
|
}
|
|
6766
6776
|
};
|
|
@@ -6772,14 +6782,14 @@
|
|
|
6772
6782
|
const days = $('apikeys-days').value;
|
|
6773
6783
|
try {
|
|
6774
6784
|
const scopes = [...$('apikeys-scopes').querySelectorAll('input:checked')].map((b) => b.value);
|
|
6775
|
-
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) });
|
|
6785
|
+
const r = await post(`${API}/apikeys`, { label: $('apikeys-label').value.trim(), scopes, conversations: Object.fromEntries(apiKeyNewConvs), rooms: Object.fromEntries(apiKeyNewRooms), boxes: Object.fromEntries(apiKeyNewBoxes), binders: Object.fromEntries(apiKeyNewBinders), days: days === 'never' ? null : Number(days) });
|
|
6776
6786
|
apiKeyFormShown(false);
|
|
6777
6787
|
$('apikeys-secret').value = r.secret; $('apikeys-once').hidden = false;
|
|
6778
6788
|
/* 06-11 (the owner): the configuration an AI tool takes, ready to paste -- its MCP door and the key, shown this once too. */
|
|
6779
6789
|
const slug = (appName || 'clavarium').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'clavarium';
|
|
6780
6790
|
$('apikeys-config').textContent = JSON.stringify({ mcpServers: { [slug]: { type: 'http', url: `${location.origin}/api/ext/mcp`, headers: { Authorization: `Bearer ${r.secret}` } } } }, null, 2);
|
|
6781
6791
|
/* The curl line reads what it was given: the status, or else its conversations, rooms or voice boxes. */
|
|
6782
|
-
const first = scopes.includes('status') ? 'status' : apiKeyNewConvs.size ? 'conversations' : apiKeyNewRooms.size ? 'rooms' : apiKeyNewBoxes.size ? 'boxes' : 'status';
|
|
6792
|
+
const first = scopes.includes('status') ? 'status' : apiKeyNewConvs.size ? 'conversations' : apiKeyNewRooms.size ? 'rooms' : apiKeyNewBoxes.size ? 'boxes' : apiKeyNewBinders.size ? 'binders' : 'status';
|
|
6783
6793
|
$('apikeys-curl').textContent = `curl -H "Authorization: Bearer ${r.secret}" ${location.origin}/api/ext/v1/${first}`;
|
|
6784
6794
|
} catch (error) { $('apikeys-error').textContent = error.message; $('apikeys-error').hidden = false; return; }
|
|
6785
6795
|
await paintApiKeys();
|
package/web/index.html
CHANGED
|
@@ -1058,6 +1058,7 @@
|
|
|
1058
1058
|
<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, talk or act</small></div><div class="set-control" id="apikeys-convs"></div></div>
|
|
1059
1059
|
<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, talk or manage</small></div><div class="set-control" id="apikeys-rooms"></div></div>
|
|
1060
1060
|
<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>
|
|
1061
|
+
<div class="set-row" id="apikeys-binders-row" hidden><div class="set-what"><span data-said="set-apikeys-binders">Binders</span><small data-said="set-apikeys-binders-hint">One by one: read or write</small></div><div class="set-control" id="apikeys-binders"></div></div>
|
|
1061
1062
|
<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>
|
|
1062
1063
|
<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>
|
|
1063
1064
|
</form>
|