@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.
- package/README.md +129 -23
- package/dist/assistant-config.d.ts +78 -0
- package/dist/assistant-config.d.ts.map +1 -1
- package/dist/assistant-config.js +127 -0
- package/dist/assistant-config.js.map +1 -1
- package/dist/channels/bluesky/channel.d.ts.map +1 -1
- package/dist/channels/bluesky/channel.js +1 -0
- package/dist/channels/bluesky/channel.js.map +1 -1
- package/dist/channels/email/address.d.ts +44 -0
- package/dist/channels/email/address.d.ts.map +1 -0
- package/dist/channels/email/address.js +211 -0
- package/dist/channels/email/address.js.map +1 -0
- package/dist/channels/email/auth.d.ts +59 -0
- package/dist/channels/email/auth.d.ts.map +1 -0
- package/dist/channels/email/auth.js +212 -0
- package/dist/channels/email/auth.js.map +1 -0
- package/dist/channels/email/channel.d.ts +256 -0
- package/dist/channels/email/channel.d.ts.map +1 -0
- package/dist/channels/email/channel.js +701 -0
- package/dist/channels/email/channel.js.map +1 -0
- package/dist/channels/email/errors.d.ts +11 -0
- package/dist/channels/email/errors.d.ts.map +1 -0
- package/dist/channels/email/errors.js +13 -0
- package/dist/channels/email/errors.js.map +1 -0
- package/dist/channels/email/imap.d.ts +125 -0
- package/dist/channels/email/imap.d.ts.map +1 -0
- package/dist/channels/email/imap.js +309 -0
- package/dist/channels/email/imap.js.map +1 -0
- package/dist/channels/email/index.d.ts +7 -0
- package/dist/channels/email/index.d.ts.map +1 -0
- package/dist/channels/email/index.js +3 -0
- package/dist/channels/email/index.js.map +1 -0
- package/dist/channels/email/mime.d.ts +71 -0
- package/dist/channels/email/mime.d.ts.map +1 -0
- package/dist/channels/email/mime.js +83 -0
- package/dist/channels/email/mime.js.map +1 -0
- package/dist/channels/email/multipart.d.ts +28 -0
- package/dist/channels/email/multipart.d.ts.map +1 -0
- package/dist/channels/email/multipart.js +102 -0
- package/dist/channels/email/multipart.js.map +1 -0
- package/dist/channels/email/providers.d.ts +49 -0
- package/dist/channels/email/providers.d.ts.map +1 -0
- package/dist/channels/email/providers.js +292 -0
- package/dist/channels/email/providers.js.map +1 -0
- package/dist/channels/email/reply.d.ts +9 -0
- package/dist/channels/email/reply.d.ts.map +1 -0
- package/dist/channels/email/reply.js +122 -0
- package/dist/channels/email/reply.js.map +1 -0
- package/dist/channels/github/channel.d.ts +222 -0
- package/dist/channels/github/channel.d.ts.map +1 -0
- package/dist/channels/github/channel.js +736 -0
- package/dist/channels/github/channel.js.map +1 -0
- package/dist/channels/github/client.d.ts +45 -0
- package/dist/channels/github/client.d.ts.map +1 -0
- package/dist/channels/github/client.js +119 -0
- package/dist/channels/github/client.js.map +1 -0
- package/dist/channels/github/events.d.ts +49 -0
- package/dist/channels/github/events.d.ts.map +1 -0
- package/dist/channels/github/events.js +147 -0
- package/dist/channels/github/events.js.map +1 -0
- package/dist/channels/github/index.d.ts +4 -0
- package/dist/channels/github/index.d.ts.map +1 -0
- package/dist/channels/github/index.js +4 -0
- package/dist/channels/github/index.js.map +1 -0
- package/dist/channels/slack.d.ts.map +1 -1
- package/dist/channels/slack.js +2 -1
- package/dist/channels/slack.js.map +1 -1
- package/dist/channels/telegram.d.ts.map +1 -1
- package/dist/channels/telegram.js +14 -6
- package/dist/channels/telegram.js.map +1 -1
- package/dist/channels/threads/channel.d.ts.map +1 -1
- package/dist/channels/threads/channel.js +1 -0
- package/dist/channels/threads/channel.js.map +1 -1
- package/dist/channels/webchat.d.ts +8 -0
- package/dist/channels/webchat.d.ts.map +1 -1
- package/dist/channels/webchat.js +54 -15
- package/dist/channels/webchat.js.map +1 -1
- package/dist/channels/whatsapp.d.ts.map +1 -1
- package/dist/channels/whatsapp.js +13 -0
- package/dist/channels/whatsapp.js.map +1 -1
- package/dist/formatters/html.d.ts +16 -0
- package/dist/formatters/html.d.ts.map +1 -0
- package/dist/formatters/html.js +535 -0
- package/dist/formatters/html.js.map +1 -0
- package/dist/formatters/markdown.d.ts.map +1 -1
- package/dist/formatters/markdown.js +2 -0
- package/dist/formatters/markdown.js.map +1 -1
- package/dist/gateway.d.ts +91 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +345 -46
- package/dist/gateway.js.map +1 -1
- package/dist/heartbeat.d.ts +5 -1
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +13 -3
- package/dist/heartbeat.js.map +1 -1
- package/dist/inbound-debounce.d.ts +4 -1
- package/dist/inbound-debounce.d.ts.map +1 -1
- package/dist/inbound-debounce.js +4 -2
- package/dist/inbound-debounce.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/merge-messages.d.ts +3 -2
- package/dist/merge-messages.d.ts.map +1 -1
- package/dist/merge-messages.js +4 -2
- package/dist/merge-messages.js.map +1 -1
- package/dist/message-queue.d.ts +14 -4
- package/dist/message-queue.d.ts.map +1 -1
- package/dist/message-queue.js +36 -35
- package/dist/message-queue.js.map +1 -1
- package/dist/runtime-builder.d.ts +9 -0
- package/dist/runtime-builder.d.ts.map +1 -1
- package/dist/runtime-builder.js +149 -5
- package/dist/runtime-builder.js.map +1 -1
- package/dist/thread-key.d.ts +50 -0
- package/dist/thread-key.d.ts.map +1 -0
- package/dist/thread-key.js +82 -0
- package/dist/thread-key.js.map +1 -0
- 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
|
-
|
|
93
|
-
?
|
|
94
|
-
:
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (this.messageQueue) {
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
this.
|
|
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
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
|
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;
|