dsh-a2a-groups 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,1164 @@
1
+ /**
2
+ * The group skills: create, invite, join, post, read, subscribe, members, leave.
3
+ *
4
+ * A group is a DSH instance that registered itself as an A2A agent, so these
5
+ * skills are the whole protocol between a group host and its members. Three
6
+ * decisions deserve their reasons written down, because each looks odd without
7
+ * one:
8
+ *
9
+ * **Every member call carries `personId` + `personKey` in the payload, not in
10
+ * a header.** A2A's auth story is the transport's (the gateway's API key), and
11
+ * that credential says "this caller may reach this agent" — not "this caller
12
+ * is a member of this group". Keeping the membership proof in the payload
13
+ * means the gateway cannot see it (it only logs an opaque JSON body) and the
14
+ * group host does not need a second authentication scheme stacked on A2A.
15
+ *
16
+ * **`group/subscribe` is a stream, everything else is unary.** A member has to
17
+ * learn about a post without polling, and A2A already has exactly one
18
+ * mechanism for "keep telling me as things happen". Using it means a member's
19
+ * plugin holds one long-lived SSE connection and never has to invent its own
20
+ * delta format.
21
+ *
22
+ * **`group/invite` takes the owner key as well as the role check.** The role
23
+ * on a member view is a display field, not an authorization fact; passing the
24
+ * credential through to the store gives the store a cryptographic second line
25
+ * behind the caller-side check.
26
+ *
27
+ * @module dsh-a2a/skills-group
28
+ */
29
+
30
+ import { randomFillSync } from 'node:crypto';
31
+
32
+ // A skill call arrives over A2A and therefore carries no Session: the caller is a
33
+ // remote agent, not a conversation. A pure group host is exactly that case, and it
34
+ // still keeps a membership record (the owner credential lives there). See
35
+ // `HOST_SESSION_ID` for why that record cannot simply be keyed on nothing.
36
+ // Used by `group/create` only: when the caller creates a group it IS the host, so the host's
37
+ // own config is the right source for its address. `group/join` must NOT use it — see the note
38
+ // there.
39
+ import { ownEndpointFromConfig } from './endpoint.js';
40
+ import { HOST_SESSION_ID } from './memberships.js';
41
+ // The three message kinds the MVP sends. A fourth would mean the scope changed.
42
+ import { DM_KIND_INVITE, DM_KIND_JOIN_NOTICE, DM_KIND_WELCOME } from './groups.js';
43
+
44
+ /**
45
+ * How often an open subscription refreshes its member's presence.
46
+ *
47
+ * Must be comfortably below `ONLINE_WINDOW_MS` (120s) or a live connection would flicker offline
48
+ * between ticks. Half the window leaves room for one missed tick without the indicator lying.
49
+ */
50
+ const PRESENCE_TICK_MS = 50_000;
51
+
52
+ /**
53
+ * The sender id used for a message no person authored.
54
+ *
55
+ * A welcome cannot be signed by the person being welcomed — the store refuses a message from yourself, and
56
+ * a self-signed greeting reads oddly anyway. When a group has no owner to speak for it, this stands in. It
57
+ * is deliberately not a person id: pretending a session identifier is a person is how ids stop meaning one
58
+ * thing.
59
+ */
60
+ const SYSTEM_PERSON_ID = 'system';
61
+
62
+ /** How many feed entries a join hands back so a new member has context. */
63
+ const JOIN_FEED_TAIL = 20;
64
+
65
+ /** How many entries one `group/read` returns when the caller names no limit. */
66
+ const DEFAULT_READ_LIMIT = 50;
67
+
68
+ /** Largest `group/read` page, so one call cannot return an unbounded feed. */
69
+ const MAX_READ_LIMIT = 200;
70
+
71
+ /**
72
+ * Build every group skill over one store.
73
+ *
74
+ * @param {object} deps - `{ store, identities, config, logger, clock }`.
75
+ * `store` is `createGroupStore(...)`; `identities` is the membership ledger,
76
+ * which is how a local member proves itself without a network round trip.
77
+ * @returns {object[]} the skill definitions, in card order.
78
+ */
79
+ export function createGroupSkills({ store, identities, config, logger, clock = () => Date.now() }) {
80
+ const settings = config.groups;
81
+
82
+ /**
83
+ * Tell the owner somebody joined, and tell the newcomer what they joined.
84
+ *
85
+ * Both messages are best-effort: the membership is already stored, and a courtesy that fails must not
86
+ * turn a successful join into an error the caller has to interpret.
87
+ *
88
+ * @param {object} group - the group snapshot, after the join.
89
+ * @param {object} joiner - `{ personId, displayName }`.
90
+ * @returns {{notifiedOwner: boolean, welcomed: boolean}} what was actually sent.
91
+ */
92
+ function announceJoin(group, joiner) {
93
+ const result = { notifiedOwner: false, welcomed: false };
94
+ const ownerId = group?.owner?.personId;
95
+ try {
96
+ if (typeof ownerId === 'string' && ownerId !== '' && ownerId !== joiner.personId) {
97
+ store.sendPersonMessage({
98
+ fromPersonId: joiner.personId,
99
+ toPersonId: ownerId,
100
+ kind: DM_KIND_JOIN_NOTICE,
101
+ // The name and the group, nothing else. A notice that quotes the invitation would put the token
102
+ // in the owner's own inbox, which is where a forwarded screenshot comes from.
103
+ text: `${joiner.displayName} 加入了「${group.name}」`,
104
+ payload: { groupId: group.groupId, groupName: group.name, personId: joiner.personId },
105
+ });
106
+ result.notifiedOwner = true;
107
+ }
108
+ } catch (error) {
109
+ // Best effort, and deliberately silent: the log already records the join.
110
+ void error;
111
+ }
112
+ try {
113
+ store.sendPersonMessage({
114
+ // Sent by the owner when there is one, never by the joiner themselves: a message from yourself
115
+ // is refused by the store, and a "welcome" signed by the person being welcomed reads oddly anyway.
116
+ // The host is the neutral sender when no owner exists.
117
+ // The owner when there is one; otherwise a reserved id that is not a person. Using
118
+ // HOST_SESSION_ID here would put a session identifier where a person id belongs.
119
+ fromPersonId: typeof ownerId === 'string' && ownerId !== '' ? ownerId : SYSTEM_PERSON_ID,
120
+ toPersonId: joiner.personId,
121
+ kind: DM_KIND_WELCOME,
122
+ text: `欢迎加入「${group.name}」`,
123
+ payload: {
124
+ groupId: group.groupId,
125
+ groupName: group.name,
126
+ topic: group.topic ?? '',
127
+ memberCount: group.memberCount ?? 0,
128
+ },
129
+ });
130
+ result.welcomed = true;
131
+ } catch (error) {
132
+ void error;
133
+ }
134
+ return result;
135
+ }
136
+
137
+ /**
138
+ * Turn a store refusal into the A2A error shape the server expects.
139
+ *
140
+ * The store speaks in `GroupError` with a stable `code`; re-throwing keeps the
141
+ * code so the server can put it in `error.data.code` and a caller can branch on
142
+ * "wrong password" versus "expired invite" without parsing Chinese prose.
143
+ */
144
+ const refuse = (error) => {
145
+ const wrapped = new Error(describe(error));
146
+ wrapped.code = typeof error?.code === 'string' ? error.code : 'group_error';
147
+ throw wrapped;
148
+ };
149
+
150
+ /**
151
+ * The structured payload of one call.
152
+ *
153
+ * The server hands a skill `{text, message, data, metadata, skill, messageId}`,
154
+ * where `data` is the caller's `params.message.metadata.data` already unwrapped.
155
+ * Skills read their fields from here rather than from the top level, so the wire
156
+ * envelope has exactly one shape: a caller never has to guess whether `groupId`
157
+ * belongs beside `skill` or under `data`.
158
+ */
159
+ const payload = (input) => (isPlainObject(input?.data) ? input.data : {});
160
+
161
+ /**
162
+ * Every member call starts by proving membership.
163
+ *
164
+ * The proof is accepted in the payload form the store understands, so the
165
+ * store's own constant-time comparison is what decides — this function only
166
+ * checks that the fields are present.
167
+ */
168
+ const requireMember = (input) => {
169
+ const data = payload(input);
170
+ const groupId = required(data.groupId, 'groupId');
171
+ const personId = required(data.personId, 'personId');
172
+ const personKey = required(data.personKey, 'personKey');
173
+ return { groupId, member: { personId, personKey } };
174
+ };
175
+
176
+ return [
177
+ {
178
+ id: 'person/send',
179
+ name: "Send a person a one-to-one message",
180
+ description:
181
+ 'Send ONE message to ONE person: a group invitation, a join notice, or a welcome. This is a doorbell, not a chat — sustained private conversation belongs in a group, and there is deliberately no message kind for it. For kind "group-invite" a fresh single-use invite is minted at send time, because a token cannot be read back after issue. The reply says whether they are online and gives the message id; it does NOT claim delivery, which only their ack confirms.',
182
+ examples: [
183
+ 'Invite person m-1234 to this group',
184
+ 'Tell the owner that someone joined',
185
+ ],
186
+ async run(input) {
187
+ const data = payload(input);
188
+ const fromPersonId = required(data.fromPersonId, 'fromPersonId');
189
+ const toPersonId = required(data.toPersonId, 'toPersonId');
190
+ const kind = required(data.kind, 'kind');
191
+ try {
192
+ // A group invitation is built here rather than handed in, so the sender cannot share a
193
+ // link that was already issued to somebody else. Each invitation is one token, one use.
194
+ let text = typeof data.text === 'string' ? data.text : '';
195
+ let extra;
196
+ if (kind === DM_KIND_INVITE) {
197
+ const groupId = required(data.groupId, 'groupId');
198
+ const caller = requireMember(input);
199
+ const group = store.snapshot({ groupId, member: caller.member });
200
+ const issued = store.invite({
201
+ groupId,
202
+ // The caller's own key. Minting is an act of whoever can also revoke, so it has to
203
+ // be the owner's credential — and the store verifies that, which is why a non-owner
204
+ // gets "只有群主可以生成邀请" rather than a link they cannot attribute.
205
+ ownerKey: data.personKey,
206
+ // One person, one redemption: a link that can be used twice cannot be traced to
207
+ // whoever leaked it.
208
+ maxUses: 1,
209
+ expiresInHours: data.expiresInHours,
210
+ label: `invite ${toPersonId}`,
211
+ });
212
+ const link = issued.link;
213
+ text = `${group.name}`;
214
+ extra = {
215
+ groupId,
216
+ groupName: group.name,
217
+ topic: group.topic ?? '',
218
+ inviteLink: link,
219
+ members: group.memberCount,
220
+ };
221
+ }
222
+ const message = store.sendPersonMessage({
223
+ fromPersonId,
224
+ toPersonId,
225
+ kind,
226
+ text,
227
+ payload: extra,
228
+ });
229
+ const online = store.isPersonOnline(toPersonId);
230
+ return {
231
+ text: online
232
+ ? `已发送(对方在线,等待确认)。\ndmId: ${message.dmId}`
233
+ : `已存入对方的收件箱:对方当前不在线,上线后会收到。\ndmId: ${message.dmId}`,
234
+ meta: { dmId: message.dmId, online, kind, createdAt: message.createdAt },
235
+ };
236
+ } catch (error) {
237
+ refuse(error);
238
+ }
239
+ },
240
+ },
241
+ {
242
+ id: 'person/profile',
243
+ name: 'Set my person-level profile',
244
+ description:
245
+ 'What I do, independent of any group: the line that appears on every colleague card. Different from group/role, which describes me in ONE group. Requires a member credential from any group I am in — that is how the host knows which person row to write, not because the value belongs to that group.',
246
+ examples: ['My headline is 后端工程师,专注对账系统'],
247
+ async run(input) {
248
+ const data = payload(input);
249
+ const { groupId, member } = requireMember(input);
250
+ try {
251
+ const updated = store.setPersonProfile({
252
+ groupId,
253
+ member,
254
+ // `undefined` leaves it alone; an empty string clears it. The distinction matters: a member
255
+ // clearing their headline must not have it silently restored by the next role save.
256
+ headline: data.headline === undefined ? undefined : data.headline,
257
+ tags: data.tags === undefined ? undefined : data.tags,
258
+ });
259
+ return {
260
+ text: updated.headline === ''
261
+ ? '个人介绍已清空。'
262
+ : `个人介绍已更新:${updated.headline}`,
263
+ meta: {
264
+ personId: updated.personId,
265
+ headline: updated.headline,
266
+ tags: updated.tags ?? [],
267
+ },
268
+ };
269
+ } catch (error) {
270
+ refuse(error);
271
+ }
272
+ },
273
+ },
274
+ {
275
+ id: 'person/inbox',
276
+ name: "Read one person's waiting messages",
277
+ description:
278
+ 'Everything waiting for one person, oldest first. Used by a member instance when it starts up or reconnects, so a message sent while it was offline is not lost.',
279
+ examples: ['What is waiting for person m-1234?'],
280
+ async run(input) {
281
+ const data = payload(input);
282
+ const personId = required(data.personId, 'personId');
283
+ try {
284
+ const messages = store.listPersonMessages({
285
+ personId,
286
+ since: Number.isFinite(data.since) ? data.since : 0,
287
+ });
288
+ return {
289
+ text: messages.length === 0
290
+ ? '没有待收消息。'
291
+ : messages.map((entry) => `- [${entry.kind}] ${entry.text}`).join('\n'),
292
+ meta: { personId, messages },
293
+ };
294
+ } catch (error) {
295
+ refuse(error);
296
+ }
297
+ },
298
+ },
299
+ {
300
+ id: 'person/ack',
301
+ name: 'Confirm receipt of one-to-one messages',
302
+ description:
303
+ 'Called by the RECEIVER once a message has actually arrived in a Session. This is what turns a send into a delivery: an HTTP call returning proves nothing, and reporting success for a message nobody received is how a messenger loses trust.',
304
+ examples: ['Acknowledge dm-1 and dm-2'],
305
+ async run(input) {
306
+ const data = payload(input);
307
+ const personId = required(data.personId, 'personId');
308
+ try {
309
+ const result = store.ackPersonMessages({ personId, dmIds: data.dmIds });
310
+ return {
311
+ text: result.acked === 0
312
+ ? '没有新的确认(可能已经确认过了)。'
313
+ : `已确认 ${result.acked} 条。`
314
+ + (result.unknown.length > 0 ? `\n${result.unknown.length} 条不属于你,已忽略。` : ''),
315
+ meta: result,
316
+ };
317
+ } catch (error) {
318
+ refuse(error);
319
+ }
320
+ },
321
+ },
322
+ {
323
+ id: 'group/create',
324
+ name: 'Create a group',
325
+ description:
326
+ 'Create a collaboration group on this instance. Returns the group id and a one-time owner key: the caller must keep that key, because it is what authenticates the owner to the group from now on.',
327
+ examples: ['Create a group called "invoice refactor" about splitting the invoice service'],
328
+ async run(input, context) {
329
+ const name = required(payload(input).name, 'name');
330
+ const data = payload(input);
331
+ try {
332
+ // No password: admission is an invite token or an approved request, both
333
+ // of which the owner can revoke per person. See `groups.js#create`.
334
+ const created = store.create({
335
+ name,
336
+ topic: typeof data.topic === 'string' ? data.topic : '',
337
+ ownerDisplayName: typeof data.ownerDisplayName === 'string' && data.ownerDisplayName !== ''
338
+ ? data.ownerDisplayName
339
+ : 'owner',
340
+ // `visibility` decides whether the group appears in the channel
341
+ // directory. Passing it through is what makes `settings` meaningful
342
+ // from a caller: without it, `unlisted` could only be set by editing
343
+ // groups.json by hand.
344
+ settings: {
345
+ visibility: data.visibility === 'unlisted' ? 'unlisted' : 'public',
346
+ },
347
+ // The owner is a member too, and joins by creating. Without an address here the
348
+ // roster shows an empty endpoint for the one person guaranteed to be in every group
349
+ // they own, which reads as "the feature is broken" rather than "not configured".
350
+ endpoint: ownEndpointFromConfig(config),
351
+ });
352
+ const groupId = created.group.groupId;
353
+ const ownerPersonId = created.group.owner.personId;
354
+ identities.upsert({
355
+ sessionId: context?.sessionId ?? HOST_SESSION_ID,
356
+ groupId,
357
+ personId: ownerPersonId,
358
+ personKey: created.ownerKey,
359
+ local: true,
360
+ groupName: created.group.name,
361
+ role: 'owner',
362
+ lastSeq: created.group.lastSeq ?? 0,
363
+ });
364
+ logger?.info?.(`a2a: group "${name}" created as ${groupId} (owner key issued once)`);
365
+ return {
366
+ text: `Group "${name}" created.\n\n`
367
+ + `groupId : ${groupId}\n`
368
+ + `personId: ${ownerPersonId}\n`
369
+ + `ownerKey: ${created.ownerKey} ← shown once; this instance has stored it, and you need it nowhere else\n\n`
370
+ + 'Next: a2a_group_invite to mint an invite link and a paste-ready message for a colleague.',
371
+ meta: {
372
+ groupId,
373
+ name: created.group.name,
374
+ personId: ownerPersonId,
375
+ ownerKey: created.ownerKey,
376
+ },
377
+ };
378
+ } catch (error) {
379
+ refuse(error);
380
+ }
381
+ },
382
+ },
383
+ {
384
+ id: 'group/invite',
385
+ name: 'Invite someone to a group',
386
+ description:
387
+ 'Mint an invite: a link plus a paste-ready message for an IM. The link carries a token that the owner can revoke at any time; it has no use counter, so it works for everyone you send it to until it expires or is revoked. Only the group owner may mint one.',
388
+ examples: ['Make an invite for the invoice group, labelled for 张工, valid 7 days', 'Make a permanent invite for the announcements group'],
389
+ async run(input) {
390
+ const data = payload(input);
391
+ const { groupId, member } = requireMember(input);
392
+ try {
393
+ const issued = store.invite({
394
+ groupId,
395
+ label: typeof data.label === 'string' ? data.label : undefined,
396
+ // `undefined` = configured default; `null` = never expires. The store
397
+ // tells them apart, so passing the field through unchanged is correct.
398
+ expiresInHours: data.expiresInHours === undefined ? undefined : data.expiresInHours,
399
+ // Only the owner may mint, and the store enforces it against the owner
400
+ // credential itself — the caller-side role check is a courtesy.
401
+ ownerKey: member.personKey,
402
+ gatewayBase: settings.gatewayBase === '' ? undefined : settings.gatewayBase,
403
+ agent: settings.agentName === '' ? undefined : settings.agentName,
404
+ });
405
+ return {
406
+ text: issued.prompt,
407
+ meta: {
408
+ groupId,
409
+ token: issued.token,
410
+ link: issued.link,
411
+ prompt: issued.prompt,
412
+ expiresAt: issued.expiresAt === null ? '' : new Date(issued.expiresAt).toISOString(),
413
+ neverExpires: issued.expiresAt === null,
414
+ },
415
+ };
416
+ } catch (error) {
417
+ refuse(error);
418
+ }
419
+ },
420
+ },
421
+ {
422
+ id: 'group/join',
423
+ name: 'Join a group',
424
+ description:
425
+ 'Join a group with an invite link token and a display name. Returns a member key, shown once: it authenticates every later call. No password is involved — the token is the credential.',
426
+ examples: ['Join group g-7f3a1c with token inv_xxx'],
427
+ async run(input, context) {
428
+ const data = payload(input);
429
+ const groupId = required(data.groupId, 'groupId');
430
+ try {
431
+ // ONLY what the caller supplies. This skill runs inside the HOST process, so
432
+ // `config.groups.agentName` here names the host — never the person joining. Composing
433
+ // from it recorded every member at the host's own address, which is not a best guess but
434
+ // a guaranteed-wrong one: two people, one address, and it pointed at the wrong process
435
+ // even for the owner.
436
+ //
437
+ // The address belongs to the member's own configuration on the member's own machine. A
438
+ // member that wants to be reachable passes it; one that does not stays empty, and empty
439
+ // means "no address known" rather than "here is a plausible lie".
440
+ const endpoint = typeof data.endpoint === 'string' && data.endpoint !== ''
441
+ ? data.endpoint
442
+ : undefined;
443
+ const joined = store.join({
444
+ groupId,
445
+ token: typeof data.token === 'string' && data.token !== '' ? data.token : undefined,
446
+ displayName: typeof data.displayName === 'string' && data.displayName !== '' ? data.displayName : 'member',
447
+ endpoint,
448
+ });
449
+ identities.upsert({
450
+ sessionId: context?.sessionId ?? HOST_SESSION_ID,
451
+ groupId,
452
+ personId: joined.member.personId,
453
+ personKey: joined.personKey,
454
+ local: true,
455
+ groupName: joined.group.name,
456
+ role: joined.member.role,
457
+ lastSeq: joined.group.lastSeq ?? 0,
458
+ });
459
+
460
+ // The join is stored; now tell both sides. Best-effort — see announceJoin.
461
+ const announced = announceJoin(store.snapshot({ groupId, member: { personId: joined.member.personId, personKey: joined.personKey } }), {
462
+ personId: joined.member.personId,
463
+ displayName: joined.member.displayName,
464
+ });
465
+ return {
466
+ text: `Joined "${joined.group.name}".\n\n`
467
+ + `groupId : ${groupId}\n`
468
+ + `personId : ${joined.member.personId}\n`
469
+ + `personKey: ${joined.personKey} ← shown once; this instance has stored it\n\n`
470
+ + `${joined.feedTail.length} recent message(s) are included in the result.`
471
+ // Saying this once at join time is the only moment it is actionable: the fix is a
472
+ // config change, and silence would leave the card's address blank forever with no
473
+ // hint why.
474
+ + (endpoint === undefined
475
+ ? '\n\n(No A2A address recorded for this instance: set groups.gatewayBase and '
476
+ + 'groups.agentName so colleagues can reach you directly. Groups still work '
477
+ + 'through the invite link.)'
478
+ : `\n\nThis instance's address: ${endpoint}`),
479
+ meta: {
480
+ groupId,
481
+ personId: joined.member.personId,
482
+ personKey: joined.personKey,
483
+ groupName: joined.group.name,
484
+ topic: joined.group.topic ?? '',
485
+ members: joined.group.memberCount,
486
+ lastSeq: joined.group.lastSeq ?? 0,
487
+ feedTail: joined.feedTail,
488
+ // Surfaced so the caller can tell "you are reachable" from "you are not", without
489
+ // having to read the group back. Empty means the host is not configured with a
490
+ // gateway base and agent name; it does NOT mean the join failed.
491
+ endpoint: endpoint ?? '',
492
+ // Who was told. Silence here would make "did the owner get notified?" unanswerable.
493
+ notifiedOwner: announced.notifiedOwner,
494
+ welcomed: announced.welcomed,
495
+ },
496
+ };
497
+ } catch (error) {
498
+ refuse(error);
499
+ }
500
+ },
501
+ },
502
+ {
503
+ id: 'group/post',
504
+ name: 'Post to a group',
505
+ description:
506
+ 'Post a message to a group. Pass `mentions` to name who should ACT on it: those '
507
+ + 'members are woken, everyone else only sees an unread count. Use ["@all"] for a '
508
+ + 'notice concerning the whole group; omit it for ordinary progress a member can '
509
+ + 'read when they are already working. Returns the sequence number it landed on.',
510
+ examples: ['Tell the group the migration is done and the branch is merged'],
511
+ async run(input) {
512
+ const data = payload(input);
513
+ const { groupId, member } = requireMember(input);
514
+ // Two spellings, one meaning: the A2A message's own text part (what a
515
+ // human-facing caller sends) and `data.text` (what a programmatic caller
516
+ // sends when it has no prose to put in a part). Taking either keeps a
517
+ // simple caller from having to know which one this skill reads.
518
+ // `data.text` wins over the message's own text part.
519
+ //
520
+ // Reasoning, because the opposite looks equally plausible: the A2A
521
+ // envelope always has a `parts` array, and a caller that carries its real
522
+ // payload in `metadata.data` still has to put SOMETHING in that part —
523
+ // so what arrives as `input.text` is often a label like "group/post",
524
+ // not the message. The structured field is the one the caller chose
525
+ // deliberately, so it is the truthful source. `input.text` remains the
526
+ // fallback for a human-facing caller that typed prose with no metadata.
527
+ const fromPayload = typeof data.text === 'string' ? data.text : '';
528
+ const text = fromPayload !== ''
529
+ ? fromPayload
530
+ : (typeof input.text === 'string' ? input.text : '');
531
+ if (text.trim() === '' && data.payload === undefined) {
532
+ throw coded('empty_message', 'group/post needs a non-empty text');
533
+ }
534
+ try {
535
+ // `mentions` decides who is WOKEN, not merely who is named in the prose.
536
+ // Without it every post interrupts every member, which is how a group turns
537
+ // into noise and then gets muted.
538
+ const posted = store.post({
539
+ groupId,
540
+ member,
541
+ text,
542
+ payload: data.payload,
543
+ mentions: data.mentions,
544
+ });
545
+ const named = Array.isArray(posted.mentions) && posted.mentions.length > 0;
546
+ return {
547
+ text: `Posted to the group at seq ${posted.seq}.`
548
+ + (named
549
+ ? (posted.mentions.includes('@all')
550
+ ? '\nAddressed to everyone: every member is woken.'
551
+ : `\nAddressed to ${posted.mentions.length} member(s): they are woken, everyone else only sees an unread count.`)
552
+ : '\nNot addressed to anyone in particular: members see it in their unread count and read it when they are already working.'),
553
+ meta: {
554
+ seq: posted.seq,
555
+ msgId: posted.msgId,
556
+ at: posted.atIso,
557
+ mentions: posted.mentions ?? [],
558
+ },
559
+ };
560
+ } catch (error) {
561
+ refuse(error);
562
+ }
563
+ },
564
+ },
565
+ {
566
+ id: 'group/read',
567
+ name: 'Read a group feed',
568
+ description:
569
+ 'Read group messages newer than a sequence number. Use it after a reconnect (pass the last seq you saw) or when a stream is unavailable. Read-only — it never wakes a Session.',
570
+ examples: ['Read everything after seq 41'],
571
+ async run(input) {
572
+ const data = payload(input);
573
+ const { groupId, member } = requireMember(input);
574
+ try {
575
+ const page = store.read({
576
+ groupId,
577
+ member,
578
+ sinceSeq: finiteOr(data.sinceSeq, 0),
579
+ limit: clamp(finiteOr(data.limit, DEFAULT_READ_LIMIT), 1, MAX_READ_LIMIT),
580
+ });
581
+ return {
582
+ text: page.entries.length === 0
583
+ ? 'No new messages.'
584
+ : page.entries
585
+ .map((entry) => `#${entry.seq} ${entry.from.displayName} (${entry.atIso}): ${entry.text}`)
586
+ .join('\n'),
587
+ meta: {
588
+ // `lastSeq` is the group head, not the last entry returned: a caller
589
+ // that stores it will read forward from the right place even when a
590
+ // page was empty because nothing new had arrived.
591
+ lastSeq: page.lastSeq,
592
+ truncated: page.truncated,
593
+ entries: page.entries.map((entry) => ({
594
+ seq: entry.seq,
595
+ msgId: entry.msgId,
596
+ at: entry.at,
597
+ atIso: entry.atIso,
598
+ // `from` stays an OBJECT. It used to be flattened to the display name with `personId`
599
+ // hoisted alongside, which silently broke every consumer reading `from.displayName` —
600
+ // `publicEntry` documents a { personId, displayName } shape, and a flat string is not that.
601
+ from: { personId: entry.from.personId, displayName: entry.from.displayName },
602
+ kind: entry.kind,
603
+ text: entry.text,
604
+ })),
605
+ },
606
+ };
607
+ } catch (error) {
608
+ refuse(error);
609
+ }
610
+ },
611
+ },
612
+ {
613
+ id: 'group/subscribe',
614
+ name: 'Subscribe to a group feed',
615
+ description:
616
+ 'Open a stream of new group messages. Each new post arrives as an artifact update; a heartbeat keeps the connection alive. Reconnect with sinceSeq to resume where you left off.',
617
+ examples: ['Subscribe to the invoice group from seq 41'],
618
+ streaming: true,
619
+ async run(input, context) {
620
+ const data = payload(input);
621
+ const { groupId, member } = requireMember(input);
622
+ const sinceSeq = finiteOr(data.sinceSeq, 0);
623
+ const emit = context.emit;
624
+ if (typeof emit !== 'function') {
625
+ throw coded('not_streaming', 'group/subscribe requires a streaming caller');
626
+ }
627
+ // Catch up first. A member restarting its subscription must not miss what
628
+ // was posted while it was away, and replaying from the durable feed is
629
+ // more truthful than asking posters to re-send.
630
+ const backlog = store.read({ groupId, member, sinceSeq, limit: MAX_READ_LIMIT });
631
+ for (const entry of backlog.entries) emit('post', entry);
632
+
633
+ const dispose = store.subscribe({ groupId, fn: (entry) => emit('post', entry) });
634
+
635
+ // An open subscription IS presence. Without this, a member who joins and watches — the
636
+ // ordinary way to use a chat app — shows as offline after two minutes, because watching
637
+ // writes nothing. A live socket reported as offline is worse than no indicator at all.
638
+ //
639
+ // Deliberately a timer rather than a single touch: `lastSeenAt` is a window, and a
640
+ // connection that stays up for an hour must keep refreshing it.
641
+ const presence = typeof store.touch === 'function'
642
+ ? setInterval(() => {
643
+ try {
644
+ store.touch({ groupId, member });
645
+ } catch {
646
+ // A presence refresh must never be able to kill a working subscription.
647
+ }
648
+ }, PRESENCE_TICK_MS)
649
+ : undefined;
650
+ presence?.unref?.();
651
+
652
+ try {
653
+ // Hold the stream open until the caller goes away. The server owns the
654
+ // heartbeat and the timeouts; this skill only has to not return.
655
+ await context.untilCancelled();
656
+ } finally {
657
+ if (presence !== undefined) clearInterval(presence);
658
+ dispose();
659
+ }
660
+ return { text: 'subscription closed' };
661
+ },
662
+ },
663
+ {
664
+ id: 'group/info',
665
+ name: 'Group metadata',
666
+ description:
667
+ 'Name, topic, member count, online count, MY role, and the roster — who is in it and '
668
+ + 'what each person declared they do here. Needs a member credential: a roster names '
669
+ + 'people, so it is a member fact, not a directory fact. Returned in one call because a '
670
+ + 'caller that needs both the group and its people should not have to fetch twice.',
671
+ examples: ['What is this group called and how many people are in it?'],
672
+ async run(input, context) {
673
+ const data = payload(input);
674
+ const { groupId, member } = requireMember(input);
675
+ try {
676
+ const snapshot = store.snapshot({ groupId, member });
677
+ return {
678
+ text: `${snapshot.name}`
679
+ + (snapshot.topic === '' ? '' : ` — ${snapshot.topic}`)
680
+ + `\n${snapshot.memberCount} 人`
681
+ + (snapshot.onlineCount > 0 ? `,${snapshot.onlineCount} 人在线` : '')
682
+ + `\n我在这个群的角色:${member.personId === snapshot.owner?.personId ? '群主' : '成员'}`,
683
+ meta: {
684
+ groupId,
685
+ name: snapshot.name,
686
+ topic: snapshot.topic,
687
+ memberCount: snapshot.memberCount,
688
+ onlineCount: snapshot.onlineCount,
689
+ lastSeq: snapshot.lastSeq,
690
+ lastPostAt: snapshot.lastPostAt ?? 0,
691
+ isOwner: member.personId === snapshot.owner?.personId,
692
+ // The roster travels with the metadata: a colleague card is built from these
693
+ // rows, and fetching them separately would double the round trips for every
694
+ // caller that wants to know both "what is this group" and "who is in it".
695
+ members: store.members({ groupId, member }).map((entry) => ({
696
+ personId: entry.personId,
697
+ displayName: entry.displayName,
698
+ role: entry.role,
699
+ roleAbout: entry.roleAbout ?? '',
700
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
701
+ // Where this person can be reached directly, when it is known. Empty is the
702
+ // normal case for a host with no gateway base configured; a caller must read it
703
+ // as "no address", never as "offline".
704
+ endpoint: typeof entry.endpoint === 'string' ? entry.endpoint : '',
705
+ online: entry.online === true,
706
+ })),
707
+ // Surfaced so a member instance can tell a stale cache from live data.
708
+ fetchedAt: Date.now(),
709
+ },
710
+ };
711
+ } catch (error) {
712
+ refuse(error);
713
+ }
714
+ },
715
+ },
716
+ {
717
+ id: 'group/members',
718
+ name: 'List group members',
719
+ description: 'List the members of a group: display name, permission role, what each person says they do here, and whether they are online.',
720
+ examples: ['Who is in the invoice group?'],
721
+ async run(input) {
722
+ const data = payload(input);
723
+ const { groupId, member } = requireMember(input);
724
+ try {
725
+ const members = store.members({ groupId, member });
726
+ return {
727
+ // `role` is the permission bit; `about`/`tags` are what each person
728
+ // said about themselves in this group. Kept visibly distinct in the
729
+ // rendered text so nobody reads "产品" as an entitlement.
730
+ text: members.map((item) => {
731
+ const label = item.role === 'owner' ? '群主' : '成员';
732
+ const declared = [
733
+ item.roleAbout === undefined || item.roleAbout === '' ? '' : item.roleAbout,
734
+ (item.tags ?? []).join('、'),
735
+ ].filter((part) => part !== '').join(' · ');
736
+ const presence = item.online === true ? ', 在线' : '';
737
+ return `- ${item.displayName} [${label}${presence}]${declared === '' ? '' : ` — ${declared}`}`;
738
+ }).join('\n'),
739
+ meta: {
740
+ groupId,
741
+ members: members.map((item) => ({
742
+ personId: item.personId,
743
+ displayName: item.displayName,
744
+ role: item.role,
745
+ // Layer A, self-declared. Named `roleAbout` to match `group/info` and the
746
+ // store's own field. An earlier version emitted `about` here while `group/info`
747
+ // emitted `roleAbout`, so a caller reading one and caching against the other
748
+ // silently lost every role — which is exactly how the colleague cards came out
749
+ // blank.
750
+ roleAbout: item.roleAbout ?? '',
751
+ tags: item.tags ?? [],
752
+ joinedAt: item.joinedAt,
753
+ lastSeenAt: item.lastSeenAt,
754
+ online: item.online === true,
755
+ })),
756
+ },
757
+ };
758
+ } catch (error) {
759
+ refuse(error);
760
+ }
761
+ },
762
+ },
763
+ {
764
+ id: 'group/directory',
765
+ name: 'Browse this host\'s groups',
766
+ description:
767
+ 'List the groups this host offers: name, topic, join policy, member count and when each was last active. Needs no credential — it is a directory, not a roster. Member identities and message content are never returned here; use group/members and group/read with a member credential for those. Groups marked unlisted are omitted entirely.',
768
+ examples: ['What groups are there on this host?', 'Is there anything about invoices?'],
769
+ async run(input) {
770
+ const data = payload(input);
771
+ try {
772
+ // Deliberately no `requireMember`: this is the browse-before-you-join
773
+ // path, and requiring a credential would make the directory useless to
774
+ // exactly the person who needs it.
775
+ const page = store.listDirectory({
776
+ query: typeof data.query === 'string' ? data.query : undefined,
777
+ limit: data.limit,
778
+ offset: data.offset,
779
+ });
780
+ return {
781
+ text: page.groups.length === 0
782
+ ? 'No public groups on this host.'
783
+ : page.groups.map((group) => {
784
+ // Two ways in, and the directory says which one applies: asking is
785
+ // always possible (the owner decides), while an invite link needs
786
+ // somebody to mint one. Saying "ask to join" is more useful than a
787
+ // policy enum a person has to decode.
788
+ const online = group.onlineCount > 0 ? `,${group.onlineCount} 人在线` : '';
789
+ return `- ${group.name}(${group.memberCount} 人${online})`
790
+ + (group.topic === '' ? '' : `\n ${group.topic}`)
791
+ + `\n id: ${group.groupId} — 可以用 a2a_group_request 申请加入`;
792
+ }).join('\n'),
793
+ meta: {
794
+ total: page.total,
795
+ returned: page.returned,
796
+ hasMore: page.hasMore,
797
+ groups: page.groups.map((group) => ({
798
+ groupId: group.groupId,
799
+ name: group.name,
800
+ topic: group.topic,
801
+ memberCount: group.memberCount,
802
+ onlineCount: group.onlineCount,
803
+ lastActivityAt: group.lastActivityAt,
804
+ lastActivityAtIso: group.lastActivityAtIso,
805
+ })),
806
+ },
807
+ };
808
+ } catch (error) {
809
+ refuse(error);
810
+ }
811
+ },
812
+ },
813
+ {
814
+ id: 'group/request',
815
+ name: 'Apply to join a group',
816
+ description:
817
+ 'Apply to join a group you found in the directory. Needs NO credential — you are not a member yet, which is the point. Returns a requestId (public, a queue index) and a claimKey (SECRET: keep it, it is what collects the membership once the owner approves). The owner decides out of band; come back with group/claim.',
818
+ examples: ['Ask to join the invoice group, saying I work on the billing service'],
819
+ async run(input) {
820
+ const data = payload(input);
821
+ try {
822
+ const issued = store.requestJoin({
823
+ groupId: required(data.groupId, 'groupId'),
824
+ displayName: required(data.displayName, 'displayName'),
825
+ reason: typeof data.reason === 'string' ? data.reason : undefined,
826
+ });
827
+ return {
828
+ text: issued.duplicate === true
829
+ ? `你之前已经申请过「${issued.groupName}」,申请还在队列里(${issued.requestId})。\n\n`
830
+ + '请等待群主确认。注意:最初那次返回的 claimKey 才是领取成员身份的凭证,这次不会再给你一份。'
831
+ : `已提交加入「${issued.groupName}」的申请。\n\n`
832
+ + `requestId : ${issued.requestId}\n`
833
+ + `claimKey : ${issued.claimKey}\n\n`
834
+ + 'claimKey 只显示这一次,请保存好——群主同意后要用它领取成员身份。\n'
835
+ + `申请有效期至 ${new Date(issued.expiresAt).toISOString()}。`,
836
+ meta: {
837
+ groupId: required(data.groupId, 'groupId'),
838
+ requestId: issued.requestId,
839
+ // Only present on the first call: re-issuing would invalidate the key
840
+ // the applicant may already have saved.
841
+ claimKey: issued.claimKey ?? '',
842
+ duplicate: issued.duplicate === true,
843
+ expiresAt: new Date(issued.expiresAt).toISOString(),
844
+ groupName: issued.groupName,
845
+ },
846
+ };
847
+ } catch (error) {
848
+ refuse(error);
849
+ }
850
+ },
851
+ },
852
+ {
853
+ id: 'group/requests',
854
+ name: 'Review join requests',
855
+ description:
856
+ 'List the applications waiting for a decision. OWNER ONLY: the queue holds other people\'s names and stated reasons, which is the same class of information as the member list. Use group/decide to answer one.',
857
+ examples: ['Who is waiting to join?'],
858
+ async run(input) {
859
+ const data = payload(input);
860
+ const { groupId, member } = requireMember(input);
861
+ try {
862
+ const requests = store.listRequests({
863
+ groupId,
864
+ member,
865
+ status: typeof data.status === 'string' ? data.status : 'pending',
866
+ });
867
+ return {
868
+ text: requests.length === 0
869
+ ? '没有待处理的申请。'
870
+ : requests.map((entry) => `- ${entry.displayName}(${entry.requestId})`
871
+ + (entry.reason === '' ? '' : `\n ${entry.reason}`)).join('\n'),
872
+ meta: {
873
+ groupId,
874
+ count: requests.length,
875
+ requests: requests.map((entry) => ({
876
+ requestId: entry.requestId,
877
+ displayName: entry.displayName,
878
+ reason: entry.reason,
879
+ status: entry.status,
880
+ createdAt: entry.createdAtIso,
881
+ expiresAt: entry.expiresAtIso,
882
+ })),
883
+ },
884
+ };
885
+ } catch (error) {
886
+ refuse(error);
887
+ }
888
+ },
889
+ },
890
+ {
891
+ id: 'group/decide',
892
+ name: 'Approve or reject a join request',
893
+ description:
894
+ 'Answer one application. OWNER ONLY. Approving does not create the membership by itself — the applicant collects it with their own claimKey, which is how "one person vouched for one person" stays true. Rejecting is final for that request; the person may apply again.',
895
+ examples: ['Approve the request from 李四', 'Reject r-ab12cd34'],
896
+ async run(input) {
897
+ const data = payload(input);
898
+ const { groupId, member } = requireMember(input);
899
+ try {
900
+ const decided = store.decideRequest({
901
+ groupId,
902
+ member,
903
+ requestId: required(data.requestId, 'requestId'),
904
+ // Defaulting to approve would be the wrong default: an accidental call
905
+ // with no opinion should not open the door. decide() requires an
906
+ // explicit answer.
907
+ approve: data.approve !== false,
908
+ });
909
+ return {
910
+ text: `已${decided.status === 'approved' ? '同意' : '拒绝'} ${decided.displayName} 的申请。`
911
+ + (decided.status === 'approved'
912
+ ? '\n对方会用自己的 claimKey 领取成员身份,你不需要再发邀请链接。'
913
+ : ''),
914
+ meta: {
915
+ groupId,
916
+ requestId: decided.requestId,
917
+ status: decided.status,
918
+ displayName: decided.displayName,
919
+ },
920
+ };
921
+ } catch (error) {
922
+ refuse(error);
923
+ }
924
+ },
925
+ },
926
+ {
927
+ id: 'group/claim',
928
+ name: 'Collect an approved membership',
929
+ description:
930
+ 'Turn an approved request into a membership, using the claimKey you got when you applied. Needs NO group password: the owner already vouched for you. The claimKey is single-use and only you have it.',
931
+ examples: ['Collect my membership for request r-ab12cd34'],
932
+ async run(input, context) {
933
+ const data = payload(input);
934
+ try {
935
+ const groupId = required(data.groupId, 'groupId');
936
+ const claimed = store.claimJoin({
937
+ groupId,
938
+ requestId: required(data.requestId, 'requestId'),
939
+ claimKey: required(data.claimKey, 'claimKey'),
940
+ });
941
+ identities.upsert({
942
+ sessionId: context?.sessionId ?? HOST_SESSION_ID,
943
+ groupId,
944
+ personId: claimed.member.personId,
945
+ personKey: claimed.personKey,
946
+ local: true,
947
+ groupName: claimed.group.name,
948
+ role: claimed.member.role,
949
+ lastSeq: claimed.group.lastSeq ?? 0,
950
+ });
951
+ return {
952
+ text: `已加入「${claimed.group.name}」。\n\n`
953
+ + `personId : ${claimed.member.personId}\n`
954
+ + `personKey: ${claimed.personKey}\n\n`
955
+ + '这份凭证本机已保存;从现在起你在群里发言都用它。',
956
+ meta: {
957
+ groupId,
958
+ personId: claimed.member.personId,
959
+ personKey: claimed.personKey,
960
+ groupName: claimed.group.name,
961
+ },
962
+ };
963
+ } catch (error) {
964
+ refuse(error);
965
+ }
966
+ },
967
+ },
968
+ {
969
+ id: 'group/role',
970
+ name: 'Declare your role in this group',
971
+ description:
972
+ 'Say what you do in ONE group and update it whenever it changes. This is self-description, not a permission: it does not grant any ability, and nobody can set it for you. Use it when you take on a different hat in a project (a developer who is also acting as product).',
973
+ examples: ['In this group I am the product owner, tags 产品, 验收', 'I switched to doing the backend here: 研发, 后端'],
974
+ async run(input) {
975
+ const data = payload(input);
976
+ const { groupId, member } = requireMember(input);
977
+ try {
978
+ const updated = store.setProfile({
979
+ groupId,
980
+ member,
981
+ // `undefined` means "leave it alone"; an empty string clears it.
982
+ about: data.about === undefined ? undefined : data.about,
983
+ tags: data.tags === undefined ? undefined : data.tags,
984
+ });
985
+ return {
986
+ text: `Updated your role in this group.\n`
987
+ + `about: ${updated.roleAbout ?? '(cleared)'}\n`
988
+ + `tags : ${(updated.tags ?? []).join('、') || '(none)'}`,
989
+ meta: {
990
+ groupId,
991
+ personId: updated.personId,
992
+ about: updated.roleAbout ?? '',
993
+ tags: updated.tags ?? [],
994
+ updatedAt: updated.updatedAt,
995
+ },
996
+ };
997
+ } catch (error) {
998
+ refuse(error);
999
+ }
1000
+ },
1001
+ },
1002
+ {
1003
+ id: 'group/remove',
1004
+ name: 'Remove a member',
1005
+ description:
1006
+ 'Remove another member from a group. OWNER ONLY. Their messages stay in the feed — history is not theirs to erase and deleting it would make the conversation read as if people answered someone who never spoke. A system line records the removal, because the remaining members will notice. The owner cannot remove themselves; that is group/leave, which hands ownership on.',
1007
+ examples: ['Remove 王五 from the invoice group'],
1008
+ async run(input) {
1009
+ const data = payload(input);
1010
+ const { groupId, member } = requireMember(input);
1011
+ try {
1012
+ const removed = store.removeMember({
1013
+ groupId,
1014
+ member,
1015
+ targetPersonId: required(data.targetPersonId, 'targetPersonId'),
1016
+ reason: typeof data.reason === 'string' ? data.reason : undefined,
1017
+ });
1018
+ return {
1019
+ text: `已把 ${removed.displayName} 移出群。\n\n`
1020
+ + '他之前的消息都保留着,feed 里记了一条系统记录。'
1021
+ + '\n对方下次发帖会得到「不是成员」,客户端应当据此清掉本机记录。',
1022
+ meta: {
1023
+ groupId,
1024
+ personId: removed.personId,
1025
+ displayName: removed.displayName,
1026
+ feedSeq: removed.feedSeq ?? 0,
1027
+ },
1028
+ };
1029
+ } catch (error) {
1030
+ refuse(error);
1031
+ }
1032
+ },
1033
+ },
1034
+ {
1035
+ id: 'group/rename',
1036
+ name: 'Rename a group',
1037
+ description:
1038
+ 'Change a group name, its topic, or both. OWNER ONLY. Renaming is the ordinary maintenance act in a long-lived group; the alternative used to be creating a new group and losing the history, which nobody would do, so groups kept wrong names.',
1039
+ examples: ['Rename the invoice group to 发票与对账', 'Clear the topic of this group'],
1040
+ async run(input) {
1041
+ const data = payload(input);
1042
+ const { groupId, member } = requireMember(input);
1043
+ try {
1044
+ const renamed = store.renameGroup({
1045
+ groupId,
1046
+ member,
1047
+ name: data.name,
1048
+ topic: data.topic,
1049
+ });
1050
+ return {
1051
+ text: renamed.changed.length === 0
1052
+ ? '名字和主题都没变,所以没有改动。'
1053
+ : `已修改${renamed.changed.map((field) => (field === 'name' ? '群名' : '群主题')).join(' 和 ')}。\n\n`
1054
+ + `群名:${renamed.name}`
1055
+ + (renamed.topic === '' ? '' : `\n主题:${renamed.topic}`),
1056
+ meta: {
1057
+ groupId,
1058
+ name: renamed.name,
1059
+ topic: renamed.topic,
1060
+ changed: renamed.changed,
1061
+ },
1062
+ };
1063
+ } catch (error) {
1064
+ refuse(error);
1065
+ }
1066
+ },
1067
+ },
1068
+ {
1069
+ id: 'group/archive',
1070
+ name: 'Archive or reopen a group',
1071
+ description:
1072
+ 'Retire a group, or bring it back. OWNER ONLY. Archived means readable but not writable, and absent from the directory — deliberately not a delete, because a finished project conversation is worth keeping and re-opening by mistake must be possible.',
1073
+ examples: ['Archive the invoice group, we shipped it'],
1074
+ async run(input) {
1075
+ const data = payload(input);
1076
+ const { groupId, member } = requireMember(input);
1077
+ try {
1078
+ const result = store.archiveGroup({
1079
+ groupId,
1080
+ member,
1081
+ // Defaults to archiving: the common call is "we are done", not "reopen".
1082
+ archived: data.archived !== false,
1083
+ });
1084
+ return {
1085
+ text: result.archived
1086
+ ? '这个群已归档:还能读,但不能发言,也不会出现在频道目录里。让群主再调一次并传 archived=false 可以恢复。'
1087
+ : '这个群已恢复:可以继续发言,也会重新出现在频道目录里。',
1088
+ meta: { groupId, archived: result.archived },
1089
+ };
1090
+ } catch (error) {
1091
+ refuse(error);
1092
+ }
1093
+ },
1094
+ },
1095
+ {
1096
+ id: 'group/leave',
1097
+ name: 'Leave a group',
1098
+ description: 'Remove a member from a group. Anyone may remove themselves; only the owner may remove someone else.',
1099
+ examples: ['Leave the invoice group'],
1100
+ async run(input) {
1101
+ const { groupId, member } = requireMember(input);
1102
+ try {
1103
+ // Only self-removal is expressible here: the store keys removal on the
1104
+ // authenticated caller, so "owner removes another member" is a separate
1105
+ // operation (a future `group/remove`) rather than a field on this one.
1106
+ const removed = store.leave({ groupId, member });
1107
+ if (removed) identities.remove(groupId);
1108
+ return {
1109
+ text: removed ? 'Left the group.' : 'You were already not a member.',
1110
+ meta: { groupId, removed: removed === true },
1111
+ };
1112
+ } catch (error) {
1113
+ refuse(error);
1114
+ }
1115
+ },
1116
+ },
1117
+ ];
1118
+
1119
+ /** A caller-named field the skill cannot proceed without. */
1120
+ function required(value, name) {
1121
+ if (typeof value !== 'string' || value.trim() === '') {
1122
+ throw coded('invalid_payload', `this skill needs "${name}"`);
1123
+ }
1124
+ return value;
1125
+ }
1126
+ }
1127
+
1128
+ /** An error carrying a stable code, for the model and for tests. */
1129
+ function coded(code, message) {
1130
+ const error = new Error(`[${code}] ${message}`);
1131
+ error.code = code;
1132
+ return error;
1133
+ }
1134
+
1135
+ /** One line about an error, without a stack. */
1136
+ function describe(error) {
1137
+ return error instanceof Error ? error.message : String(error);
1138
+ }
1139
+
1140
+ /** A generated password: unambiguous alphabet, so it survives being read aloud. */
1141
+ export function randomPassword(length = 12) {
1142
+ const alphabet = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
1143
+ const bytes = new Uint8Array(length);
1144
+ randomFillSync(bytes);
1145
+ let out = '';
1146
+ for (const byte of bytes) out += alphabet[byte % alphabet.length];
1147
+ return out;
1148
+ }
1149
+
1150
+ /** A finite number, or the fallback. */
1151
+ function finiteOr(value, fallback) {
1152
+ const number = typeof value === 'number' ? value : Number(value);
1153
+ return Number.isFinite(number) ? number : fallback;
1154
+ }
1155
+
1156
+ /** Clamp a number into `[min, max]`. */
1157
+ function clamp(value, min, max) {
1158
+ return Math.max(min, Math.min(value, max));
1159
+ }
1160
+
1161
+ /** Whether a value is a plain JSON object (not an array, not null). */
1162
+ function isPlainObject(value) {
1163
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1164
+ }