clavarium 0.2.384-next.1 → 0.2.386-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 +6 -6
- package/package.json +3 -2
- package/server/api.mjs +10 -1
- package/server/apikeys.mjs +319 -18
- package/server/binders.mjs +12 -0
- package/server/browsers.mjs +295 -0
- package/server/demandes.mjs +51 -2
- package/server/dockets.mjs +20 -1
- package/server/grants.mjs +2 -0
- package/server/main.mjs +14 -3
- package/server/settingswords.mjs +21 -0
- package/server/tethers.mjs +9 -3
- package/web/app.js +98 -15
- package/web/index.html +14 -0
- package/web/style.css +10 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.386-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.386-next.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"clavarium-binder": "0.1.62-next.1",
|
|
15
15
|
"clavarium-docket": "0.1.2-next.2",
|
|
16
16
|
"clavarium-parley": "0.17.88-next.1",
|
|
17
|
-
"clavarium-tether": "0.1.0-next.
|
|
17
|
+
"clavarium-tether": "0.1.0-next.58",
|
|
18
18
|
"mammoth": "1.13.0",
|
|
19
19
|
"mermaid": "11.17.2",
|
|
20
20
|
"node-pty": "^1.1.0",
|
|
@@ -800,9 +800,9 @@
|
|
|
800
800
|
}
|
|
801
801
|
},
|
|
802
802
|
"node_modules/clavarium-tether": {
|
|
803
|
-
"version": "0.1.0-next.
|
|
804
|
-
"resolved": "https://registry.npmjs.org/clavarium-tether/-/clavarium-tether-0.1.0-next.
|
|
805
|
-
"integrity": "sha512-
|
|
803
|
+
"version": "0.1.0-next.58",
|
|
804
|
+
"resolved": "https://registry.npmjs.org/clavarium-tether/-/clavarium-tether-0.1.0-next.58.tgz",
|
|
805
|
+
"integrity": "sha512-doQf9RcohN4WIA2kQZeuBI6JlRlV9d+yTMw/iT14aqVJBaBh7i1ZIloIxhX2dRxAOFsGLNxuYUdSC4zShoIGJQ==",
|
|
806
806
|
"license": "MIT",
|
|
807
807
|
"bin": {
|
|
808
808
|
"tether": "bin/cli.mjs",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.386-next.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"check:hooks": "node tools/check-hooks.mjs",
|
|
37
37
|
"test:e2e": "node --test --test-concurrency=1 \"e2e/*.e2e.mjs\"",
|
|
38
38
|
"toolbox": "node deploy/parley-archive.mjs",
|
|
39
|
+
"extension": "node tools/extension-zips.mjs",
|
|
39
40
|
"acceptance": "node acceptance/run.mjs"
|
|
40
41
|
},
|
|
41
42
|
"engines": {
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
"clavarium-binder": "0.1.62-next.1",
|
|
48
49
|
"clavarium-docket": "0.1.2-next.2",
|
|
49
50
|
"clavarium-parley": "0.17.88-next.1",
|
|
50
|
-
"clavarium-tether": "0.1.0-next.
|
|
51
|
+
"clavarium-tether": "0.1.0-next.58",
|
|
51
52
|
"mammoth": "1.13.0",
|
|
52
53
|
"mermaid": "11.17.2",
|
|
53
54
|
"node-pty": "^1.1.0",
|
package/server/api.mjs
CHANGED
|
@@ -360,7 +360,9 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
360
360
|
const r = await tethers.act('state', { agent: query.get('agent') ?? undefined }, '');
|
|
361
361
|
const data = r.data ?? {};
|
|
362
362
|
const approvals = Array.isArray(data.approvals) ? data.approvals.map((q) => { try { return { ...q, view: demandeView(q) }; } catch { return q; } }) : data.approvals;
|
|
363
|
-
|
|
363
|
+
/* Each paired browser: connected or not, and the tabs its person granted (browsers.mjs). Never a token. */
|
|
364
|
+
const browsersNow = tethers.browsers ? { browsers: tethers.browsers.all(), browsersWaiting: tethers.browsers.waiting() } : {};
|
|
365
|
+
return send(200, { given: tethers.given(), changes: tethers.changes(), ...data, ...(approvals ? { approvals } : {}), ...browsersNow }), true;
|
|
364
366
|
}
|
|
365
367
|
/* Every device, each with all it was given. */
|
|
366
368
|
if (pathname === '/api/devices' && sharing) return send(200, sharing.devices.all()), true;
|
|
@@ -623,10 +625,17 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
623
625
|
const done = await { add: mcpAdd, import: mcpImport, refresh: mcpRefresh, rights: mcpRights, seen: mcpSeen }[mcpOp[1]](ctx, body ?? {});
|
|
624
626
|
return send(mcpOp[1] === 'add' || mcpOp[1] === 'import' ? 201 : 200, done), true;
|
|
625
627
|
}
|
|
628
|
+
/* A browser paired through the Tether extension (browsers.mjs): the code its popup shows, and its name; or paired
|
|
629
|
+
again ({ code, target }). The token it is handed is never in the answer. */
|
|
630
|
+
/* The sites a conversation acts on without asking, at frame: { target, agent, sites: [host | *.host] }. */
|
|
631
|
+
if (pathname === '/api/tether/browser-sites' && tethers?.browsers) return send(200, await tethers.browsers.setSites(tethers, body ?? {})), true;
|
|
632
|
+
if (pathname === '/api/tether/browser-pair' && tethers?.browsers) return send(201, tethers.browsers.pair({ code: body?.code, name: body?.name, target: body?.target })), true;
|
|
626
633
|
/* A server taken away: the key kept for it goes too. */
|
|
627
634
|
const mcpGone = pathname === '/api/tether/remove-target' && tethers ? tethers.store().target(String(body?.target ?? '')) : null;
|
|
628
635
|
const tetherOp = /^\/api\/tether\/(level|allow|unallow|decide|remove-target|stop|resume|job-done|job-file)$/.exec(pathname);
|
|
629
636
|
if (tetherOp && tethers && mcpGone?.kind === 'mcp') { const r = await tethers.act('remove-target', body, ''); if (!r.ok) throw new Refusal(400, r.text); forgetKey(secrets, mcpGone); return send(200, r.data ?? { ok: true }), true; }
|
|
637
|
+
/* A browser taken away: its tokens and its socket go too. */
|
|
638
|
+
if (tetherOp && tethers && mcpGone?.kind === 'browser') { const r = await tethers.act('remove-target', body, ''); if (!r.ok) throw new Refusal(400, r.text); tethers.browsers?.forget(mcpGone.id); return send(200, r.data ?? { ok: true }), true; }
|
|
630
639
|
if (tetherOp && tethers) { const r = await tethers.act(tetherOp[1], body, ''); if (!r.ok) throw new Refusal(400, r.text); return send(200, r.data ?? { ok: true }), true; }
|
|
631
640
|
/* A mailbox's signature (Markdown), added under what the agent sends from it. */
|
|
632
641
|
if (pathname === '/api/tether/signature' && tethers) {
|
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;
|