@zhin.js/core 1.5.11 → 1.5.13

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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/lib/adapter.d.ts +3 -0
  3. package/lib/built/ai-trigger.d.ts +0 -2
  4. package/lib/built/ai-trigger.js +0 -1
  5. package/lib/built/inbound-pipeline.js +0 -1
  6. package/lib/built/inbound-runner.js +0 -2
  7. package/lib/built/login-assist.d.ts +22 -4
  8. package/lib/built/login-assist.js +71 -7
  9. package/lib/built/message-enrich.d.ts +0 -1
  10. package/lib/built/message-enrich.js +0 -1
  11. package/lib/built/rich-segments/markdown-to-text.js +2 -2
  12. package/lib/built/schema-endpoint-manager.js +6 -6
  13. package/lib/built/segment-contract/types.d.ts +4 -45
  14. package/lib/built/segment-contract/types.js +0 -4
  15. package/lib/built/user-interaction.d.ts +30 -0
  16. package/lib/built/user-interaction.js +248 -0
  17. package/lib/feature/handler.d.ts +1 -1
  18. package/lib/feature/handler.js +1 -1
  19. package/lib/im-scene.js +0 -2
  20. package/lib/index.d.ts +4 -3
  21. package/lib/index.js +3 -3
  22. package/lib/message.d.ts +0 -5
  23. package/lib/message.js +0 -13
  24. package/lib/plugin-runtime/im/im-runtime.d.ts +30 -8
  25. package/lib/plugin-runtime/im/im-runtime.js +471 -145
  26. package/lib/plugin-runtime/im/index.d.ts +2 -0
  27. package/lib/plugin-runtime/im/index.js +2 -0
  28. package/lib/plugin-runtime/im/login-assist-host.d.ts +6 -0
  29. package/lib/plugin-runtime/im/login-assist-host.js +6 -0
  30. package/lib/plugin-runtime/im/message-dispatcher.d.ts +2 -2
  31. package/lib/plugin-runtime/im/message-dispatcher.js +2 -2
  32. package/lib/plugin-runtime/im/outbound-segments.d.ts +4 -0
  33. package/lib/plugin-runtime/im/outbound-segments.js +26 -0
  34. package/lib/plugin-runtime/im/side-event-gateway.d.ts +13 -0
  35. package/lib/plugin-runtime/im/side-event-gateway.js +2 -0
  36. package/lib/plugin.d.ts +1 -0
  37. package/lib/{prompt.d.ts → schema-interaction.d.ts} +6 -6
  38. package/lib/{prompt.js → schema-interaction.js} +12 -12
  39. package/lib/side-event/base.d.ts +7 -0
  40. package/lib/side-event/dispatch.d.ts +15 -0
  41. package/lib/side-event/dispatch.js +140 -0
  42. package/lib/side-event/index.d.ts +1 -0
  43. package/lib/side-event/index.js +1 -0
  44. package/lib/side-event/normalize.d.ts +2 -0
  45. package/lib/side-event/normalize.js +4 -0
  46. package/lib/side-event/types.d.ts +4 -2
  47. package/lib/side-event/types.js +1 -1
  48. package/lib/system-event.d.ts +15 -0
  49. package/lib/system-event.js +7 -0
  50. package/lib/types.d.ts +0 -15
  51. package/package.json +11 -10
  52. package/lib/built/prepend-quote-context.d.ts +0 -34
  53. package/lib/built/prepend-quote-context.js +0 -130
  54. package/lib/message-quote.d.ts +0 -10
  55. package/lib/message-quote.js +0 -90
@@ -1,39 +1,97 @@
1
1
  import { createToken, generationAdmissionBinder, htmlRendererToken, } from '@zhin.js/plugin-runtime';
2
2
  import { createPermissionHost, permissionHostToken } from '@zhin.js/permission';
3
3
  import { MessageBus, messageBusToken } from './message-bus.js';
4
- import { adapterFeatureId, isAdapterIndex, endpointControlOf, resolveEndpointManagement, } from '@zhin.js/adapter';
5
- import { isDeliveryReceipt, } from '@zhin.js/im-contract';
4
+ import { adapterFeatureId, isAdapterIndex, resolveEndpointManagement, } from '@zhin.js/adapter';
5
+ import { MemoryConversationEventStore, conversationRefKey, messageRefKey, } from '@zhin.js/im-contract';
6
+ import { segmentsToPlainText } from '../../built/segment-contract/text.js';
6
7
  import { isMiddlewareIndex, middlewareFeatureId } from '@zhin.js/middleware';
7
8
  import { isHandlerIndex, handlerFeatureId } from '../../feature/handler.js';
8
9
  import { formatCompact, getLogger, truncatePreview } from '@zhin.js/logger';
9
10
  import { Message, createOutboundEnvelope, } from './contracts.js';
11
+ import { sideEventGatewayToken, } from './side-event-gateway.js';
12
+ import { loginAssistToken } from './login-assist-host.js';
13
+ import { LoginAssist } from '../../built/login-assist.js';
14
+ import { sideEventSendChannel } from '../../side-event/base.js';
10
15
  import { defaultCommandPrefixResolver, MessageDispatcher } from './message-dispatcher.js';
11
16
  import { OutboundRenderer } from './outbound-renderer.js';
12
- import { applyOutboundInteractivePolicy, normalizeOutboundPayload, resolveOutboundInteractivePolicy, resolveOutboundMediaPolicy, } from './outbound-segments.js';
17
+ import { applyOutboundInteractivePolicy, applyOutboundMarkdownPolicy, normalizeOutboundPayload, resolveOutboundInteractivePolicy, resolveOutboundMarkdownPolicy, resolveOutboundMediaPolicy, } from './outbound-segments.js';
13
18
  import { assertCanonicalSegments } from '../../built/segment-contract/assert.js';
14
19
  import { keyboardFallbackStore } from '../../built/interactive-segments/fallback-store.js';
