@kuralle-agents/messaging 0.8.5 → 0.9.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 CHANGED
@@ -19,6 +19,7 @@ Provides the `PlatformClient` interface that every messaging vendor package impl
19
19
  - **`MessageDeduplicator`** — LRU cache that prevents duplicate webhook processing.
20
20
  - **`WindowTracker`** / **`WindowStore`** — tracks 24-hour messaging windows per thread; used by `createMessagingRouter` to detect expired windows.
21
21
  - **`OutboundPipeline`** + **`windowGuard`** — window-safe outbound path (see below).
22
+ - **`createInputCoalescer`** + **`inboundCoalescing`** — optional per-thread burst coalescing before `runtime.run` (WhatsApp text-ins). Default **off**; see [Inbound coalescing](#inbound-coalescing).
22
23
 
23
24
  ### Window-safe outbound
24
25
 
@@ -27,6 +28,31 @@ Every outbound send — default `StreamMapper` text replies, custom `responseMap
27
28
  `createMessagingRouter` accepts optional `windowStore` (default `InMemoryWindowStore`) and `outbound` (extra middleware installed **before** `windowGuard`). Custom `responseMapper` closures still return `Promise<SendResult>`; deferred sends resolve to a synthetic result with an empty `messageId` (not delivered).
28
29
 
29
30
  `WhatsAppClient.sendTextOrTemplate` is **deprecated** — it bypasses this pipeline. Use `OutboundPipeline` / the router instead.
31
+
32
+ ### Inbound coalescing
33
+
34
+ WhatsApp users often send bursts of short messages (`hi` / `i want to order` / `the blue one`). Without coalescing, each webhook becomes its own serialized turn. Enable burst merging on the router:
35
+
36
+ ```typescript
37
+ const router = createMessagingRouter({
38
+ runtime,
39
+ platforms: { whatsapp },
40
+ inboundCoalescing: {
41
+ debounceMs: 3000, // trailing debounce; 0 = off (pass-through)
42
+ maxWaitMs: 10_000, // hard cap from first buffered message
43
+ maxMessages: 10, // flush when buffer is full
44
+ },
45
+ });
46
+ ```
47
+
48
+ Defaults when `inboundCoalescing` is set: `debounceMs` **3000**, `maxWaitMs` **10000**, `maxMessages` **10**. Interactive selections (button/list taps) flush immediately — they are complete by construction. Each flushed batch becomes **one** `runtime.run` with a merged `UserInputContent` parts array in arrival order (image-then-caption → `[FilePart, TextPart]`).
49
+
50
+ Omit `inboundCoalescing` entirely for today's behavior (one message → one turn).
51
+
52
+ **Durable Objects:** v1 keeps the coalescer buffer in-memory on the router instance. In a DO deployment the DO *is* the thread, so the timer lives with the conversation. Process eviction can lose at most one un-flushed buffer; upgrade to `storage.setAlarm` for eviction-proof debounce (not implemented in v1).
53
+
54
+ Mid-turn messages queued while a turn is in flight are merged at admission in `@kuralle-agents/core` (`consumeAllPendingUserInput`) — pair both layers for burst UX on WhatsApp.
55
+
30
56
  - Error classes: `MessagingError`, `RateLimitError` (with `retryAfterMs`), `WindowClosedError` (with `suggestedTemplates`), `AuthenticationError`, `PermissionError`, `RecipientError`, `TemplateError`, `MediaError`, `WebhookVerificationError`.
31
57
 
32
58
  ## Usage
@@ -1,9 +1,11 @@
1
1
  import { Hono } from 'hono';
2
+ import { mergeUserInputContents } from '@kuralle-agents/core';
2
3
  import { MessageDeduplicator } from '../shared/deduplicator.js';
3
4
  import { InMemoryWindowStore } from './window-store.js';
4
5
  import { defaultSessionResolver } from './session-resolver.js';
5
6
  import { InboundResolverChain, defaultInboundChain, } from './input-resolver-chain.js';
6
7
  import { attachInboundMedia } from './inbound-media.js';
8
+ import { createInputCoalescer } from './input-coalescer.js';
7
9
  import { StreamMapper } from './stream-mapper.js';
8
10
  import { OutboundPipeline } from './outbound-pipeline.js';
9
11
  import { windowGuard } from './middleware/window-guard.js';
@@ -86,6 +88,16 @@ export function createMessagingRouter(config) {
86
88
  : defaultInboundChain();
87
89
  const streamMapper = new StreamMapper();
88
90
  const fallbackMessage = config.fallbackMessage ?? "Sorry, I'm having trouble right now. Please try again.";
91
+ const coalescing = config.inboundCoalescing;
92
+ const coalescer = coalescing
93
+ ? createInputCoalescer({
94
+ debounceMs: coalescing.debounceMs,
95
+ maxWaitMs: coalescing.maxWaitMs,
96
+ maxMessages: coalescing.maxMessages,
97
+ timer: coalescing.timer,
98
+ flushImmediately: coalescing.flushImmediately ?? ((item) => item.selection !== undefined),
99
+ })
100
+ : undefined;
89
101
  for (const [name, platform] of Object.entries(config.platforms)) {
90
102
  const pipeline = new OutboundPipeline(buildOutboundChain(config.outbound), platform);
91
103
  platform.onMessage(async (message) => {
@@ -105,44 +117,63 @@ export function createMessagingRouter(config) {
105
117
  // Multimodal intake: download any inbound media and attach it as AI SDK file
106
118
  // parts so photos / voice notes / documents reach the model (REQ multimodal).
107
119
  const input = await attachInboundMedia(message, resolvedInput, platform);
108
- try {
109
- const handle = config.runtime.run({
110
- input,
111
- sessionId,
112
- userId,
113
- selection,
114
- });
115
- const parts = await streamMapper.mapStream(handle.events, platform, message.threadId, {
116
- responseMapper: config.responseMapper,
117
- pipeline,
118
- windowStore,
119
- sessionId,
120
- userId,
121
- });
122
- if (config.ownership &&
123
- parts.some((p) => p.type === 'handoff' && p.targetAgent === 'human')) {
124
- await config.ownership.claim(message.threadId, 'human');
125
- }
126
- }
127
- catch (error) {
120
+ const runInbound = async (items) => {
121
+ const mergedInput = mergeUserInputContents(items.map((i) => i.input)) ?? '';
122
+ const last = items[items.length - 1];
123
+ const { sessionId, userId } = last;
128
124
  try {
129
- const window = await windowStore.get(message.threadId);
130
- await pipeline.send({
131
- threadId: message.threadId,
132
- platform: name,
133
- payload: { kind: 'text', text: fallbackMessage },
134
- meta: { window, parts: [], sessionId, userId },
125
+ const handle = config.runtime.run({
126
+ input: mergedInput,
127
+ sessionId,
128
+ userId,
129
+ selection: last.selection,
135
130
  });
131
+ const parts = await streamMapper.mapStream(handle.events, platform, message.threadId, {
132
+ responseMapper: config.responseMapper,
133
+ pipeline,
134
+ windowStore,
135
+ sessionId,
136
+ userId,
137
+ });
138
+ if (config.ownership &&
139
+ parts.some((p) => p.type === 'handoff' && p.targetAgent === 'human')) {
140
+ await config.ownership.claim(message.threadId, 'human');
141
+ }
136
142
  }
137
- catch {
138
- // Cannot even send fallback — nothing more we can do
143
+ catch (error) {
144
+ try {
145
+ const window = await windowStore.get(message.threadId);
146
+ await pipeline.send({
147
+ threadId: message.threadId,
148
+ platform: name,
149
+ payload: { kind: 'text', text: fallbackMessage },
150
+ meta: { window, parts: [], sessionId, userId },
151
+ });
152
+ }
153
+ catch {
154
+ // Cannot even send fallback — nothing more we can do
155
+ }
156
+ const errorContext = {
157
+ message: last.message,
158
+ platform: name,
159
+ error: error,
160
+ };
161
+ config.onError?.(error, errorContext);
139
162
  }
140
- const errorContext = {
141
- message,
142
- platform: name,
143
- error: error,
144
- };
145
- config.onError?.(error, errorContext);
163
+ };
164
+ const item = {
165
+ input,
166
+ selection,
167
+ sessionId,
168
+ userId,
169
+ message,
170
+ platform: name,
171
+ };
172
+ if (coalescer) {
173
+ coalescer.push(message.threadId, item, runInbound);
174
+ }
175
+ else {
176
+ await runInbound([item]);
146
177
  }
147
178
  });
148
179
  platform.onStatus(async (status) => {
@@ -0,0 +1,14 @@
1
+ import type { InjectableTimer } from '@kuralle-agents/core';
2
+ export type InputCoalescerTimer = InjectableTimer;
3
+ export interface InputCoalescerOptions<T> {
4
+ debounceMs?: number;
5
+ maxWaitMs?: number;
6
+ maxMessages?: number;
7
+ flushImmediately?: (item: T) => boolean;
8
+ timer?: InputCoalescerTimer;
9
+ }
10
+ export interface InputCoalescer<T> {
11
+ push(threadId: string, item: T, deliver: (items: T[]) => void | Promise<void>): void;
12
+ flush(threadId: string): void;
13
+ }
14
+ export declare function createInputCoalescer<T>(opts?: InputCoalescerOptions<T>): InputCoalescer<T>;
@@ -0,0 +1,86 @@
1
+ const defaultTimer = {
2
+ set: (fn, ms) => setTimeout(fn, ms),
3
+ clear: (handle) => clearTimeout(handle),
4
+ };
5
+ export function createInputCoalescer(opts = {}) {
6
+ const debounceMs = opts.debounceMs ?? 3000;
7
+ const maxWaitMs = opts.maxWaitMs ?? 10000;
8
+ const maxMessages = opts.maxMessages ?? 10;
9
+ const flushImmediately = opts.flushImmediately ?? (() => false);
10
+ const timer = opts.timer ?? defaultTimer;
11
+ const buffers = new Map();
12
+ function clearTimers(buf) {
13
+ if (buf.debounceHandle != null)
14
+ timer.clear(buf.debounceHandle);
15
+ if (buf.maxWaitHandle != null)
16
+ timer.clear(buf.maxWaitHandle);
17
+ buf.debounceHandle = undefined;
18
+ buf.maxWaitHandle = undefined;
19
+ }
20
+ // Delivery runs a full agent turn — invoke synchronously (tests and router
21
+ // ordering rely on it) but never let a sync throw or async rejection escape.
22
+ function safeDeliver(deliver, items) {
23
+ try {
24
+ const result = deliver(items);
25
+ if (result && typeof result.catch === 'function') {
26
+ void result.catch((error) => {
27
+ console.warn('[Kuralle] coalesced inbound delivery failed:', error);
28
+ });
29
+ }
30
+ }
31
+ catch (error) {
32
+ console.warn('[Kuralle] coalesced inbound delivery failed:', error);
33
+ }
34
+ }
35
+ function invokeDeliver(buf) {
36
+ safeDeliver(buf.deliver, buf.items);
37
+ }
38
+ function flush(threadId) {
39
+ const buf = buffers.get(threadId);
40
+ if (!buf || buf.items.length === 0)
41
+ return;
42
+ clearTimers(buf);
43
+ buffers.delete(threadId);
44
+ invokeDeliver(buf);
45
+ }
46
+ function push(threadId, item, deliver) {
47
+ if (debounceMs === 0) {
48
+ safeDeliver(deliver, [item]);
49
+ return;
50
+ }
51
+ if (flushImmediately(item)) {
52
+ const buf = buffers.get(threadId);
53
+ const batch = buf ? [...buf.items, item] : [item];
54
+ if (buf) {
55
+ clearTimers(buf);
56
+ buffers.delete(threadId);
57
+ }
58
+ safeDeliver(deliver, batch);
59
+ return;
60
+ }
61
+ let buf = buffers.get(threadId);
62
+ if (!buf) {
63
+ buf = {
64
+ items: [],
65
+ firstAt: Date.now(),
66
+ debounceHandle: undefined,
67
+ maxWaitHandle: undefined,
68
+ deliver,
69
+ };
70
+ buffers.set(threadId, buf);
71
+ buf.maxWaitHandle = timer.set(() => flush(threadId), maxWaitMs);
72
+ }
73
+ else {
74
+ buf.deliver = deliver;
75
+ }
76
+ buf.items.push(item);
77
+ if (buf.items.length >= maxMessages) {
78
+ flush(threadId);
79
+ return;
80
+ }
81
+ if (buf.debounceHandle != null)
82
+ timer.clear(buf.debounceHandle);
83
+ buf.debounceHandle = timer.set(() => flush(threadId), debounceMs);
84
+ }
85
+ return { push, flush };
86
+ }
@@ -0,0 +1,20 @@
1
+ import type { ChoiceOption } from '@kuralle-agents/core';
2
+ import type { InteractiveMessage } from '../types/messages.js';
3
+ /** WhatsApp reply-button title limit (R-11). */
4
+ export declare const BUTTON_TITLE_MAX = 20;
5
+ /** WhatsApp list row title limit (R-11). */
6
+ export declare const LIST_ROW_TITLE_MAX = 24;
7
+ /** WhatsApp reply buttons per message. */
8
+ export declare const BUTTON_COUNT_MAX = 3;
9
+ /** WhatsApp list rows per message. */
10
+ export declare const LIST_ROW_COUNT_MAX = 10;
11
+ /**
12
+ * Renders author choices to a channel-neutral `InteractiveMessage`, enforcing
13
+ * WhatsApp limits (R-11). URL options map to a single-button reply shape because
14
+ * `InteractiveMessage` has no dedicated CTA variant — the sink must interpret
15
+ * `ChoiceOption.url` when sending platform CTA messages.
16
+ *
17
+ * Lives in `@kuralle-agents/messaging` (single source); `@kuralle-agents/engagement`
18
+ * re-exports it.
19
+ */
20
+ export declare function renderChoices(options: ChoiceOption[], prompt: string): InteractiveMessage;
@@ -0,0 +1,105 @@
1
+ /** WhatsApp reply-button title limit (R-11). */
2
+ export const BUTTON_TITLE_MAX = 20;
3
+ /** WhatsApp list row title limit (R-11). */
4
+ export const LIST_ROW_TITLE_MAX = 24;
5
+ /** WhatsApp reply buttons per message. */
6
+ export const BUTTON_COUNT_MAX = 3;
7
+ /** WhatsApp list rows per message. */
8
+ export const LIST_ROW_COUNT_MAX = 10;
9
+ function assertButtonTitle(label, optionId) {
10
+ if (label.length > BUTTON_TITLE_MAX) {
11
+ throw new Error(`interactive: button title for "${optionId}" exceeds ${BUTTON_TITLE_MAX} characters (got ${label.length})`);
12
+ }
13
+ }
14
+ function assertListRowTitle(label, optionId) {
15
+ if (label.length > LIST_ROW_TITLE_MAX) {
16
+ throw new Error(`interactive: list row title for "${optionId}" exceeds ${LIST_ROW_TITLE_MAX} characters (got ${label.length})`);
17
+ }
18
+ }
19
+ /**
20
+ * Renders author choices to a channel-neutral `InteractiveMessage`, enforcing
21
+ * WhatsApp limits (R-11). URL options map to a single-button reply shape because
22
+ * `InteractiveMessage` has no dedicated CTA variant — the sink must interpret
23
+ * `ChoiceOption.url` when sending platform CTA messages.
24
+ *
25
+ * Lives in `@kuralle-agents/messaging` (single source); `@kuralle-agents/engagement`
26
+ * re-exports it.
27
+ */
28
+ export function renderChoices(options, prompt) {
29
+ if (options.length === 0) {
30
+ throw new Error('interactive: at least one option is required');
31
+ }
32
+ const flowOption = options.find((o) => o.flow);
33
+ if (flowOption) {
34
+ if (options.length > 1) {
35
+ throw new Error('interactive: flow messages support exactly one option');
36
+ }
37
+ const { flowId } = flowOption.flow;
38
+ if (!flowOption.flow.cta) {
39
+ throw new Error('interactive: flow option requires flow.cta');
40
+ }
41
+ return {
42
+ type: 'flow',
43
+ body: prompt,
44
+ action: {
45
+ type: 'flow',
46
+ flowId,
47
+ flowToken: flowOption.id,
48
+ },
49
+ };
50
+ }
51
+ if (options.some((o) => o.url)) {
52
+ const urlOptions = options.filter((o) => o.url);
53
+ if (urlOptions.length > BUTTON_COUNT_MAX) {
54
+ throw new Error(`interactive: too many URL options (max ${BUTTON_COUNT_MAX} reply buttons)`);
55
+ }
56
+ for (const o of urlOptions) {
57
+ assertButtonTitle(o.label, o.id);
58
+ }
59
+ return {
60
+ type: 'buttons',
61
+ body: prompt,
62
+ action: {
63
+ type: 'buttons',
64
+ buttons: urlOptions.map((o) => ({ id: o.id, title: o.label })),
65
+ },
66
+ };
67
+ }
68
+ if (options.length > LIST_ROW_COUNT_MAX) {
69
+ throw new Error(`interactive: too many options (max ${LIST_ROW_COUNT_MAX} list rows)`);
70
+ }
71
+ if (options.length <= BUTTON_COUNT_MAX) {
72
+ for (const o of options) {
73
+ assertButtonTitle(o.label, o.id);
74
+ }
75
+ return {
76
+ type: 'buttons',
77
+ body: prompt,
78
+ action: {
79
+ type: 'buttons',
80
+ buttons: options.map((o) => ({ id: o.id, title: o.label })),
81
+ },
82
+ };
83
+ }
84
+ for (const o of options) {
85
+ assertListRowTitle(o.label, o.id);
86
+ }
87
+ return {
88
+ type: 'list',
89
+ body: prompt,
90
+ action: {
91
+ type: 'list',
92
+ button: 'Choose',
93
+ sections: [
94
+ {
95
+ title: 'Options',
96
+ rows: options.map((o) => ({
97
+ id: o.id,
98
+ title: o.label,
99
+ description: o.description,
100
+ })),
101
+ },
102
+ ],
103
+ },
104
+ };
105
+ }
@@ -1,3 +1,4 @@
1
+ import { renderChoices } from './render-choices.js';
1
2
  /** Default interval for sending typing indicators during streaming (ms). */
2
3
  const DEFAULT_TYPING_INTERVAL_MS = 5_000;
3
4
  function outcomeToSendResult(threadId, outcome) {
@@ -48,7 +49,7 @@ export class StreamMapper {
48
49
  });
49
50
  }
50
51
  else {
51
- await this.defaultMapResponse(options.pipeline, platform, threadId, textBuffer, meta);
52
+ await this.defaultMapResponse(options.pipeline, platform, threadId, textBuffer, meta, parts);
52
53
  }
53
54
  }
54
55
  finally {
@@ -76,7 +77,34 @@ export class StreamMapper {
76
77
  }
77
78
  return outcomeToSendResult(threadId, outcome);
78
79
  }
79
- async defaultMapResponse(pipeline, platform, threadId, text, meta) {
80
+ async defaultMapResponse(pipeline, platform, threadId, text, meta, parts) {
81
+ // A turn that ends with an `interactive` part (flow choices) renders as a
82
+ // native interactive message; its prompt IS the user-facing question, so
83
+ // accumulated text is sent first only when it adds something beyond it.
84
+ const interactivePart = [...parts]
85
+ .reverse()
86
+ .find((part) => part.type === 'interactive');
87
+ if (interactivePart) {
88
+ const trimmed = text.trim();
89
+ if (trimmed.length > 0 && trimmed !== interactivePart.prompt.trim()) {
90
+ await pipeline.send({
91
+ threadId,
92
+ platform: platform.platform,
93
+ payload: { kind: 'text', text: platform.formatConverter.toPlatformFormat(trimmed) },
94
+ meta,
95
+ });
96
+ }
97
+ await pipeline.send({
98
+ threadId,
99
+ platform: platform.platform,
100
+ payload: {
101
+ kind: 'interactive',
102
+ interactive: renderChoices(interactivePart.options, interactivePart.prompt),
103
+ },
104
+ meta,
105
+ });
106
+ return;
107
+ }
80
108
  if (text.trim().length === 0)
81
109
  return;
82
110
  const formatted = platform.formatConverter.toPlatformFormat(text);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
- export type { PlatformClient, InboundMessage, StatusUpdate, SendResult, MediaPayload, MediaHandle, MediaDownload, MediaUploadOptions, MediaReference, InteractiveMessage, InteractiveAction, InteractiveReply, ContactInfo, LocationData, ReactionData, MessageContext, ConversationInfo, PricingInfo, StatusError, FormatConverter, MessageHandler, StatusHandler, ReactionHandler, MessagingRouterConfig, SessionResolver, ResponseMapper, ResponseContext, ErrorContext, StreamMapperOptions, HealthCheckResult, } from './types.js';
1
+ export type { PlatformClient, InboundMessage, StatusUpdate, SendResult, MediaPayload, MediaHandle, MediaDownload, MediaUploadOptions, MediaReference, InteractiveMessage, InteractiveAction, InteractiveReply, ContactInfo, LocationData, ReactionData, MessageContext, ConversationInfo, PricingInfo, StatusError, FormatConverter, MessageHandler, StatusHandler, ReactionHandler, MessagingRouterConfig, InboundCoalescingConfig, CoalescedInboundItem, SessionResolver, ResponseMapper, ResponseContext, ErrorContext, StreamMapperOptions, HealthCheckResult, } from './types.js';
2
2
  export { MessagingError, RateLimitError, WindowClosedError, AuthenticationError, PermissionError, RecipientError, TemplateError, MediaError, WebhookVerificationError, } from './errors.js';
3
3
  export { createMessagingRouter } from './adapter/createMessagingRouter.js';
4
+ export { createInputCoalescer } from './adapter/input-coalescer.js';
5
+ export type { InputCoalescer, InputCoalescerOptions } from './adapter/input-coalescer.js';
4
6
  export { defaultSessionResolver } from './adapter/session-resolver.js';
5
7
  export { SessionResolverChain, ThreadIdResolver, PhoneLookupResolver, } from './adapter/session-resolver-chain.js';
6
8
  export type { SessionResolverPlugin } from './adapter/session-resolver-chain.js';
@@ -28,3 +30,4 @@ export { OutboundPipeline } from './adapter/outbound-pipeline.js';
28
30
  export { windowGuard } from './adapter/middleware/window-guard.js';
29
31
  export type { OutboundSink, OutboundTemplate, OutboundTemplateComponent, OutboundMiddleware, OutboundNext, OutboundRequest, OutboundPayload, OutboundMeta, SendOutcome, DeferReason, } from './types/outbound.js';
30
32
  export { isTemplateCapable, isTagCapable } from './types/outbound.js';
33
+ export { renderChoices, BUTTON_TITLE_MAX, LIST_ROW_TITLE_MAX, BUTTON_COUNT_MAX, LIST_ROW_COUNT_MAX, } from './adapter/render-choices.js';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export { MessagingError, RateLimitError, WindowClosedError, AuthenticationError,
6
6
  // Adapter
7
7
  // ====================================
8
8
  export { createMessagingRouter } from './adapter/createMessagingRouter.js';
9
+ export { createInputCoalescer } from './adapter/input-coalescer.js';
9
10
  export { defaultSessionResolver } from './adapter/session-resolver.js';
10
11
  export { SessionResolverChain, ThreadIdResolver, PhoneLookupResolver, } from './adapter/session-resolver-chain.js';
11
12
  export { InboundResolverChain, InteractiveResolver, TextResolver, defaultInboundChain, } from './adapter/input-resolver-chain.js';
@@ -32,3 +33,4 @@ export { filterStreamParts } from './stream-filter.js';
32
33
  export { OutboundPipeline } from './adapter/outbound-pipeline.js';
33
34
  export { windowGuard } from './adapter/middleware/window-guard.js';
34
35
  export { isTemplateCapable, isTagCapable } from './types/outbound.js';
36
+ export { renderChoices, BUTTON_TITLE_MAX, LIST_ROW_TITLE_MAX, BUTTON_COUNT_MAX, LIST_ROW_COUNT_MAX, } from './adapter/render-choices.js';
@@ -4,7 +4,7 @@
4
4
  * Adapter types: session resolution, response mapping, error context,
5
5
  * router config, and stream mapper options.
6
6
  */
7
- import type { RuntimeLike, HarnessStreamPart } from '@kuralle-agents/core';
7
+ import type { RuntimeLike, HarnessStreamPart, ResolvedSelection, UserInputContent, InjectableTimer } from '@kuralle-agents/core';
8
8
  import type { OutboundPipeline } from '../adapter/outbound-pipeline.js';
9
9
  import type { WindowStore } from '../adapter/window-store.js';
10
10
  import type { ConsentStore } from '../adapter/consent-store.js';
@@ -65,6 +65,33 @@ export interface MessagingRouterConfig {
65
65
  ownership?: OwnershipStore;
66
66
  /** When set, STOP inbound opts the customer out; outbound uses `consentGate` (REQ-11). */
67
67
  consent?: ConsentStore;
68
+ /**
69
+ * Per-thread inbound debounce/coalesce before `runtime.run`. Default off (each
70
+ * message is its own turn). See README for `debounceMs`, `maxWaitMs`, DO note.
71
+ */
72
+ inboundCoalescing?: InboundCoalescingConfig;
73
+ }
74
+ /** Sliding debounce + max-wait cap for burst WhatsApp text-ins. */
75
+ export interface InboundCoalescingConfig {
76
+ /** Trailing debounce in ms; `0` disables coalescing (pass-through). Default 3000. */
77
+ debounceMs?: number;
78
+ /** Hard cap from first buffered message (ms). Default 10000. */
79
+ maxWaitMs?: number;
80
+ /** Flush when buffer reaches this count. Default 10. */
81
+ maxMessages?: number;
82
+ /** Immediate flush predicate; default: any resolved interactive selection. */
83
+ flushImmediately?: (item: CoalescedInboundItem) => boolean;
84
+ /** Injectable timer for deterministic tests. */
85
+ timer?: InjectableTimer;
86
+ }
87
+ /** One resolved inbound waiting in the coalescer or about to run. */
88
+ export interface CoalescedInboundItem {
89
+ input: UserInputContent;
90
+ selection?: ResolvedSelection;
91
+ sessionId: string;
92
+ userId?: string;
93
+ message: InboundMessage;
94
+ platform: string;
68
95
  }
69
96
  /** Options for the stream mapper. */
70
97
  export interface StreamMapperOptions {
package/dist/types.d.ts CHANGED
@@ -7,4 +7,4 @@
7
7
  export type { ContactInfo, LocationData, MediaPayload, MediaReference, MediaHandle, MediaDownload, MediaUploadOptions, InteractiveMessage, InteractiveAction, InteractiveReply, ReactionData, MessageContext, ConversationInfo, PricingInfo, StatusError, InboundMessage, StatusUpdate, } from './types/messages.js';
8
8
  export type { SendResult, FormatConverter } from './types/responses.js';
9
9
  export type { MessageHandler, StatusHandler, ReactionHandler, PlatformClient, HealthCheckResult, } from './types/client.js';
10
- export type { SessionResolver, ResponseContext, ResponseMapper, ErrorContext, MessagingRouterConfig, StreamMapperOptions, } from './types/adapter.js';
10
+ export type { SessionResolver, ResponseContext, ResponseMapper, ErrorContext, MessagingRouterConfig, InboundCoalescingConfig, CoalescedInboundItem, StreamMapperOptions, } from './types/adapter.js';
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/kuralle/kuralle-agents.git",
7
7
  "directory": "packages/kuralle-messaging"
8
8
  },
9
- "version": "0.8.5",
9
+ "version": "0.9.0",
10
10
  "description": "Core interfaces and Kuralle adapter for messaging platforms",
11
11
  "type": "module",
12
12
  "main": "dist/index.js",
@@ -26,7 +26,7 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "hono": "^4.7.4",
29
- "@kuralle-agents/core": "0.8.5"
29
+ "@kuralle-agents/core": "0.9.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "typescript": "^5.8.2",