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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.384-next.1",
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.384-next.1",
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.57",
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.57",
804
- "resolved": "https://registry.npmjs.org/clavarium-tether/-/clavarium-tether-0.1.0-next.57.tgz",
805
- "integrity": "sha512-klagmTU3NdzMO6fA+nMvLg9UTW9XOWN1QYa1JF4ywykeliJsAMNOIWzAvlsVFEMPSg0t4IkcZq/Ui7dyTdDHDg==",
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.384-next.1",
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.57",
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
- return send(200, { given: tethers.given(), changes: tethers.changes(), ...data, ...(approvals ? { approvals } : {}) }), true;
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) {
@@ -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
- * (and on creation: rooms: {id: level}, boxes: {id: level})
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), JSON-RPC over HTTP
96
- * A conversation, room or box not given, or not there, is the same 403: a key learns nothing of what it was not given.
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
- constructor({ store, grants = new Grants(), journal = null, now = () => Date.now(), exists = () => true, roomExists = () => false, boxExists = () => false }) {
157
- this.#store = store; this.#grants = grants; this.#journal = journal; this.#now = now; this.#exists = exists;
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
- export const TOOLS = [STATUS_TOOL, CONVERSATIONS_TOOL, READ_TOOL, SAY_TOOL, WAKE_TOOL, SLEEP_TOOL, STOP_TOOL, ROOMS_TOOL, ROOM_READ_TOOL, ROOM_SAY_TOOL,
561
- ROOM_CREATE_TOOL, ROOM_CHANGE_TOOL, ROOM_REMOVE_TOOL, BOXES_TOOL, BOX_SAY_TOOL];
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
- export async function handleExternal(request, response, { keys, status, chats = null, rooms = null, boxes = null, events = null, headers = {}, originOk = () => false, version = null }) {
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;