20
+ import { assertUserInteractionRequest, parseUserInteractionAnswer, projectUserInteraction, renderUserInteraction, } from '../../built/user-interaction.js';
15
21
  import { findRuntimeInteractiveHandler, resolveRuntimeInteractivePayload, runtimeInteractiveConversationKey, } from './interactive.js';
16
22
  const logger = getLogger('im');
17
23
  export const messageGatewayToken = createToken('zhin.im.message-gateway');
18
24
  export const ingressRouteToken = createToken('zhin.im.ingress-route');
19
25
  export const messagePreviewLimit = 200;
26
+ class UserInteractionTimeoutError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = 'UserInteractionTimeoutError';
30
+ }
31
+ }
32
+ class UserInteractionSupersededError extends Error {
33
+ constructor() {
34
+ super('User interaction superseded');
35
+ this.name = 'UserInteractionSupersededError';
36
+ }
37
+ }
38
+ class UserInteractionDeliveryError extends Error {
39
+ constructor(receipt) {
40
+ super(`User interaction delivery failed: ${receipt.failure?.code ?? receipt.status}`);
41
+ this.name = 'UserInteractionDeliveryError';
42
+ }
43
+ }
20
44
  export class ImRuntime {
21
45
  #dispatcher;
22
46
  #renderer;
23
47
  #messageListeners = new Set();
24
48
  #interactiveHandlers = [];
25
- #promptClaims = new Map();
49
+ #interactionClaims = new Map();
26
50
  #snapshots;
27
51
  #inboundClaim;
28
52
  #enrichSender;
53
+ conversationEvents;
29
54
  constructor(options = {}) {
30
55
  this.#dispatcher = new MessageDispatcher(options.commandPrefix === undefined
31
56
  ? defaultCommandPrefixResolver
32
57
  : () => options.commandPrefix ?? '');
33
58
  this.#renderer = options.renderer ?? new OutboundRenderer();
59
+ this.conversationEvents = options.conversationEvents ?? new MemoryConversationEventStore();
34
60
  this.#inboundClaim = options.inboundClaim;
35
61
  this.#enrichSender = options.enrichSender;
36
62
  }
63
+ /** Process composition replaces the bootstrap memory store after required DB activation. */
64
+ replaceConversationEventStore(store) {
65
+ this.conversationEvents = store;
66
+ }
67
+ async resolveConversationReference(lease, reference, context) {
68
+ if (!this.#snapshots?.owns(lease) || !lease.active) {
69
+ return Object.freeze({ status: 'expired', code: 'generation_lease_expired' });
70
+ }
71
+ if (reference.kind === 'message') {
72
+ const local = await this.conversationEvents.getMessage(reference.message);
73
+ if (local)
74
+ return Object.freeze({ status: 'resolved', reference, value: local });
75
+ }
76
+ context.signal.throwIfAborted();
77
+ const conversation = reference.kind === 'message' ? reference.message.conversation : reference.conversation;
78
+ return requireAdapters(lease.value).resolveContent(conversation.endpoint.id, reference, context);
79
+ }
80
+ async readConversationContext(conversation, consumer, throughSequence, limit = 50, excludeMessageId) {
81
+ if (!Number.isSafeInteger(throughSequence) || throughSequence < 0) {
82
+ throw new TypeError('Conversation context throughSequence must be a non-negative integer');
83
+ }
84
+ const cursor = await this.conversationEvents.getCursor(consumer, conversation);
85
+ const events = await this.conversationEvents.listBetween(conversation, cursor, throughSequence, limit);
86
+ const blocks = aggregateConversationContext(events, excludeMessageId);
87
+ return Object.freeze({
88
+ blocks: Object.freeze(blocks),
89
+ cursor: Math.max(cursor, throughSequence),
90
+ });
91
+ }
92
+ async commitConversationContext(conversation, consumer, cursor) {
93
+ await this.conversationEvents.commitCursor(consumer, conversation, cursor);
94
+ }
37
95
  attach(snapshots) {
38
96
  if (this.#snapshots && this.#snapshots !== snapshots) {
39
97
  throw new Error('ImRuntime is already attached to another Root');
@@ -42,11 +100,36 @@ export class ImRuntime {
42
100
  }
43
101
  permissionHost = createPermissionHost();
44
102
  messageBus = new MessageBus();
103
+ loginAssist = new LoginAssist();
45
104
  install(resources) {
46
105
  resources.provide(messageGatewayToken, this);
106
+ resources.provide(sideEventGatewayToken, this.#sideEventGateway);
107
+ resources.provide(loginAssistToken, this.loginAssist);
47
108
  resources.provide(permissionHostToken, this.permissionHost);
48
109
  resources.provide(messageBusToken, this.messageBus);
49
110
  }
111
+ #sideEventGateway = (() => {
112
+ const self = this;
113
+ const gateway = {
114
+ receiveNotice: (notice) => self.receiveNotice(notice),
115
+ receiveRequest: (request) => self.receiveRequest(request),
116
+ receiveSystem: (event) => self.receiveSystem(event),
117
+ [generationAdmissionBinder](gate) {
118
+ return Object.freeze({
119
+ receiveNotice: async (notice) => {
120
+ await gate.enter(() => self.receiveNotice(notice));
121
+ },
122
+ receiveRequest: async (request) => {
123
+ await gate.enter(() => self.receiveRequest(request));
124
+ },
125
+ receiveSystem: async (event) => {
126
+ await gate.enter(() => self.receiveSystem(event));
127
+ },
128
+ });
129
+ },
130
+ };
131
+ return gateway;
132
+ })();
50
133
  [generationAdmissionBinder](gate) {
51
134
  const gateway = {
52
135
  receive: async (input) => gate.enter(() => this.#receive(input, gate))
@@ -74,18 +157,17 @@ export class ImRuntime {
74
157
  };
75
158
  }
76
159
  // ==========================================================================
77
- // Prompt claim — 命令对话式交互
160
+ // User interaction claim — 命令/Agent 对话式交互
78
161
  // ==========================================================================
79
- #promptConversationKey(message, subjectId = message.sender?.id ?? '') {
80
- const conv = message.conversation;
81
- return `${conv.endpoint.adapter}:${conv.endpoint.id}:${conv.kind}:${conv.id}:${subjectId}`;
162
+ #interactionConversationKey(message, subjectId = message.sender?.id ?? '') {
163
+ return `${conversationRefKey(message.conversation)}:${subjectId}`;
82
164
  }
83
- #resolvePromptClaim(message) {
84
- const key = this.#promptConversationKey(message);
85
- const claim = this.#promptClaims.get(key);
165
+ #resolveInteractionClaim(message) {
166
+ const key = this.#interactionConversationKey(message);
167
+ const claim = this.#interactionClaims.get(key);
86
168
  if (!claim)
87
169
  return false;
88
- claim.resolve(message.content);
170
+ claim.resolve(resolveRuntimeInteractivePayload(message) ?? message.content);
89
171
  return true;
90
172
  }
91
173
  #claimNextMessage(message, timeout, timeoutText, signal, subjectId) {
@@ -94,146 +176,126 @@ export class ImRuntime {
94
176
  reject(abortError(signal, timeoutText));
95
177
  return;
96
178
  }
97
- const key = this.#promptConversationKey(message, subjectId);
98
- const existing = this.#promptClaims.get(key);
179
+ const key = this.#interactionConversationKey(message, subjectId);
180
+ const existing = this.#interactionClaims.get(key);
99
181
  if (existing) {
100
182
  clearTimeout(existing.timer);
101
- existing.reject(new Error('Prompt superseded'));
183
+ existing.reject(new UserInteractionSupersededError());
102
184
  }
103
185
  const timer = setTimeout(() => {
104
- settle(undefined, new Error(timeoutText));
186
+ settle(undefined, new UserInteractionTimeoutError(timeoutText));
105
187
  }, timeout);
106
188
  const onAbort = () => settle(undefined, abortError(signal, timeoutText));
107
189
  signal?.addEventListener('abort', onAbort, { once: true });
108
190
  const settle = (value, error) => {
109
- if (this.#promptClaims.get(key) !== claim)
191
+ if (this.#interactionClaims.get(key) !== claim)
110
192
  return;
111
- this.#promptClaims.delete(key);
193
+ this.#interactionClaims.delete(key);
112
194
  clearTimeout(timer);
113
195
  signal?.removeEventListener('abort', onAbort);
114
196
  if (value !== undefined)
115
197
  resolve(value);
116
198
  else
117
- reject(error ?? new Error(timeoutText));
199
+ reject(error ?? new UserInteractionTimeoutError(timeoutText));
118
200
  };
119
201
  const claim = {
120
202
  resolve: (raw) => settle(raw),
121
203
  reject: (error) => settle(undefined, error),
122
204
  timer,
123
205
  };
124
- this.#promptClaims.set(key, claim);
206
+ this.#interactionClaims.set(key, claim);
125
207
  });
126
208
  }
