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.
- package/README-ui.md +350 -0
- package/README.md +160 -2
- package/bin/agent-groups.js +438 -0
- package/client.js +3782 -0
- package/cordis.patch.yml +17 -0
- package/docs/ARCHITECTURE.md +281 -0
- package/docs/BACKLOG.md +201 -0
- package/docs/CONTRACT.md +592 -0
- package/docs/DEPLOY.md +645 -0
- package/docs/DESIGN.md +390 -0
- package/docs/MCP.md +260 -0
- package/docs/PRODUCT.md +186 -0
- package/docs/RUNBOOK.md +406 -0
- package/lib/core/bridge.js +1167 -0
- package/lib/core/card.js +217 -0
- package/lib/core/colleague-cards.js +149 -0
- package/lib/core/config.js +235 -0
- package/lib/core/endpoint.js +69 -0
- package/lib/core/groups.js +3010 -0
- package/lib/core/invite.js +412 -0
- package/lib/core/memberships.js +366 -0
- package/lib/core/probe.js +219 -0
- package/lib/core/registry.js +344 -0
- package/lib/core/server.js +1304 -0
- package/lib/core/skills-group.js +1164 -0
- package/lib/core/skills-session.js +116 -0
- package/lib/core/skills.js +76 -0
- package/lib/core/wire.js +772 -0
- package/lib/dsh/deliver.js +601 -0
- package/lib/dsh/inbox.js +658 -0
- package/lib/dsh/profile.js +195 -0
- package/lib/dsh/prompt.js +55 -0
- package/lib/dsh/sessions.js +278 -0
- package/lib/dsh/toolkit.js +166 -0
- package/lib/dsh/tools.js +101 -0
- package/lib/index.js +648 -0
- package/lib/mcp/mcp-deps.js +145 -0
- package/lib/mcp-server.js +261 -0
- package/lib/shared/tool-runtime.js +1349 -0
- package/lib/shared/tool-specs.js +1123 -0
- package/lib/types/client.d.ts +144 -0
- package/package.json +84 -4
package/lib/dsh/inbox.js
ADDED
|
@@ -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
|
+
}
|