@cogitator-ai/channels 0.15.12 → 0.17.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 (120) hide show
  1. package/README.md +129 -23
  2. package/dist/assistant-config.d.ts +78 -0
  3. package/dist/assistant-config.d.ts.map +1 -1
  4. package/dist/assistant-config.js +127 -0
  5. package/dist/assistant-config.js.map +1 -1
  6. package/dist/channels/bluesky/channel.d.ts.map +1 -1
  7. package/dist/channels/bluesky/channel.js +1 -0
  8. package/dist/channels/bluesky/channel.js.map +1 -1
  9. package/dist/channels/email/address.d.ts +44 -0
  10. package/dist/channels/email/address.d.ts.map +1 -0
  11. package/dist/channels/email/address.js +211 -0
  12. package/dist/channels/email/address.js.map +1 -0
  13. package/dist/channels/email/auth.d.ts +59 -0
  14. package/dist/channels/email/auth.d.ts.map +1 -0
  15. package/dist/channels/email/auth.js +212 -0
  16. package/dist/channels/email/auth.js.map +1 -0
  17. package/dist/channels/email/channel.d.ts +256 -0
  18. package/dist/channels/email/channel.d.ts.map +1 -0
  19. package/dist/channels/email/channel.js +701 -0
  20. package/dist/channels/email/channel.js.map +1 -0
  21. package/dist/channels/email/errors.d.ts +11 -0
  22. package/dist/channels/email/errors.d.ts.map +1 -0
  23. package/dist/channels/email/errors.js +13 -0
  24. package/dist/channels/email/errors.js.map +1 -0
  25. package/dist/channels/email/imap.d.ts +125 -0
  26. package/dist/channels/email/imap.d.ts.map +1 -0
  27. package/dist/channels/email/imap.js +309 -0
  28. package/dist/channels/email/imap.js.map +1 -0
  29. package/dist/channels/email/index.d.ts +7 -0
  30. package/dist/channels/email/index.d.ts.map +1 -0
  31. package/dist/channels/email/index.js +3 -0
  32. package/dist/channels/email/index.js.map +1 -0
  33. package/dist/channels/email/mime.d.ts +71 -0
  34. package/dist/channels/email/mime.d.ts.map +1 -0
  35. package/dist/channels/email/mime.js +83 -0
  36. package/dist/channels/email/mime.js.map +1 -0
  37. package/dist/channels/email/multipart.d.ts +28 -0
  38. package/dist/channels/email/multipart.d.ts.map +1 -0
  39. package/dist/channels/email/multipart.js +102 -0
  40. package/dist/channels/email/multipart.js.map +1 -0
  41. package/dist/channels/email/providers.d.ts +49 -0
  42. package/dist/channels/email/providers.d.ts.map +1 -0
  43. package/dist/channels/email/providers.js +292 -0
  44. package/dist/channels/email/providers.js.map +1 -0
  45. package/dist/channels/email/reply.d.ts +9 -0
  46. package/dist/channels/email/reply.d.ts.map +1 -0
  47. package/dist/channels/email/reply.js +122 -0
  48. package/dist/channels/email/reply.js.map +1 -0
  49. package/dist/channels/github/channel.d.ts +222 -0
  50. package/dist/channels/github/channel.d.ts.map +1 -0
  51. package/dist/channels/github/channel.js +736 -0
  52. package/dist/channels/github/channel.js.map +1 -0
  53. package/dist/channels/github/client.d.ts +45 -0
  54. package/dist/channels/github/client.d.ts.map +1 -0
  55. package/dist/channels/github/client.js +119 -0
  56. package/dist/channels/github/client.js.map +1 -0
  57. package/dist/channels/github/events.d.ts +49 -0
  58. package/dist/channels/github/events.d.ts.map +1 -0
  59. package/dist/channels/github/events.js +147 -0
  60. package/dist/channels/github/events.js.map +1 -0
  61. package/dist/channels/github/index.d.ts +4 -0
  62. package/dist/channels/github/index.d.ts.map +1 -0
  63. package/dist/channels/github/index.js +4 -0
  64. package/dist/channels/github/index.js.map +1 -0
  65. package/dist/channels/slack.d.ts.map +1 -1
  66. package/dist/channels/slack.js +2 -1
  67. package/dist/channels/slack.js.map +1 -1
  68. package/dist/channels/telegram.d.ts.map +1 -1
  69. package/dist/channels/telegram.js +14 -6
  70. package/dist/channels/telegram.js.map +1 -1
  71. package/dist/channels/threads/channel.d.ts.map +1 -1
  72. package/dist/channels/threads/channel.js +1 -0
  73. package/dist/channels/threads/channel.js.map +1 -1
  74. package/dist/channels/webchat.d.ts +8 -0
  75. package/dist/channels/webchat.d.ts.map +1 -1
  76. package/dist/channels/webchat.js +54 -15
  77. package/dist/channels/webchat.js.map +1 -1
  78. package/dist/channels/whatsapp.d.ts.map +1 -1
  79. package/dist/channels/whatsapp.js +13 -0
  80. package/dist/channels/whatsapp.js.map +1 -1
  81. package/dist/formatters/html.d.ts +16 -0
  82. package/dist/formatters/html.d.ts.map +1 -0
  83. package/dist/formatters/html.js +535 -0
  84. package/dist/formatters/html.js.map +1 -0
  85. package/dist/formatters/markdown.d.ts.map +1 -1
  86. package/dist/formatters/markdown.js +2 -0
  87. package/dist/formatters/markdown.js.map +1 -1
  88. package/dist/gateway.d.ts +91 -1
  89. package/dist/gateway.d.ts.map +1 -1
  90. package/dist/gateway.js +345 -46
  91. package/dist/gateway.js.map +1 -1
  92. package/dist/heartbeat.d.ts +5 -1
  93. package/dist/heartbeat.d.ts.map +1 -1
  94. package/dist/heartbeat.js +13 -3
  95. package/dist/heartbeat.js.map +1 -1
  96. package/dist/inbound-debounce.d.ts +4 -1
  97. package/dist/inbound-debounce.d.ts.map +1 -1
  98. package/dist/inbound-debounce.js +4 -2
  99. package/dist/inbound-debounce.js.map +1 -1
  100. package/dist/index.d.ts +8 -2
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +6 -2
  103. package/dist/index.js.map +1 -1
  104. package/dist/merge-messages.d.ts +3 -2
  105. package/dist/merge-messages.d.ts.map +1 -1
  106. package/dist/merge-messages.js +4 -2
  107. package/dist/merge-messages.js.map +1 -1
  108. package/dist/message-queue.d.ts +14 -4
  109. package/dist/message-queue.d.ts.map +1 -1
  110. package/dist/message-queue.js +36 -35
  111. package/dist/message-queue.js.map +1 -1
  112. package/dist/runtime-builder.d.ts +9 -0
  113. package/dist/runtime-builder.d.ts.map +1 -1
  114. package/dist/runtime-builder.js +149 -5
  115. package/dist/runtime-builder.js.map +1 -1
  116. package/dist/thread-key.d.ts +50 -0
  117. package/dist/thread-key.d.ts.map +1 -0
  118. package/dist/thread-key.js +82 -0
  119. package/dist/thread-key.js.map +1 -0
  120. package/package.json +26 -8