127
- #buildCommandPrompt(message, subjectId) {
209
+ #buildUserInteraction(message, subjectId) {
128
210
  const DEFAULT_TIMEOUT = 3 * 60 * 1000;
129
211
  const DEFAULT_TIMEOUT_TEXT = '输入超时';
130
212
  const claim = (timeout, timeoutText, signal) => this.#claimNextMessage(message, timeout, timeoutText, signal, subjectId);
131
- const reply = (content) => message.$reply(content);
132
- return {
133
- async text(tips, options) {
134
- await reply(tips);
135
- try {
136
- return await claim(options?.timeout ?? DEFAULT_TIMEOUT, options?.timeoutText ?? DEFAULT_TIMEOUT_TEXT, options?.signal);
137
- }
138
- catch (e) {
139
- if (options?.default !== undefined)
140
- return options.default;
141
- await reply(e.message);
142
- throw e;
143
- }
144
- },
145
- async number(tips, options) {
146
- await reply(tips);
147
- try {
148
- const raw = await claim(options?.timeout ?? DEFAULT_TIMEOUT, options?.timeoutText ?? DEFAULT_TIMEOUT_TEXT, options?.signal);
149
- return +raw;
150
- }
151
- catch (e) {
152
- if (options?.default !== undefined)
153
- return options.default;
154
- await reply(e.message);
155
- throw e;
156
- }
157
- },
158
- async confirm(tips, options) {
159
- const condition = options?.condition ?? 'yes';
160
- await reply(`${tips}\n输入"${condition}"以确认`);
161
- try {
162
- const raw = await claim(options?.timeout ?? DEFAULT_TIMEOUT, options?.timeoutText ?? DEFAULT_TIMEOUT_TEXT, options?.signal);
163
- return raw === condition;
164
- }
165
- catch (e) {
166
- if (options?.default !== undefined)
167
- return options.default;
168
- await reply(e.message);
169
- throw e;
170
- }
171
- },
172
- async list(tips, options) {
173
- const separator = options?.separator ?? ',';
174
- await reply(`${tips}\n值之间使用"${separator}"分隔`);
213
+ const reply = async (view) => {
214
+ const receipt = await message.$reply(renderUserInteraction(view));
215
+ if (receipt.status !== 'sent')
216
+ throw new UserInteractionDeliveryError(receipt);
217
+ };
218
+ const ask = async (request, progress) => {
219
+ assertUserInteractionRequest(request);
220
+ const timeout = request.timeout ?? DEFAULT_TIMEOUT;
221
+ const timeoutText = request.timeoutText ?? DEFAULT_TIMEOUT_TEXT;
222
+ const deadline = Date.now() + timeout;
223
+ const view = projectUserInteraction(request, progress);
224
+ await reply(view);
225
+ while (true) {
175
226
  try {
176
- const raw = await claim(options?.timeout ?? DEFAULT_TIMEOUT, options?.timeoutText ?? DEFAULT_TIMEOUT_TEXT, options?.signal);
177
- const type = options?.type ?? 'text';
178
- return raw.split(separator).map((v) => {
179
- if (type === 'number')
180
- return +v;
181
- if (type === 'boolean')
182
- return v === 'true';
183
- return v;
227
+ const raw = await claim(Math.max(1, deadline - Date.now()), timeoutText, request.signal);
228
+ const result = parseUserInteractionAnswer(request, raw);
229
+ if (result.ok)
230
+ return result.value;
231
+ await reply({
232
+ ...view,
233
+ tip: [request.invalidText ?? result.message, view.tip].filter(Boolean).join('\n'),
184
234
  });
185
235
  }
186
- catch (e) {
187
- if (options?.default !== undefined)
188
- return options.default;
189
- await reply(e.message);
190
- throw e;
191
- }
192
- },
193
- async pick(tips, options) {
194
- const items = options.options.map((o, i) => `${i + 1}.${o.label}`);
195
- const separator = options.separator ?? ',';
196
- if (options.multiple)
197
- items.push(`多选请用"${separator}"分隔`);
198
- await reply(`${tips}\n${items.join('\n')}`);
199
- try {
200
- const raw = await claim(options.timeout ?? DEFAULT_TIMEOUT, options.timeoutText ?? DEFAULT_TIMEOUT_TEXT, options.signal);
201
- if (!options.multiple) {
202
- return options.options.find((_, i) => i + 1 === +raw)?.value;
236
+ catch (error) {
237
+ if (error instanceof UserInteractionTimeoutError && 'default' in request && request.default !== undefined) {
238
+ return request.default;
203
239
  }
204
- const indices = raw.split(separator).map(Number);
205
- return options.options
206
- .filter((_, i) => indices.includes(i + 1))
207
- .map((o) => o.value);
208
- }
209
- catch (e) {
210
- if (options.default !== undefined)
211
- return options.default;
212
- await reply(e.message);
213
- throw e;
240
+ if (error instanceof UserInteractionTimeoutError) {
241
+ await reply({ title: '交互已结束', description: error.message });
242
+ }
243
+ throw error;
214
244
  }
215
- },
245
+ }
246
+ };
247
+ const sequence = async (definition) => {
248
+ if (!definition.title.trim())
249
+ throw new TypeError('User interaction sequence title must not be empty');
250
+ const ids = new Set();
251
+ for (const step of definition.steps) {
252
+ if (!step.id.trim())
253
+ throw new TypeError('User interaction sequence step id must not be empty');
254
+ if (ids.has(step.id))
255
+ throw new TypeError(`Duplicate user interaction sequence step id: ${step.id}`);
256
+ ids.add(step.id);
257
+ }
258
+ const result = {};
259
+ for (let index = 0; index < definition.steps.length; index += 1) {
260
+ const step = definition.steps[index];
261
+ const request = {
262
+ ...step,
263
+ timeout: step.timeout ?? definition.timeout,
264
+ timeoutText: step.timeoutText ?? definition.timeoutText,
265
+ invalidText: step.invalidText ?? definition.invalidText,
266
+ signal: step.signal ?? definition.signal,
267
+ };
268
+ result[step.id] = await ask(request, {
269
+ title: definition.title,
270
+ description: definition.description,
271
+ tip: definition.tip,
272
+ index: index + 1,
273
+ total: definition.steps.length,
274
+ });
275
+ }
276
+ return Object.freeze(result);
216
277
  };
278
+ return Object.freeze({ ask, sequence });
217
279
  }
218
280
  /**
219
- * Bound Prompt for this conversation.
281
+ * User interaction bound to this conversation.
220
282
  * `bind.subjectId` waits for that user (e.g. master) instead of the message sender.
221
283
  */
222
- createPrompt(message, bind) {
284
+ createInteraction(message, bind) {
223
285
  const subjectId = bind?.subjectId?.trim();
224
286
  if (bind && !subjectId)
225
287
  return undefined;
226
288
  if (typeof message.$reply !== 'function' || !message.conversation)
227
289
  return undefined;
228
- return this.#buildCommandPrompt(message, subjectId);
290
+ return this.#buildUserInteraction(message, subjectId);
229
291
  }
230
- #createPromptForSource(source) {
292
+ #createInteractionForSource(source) {
231
293
  if (!source || typeof source !== 'object')
232
294
  return undefined;
233
295
  const msg = source;
234
296
  if (typeof msg.$reply !== 'function' || !msg.conversation)
235
297
  return undefined;
236
- return this.#buildCommandPrompt(msg);
298
+ return this.#buildUserInteraction(msg);
237
299
  }
238
300
  /**
239
301
  * 订阅消息事件(入站 dispatch 完成后 / 出站发送成功后回调)。
@@ -293,24 +355,51 @@ export class ImRuntime {
293
355
  },
294
356
  }, lease.value);
295
357
  }, enrichedSender, Object.freeze({ ...input.metadata }), input.segments ? Object.freeze([...input.segments]) : undefined, input.message, input.endpointId, input.mentioned, input.replyTo);
296
- await runHandlers(lease.value, 'message.receive', message);
358
+ let conversationSequence;
359
+ if (input.message?.id) {
360
+ const appended = await this.conversationEvents.append(Object.freeze({
361
+ eventId: `message:${messageRefKey(input.message)}`,
362
+ conversation,
363
+ timestamp: Date.now(),
364
+ type: 'message.created',
365
+ message: Object.freeze({
366
+ ref: input.message,
367
+ ...(enrichedSender ? { actor: Object.freeze({
368
+ id: enrichedSender.id,
369
+ ...(enrichedSender.name ? { displayName: enrichedSender.name } : {}),
370
+ }) } : {}),
371
+ segments: Object.freeze(input.segments?.length
372
+ ? [...input.segments]
373
+ : [{ type: 'text', data: { text: input.content } }]),
374
+ timestamp: Date.now(),
375
+ ...(input.replyTo ? { replyTo: Object.freeze({ conversation, id: input.replyTo.id }) } : {}),
376
+ }),
377
+ }));
378
+ conversationSequence = appended.sequence;
379
+ }
297
380
  let result = Object.freeze({ matched: false });
298
381
  const claimed = await this.#inboundClaim?.(message) === true;
299
382
  if (claimed) {
300
383
  result = Object.freeze({ matched: true, command: 'interaction', owner: requester });
301
384
  }
302
- else if (this.#resolvePromptClaim(message)) {
303
- result = Object.freeze({ matched: true, command: 'prompt', owner: requester });
385
+ else if (this.#resolveInteractionClaim(message)) {
386
+ result = Object.freeze({ matched: true, command: 'interaction', owner: requester });
304
387
  }
305
388
  else {
306
- const promptFactory = (source) => this.#createPromptForSource(source);
389
+ const interactionFactory = (source) => this.#createInteractionForSource(source);
307
390
  await runMiddleware(lease.value, message, async () => {
308
- result = await this.#dispatchInteractive(message, requester, admission)
309
- ?? await this.#dispatcher.dispatch(message, lease.value, promptFactory);
310
391
  const ingressRoute = resolveIngressRoute(lease.value);
392
+ const preRouted = await ingressRoute?.preRoute?.(message, lease, requester, conversationSequence) === true;
393
+ if (preRouted) {
394
+ result = Object.freeze({ matched: true, command: 'pre-route', owner: requester });
395
+ return;
396
+ }
397
+ await this.#runHandlers(lease.value, 'message.receive', [message]);
398
+ result = await this.#dispatchInteractive(message, requester, admission)
399
+ ?? await this.#dispatcher.dispatch(message, lease.value, interactionFactory);
311
400
  if (!result.matched && ingressRoute) {
312
401
  logger.debug(formatCompact({ op: 'unmatched', conv: formatConversationLog(conversation) }));
313
- const handled = await ingressRoute.route(message, lease, requester);
402
+ const handled = await ingressRoute.route(message, lease, requester, conversationSequence);
314
403
  if (handled) {
315
404
  result = Object.freeze({ matched: true, command: 'ai', owner: requester });
316
405
  }
@@ -348,12 +437,108 @@ export class ImRuntime {
348
437
  lease.release();
349
438
  }
350
439
  }
440
+ async receiveNotice(notice) {
441
+ const event = conversationEventFromNotice(notice);
442
+ if (event)
443
+ await this.conversationEvents.append(event);
444
+ await this.#receiveSideEvent('notice.receive', notice);
445
+ }
446
+ async receiveRequest(request) {
447
+ await this.#withRequestActionScope(request, async (scoped) => {
448
+ await this.#receiveSideEvent('request.receive', scoped);
449
+ });
450
+ }
451
+ async #withRequestActionScope(request, dispatch) {
452
+ let active = true;
453
+ const actions = new Set();
454
+ const run = (action) => {
455
+ if (!active)
456
+ throw new Error('Request action port expired with its generation operation');
457
+ const operation = Promise.resolve().then(action);
458
+ actions.add(operation);
459
+ void operation.then(() => actions.delete(operation), () => actions.delete(operation));
460
+ return operation;
461
+ };
462
+ const scoped = Object.assign(Object.create(Object.getPrototypeOf(request)), request, {
463
+ $approve: async (remark) => run(() => request.$approve(remark)),
464
+ $reject: async (reason) => run(() => request.$reject(reason)),
465
+ });
466
+ try {
467
+ await dispatch(scoped);
468
+ }
469
+ finally {
470
+ active = false;
471
+ await Promise.allSettled([...actions]);
472
+ }
473
+ }
474
+ async receiveSystem(event) {
475
+ await this.#receiveSideEvent('system.receive', event);
476
+ }
477
+ async #receiveSideEvent(event, payload) {
478
+ const lease = this.#acquire();
479
+ try {
480
+ await this.#runHandlers(lease.value, event, [payload]);
481
+ }
482
+ finally {
483
+ lease.release();
484
+ }
485
+ }
486
+ /**
487
+ * User interaction bound to a side-event scene (private/group channel derived from
488
+ * `$scene_type` / `$scene_id`). Returns undefined when outbound is unavailable.
489
+ */
490
+ #createInteractionForSideEvent(payload, snapshot) {
491
+ const adapter = String(payload.$adapter);
492
+ const endpointKey = String(payload.$endpoint);
493
+ if (!adapter || !endpointKey)
494
+ return undefined;
495
+ const channel = sideEventSendChannel(payload);
496
+ const conversation = Object.freeze({
497
+ endpoint: Object.freeze({ adapter, id: endpointKey }),
498
+ kind: channel.type,
499
+ id: channel.id || endpointKey,
500
+ });
501
+ const requester = snapshot.root;
502
+ const source = Object.freeze({
503
+ conversation,
504
+ ...(payload.$actor?.id
505
+ ? { sender: Object.freeze({ id: payload.$actor.id }) }
506
+ : {}),
507
+ $reply: (content) => this.#sendWithSnapshot({
508
+ conversation,
509
+ requester,
510
+ content,
511
+ }, snapshot),
512
+ });
513
+ return this.#buildUserInteraction(source);
514
+ }
515
+ async #runHandlers(snapshot, event, args) {
516
+ const index = handlers(snapshot);
517
+ if (!index)
518
+ return;
519
+ const options = {
520
+ resolveInteraction: (name, interactionArgs) => {
521
+ const payload = interactionArgs[0];
522
+ if (name === 'message.receive' && payload instanceof Message) {
523
+ return this.createInteraction(payload);
524
+ }
525
+ if (name === 'notice.receive'
526
+ || name === 'request.receive'
527
+ || name === 'system.receive') {
528
+ return this.#createInteractionForSideEvent(payload, snapshot);
529
+ }
530
+ return undefined;
531
+ },
532
+ };
533
+ await index.dispatch(event, args, options);
534
+ }
351
535
  /** Console `endpoint.list` — empty until Adapter Feature projection is ready. */
