@kin-tio/cli 0.6.0

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 (66) hide show
  1. package/.env.example +46 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +202 -0
  4. package/README.md +150 -0
  5. package/README.zh-CN.md +79 -0
  6. package/THIRD_PARTY_NOTICES +31 -0
  7. package/assets/ilink-login-card.png +0 -0
  8. package/bin/kintio.js +3 -0
  9. package/codex-workspace/.agents/skills/wechat-kf-reply-sop/SKILL.md +58 -0
  10. package/dist/cli.js +3 -0
  11. package/dist/daemon.js +28 -0
  12. package/dist/index.js +70 -0
  13. package/dist/mcp-relay.js +11 -0
  14. package/dist/src/agent/runtime.js +1 -0
  15. package/dist/src/app.js +34 -0
  16. package/dist/src/cli.js +578 -0
  17. package/dist/src/config.js +237 -0
  18. package/dist/src/domain/message.js +23 -0
  19. package/dist/src/domain/send-contract.js +205 -0
  20. package/dist/src/domain/wecom-message.js +281 -0
  21. package/dist/src/ilink/executor.js +306 -0
  22. package/dist/src/ilink/inbound-image.js +310 -0
  23. package/dist/src/ilink/listener.js +306 -0
  24. package/dist/src/ilink/login-manager.js +198 -0
  25. package/dist/src/ilink/login-store.js +197 -0
  26. package/dist/src/ilink/media-gateway.js +83 -0
  27. package/dist/src/ilink/media.js +267 -0
  28. package/dist/src/ilink/message.js +247 -0
  29. package/dist/src/ilink/protocol/client.js +464 -0
  30. package/dist/src/ilink/protocol/types.js +35 -0
  31. package/dist/src/ilink/qr.js +109 -0
  32. package/dist/src/ilink/secret-box.js +143 -0
  33. package/dist/src/ilink/sqlite-store.js +1194 -0
  34. package/dist/src/ilink/store-types.js +63 -0
  35. package/dist/src/lib/image-format.js +23 -0
  36. package/dist/src/lib/path-identity.js +38 -0
  37. package/dist/src/lib/private-directory.js +51 -0
  38. package/dist/src/lib/text.js +19 -0
  39. package/dist/src/lib/wecom-crypto.js +74 -0
  40. package/dist/src/lib/xml.js +8 -0
  41. package/dist/src/mcp/conversation-memory-server.js +179 -0
  42. package/dist/src/mcp/ilink-server.js +158 -0
  43. package/dist/src/mcp/ipc-host.js +275 -0
  44. package/dist/src/mcp/ipc-protocol.js +226 -0
  45. package/dist/src/mcp/stdio-relay.js +122 -0
  46. package/dist/src/mcp/wechat-kf-executor.js +295 -0
  47. package/dist/src/mcp/wechat-kf-server.js +208 -0
  48. package/dist/src/routes/wecom.js +89 -0
  49. package/dist/src/runtime/daemon-protocol.js +202 -0
  50. package/dist/src/runtime/managed-skill.js +49 -0
  51. package/dist/src/runtime/native-daemon.js +325 -0
  52. package/dist/src/runtime/single-instance-lock.js +167 -0
  53. package/dist/src/runtime.js +503 -0
  54. package/dist/src/services/codex-agent.js +542 -0
  55. package/dist/src/services/codex-app-server.js +436 -0
  56. package/dist/src/services/conversation-processor.js +762 -0
  57. package/dist/src/services/image-stager.js +49 -0
  58. package/dist/src/services/media-gateway.js +83 -0
  59. package/dist/src/services/wecom-api.js +311 -0
  60. package/dist/src/services/wecom-sync.js +316 -0
  61. package/dist/src/state/persistence.js +124 -0
  62. package/dist/src/state/sqlite-store.js +3102 -0
  63. package/dist/src/supervisor.js +212 -0
  64. package/dist/src/types.js +1 -0
  65. package/dist/src/version.js +1 -0
  66. package/package.json +72 -0