package/dist/gateway.js CHANGED
@@ -7,6 +7,7 @@ import { StatusReactionTracker } from './status-reactions.js';
7
7
  import { InboundDebouncer } from './inbound-debounce.js';
8
8
  import { formatEnvelope } from './envelope.js';
9
9
  import { MessageQueue } from './message-queue.js';
10
+ import { CONVERSATIONS_KEY, defaultThreadKey, findConversation, legacyThreadKey, readConversations, rememberConversation, } from './thread-key.js';
10
11
  import { DEFAULT_APPROVE_WORDS, DEFAULT_DENY_WORDS, DEFAULT_NOT_ALLOWED_MESSAGE, DEFAULT_EXPIRED_MESSAGE, APPROVE_ACTION, DENY_ACTION, formatApprovalPrompt, parseApprovalReply, } from './approvals.js';
11
12
  /** Markdown as the channel takes it: as written when it renders Markdown itself. */
12
13
  function adaptFor(channel, msg, text) {
@@ -16,6 +17,18 @@ function adaptFor(channel, msg, text) {
16
17
  function limitFor(channel, msg) {
17
18
  return channel.maxMessageChars ?? getPlatformLimit(msg.channelType);
18
19
  }
20
+ /**
21
+ * Lets the promise chains of what just settled run (the queue settles what it drops at once,
22
+ * and each answer settles a few promise steps later), without waiting for any timer.
23
+ */
24
+ async function settleMicrotasks() {
25
+ for (let i = 0; i < 20; i++)
26
+ await Promise.resolve();
27
+ }
28
+ /** The key of a message whose answer the gateway tracks: its channel, chat and id. */
29
+ function answerKey(msg) {
30
+ return `${msg.channelType}\u0000${msg.channelId}\u0000${msg.id}`;
31
+ }
19
32
  function draftKey(channelType, channelId, draftId) {
20
33
  return `${channelType}:${channelId}:${draftId}`;
21
34
  }
@@ -26,6 +39,34 @@ const DEFAULT_SUMMARY_PROMPT = 'Summarize the following conversation between a u
26
39
  'Preserve facts, decisions, user preferences, open tasks and any commitments. ' +
27
40
  'Write a concise summary in the language of the conversation.';
28
41
  const TYPING_INTERVAL_MS = 4000;
42
+ const DEFAULT_DRAIN_TIMEOUT_MS = 30_000;
43
+ /** Why {@link Gateway.stop} aborts the runs still going past `drainTimeout` */
44
+ const GATEWAY_STOPPED = new Error('The gateway stopped');
45
+ /** Threads kept in the gateway's own bookkeeping (stats, sessions, envelope times) */
46
+ export const MAX_TRACKED_THREADS = 10_000;
47
+ /** Sets `key` as the most recent entry of `map`, dropping the least recent one past the limit */
48
+ function touch(map, key, value) {
49
+ map.delete(key);
50
+ map.set(key, value);
51
+ if (map.size > MAX_TRACKED_THREADS) {
52
+ const oldest = map.keys().next();
53
+ if (!oldest.done)
54
+ map.delete(oldest.value);
55
+ }
56
+ }
57
+ /** Resolves `true` once `promise` settles, or `false` after `ms` */
58
+ async function settlesWithin(promise, ms) {
59
+ let timer;
60
+ const timeout = new Promise((resolve) => {
61
+ timer = setTimeout(() => resolve(false), ms);
62
+ });
63
+ try {
64
+ return await Promise.race([promise.then(() => true), timeout]);
65
+ }
66
+ finally {
67
+ clearTimeout(timer);
68
+ }
69
+ }
29
70
  function messageText(message) {
30
71
  if (typeof message.content === 'string')
31
72
  return message.content;
@@ -33,10 +74,6 @@ function messageText(message) {
33
74
  .map((part) => (part.type === 'text' ? part.text : `[${part.type}]`))
34
75
  .join(' ');
35
76
  }
36
- /** The thread of a user on a channel when `session.threadKey` does not name it */
37
- function defaultThreadId(channelType, userId) {
38
- return `${channelType}:${userId}`;
39
- }
40
77
  function isScheduled(msg) {
41
78
  const raw = msg.raw;
42
79
  return typeof raw === 'object' && raw !== null && 'scheduled' in raw && raw.scheduled === true;
@@ -63,6 +100,8 @@ export class Gateway {
63
100
  hooks;
64
101
  approvalWords;
65
102
  pausedThreads = new Map();
103
+ /** The stop of every run in progress */
104
+ runs = new Set();
66
105
  /** The stop of each run, by the signal the run was given */
67
106
  stops = new WeakMap();
68
107
  /** The stop of each run streaming into a draft with a stop button, by channel, chat and draft */
@@ -72,6 +111,24 @@ export class Gateway {
72
111
  lastMessageTime = new Map();
73
112
  threads = new Map();
74
113
  inFlight = new Map();
114
+ /** Messages taken and not answered yet, for {@link idle} */
115
+ pending = new Set();
116
+ /** The stop in progress, which a second {@link stop} call returns */
117
+ stopping = null;
118
+ /** Calls of `channel.redeliver` in progress */
119
+ redeliveries = new Set();
120
+ /**
121
+ * Messages taken from a channel that can redeliver them, by the promise of their answer, until
122
+ * it settles. Those still here once `stop()` gave up on the runs are handed back.
123
+ */
124
+ unanswered = new Map();
125
+ /**
126
+ * Messages whose answer is going out or went out, by `answerKey`, so a run aborted after its
127
+ * first message was sent (or while it is being sent) is not redelivered
128
+ */
129
+ answeredKeys = new Set();
130
+ /** The last pending write of each user's session, so writes to one session never interleave */
131
+ sessionWrites = new Map();
75
132
  running = false;
76
133
  startedAt = null;
77
134
  messageCount = 0;
@@ -88,15 +145,23 @@ export class Gateway {
88
145
  };
89
146
  this.sessionManager =
90
147
  config.sessionManager ?? (config.memory ? new SessionManager(config.memory) : null);
148
+ const queueMode = config.queueMode ?? 'sequential';
91
149
  this.messageQueue =
92
- config.queueMode && config.queueMode !== 'parallel'
93
- ? new MessageQueue(config.queueMode, (msg, signal) => this.processMessage(msg, signal))
94
- : null;
150
+ queueMode === 'parallel'
151
+ ? null
152
+ : new MessageQueue(queueMode, (msg, signal) => this.processMessage(msg, signal));
95
153
  this.debouncer = config.debounce?.enabled
96
- ? new InboundDebouncer(config.debounce, (merged) => this.dispatch(merged))
154
+ ? new InboundDebouncer(config.debounce, async (merged, originals) => {
155
+ const answered = this.dispatch(merged);
156
+ const channel = this.channels.find((ch) => ch.type === merged.channelType);
157
+ if (channel?.redeliver)
158
+ this.watchUnanswered(channel, merged, originals, answered);
159
+ else
160
+ answered.catch(() => { });
161
+ })
97
162
  : null;
98
163
  for (const channel of this.channels) {
99
- channel.onMessage((msg) => this.handleIncoming(msg));
164
+ channel.onMessage((msg) => this.handleIncoming(msg, false, channel));
100
165
  channel.onAction?.((action) => this.handleAction(channel, action));
101
166
  channel.onStop?.((stop) => {
102
167
  this.stoppableDrafts
@@ -119,13 +184,48 @@ export class Gateway {
119
184
  throw failure.reason instanceof Error ? failure.reason : new Error(String(failure.reason));
120
185
  }
121
186
  }
122
- async stop() {
187
+ /**
188
+ * Stops the gateway. Channels have already confirmed the messages they handed over, so it
189
+ * first lets them be answered: messages held by `debounce` are flushed, and it waits up to
190
+ * `drainTimeout` for the queues and the runs in progress. Then it aborts the runs still going,
191
+ * drops the messages still waiting, waits for the aborted runs to settle (up to `drainTimeout`
192
+ * again) and stops the channels. Messages that come in meanwhile are refused (see
193
+ * {@link injectMessage}). A second call while it stops returns the same promise.
194
+ */
195
+ stop() {
196
+ if (this.stopping)
197
+ return this.stopping;
123
198
  if (!this.running)
124
- return;
199
+ return Promise.resolve();
125
200
  this.running = false;
126
- if (this.debouncer)
201
+ const stopping = this.drainAndStop().finally(() => {
202
+ this.stopping = null;
203
+ });
204
+ this.stopping = stopping;
205
+ return stopping;
206
+ }
207
+ async drainAndStop() {
208
+ const timeout = this.config.drainTimeout ?? DEFAULT_DRAIN_TIMEOUT_MS;
209
+ if (this.debouncer) {
127
210
  await this.debouncer.flushAll();
211
+ this.debouncer.dispose();
212
+ }
213
+ if (!(await settlesWithin(this.idle(), timeout))) {
214
+ this.messageQueue?.dispose();
215
+ for (const run of this.runs)
216
+ run.abort(GATEWAY_STOPPED);
217
+ await settlesWithin(this.idle(), timeout);
218
+ }
128
219
  this.messageQueue?.dispose();
220
+ await settleMicrotasks();
221
+ for (const [answered, { channel, handled, messages }] of this.unanswered) {
222
+ this.unanswered.delete(answered);
223
+ if (this.answeredKeys.has(answerKey(handled)))
224
+ continue;
225
+ for (const msg of messages)
226
+ void this.redeliver(channel, msg);
227
+ }
228
+ await Promise.all([...this.redeliveries].map((redelivery) => settlesWithin(redelivery, timeout)));
129
229
  await Promise.all(this.channels.map((ch) => ch.stop()));
130
230
  this.startedAt = null;
131
231
  }
@@ -144,8 +244,26 @@ export class Gateway {
144
244
  .map(([threadId, info]) => ({ threadId, ...info, active: this.inFlight.has(threadId) }))
145
245
  .sort((a, b) => b.lastActiveAt - a.lastActiveAt);
146
246
  }
247
+ /**
248
+ * Feeds a message to the gateway as if a channel had received it (the scheduler sends its
249
+ * tasks this way). It resolves once the message is answered, or once it is buffered when
250
+ * `debounce` is on. It rejects when the message was not answered: its run failed (also
251
+ * reported to `onError`), it was aborted or dropped by {@link stop}, or it came in while the
252
+ * gateway was stopping.
253
+ */
147
254
  async injectMessage(msg) {
148
- return this.handleIncoming(msg);
255
+ return this.handleIncoming(msg, true);
256
+ }
257
+ /**
258
+ * Resolves once every message the gateway has taken is answered: those waiting in their
259
+ * thread's queue and the runs in progress, including messages that come in while it waits.
260
+ * Messages held by `debounce` count from the moment they are flushed. {@link stop} waits
261
+ * for it, up to `drainTimeout`.
262
+ */
263
+ async idle() {
264
+ while (this.pending.size > 0) {
265
+ await Promise.allSettled(this.pending);
266
+ }
149
267
  }
150
268
  /**
151
269
  * Summarize older messages of a conversation thread, keeping `keepRecent` messages intact.
@@ -196,16 +314,31 @@ export class Gateway {
196
314
  }
197
315
  /**
198
316
  * The threads {@link compactAll} goes through: those served since the gateway started, and the
199
- * default thread of each stored session when threads are not keyed by `session.threadKey`
317
+ * threads of each stored session when they are keyed by the default key (the conversations kept
318
+ * in the session's metadata, or the chat it was created in) or by `legacyThreadKey`
200
319
  */
201
320
  async knownThreads() {
202
321
  const threadIds = new Set(this.threads.keys());
203
- if (!this.sessionManager || this.config.session?.threadKey) {
322
+ const threadKey = this.config.session?.threadKey;
323
+ if (!this.sessionManager || (threadKey && threadKey !== legacyThreadKey)) {
204
324
  return { threadIds: [...threadIds] };
205
325
  }
206
326
  try {
207
327
  for (const session of await this.sessionManager.list()) {
208
- threadIds.add(defaultThreadId(session.channelType, session.userId));
328
+ if (threadKey) {
329
+ threadIds.add(legacyThreadKey(session));
330
+ continue;
331
+ }
332
+ const conversations = readConversations(session.metadata);
333
+ for (const conversation of conversations.length > 0
334
+ ? conversations
335
+ : [{ channelId: session.channelId }]) {
336
+ threadIds.add(defaultThreadKey({
337
+ channelType: session.channelType,
338
+ userId: session.userId,
339
+ ...conversation,
340
+ }));
341
+ }
209
342
  }
210
343
  }
211
344
  catch (error) {
@@ -272,6 +405,7 @@ export class Gateway {
272
405
  ...(action.userName ? { userName: action.userName } : {}),
273
406
  ...(action.channelId.startsWith('-') ? { groupId: action.channelId } : {}),
274
407
  ...(action.topicId ? { topicId: action.topicId } : {}),
408
+ ...(action.conversationId ? { conversationId: action.conversationId } : {}),
275
409
  text: (approve ? this.approvalWords.approveWords : this.approvalWords.denyWords)[0] ?? '',
276
410
  raw: action.raw,
277
411
  };
@@ -300,7 +434,7 @@ export class Gateway {
300
434
  }
301
435
  await answer();
302
436
  await this.closePrompts(prompt.threadId, approve);
303
- await this.handleIncoming(msg);
437
+ await this.handleIncoming(msg, false);
304
438
  }
305
439
  /**
306
440
  * Retires the buttons of a thread's approval prompts once it is answered: they show the
@@ -330,19 +464,118 @@ export class Gateway {
330
464
  .catch(() => { });
331
465
  }
332
466
  }
333
- async handleIncoming(msg) {
467
+ /**
468
+ * A message from a channel, a button or {@link injectMessage}. With a queue, a channel's message
469
+ * is only queued, so the channel goes on to its next update while the thread waits its turn.
470
+ * `untilAnswered` waits for the answer. A message of `channel` the gateway does not answer
471
+ * because it stops (refused while stopping, or dropped by `stop()`) goes back through
472
+ * `channel.redeliver`.
473
+ */
474
+ async handleIncoming(msg, untilAnswered, channel) {
475
+ if (this.stopping) {
476
+ const refused = new Error('The gateway is stopping: the message was dropped');
477
+ this.config.onError?.(refused, msg);
478
+ if (untilAnswered)
479
+ throw refused;
480
+ if (channel?.redeliver)
481
+ await this.redeliver(channel, msg);
482
+ return;
483
+ }
334
484
  if (this.debouncer) {
335
485
  this.debouncer.enqueue(msg);
336
486
  return;
337
487
  }
338
- await this.dispatch(msg);
339
- }
340
- async dispatch(msg) {
341
- if (this.messageQueue) {
342
- this.messageQueue.push(msg, this.getThreadId(msg));
488
+ const answered = this.dispatch(msg);
489
+ if (channel?.redeliver && !untilAnswered)
490
+ this.watchUnanswered(channel, msg, [msg], answered);
491
+ if (untilAnswered || !this.messageQueue) {
492
+ const done = await answered;
493
+ if (!done && untilAnswered) {
494
+ throw new Error('The message was not answered: its run failed or was aborted');
495
+ }
343
496
  return;
344
497
  }
345
- await this.processMessage(msg);
498
+ if (!channel?.redeliver)
499
+ answered.catch(() => { });
500
+ }
501
+ /** Hands a message back to its channel, reporting a failure to `onError`. */
502
+ redeliver(channel, msg) {
503
+ const redelivery = Promise.resolve()
504
+ .then(() => channel.redeliver?.(msg))
505
+ .catch((error) => this.config.onError?.(toError(error), msg))
506
+ .finally(() => this.redeliveries.delete(redelivery));
507
+ this.redeliveries.add(redelivery);
508
+ return redelivery;
509
+ }
510
+ /**
511
+ * Watches the answer of messages taken from `channel`: when the gateway stops before answering
512
+ * them they go back through `channel.redeliver`, and so do those whose run is still going when
513
+ * `stop()` gives up on it. A message whose answer went out is never handed back.
514
+ */
515
+ watchUnanswered(channel, handled, messages, answered) {
516
+ const entry = { channel, handled, messages };
517
+ this.unanswered.set(answered, entry);
518
+ void answered.then((done) => {
519
+ if (this.unanswered.get(answered) !== entry)
520
+ return;
521
+ this.unanswered.delete(answered);
522
+ if (done || this.running)
523
+ return;
524
+ for (const msg of messages)
525
+ void this.redeliver(channel, msg);
526
+ });
527
+ }
528
+ /** Puts a message through the queue of its thread, resolving once it is done with */
529
+ dispatch(msg) {
530
+ const answered = this.route(msg);
531
+ this.pending.add(answered);
532
+ const forget = () => {
533
+ this.pending.delete(answered);
534
+ };
535
+ answered.then(forget, forget);
536
+ return answered;
537
+ }
538
+ /** Resolves `true` once the message is handled, `false` when it failed or was dropped */
539
+ async route(incoming) {
540
+ const msg = await this.locateScheduled(incoming);
541
+ if (this.messageQueue)
542
+ return this.messageQueue.push(msg, this.getThreadId(msg));
543
+ return this.processMessage(msg);
544
+ }
545
+ /**
546
+ * A scheduled message names only its chat, so with the default thread key it gets the topic or
547
+ * conversation of the user's most recent conversation in that chat, kept in their session: the
548
+ * task is then answered in the thread and topic it was set up in.
549
+ */
550
+ async locateScheduled(msg) {
551
+ if (!isScheduled(msg) ||
552
+ msg.topicId ||
553
+ msg.conversationId ||
554
+ !this.sessionManager ||
555
+ this.config.session?.threadKey) {
556
+ return msg;
557
+ }
558
+ try {
559
+ const [session] = await this.sessionManager.list({
560
+ userId: msg.userId,
561
+ channelType: msg.channelType,
562
+ limit: 1,
563
+ });
564
+ const conversation = session
565
+ ? findConversation(readConversations(session.metadata), msg.channelId)
566
+ : undefined;
567
+ if (!conversation)
568
+ return msg;
569
+ return {
570
+ ...msg,
571
+ ...(conversation.topicId && { topicId: conversation.topicId }),
572
+ ...(conversation.conversationId && { conversationId: conversation.conversationId }),
573
+ };
574
+ }
575
+ catch (error) {
576
+ this.config.onError?.(toError(error), msg);
577
+ return msg;
578
+ }
346
579
  }
347
580
  rollMessageCounter() {
348
581
  const today = dayKey(Date.now());
@@ -363,24 +596,35 @@ export class Gateway {
363
596
  await runAll(0);
364
597
  return reachedEnd;
365
598
  }
599
+ /**
600
+ * Handles one message, `false` when its run failed or was aborted. A message for a channel
601
+ * the gateway does not run counts as handled: it never can be.
602
+ */
366
603
  async processMessage(msg, signal) {
367
604
  const channel = this.channels.find((ch) => ch.type === msg.channelType);
368
605
  if (!channel)
369
- return;
606
+ return true;
370
607
  const threadId = this.getThreadId(msg);
371
608
  this.inFlight.set(threadId, (this.inFlight.get(threadId) ?? 0) + 1);
372
609
  const stop = new AbortController();
373
610
  const runSignal = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal;
374
611
  this.stops.set(runSignal, stop);
612
+ this.runs.add(stop);
613
+ const key = answerKey(msg);
375
614
  try {
376
615
  await this.processWithChannel(msg, channel, threadId, runSignal);
616
+ if (this.answeredKeys.has(key))
617
+ return true;
618
+ return signal?.aborted !== true && stop.signal.reason !== GATEWAY_STOPPED;
377
619
  }
378
620
  catch (error) {
379
- if (runSignal.aborted)
380
- return;
381
- this.config.onError?.(toError(error), msg);
621
+ if (!runSignal.aborted)
622
+ this.config.onError?.(toError(error), msg);
623
+ return this.answeredKeys.has(key);
382
624
  }
383
625
  finally {
626
+ this.answeredKeys.delete(key);
627
+ this.runs.delete(stop);
384
628
  const remaining = (this.inFlight.get(threadId) ?? 1) - 1;
385
629
  if (remaining <= 0)
386
630
  this.inFlight.delete(threadId);
@@ -404,7 +648,7 @@ export class Gateway {
404
648
  this.rollMessageCounter();
405
649
  this.messageCount++;
406
650
  const known = this.threads.get(threadId);
407
- this.threads.set(threadId, {
651
+ touch(this.threads, threadId, {
408
652
  channelType: msg.channelType,
409
653
  userId: msg.userId,
410
654
  ...(msg.userName || known?.userName ? { userName: msg.userName ?? known?.userName } : {}),
@@ -413,16 +657,22 @@ export class Gateway {
413
657
  });
414
658
  const agent = await this.resolveAgent(user);
415
659
  if (this.sessionManager) {
416
- const session = await this.sessionManager.getOrCreate({
417
- userId: msg.userId,
418
- channelType: msg.channelType,
419
- channelId: msg.channelId,
420
- agentId: agent.name,
660
+ const sessionManager = this.sessionManager;
661
+ await this.writeSession(`${msg.channelType}:${msg.userId}`, async () => {
662
+ const session = await sessionManager.getOrCreate({
663
+ userId: msg.userId,
664
+ channelType: msg.channelType,
665
+ channelId: msg.channelId,
666
+ agentId: agent.name,
667
+ });
668
+ if (session.messageCount === 0) {
669
+ await this.hooks?.emit('session:created', { session, threadId });
670
+ }
671
+ if (!this.config.session?.threadKey && !isScheduled(msg)) {
672
+ await this.rememberConversation(sessionManager, session, msg);
673
+ }
674
+ await sessionManager.incrementMessageCount?.(session.id);
421
675
  });
422
- if (session.messageCount === 0) {
423
- await this.hooks?.emit('session:created', { session, threadId });
424
- }
425
- await this.sessionManager.incrementMessageCount?.(session.id);
426
676
  }
427
677
  const decision = this.readApprovalReply(msg, threadId, known !== undefined);
428
678
  if (!decision && this.config.memory && this.config.session?.compaction) {
@@ -480,7 +730,7 @@ export class Gateway {
480
730
  if (this.config.envelope?.enabled) {
481
731
  const prevTime = this.lastMessageTime.get(threadId);
482
732
  msg = { ...msg, text: formatEnvelope(msg, this.config.envelope, prevTime) };
483
- this.lastMessageTime.set(threadId, Date.now());
733
+ touch(this.lastMessageTime, threadId, Date.now());
484
734
  }
485
735
  const { input, images } = await this.extractMedia(msg, agent);
486
736
  const options = {
@@ -619,6 +869,8 @@ export class Gateway {
619
869
  userId: msg.userId,
620
870
  channelType: msg.channelType,
621
871
  channelId: msg.channelId,
872
+ ...(msg.topicId && { topicId: msg.topicId }),
873
+ ...(msg.conversationId && { conversationId: msg.conversationId }),
622
874
  ...(signal ? { signal } : {}),
623
875
  ...(this.config.runTimeout !== undefined ? { timeout: this.config.runTimeout } : {}),
624
876
  ...(tracker
@@ -632,13 +884,24 @@ export class Gateway {
632
884
  async sendChunked(channel, msg, output, replyTo, buttons) {
633
885
  const chunks = chunkMessage(output, limitFor(channel, msg));
634
886
  const ids = [];
887
+ const key = answerKey(msg);
888
+ const first = !this.answeredKeys.has(key);
635
889
  for (let i = 0; i < chunks.length; i++) {
636
- ids.push(await channel.sendText(msg.channelId, chunks[i], {
637
- ...(i === 0 && replyTo ? { replyTo } : {}),
638
- ...(i === chunks.length - 1 && buttons ? { buttons } : {}),
639
- ...(msg.topicId ? { topicId: msg.topicId } : {}),
640
- format: 'markdown',
641
- }));
890
+ if (i === 0)
891
+ this.answeredKeys.add(key);
892
+ try {
893
+ ids.push(await channel.sendText(msg.channelId, chunks[i], {
894
+ ...(i === 0 && replyTo ? { replyTo } : {}),
895
+ ...(i === chunks.length - 1 && buttons ? { buttons } : {}),
896
+ ...(msg.topicId ? { topicId: msg.topicId } : {}),
897
+ format: 'markdown',
898
+ }));
899
+ }
900
+ catch (error) {
901
+ if (i === 0 && first)
902
+ this.answeredKeys.delete(key);
903
+ throw error;
904
+ }
642
905
  }
643
906
  return ids;
644
907
  }
@@ -712,6 +975,7 @@ export class Gateway {
712
975
  const stoppedByUser = () => stop?.signal.aborted === true;
713
976
  const keepWhatWasWritten = async () => {
714
977
  await stream.finish();
978
+ this.answeredKeys.add(answerKey(msg));
715
979
  await this.hooks?.emit('stream:finished', {
716
980
  msg,
717
981
  threadId,
@@ -753,6 +1017,7 @@ export class Gateway {
753
1017
  let replied = tokenCount > 0;
754
1018
  if (replied) {
755
1019
  await stream.finish();
1020
+ this.answeredKeys.add(answerKey(msg));
756
1021
  }
757
1022
  else {
758
1023
  await stream.abort();
@@ -774,7 +1039,41 @@ export class Gateway {
774
1039
  if (this.config.session?.threadKey) {
775
1040
  return this.config.session.threadKey(msg);
776
1041
  }
777
- return defaultThreadId(msg.channelType, msg.userId);
1042
+ return defaultThreadKey(msg);
1043
+ }
1044
+ /**
1045
+ * Keeps the message's conversation in the user's session, so its thread is known after a
1046
+ * restart. A failure is reported to `onError` and does not keep the message from its answer.
1047
+ */
1048
+ async rememberConversation(sessionManager, session, msg) {
1049
+ const conversations = rememberConversation(readConversations(session.metadata), msg);
1050
+ if (!conversations)
1051
+ return;
1052
+ try {
1053
+ await sessionManager.update(session.id, {
1054
+ metadata: { [CONVERSATIONS_KEY]: conversations },
1055
+ });
1056
+ }
1057
+ catch (error) {
1058
+ this.config.onError?.(toError(error), msg);
1059
+ }
1060
+ }
1061
+ /**
1062
+ * Runs `write` after every earlier write to the same session has finished, so a read, change
1063
+ * and write of its metadata never loses another one's change.
1064
+ */
1065
+ async writeSession(key, write) {
1066
+ const previous = this.sessionWrites.get(key) ?? Promise.resolve();
1067
+ const current = previous.then(write);
1068
+ const settled = current.catch(() => { });
1069
+ this.sessionWrites.set(key, settled);
1070
+ try {
1071
+ await current;
1072
+ }
1073
+ finally {
1074
+ if (this.sessionWrites.get(key) === settled)
1075
+ this.sessionWrites.delete(key);
1076
+ }
778
1077
  }
779
1078
  async resolveAgent(user) {
780
1079
  const agentOrFactory = this.config.agent;