352
536
  listEndpoints() {
353
537
  try {
354
538
  const lease = this.#acquire();
355
539
  try {
356
540
  return requireAdapters(lease.value).describe().map((row) => Object.freeze({
541
+ id: String(row.id),
357
542
  name: row.name,
358
543
  // adapter 列显示平台类型(owner 包名去 scope/adapter- 前缀),不是 slot localName
359
544
  adapter: adapterTypeName(lease.value.tree.get(row.owner)?.packageName) ?? row.name,
@@ -361,6 +546,7 @@ export class ImRuntime {
361
546
  connected: row.connected,
362
547
  status: row.status,
363
548
  phase: row.phase,
549
+ operations: row.operations,
364
550
  managementCapabilities: row.managementCapabilities,
365
551
  }));
366
552
  }
@@ -421,6 +607,7 @@ export class ImRuntime {
421
607
  connected: row.connected,
422
608
  status: row.status,
423
609
  phase: row.phase,
610
+ operations: row.operations,
424
611
  managementCapabilities: row.managementCapabilities,
425
612
  });
426
613
  }
@@ -450,7 +637,7 @@ export class ImRuntime {
450
637
  requester,
451
638
  content,
452
639
  }, lease.value);
453
- return { messageId: result.message?.id ?? result.legacyMessageId ?? '' };
640
+ return { messageId: result.message?.id ?? '' };
454
641
  }
