@cogitator-ai/channels 0.15.12 → 0.16.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 (71) hide show
  1. package/README.md +88 -23
  2. package/dist/assistant-config.d.ts +38 -0
  3. package/dist/assistant-config.d.ts.map +1 -1
  4. package/dist/assistant-config.js +61 -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/github/channel.d.ts +222 -0
  10. package/dist/channels/github/channel.d.ts.map +1 -0
  11. package/dist/channels/github/channel.js +736 -0
  12. package/dist/channels/github/channel.js.map +1 -0
  13. package/dist/channels/github/client.d.ts +45 -0
  14. package/dist/channels/github/client.d.ts.map +1 -0
  15. package/dist/channels/github/client.js +119 -0
  16. package/dist/channels/github/client.js.map +1 -0
  17. package/dist/channels/github/events.d.ts +49 -0
  18. package/dist/channels/github/events.d.ts.map +1 -0
  19. package/dist/channels/github/events.js +147 -0
  20. package/dist/channels/github/events.js.map +1 -0
  21. package/dist/channels/github/index.d.ts +4 -0
  22. package/dist/channels/github/index.d.ts.map +1 -0
  23. package/dist/channels/github/index.js +4 -0
  24. package/dist/channels/github/index.js.map +1 -0
  25. package/dist/channels/slack.d.ts.map +1 -1
  26. package/dist/channels/slack.js +2 -1
  27. package/dist/channels/slack.js.map +1 -1
  28. package/dist/channels/telegram.d.ts.map +1 -1
  29. package/dist/channels/telegram.js +14 -6
  30. package/dist/channels/telegram.js.map +1 -1
  31. package/dist/channels/threads/channel.d.ts.map +1 -1
  32. package/dist/channels/threads/channel.js +1 -0
  33. package/dist/channels/threads/channel.js.map +1 -1
  34. package/dist/channels/webchat.d.ts +8 -0
  35. package/dist/channels/webchat.d.ts.map +1 -1
  36. package/dist/channels/webchat.js +54 -15
  37. package/dist/channels/webchat.js.map +1 -1
  38. package/dist/channels/whatsapp.d.ts.map +1 -1
  39. package/dist/channels/whatsapp.js +13 -0
  40. package/dist/channels/whatsapp.js.map +1 -1
  41. package/dist/formatters/markdown.d.ts.map +1 -1
  42. package/dist/formatters/markdown.js +1 -0
  43. package/dist/formatters/markdown.js.map +1 -1
  44. package/dist/gateway.d.ts +69 -1
  45. package/dist/gateway.d.ts.map +1 -1
  46. package/dist/gateway.js +249 -40
  47. package/dist/gateway.js.map +1 -1
  48. package/dist/heartbeat.d.ts +5 -1
  49. package/dist/heartbeat.d.ts.map +1 -1
  50. package/dist/heartbeat.js +13 -3
  51. package/dist/heartbeat.js.map +1 -1
  52. package/dist/index.d.ts +5 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +3 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/merge-messages.d.ts +3 -2
  57. package/dist/merge-messages.d.ts.map +1 -1
  58. package/dist/merge-messages.js +4 -2
  59. package/dist/merge-messages.js.map +1 -1
  60. package/dist/message-queue.d.ts +14 -4
  61. package/dist/message-queue.d.ts.map +1 -1
  62. package/dist/message-queue.js +36 -35
  63. package/dist/message-queue.js.map +1 -1
  64. package/dist/runtime-builder.d.ts.map +1 -1
  65. package/dist/runtime-builder.js +39 -5
  66. package/dist/runtime-builder.js.map +1 -1
  67. package/dist/thread-key.d.ts +50 -0
  68. package/dist/thread-key.d.ts.map +1 -0
  69. package/dist/thread-key.js +82 -0
  70. package/dist/thread-key.js.map +1 -0
  71. package/package.json +8 -7
package/dist/gateway.d.ts CHANGED
@@ -23,6 +23,12 @@ export interface GatewayFullConfig extends GatewayConfig {
23
23
  * the prompt cache they open with
24
24
  */
25
25
  brief?: (msg: ChannelMessage) => string | undefined;
26
+ /**
27
+ * How long {@link Gateway.stop} waits for the queued messages and the runs in progress to be
28
+ * answered, in milliseconds (default 30000). What is still running then is aborted, and what
29
+ * is still waiting is dropped.
30
+ */
31
+ drainTimeout?: number;
26
32
  }
