clavarium 0.2.372-next.1 → 0.2.374-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,19 +1,19 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.372-next.1",
3
+ "version": "0.2.374-next.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.372-next.1",
9
+ "version": "0.2.374-next.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
13
13
  "@xterm/xterm": "^6.0.0",
14
14
  "clavarium-binder": "0.1.62-next.1",
15
15
  "clavarium-docket": "0.1.2-next.2",
16
- "clavarium-parley": "0.17.86-next.1",
16
+ "clavarium-parley": "0.17.87-next.1",
17
17
  "clavarium-tether": "0.1.0-next.57",
18
18
  "mammoth": "1.13.0",
19
19
  "mermaid": "11.17.2",
@@ -787,9 +787,9 @@
787
787
  }
788
788
  },
789
789
  "node_modules/clavarium-parley": {
790
- "version": "0.17.86-next.1",
791
- "resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.17.86-next.1.tgz",
792
- "integrity": "sha512-IPyRfk8mEVl3HR4uM+lbT+3z/njDnvf/VBsRH32XPL3RTxqk8/AXgqJTTGcIce8OQAiOQmJ0uH4CHGeEVypP0w==",
790
+ "version": "0.17.87-next.1",
791
+ "resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.17.87-next.1.tgz",
792
+ "integrity": "sha512-xp5kdNJPL+dGQq0zQXoSy5CCzoQkDFCbOkmvIiXC2JAOlZZg209B/d2IIUXMXHehiLG+uBLgFEvMwsoBIsqGPg==",
793
793
  "license": "MIT",
794
794
  "bin": {
795
795
  "parley": "bin/parley.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.372-next.1",
3
+ "version": "0.2.374-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.",
@@ -46,7 +46,7 @@
46
46
  "@xterm/xterm": "^6.0.0",
47
47
  "clavarium-binder": "0.1.62-next.1",
48
48
  "clavarium-docket": "0.1.2-next.2",
49
- "clavarium-parley": "0.17.86-next.1",
49
+ "clavarium-parley": "0.17.87-next.1",
50
50
  "clavarium-tether": "0.1.0-next.57",
51
51
  "mammoth": "1.13.0",
52
52
  "mermaid": "11.17.2",
@@ -14,6 +14,8 @@
14
14
  * read read its chat conversation:<name> r
15
15
  * talk and post a message in it, as the chat does,
16
16
  * signed with the key's name, never as the person conversation:<name> rw
17
+ * act and wake it, put it to sleep, stop its turn --
18
+ * as the owner's buttons do conversation:<name> rwm
17
19
  * A conversation archived takes its lines with it (devices.forget). Its
18
20
  * terminal ("x") is never given to a key.
19
21
  *
@@ -22,6 +24,11 @@
22
24
  * secrets masked) room:<id> r
23
25
  * talk and post in it as the person does -- to its host, or to the AIs
24
26
  * named -- signed with the key's name and look room:<id> rw
27
+ * manage and rename it, add or take away members, change
28
+ * its host, remove it -- its members only among the
29
+ * conversations and boxes the key was given room:<id> rwm
30
+ * create rooms make a room (its members as above): the room a key
31
+ * makes is given to it at "manage" server:rooms w
25
32
  *
26
33
  * a voice box, given ONE BY ONE:
27
34
  * state online, where it speaks, when it last heard, its volume --
@@ -44,7 +51,14 @@
44
51
  * never the secret, nor anything of it. Its own file, one JSON line each,
45
52
  * cut to its newest half past JOURNAL_MAX. Each key is limited to
46
53
  * RATE_PER_MIN calls a minute; calls with a key nobody has are limited too,
47
- * all together, so that they cannot flood the journal.
54
+ * all together, so that they cannot flood the journal. What a key DOES (a
55
+ * conversation woken, a room made...) is said in What happened too, with
56
+ * its name.
57
+ *
58
+ * THE KEY'S RIGHTS ARE THE SECURITY (07-11, the owner): no approval card for
59
+ * what a key was given. Three limits hold whatever it was given: a key never
60
+ * reads a secret's value; never makes a key or an account; never changes its
61
+ * own rights or any key's -- no route under /api/ext/ reaches any of them.
48
62
  *
49
63
  * Who manages keys: whoever passes the gate (an administrator), from
50
64
  * Settings, Access. A device (/c/) never reaches these routes; a key never
@@ -65,14 +79,20 @@
65
79
  * GET /api/ext/v1/conversations those it may read: {conversations: [{id, name, level, chat}]}
66
80
  * GET /api/ext/v1/conversations/<name>/messages ?last=1..200 (50) | ?since=<cursor>: {chat, messages, cursor}
67
81
  * POST /api/ext/v1/conversations/<name>/messages {text}: posted in its chat, signed with the key's name
82
+ * POST /api/ext/v1/conversations/<name>/wake|sleep|stop (act) woken, put to sleep, its turn stopped (Escape)
68
83
  * GET /api/ext/v1/rooms those it may read: {rooms: [{id, name, level, host, members}]}
69
84
  * GET /api/ext/v1/rooms/<id>/messages as a conversation's: {messages, cursor}
70
85
  * POST /api/ext/v1/rooms/<id>/messages {text, to?: [names of its AIs]}: posted in the room, signed with the key
86
+ * POST /api/ext/v1/rooms (create rooms) {name, conversations?: [ids], boxes?: [ids], host?: id}
87
+ * POST /api/ext/v1/rooms/<id> (manage) {name?, host?: id, add?: {conversations, boxes}, remove?: {conversations, boxes}}
88
+ * POST /api/ext/v1/rooms/<id>/remove (manage) the room removed
71
89
  * GET /api/ext/v1/boxes {boxes: [{id, name, level, online, where, heardAt, volume}]}
72
90
  * POST /api/ext/v1/boxes/<id>/say {text}: said aloud by the box
73
91
  * POST /api/ext/mcp the same as MCP tools (clavarium_status, clavarium_conversations,
74
92
  * clavarium_read, clavarium_say, clavarium_rooms, clavarium_room_read,
75
- * clavarium_room_say, clavarium_boxes, clavarium_box_say), JSON-RPC over HTTP
93
+ * clavarium_room_say, clavarium_boxes, clavarium_box_say, clavarium_conversation_wake,
94
+ * clavarium_conversation_sleep, clavarium_conversation_stop, clavarium_room_create,
95
+ * clavarium_room_change, clavarium_room_remove), JSON-RPC over HTTP
76
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.
77
97
  *
78
98
  * The front lets /api/ext/ through without sign-on, as it does /c/
@@ -102,16 +122,18 @@ const USED_SAVE_MS = 60_000;
102
122
  export const EXT_PREFIX = '/api/ext/';
103
123
 
104
124
  /* The scopes a key may be given: [asset, rights] in the table. */
105
- export const SCOPES = Object.freeze({ status: { asset: 'server:status', rights: 'r', label: 'Its state (memory, disk, versions)' } });
125
+ export const SCOPES = Object.freeze({ status: { asset: 'server:status', rights: 'r', label: 'Its state (memory, disk, versions)' },
126
+ 'create-rooms': { asset: 'server:rooms', rights: 'w', label: 'Create rooms' } });
106
127
  /* What a key may do in one conversation: [rights in the table]. */
107
- export const LEVELS = Object.freeze({ read: 'r', talk: 'rw' });
108
- export const levelOf = (rights) => (String(rights).includes('w') ? 'talk' : String(rights).includes('r') ? 'read' : null);
128
+ export const LEVELS = Object.freeze({ read: 'r', talk: 'rw', act: 'rwm' });
129
+ export const levelOf = (rights) => (String(rights).includes('m') ? 'act' : String(rights).includes('w') ? 'talk' : String(rights).includes('r') ? 'read' : null);
109
130
  /* The messages one read gives, by default and at most. */
