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,658 @@
1
+ /**
2
+ * The group inbox: a long-lived subscription per joined group, whose posts
3
+ * become Session turns.
4
+ *
5
+ * This is the module that makes a group feel like a group. Everything below it
6
+ * is transport (A2A over a gateway) and everything above it is a Session; this
7
+ * file is the only place that decides *when* a message is worth waking somebody
8
+ * for, and it is deliberately conservative about it:
9
+ *
10
+ * - **A post by this Session is not delivered back to it.** Otherwise every
11
+ * message a Session sends costs it a turn to read its own words, and two
12
+ * Sessions in one group would ping-pong forever.
13
+ * - **A post already delivered is not delivered again.** Reconnects replay the
14
+ * feed from a sequence number; without a watermark, a flaky link would
15
+ * re-wake the Session for everything it already saw.
16
+ * - **A post is delivered as a task message, not as a user bubble.** The user
17
+ * did not type it, and a Session that thinks its human said something will
18
+ * answer the human instead of the group.
19
+ *
20
+ * The subscription itself is `bridge.stream` with a `sinceSeq`, which is why the
21
+ * bridge's resume-on-reconnect exists: an inbox that gives up on the first
22
+ * network blip is an inbox that quietly stops working, and nothing looks more
23
+ * broken than a group where nobody answers.
24
+ *
25
+ * @module dsh-a2a/inbox
26
+ */
27
+
28
+ /** How long one subscription waits for a heartbeat before considering the link dead. */
29
+ const DEFAULT_IDLE_MS = 120000;
30
+
31
+ /** How long to wait before re-subscribing after the stream closes on its own. */
32
+ const RESUBSCRIBE_BASE_MS = 1000;
33
+
34
+ /**
35
+ * How often to check for one-to-one messages.
36
+ *
37
+ * A group feed streams; a direct message polls. Ten seconds is short enough that an invitation feels
38
+ * immediate when both sides are up, and long enough that an idle instance is not making a request
39
+ * every second to learn nothing — an invitation is a low-frequency event by nature.
40
+ */
41
+ const DIRECT_POLL_MS = 10_000;
42
+
43
+ /** Largest re-subscribe backoff, so a down host is retried forever at a slow rate. */
44
+ const RESUBSCRIBE_MAX_MS = 60000;
45
+
46
+
47
+ /**
48
+ * The sentinel meaning "everyone in the group", mirrored from groups.js.
49
+ *
50
+ * Duplicated as a constant rather than imported: inbox.js is deliberately free of a
51
+ * dependency on the group store (it only ever sees feed entries), and a single string
52
+ * literal is a smaller coupling than a module edge.
53
+ */
54
+ const MENTION_ALL_LITERAL = '@all';
55
+
56
+ /**
57
+ * How loudly one post should arrive for one membership.
58
+ *
59
+ * @param {object} post - the feed entry.
60
+ * @param {object} membership - the receiving membership.
61
+ * @param {boolean} muted - whether this group is muted for this Session.
62
+ * @returns {'mention'|'notice'|'ambient'} the grade.
63
+ */
64
+ export function gradeOf(post, membership, muted) {
65
+ // A mute is an explicit instruction from the person, so it outranks everything.
66
+ // A mention still arrives, just quietly, rather than being dropped.
67
+ if (muted) return 'ambient';
68
+ const mentions = Array.isArray(post.mentions) ? post.mentions : [];
69
+ if (mentions.length === 0) return 'notice';
70
+ if (mentions.includes(MENTION_ALL_LITERAL)) return 'mention';
71
+ if (mentions.includes(membership.personId)) return 'mention';
72
+ // The post addressed somebody, just not this member: deliberately ambient. Being
73
+ // told "other people were mentioned" is noise, not signal.
74
+ return 'ambient';
75
+ }
76
+
77
+ /**
78
+ * Build the inbox.
79
+ *
80
+ * @param {object} deps - `{ ctx, logger, config, clock, groups, identities, registry, bridge, deliverer, sessions }`.
81
+ * @returns {object} `{ enable, disable, enabled, state, resume, stop }`.
82
+ */
83
+ export function createInbox({ ctx, logger, config, clock = () => Date.now(), groups, identities, registry, bridge, deliverer, sessions }) {
84
+ /** groupId → { controller, attempt, timer, lastSeq, active } */
85
+ const subscriptions = new Map();
86
+ /** groupIds the operator turned off explicitly, so resume() does not fight them. */
87
+ const disabled = new Set();
88
+ /** messageIds already delivered, so a replay is not a second wake. */
89
+ const delivered = new Map();
90
+ /** Timer for the direct-message poll, or undefined when not running. */
91
+ let directTimer;
92
+ let stopped = false;
93
+
94
+ const enabled = () => config.groups.enabled === true && stopped === false;
95
+
96
+ /**
97
+ * Start (or restart) the subscription for one group, or for every joined group.
98
+ *
99
+ * Awaiting this returns once subscriptions have been *started*, not once they
100
+ * are connected: a group host that is unreachable right now must not make
101
+ * `a2a_group_inbox on` hang, and the retry loop is the thing that handles it.
102
+ *
103
+ * @param {string} [groupId] - one group, or every known group when omitted.
104
+ * @returns {Promise<void>} resolves once started.
105
+ */
106
+ const enable = async (groupId) => {
107
+ stopped = false;
108
+ const targets = groupId === undefined ? joinedGroupIds() : [groupId];
109
+ for (const id of targets) {
110
+ disabled.delete(id);
111
+ start(id);
112
+ }
113
+ // Direct messages are pulled rather than streamed: this covers the case a subscription cannot —
114
+ // an invitation sent while this instance was asleep.
115
+ startDirectPoll();
116
+ };
117
+
118
+ /** Poll for direct messages on behalf of any membership this instance holds. */
119
+ function startDirectPoll() {
120
+ if (directTimer !== undefined) return;
121
+ const tick = () => {
122
+ if (!enabled()) return;
123
+ const membership = identities.list()[0];
124
+ if (membership === undefined) return;
125
+ void pullDirect(membership);
126
+ };
127
+ // Once immediately, so an invitation sent while this DSH was closed is not delayed by a full
128
+ // interval before it appears.
129
+ tick();
130
+ directTimer = setInterval(tick, DIRECT_POLL_MS);
131
+ directTimer.unref?.();
132
+ }
133
+
134
+ /**
135
+ * Stop one subscription, or all of them.
136
+ *
137
+ * The watermark is kept, so turning the inbox back on resumes where it left
138
+ * off instead of replaying the group's whole history into the Session.
139
+ *
140
+ * @param {string} [groupId] - one group, or every group when omitted.
141
+ * @returns {Promise<void>} resolves once stopped.
142
+ */
143
+ const disable = async (groupId) => {
144
+ const targets = groupId === undefined ? [...subscriptions.keys()] : [groupId];
145
+ for (const id of targets) {
146
+ disabled.add(id);
147
+ stop(id);
148
+ }
149
+ };
150
+
151
+ /** Resume every joined group that is not explicitly disabled. */
152
+ const resume = async () => {
153
+ if (!enabled()) return;
154
+ const ids = joinedGroupIds().filter((id) => !disabled.has(id));
155
+ if (ids.length === 0) return;
156
+ for (const id of ids) start(id);
157
+ logger?.info?.(`a2a: group inbox resumed for ${ids.length} group(s)`);
158
+ };
159
+
160
+ /**
161
+ * Stop one subscription, or every subscription when called with no argument.
162
+ *
163
+ * The no-argument form is what unload uses, and it has to actually stop
164
+ * everything: an earlier version looked the group up by `undefined` and
165
+ * stopped nothing, which left every SSE connection open after the plugin was
166
+ * disposed. In a test that is a hanging process; in production it is a
167
+ * connection that keeps waking a Session for a plugin the operator turned off.
168
+ *
169
+ * @param {string} [groupId] - one group, or every group when omitted.
170
+ * @returns {void}
171
+ */
172
+ const stop = (groupId) => {
173
+ if (groupId === undefined) {
174
+ for (const id of [...subscriptions.keys()]) stop(id);
175
+ stopped = true;
176
+ // The direct-message poll answers to the same on/off switch as the group subscriptions: turning
177
+ // the inbox off must stop everything that would insert text into a conversation.
178
+ if (directTimer !== undefined) {
179
+ clearInterval(directTimer);
180
+ directTimer = undefined;
181
+ }
182
+ return;
183
+ }
184
+ const entry = subscriptions.get(groupId);
185
+ if (entry === undefined) return;
186
+ if (entry.timer !== undefined) clearTimeout(entry.timer);
187
+ try {
188
+ entry.controller?.abort();
189
+ } catch {
190
+ // An already-aborted controller throws nothing, but a foreign
191
+ // implementation might; stopping must never be the thing that breaks.
192
+ }
193
+ subscriptions.delete(groupId);
194
+ };
195
+
196
+ /**
197
+ * What the tools report for `a2a_group_inbox status`.
198
+ *
199
+ * `lastSeq` is the watermark actually used for the next resume, so a member
200
+ * that received nothing still shows where it is in the feed.
201
+ */
202
+ const state = () => [...subscriptions.entries()].map(([groupId, entry]) => ({
203
+ groupId,
204
+ // How many Sessions of this instance this one stream feeds. Two means the same
205
+ // group was joined twice on this machine — legitimate, and worth surfacing.
206
+ sessions: identities.list({ groupId }).filter((record) => record.local !== true).length,
207
+ active: entry.active === true,
208
+ connecting: entry.connecting === true,
209
+ lastSeq: entry.lastSeq ?? 0,
210
+ }));
211
+
212
+ /**
213
+ * Groups this instance should watch.
214
+ *
215
+ * Only REMOTE memberships get a subscription: a group hosted by this very
216
+ * instance already reaches its own Session through the local store's
217
+ * `subscribe()`, and subscribing over A2A to oneself would put a network hop
218
+ * (and an authentication round trip) between a post and the person who wrote it.
219
+ */
220
+ function joinedGroupIds() {
221
+ return identities.remoteMemberships()
222
+ .map((record) => record.groupId)
223
+ .filter((id) => typeof id === 'string' && id !== '');
224
+ }
225
+
226
+ /** Open one subscription, with resubscribe on close. */
227
+ function start(groupId) {
228
+ if (stopped || !enabled()) return;
229
+ const existing = subscriptions.get(groupId);
230
+ if (existing !== undefined && existing.active === true) return;
231
+ if (existing !== undefined) stop(groupId);
232
+
233
+ const entry = {
234
+ controller: new AbortController(),
235
+ attempt: 0,
236
+ lastSeq: existing?.lastSeq ?? watermarkOf(groupId),
237
+ active: false,
238
+ connecting: true,
239
+ };
240
+ subscriptions.set(groupId, entry);
241
+ void run(groupId, entry);
242
+ }
243
+
244
+ /**
245
+ * One subscription attempt, then reschedule if it ends.
246
+ *
247
+ * `entry.active` means "a stream is open right now" and nothing else — it is
248
+ * set only after the bridge has accepted the call and cleared when the stream
249
+ * ends. An earlier version set it before checking the membership record, which
250
+ * made `state()` report a subscription that never opened, so the one diagnostic
251
+ * a person has for "why am I not getting group messages" lied.
252
+ */
253
+ async function run(groupId, entry) {
254
+ // The stream authenticates with ONE membership's credential — the group host
255
+ // answers one subscriber. Posts are fanned out to every membership afterwards,
256
+ // so which credential opened the stream does not decide who receives.
257
+ const memberships = identities.list({ groupId }).filter((record) => record.local !== true);
258
+ if (memberships.length === 0) {
259
+ logger?.warn?.(`a2a: no remote membership record for group ${groupId}; not subscribing. Run a2a_groups to check it.`);
260
+ return;
261
+ }
262
+ const membership = memberships[0];
263
+ // The bridge calls this an idle timeout: it resets on every chunk, so a healthy
264
+ // silent subscription is not killed by total elapsed time.
265
+ const idleMs = config.client.streamIdleMs ?? DEFAULT_IDLE_MS;
266
+ try {
267
+ const result = await bridge.stream(sourceOf(membership), {
268
+ skill: 'group/subscribe',
269
+ text: `subscribe ${groupId} from ${entry.lastSeq}`,
270
+ data: {
271
+ groupId,
272
+ personId: membership.personId,
273
+ personKey: membership.personKey,
274
+ sinceSeq: entry.lastSeq,
275
+ },
276
+ signal: entry.controller.signal,
277
+ sinceSeq: entry.lastSeq,
278
+ timeoutMs: idleMs,
279
+ onEvent: (event) => {
280
+ // The first frame — even a heartbeat status — is the proof the stream is
281
+ // live. The bridge's promise only settles when the stream ENDS, so
282
+ // waiting for it to mark "subscribed" would show nothing until the day
283
+ // it stopped, which is the opposite of what the diagnostic is for.
284
+ entry.connecting = false;
285
+ entry.active = true;
286
+ entry.attempt = 0;
287
+ if (event?.type === 'reconnect') {
288
+ logger?.info?.(`a2a: group ${groupId} stream reconnecting (attempt ${event.attempt})`);
289
+ return;
290
+ }
291
+ if (event?.type !== 'artifact') return;
292
+ const post = parsePost(event);
293
+ if (post === undefined) return;
294
+ if (post.seq > entry.lastSeq) entry.lastSeq = post.seq;
295
+ saveWatermark(groupId, entry.lastSeq);
296
+ void deliverPost(groupId, membership, post);
297
+ },
298
+ });
299
+ if (typeof result?.lastSeq === 'number' && result.lastSeq > entry.lastSeq) {
300
+ entry.lastSeq = result.lastSeq;
301
+ saveWatermark(groupId, entry.lastSeq);
302
+ }
303
+ entry.active = false;
304
+ } catch (error) {
305
+ entry.active = false;
306
+ entry.connecting = false;
307
+ if (entry.controller.signal.aborted || stopped) return;
308
+ logger?.warn?.(`a2a: group ${groupId} subscription ended (${describe(error)}); will retry`);
309
+ }
310
+ // A run that finished after `stop()` must not resurrect a subscription the
311
+ // caller already cancelled: `stop` deletes the map entry, and `schedule`
312
+ // checks for that, which is what keeps a disposed plugin from holding a timer.
313
+ schedule(groupId, entry);
314
+ }
315
+
316
+ /** Re-subscribe after a pause, backing off so a dead host is cheap to retry. */
317
+ function schedule(groupId, entry) {
318
+ if (stopped || disabled.has(groupId)) return;
319
+ if (!subscriptions.has(groupId)) return;
320
+ entry.attempt += 1;
321
+ const delay = Math.min(RESUBSCRIBE_BASE_MS * 2 ** (entry.attempt - 1), RESUBSCRIBE_MAX_MS);
322
+ const timer = setTimeout(() => {
323
+ const current = subscriptions.get(groupId);
324
+ if (current === undefined || current !== entry) return;
325
+ start(groupId);
326
+ }, delay);
327
+ if (typeof timer?.unref === 'function') timer.unref();
328
+ // Keep the old entry so its watermark survives the gap; `start` reads it.
329
+ entry.timer = timer;
330
+ }
331
+
332
+ /**
333
+ * Turn one group post into one Session turn.
334
+ *
335
+ * Three refusals, each for a reason stated in the module comment: our own
336
+ * post, an already-delivered post, and the case where this instance has no
337
+ * bound Session to wake.
338
+ */
339
+ /**
340
+ * Fetch and deliver any direct messages waiting for this Session, then confirm them.
341
+ *
342
+ * Renders the invitation as something actionable rather than as a notification: the link is right
343
+ * there, so joining does not require the recipient to find and paste it.
344
+ *
345
+ * Confirmation happens here, immediately after delivery, because the ack is what the SENDER's
346
+ * screen depends on. Leaving it to the reader would make every message look undelivered until
347
+ * somebody happened to interact with their own inbox.
348
+ *
349
+ * @param {object} membership - a membership record, used only for its credential and personId.
350
+ * @returns {Promise<number>} how many messages were delivered.
351
+ */
352
+ async function pullDirect(membership) {
353
+ if (membership === undefined) return 0;
354
+ let messages;
355
+ try {
356
+ if (membership.local === true) {
357
+ messages = groups.listPersonMessages({ personId: membership.personId });
358
+ } else {
359
+ const result = await bridge.send(
360
+ {
361
+ baseUrl: membership.baseUrl,
362
+ apiKey: membership.apiKey ?? config.client.apiKey,
363
+ forwardProto: config.client.forwardProto,
364
+ protocolVersion: config.client.protocolVersion,
365
+ },
366
+ {
367
+ skill: 'person/inbox',
368
+ text: 'inbox',
369
+ data: { personId: membership.personId },
370
+ timeoutMs: config.client.timeoutMs,
371
+ },
372
+ );
373
+ messages = Array.isArray(result?.data?.messages) ? result.data.messages : [];
374
+ }
375
+ } catch (error) {
376
+ // A host that is down must not throw from a poll: the next tick retries, and a thrown error
377
+ // here would surface as an unhandled rejection in the plugin's startup path.
378
+ logger?.warn?.(`a2a: could not read direct messages (${error?.message ?? error})`);
379
+ return 0;
380
+ }
381
+ if (!Array.isArray(messages) || messages.length === 0) return 0;
382
+
383
+ const deliveredIds = [];
384
+ for (const message of messages) {
385
+ if (message === null || typeof message !== 'object') continue;
386
+ const dmId = typeof message.dmId === 'string' ? message.dmId : '';
387
+ if (dmId === '') continue;
388
+ // Already handled in this process: a poll that overlaps with itself must not deliver twice.
389
+ if (delivered.has(dmId)) {
390
+ deliveredIds.push(dmId);
391
+ continue;
392
+ }
393
+ try {
394
+ await deliverer.deliver({
395
+ sessionId: membership.sessionId,
396
+ text: renderDirect(message),
397
+ // Reuse the message id as the delivery key, so the deliverer's own dedupe covers the case
398
+ // where a poll retries before the ack lands.
399
+ messageId: dmId,
400
+ kind: message.kind,
401
+ });
402
+ delivered.set(dmId, true);
403
+ deliveredIds.push(dmId);
404
+ } catch (error) {
405
+ // Leave it unacked on purpose: an undelivered message must stay in the queue, and claiming
406
+ // otherwise would lose it.
407
+ logger?.warn?.(`a2a: could not deliver direct message ${dmId} (${error?.message ?? error})`);
408
+ }
409
+ }
410
+
411
+ if (deliveredIds.length > 0) {
412
+ try {
413
+ if (membership.local === true) {
414
+ groups.ackPersonMessages({ personId: membership.personId, dmIds: deliveredIds });
415
+ } else {
416
+ await bridge.send(
417
+ {
418
+ baseUrl: membership.baseUrl,
419
+ apiKey: membership.apiKey ?? config.client.apiKey,
420
+ forwardProto: config.client.forwardProto,
421
+ protocolVersion: config.client.protocolVersion,
422
+ },
423
+ {
424
+ skill: 'person/ack',
425
+ text: 'ack',
426
+ data: { personId: membership.personId, dmIds: deliveredIds },
427
+ timeoutMs: config.client.timeoutMs,
428
+ },
429
+ );
430
+ }
431
+ } catch (error) {
432
+ // The message was delivered; failing to confirm only costs the sender an indicator. Retrying
433
+ // the ack is safe because it is idempotent.
434
+ logger?.warn?.(`a2a: could not confirm direct messages (${error?.message ?? error})`);
435
+ }
436
+ }
437
+ return deliveredIds.length;
438
+ }
439
+
440
+ /**
441
+ * How one direct message reads in a conversation.
442
+ *
443
+ * An invitation shows the link, because the point of pushing it is that the recipient does not have
444
+ * to hunt for one. A notice or welcome is already a sentence and is passed through.
445
+ *
446
+ * @param {object} message - the delivered message.
447
+ * @returns {string} the text to insert.
448
+ */
449
+ function renderDirect(message) {
450
+ const payload = message.payload ?? {};
451
+ if (message.kind === 'group-invite' && typeof payload.inviteLink === 'string') {
452
+ const name = typeof payload.groupName === 'string' && payload.groupName !== ''
453
+ ? payload.groupName
454
+ : payload.groupId;
455
+ const topic = typeof payload.topic === 'string' && payload.topic !== ''
456
+ ? `\n主题:${payload.topic}`
457
+ : '';
458
+ return `邀请你加入协作群「${name}」${topic}\n\n`
459
+ + `用 a2a_group_join 加入这个群:${payload.inviteLink}`;
460
+ }
461
+ return typeof message.text === 'string' && message.text !== ''
462
+ ? message.text
463
+ : '(收到一条消息)';
464
+ }
465
+
466
+ async function deliverPost(groupId, openingMembership, post) {
467
+ /**
468
+ * Every Session of this instance that belongs to this group, each delivered to
469
+ * on its own behalf.
470
+ *
471
+ * `openingMembership` is the record whose credential opened the stream; it is
472
+ * NOT necessarily the only one. A person who joined the same group from two
473
+ * Sessions is two members, and both Sessions must see the post.
474
+ */
475
+ const memberships = identities.list({ groupId }).filter((record) => record.local !== true);
476
+ if (memberships.length === 0) return;
477
+
478
+ for (const membership of memberships) {
479
+ // A Session does not receive its own post: its author already knows.
480
+ if (post.from?.personId !== undefined && post.from.personId === membership.personId) continue;
481
+ // Dedupe is keyed per (Session, message): one Session having seen a post must
482
+ // not suppress delivery to another Session of the same instance.
483
+ const dedupeKey = `${membership.sessionId}\u0000${post.msgId}`;
484
+ if (delivered.has(dedupeKey)) continue;
485
+ const sessionId = membership.sessionId;
486
+ if (typeof sessionId !== 'string' || sessionId === '') {
487
+ logger?.warn?.(`a2a: membership in ${groupId} has no Session; skipping delivery`);
488
+ continue;
489
+ }
490
+ const muted = disabled.has(groupId);
491
+ const grade = gradeOf(post, membership, muted);
492
+ // Only a mention changes the rendering: the reader has to know the post is FOR
493
+ // them, not merely in a group they belong to.
494
+ const addressedMe = grade === 'mention'
495
+ && (!Array.isArray(post.mentions) || post.mentions.includes('@all') || post.mentions.includes(membership.personId));
496
+ try {
497
+ await deliverer.deliver({
498
+ sessionId,
499
+ text: renderPost(membership.groupName ?? groupId, post, addressedMe),
500
+ messageId: post.msgId,
501
+ // Only a mention or an ordinary notice starts a turn. Ambient posts wait
502
+ // in the task inbox, so a large group cannot interrupt everybody for
503
+ // everything that happens in it.
504
+ mode: grade === 'ambient' ? 'inbox' : 'queue',
505
+ source: {
506
+ kind: 'a2a',
507
+ skill: 'group/subscribe',
508
+ groupId,
509
+ seq: post.seq,
510
+ from: post.from?.displayName,
511
+ grade,
512
+ },
513
+ // A group post arrives on somebody else's schedule; it must not raise the
514
+ // Session's permission level as a side effect of being delivered.
515
+ elevate: false,
516
+ });
517
+ remembered(dedupeKey);
518
+ } catch (error) {
519
+ logger?.warn?.(`a2a: could not deliver group ${groupId} post #${post.seq} to ${sessionId} (${describe(error)})`);
520
+ // A failed delivery must be retryable: no dedupe entry means the next resume
521
+ // replays this post instead of losing it.
522
+ }
523
+ }
524
+ void openingMembership;
525
+ }
526
+
527
+
528
+ /**
529
+ * The wire watermark for one group: the LOWEST read position among its
530
+ * memberships.
531
+ *
532
+ * The stream is per group, so it carries one `sinceSeq`. Taking the minimum means
533
+ * the Session that was furthest ahead re-reads a few posts and per-Session dedupe
534
+ * drops them, while the Session that was behind misses nothing. The other
535
+ * direction would silently skip posts for the member who had read less.
536
+ */
537
+ function watermarkOf(groupId) {
538
+ const records = identities.list({ groupId });
539
+ let lowest = Number.POSITIVE_INFINITY;
540
+ for (const record of records) {
541
+ if (typeof record.lastSeq === 'number' && record.lastSeq < lowest) lowest = record.lastSeq;
542
+ }
543
+ return Number.isFinite(lowest) ? lowest : 0;
544
+ }
545
+
546
+ /** Persist a read position for every membership of a group. */
547
+ function saveWatermark(groupId, seq) {
548
+ try {
549
+ for (const record of identities.list({ groupId })) {
550
+ identities.upsert({ sessionId: record.sessionId, groupId, lastSeq: seq });
551
+ }
552
+ } catch (error) {
553
+ logger?.warn?.(`a2a: could not persist the group ${groupId} watermark (${describe(error)})`);
554
+ }
555
+ }
556
+
557
+ /**
558
+ * The membership whose credential opens the stream for a group, or undefined.
559
+ *
560
+ * Only remote memberships are subscribable (see `joinedGroupIds`), so a local
561
+ * record is skipped rather than subscribed to.
562
+ */
563
+ function membershipOf(groupId) {
564
+ const records = identities.list({ groupId }).filter((record) => record.local !== true);
565
+ return records.length === 0 ? undefined : records[0];
566
+ }
567
+
568
+ /** The bridge target for one membership. */
569
+ function sourceOf(membership) {
570
+ return {
571
+ baseUrl: membership.baseUrl,
572
+ apiKey: membership.apiKey ?? config.client.apiKey,
573
+ forwardProto: config.client.forwardProto,
574
+ protocolVersion: config.client.protocolVersion,
575
+ };
576
+ }
577
+
578
+ /** Bounded memory for the delivered-id set. */
579
+ function remembered(msgId) {
580
+ delivered.set(msgId, clock());
581
+ if (delivered.size <= 2000) return;
582
+ const cutoff = clock() - (config.delivery.dedupeWindowMs ?? 600000);
583
+ for (const [key, at] of delivered) {
584
+ if (at < cutoff) delivered.delete(key);
585
+ }
586
+ // Still too big (a burst), so drop the oldest half rather than grow forever.
587
+ if (delivered.size > 2000) {
588
+ const keys = [...delivered.keys()].slice(0, delivered.size - 1000);
589
+ for (const key of keys) delivered.delete(key);
590
+ }
591
+ }
592
+
593
+ return {
594
+ // Exposed so a caller can fetch pending invitations once at startup without waiting for the
595
+ // first poll tick.
596
+ pullDirect,
597
+ enable, disable, enabled, state, resume, stop };
598
+ }
599
+
600
+ /**
601
+ * One group post out of a stream artifact event.
602
+ *
603
+ * The bridge hands back the artifact text as JSON because that is what the
604
+ * group's `group/subscribe` skill emits; a malformed payload is skipped rather
605
+ * than delivered, since a Session woken to read garbage is worse than a Session
606
+ * not woken at all.
607
+ */
608
+ function parsePost(event) {
609
+ const raw = typeof event?.text === 'string' ? event.text : '';
610
+ if (raw.trim() === '') return undefined;
611
+ let payload;
612
+ try {
613
+ payload = JSON.parse(raw);
614
+ } catch {
615
+ return undefined;
616
+ }
617
+ if (typeof payload?.msgId !== 'string' || typeof payload?.seq !== 'number') return undefined;
618
+ return payload;
619
+ }
620
+
621
+ /**
622
+ * What a Session actually reads when a group post arrives.
623
+ *
624
+ * Named sender, sequence number, and an explicit instruction to answer in the
625
+ * group: a Session that receives "fix the invoice bug" with no return address
626
+ * will fix it and tell its human, and the group will never hear about it.
627
+ */
628
+ function renderPost(groupName, post, addressedMe = false) {
629
+ const sender = post.from?.displayName ?? post.from?.personId ?? 'a member';
630
+ const when = typeof post.at === 'number' ? new Date(post.at).toISOString() : 'just now';
631
+ const toEveryone = Array.isArray(post.mentions) && post.mentions.includes('@all');
632
+ // The first line is what a reader sees in the collapsed card, so the addressing has
633
+ // to be there. "You were named" is the difference between "read this now" and "read
634
+ // this when you get to it" — which is the entire point of mentions.
635
+ const addressing = addressedMe === true
636
+ ? (toEveryone ? ' — @everyone' : ' — you were mentioned')
637
+ : (toEveryone ? ' — to everyone' : '');
638
+ const lines = [
639
+ `Group message in 「${groupName}」 from ${sender}${addressing} (#${post.seq}, ${when}):`,
640
+ '',
641
+ typeof post.text === 'string' && post.text !== '' ? post.text : '(no text)',
642
+ ];
643
+ if (post.payload !== undefined && post.payload !== null) {
644
+ lines.push('', 'Structured payload:', '```json', JSON.stringify(post.payload, null, 2), '```');
645
+ }
646
+ lines.push(
647
+ '',
648
+ addressedMe === true
649
+ ? 'This one names you. Answer with a2a_group_post to the same group — a reply told only to your user leaves the rest of the group blind.'
650
+ : 'Answer with a2a_group_post to the same group — a reply told only to your user leaves the rest of the group blind.',
651
+ );
652
+ return lines.join('\n');
653
+ }
654
+
655
+ /** One line about an error, without a stack. */
656
+ function describe(error) {
657
+ return error instanceof Error ? error.message : String(error);
658
+ }