27
33
  export interface GatewaySessionInfo {
28
34
  threadId: string;
@@ -33,6 +39,8 @@ export interface GatewaySessionInfo {
33
39
  lastActiveAt: number;
34
40
  active: boolean;
35
41
  }
42
+ /** Threads kept in the gateway's own bookkeeping (stats, sessions, envelope times) */
43
+ export declare const MAX_TRACKED_THREADS = 10000;
36
44
  export declare class Gateway {
37
45
  private readonly config;
38
46
  private readonly channels;
@@ -44,6 +52,8 @@ export declare class Gateway {
44
52
  private readonly hooks;
45
53
  private readonly approvalWords;
46
54
  private readonly pausedThreads;
55
+ /** The stop of every run in progress */
56
+ private readonly runs;
47
57
  /** The stop of each run, by the signal the run was given */
48
58
  private readonly stops;
49
59
  /** The stop of each run streaming into a draft with a stop button, by channel, chat and draft */
@@ -53,16 +63,45 @@ export declare class Gateway {
53
63
  private readonly lastMessageTime;
54
64
  private readonly threads;
55
65
  private readonly inFlight;
66
+ /** Messages taken and not answered yet, for {@link idle} */
67
+ private readonly pending;
68
+ /** The stop in progress, which a second {@link stop} call returns */
69
+ private stopping;
70
+ /** The last pending write of each user's session, so writes to one session never interleave */
71
+ private readonly sessionWrites;
56
72
  private running;
57
73
  private startedAt;
58
74
  private messageCount;
59
75
  private messageCountDay;
60
76
  constructor(config: GatewayFullConfig);
61
77
  start(): Promise<void>;
78
+ /**
79
+ * Stops the gateway. Channels have already confirmed the messages they handed over, so it
80
+ * first lets them be answered: messages held by `debounce` are flushed, and it waits up to
81
+ * `drainTimeout` for the queues and the runs in progress. Then it aborts the runs still going,
82
+ * drops the messages still waiting, waits for the aborted runs to settle (up to `drainTimeout`
83
+ * again) and stops the channels. Messages that come in meanwhile are refused (see
84
+ * {@link injectMessage}). A second call while it stops returns the same promise.
85
+ */
62
86
  stop(): Promise<void>;
87
+ private drainAndStop;
63
88
  get stats(): GatewayStats;
64
89
  getSessions(): GatewaySessionInfo[];
90
+ /**
91
+ * Feeds a message to the gateway as if a channel had received it (the scheduler sends its
92
+ * tasks this way). It resolves once the message is answered, or once it is buffered when
93
+ * `debounce` is on. It rejects when the message was not answered: its run failed (also
94
+ * reported to `onError`), it was aborted or dropped by {@link stop}, or it came in while the
95
+ * gateway was stopping.
96
+ */
65
97
  injectMessage(msg: ChannelMessage): Promise<void>;
98
+ /**
99
+ * Resolves once every message the gateway has taken is answered: those waiting in their
100
+ * thread's queue and the runs in progress, including messages that come in while it waits.
101
+ * Messages held by `debounce` count from the moment they are flushed. {@link stop} waits
102
+ * for it, up to `drainTimeout`.
103
+ */
104
+ idle(): Promise<void>;
66
105
  /**
67
106
  * Summarize older messages of a conversation thread, keeping `keepRecent` messages intact.
68
107
  * Runs regardless of the configured thresholds.
@@ -77,7 +116,8 @@ export declare class Gateway {
77
116
  compactAll(): Promise<string>;
78
117
  /**
79
118
  * The threads {@link compactAll} goes through: those served since the gateway started, and the
80
- * default thread of each stored session when threads are not keyed by `session.threadKey`
119
+ * threads of each stored session when they are keyed by the default key (the conversations kept
120
+ * in the session's metadata, or the chat it was created in) or by `legacyThreadKey`
81
121
  */
82
122
  private knownThreads;
83
123
  /**
@@ -98,10 +138,28 @@ export declare class Gateway {
98
138
  * decision, or go away when the request was dropped.
99
139
  */
100
140
  private closePrompts;
141
+ /**
142
+ * A message from a channel, a button or {@link injectMessage}. With a queue, a channel's message
143
+ * is only queued, so the channel goes on to its next update while the thread waits its turn.
144
+ * `untilAnswered` waits for the answer.
145
+ */
101
146
  private handleIncoming;
147
+ /** Puts a message through the queue of its thread, resolving once it is done with */
102
148
  private dispatch;
149
+ /** Resolves `true` once the message is handled, `false` when it failed or was dropped */
150
+ private route;
151
+ /**
152
+ * A scheduled message names only its chat, so with the default thread key it gets the topic or
153
+ * conversation of the user's most recent conversation in that chat, kept in their session: the
154
+ * task is then answered in the thread and topic it was set up in.
155
+ */
156
+ private locateScheduled;
103
157
  private rollMessageCounter;
104
158
  private runMiddleware;
159
+ /**
160
+ * Handles one message, `false` when its run failed or was aborted. A message for a channel
161
+ * the gateway does not run counts as handled: it never can be.
162
+ */
105
163
  private processMessage;
106
164
  private processWithChannel;
107
165
  private handleAccepted;
@@ -128,6 +186,16 @@ export declare class Gateway {
128
186
  */
129
187
  private streamRun;
130
188
  private getThreadId;
189
+ /**
190
+ * Keeps the message's conversation in the user's session, so its thread is known after a
191
+ * restart. A failure is reported to `onError` and does not keep the message from its answer.
192
+ */
193
+ private rememberConversation;
194
+ /**
195
+ * Runs `write` after every earlier write to the same session has finished, so a read, change
196
+ * and write of its metadata never loses another one's change.
197
+ */
198
+ private writeSession;
131
199
  private resolveAgent;
132
200
  private createMiddlewareContext;
133
201
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../src/gateway.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,aAAa,EACb,YAAY,EAIZ,cAAc,EAMd,YAAY,EASb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAM3D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAe9D,OAAO,KAAK,EAIV,sBAAsB,EACvB,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,iBAAkB,SAAQ,aAAa;IACtD,SAAS,EAAE,SAAS,CAAC;IACrB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB;;;;OAIG;IACH,SAAS,CAAC,EAAE,sBAAsB,CAAC;IACnC;;;;OAIG;IACH,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,MAAM,GAAG,SAAS,CAAC;CACrD;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;CACjB;AAgFD,qBAAa,OAAO;IAClB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoB;IAC3C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAY;IACrC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA+B;IAC9D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsB;IAClD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAe;IAC5C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0B;IACpD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAsB;IACnD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAsB;IAC5C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAqB;IACnD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAmC;IACjE,4DAA4D;IAC5D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA+C;IACrE,iGAAiG;IACjG,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAsC;IACtE,iGAAiG;IACjG,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqC;IACrE,OAAO,CAAC,QAAQ,CAAC,eAAe,CAA6B;IAC7D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsE;IAC9F,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6B;IACtD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,eAAe,CAAsB;gBAEjC,MAAM,EAAE,iBAAiB;IAkC/B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAetB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAW3B,IAAI,KAAK,IAAI,YAAY,CASxB;IAED,WAAW,IAAI,kBAAkB,EAAE;IAM7B,aAAa,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvD;;;OAGG;IACG,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC;IAYrE;;;;;OAKG;IACG,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC;IA2BnC;;;OAGG;YACW,YAAY;IAe1B;;;OAGG;YACW,eAAe;YAMf,OAAO;IAqBrB;;;;OAIG;YACW,YAAY;YAmBZ,oBAAoB;IA8ClC;;;OAGG;YACW,YAAY;YAyBZ,cAAc;YAQd,QAAQ;IAQtB,OAAO,CAAC,kBAAkB;YAQZ,aAAa;YAab,cAAc;YAuBd,kBAAkB;YAkBlB,cAAc;IAgF5B;;;;OAIG;IACH,OAAO,CAAC,iBAAiB;YAUX,UAAU;YA+BV,eAAe;YAgDf,cAAc;YAgBd,gBAAgB;YA4ChB,OAAO;YAeP,YAAY;IA6B1B,OAAO,CAAC,eAAe;YAuBT,WAAW;YAsBX,SAAS;YA0BT,SAAS;YA+BT,YAAY;IAuC1B;;;OAGG;YACW,SAAS;IA6EvB,OAAO,CAAC,WAAW;YAOL,YAAY;IAQ1B,OAAO,CAAC,uBAAuB;IAmB/B;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;CAqB1B"}
1
+ {"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../src/gateway.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,aAAa,EACb,YAAY,EAIZ,cAAc,EAMd,YAAY,EAUb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAM3D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAuB9D,OAAO,KAAK,EAIV,sBAAsB,EACvB,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,iBAAkB,SAAQ,aAAa;IACtD,SAAS,EAAE,SAAS,CAAC;IACrB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB;;;;OAIG;IACH,SAAS,CAAC,EAAE,sBAAsB,CAAC;IACnC;;;;OAIG;IACH,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,MAAM,GAAG,SAAS,CAAC;IACpD;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;CACjB;AAoDD,sFAAsF;AACtF,eAAO,MAAM,mBAAmB,QAAS,CAAC;AAqD1C,qBAAa,OAAO;IAClB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoB;IAC3C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAY;IACrC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA+B;IAC9D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsB;IAClD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAe;IAC5C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0B;IACpD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAsB;IACnD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAsB;IAC5C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAqB;IACnD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAmC;IACjE,wCAAwC;IACxC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA8B;IACnD,4DAA4D;IAC5D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA+C;IACrE,iGAAiG;IACjG,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAsC;IACtE,iGAAiG;IACjG,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqC;IACrE,OAAO,CAAC,QAAQ,CAAC,eAAe,CAA6B;IAC7D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsE;IAC9F,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6B;IACtD,4DAA4D;IAC5D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA+B;IACvD,qEAAqE;IACrE,OAAO,CAAC,QAAQ,CAA8B;IAC9C,+FAA+F;IAC/F,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAoC;IAClE,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,eAAe,CAAsB;gBAEjC,MAAM,EAAE,iBAAiB;IAqC/B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAe5B;;;;;;;OAOG;IACH,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAWP,YAAY;IAkB1B,IAAI,KAAK,IAAI,YAAY,CASxB;IAED,WAAW,IAAI,kBAAkB,EAAE;IAMnC;;;;;;OAMG;IACG,aAAa,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvD;;;;;OAKG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAM3B;;;OAGG;IACG,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC;IAYrE;;;;;OAKG;IACG,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC;IA2BnC;;;;OAIG;YACW,YAAY;IA+B1B;;;OAGG;YACW,eAAe;YAMf,OAAO;IAqBrB;;;;OAIG;YACW,YAAY;YAmBZ,oBAAoB;IA+ClC;;;OAGG;YACW,YAAY;IAyB1B;;;;OAIG;YACW,cAAc;IAqB5B,qFAAqF;IACrF,OAAO,CAAC,QAAQ;IAUhB,yFAAyF;YAC3E,KAAK;IAMnB;;;;OAIG;YACW,eAAe;IA+B7B,OAAO,CAAC,kBAAkB;YAQZ,aAAa;IAa3B;;;OAGG;YACW,cAAc;YA0Bd,kBAAkB;YAkBlB,cAAc;IAsF5B;;;;OAIG;IACH,OAAO,CAAC,iBAAiB;YAUX,UAAU;YA+BV,eAAe;YAgDf,cAAc;YAgBd,gBAAgB;YA4ChB,OAAO;YAeP,YAAY;IA6B1B,OAAO,CAAC,eAAe;YAyBT,WAAW;YAsBX,SAAS;YA0BT,SAAS;YA+BT,YAAY;IAuC1B;;;OAGG;YACW,SAAS;IA6EvB,OAAO,CAAC,WAAW;IAOnB;;;OAGG;YACW,oBAAoB;IAgBlC;;;OAGG;YACW,YAAY;YAYZ,YAAY;IAQ1B,OAAO,CAAC,uBAAuB;IAmB/B;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;CAqB1B"}
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) {
@@ -26,6 +27,34 @@ const DEFAULT_SUMMARY_PROMPT = 'Summarize the following conversation between a u
26
27
  'Preserve facts, decisions, user preferences, open tasks and any commitments. ' +
27
28
  'Write a concise summary in the language of the conversation.';
28
29
  const TYPING_INTERVAL_MS = 4000;
30
+ const DEFAULT_DRAIN_TIMEOUT_MS = 30_000;
31
+ /** Why {@link Gateway.stop} aborts the runs still going past `drainTimeout` */
32
+ const GATEWAY_STOPPED = new Error('The gateway stopped');
33
+ /** Threads kept in the gateway's own bookkeeping (stats, sessions, envelope times) */
34
+ export const MAX_TRACKED_THREADS = 10_000;
35
+ /** Sets `key` as the most recent entry of `map`, dropping the least recent one past the limit */
36
+ function touch(map, key, value) {
37
+ map.delete(key);
38
+ map.set(key, value);
39
+ if (map.size > MAX_TRACKED_THREADS) {
40
+ const oldest = map.keys().next();
41
+ if (!oldest.done)
42
+ map.delete(oldest.value);
43
+ }
44
+ }
45
+ /** Resolves `true` once `promise` settles, or `false` after `ms` */
46
+ async function settlesWithin(promise, ms) {
47
+ let timer;
48
+ const timeout = new Promise((resolve) => {
49
+ timer = setTimeout(() => resolve(false), ms);
50
+ });
51
+ try {
52
+ return await Promise.race([promise.then(() => true), timeout]);
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
29
58
  function messageText(message) {
30
59
  if (typeof message.content === 'string')
31
60
  return message.content;
@@ -33,10 +62,6 @@ function messageText(message) {
33
62
  .map((part) => (part.type === 'text' ? part.text : `[${part.type}]`))
34
63
  .join(' ');
35
64
  }
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
65
  function isScheduled(msg) {
41
66
  const raw = msg.raw;
42
67
  return typeof raw === 'object' && raw !== null && 'scheduled' in raw && raw.scheduled === true;
@@ -63,6 +88,8 @@ export class Gateway {
63
88
  hooks;
64
89
  approvalWords;
65
90
  pausedThreads = new Map();
91
+ /** The stop of every run in progress */
92
+ runs = new Set();
66
93
  /** The stop of each run, by the signal the run was given */
67
94
  stops = new WeakMap();
68
95
  /** The stop of each run streaming into a draft with a stop button, by channel, chat and draft */
@@ -72,6 +99,12 @@ export class Gateway {
72
99
  lastMessageTime = new Map();
73
100
  threads = new Map();
74
101
  inFlight = new Map();
102
+ /** Messages taken and not answered yet, for {@link idle} */
103
+ pending = new Set();
104
+ /** The stop in progress, which a second {@link stop} call returns */
105
+ stopping = null;
106
+ /** The last pending write of each user's session, so writes to one session never interleave */
107
+ sessionWrites = new Map();
75
108
  running = false;
76
109
  startedAt = null;
77
110
  messageCount = 0;
@@ -88,15 +121,18 @@ export class Gateway {
88
121
  };
89
122
  this.sessionManager =
90
123
  config.sessionManager ?? (config.memory ? new SessionManager(config.memory) : null);
124
+ const queueMode = config.queueMode ?? 'sequential';
91
125
  this.messageQueue =
92
- config.queueMode && config.queueMode !== 'parallel'
93
- ? new MessageQueue(config.queueMode, (msg, signal) => this.processMessage(msg, signal))
94
- : null;
126
+ queueMode === 'parallel'
127
+ ? null
128
+ : new MessageQueue(queueMode, (msg, signal) => this.processMessage(msg, signal));
95
129
  this.debouncer = config.debounce?.enabled
96
- ? new InboundDebouncer(config.debounce, (merged) => this.dispatch(merged))
130
+ ? new InboundDebouncer(config.debounce, async (merged) => {
131
+ this.dispatch(merged).catch(() => { });
132
+ })
97
133
  : null;
98
134
  for (const channel of this.channels) {
99
- channel.onMessage((msg) => this.handleIncoming(msg));
135
+ channel.onMessage((msg) => this.handleIncoming(msg, false));
100
136
  channel.onAction?.((action) => this.handleAction(channel, action));
101
137
  channel.onStop?.((stop) => {
102
138
  this.stoppableDrafts
@@ -119,12 +155,38 @@ export class Gateway {
119
155
  throw failure.reason instanceof Error ? failure.reason : new Error(String(failure.reason));
120
156
  }
121
157
  }
122
- async stop() {
158
+ /**
159
+ * Stops the gateway. Channels have already confirmed the messages they handed over, so it
160
+ * first lets them be answered: messages held by `debounce` are flushed, and it waits up to
161
+ * `drainTimeout` for the queues and the runs in progress. Then it aborts the runs still going,
162
+ * drops the messages still waiting, waits for the aborted runs to settle (up to `drainTimeout`
163
+ * again) and stops the channels. Messages that come in meanwhile are refused (see
164
+ * {@link injectMessage}). A second call while it stops returns the same promise.
165
+ */
166
+ stop() {
167
+ if (this.stopping)
168
+ return this.stopping;
123
169
  if (!this.running)
124
- return;
170
+ return Promise.resolve();
125
171
  this.running = false;
126
- if (this.debouncer)
172
+ const stopping = this.drainAndStop().finally(() => {
173
+ this.stopping = null;
174
+ });
175
+ this.stopping = stopping;
176
+ return stopping;
177
+ }
178
+ async drainAndStop() {
179
+ const timeout = this.config.drainTimeout ?? DEFAULT_DRAIN_TIMEOUT_MS;
180
+ if (this.debouncer) {
127
181
  await this.debouncer.flushAll();
182
+ this.debouncer.dispose();
183
+ }
184
+ if (!(await settlesWithin(this.idle(), timeout))) {
185
+ this.messageQueue?.dispose();
186
+ for (const run of this.runs)
187
+ run.abort(GATEWAY_STOPPED);
188
+ await settlesWithin(this.idle(), timeout);
189
+ }
128
190
  this.messageQueue?.dispose();
129
191
  await Promise.all(this.channels.map((ch) => ch.stop()));
130
192
  this.startedAt = null;
@@ -144,8 +206,26 @@ export class Gateway {
144
206
  .map(([threadId, info]) => ({ threadId, ...info, active: this.inFlight.has(threadId) }))
145
207
  .sort((a, b) => b.lastActiveAt - a.lastActiveAt);
146
208
  }
209
+ /**
210
+ * Feeds a message to the gateway as if a channel had received it (the scheduler sends its
211
+ * tasks this way). It resolves once the message is answered, or once it is buffered when
212
+ * `debounce` is on. It rejects when the message was not answered: its run failed (also
213
+ * reported to `onError`), it was aborted or dropped by {@link stop}, or it came in while the
214
+ * gateway was stopping.
215
+ */
147
216
  async injectMessage(msg) {
148
- return this.handleIncoming(msg);
217
+ return this.handleIncoming(msg, true);
218
+ }
219
+ /**
220
+ * Resolves once every message the gateway has taken is answered: those waiting in their
221
+ * thread's queue and the runs in progress, including messages that come in while it waits.
222
+ * Messages held by `debounce` count from the moment they are flushed. {@link stop} waits
223
+ * for it, up to `drainTimeout`.
224
+ */
225
+ async idle() {
226
+ while (this.pending.size > 0) {
227
+ await Promise.allSettled(this.pending);
228
+ }
149
229
  }
150
230
  /**
151
231
  * Summarize older messages of a conversation thread, keeping `keepRecent` messages intact.
@@ -196,16 +276,31 @@ export class Gateway {
196
276
  }
197
277
  /**
198
278
  * 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`
279
+ * threads of each stored session when they are keyed by the default key (the conversations kept
280
+ * in the session's metadata, or the chat it was created in) or by `legacyThreadKey`
200
281
  */
201
282
  async knownThreads() {
202
283
  const threadIds = new Set(this.threads.keys());
203
- if (!this.sessionManager || this.config.session?.threadKey) {
284
+ const threadKey = this.config.session?.threadKey;
285
+ if (!this.sessionManager || (threadKey && threadKey !== legacyThreadKey)) {
204
286
  return { threadIds: [...threadIds] };
205
287
  }
206
288
  try {
207
289
  for (const session of await this.sessionManager.list()) {
208
- threadIds.add(defaultThreadId(session.channelType, session.userId));
290
+ if (threadKey) {
291
+ threadIds.add(legacyThreadKey(session));
292
+ continue;
293
+ }
294
+ const conversations = readConversations(session.metadata);
295
+ for (const conversation of conversations.length > 0
296
+ ? conversations
297
+ : [{ channelId: session.channelId }]) {
298
+ threadIds.add(defaultThreadKey({
299
+ channelType: session.channelType,
300
+ userId: session.userId,
301
+ ...conversation,
302
+ }));
303
+ }
209
304
  }
210
305
  }
211
306
  catch (error) {
@@ -272,6 +367,7 @@ export class Gateway {
272
367
  ...(action.userName ? { userName: action.userName } : {}),
273
368
  ...(action.channelId.startsWith('-') ? { groupId: action.channelId } : {}),
274
369
  ...(action.topicId ? { topicId: action.topicId } : {}),
370
+ ...(action.conversationId ? { conversationId: action.conversationId } : {}),
275
371
  text: (approve ? this.approvalWords.approveWords : this.approvalWords.denyWords)[0] ?? '',
276
372
  raw: action.raw,
277
373
  };
@@ -300,7 +396,7 @@ export class Gateway {
300
396
  }
301
397
  await answer();
302
398
  await this.closePrompts(prompt.threadId, approve);
303
- await this.handleIncoming(msg);
399
+ await this.handleIncoming(msg, false);
304
400
  }
305
401
  /**
306
402
  * Retires the buttons of a thread's approval prompts once it is answered: they show the
@@ -330,19 +426,83 @@ export class Gateway {
330
426
  .catch(() => { });
331
427
  }
332
428
  }
333
- async handleIncoming(msg) {
429
+ /**
430
+ * A message from a channel, a button or {@link injectMessage}. With a queue, a channel's message
431
+ * is only queued, so the channel goes on to its next update while the thread waits its turn.
432
+ * `untilAnswered` waits for the answer.
433
+ */
434
+ async handleIncoming(msg, untilAnswered) {
435
+ if (this.stopping) {
436
+ const refused = new Error('The gateway is stopping: the message was dropped');
437
+ this.config.onError?.(refused, msg);
438
+ if (untilAnswered)
439
+ throw refused;
440
+ return;
441
+ }
334
442
  if (this.debouncer) {
335
443
  this.debouncer.enqueue(msg);
336
444
  return;
337
445
  }
338
- await this.dispatch(msg);
339
- }
340
- async dispatch(msg) {
341
- if (this.messageQueue) {
342
- this.messageQueue.push(msg, this.getThreadId(msg));
446
+ const answered = this.dispatch(msg);
447
+ if (untilAnswered || !this.messageQueue) {
448
+ if (!(await answered) && untilAnswered) {
449
+ throw new Error('The message was not answered: its run failed or was aborted');
450
+ }
343
451
  return;
344
452
  }
345
- await this.processMessage(msg);
453
+ answered.catch(() => { });
454
+ }
455
+ /** Puts a message through the queue of its thread, resolving once it is done with */
456
+ dispatch(msg) {
457
+ const answered = this.route(msg);
458
+ this.pending.add(answered);
459
+ const forget = () => {
460
+ this.pending.delete(answered);
461
+ };
462
+ answered.then(forget, forget);
463
+ return answered;
464
+ }
465
+ /** Resolves `true` once the message is handled, `false` when it failed or was dropped */
466
+ async route(incoming) {
467
+ const msg = await this.locateScheduled(incoming);
468
+ if (this.messageQueue)
469
+ return this.messageQueue.push(msg, this.getThreadId(msg));
470
+ return this.processMessage(msg);
471
+ }
472
+ /**
473
+ * A scheduled message names only its chat, so with the default thread key it gets the topic or
474
+ * conversation of the user's most recent conversation in that chat, kept in their session: the
475
+ * task is then answered in the thread and topic it was set up in.
476
+ */
477
+ async locateScheduled(msg) {
478
+ if (!isScheduled(msg) ||
479
+ msg.topicId ||
480
+ msg.conversationId ||
481
+ !this.sessionManager ||
482
+ this.config.session?.threadKey) {
483
+ return msg;
484
+ }
485
+ try {
486
+ const [session] = await this.sessionManager.list({
487
+ userId: msg.userId,
488
+ channelType: msg.channelType,
489
+ limit: 1,
490
+ });
491
+ const conversation = session
492
+ ? findConversation(readConversations(session.metadata), msg.channelId)
493
+ : undefined;
494
+ if (!conversation)
495
+ return msg;
496
+ return {
497
+ ...msg,
498
+ ...(conversation.topicId && { topicId: conversation.topicId }),
499
+ ...(conversation.conversationId && { conversationId: conversation.conversationId }),
500
+ };
501
+ }
502
+ catch (error) {
503
+ this.config.onError?.(toError(error), msg);
504
+ return msg;
505
+ }
346
506
  }
347
507
  rollMessageCounter() {
348
508
  const today = dayKey(Date.now());
@@ -363,24 +523,31 @@ export class Gateway {
363
523
  await runAll(0);
364
524
  return reachedEnd;
365
525
  }
526
+ /**
527
+ * Handles one message, `false` when its run failed or was aborted. A message for a channel
528
+ * the gateway does not run counts as handled: it never can be.
529
+ */
366
530
  async processMessage(msg, signal) {
367
531
  const channel = this.channels.find((ch) => ch.type === msg.channelType);
368
532
  if (!channel)
369
- return;
533
+ return true;
370
534
  const threadId = this.getThreadId(msg);
371
535
  this.inFlight.set(threadId, (this.inFlight.get(threadId) ?? 0) + 1);
372
536
  const stop = new AbortController();
373
537
  const runSignal = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal;
374
538
  this.stops.set(runSignal, stop);
539
+ this.runs.add(stop);
375
540
  try {
376
541
  await this.processWithChannel(msg, channel, threadId, runSignal);
542
+ return signal?.aborted !== true && stop.signal.reason !== GATEWAY_STOPPED;
377
543
  }
378
544
  catch (error) {
379
- if (runSignal.aborted)
380
- return;
381
- this.config.onError?.(toError(error), msg);
545
+ if (!runSignal.aborted)
546
+ this.config.onError?.(toError(error), msg);
547
+ return false;
382
548
  }
383
549
  finally {
550
+ this.runs.delete(stop);
384
551
  const remaining = (this.inFlight.get(threadId) ?? 1) - 1;
385
552
  if (remaining <= 0)
386
553
  this.inFlight.delete(threadId);
@@ -404,7 +571,7 @@ export class Gateway {
404
571
  this.rollMessageCounter();
405
572
  this.messageCount++;
406
573
  const known = this.threads.get(threadId);
407
- this.threads.set(threadId, {
574
+ touch(this.threads, threadId, {
408
575
  channelType: msg.channelType,
409
576
  userId: msg.userId,
410
577
  ...(msg.userName || known?.userName ? { userName: msg.userName ?? known?.userName } : {}),
@@ -413,16 +580,22 @@ export class Gateway {
413
580
  });
414
581
  const agent = await this.resolveAgent(user);
415
582
  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,
583
+ const sessionManager = this.sessionManager;
584
+ await this.writeSession(`${msg.channelType}:${msg.userId}`, async () => {
585
+ const session = await sessionManager.getOrCreate({
586
+ userId: msg.userId,
587
+ channelType: msg.channelType,
588
+ channelId: msg.channelId,
589
+ agentId: agent.name,
590
+ });
591
+ if (session.messageCount === 0) {
592
+ await this.hooks?.emit('session:created', { session, threadId });
593
+ }
594
+ if (!this.config.session?.threadKey && !isScheduled(msg)) {
595
+ await this.rememberConversation(sessionManager, session, msg);
596
+ }
597
+ await sessionManager.incrementMessageCount?.(session.id);
421
598
  });
422
- if (session.messageCount === 0) {
423
- await this.hooks?.emit('session:created', { session, threadId });
424
- }
425
- await this.sessionManager.incrementMessageCount?.(session.id);
426
599
  }
427
600
  const decision = this.readApprovalReply(msg, threadId, known !== undefined);
428
601
  if (!decision && this.config.memory && this.config.session?.compaction) {
@@ -480,7 +653,7 @@ export class Gateway {
480
653
  if (this.config.envelope?.enabled) {
481
654
  const prevTime = this.lastMessageTime.get(threadId);
482
655
  msg = { ...msg, text: formatEnvelope(msg, this.config.envelope, prevTime) };
483
- this.lastMessageTime.set(threadId, Date.now());
656
+ touch(this.lastMessageTime, threadId, Date.now());
484
657
  }
485
658
  const { input, images } = await this.extractMedia(msg, agent);
486
659
  const options = {
@@ -619,6 +792,8 @@ export class Gateway {
619
792
  userId: msg.userId,
620
793
  channelType: msg.channelType,
621
794
  channelId: msg.channelId,
795
+ ...(msg.topicId && { topicId: msg.topicId }),
796
+ ...(msg.conversationId && { conversationId: msg.conversationId }),
622
797
  ...(signal ? { signal } : {}),
623
798
  ...(this.config.runTimeout !== undefined ? { timeout: this.config.runTimeout } : {}),
624
799
  ...(tracker
@@ -774,7 +949,41 @@ export class Gateway {
774
949
  if (this.config.session?.threadKey) {
775
950
  return this.config.session.threadKey(msg);
776
951
  }
777
- return defaultThreadId(msg.channelType, msg.userId);
952
+ return defaultThreadKey(msg);
953
+ }
954
+ /**
955
+ * Keeps the message's conversation in the user's session, so its thread is known after a
956
+ * restart. A failure is reported to `onError` and does not keep the message from its answer.
957
+ */
958
+ async rememberConversation(sessionManager, session, msg) {
959
+ const conversations = rememberConversation(readConversations(session.metadata), msg);
960
+ if (!conversations)
961
+ return;
962
+ try {
963
+ await sessionManager.update(session.id, {
964
+ metadata: { [CONVERSATIONS_KEY]: conversations },
965
+ });
966
+ }
967
+ catch (error) {
968
+ this.config.onError?.(toError(error), msg);
969
+ }
970
+ }
971
+ /**
972
+ * Runs `write` after every earlier write to the same session has finished, so a read, change
973
+ * and write of its metadata never loses another one's change.
974
+ */
975
+ async writeSession(key, write) {
976
+ const previous = this.sessionWrites.get(key) ?? Promise.resolve();
977
+ const current = previous.then(write);
978
+ const settled = current.catch(() => { });
979
+ this.sessionWrites.set(key, settled);
980
+ try {
981
+ await current;
982
+ }
983
+ finally {
984
+ if (this.sessionWrites.get(key) === settled)
985
+ this.sessionWrites.delete(key);
986
+ }
778
987
  }
779
988
  async resolveAgent(user) {
780
989
  const agentOrFactory = this.config.agent;