110
131
  export const READ_DEFAULT = 50;
111
132
  export const READ_MAX = 200;
112
133
  export const SAY_MAX = 8000;
113
134
  /* What a room and a voice box may be given: [rights in the table]. */
114
- export const ROOM_LEVELS = Object.freeze({ read: 'r', talk: 'rw' });
135
+ export const ROOM_LEVELS = Object.freeze({ read: 'r', talk: 'rw', manage: 'rwm' });
136
+ export const roomLevelOf = (rights) => (String(rights).includes('m') ? 'manage' : String(rights).includes('w') ? 'talk' : String(rights).includes('r') ? 'read' : null);
115
137
  export const BOX_LEVELS = Object.freeze({ state: 'r', speak: 'rw' });
116
138
  export const boxLevelOf = (rights) => (String(rights).includes('w') ? 'speak' : String(rights).includes('r') ? 'state' : null);
117
139
  /* Said aloud in the home: short, and a few a minute (per key and box). */
@@ -135,7 +157,7 @@ export class ApiKeys {
135
157
  this.#store = store; this.#grants = grants; this.#journal = journal; this.#now = now; this.#exists = exists;
136
158
  /* What is given one by one: its asset type, its levels, a valid name, whether it is there, its words. */
137
159
  this.#kinds = {
138
- room: { levels: ROOM_LEVELS, of: levelOf, valid: (v) => typeof v === 'string' && UUID.test(v), exists: roomExists, what: 'room', the: 'a room' },
160
+ room: { levels: ROOM_LEVELS, of: roomLevelOf, valid: (v) => typeof v === 'string' && UUID.test(v), exists: roomExists, what: 'room', the: 'a room' },
139
161
  box: { levels: BOX_LEVELS, of: boxLevelOf, valid: (v) => typeof v === 'string' && UUID.test(v), exists: boxExists, what: 'voice box', the: 'a voice box' },
140
162
  };
141
163
  }