455
642
  finally {
456
643
  lease.release();
@@ -458,25 +645,25 @@ export class ImRuntime {
458
645
  }
459
646
  /** Activity-feedback: add a message reaction when the live Endpoint supports it. */
460
647
  async addEndpointReaction(input) {
461
- return this.#withEndpointControl(input.adapter, input.endpointKey, (control) => control.addReaction?.(input.message, input.emoji, {
648
+ return this.#withEndpointControl(input.adapter, input.endpointKey, 'reaction', (control) => control.addReaction?.(input.message, input.emoji, {
462
649
  sceneType: input.sceneType,
463
650
  channelId: input.channelId,
464
651
  }) ?? null, null);
465
652
  }
466
653
  async removeEndpointReaction(input) {
467
- await this.#withEndpointControl(input.adapter, input.endpointKey, (control) => control.removeReaction?.(input.message, input.reactionId), undefined);
654
+ await this.#withEndpointControl(input.adapter, input.endpointKey, 'reaction', (control) => control.removeReaction?.(input.message, input.reactionId), undefined);
468
655
  }
469
656
  /** Activity-feedback autoRemove: recall a previously sent status message. */
470
657
  async recallEndpointMessage(input) {
471
- await this.#withEndpointControl(input.adapter, input.endpointKey, (control) => control.recall?.(input.message), undefined);
658
+ await this.#withEndpointControl(input.adapter, input.endpointKey, 'recall', (control) => control.recall?.(input.message), undefined);
472
659
  }