@@ -0,0 +1,762 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { COMMON_MESSAGE_TYPES, MESSAGE_ORIGINS, isProcessableCustomerMessage, isSystemEvent, renderMessageForAgent, } from '../domain/message.js';
3
+ function errorMessage(error) {
4
+ return error instanceof Error ? error.message : String(error);
5
+ }
6
+ function messageFromRecord(record) {
7
+ const payload = (record.payload || {});
8
+ if ((payload.providerMessageId &&
9
+ payload.providerMessageId !== record.providerMessageId) ||
10
+ (payload.conversation?.channel &&
11
+ payload.conversation.channel !== record.channel) ||
12
+ (payload.conversation?.accountKey &&
13
+ payload.conversation.accountKey !== record.accountKey) ||
14
+ (payload.conversation?.peerId &&
15
+ payload.conversation.peerId !== record.peerId)) {
16
+ throw new Error(`Inbound payload identity mismatch: ${record.messageKey}`);
17
+ }
18
+ return Object.freeze({
19
+ providerMessageId: record.providerMessageId,
20
+ messageKey: record.messageKey,
21
+ origin: record.origin,
22
+ type: record.type,
23
+ rawType: payload.rawType || record.type,
24
+ sentAt: record.sentAt,
25
+ sync: payload.sync || { cursor: '', index: 0 },
26
+ conversation: {
27
+ channel: record.channel,
28
+ accountKey: record.accountKey,
29
+ peerId: record.peerId,
30
+ },
31
+ text: payload.text || '',
32
+ summary: payload.summary || payload.text || '[Channel message: no readable summary]',
33
+ attributes: payload.attributes || {},
34
+ attachments: payload.attachments || [],
35
+ });
36
+ }
37
+ function agentMessage(message) {
38
+ return {
39
+ messageKey: message.messageKey,
40
+ text: message.text,
41
+ summary: message.summary,
42
+ };
43
+ }
44
+ function conversationId(record) {
45
+ return `cv_${createHash('sha256')
46
+ .update(`${record.channel}\0${record.accountKey}\0${record.peerId}`)
47
+ .digest('hex').slice(0, 32)}`;
48
+ }
49
+ export class ConversationProcessor {
50
+ #store;
51
+ #pipeline;
52
+ #allowedUsers;
53
+ #authorization;
54
+ #logger;
55
+ #queues = new Map();
56
+ #recoveries = new Map();
57
+ #background = new Set();
58
+ #onlineRetries = new Map();
59
+ #activeConversations = new Map();
60
+ #highWaiters = [];
61
+ #lowWaiters = [];
62
+ #queueNotified = new Set();
63
+ #preempting = new Set();
64
+ #maxConcurrentConversations;
65
+ #accepting = true;
66
+ constructor(options) {
67
+ this.#store = options.store;
68
+ this.#pipeline = options;
69
+ this.#allowedUsers = new Set(options.allowedUserIds || []);
70
+ this.#authorization = {
71
+ trigger: options.authorization?.trigger || '',
72
+ requiredConsecutive: Math.max(1, Number(options.authorization?.requiredConsecutive) || 3),
73
+ confirmationText: options.authorization?.confirmationText ||
74
+ 'Code accepted. You can continue the conversation.',
75
+ };
76
+ this.#logger = options.logger || console;
77
+ this.#maxConcurrentConversations = Math.max(1, Math.min(Number(options.maxConcurrentConversations) || 10, 10));
78
+ }
79
+ #message(record) {
80
+ try {
81
+ return messageFromRecord(record);
82
+ }
83
+ catch (error) {
84
+ const current = this.#store.getInbound(record.messageKey);
85
+ if (current?.status === 'received') {
86
+ this.#store.markInboundIgnored(record.messageKey);
87
+ }
88
+ else {
89
+ this.#store.suppressInbound(record.messageKey, 'invalid_persisted_identity');
90
+ }
91
+ this.#logger.error?.(`[processor] rejected inbound identity mismatch message_key=${record.messageKey}: ${errorMessage(error)}`);
92
+ return undefined;
93
+ }
94
+ }
95
+ #mediaCatalog(record) {
96
+ return this.#store.listRecentMedia({
97
+ channel: record.channel,
98
+ accountKey: record.accountKey,
99
+ peerId: record.peerId,
100
+ limit: 10,
101
+ }).map(({ ref, kind, messageKey }) => ({ ref, kind, messageKey }));
102
+ }
103
+ #conversationKey(record) {
104
+ return `${record.channel}\0${record.accountKey}\0${record.peerId}`;
105
+ }
106
+ #notifyQueued(record) {
107
+ const key = this.#conversationKey(record);
108
+ if (this.#queueNotified.has(key))
109
+ return;
110
+ this.#queueNotified.add(key);
111
+ if (record.channel === 'weixin_ilink') {
112
+ void this.#pipeline.channel.notifyQueued?.(record).catch((error) => {
113
+ this.#logger.error?.(`[ilink] queue notice failed message_key=${record.messageKey}: ${errorMessage(error)}`);
114
+ });
115
+ return;
116
+ }
117
+ try {
118
+ this.#store.reserveQueueNotice(record.messageKey);
119
+ void this.#pipeline.channel.kick(record.channel);
120
+ }
121
+ catch (error) {
122
+ this.#logger.error?.(`[processor] queue notice failed message_key=${record.messageKey}: ${errorMessage(error)}`);
123
+ }
124
+ }
125
+ #wakeWaiters() {
126
+ while (this.#highWaiters.length &&
127
+ this.#activeConversations.size < this.#maxConcurrentConversations &&
128
+ ![...this.#activeConversations.values()].some(({ priority }) => priority === 'low')) {
129
+ const waiter = this.#highWaiters.shift();
130
+ this.#activeConversations.set(waiter.key, {
131
+ record: waiter.record,
132
+ priority: waiter.priority,
133
+ });
134
+ waiter.resolve();
135
+ }
136
+ if (this.#activeConversations.size === 0 &&
137
+ this.#highWaiters.length === 0 &&
138
+ this.#lowWaiters.length) {
139
+ const waiter = this.#lowWaiters.shift();
140
+ this.#activeConversations.set(waiter.key, {
141
+ record: waiter.record,
142
+ priority: waiter.priority,
143
+ });
144
+ waiter.resolve();
145
+ }
146
+ }
147
+ async #preemptLow(exceptKey) {
148
+ const entry = [...this.#activeConversations.entries()].find(([key, active]) => key !== exceptKey && active.priority === 'low');
149
+ if (!entry || !this.#pipeline.agent.interrupt)
150
+ return;
151
+ const [, active] = entry;
152
+ const opaqueId = conversationId(active.record);
153
+ const primary = this.#pipeline.agent.activePrimary(opaqueId);
154
+ if (!primary || this.#store.listMessageAttempts(primary).length)
155
+ return;
156
+ this.#preempting.add(primary);
157
+ try {
158
+ if (!await this.#pipeline.agent.interrupt(opaqueId)) {
159
+ this.#preempting.delete(primary);
160
+ }
161
+ }
162
+ catch (error) {
163
+ this.#preempting.delete(primary);
164
+ this.#logger.error?.(`[processor] backlog interrupt failed message_key=${primary}: ${errorMessage(error)}`);
165
+ }
166
+ }
167
+ #acquire(record, priority) {
168
+ const key = this.#conversationKey(record);
169
+ if (this.#activeConversations.has(key))
170
+ return Promise.resolve();
171
+ const lowActive = [...this.#activeConversations.values()]
172
+ .some((active) => active.priority === 'low');
173
+ if (priority === 'high' &&
174
+ !lowActive &&
175
+ this.#activeConversations.size < this.#maxConcurrentConversations) {
176
+ this.#activeConversations.set(key, { record, priority });
177
+ return Promise.resolve();
178
+ }
179
+ if (priority === 'low' &&
180
+ this.#activeConversations.size === 0 &&
181
+ this.#highWaiters.length === 0) {
182
+ this.#activeConversations.set(key, { record, priority });
183
+ return Promise.resolve();
184
+ }
185
+ const waiting = new Promise((resolve) => {
186
+ const waiter = { key, record, priority, resolve };
187
+ (priority === 'high' ? this.#highWaiters : this.#lowWaiters).push(waiter);
188
+ });
189
+ if (priority === 'high') {
190
+ this.#notifyQueued(record);
191
+ if (lowActive)
192
+ void this.#preemptLow(key);
193
+ }
194
+ return waiting;
195
+ }
196
+ #release(record) {
197
+ const key = this.#conversationKey(record);
198
+ this.#activeConversations.delete(key);
199
+ this.#queueNotified.delete(key);
200
+ this.#wakeWaiters();
201
+ }
202
+ #releaseIfInactive(record) {
203
+ if (!this.#pipeline.agent.activePrimary(conversationId(record))) {
204
+ this.#release(record);
205
+ }
206
+ }
207
+ enqueue(messageKey) {
208
+ if (!this.#accepting)
209
+ return Promise.resolve();
210
+ const record = this.#store.getInbound(messageKey);
211
+ if (!record)
212
+ return Promise.resolve();
213
+ const key = this.#conversationKey(record);
214
+ const task = (this.#queues.get(key) || this.#recoveries.get(key) || Promise.resolve())
215
+ .catch(() => undefined)
216
+ .then(() => this.#processRecoverably(record.messageKey))
217
+ .catch((error) => {
218
+ this.#releaseIfInactive(record);
219
+ this.#logger.error?.(`[processor] inbound processing failed message_key=${messageKey}: ${errorMessage(error)}`);
220
+ });
221
+ this.#queues.set(key, task);
222
+ void task.finally(() => {
223
+ if (this.#queues.get(key) === task)
224
+ this.#queues.delete(key);
225
+ });
226
+ return task;
227
+ }
228
+ async #processRecoverably(messageKey) {
229
+ let lastError;
230
+ for (let attempt = 0; attempt < 3; attempt += 1) {
231
+ try {
232
+ let record = this.#store.getInbound(messageKey);
233
+ if (!record)
234
+ return;
235
+ if (record.status === 'received') {
236
+ await this.#process(messageKey);
237
+ return;
238
+ }
239
+ if (record.status === 'failed') {
240
+ record = this.#store.claimInbound({
241
+ messageKey,
242
+ clientInputId: record.clientInputId || messageKey,
243
+ });
244
+ }
245
+ if (!['processing', 'preparing'].includes(record.status))
246
+ return;
247
+ const group = this.#store.listPendingInbound({
248
+ statuses: ['received', 'processing', 'preparing', 'steering', 'steered'],
249
+ channel: record.channel,
250
+ accountKey: record.accountKey,
251
+ peerId: record.peerId,
252
+ limit: 1000,
253
+ }).filter((candidate) => candidate.messageKey === messageKey ||
254
+ candidate.primaryMessageKey === messageKey ||
255
+ (candidate.status === 'received' && candidate.inboxSeq > record.inboxSeq));
256
+ await this.#recoverConversation(group, 'high');
257
+ return;
258
+ }
259
+ catch (error) {
260
+ lastError = error;
261
+ if (attempt < 2) {
262
+ await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
263
+ }
264
+ }
265
+ }
266
+ throw lastError;
267
+ }
268
+ #track(task, record) {
269
+ const messageKey = record.messageKey;
270
+ const guarded = task.catch((error) => {
271
+ if (this.#preempting.delete(messageKey)) {
272
+ this.#store.closeAgentSessions(messageKey);
273
+ if (!this.#store.deferActiveInbound(messageKey)) {
274
+ this.#store.failInbound(messageKey, error);
275
+ }
276
+ this.#logger.info?.(`[processor] deferred backlog preempted message_key=${messageKey}`);
277
+ return;
278
+ }
279
+ const inbound = this.#store.getInbound(messageKey);
280
+ const superseded = inbound && this.#store.listPendingInbound({
281
+ statuses: ['received'],
282
+ channel: inbound.channel,
283
+ accountKey: inbound.accountKey,
284
+ peerId: inbound.peerId,
285
+ limit: 1000,
286
+ }).some((candidate) => candidate.inboxSeq > inbound.inboxSeq && candidate.origin === 'customer');
287
+ this.#store.closeAgentSessions(messageKey);
288
+ if (superseded) {
289
+ this.#store.suppressInbound(messageKey, 'superseded_by_arrived_followup');
290
+ }
291
+ else {
292
+ this.#store.failInbound(messageKey, error);
293
+ const retries = this.#onlineRetries.get(messageKey) || 0;
294
+ if (retries < 2) {
295
+ this.#onlineRetries.set(messageKey, retries + 1);
296
+ void this.enqueue(messageKey);
297
+ }
298
+ }
299
+ this.#logger.error?.(`[processor] Codex completion failed message_key=${messageKey}: ${errorMessage(error)}`);
300
+ }).finally(() => this.#release(record));
301
+ this.#background.add(guarded);
302
+ void guarded.finally(() => this.#background.delete(guarded));
303
+ return guarded;
304
+ }
305
+ async #submit(record, input, options = {}) {
306
+ const opaqueConversationId = conversationId(record);
307
+ const activePrimary = options.wait
308
+ ? undefined
309
+ : this.#pipeline.agent.activePrimary(opaqueConversationId);
310
+ if (activePrimary) {
311
+ this.#store.beginInboundSteering({
312
+ messageKey: record.messageKey,
313
+ primaryMessageKey: activePrimary,
314
+ clientInputId: record.messageKey,
315
+ });
316
+ const primary = this.#store.getInbound(activePrimary);
317
+ if (!primary)
318
+ throw new Error(`Missing active primary ${activePrimary}`);
319
+ const session = this.#store.createAgentSession({
320
+ messageKey: activePrimary,
321
+ boundaryMessageKey: record.messageKey,
322
+ });
323
+ const memoryThreadId = this.#store.getConversation(record.channel, record.accountKey, record.peerId)?.memoryThreadId || '';
324
+ try {
325
+ const submission = await this.#pipeline.agent.submit({
326
+ ...input,
327
+ channel: record.channel,
328
+ mode: 'steer',
329
+ conversationId: opaqueConversationId,
330
+ threadId: this.#store.getConversation(record.channel, record.accountKey, record.peerId)?.threadId || '',
331
+ ...(memoryThreadId ? { archivedThreadId: memoryThreadId } : {}),
332
+ toolSessionToken: session.token,
333
+ publishArtifact: async (artifact) => this.#store.registerAgentArtifact({
334
+ sessionToken: session.token,
335
+ bytes: artifact.bytes,
336
+ filename: artifact.filename,
337
+ contentType: artifact.contentType,
338
+ ...(artifact.metadata ? { metadata: artifact.metadata } : {}),
339
+ }),
340
+ });
341
+ if (submission.kind !== 'steered') {
342
+ throw new Error('Active Agent turn did not accept steering');
343
+ }
344
+ this.#store.confirmInboundSteered(record.messageKey, {
345
+ codexTurnId: submission.turnId,
346
+ });
347
+ return submission;
348
+ }
349
+ catch (error) {
350
+ this.#store.closeAgentSession(session.token);
351
+ this.#store.requeueInboundSteering(record.messageKey, activePrimary);
352
+ throw error;
353
+ }
354
+ }
355
+ await this.#acquire(record, options.priority || 'high');
356
+ this.#store.claimInbound({
357
+ messageKey: record.messageKey,
358
+ clientInputId: input.clientInputId || record.messageKey,
359
+ });
360
+ const boundaryMessageKey = options.boundaryMessageKey || record.messageKey;
361
+ const conversationBefore = this.#store.getConversation(record.channel, record.accountKey, record.peerId);
362
+ const ensuredThreadId = await this.#pipeline.agent.ensureThread(opaqueConversationId, conversationBefore?.threadId || '');
363
+ const pendingMemoryThreadId = this.#pipeline.agent.takePendingMemoryThread?.(opaqueConversationId) || '';
364
+ if (!conversationBefore || ensuredThreadId !== conversationBefore.threadId) {
365
+ this.#store.setConversationThread({
366
+ channel: record.channel,
367
+ accountKey: record.accountKey,
368
+ peerId: record.peerId,
369
+ threadId: ensuredThreadId,
370
+ memoryThreadId: pendingMemoryThreadId,
371
+ });
372
+ }
373
+ const memoryThreadId = this.#store.getConversation(record.channel, record.accountKey, record.peerId)?.memoryThreadId || '';
374
+ const session = this.#store.createAgentSession({
375
+ messageKey: record.messageKey,
376
+ boundaryMessageKey,
377
+ });
378
+ const artifactCatalog = (options.recoveredArtifacts || []).map((artifact) => ({
379
+ ref: this.#store.registerAgentArtifact({
380
+ sessionToken: session.token,
381
+ bytes: artifact.bytes,
382
+ filename: artifact.filename,
383
+ contentType: artifact.contentType,
384
+ ...(artifact.metadata ? { metadata: artifact.metadata } : {}),
385
+ }),
386
+ kind: 'image',
387
+ }));
388
+ let submission;
389
+ try {
390
+ submission = await this.#pipeline.agent.submit({
391
+ ...input,
392
+ channel: record.channel,
393
+ ...(artifactCatalog.length ? { artifactCatalog } : {}),
394
+ mode: 'start',
395
+ conversationId: opaqueConversationId,
396
+ threadId: ensuredThreadId,
397
+ ...(memoryThreadId ? { archivedThreadId: memoryThreadId } : {}),
398
+ toolSessionToken: session.token,
399
+ publishArtifact: async (artifact) => this.#store.registerAgentArtifact({
400
+ sessionToken: session.token,
401
+ bytes: artifact.bytes,
402
+ filename: artifact.filename,
403
+ contentType: artifact.contentType,
404
+ ...(artifact.metadata ? { metadata: artifact.metadata } : {}),
405
+ }),
406
+ });
407
+ }
408
+ catch (error) {
409
+ this.#store.closeAgentSession(session.token);
410
+ throw error;
411
+ }
412
+ if (submission.kind !== 'started') {
413
+ this.#store.closeAgentSession(session.token);
414
+ throw new Error('Agent start unexpectedly returned steering');
415
+ }
416
+ void submission.completion.catch(() => undefined);
417
+ this.#store.markInboundPreparing(record.messageKey, submission.turnId);
418
+ options.started?.(submission);
419
+ const completion = this.#track(submission.completion.then((result) => this.#complete(record, result)), record);
420
+ if (options.wait)
421
+ await completion;
422
+ return submission;
423
+ }
424
+ async #process(messageKey, priority = 'high', { wait = false, boundaryMessageKey, } = {}) {
425
+ const record = this.#store.getInbound(messageKey);
426
+ if (!record || record.status !== 'received')
427
+ return;
428
+ const message = this.#message(record);
429
+ if (!message)
430
+ return;
431
+ if (isSystemEvent(message)) {
432
+ this.#systemEvent(record, message);
433
+ return;
434
+ }
435
+ const { channel, accountKey, peerId } = message.conversation;
436
+ if (message.origin !== MESSAGE_ORIGINS.CUSTOMER || !peerId || !accountKey) {
437
+ this.#store.markInboundIgnored(messageKey);
438
+ return;
439
+ }
440
+ if (channel === 'wechat_kf' &&
441
+ !this.#allowedUsers.has(peerId) &&
442
+ this.#store.getAuthorization(peerId)?.authorized !== true) {
443
+ const isTrigger = Boolean(this.#authorization.trigger) &&
444
+ message.type === COMMON_MESSAGE_TYPES.TEXT &&
445
+ message.text === this.#authorization.trigger;
446
+ const result = this.#store.evaluateAuthorization({
447
+ messageKey,
448
+ accountKey,
449
+ peerId,
450
+ isTrigger,
451
+ requiredConsecutive: this.#authorization.requiredConsecutive,
452
+ confirmationText: this.#authorization.confirmationText,
453
+ });
454
+ if (result.decision !== 'already_authorized') {
455
+ if (result.decision === 'authorized_now') {
456
+ void this.#pipeline.channel.kick(channel);
457
+ }
458
+ return;
459
+ }
460
+ }
461
+ if (!isProcessableCustomerMessage(message)) {
462
+ this.#store.markInboundIgnored(messageKey);
463
+ return;
464
+ }
465
+ await this.#acquire(record, priority);
466
+ if (message.attachments.length) {
467
+ this.#store.rememberInboundMedia({
468
+ messageKey,
469
+ attachments: message.attachments,
470
+ sentAt: message.sentAt,
471
+ });
472
+ }
473
+ const mediaCatalog = this.#mediaCatalog(record);
474
+ const latestImage = this.#store.listRecentConversationAttempts({
475
+ channel,
476
+ accountKey,
477
+ peerId,
478
+ limit: 5,
479
+ }).find((attempt) => attempt.type === 'image' &&
480
+ attempt.metadata?.tool === 'generated_image' &&
481
+ ['accepted', 'uncertain'].includes(attempt.status));
482
+ await this.#submit(record, {
483
+ message: agentMessage(message),
484
+ resolvedMedia: await this.#pipeline.mediaGateway.resolveForCodex(message),
485
+ mediaCatalog,
486
+ contextText: renderMessageForAgent(message),
487
+ ...(latestImage
488
+ ? {
489
+ channelState: {
490
+ accepted: latestImage.status === 'accepted',
491
+ revisedPrompt: latestImage.metadata?.revisedPrompt,
492
+ customerObserved: /(?:(?:上一张|刚才|之前).{0,8}(?:图|图片|照片|结果)|(?:previous|last|earlier|just sent).{0,24}(?:image|photo|picture|result))/iu
493
+ .test(message.text),
494
+ },
495
+ }
496
+ : {}),
497
+ }, { priority, wait, ...(boundaryMessageKey ? { boundaryMessageKey } : {}) });
498
+ }
499
+ async #complete(record, result) {
500
+ const later = this.#store.listPendingInbound({
501
+ statuses: ['received'],
502
+ channel: record.channel,
503
+ accountKey: record.accountKey,
504
+ peerId: record.peerId,
505
+ limit: 1000,
506
+ }).filter((candidate) => candidate.inboxSeq > record.inboxSeq);
507
+ let customerFollowupArrived = false;
508
+ for (const candidate of later) {
509
+ const message = this.#message(candidate);
510
+ if (!message)
511
+ continue;
512
+ if (isSystemEvent(message)) {
513
+ this.#systemEvent(candidate, message);
514
+ }
515
+ else if (isProcessableCustomerMessage(message)) {
516
+ customerFollowupArrived = true;
517
+ }
518
+ else {
519
+ this.#store.markInboundIgnored(candidate.messageKey);
520
+ }
521
+ }
522
+ if (customerFollowupArrived) {
523
+ if (result.executedAttemptIds?.length) {
524
+ this.#finalizeAttempts(record, result.executedAttemptIds);
525
+ this.#onlineRetries.delete(record.messageKey);
526
+ return;
527
+ }
528
+ this.#store.suppressInbound(record.messageKey, 'superseded_by_arrived_followup');
529
+ this.#onlineRetries.delete(record.messageKey);
530
+ return;
531
+ }
532
+ if (result.executedAttemptIds?.length) {
533
+ this.#finalizeAttempts(record, result.executedAttemptIds);
534
+ this.#onlineRetries.delete(record.messageKey);
535
+ return;
536
+ }
537
+ if (result.decision === 'no_action') {
538
+ this.#finalizeAttempts(record, []);
539
+ this.#onlineRetries.delete(record.messageKey);
540
+ return;
541
+ }
542
+ throw new Error('Agent completed without an MCP execution');
543
+ }
544
+ #finalizeAttempts(record, attemptIds) {
545
+ const group = this.#store.listPendingInbound({
546
+ statuses: ['steering', 'steered'],
547
+ channel: record.channel,
548
+ accountKey: record.accountKey,
549
+ peerId: record.peerId,
550
+ limit: 100,
551
+ }).filter((item) => item.primaryMessageKey === record.messageKey);
552
+ if (group.some((item) => item.status === 'steering')) {
553
+ throw new Error('Cannot finalize while a steering RPC is unconfirmed');
554
+ }
555
+ const direction = Math.max(record.inboxSeq, ...group.map((item) => item.inboxSeq));
556
+ const durable = this.#store.listMessageAttempts(record.messageKey)
557
+ .filter((attempt) => attempt.source === 'mcp_tool');
558
+ const latest = attemptIds.length
559
+ ? attemptIds.map((attemptId) => this.#store.getAttempt(attemptId))
560
+ : durable.filter((attempt) => Number(attempt.metadata?.direction || 0) === direction &&
561
+ ['accepted', 'failed', 'uncertain'].includes(attempt.status));
562
+ if (!latest.length || latest.some((attempt) => !attempt || Number(attempt.metadata?.direction || 0) !== direction)) {
563
+ throw new Error('Agent completion has no MCP execution for the latest direction');
564
+ }
565
+ this.#store.finalizeAgentExecution({
566
+ messageKey: record.messageKey,
567
+ steeringMessageKeys: group.map((item) => item.messageKey),
568
+ attemptIds: durable.map((attempt) => attempt.attemptId),
569
+ });
570
+ }
571
+ #systemEvent(record, message) {
572
+ if (message.conversation.channel !== 'wechat_kf') {
573
+ this.#store.markInboundIgnored(record.messageKey);
574
+ return;
575
+ }
576
+ const event = message.attributes;
577
+ if (event.event_type === 'msg_send_fail') {
578
+ this.#store.markSendMsgFailed({
579
+ providerMessageId: String(event.fail_msgid || ''),
580
+ failType: Number(event.fail_type || 0),
581
+ });
582
+ this.#store.markInboundCompleted(record.messageKey);
583
+ return;
584
+ }
585
+ this.#store.markInboundIgnored(record.messageKey);
586
+ }
587
+ recover(records, { priority = 'high' } = {}) {
588
+ const conversations = new Map();
589
+ for (const record of [...records].sort((left, right) => left.inboxSeq - right.inboxSeq)) {
590
+ const key = this.#conversationKey(record);
591
+ const group = conversations.get(key) || [];
592
+ group.push(record);
593
+ conversations.set(key, group);
594
+ }
595
+ const tasks = [...conversations.entries()].map(([key, group]) => {
596
+ const task = this.#recoverConversation(group, priority).catch((error) => {
597
+ const first = group[0];
598
+ if (first)
599
+ this.#releaseIfInactive(first);
600
+ this.#logger.error?.(`[recovery] conversation recovery failed: ${errorMessage(error)}`);
601
+ });
602
+ this.#recoveries.set(key, task);
603
+ void task.finally(() => {
604
+ if (this.#recoveries.get(key) === task)
605
+ this.#recoveries.delete(key);
606
+ });
607
+ return task;
608
+ });
609
+ return Promise.all(tasks).then(() => {
610
+ void this.#pipeline.channel.kick();
611
+ });
612
+ }
613
+ async #recoverConversation(ordered, priority) {
614
+ for (const record of ordered.filter((item) => item.status === 'received')) {
615
+ const message = this.#message(record);
616
+ if (!message) {
617
+ record.status = this.#store.getInbound(record.messageKey)?.status || record.status;
618
+ continue;
619
+ }
620
+ if (isSystemEvent(message)) {
621
+ await this.#process(record.messageKey, priority);
622
+ record.status = this.#store.getInbound(record.messageKey)?.status || record.status;
623
+ }
624
+ }
625
+ const primaries = ordered.filter((record) => ['failed', 'processing', 'preparing'].includes(record.status) &&
626
+ !record.primaryMessageKey);
627
+ const recoveryBoundary = [...ordered].reverse().find((record) => {
628
+ if (['completed', 'ignored', 'absorbed', 'suppressed'].includes(record.status)) {
629
+ return false;
630
+ }
631
+ if (this.#message(record))
632
+ return true;
633
+ record.status = this.#store.getInbound(record.messageKey)?.status || record.status;
634
+ return false;
635
+ })?.messageKey;
636
+ for (const primary of primaries) {
637
+ const group = ordered.filter((record) => record.messageKey === primary.messageKey ||
638
+ record.primaryMessageKey === primary.messageKey);
639
+ await this.#recoverPrimary(primary, group, priority, recoveryBoundary);
640
+ }
641
+ for (const record of ordered) {
642
+ if (record.status === 'received') {
643
+ await this.#process(record.messageKey, priority, {
644
+ wait: true,
645
+ ...(recoveryBoundary ? { boundaryMessageKey: recoveryBoundary } : {}),
646
+ });
647
+ }
648
+ }
649
+ }
650
+ async #recoverPrimary(primary, group, priority, recoveryBoundary) {
651
+ if (primary.status === 'failed') {
652
+ primary = this.#store.claimInbound({
653
+ messageKey: primary.messageKey,
654
+ clientInputId: primary.clientInputId || primary.messageKey,
655
+ });
656
+ }
657
+ const decoded = group.flatMap((record) => {
658
+ const message = this.#message(record);
659
+ return message ? [{ record, message }] : [];
660
+ });
661
+ const primaryMessage = decoded.find(({ record }) => record.messageKey === primary.messageKey)?.message;
662
+ if (!primaryMessage)
663
+ return;
664
+ const validGroup = decoded.map(({ record }) => record);
665
+ const conversation = this.#store.getConversation(primary.channel, primary.accountKey, primary.peerId);
666
+ const mediaCatalog = this.#mediaCatalog(primary);
667
+ const ids = validGroup.map((record) => record.clientInputId || record.messageKey);
668
+ const latestId = ids.at(-1) || primary.clientInputId;
669
+ const steering = validGroup.filter((item) => item.status === 'steering');
670
+ const inspection = conversation?.threadId && this.#pipeline.agent.inspectHistory
671
+ ? await this.#pipeline.agent.inspectHistory(conversation.threadId, ids, latestId)
672
+ : undefined;
673
+ const missingInput = steering.some((record) => {
674
+ const clientId = record.clientInputId || record.messageKey;
675
+ if (!inspection?.foundClientInputIds.has(clientId))
676
+ return true;
677
+ this.#store.confirmInboundSteered(record.messageKey, {
678
+ codexTurnId: inspection.turnId || record.codexTurnId,
679
+ });
680
+ record.status = 'steered';
681
+ return false;
682
+ });
683
+ if (inspection?.state === 'completed' && !missingInput) {
684
+ const artifactAttempted = (inspection.executedAttemptIds || []).some((attemptId) => this.#store.getAttempt(attemptId)?.metadata?.tool === 'generated_image');
685
+ if (inspection.executedAttemptIds?.length &&
686
+ (!inspection.artifacts.length || artifactAttempted)) {
687
+ await this.#complete(primary, {
688
+ ...(inspection.executedAttemptIds
689
+ ? { executedAttemptIds: inspection.executedAttemptIds }
690
+ : {}),
691
+ });
692
+ return;
693
+ }
694
+ }
695
+ const latestDirection = Math.max(primary.inboxSeq, ...validGroup.map((record) => record.inboxSeq));
696
+ const attempts = this.#store.listMessageAttempts(primary.messageKey);
697
+ const artifactAlreadyHandled = attempts
698
+ .some((attempt) => attempt.metadata?.tool === 'generated_image' &&
699
+ Number(attempt.metadata.direction || 0) === latestDirection &&
700
+ ['accepted', 'uncertain'].includes(attempt.status));
701
+ const allowNoAction = attempts.some((attempt) => attempt.source === 'mcp_tool' &&
702
+ Number(attempt.metadata?.direction || 0) === latestDirection &&
703
+ ['accepted', 'failed', 'uncertain'].includes(attempt.status));
704
+ const recoveredArtifacts = inspection && !missingInput && !artifactAlreadyHandled
705
+ ? inspection.artifacts.filter((artifact) => artifact.type === 'generated_image' && Buffer.isBuffer(artifact.bytes))
706
+ : [];
707
+ const resolvedMedia = (await Promise.all(decoded.map(({ message }) => this.#pipeline.mediaGateway.resolveForCodex(message)))).flat();
708
+ const boundaryMessageKey = recoveryBoundary || validGroup.at(-1)?.messageKey || primary.messageKey;
709
+ await this.#submit(primary, {
710
+ message: agentMessage(primaryMessage),
711
+ resolvedMedia,
712
+ mediaCatalog,
713
+ contextText: [
714
+ 'The previous turn exited before delivery. Use the current thread and the persisted participant messages below to produce one current response:',
715
+ ...(recoveredArtifacts.length
716
+ ? ['The previous turn generated an image that is now available as a deliverable artifact. Do not generate it again.']
717
+ : []),
718
+ ...decoded.sort((left, right) => left.record.inboxSeq - right.record.inboxSeq)
719
+ .map(({ message }) => renderMessageForAgent(message)),
720
+ ].join('\n'),
721
+ allowNoAction,
722
+ clientInputId: `${primary.messageKey}-recovery`,
723
+ }, {
724
+ wait: true,
725
+ boundaryMessageKey,
726
+ ...(recoveredArtifacts.length
727
+ ? { recoveredArtifacts }
728
+ : {}),
729
+ started: (submission) => {
730
+ for (const record of steering) {
731
+ this.#store.confirmInboundSteered(record.messageKey, {
732
+ codexTurnId: submission.turnId,
733
+ });
734
+ }
735
+ },
736
+ priority,
737
+ });
738
+ }
739
+ async waitForIdle() {
740
+ while (this.#recoveries.size || this.#queues.size || this.#background.size ||
741
+ this.#activeConversations.size || this.#highWaiters.length ||
742
+ this.#lowWaiters.length) {
743
+ await Promise.allSettled([
744
+ ...this.#recoveries.values(),
745
+ ...this.#queues.values(),
746
+ ...this.#background,
747
+ ]);
748
+ }
749
+ }
750
+ stopAccepting() {
751
+ this.#accepting = false;
752
+ }
753
+ async close() {
754
+ this.stopAccepting();
755
+ await this.waitForIdle();
756
+ await this.#pipeline.agent.close();
757
+ }
758
+ async abort() {
759
+ this.stopAccepting();
760
+ await this.#pipeline.agent.abort();
761
+ }
762
+ }