@@ -187,7 +209,7 @@ export class ApiKeys {
187
209
 
188
210
  #conversationAsked(name, level) {
189
211
  if (!isConversationName(name) || !this.#exists(name)) throw new Refusal(404, 'No such conversation.');
190
- if (level !== null && !Object.hasOwn(LEVELS, level)) throw new Refusal(400, `A conversation is given to read or to talk: ${Object.keys(LEVELS).join(', ')}.`);
212
+ 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(', ')}.`);
191
213
  }
192
214
 
193
215
  /* One conversation given to a key, its level changed, or taken back (level null). */
@@ -282,6 +304,7 @@ export class ApiKeys {
282
304
  const rights = isConversationName(name) ? this.#grants.rightsOf(apiKeySubject(id), `conversation:${name}`) : '';
283
305
  if (!rights.includes('r')) throw Object.assign(new Refusal(403, 'This key was not given this conversation.'), { key: id });
284
306
  if (level === 'talk' && !rights.includes('w')) throw Object.assign(new Refusal(403, 'This key may read this conversation, not talk in it.'), { key: id });
307
+ if (level === 'act' && !rights.includes('m')) throw Object.assign(new Refusal(403, 'This key may not act on this conversation (wake, sleep, stop).'), { key: id });
285
308
  return levelOf(rights);
286
309
  }
287
310
 
@@ -291,7 +314,17 @@ export class ApiKeys {
291
314
  const rights = typeof room === 'string' && UUID.test(room) && this.#kinds.room.exists(room) ? this.#grants.rightsOf(apiKeySubject(id), `room:${room}`) : '';
292
315
  if (!rights.includes('r')) throw Object.assign(new Refusal(403, 'This key was not given this room.'), { key: id });
293
316
  if (level === 'talk' && !rights.includes('w')) throw Object.assign(new Refusal(403, 'This key may read this room, not talk in it.'), { key: id });
294
- return levelOf(rights);
317
+ if (level === 'manage' && !rights.includes('m')) throw Object.assign(new Refusal(403, 'This key may not manage this room.'), { key: id });
318
+ return roomLevelOf(rights);
319
+ }
320
+
321
+ /* A ROOM THE KEY MADE ITSELF (with "create rooms") is given to it at "manage". This is the one right a key ever
322
+ gets on its own, and it is not a key changing its rights: it is scoped to what it created, and nothing else
323
+ of this key or any other changes -- a room made by another, a conversation, a box, a scope stay as they were. */
324
+ madeRoom(id, room) {
325
+ if (!this.may(id, 'create-rooms')) throw Object.assign(new Refusal(403, `This key was not given the scope "${SCOPES['create-rooms'].label}".`), { key: id });
326
+ if (typeof room !== 'string' || !UUID.test(room) || !this.#kinds.room.exists(room)) throw new Refusal(404, 'No such room.');
327
+ this.#grants.give(apiKeySubject(id), `room:${room}`, ROOM_LEVELS.manage);
295
328
  }
296
329
  /* Whether a key may see the state of ('state') or make speak ('speak') one voice box; 403 if not. */
297
330
  mayBox(id, box, level = 'state') {
@@ -437,7 +470,7 @@ export const STATUS_TOOL = {
437
470
  export const CONVERSATIONS_TOOL = {
438
471
  name: 'clavarium_conversations',
439
472
  title: 'Conversations of this key',
440
- description: 'The conversations this key was given, each with its id, name, level (read or talk) and whether its chat is on.',
473
+ description: 'The conversations this key was given, each with its id, name, level (read, talk or act) and whether its chat is on.',
441
474
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
442
475
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
443
476
  };
@@ -460,7 +493,7 @@ const BOX_ARG = { type: 'string', description: 'The voice box\'s id, as clavariu
460
493
  export const ROOMS_TOOL = {
461
494
  name: 'clavarium_rooms',
462
495
  title: 'Rooms of this key',
463
- description: 'The rooms this key was given, each with its id, name, level (read or talk), its host and its members by name.',
496
+ description: 'The rooms this key was given, each with its id, name, level (read, talk or manage), its host and its members by name (with their id when this key was given them).',
464
497
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
465
498
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
466
499
  };
@@ -492,15 +525,52 @@ export const BOX_SAY_TOOL = {
492
525
  inputSchema: { type: 'object', properties: { box: BOX_ARG, text: { type: 'string', minLength: 1, maxLength: SPEAK_MAX } }, required: ['box', 'text'], additionalProperties: false },
493
526
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
494
527
  };
495
- export const TOOLS = [STATUS_TOOL, CONVERSATIONS_TOOL, READ_TOOL, SAY_TOOL, ROOMS_TOOL, ROOM_READ_TOOL, ROOM_SAY_TOOL, BOXES_TOOL, BOX_SAY_TOOL];
528
+ /* To act on a conversation (level "act"), and to manage rooms (level "manage", or "create rooms"). */
529
+ const actTool = (act, title, description, destructive) => ({
530
+ name: `clavarium_conversation_${act}`, title, description: `${description} The conversation must have been given to this key to act.`,
531
+ inputSchema: { type: 'object', properties: { conversation: CONVERSATION_ARG }, required: ['conversation'], additionalProperties: false },
532
+ annotations: { readOnlyHint: false, destructiveHint: destructive, idempotentHint: true, openWorldHint: false },
533
+ });
534
+ export const WAKE_TOOL = actTool('wake', 'Wake a conversation', 'Wake one conversation\'s machine (asleep: it starts again; awake: nothing changes).', false);
535
+ export const SLEEP_TOOL = actTool('sleep', 'Put a conversation to sleep', 'Put one conversation\'s machine to sleep, as the owner\'s Sleep button does: its files and chat are kept.', false);
536
+ export const STOP_TOOL = actTool('stop', 'Stop a conversation\'s turn', 'Stop what one conversation\'s AI is doing now (its current turn, as Escape does); the conversation stays awake.', true);
537
+ const IDS = { type: 'array', items: { type: 'string' }, maxItems: 40 };
538
+ const MEMBERS_ARG = { type: 'object', properties: { conversations: { ...IDS, description: 'Conversation ids, as clavarium_conversations gives them.' }, boxes: { ...IDS, description: 'Voice box ids, as clavarium_boxes gives them.' } }, additionalProperties: false };
539
+ export const ROOM_CREATE_TOOL = {
540
+ name: 'clavarium_room_create',
541
+ title: 'Create a room',
542
+ description: 'Make a room with conversations (AIs) and voice boxes this key was given; one conversation at least, its host (the first by default). The room is then given to this key to manage. Needs the scope "Create rooms".',
543
+ inputSchema: { type: 'object', properties: { name: { type: 'string', minLength: 1, maxLength: 60 }, conversations: { ...IDS, minItems: 1, description: 'Conversation ids, as clavarium_conversations gives them.' }, boxes: { ...IDS, description: 'Voice box ids, as clavarium_boxes gives them.' }, host: CONVERSATION_ARG }, required: ['name', 'conversations'], additionalProperties: false },
544
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
545
+ };
546
+ export const ROOM_CHANGE_TOOL = {
547
+ name: 'clavarium_room_change',
548
+ title: 'Change a room',
549
+ description: 'Rename a room, add or take away members (only conversations and voice boxes this key was given), or change its host (one of its conversations). The room must have been given to this key to manage.',
550
+ inputSchema: { type: 'object', properties: { room: ROOM_ARG, name: { type: 'string', minLength: 1, maxLength: 60 }, host: CONVERSATION_ARG, add: MEMBERS_ARG, remove: MEMBERS_ARG }, required: ['room'], additionalProperties: false },
551
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
552
+ };
553
+ export const ROOM_REMOVE_TOOL = {
554
+ name: 'clavarium_room_remove',
555
+ title: 'Remove a room',
556
+ description: 'Remove one room and its thread, for good. The room must have been given to this key to manage.',
557
+ inputSchema: { type: 'object', properties: { room: ROOM_ARG }, required: ['room'], additionalProperties: false },
558
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
559
+ };
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];
496
562
 
497
563
  /* Returns true when the request was under /api/ext/ (answered here). `status()`: the state, as statusOf
498
564
  makes it. `chats`: { list() -> [{ id, name }], read(name, part) -> talk.of's answer, chatOn(name),
499
565
  say(name, text, by) -> { told } }. `rooms`: { list() -> [{ id, name, host, members: [{ name, kind }] }],
500
566
  read(id, part) -> rooms.read's answer, say(id, text, { as, to }) }. `boxes`: { list() -> [{ id, name, online, where,
501
567
  heardAt, volume }], say(id, text, by) -> { seconds? } }. `originOk(headers)`: whether an Origin is this site's (gate.mjs); a
502
- call from a page of another site is refused. */
503
- export async function handleExternal(request, response, { keys, status, chats = null, rooms = null, boxes = null, headers = {}, originOk = () => false, version = null }) {
568
+ call from a page of another site is refused.
569
+ To act (07-11): `chats` also { wake(name) -> { state }, sleep(name), stop(name) } -- the owner's own calls; `rooms` also
570
+ { create({ name, conversations, boxes, host }) -> { id }, change(id, { name, host, add, remove }) -- by member ids, as the
571
+ 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 }) {
504
574
  const url = new URL(request.url ?? '/', 'http://local');
505
575
  const { pathname } = url;
506
576
  if (!pathname.startsWith(EXT_PREFIX) && pathname !== EXT_PREFIX.slice(0, -1)) return false;
@@ -546,8 +616,11 @@ export async function handleExternal(request, response, { keys, status, chats =
546
616
  /* The same for rooms and voice boxes. */
547
617
  const roomsListed = (id) => {
548
618
  const there = new Map((rooms?.list() ?? []).map((r) => [r.id, r]));
619
+ /* A member's id only when the key was given it (a conversation's, a box's): what it may add or take away. */
620
+ const convs = new Set(keys.conversationsOf(id).map((c) => c.name)); const bs = new Set(keys.boxesOf(id).map((b) => b.id));
621
+ const mine = (m) => (m.kind === 'ai' && convs.has(m.ref)) || (m.kind === 'box' && bs.has(m.ref));
549
622
  return { rooms: keys.roomsOf(id).filter((g) => there.has(g.id)).map((g) => { const r = there.get(g.id);
550
- return { id: g.id, name: String(r.name ?? ''), level: g.level, host: r.host ?? null, members: (Array.isArray(r.members) ? r.members : []).map((m) => ({ name: String(m.name ?? ''), kind: String(m.kind ?? '') })) }; }) };
623
+ return { id: g.id, name: String(r.name ?? ''), level: g.level, host: r.host ?? null, members: (Array.isArray(r.members) ? r.members : []).map((m) => ({ name: String(m.name ?? ''), kind: String(m.kind ?? ''), ...(typeof m.ref === 'string' && mine(m) ? { id: m.ref } : {}) })) }; }) };
551
624
  };
552
625
  const roomKnown = (room) => { if (!(rooms?.list() ?? []).some((r) => r.id === room)) throw new Refusal(403, 'This key was not given this room.'); };
553
626
  const roomReading = (id, room, part) => {
@@ -577,6 +650,95 @@ export async function handleExternal(request, response, { keys, status, chats =
577
650
  const done = await boxes.say(box, text, keys.signature(id));
578
651
  return { ok: true, ...(Number.isFinite(done?.seconds) ? { seconds: done.seconds } : {}) };
579
652
  };
653
+ /* SAID IN WHAT HAPPENED, with the key's name: what a key did, never what it read. */
654
+ const said = (id, kind, text, conversation = null) => {
655
+ const label = keys.signature(id)?.name ?? '';
656
+ try { events?.add({ kind, text, ...(conversation ? { conversation } : {}), who: `API key "${label}"` }); } catch { /* never in the way */ }
657
+ };
658
+ /* ACT on a conversation (level "act"): the owner's own calls -- woken, put to sleep, its turn stopped. */
659
+ const ACTS = { wake: 'woken', sleep: 'put to sleep', stop: 'its turn stopped' };
660
+ const acting = async (id, name, act) => {
661
+ keys.mayConversation(id, name, 'act'); known(name);
662
+ if (typeof chats?.[act] !== 'function') throw new Refusal(409, 'Not possible here.');
663
+ const done = await chats[act](name);
664
+ said(id, act === 'stop' ? 'conversation' : 'machine', act === 'stop' ? `${name}: its turn stopped` : `Machine of ${name} ${ACTS[act]}`, name);
665
+ return { ok: true, ...(typeof done?.state === 'string' ? { state: done.state } : {}) };
666
+ };
667
+ /* MANAGE a room (level "manage"), or make one ("create rooms"). Its members are named as the key knows them --
668
+ a conversation by its id, a box by its id -- and only among those this key was given (at any level): else 403,
669
+ the same for one not there. The body takes these fields and no other: never a level, a right, a key. */
670
+ const refs = (value, what) => {
671
+ if (value === undefined || value === null) return [];
672
+ if (!Array.isArray(value) || value.length > 40 || value.some((x) => typeof x !== 'string')) throw new Refusal(400, `${what}: a list of ids.`);
673
+ return [...new Set(value)];
674
+ };
675
+ const sides = (value, what) => {
676
+ if (value === undefined || value === null) return { conversations: [], boxes: [] };
677
+ if (typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((k) => !['conversations', 'boxes'].includes(k))) throw new Refusal(400, `${what}: {conversations: [ids], boxes: [ids]}.`);
678
+ return { conversations: refs(value.conversations, `${what}.conversations`), boxes: refs(value.boxes, `${what}.boxes`) };
679
+ };
680
+ const onlyFields = (body, fields) => {
681
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Refusal(400, 'Send JSON: an object.');
682
+ const other = Object.keys(body).filter((k) => !fields.includes(k));
683
+ if (other.length) throw new Refusal(400, `Not a field here: ${other[0].slice(0, 40)}. Only: ${fields.join(', ')}.`);
684
+ };
685
+ const givenMembers = (id, { conversations, boxes: bs }) => {
686
+ const convs = new Set(keys.conversationsOf(id).map((c) => c.name));
687
+ const there = new Set((chats?.list() ?? []).map((c) => c.id));
688
+ for (const c of conversations) if (!convs.has(c) || !there.has(c)) throw Object.assign(new Refusal(403, 'This key was not given this conversation.'), { key: id });
689
+ const given = new Set(keys.boxesOf(id).map((b) => b.id));
690
+ const boxesThere = new Set((boxes?.list() ?? []).map((b) => b.id));
691
+ for (const b of bs) if (!given.has(b) || !boxesThere.has(b)) throw Object.assign(new Refusal(403, 'This key was not given this voice box.'), { key: id });
692
+ };
693
+ const roomShown = (id, room) => roomsListed(id).rooms.find((r) => r.id === room) ?? null;
694
+ const roomCreating = (id, body) => {
695
+ if (!keys.may(id, 'create-rooms')) throw Object.assign(new Refusal(403, `This key was not given the scope "${SCOPES['create-rooms'].label}".`), { key: id });
696
+ onlyFields(body, ['name', 'conversations', 'boxes', 'host']);
697
+ const members = { conversations: refs(body.conversations, 'conversations'), boxes: refs(body.boxes, 'boxes') };
698
+ if (body.host !== undefined && body.host !== null && (typeof body.host !== 'string' || !members.conversations.includes(body.host))) throw new Refusal(400, 'host: one of its conversations.');
699
+ givenMembers(id, members);
700
+ if (typeof rooms?.create !== 'function') throw new Refusal(409, 'Not possible here.');
701
+ const made = rooms.create({ name: body.name, conversations: members.conversations, boxes: members.boxes, ...(body.host ? { host: body.host } : {}) });
702
+ /* The one right a key gets on its own: the room it made, at "manage" (see madeRoom). */
703
+ keys.madeRoom(id, made.id);
704
+ const shown = roomShown(id, made.id);
705
+ said(id, 'conversation', `Room "${String(shown?.name ?? made.name ?? '').slice(0, 60)}" made`);
706
+ return { ok: true, room: shown };
707
+ };
708
+ const roomChanging = (id, room, body) => {
709
+ keys.mayRoom(id, room, 'manage'); roomKnown(room);
710
+ onlyFields(body, ['name', 'host', 'add', 'remove']);
711
+ const add = sides(body.add, 'add'); const remove = sides(body.remove, 'remove');
712
+ if (body.host !== undefined && body.host !== null && typeof body.host !== 'string') throw new Refusal(400, 'host: the id of one of its conversations.');
713
+ const host = typeof body.host === 'string' ? body.host : null;
714
+ givenMembers(id, { conversations: [...add.conversations, ...remove.conversations, ...(host ? [host] : [])], boxes: [...add.boxes, ...remove.boxes] });
715
+ const before = rooms.list().find((r) => r.id === room);
716
+ const members = Array.isArray(before?.members) ? before.members : [];
717
+ const member = (kind, ref) => members.find((m) => m.kind === kind && m.ref === ref);
718
+ const gone = [...remove.conversations.map((c) => ['ai', c]), ...remove.boxes.map((b) => ['box', b])].map(([kind, ref]) => {
719
+ const m = member(kind, ref); if (!m) throw new Refusal(400, 'Not a member of this room.'); return m.id; });
720
+ /* The host: one of its AIs once the change is made (one added now, too). */
721
+ if (host && !(member('ai', host) && !remove.conversations.includes(host)) && !add.conversations.includes(host)) throw new Refusal(400, 'host: one of its conversations.');
722
+ const name = body.name === undefined || body.name === null ? undefined : String(body.name);
723
+ rooms.change(room, { ...(name !== undefined ? { name } : {}), add, remove: gone });
724
+ if (host) {
725
+ const now = rooms.list().find((r) => r.id === room)?.members ?? [];
726
+ const m = now.find((x) => x.kind === 'ai' && x.ref === host);
727
+ if (m && !m.host) rooms.change(room, { host: m.id });
728
+ }
729
+ const shown = roomShown(id, room);
730
+ const what = [...(name !== undefined ? [`renamed "${String(shown?.name ?? name).slice(0, 60)}"`] : []), ...(add.conversations.length + add.boxes.length ? [`${add.conversations.length + add.boxes.length} added`] : []),
731
+ ...(gone.length ? [`${gone.length} taken away`] : []), ...(host ? [`host ${host}`] : [])];
732
+ said(id, 'conversation', `Room "${String(before?.name ?? '').slice(0, 60)}" changed${what.length ? `: ${what.join(', ')}` : ''}`);
733
+ return { ok: true, room: shown };
734
+ };
735
+ const roomRemoving = (id, room) => {
736
+ keys.mayRoom(id, room, 'manage'); roomKnown(room);
737
+ const name = rooms.list().find((r) => r.id === room)?.name ?? '';
738
+ rooms.remove(room);
739
+ said(id, 'conversation', `Room "${String(name).slice(0, 60)}" removed`);
740
+ return { ok: true, removed: room };
741
+ };
580
742
  const ownError = (error, id) => (error instanceof Refusal ? Object.assign(error, { key: error.key ?? id }) : error);
581
743
  try {
582
744
  if (route === '/v1/status') {
@@ -587,9 +749,9 @@ export async function handleExternal(request, response, { keys, status, chats =
587
749
  note(id, 200);
588
750
  return send(200, body);
589
751
  }
590
- const one = /^\/v1\/conversations(?:\/([^/]+)\/messages)?$/.exec(route);
752
+ const one = /^\/v1\/conversations(?:\/([^/]+)\/(messages|wake|sleep|stop))?$/.exec(route);
591
753
  if (one) {
592
- const allowed = one[1] ? 'GET, POST' : 'GET';
754
+ const allowed = one[2] && one[2] !== 'messages' ? 'POST' : one[1] ? 'GET, POST' : 'GET';
593
755
  if (!allowed.split(', ').includes(request.method)) return send(405, { error: `Call this address with ${allowed}.` }, { allow: allowed });
594
756
  let id;
595
757
  try { id = keys.check(request.headers.authorization); } catch (error) { return refused(error); }
@@ -597,6 +759,7 @@ export async function handleExternal(request, response, { keys, status, chats =
597
759
  if (!one[1]) { const body = listed(id); note(id, 200); return send(200, body); }
598
760
  let name;
599
761
  try { name = decodeURIComponent(one[1]); } catch { name = ''; }
762
+ if (one[2] !== 'messages') { const done = await acting(id, name, one[2]); const code = one[2] === 'wake' ? 202 : 200; note(id, code); return send(code, done); }
600
763
  if (request.method === 'GET') { const body = reading(id, name, readPart((k) => url.searchParams.get(k))); note(id, 200); return send(200, body); }
601
764
  if (!String(request.headers['content-type'] ?? '').startsWith('application/json')) throw new Refusal(415, 'Send JSON: {"text": "..."}.');
602
765
  let body;
@@ -606,17 +769,26 @@ export async function handleExternal(request, response, { keys, status, chats =
606
769
  return send(201, done);
607
770
  } catch (error) { return refused(ownError(error, id)); }
608
771
  }
609
- const room = /^\/v1\/rooms(?:\/([^/]+)\/messages)?$/.exec(route);
772
+ const room = /^\/v1\/rooms(?:\/([^/]+)(\/messages|\/remove)?)?$/.exec(route);
610
773
  if (room) {
611
- const allowed = room[1] ? 'GET, POST' : 'GET';
774
+ const allowed = room[2] === '/messages' || !room[1] ? 'GET, POST' : 'POST';
612
775
  if (!allowed.split(', ').includes(request.method)) return send(405, { error: `Call this address with ${allowed}.` }, { allow: allowed });
613
776
  let id;
614
777
  try { id = keys.check(request.headers.authorization); } catch (error) { return refused(error); }
615
778
  try {
616
- if (!room[1]) { const body = roomsListed(id); note(id, 200); return send(200, body); }
779
+ if (!room[1] && request.method === 'GET') { const body = roomsListed(id); note(id, 200); return send(200, body); }
617
780
  let ref;
618
- try { ref = decodeURIComponent(room[1]); } catch { ref = ''; }
781
+ try { ref = decodeURIComponent(room[1] ?? ''); } catch { ref = ''; }
782
+ if (room[2] === '/remove') { const done = roomRemoving(id, ref); note(id, 200); return send(200, done); }
619
783
  if (request.method === 'GET') { const body = roomReading(id, ref, readPart((k) => url.searchParams.get(k))); note(id, 200); return send(200, body); }
784
+ if (room[2] !== '/messages') {
785
+ if (!String(request.headers['content-type'] ?? '').startsWith('application/json')) throw new Refusal(415, 'Send JSON.');
786
+ let body;
787
+ try { body = JSON.parse(await bodyOf(16_384)); } catch (error) { if (error instanceof Refusal) throw error; throw new Refusal(400, 'The request is not JSON.'); }
788
+ const done = room[1] ? roomChanging(id, ref, body) : roomCreating(id, body);
789
+ const code = room[1] ? 200 : 201;
790
+ note(id, code); return send(code, done);
791
+ }
620
792
  if (!String(request.headers['content-type'] ?? '').startsWith('application/json')) throw new Refusal(415, 'Send JSON: {"text": "..."}.');
621
793
  let body;
622
794
  try { body = JSON.parse(await bodyOf(SAY_MAX * 4 + 4096)); } catch (error) { if (error instanceof Refusal) throw error; throw new Refusal(400, 'The request is not JSON.'); }
@@ -668,7 +840,7 @@ export async function handleExternal(request, response, { keys, status, chats =
668
840
  const asked = String(msg.params?.protocolVersion ?? '');
669
841
  return answer({ protocolVersion: MCP_VERSIONS.includes(asked) ? asked : MCP_VERSIONS[0], capabilities: { tools: { listChanged: false } },
670
842
  serverInfo: { name: 'clavarium', title: 'Clavarium', version: version ?? '0' },
671
- instructions: 'This Clavarium, as far as this key was given it: its state (clavarium_status), the conversations given to it (clavarium_conversations), to read (clavarium_read) or to talk in (clavarium_say); its rooms (clavarium_rooms, clavarium_room_read, clavarium_room_say); its voice boxes (clavarium_boxes; clavarium_box_say says a short text aloud in the home).' });
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).' });
672
844
  }
673
845
  case 'ping': return answer({});
674
846
  case 'tools/list': return answer({ tools: TOOLS });
@@ -685,17 +857,22 @@ export async function handleExternal(request, response, { keys, status, chats =
685
857
  if (name === BOXES_TOOL.name) return result(boxesListed(id));
686
858
  const part = () => readPart((k) => (args[k] === undefined ? null : typeof args[k] === 'number' ? String(args[k]) : args[k]));
687
859
  let done;
688
- if (name === ROOM_READ_TOOL.name || name === ROOM_SAY_TOOL.name) {
860
+ if (name === ROOM_CREATE_TOOL.name) {
861
+ done = roomCreating(id, args);
862
+ } else if (name === ROOM_READ_TOOL.name || name === ROOM_SAY_TOOL.name || name === ROOM_CHANGE_TOOL.name || name === ROOM_REMOVE_TOOL.name) {
689
863
  if (typeof args.room !== 'string') return fail(-32602, 'room: the id of a room.');
690
864
  if (name === ROOM_READ_TOOL.name) return result(roomReading(id, args.room, part()));
691
- done = await roomSaying(id, args.room, args.text, args.to);
865
+ if (name === ROOM_CHANGE_TOOL.name) { const { room: target, ...change } = args; done = roomChanging(id, target, change); }
866
+ else if (name === ROOM_REMOVE_TOOL.name) done = roomRemoving(id, args.room);
867
+ else done = await roomSaying(id, args.room, args.text, args.to);
692
868
  } else if (name === BOX_SAY_TOOL.name) {
693
869
  if (typeof args.box !== 'string') return fail(-32602, 'box: the id of a voice box.');
694
870
  done = await boxSaying(id, args.box, args.text);
695
871
  } else {
696
872
  if (typeof args.conversation !== 'string') return fail(-32602, 'conversation: the id of a conversation.');
697
873
  if (name === READ_TOOL.name) return result(reading(id, args.conversation, part()));
698
- done = await saying(id, args.conversation, args.text);
874
+ const act = { [WAKE_TOOL.name]: 'wake', [SLEEP_TOOL.name]: 'sleep', [STOP_TOOL.name]: 'stop' }[name];
875
+ done = act ? await acting(id, args.conversation, act) : await saying(id, args.conversation, args.text);
699
876
  }
700
877
  note(id, 201, what);
701
878
  return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: JSON.stringify(done) }], structuredContent: done, isError: false } });
@@ -34,7 +34,7 @@ const plain = (body) => String(body ?? '').replace(/\*\*(\S[^*\n]*?)\*\*/g, '$1'
34
34
 
35
35
  /* A CALL OF AN EXTERNAL MCP SERVER'S TOOL (Tether's card { kind: 'mcp', action: 'call', server, tool, title?, class, open?,
36
36
  * fresh?, args }, 07-11): which server, which tool, its class, its arguments in short, the AI's why. Parley has no card
37
- * of this kind nor its words yet: drawn as its generic card (kind 'other'), each word with its English (`t`), which an
37
+ * of this kind (its words since 0.17.87): drawn as its generic card (kind 'other'), each word with its English (`t`), which an
38
38
  * older or unknowing screen says as it is. A tool that deletes or overwrites: irreversible (amber, never in Accept all). */
39
39
  export const MCP_CLASS_SAID = { read: 'Reads only', write: 'Writes', destructive: 'Deletes or overwrites' };
40
40
  const MCP_ARGS_MAX = 6; const MCP_ARG_MAX = 160;
package/server/grants.mjs CHANGED
@@ -19,17 +19,23 @@
19
19
  * mailbox read the messages (send, file: not built) -
20
20
  * git see the files change them -
21
21
  * secret never - be handed to a conversation
22
- * server read its state - -
22
+ * server read its state make rooms (API keys) -
23
23
  * room read its thread post in it -
24
24
  * box read its state make it say a text aloud -
25
25
  *
26
+ * And a fourth letter, for an API key only (07-11): "m", to act on or manage
27
+ * what it was given -- a conversation woken, put to sleep, its turn stopped;
28
+ * a room renamed, its members and its host changed, removed. "m" brings "rw"
29
+ * with it (it is Talk and more), and is never given to a device or a
30
+ * conversation.
31
+ *
26
32
  * Three kinds of subject: a CONVERSATION (what is attached to it reaches its
27
33
  * machine), a PAIRING (what a device was given by a code) and an API KEY
28
34
  * (what a program elsewhere may ask with it: apikeys.mjs). The owner is not
29
35
  * in the table: the owner may do everything, always.
30
36
  *
31
37
  * An API key is not a device and not a conversation: it is given only what
32
- * its scopes name -- "server:status" (read), and conversations one by one,
38
+ * its scopes name -- "server:status" (read), "server:rooms" (make rooms), and conversations one by one,
33
39
  * their chat to read ("r") or to talk in ("rw"), never their terminal ("x")
34
40
  * -- rooms one by one, their thread to read ("r") or to post in ("rw"), and
35
41
  * voice boxes one by one, their state ("r") or a short text said aloud
@@ -54,9 +60,11 @@
54
60
  */
55
61
  import { Refusal } from './conversations.mjs';
56
62
 
57
- export const RIGHTS = Object.freeze({ conversation: 'rwx', folder: 'rw', mailbox: 'r', git: 'rw', secret: 'x', server: 'r', room: 'rw', box: 'rw' });
63
+ export const RIGHTS = Object.freeze({ conversation: 'rwxm', folder: 'rw', mailbox: 'r', git: 'rw', secret: 'x', server: 'rw', room: 'rwm', box: 'rw' });
64
+ /* What each of the server's own assets takes: its state is read, rooms are made. */
65
+ const SERVER_RIGHTS = Object.freeze({ 'server:status': 'r', 'server:rooms': 'w' });
58
66
  const SUBJECT = /^(conversation:[a-z]([a-z0-9-]{0,30}[a-z0-9])?|(pairing|apikey):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
59
- const ASSET = /^(conversation|folder|mailbox|git):[a-z]([a-z0-9-]{0,30}[a-z0-9])?$|^secret:[A-Z][A-Z0-9_]{0,39}$|^server:status$|^(room|box):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
67
+ const ASSET = /^(conversation|folder|mailbox|git):[a-z]([a-z0-9-]{0,30}[a-z0-9])?$|^secret:[A-Z][A-Z0-9_]{0,39}$|^server:(status|rooms)$|^(room|box):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
60
68
 
61
69
  export const typeOf = (asset) => String(asset).split(':')[0];
62
70
  export const nameOf = (asset) => String(asset).slice(String(asset).indexOf(':') + 1);
@@ -70,12 +78,15 @@ export function readRights(asset, rights) {
70
78
  if (typeof asset !== 'string' || !ASSET.test(asset)) throw new Refusal(400, 'Not an asset: a type and a name, such as "folder:reports".');
71
79
  const allowed = RIGHTS[typeOf(asset)];
72
80
  const asked = [...new Set(String(rights ?? ''))];
73
- if (asked.length === 0 || asked.some((r) => !allowed.includes(r))) {
74
- throw new Refusal(400, `A ${typeOf(asset)} takes these rights: ${[...allowed].join(', ')}.`);
81
+ const takes = SERVER_RIGHTS[asset] ?? allowed;
82
+ if (asked.length === 0 || asked.some((r) => !takes.includes(r))) {
83
+ throw new Refusal(400, `A ${typeOf(asset)} takes these rights: ${[...takes].join(', ')}.`);
75
84
  }
85
+ /* Acting, managing is talking and more: "m" brings "w" with it. */
86
+ if (asked.includes('m') && !asked.includes('w')) asked.push('w');
76
87
  /* Writing without reading is not a thing here: "w" brings "r" with it. */
77
- if (asked.includes('w') && allowed.includes('r') && !asked.includes('r')) asked.push('r');
78
- return [...'rwx'].filter((r) => asked.includes(r)).join('');
88
+ if (asked.includes('w') && takes.includes('r') && !asked.includes('r')) asked.push('r');
89
+ return [...'rwxm'].filter((r) => asked.includes(r)).join('');
79
90
  }
80
91
 
81
92
  /* A table kept in memory only: for tests, and for a part built without one. */
@@ -105,6 +116,7 @@ export class Grants {
105
116
  if (typeOf(asset) === 'server' && !subject.startsWith('apikey:')) throw new Refusal(400, 'The state of the server is given to an API key only.');
106
117
  if (['room', 'box'].includes(typeOf(asset)) && !subject.startsWith('apikey:')) throw new Refusal(400, 'A room or a voice box is given to an API key only.');
107
118
  if (subject.startsWith('apikey:') && !['server', 'conversation', 'room', 'box'].includes(typeOf(asset))) throw new Refusal(400, 'An API key is given the state of the server, conversations, rooms and voice boxes, nothing else.');
119
+ if (clean.includes('m') && !subject.startsWith('apikey:')) throw new Refusal(400, 'Acting on a conversation, managing a room: given to an API key only.');
108
120
  if (subject.startsWith('apikey:') && clean.includes('x')) throw new Refusal(400, 'An API key reads a conversation or talks in it, never uses its terminal.');
109
121
  this.#store.put({ name: this.#key(subject, asset), subject, asset, rights: clean });
110
122
  return clean;
package/server/main.mjs CHANGED
@@ -45,7 +45,7 @@ import { Dockets } from './dockets.mjs';
45
45
  import { PULSE_MS } from 'clavarium-parley/ui';
46
46
  import { Wakeups, TypingWatch, CALL_PATH } from './wakeups.mjs';
47
47
  import { Alerts } from './alerts.mjs';
48
- import { Talk } from './talk.mjs';
48
+ import { Talk, escapeCommand } from './talk.mjs';
49
49
  import { Toolbox } from './toolbox.mjs';
50
50
  import { Guide, commonText } from './guide.mjs';
51
51
  import { SignInMailboxes } from './signin.mjs';
@@ -235,7 +235,7 @@ function serve(port, conversations, resources, { gate = () => 'local', originOk,
235
235
  /* THE DOOR FOR PROGRAMS (apikeys.mjs): an API key is the right, never the gate -- the front lets
236
236
  /api/ext/ through without sign-on, and strips the identity headers there. */
237
237
  if (apikeys && (pathname === '/api/ext' || pathname.startsWith('/api/ext/'))) {
238
- await handleExternal(request, response, { keys: apikeys.keys, status: apikeys.status, chats: apikeys.chats, rooms: apikeys.rooms, boxes: apikeys.boxes, headers: HEADERS, originOk, version: platform?.version ?? null });
238
+ await handleExternal(request, response, { keys: apikeys.keys, status: apikeys.status, chats: apikeys.chats, rooms: apikeys.rooms, boxes: apikeys.boxes, events, headers: HEADERS, originOk, version: platform?.version ?? null });
239
239
  return;
240
240
  }
241
241
  if (door && pathname.startsWith('/c/')) {
@@ -739,13 +739,31 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
739
739
  chatOn: (name) => { try { return talk.of(name, { state: '1' }).on === true; } catch { return false; } },
740
740
  read: (name, part) => talk.of(name, part),
741
741
  say: (name, text, by) => talk.say(name, text, undefined, [], undefined, { by }),
742
+ /* To act (level "act"): what the owner's buttons do. Woken as a terminal opened is (its machine started,
743
+ its terminal kept), not waited for; put to sleep as Sleep does; its turn stopped as the Esc key does. */
744
+ wake: (name) => {
745
+ const c = all.get(name);
746
+ if (c?.phase !== 'ready') throw new Refusal(409, 'This conversation is not ready.');
747
+ void bridge?.warm(name);
748
+ return { state: c.asleep === true ? 'waking' : 'awake' };
749
+ },
750
+ sleep: async (name) => { await all.sleep(name); return { state: 'asleep' }; },
751
+ stop: async (name) => {
752
+ const c = all.get(name);
753
+ if (c?.phase !== 'ready' || c.asleep === true) throw new Refusal(409, 'This conversation is asleep: nothing to stop.');
754
+ const r = await vms.exec(name, escapeCommand(), { timeoutMs: 30_000 });
755
+ if (r.code !== 0) throw new Refusal(502, 'Its turn could not be stopped.');
756
+ return { state: 'awake' };
757
+ },
742
758
  },
743
759
  /* Its rooms: read as the room's page reads them (names of the moment), never marked seen; posted as the person
744
760
  posts there (rooms.mjs say, `as` the key). */
745
761
  rooms: {
746
- list: () => rooms.list().map((r) => ({ id: r.id, name: r.name, host: r.members.find((m) => m.host)?.name ?? null, members: r.members.map((m) => ({ name: m.name, kind: m.kind })) })),
762
+ list: () => rooms.list().map((r) => ({ id: r.id, name: r.name, host: r.members.find((m) => m.host)?.name ?? null, members: r.members.map((m) => ({ id: m.id, ref: m.ref, name: m.name, kind: m.kind, ...(m.host ? { host: true } : {}) })) })),
747
763
  read: (id, part) => rooms.read(id, { ...part, ...(part.last ? { last: Number(part.last) } : {}), seen: false }),
748
764
  say: (id, text, how) => rooms.say(id, text, how),
765
+ /* To manage (level "manage", or "create rooms"): as the page asks (api.mjs /api/rooms). */
766
+ create: (asked) => rooms.create(asked), change: (id, asked) => rooms.change(id, asked), remove: (id) => rooms.remove(id),
749
767
  },
750
768
  /* Its voice boxes: their state only, and a short text said aloud (boxes.mjs announce). */
751
769
  boxes: {
@@ -75,14 +75,15 @@ export const SETTINGS_WORDS_FR = {
75
75
  'set-apikeys-name-hint': 'À quoi elle sert', 'set-apikeys-scopes': 'Serveur', 'set-apikeys-scopes-hint': 'Ce que la clé en voit',
76
76
  'set-apikeys-expires': 'Expire', 'set-apikeys-30': 'Dans 30 jours', 'set-apikeys-90': 'Dans 90 jours', 'set-apikeys-365': 'Dans un an',
77
77
  'set-apikeys-never': 'Jamais', 'set-apikeys-cancel': 'Annuler', 'set-apikeys-create': 'Créer', 'set-apikeys-new': 'Nouvelle clé',
78
- 'set-apikeys-never-by-key': 'Jamais : secrets, clés, comptes, réglages', 'set-apikeys-scope-status': 'Son état (mémoire, disque, versions)',
78
+ 'set-apikeys-never-by-key': 'Jamais : secrets, clés, comptes, droits', 'set-apikeys-scope-status': 'Son état (mémoire, disque, versions)',
79
+ 'set-apikeys-scope-create-rooms': 'Créer des salons', 'set-apikeys-act': 'Agir', 'set-apikeys-manage': 'Gérer',
79
80
  'set-apikeys-no-scope': 'Aucune portée', 'set-apikeys-by-you': 'par vous', 'set-apikeys-used': 'Utilisée {when}', 'set-apikeys-unused': 'Jamais utilisée',
80
81
  'set-apikeys-expired': 'Expirée', 'set-apikeys-expires-on': 'Expire le {date}', 'set-apikeys-no-expiry': 'N\'expire pas',
81
82
  'set-apikeys-created': 'Créée le {date}', 'set-apikeys-revoke': 'Révoquer', 'set-apikeys-revoke-q': 'Révoquer maintenant ?',
82
- 'set-apikeys-convs': 'Conversations', 'set-apikeys-convs-hint': 'Une par une : lire ou parler', 'set-apikeys-off': 'Non',
83
+ 'set-apikeys-convs': 'Conversations', 'set-apikeys-convs-hint': 'Une par une : lire, parler ou agir', 'set-apikeys-off': 'Non',
83
84
  'set-apikeys-read': 'Lire', 'set-apikeys-talk': 'Parler', 'set-apikeys-no-convs': 'Aucune conversation pour l\'instant',
84
85
  'set-apikeys-journal': 'Journal', 'set-apikeys-no-use': 'Aucun appel pour l\'instant',
85
- 'set-apikeys-rooms': 'Salons', 'set-apikeys-rooms-hint': 'Un par un : lire ou parler', 'set-apikeys-no-rooms': 'Aucun salon pour l\'instant',
86
+ 'set-apikeys-rooms': 'Salons', 'set-apikeys-rooms-hint': 'Un par un : lire, parler ou gérer', 'set-apikeys-no-rooms': 'Aucun salon pour l\'instant',
86
87
  'set-apikeys-boxes': 'Boîtiers vocaux', 'set-apikeys-boxes-hint': 'Un par un : état, ou parler à voix haute', 'set-apikeys-no-boxes': 'Aucun boîtier pour l\'instant',
87
88
  'set-apikeys-state': 'État', 'set-apikeys-speak': 'Parler à voix haute',
88
89
  /* The rights of an asset (06-11, kit.js RIGHTS): what it has (a pill), a choice, its one line. */
package/server/talk.mjs CHANGED
@@ -111,6 +111,8 @@ export const HEALTH_KNOWN_MS = 150_000;
111
111
  end of a command: such a last ";" is written "\;", which tmux gives back
112
112
  as ";" (tried). */
113
113
  export const enterCommand = () => ['tmux', '-L', SOCKET, 'send-keys', '-t', PANE, 'Enter'];
114
+ /* Escape, alone: what the Esc key of the page's key row sends -- a tool's current turn stopped (an API key that may act, apikeys.mjs). */
115
+ export const escapeCommand = () => ['tmux', '-L', SOCKET, 'send-keys', '-t', PANE, 'Escape'];
114
116
  export function typeCommand(line) {
115
117
  const text = line.endsWith(';') ? `${line.slice(0, -1)}\\;` : line;
116
118
  return ['tmux', '-L', SOCKET, 'send-keys', '-t', PANE, '-l', '--', text, ';', 'run-shell', '-d', String(ENTER_AFTER_MS / 1000), ';', 'send-keys', '-t', PANE, 'Enter'];
package/web/app.js CHANGED
@@ -6612,13 +6612,15 @@
6612
6612
  scope until one is ticked; its secret shown this once, then never; revoked at once, confirmed in place.
6613
6613
  Administrators only: hidden on a device, and where the server does not answer it. */
6614
6614
  const shortDate = (t) => new Date(t).toLocaleDateString([], { day: 'numeric', month: 'short', year: 'numeric' });
6615
- /* Its conversations, one by one (step 2): each Off / Read / Talk -- never "all". `chosen`: name -> level. */
6616
- const apiKeyLevelWord = (level) => (level === 'talk' ? uiSaid('set-apikeys-talk', 'Talk') : uiSaid('set-apikeys-read', 'Read'));
6615
+ /* Its conversations, one by one (step 2): each Off / Read / Talk / Act -- never "all". `chosen`: name -> level.
6616
+ Act (07-11): Talk, and wake it, put it to sleep, stop its turn. A room's Manage: Talk, and its name, members, host. */
6617
+ const apiKeyLevelWord = (level) => (level === 'act' ? uiSaid('set-apikeys-act', 'Act') : level === 'manage' ? uiSaid('set-apikeys-manage', 'Manage')
6618
+ : level === 'talk' ? uiSaid('set-apikeys-talk', 'Talk') : uiSaid('set-apikeys-read', 'Read'));
6617
6619
  function apiKeyConvPicker(chosen, onChange) {
6618
6620
  const box = el('div', 'apikey-convs');
6619
6621
  const list = conversations.filter((c) => c && c.name);
6620
6622
  if (!list.length) box.append(el('small', 'hint', uiSaid('set-apikeys-no-convs', 'No conversation yet')));
6621
- const options = [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'read', label: apiKeyLevelWord('read') }, { value: 'talk', label: apiKeyLevelWord('talk') }];
6623
+ const options = [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'read', label: apiKeyLevelWord('read') }, { value: 'talk', label: apiKeyLevelWord('talk') }, { value: 'act', label: apiKeyLevelWord('act') }];
6622
6624
  for (const c of list) {
6623
6625
  const row = el('div', 'apikey-conv'); row.dataset.name = c.name;
6624
6626
  row.append(el('span', '', shownName(c)), KIT.Segmented({ options, value: chosen.get(c.name) ?? '', label: shownName(c), onChange: (v) => onChange(c.name, v || null) }));
@@ -6635,7 +6637,7 @@
6635
6637
  const list = apiKeyItems(kind);
6636
6638
  if (!list.length) box.append(el('small', 'hint', kind === 'rooms' ? uiSaid('set-apikeys-no-rooms', 'No room yet') : uiSaid('set-apikeys-no-boxes', 'No voice box yet')));
6637
6639
  const options = kind === 'rooms'
6638
- ? [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'read', label: apiKeyLevelWord('read') }, { value: 'talk', label: apiKeyLevelWord('talk') }]
6640
+ ? [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'read', label: apiKeyLevelWord('read') }, { value: 'talk', label: apiKeyLevelWord('talk') }, { value: 'manage', label: apiKeyLevelWord('manage') }]
6639
6641
  : [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'state', label: apiKeyBoxWord('state') }, { value: 'speak', label: apiKeyBoxWord('speak') }];
6640
6642
  for (const x of list) {
6641
6643
  const row = el('div', kind === 'rooms' ? 'apikey-conv apikey-room' : 'apikey-conv apikey-box'); row.dataset.id = x.id;
@@ -6687,8 +6689,8 @@
6687
6689
  what.append(head);
6688
6690
  const tags = el('span', 'apikey-scopes');
6689
6691
  const given = [...k.scopes.map((sc) => ({ text: scopeWord(sc) })),
6690
- ...(k.conversations ?? []).map((c) => ({ text: `${shownName(conversations.find((x) => x.name === c.name) ?? { name: c.name })} · ${apiKeyLevelWord(c.level)}`, tone: c.level === 'talk' ? 'note' : undefined })),
6691
- ...(k.rooms ?? []).map((r) => ({ text: `${roomsNow().find((x) => x.id === r.id)?.name ?? r.id.slice(0, 8)} · ${apiKeyLevelWord(r.level)}`, icon: 'room', tone: r.level === 'talk' ? 'note' : undefined })),
6692
+ ...(k.conversations ?? []).map((c) => ({ text: `${shownName(conversations.find((x) => x.name === c.name) ?? { name: c.name })} · ${apiKeyLevelWord(c.level)}`, tone: c.level === 'read' ? undefined : 'note' })),
6693
+ ...(k.rooms ?? []).map((r) => ({ text: `${roomsNow().find((x) => x.id === r.id)?.name ?? r.id.slice(0, 8)} · ${apiKeyLevelWord(r.level)}`, icon: 'room', tone: r.level === 'read' ? undefined : 'note' })),
6692
6694
  ...(k.boxes ?? []).map((b) => ({ text: `${boxesNow().find((x) => x.id === b.id)?.name ?? b.id.slice(0, 8)} · ${apiKeyBoxWord(b.level)}`, icon: 'box', tone: b.level === 'speak' ? 'note' : undefined }))];
6693
6695
  tags.append(KIT.Chips(given.length ? given : [{ text: uiSaid('set-apikeys-no-scope', 'No scope') }]));
6694
6696
  what.append(tags);
package/web/index.html CHANGED
@@ -1070,14 +1070,14 @@
1070
1070
  <form id="apikeys-form" class="set-rows" autocomplete="off" hidden>
1071
1071
  <div class="set-row"><div class="set-what"><label for="apikeys-label" data-said="set-apikeys-name">Name</label><small data-said="set-apikeys-name-hint">What it is for</small></div><div class="set-control"><input id="apikeys-label" maxlength="40" spellcheck="false" required></div></div>
1072
1072
  <div class="set-row"><div class="set-what"><span data-said="set-apikeys-scopes">Server</span><small data-said="set-apikeys-scopes-hint">What the key sees of it</small></div><div class="set-control" id="apikeys-scopes"></div></div>
1073
- <div class="set-row"><div class="set-what"><span data-said="set-apikeys-convs">Conversations</span><small data-said="set-apikeys-convs-hint">One by one: read or talk</small></div><div class="set-control" id="apikeys-convs"></div></div>
1074
- <div class="set-row"><div class="set-what"><span data-said="set-apikeys-rooms">Rooms</span><small data-said="set-apikeys-rooms-hint">One by one: read or talk</small></div><div class="set-control" id="apikeys-rooms"></div></div>
1073
+ <div class="set-row"><div class="set-what"><span data-said="set-apikeys-convs">Conversations</span><small data-said="set-apikeys-convs-hint">One by one: read, talk or act</small></div><div class="set-control" id="apikeys-convs"></div></div>
1074
+ <div class="set-row"><div class="set-what"><span data-said="set-apikeys-rooms">Rooms</span><small data-said="set-apikeys-rooms-hint">One by one: read, talk or manage</small></div><div class="set-control" id="apikeys-rooms"></div></div>
1075
1075
  <div class="set-row"><div class="set-what"><span data-said="set-apikeys-boxes">Voice boxes</span><small data-said="set-apikeys-boxes-hint">One by one: state, or speak aloud</small></div><div class="set-control" id="apikeys-boxes"></div></div>
1076
1076
  <div class="set-row"><div class="set-what"><label for="apikeys-days" data-said="set-apikeys-expires">Expires</label></div><div class="set-control"><select id="apikeys-days"><option value="30" data-said="set-apikeys-30">In 30 days</option><option value="90" data-said="set-apikeys-90" selected>In 90 days</option><option value="365" data-said="set-apikeys-365">In a year</option><option value="never" data-said="set-apikeys-never">Never</option></select></div></div>
1077
1077
  <div class="action-row"><span class="error" id="apikeys-error" role="alert" hidden></span><button type="button" id="apikeys-cancel" data-said="set-apikeys-cancel">Cancel</button><button type="submit" class="primary" data-said="set-apikeys-create">Create</button></div>
1078
1078
  </form>
1079
1079
  <div class="action-row" id="apikeys-new-row"><span class="hint" id="apikeys-where"></span><button type="button" id="apikeys-new" class="primary" data-said="set-apikeys-new">New key</button></div>
1080
- <p class="hint" data-said="set-apikeys-never-by-key">Never by key: secrets, keys, accounts, settings</p>
1080
+ <p class="hint" data-said="set-apikeys-never-by-key">Never by key: secrets, keys, accounts, rights</p>
1081
1081
  </div>
1082
1082
  </details>
1083
1083
  <!-- NETWORK: this server's Wi-Fi (server/wifi.mjs; done as root by the host program,