473
660
  async editEndpointMessage(input) {
474
- return this.#withEndpointControl(input.adapter, input.endpointKey, (control) => control.edit?.(input.message, input.content) ?? null, null);
661
+ return this.#withEndpointControl(input.adapter, input.endpointKey, 'edit', (control) => control.edit?.(input.message, input.content) ?? null, null);
475
662
  }
476
663
  async setEndpointTyping(input) {
477
- await this.#withEndpointControl(input.adapter, input.endpointKey, (control) => control.typing?.(input.conversation, input.active), undefined);
664
+ await this.#withEndpointControl(input.adapter, input.endpointKey, 'typing', (control) => control.typing?.(input.conversation, input.active), undefined);
478
665
  }
479
- async #withEndpointControl(adapter, endpointKey, run, fallback) {
666
+ async #withEndpointControl(adapter, endpointKey, operation, run, fallback) {
480
667
  let lease;
481
668
  try {
482
669
  lease = this.#acquire();
@@ -485,8 +672,9 @@ export class ImRuntime {
485
672
  return fallback;
486
673
  }
487
674
  try {
488
- const endpoint = requireAdapters(lease.value).instance(adapter, endpointKey);
489
- const control = endpointControlOf(endpoint);
675
+ const index = requireAdapters(lease.value);
676
+ const id = index.resolve(adapter, endpointKey);
677
+ const control = id ? index.control(id, operation) : undefined;
490
678
  return control ? await run(control) : fallback;
491
679
  }
492
680
  finally {
@@ -545,19 +733,33 @@ export class ImRuntime {
545
733
  conversation: request.conversation,
546
734
  payload,
547
735
  });
548
- receipt = receiptFromEndpointResult(result);
736
+ receipt = receiptFromEndpointResult(result, request.conversation);
549
737
  }
550
738
  catch (error) {
551
739
  receipt = receiptFromEndpointError(error);
552
740
  }
553
741
  if (receipt?.status === 'sent') {
742
+ if (receipt.message?.id) {
743
+ const segments = conversationSegmentsFromContent(request.content);
744
+ await this.conversationEvents.append(Object.freeze({
745
+ eventId: `message:${messageRefKey(receipt.message)}`,
746
+ conversation: request.conversation,
747
+ timestamp: Date.now(),
748
+ type: 'message.created',
749
+ message: Object.freeze({
750
+ ref: receipt.message,
751
+ segments,
752
+ timestamp: Date.now(),
753
+ }),
754
+ }));
755
+ }
554
756
  this.#emitMessage({
555
757
  direction: 'outbound',
556
758
  conversation: request.conversation,
557
759
  requester: request.requester,
558
760
  contentPreview: previewText(payload),
559
- ...(receipt.message?.id || receipt.legacyMessageId
560
- ? { messageId: receipt.message?.id ?? receipt.legacyMessageId }
761
+ ...(receipt.message?.id
762
+ ? { messageId: receipt.message.id }
561
763
  : {}),
562
764
  timestamp: Date.now(),
563
765
  });
@@ -608,6 +810,133 @@ export class ImRuntime {
608
810
  : undefined;
609
811
  }
610
812
  }
813
+ function conversationEventFromNotice(notice) {
814
+ const sceneType = notice.$scene_type;
815
+ const kind = sceneType === 'group' ? 'group'
816
+ : sceneType === 'channel' ? 'channel'
817
+ : sceneType === 'friend' || sceneType === 'private' ? 'private'
818
+ : undefined;
819
+ if (!kind || !notice.$scene_id || !notice.$id)
820
+ return undefined;
821
+ const conversation = Object.freeze({
822
+ endpoint: Object.freeze({ adapter: String(notice.$adapter), id: String(notice.$endpoint) }),
823
+ kind,
824
+ id: String(notice.$scene_id),
825
+ });
826
+ const actor = notice.$actor?.id
827
+ ? Object.freeze({ id: String(notice.$actor.id), ...(notice.$actor.name ? { displayName: notice.$actor.name } : {}) })
828
+ : undefined;
829
+ const target = notice.$target?.id
830
+ ? Object.freeze({ id: String(notice.$target.id), ...(notice.$target.name ? { displayName: notice.$target.name } : {}) })
831
+ : undefined;
832
+ const base = Object.freeze({
833
+ eventId: `notice:${conversationRefKey(conversation)}:${String(notice.$id)}`,
834
+ conversation,
835
+ timestamp: notice.$timestamp,
836
+ });
837
+ switch (notice.$sub_type) {
838
+ case 'member_increase':
839
+ case 'increase':
840
+ return target ? Object.freeze({ ...base, type: 'member.joined', member: target, ...(actor ? { actor } : {}) }) : undefined;
841
+ case 'member_decrease':
842
+ return target ? Object.freeze({ ...base, type: 'member.left', member: target, ...(actor ? { actor } : {}), reason: actor ? 'removed' : 'left' }) : undefined;
843
+ case 'ban':
844
+ if (!target)
845
+ return undefined;
846
+ return notice.$duration_seconds === 0
847
+ ? Object.freeze({ ...base, type: 'member.unmuted', member: target, ...(actor ? { actor } : {}) })
848
+ : Object.freeze({ ...base, type: 'member.muted', member: target, ...(actor ? { actor } : {}), durationSeconds: notice.$duration_seconds ?? 0 });
849
+ case 'admin_change':
850
+ return target && notice.$role && typeof notice.$enabled === 'boolean'
851
+ ? Object.freeze({ ...base, type: 'member.role_changed', member: target, ...(actor ? { actor } : {}), role: notice.$role, enabled: notice.$enabled })
852
+ : undefined;
853
+ case 'recall':
854
+ return notice.$message_id
855
+ ? Object.freeze({ ...base, type: 'message.recalled', message: Object.freeze({ conversation, id: notice.$message_id }), ...(actor ? { actor } : {}) })
856
+ : undefined;
857
+ case 'emoji_reaction':
858
+ return notice.$message_id && notice.$reaction && notice.$operation
859
+ ? Object.freeze({ ...base, type: 'message.reaction_changed', message: Object.freeze({ conversation, id: notice.$message_id }), ...(actor ? { actor } : {}), reaction: notice.$reaction, operation: notice.$operation })
860
+ : undefined;
861
+ case 'poke':
862
+ return Object.freeze({ ...base, type: 'conversation.poked', ...(actor ? { actor } : {}), ...(target ? { target } : {}) });
863
+ default:
864
+ return undefined;
865
+ }
866
+ }
867
+ function describeConversationEvent(event) {
868
+ const actor = 'actor' in event && event.actor ? `${event.actor.displayName ?? event.actor.id} (${event.actor.id})` : undefined;
869
+ switch (event.type) {
870
+ case 'message.recalled': return `${actor ?? 'Someone'} recalled message ${event.message.id}.`;
871
+ case 'message.reaction_changed': return `${actor ?? 'Someone'} ${event.operation} reaction ${event.reaction} on message ${event.message.id}.`;
872
+ case 'conversation.poked': return `${actor ?? 'Someone'} poked ${event.target ? `${event.target.displayName ?? event.target.id} (${event.target.id})` : 'someone'}.`;
873
+ case 'member.joined': return `${event.member.displayName ?? event.member.id} (${event.member.id}) joined the conversation.`;
874
+ case 'member.left': return `${event.member.displayName ?? event.member.id} (${event.member.id}) left the conversation (${event.reason ?? 'left'}).`;
875
+ case 'member.muted': return `${event.member.displayName ?? event.member.id} (${event.member.id}) was muted for ${event.durationSeconds} seconds${actor ? ` by ${actor}` : ''}.`;
876
+ case 'member.unmuted': return `${event.member.displayName ?? event.member.id} (${event.member.id}) was unmuted${actor ? ` by ${actor}` : ''}.`;
877
+ case 'member.role_changed': return `${event.member.displayName ?? event.member.id} (${event.member.id}) role ${event.role} was ${event.enabled ? 'enabled' : 'disabled'}${actor ? ` by ${actor}` : ''}.`;
878
+ case 'message.created': return '';
879
+ }
880
+ }
881
+ function describeConversationMessage(message) {
882
+ const actor = message.actor
883
+ ? `${message.actor.displayName ?? message.actor.id} (${message.actor.id})`
884
+ : undefined;
885
+ if (!actor)
886
+ return '';
887
+ const text = segmentsToPlainText(message.segments).trim();
888
+ const attachmentTypes = [...new Set(message.segments
889
+ .filter((segment) => segment.type !== 'text' && segment.type !== 'mention' && segment.type !== 'at')
890
+ .map((segment) => segment.type))];
891
+ const attachmentNote = attachmentTypes.length > 0
892
+ ? ` [message also contains ${attachmentTypes.join(', ')} data; inspect its reference before relying on that content]`
893
+ : '';
894
+ return `${actor}: ${text || '(no plain-text content)'}${attachmentNote}`;
895
+ }
896
+ function aggregateConversationContext(events, excludeMessageId) {
897
+ const ordinary = [];
898
+ const noisy = new Map();
899
+ for (const { sequence, event } of events) {
900
+ if (event.type === 'message.created') {
901
+ if (event.message.ref.id === excludeMessageId)
902
+ continue;
903
+ const text = describeConversationMessage(event.message);
904
+ if (!text)
905
+ continue;
906
+ ordinary.push(Object.freeze({
907
+ kind: 'conversation_event',
908
+ sequence,
909
+ eventType: event.type,
910
+ text,
911
+ }));
912
+ continue;
913
+ }
914
+ if (event.type === 'message.reaction_changed' || event.type === 'conversation.poked') {
915
+ const key = event.type === 'message.reaction_changed'
916
+ ? `${event.type}:${event.message.id}:${event.actor?.id ?? ''}:${event.reaction}:${event.operation}`
917
+ : `${event.type}:${event.actor?.id ?? ''}:${event.target?.id ?? ''}`;
918
+ const previous = noisy.get(key);
919
+ noisy.set(key, { sequence, event, count: (previous?.count ?? 0) + 1 });
920
+ continue;
921
+ }
922
+ ordinary.push(Object.freeze({
923
+ kind: 'conversation_event',
924
+ sequence,
925
+ eventType: event.type,
926
+ text: describeConversationEvent(event),
927
+ }));
928
+ }
929
+ for (const { sequence, event, count } of noisy.values()) {
930
+ ordinary.push(Object.freeze({
931
+ kind: 'conversation_event',
932
+ sequence,
933
+ eventType: event.type,
934
+ text: `${describeConversationEvent(event)}${count > 1 ? ` (${count} similar events.)` : ''}`,
935
+ }));
936
+ }
937
+ ordinary.sort((left, right) => left.sequence - right.sequence);
938
+ return Object.freeze(ordinary);
939
+ }
611
940
  function resolveIngressRoute(snapshot) {
612
941
  const candidate = snapshot.resources.get(snapshot.root)?.get(ingressRouteToken.id);
613
942
  return candidate
@@ -641,9 +970,10 @@ function isDirectHtmlConsumer(snapshot, adapter) {
641
970
  async function prepareOutboundPayload(rendered, conversation, snapshot, finalizeInteractive = false) {
642
971
  const adapter = conversation.endpoint.id;
643
972
  const directHtml = isDirectHtmlConsumer(snapshot, adapter);
973
+ const markdownResolved = applyOutboundMarkdownPolicy(rendered, resolveOutboundMarkdownPolicy(adapter, snapshot));
644
974
  let payload = directHtml
645
- ? rendered
646
- : await normalizeOutboundPayload(rendered, resolveHtmlRenderer(snapshot), {
975
+ ? markdownResolved
976
+ : await normalizeOutboundPayload(markdownResolved, resolveHtmlRenderer(snapshot), {
647
977
  mediaPolicy: resolveOutboundMediaPolicy(adapter, snapshot),
648
978
  });
649
979
  if (finalizeInteractive) {
@@ -653,23 +983,12 @@ async function prepareOutboundPayload(rendered, conversation, snapshot, finalize
653
983
  assertCanonicalSegments(payload);
654
984
  return payload;
655
985
  }
656
- function receiptFromEndpointResult(result) {
657
- if (isDeliveryReceipt(result))
658
- return result;
659
- const legacyMessageId = legacyMessageIdOf(result);
986
+ function receiptFromEndpointResult(messageId, conversation) {
660
987
  return Object.freeze({
661
988
  status: 'sent',
662
- ...(legacyMessageId ? { legacyMessageId } : {}),
989
+ message: Object.freeze({ conversation, id: messageId }),
663
990
  });
664
991
  }
665
- function legacyMessageIdOf(result) {
666
- if (typeof result === 'string' || typeof result === 'number')
667
- return String(result);
668
- if (!result || typeof result !== 'object')
669
- return undefined;
670
- const id = result.id;
671
- return typeof id === 'string' || typeof id === 'number' ? String(id) : undefined;
672
- }
673
992
  function receiptFromEndpointError(error) {
674
993
  const message = error instanceof Error ? error.message : String(error);
675
994
  if (/Unknown Adapter Endpoint|does not support outbound/u.test(message)) {
@@ -725,11 +1044,6 @@ function handlers(snapshot) {
725
1044
  const projection = snapshot.projections.get(handlerFeatureId);
726
1045
  return isHandlerIndex(projection) ? projection : undefined;
727
1046
  }
728
- async function runHandlers(snapshot, event, ...args) {
729
- const index = handlers(snapshot);
730
- if (index)
731
- await index.dispatch(event, ...args);
732
- }
733
1047
  function normalizeConsoleContent(content) {
734
1048
  if (typeof content === 'string')
735
1049
  return content;
@@ -782,6 +1096,18 @@ function flattenContent(content) {
782
1096
  }
783
1097
  return String(content);
784
1098
  }
1099
+ function conversationSegmentsFromContent(content) {
1100
+ if (Array.isArray(content)) {
1101
+ try {
1102
+ assertCanonicalSegments(content);
1103
+ return Object.freeze([...content]);
1104
+ }
1105
+ catch {
1106
+ // Fall through to a truthful text projection of non-canonical output.
1107
+ }
1108
+ }
1109
+ return Object.freeze([{ type: 'text', data: Object.freeze({ text: flattenContent(content) }) }]);
1110
+ }
785
1111
  function abortError(signal, fallback) {
786
1112
  if (signal?.reason instanceof Error)
787
1113
  return signal.reason;