@kuralle-agents/messaging 0.9.0 → 0.11.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
@@ -16,7 +16,7 @@ Provides the `PlatformClient` interface that every messaging vendor package impl
16
16
  - **`PlatformClient`** — interface that normalizes sending, receiving, media, webhooks, and format conversion across vendors. Implement this to add any messaging platform.
17
17
  - **`SessionResolver`** — maps inbound messages to Kuralle session IDs. Default: `{platform}:{threadId}`. Swap in `ThreadIdResolver`, `PhoneLookupResolver`, or a custom `SessionResolverChain`.
18
18
  - **`StreamMapper`** — consumes `AsyncIterable<HarnessStreamPart>`, sends typing indicators during streaming, delegates final output to a `ResponseMapper`.
19
- - **`MessageDeduplicator`** — LRU cache that prevents duplicate webhook processing.
19
+ - **`InboundLedger`** — async claim/append/complete ledger for tenant-scoped inbound idempotency and ordering.
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
22
  - **`createInputCoalescer`** + **`inboundCoalescing`** — optional per-thread burst coalescing before `runtime.run` (WhatsApp text-ins). Default **off**; see [Inbound coalescing](#inbound-coalescing).
@@ -1,36 +1,3 @@
1
1
  import { Hono } from 'hono';
2
2
  import type { MessagingRouterConfig } from '../types.js';
3
- /**
4
- * Create a Hono router that bridges messaging platform webhooks with the Kuralle runtime.
5
- *
6
- * For each platform in the config, this function:
7
- * 1. Registers message, status, and reaction handlers on the platform client
8
- * 2. Mounts GET and POST webhook routes at `/{platformName}/webhook`
9
- *
10
- * When a message arrives:
11
- * 1. The platform client verifies the webhook signature, parses the payload,
12
- * and dispatches to the registered message handler
13
- * 2. The handler deduplicates the message, tracks the conversation window,
14
- * resolves the session, and streams the runtime response
15
- * 3. The stream mapper sends the response back through the outbound pipeline
16
- *
17
- * @param config - Router configuration with runtime, platforms, and optional customizations.
18
- * @returns A Hono app with webhook routes mounted for each platform.
19
- *
20
- * @example
21
- * ```typescript
22
- * import { createMessagingRouter } from '@kuralle-agents/messaging';
23
- *
24
- * const router = createMessagingRouter({
25
- * runtime,
26
- * platforms: {
27
- * whatsapp: whatsappClient,
28
- * messenger: messengerClient,
29
- * },
30
- * });
31
- *
32
- * // Mount into your main Hono app
33
- * app.route('/messaging', router);
34
- * ```
35
- */
36
3
  export declare function createMessagingRouter(config: MessagingRouterConfig): Hono;
@@ -1,14 +1,14 @@
1
1
  import { Hono } from 'hono';
2
- import { mergeUserInputContents } from '@kuralle-agents/core';
3
- import { MessageDeduplicator } from '../shared/deduplicator.js';
4
2
  import { InMemoryWindowStore } from './window-store.js';
5
3
  import { defaultSessionResolver } from './session-resolver.js';
6
4
  import { InboundResolverChain, defaultInboundChain, } from './input-resolver-chain.js';
7
- import { attachInboundMedia } from './inbound-media.js';
8
5
  import { createInputCoalescer } from './input-coalescer.js';
9
6
  import { StreamMapper } from './stream-mapper.js';
10
7
  import { OutboundPipeline } from './outbound-pipeline.js';
11
8
  import { windowGuard } from './middleware/window-guard.js';
9
+ import { InMemoryInboundLedger, conversationKeyToString, } from '../inbound/ledger.js';
10
+ import { claimAndAppend, coalesceMessages, consentStop, createInboundPipeline, ownershipGate, recordWindow, resolveAndAttachMedia, runTurn, statusReactionErrorPhase, } from '../inbound/pipeline.js';
11
+ import { noopCoalesceScheduler, PlatformMediaResolver, systemClock } from '../inbound/ports.js';
12
12
  function buildOutboundChain(extra) {
13
13
  return [...(extra ?? []), windowGuard];
14
14
  }
@@ -45,42 +45,9 @@ async function recordInboundToHistory(config, sessionId, message, userId) {
45
45
  session.updatedAt = new Date();
46
46
  await store.save(session);
47
47
  }
48
- /**
49
- * Create a Hono router that bridges messaging platform webhooks with the Kuralle runtime.
50
- *
51
- * For each platform in the config, this function:
52
- * 1. Registers message, status, and reaction handlers on the platform client
53
- * 2. Mounts GET and POST webhook routes at `/{platformName}/webhook`
54
- *
55
- * When a message arrives:
56
- * 1. The platform client verifies the webhook signature, parses the payload,
57
- * and dispatches to the registered message handler
58
- * 2. The handler deduplicates the message, tracks the conversation window,
59
- * resolves the session, and streams the runtime response
60
- * 3. The stream mapper sends the response back through the outbound pipeline
61
- *
62
- * @param config - Router configuration with runtime, platforms, and optional customizations.
63
- * @returns A Hono app with webhook routes mounted for each platform.
64
- *
65
- * @example
66
- * ```typescript
67
- * import { createMessagingRouter } from '@kuralle-agents/messaging';
68
- *
69
- * const router = createMessagingRouter({
70
- * runtime,
71
- * platforms: {
72
- * whatsapp: whatsappClient,
73
- * messenger: messengerClient,
74
- * },
75
- * });
76
- *
77
- * // Mount into your main Hono app
78
- * app.route('/messaging', router);
79
- * ```
80
- */
81
48
  export function createMessagingRouter(config) {
82
49
  const app = new Hono();
83
- const deduplicator = new MessageDeduplicator();
50
+ const ledger = config.ledger ?? new InMemoryInboundLedger();
84
51
  const windowStore = config.windowStore ?? new InMemoryWindowStore();
85
52
  const sessionResolver = config.sessionResolver ?? defaultSessionResolver;
86
53
  const inboundChain = config.inputResolver
@@ -100,87 +67,56 @@ export function createMessagingRouter(config) {
100
67
  : undefined;
101
68
  for (const [name, platform] of Object.entries(config.platforms)) {
102
69
  const pipeline = new OutboundPipeline(buildOutboundChain(config.outbound), platform);
70
+ const outboundSender = new PipelineOutboundSender(streamMapper, platform, pipeline, windowStore, config.responseMapper);
71
+ const inboundRuntime = {
72
+ ledger,
73
+ window: windowStore,
74
+ consent: config.consent,
75
+ ownership: config.ownership,
76
+ media: new PlatformMediaResolver(platform),
77
+ sender: outboundSender,
78
+ runtime: new RuntimeTurnRunner(config.runtime),
79
+ scheduler: config.scheduler ?? noopCoalesceScheduler,
80
+ clock: config.clock ?? systemClock,
81
+ };
82
+ const inboundPipeline = createInboundPipeline([
83
+ claimAndAppend(),
84
+ statusReactionErrorPhase({
85
+ onStatus: async (event) => {
86
+ await config.onStatus?.(event.data);
87
+ },
88
+ }),
89
+ recordWindow(),
90
+ consentStop(),
91
+ ownershipGate({
92
+ resolveSession: (message) => sessionResolver.resolve(message),
93
+ recordHumanOwned: (message, sessionId, userId) => recordInboundToHistory(config, sessionId, message, userId),
94
+ }),
95
+ resolveAndAttachMedia(inboundChain),
96
+ coalesceMessages(coalescer),
97
+ runTurn(),
98
+ ]);
103
99
  platform.onMessage(async (message) => {
104
- if (deduplicator.isDuplicate(message.id))
105
- return;
106
- await windowStore.recordInbound(message.threadId, message.timestamp);
107
- if (config.consent && message.text?.trim().toUpperCase() === 'STOP') {
108
- await config.consent.optOut(message.customerId);
109
- return;
100
+ try {
101
+ await inboundPipeline.ingest(conversationKeyFromMessage(name, message), messageEvent(message), inboundRuntime);
110
102
  }
111
- const { sessionId, userId } = await sessionResolver.resolve(message);
112
- if (config.ownership && (await config.ownership.owner(message.threadId)) === 'human') {
113
- await recordInboundToHistory(config, sessionId, message, userId);
114
- return;
115
- }
116
- const { input: resolvedInput, selection } = await inboundChain.resolve(message);
117
- // Multimodal intake: download any inbound media and attach it as AI SDK file
118
- // parts so photos / voice notes / documents reach the model (REQ multimodal).
119
- const input = await attachInboundMedia(message, resolvedInput, platform);
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;
124
- try {
125
- const handle = config.runtime.run({
126
- input: mergedInput,
127
- sessionId,
128
- userId,
129
- selection: last.selection,
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
- }
142
- }
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);
162
- }
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]);
103
+ catch (error) {
104
+ await sendFallback({
105
+ error: error,
106
+ fallbackMessage,
107
+ message,
108
+ platformName: name,
109
+ pipeline,
110
+ windowStore,
111
+ config,
112
+ });
177
113
  }
178
114
  });
179
115
  platform.onStatus(async (status) => {
180
- if (status.conversation?.expirationTimestamp && status.threadId) {
181
- await windowStore.recordExpiry(status.threadId, status.conversation.expirationTimestamp);
182
- }
183
- config.onStatus?.(status);
116
+ await inboundPipeline.ingest(conversationKeyFromStatus(name, status), statusEvent(status), inboundRuntime);
117
+ });
118
+ platform.onReaction(async (reaction) => {
119
+ await inboundPipeline.ingest(conversationKeyFromReaction(name, reaction), reactionEvent(reaction, inboundRuntime.clock.now()), inboundRuntime);
184
120
  });
185
121
  app.get(`/${name}/webhook`, async (c) => {
186
122
  return platform.handleWebhook(c.req.raw);
@@ -213,3 +149,147 @@ export function createMessagingRouter(config) {
213
149
  }
214
150
  return app;
215
151
  }
152
+ class RuntimeTurnRunner {
153
+ runtime;
154
+ constructor(runtime) {
155
+ this.runtime = runtime;
156
+ }
157
+ async runTurn(args) {
158
+ const handle = this.runtime.run({
159
+ input: args.input,
160
+ sessionId: args.sessionId,
161
+ userId: args.userId,
162
+ selection: args.selection,
163
+ abortSignal: args.signal,
164
+ });
165
+ const parts = await collectParts(handle.events);
166
+ return turnResult(parts);
167
+ }
168
+ async deliverSignal(args) {
169
+ const handle = this.runtime.run({
170
+ sessionId: args.sessionId,
171
+ signalDelivery: args.signal,
172
+ abortSignal: args.signal2,
173
+ });
174
+ const parts = await collectParts(handle.events);
175
+ return turnResult(parts);
176
+ }
177
+ }
178
+ class PipelineOutboundSender {
179
+ streamMapper;
180
+ platform;
181
+ pipeline;
182
+ windowStore;
183
+ responseMapper;
184
+ constructor(streamMapper, platform, pipeline, windowStore, responseMapper) {
185
+ this.streamMapper = streamMapper;
186
+ this.platform = platform;
187
+ this.pipeline = pipeline;
188
+ this.windowStore = windowStore;
189
+ this.responseMapper = responseMapper;
190
+ }
191
+ async send(ctx, result) {
192
+ const threadId = threadIdForContext(ctx);
193
+ await this.streamMapper.mapParts(result.parts, this.platform, threadId, {
194
+ responseMapper: this.responseMapper,
195
+ pipeline: this.pipeline,
196
+ windowStore: this.windowStore,
197
+ sessionId: ctx.sessionId ?? conversationKeyToString(ctx.key),
198
+ userId: ctx.userId,
199
+ });
200
+ }
201
+ }
202
+ async function collectParts(stream) {
203
+ const parts = [];
204
+ for await (const part of stream) {
205
+ parts.push(part);
206
+ }
207
+ return parts;
208
+ }
209
+ function turnResult(parts) {
210
+ return {
211
+ parts,
212
+ suspended: suspendedPart(parts),
213
+ handoffToHuman: parts.some((part) => part.type === 'handoff' && part.targetAgent === 'human'),
214
+ };
215
+ }
216
+ function suspendedPart(parts) {
217
+ const paused = parts.find((part) => part.type === 'paused');
218
+ return paused ? { signalId: paused.waitingFor } : undefined;
219
+ }
220
+ async function sendFallback(args) {
221
+ const resolved = await (args.config.sessionResolver ?? defaultSessionResolver).resolve(args.message);
222
+ try {
223
+ const window = await args.windowStore.get(args.message.threadId);
224
+ await args.pipeline.send({
225
+ threadId: args.message.threadId,
226
+ platform: args.platformName,
227
+ payload: { kind: 'text', text: args.fallbackMessage },
228
+ meta: { window, parts: [], sessionId: resolved.sessionId, userId: resolved.userId },
229
+ });
230
+ }
231
+ catch {
232
+ // Preserve previous behavior: fallback send failure is swallowed, original error is reported.
233
+ }
234
+ const errorContext = {
235
+ message: args.message,
236
+ platform: args.platformName,
237
+ error: args.error,
238
+ };
239
+ args.config.onError?.(args.error, errorContext);
240
+ }
241
+ function messageEvent(message) {
242
+ return {
243
+ kind: 'message',
244
+ id: message.id,
245
+ ts: message.timestamp.getTime(),
246
+ data: message,
247
+ };
248
+ }
249
+ function statusEvent(status) {
250
+ return {
251
+ kind: 'status',
252
+ id: `status:${status.messageId}:${status.status}:${status.timestamp.getTime()}`,
253
+ ts: status.timestamp.getTime(),
254
+ data: status,
255
+ };
256
+ }
257
+ function reactionEvent(reaction, now) {
258
+ return {
259
+ kind: 'reaction',
260
+ id: `reaction:${reaction.messageId}:${reaction.userId}:${reaction.emoji}:${reaction.action}`,
261
+ ts: now,
262
+ data: reaction,
263
+ };
264
+ }
265
+ function conversationKeyFromMessage(platformName, message) {
266
+ return conversationKeyFromThread(message.platform || platformName, message.threadId);
267
+ }
268
+ function conversationKeyFromStatus(platformName, status) {
269
+ return conversationKeyFromThread(platformName, status.threadId ?? status.recipientId);
270
+ }
271
+ function conversationKeyFromReaction(platformName, reaction) {
272
+ return conversationKeyFromThread(platformName, reaction.userId);
273
+ }
274
+ function conversationKeyFromThread(platform, threadId) {
275
+ const parts = threadId.split(':');
276
+ if (parts.length >= 3 && parts[0] === platform) {
277
+ return {
278
+ platform,
279
+ businessId: parts[1],
280
+ threadId: parts.slice(2).join(':'),
281
+ };
282
+ }
283
+ return {
284
+ platform,
285
+ businessId: 'default',
286
+ threadId,
287
+ };
288
+ }
289
+ function threadIdForContext(ctx) {
290
+ if (ctx.event.kind === 'message')
291
+ return ctx.event.data.threadId;
292
+ if (ctx.event.kind === 'status' && ctx.event.data.threadId)
293
+ return ctx.event.data.threadId;
294
+ return conversationKeyToString(ctx.key);
295
+ }
@@ -2,6 +2,7 @@ import type { HarnessStreamPart } from '@kuralle-agents/core';
2
2
  import type { PlatformClient, StreamMapperOptions } from '../types.js';
3
3
  export declare class StreamMapper {
4
4
  mapStream(stream: AsyncIterable<HarnessStreamPart>, platform: PlatformClient, threadId: string, options: StreamMapperOptions): Promise<HarnessStreamPart[]>;
5
+ mapParts(parts: HarnessStreamPart[], platform: PlatformClient, threadId: string, options: StreamMapperOptions, textOverride?: string): Promise<void>;
5
6
  private buildMeta;
6
7
  private sendFreeform;
7
8
  private defaultMapResponse;
@@ -38,19 +38,7 @@ export class StreamMapper {
38
38
  }
39
39
  typingActive = false;
40
40
  clearInterval(typingInterval);
41
- const meta = await this.buildMeta(options.windowStore, threadId, parts, options.sessionId, options.userId);
42
- if (options.responseMapper) {
43
- await options.responseMapper.mapResponse(parts, {
44
- threadId,
45
- platform: platform.platform,
46
- sendText: (text) => this.sendFreeform(options.pipeline, platform, threadId, { kind: 'text', text }, meta),
47
- sendInteractive: (msg) => this.sendFreeform(options.pipeline, platform, threadId, { kind: 'interactive', interactive: msg }, meta),
48
- sendMedia: (media) => this.sendFreeform(options.pipeline, platform, threadId, { kind: 'media', media }, meta),
49
- });
50
- }
51
- else {
52
- await this.defaultMapResponse(options.pipeline, platform, threadId, textBuffer, meta, parts);
53
- }
41
+ await this.mapParts(parts, platform, threadId, options, textBuffer);
54
42
  }
55
43
  finally {
56
44
  typingActive = false;
@@ -58,6 +46,25 @@ export class StreamMapper {
58
46
  }
59
47
  return parts;
60
48
  }
49
+ async mapParts(parts, platform, threadId, options, textOverride) {
50
+ const text = textOverride ??
51
+ parts
52
+ .filter((part) => part.type === 'text-delta')
53
+ .map((part) => part.delta)
54
+ .join('');
55
+ const meta = await this.buildMeta(options.windowStore, threadId, parts, options.sessionId, options.userId);
56
+ if (options.responseMapper) {
57
+ await options.responseMapper.mapResponse(parts, {
58
+ threadId,
59
+ platform: platform.platform,
60
+ sendText: (message) => this.sendFreeform(options.pipeline, platform, threadId, { kind: 'text', text: message }, meta),
61
+ sendInteractive: (msg) => this.sendFreeform(options.pipeline, platform, threadId, { kind: 'interactive', interactive: msg }, meta),
62
+ sendMedia: (media) => this.sendFreeform(options.pipeline, platform, threadId, { kind: 'media', media }, meta),
63
+ });
64
+ return;
65
+ }
66
+ await this.defaultMapResponse(options.pipeline, platform, threadId, text, meta, parts);
67
+ }
61
68
  async buildMeta(windowStore, threadId, parts, sessionId, userId) {
62
69
  const window = await windowStore.get(threadId);
63
70
  return { window, parts, sessionId, userId };
@@ -0,0 +1,37 @@
1
+ import type { InboundEvent } from './types.js';
2
+ export type ClaimResult = 'claimed' | 'duplicate' | 'in_progress';
3
+ export interface ConversationKey {
4
+ platform: string;
5
+ businessId: string;
6
+ threadId: string;
7
+ }
8
+ export type ConvKeyStr = string;
9
+ export interface InboundLedger {
10
+ claim(key: ConversationKey, eventId: string): Promise<ClaimResult>;
11
+ complete(key: ConversationKey, eventId: string): Promise<void>;
12
+ append(key: ConversationKey, event: InboundEvent): Promise<{
13
+ seq: number;
14
+ }>;
15
+ readUnprocessed(key: ConversationKey): Promise<InboundEvent[]>;
16
+ commitCursor(key: ConversationKey, throughSeq: number, expect: number): Promise<boolean>;
17
+ prune(key: ConversationKey, ttlMs: number): Promise<number>;
18
+ }
19
+ export declare const ledgerSeq: unique symbol;
20
+ export type SequencedInboundEvent = InboundEvent & {
21
+ [ledgerSeq]?: number;
22
+ };
23
+ export declare function conversationKeyToString(key: ConversationKey): ConvKeyStr;
24
+ export declare function eventSeq(event: InboundEvent): number | undefined;
25
+ export declare class InMemoryInboundLedger implements InboundLedger {
26
+ private readonly states;
27
+ claim(key: ConversationKey, eventId: string): Promise<ClaimResult>;
28
+ complete(key: ConversationKey, eventId: string): Promise<void>;
29
+ append(key: ConversationKey, event: InboundEvent): Promise<{
30
+ seq: number;
31
+ }>;
32
+ readUnprocessed(key: ConversationKey): Promise<InboundEvent[]>;
33
+ commitCursor(key: ConversationKey, throughSeq: number, expect: number): Promise<boolean>;
34
+ prune(key: ConversationKey, ttlMs: number): Promise<number>;
35
+ cursor(key: ConversationKey): number;
36
+ private state;
37
+ }
@@ -0,0 +1,72 @@
1
+ export const ledgerSeq = Symbol.for('@kuralle-agents/messaging/inbound-seq');
2
+ export function conversationKeyToString(key) {
3
+ return `${key.platform}:${key.businessId}:${key.threadId}`;
4
+ }
5
+ export function eventSeq(event) {
6
+ return event[ledgerSeq];
7
+ }
8
+ export class InMemoryInboundLedger {
9
+ states = new Map();
10
+ async claim(key, eventId) {
11
+ const state = this.state(key);
12
+ const existing = state.claims.get(eventId);
13
+ if (existing === 'complete')
14
+ return 'duplicate';
15
+ if (existing === 'in_progress')
16
+ return 'in_progress';
17
+ state.claims.set(eventId, 'in_progress');
18
+ return 'claimed';
19
+ }
20
+ async complete(key, eventId) {
21
+ this.state(key).claims.set(eventId, 'complete');
22
+ }
23
+ async append(key, event) {
24
+ const state = this.state(key);
25
+ if (state.events.some((stored) => stored.event.id === event.id)) {
26
+ const stored = state.events.find((item) => item.event.id === event.id);
27
+ return { seq: stored.seq };
28
+ }
29
+ const seq = state.nextSeq++;
30
+ const sequenced = Object.assign({}, event);
31
+ Object.defineProperty(sequenced, ledgerSeq, {
32
+ value: seq,
33
+ enumerable: false,
34
+ });
35
+ state.events.push({ event: sequenced, seq, appendedAt: Date.now() });
36
+ state.events.sort((a, b) => a.event.ts - b.event.ts || a.seq - b.seq);
37
+ return { seq };
38
+ }
39
+ async readUnprocessed(key) {
40
+ const state = this.state(key);
41
+ return state.events
42
+ .filter((stored) => stored.seq > state.cursor)
43
+ .sort((a, b) => a.event.ts - b.event.ts || a.seq - b.seq)
44
+ .map((stored) => stored.event);
45
+ }
46
+ async commitCursor(key, throughSeq, expect) {
47
+ const state = this.state(key);
48
+ if (state.cursor !== expect)
49
+ return false;
50
+ state.cursor = Math.max(state.cursor, throughSeq);
51
+ return true;
52
+ }
53
+ async prune(key, ttlMs) {
54
+ const state = this.state(key);
55
+ const cutoff = Date.now() - ttlMs;
56
+ const before = state.events.length;
57
+ state.events = state.events.filter((stored) => stored.seq > state.cursor || stored.appendedAt >= cutoff);
58
+ return before - state.events.length;
59
+ }
60
+ cursor(key) {
61
+ return this.state(key).cursor;
62
+ }
63
+ state(key) {
64
+ const str = conversationKeyToString(key);
65
+ let state = this.states.get(str);
66
+ if (!state) {
67
+ state = { nextSeq: 1, cursor: 0, claims: new Map(), events: [] };
68
+ this.states.set(str, state);
69
+ }
70
+ return state;
71
+ }
72
+ }
@@ -0,0 +1,40 @@
1
+ import type { CoalescedInboundItem } from '../types/adapter.js';
2
+ import type { InboundMessage } from '../types/messages.js';
3
+ import type { InputCoalescer } from '../adapter/input-coalescer.js';
4
+ import type { InboundResolverChain } from '../adapter/input-resolver-chain.js';
5
+ import { type ConversationKey } from './ledger.js';
6
+ import type { InboundContext, InboundEvent, InboundMiddleware, InboundOutcome, InboundRuntime } from './types.js';
7
+ export type CoalescedPipelineItem = CoalescedInboundItem & {
8
+ key: ConversationKey;
9
+ eventId: string;
10
+ seq: number;
11
+ ctx: InboundContext;
12
+ };
13
+ export declare function createInboundPipeline(mw: InboundMiddleware[]): {
14
+ ingest(key: ConversationKey, event: InboundEvent, rt: InboundRuntime): Promise<InboundOutcome>;
15
+ flush(key: ConversationKey, rt: InboundRuntime): Promise<InboundOutcome>;
16
+ };
17
+ export declare function claimAndAppend(): InboundMiddleware;
18
+ export declare function statusReactionErrorPhase(options?: {
19
+ onStatus?: (status: InboundEvent & {
20
+ kind: 'status';
21
+ }) => Promise<void>;
22
+ onReaction?: (reaction: InboundEvent & {
23
+ kind: 'reaction';
24
+ }) => Promise<void>;
25
+ onErrorEvent?: (event: InboundEvent & {
26
+ kind: 'error';
27
+ }) => Promise<void>;
28
+ }): InboundMiddleware;
29
+ export declare function recordWindow(): InboundMiddleware;
30
+ export declare function consentStop(): InboundMiddleware;
31
+ export declare function ownershipGate(options: {
32
+ resolveSession(message: InboundMessage): Promise<{
33
+ sessionId: string;
34
+ userId?: string;
35
+ }>;
36
+ recordHumanOwned(message: InboundMessage, sessionId: string, userId?: string): Promise<void>;
37
+ }): InboundMiddleware;
38
+ export declare function resolveAndAttachMedia(inboundChain: InboundResolverChain): InboundMiddleware;
39
+ export declare function coalesceMessages(coalescer: InputCoalescer<CoalescedPipelineItem> | undefined): InboundMiddleware;
40
+ export declare function runTurn(): InboundMiddleware;
@@ -0,0 +1,239 @@
1
+ import { mergeUserInputContents } from '@kuralle-agents/core';
2
+ import { eventSeq, } from './ledger.js';
3
+ export function createInboundPipeline(mw) {
4
+ async function run(ctx) {
5
+ const dispatch = (i) => {
6
+ const middleware = mw[i];
7
+ if (!middleware)
8
+ return Promise.resolve({ kind: 'short', by: 'pipeline', reason: 'no-input' });
9
+ const next = () => dispatch(i + 1);
10
+ return middleware.handle(ctx, next);
11
+ };
12
+ const outcome = await dispatch(0);
13
+ await finalizeIfTerminal(ctx, outcome);
14
+ return outcome;
15
+ }
16
+ return {
17
+ ingest(key, event, rt) {
18
+ return run({ key, event, rt, locals: {} });
19
+ },
20
+ async flush(key, rt) {
21
+ const events = await rt.ledger.readUnprocessed(key);
22
+ let last = { kind: 'short', by: 'pipeline', reason: 'no-input' };
23
+ for (const event of events) {
24
+ last = await run({ key, event, rt, locals: { replay: true } });
25
+ }
26
+ return last;
27
+ },
28
+ };
29
+ }
30
+ async function finalizeIfTerminal(ctx, outcome) {
31
+ if (ctx.locals.claimResult !== 'claimed' || outcome.kind === 'buffered')
32
+ return;
33
+ await completeAndCommit(ctx, ctx.event.id);
34
+ }
35
+ async function completeAndCommit(ctx, eventId) {
36
+ await ctx.rt.ledger.complete(ctx.key, eventId);
37
+ const seq = typeof ctx.locals.seq === 'number' ? ctx.locals.seq : eventSeq(ctx.event);
38
+ const cursor = typeof ctx.locals.cursor === 'number' ? ctx.locals.cursor : 0;
39
+ if (seq !== undefined) {
40
+ const committed = await ctx.rt.ledger.commitCursor(ctx.key, seq, cursor);
41
+ if (!committed) {
42
+ await ctx.rt.ledger.commitCursor(ctx.key, seq, seq - 1);
43
+ }
44
+ }
45
+ }
46
+ export function claimAndAppend() {
47
+ return {
48
+ name: 'claim',
49
+ async handle(ctx, next) {
50
+ const claim = await ctx.rt.ledger.claim(ctx.key, ctx.event.id);
51
+ ctx.locals.claimResult = claim;
52
+ if (claim === 'duplicate') {
53
+ return { kind: 'short', by: 'claim', reason: 'duplicate' };
54
+ }
55
+ if (claim === 'in_progress') {
56
+ return { kind: 'short', by: 'claim', reason: 'in-progress' };
57
+ }
58
+ const cursorBefore = await currentCursor(ctx);
59
+ const { seq } = await ctx.rt.ledger.append(ctx.key, ctx.event);
60
+ ctx.locals.seq = seq;
61
+ ctx.locals.cursor = cursorBefore;
62
+ return next();
63
+ },
64
+ };
65
+ }
66
+ async function currentCursor(ctx) {
67
+ const events = await ctx.rt.ledger.readUnprocessed(ctx.key);
68
+ const seqs = events.map((event) => eventSeq(event)).filter((seq) => seq !== undefined);
69
+ const minSeq = Math.min(...seqs, Number.POSITIVE_INFINITY);
70
+ return minSeq === Number.POSITIVE_INFINITY ? 0 : minSeq - 1;
71
+ }
72
+ export function statusReactionErrorPhase(options) {
73
+ return {
74
+ name: 'phase-handler',
75
+ async handle(ctx, next) {
76
+ if (ctx.event.kind === 'status') {
77
+ const status = ctx.event.data;
78
+ if (status.conversation?.expirationTimestamp && status.threadId) {
79
+ await ctx.rt.window.recordExpiry(status.threadId, status.conversation.expirationTimestamp);
80
+ }
81
+ await options?.onStatus?.(ctx.event);
82
+ return { kind: 'short', by: 'phase-handler', reason: 'status' };
83
+ }
84
+ if (ctx.event.kind === 'reaction') {
85
+ await options?.onReaction?.(ctx.event);
86
+ return { kind: 'short', by: 'phase-handler', reason: 'reaction' };
87
+ }
88
+ if (ctx.event.kind === 'error') {
89
+ await options?.onErrorEvent?.(ctx.event);
90
+ return { kind: 'short', by: 'phase-handler', reason: 'error' };
91
+ }
92
+ return next();
93
+ },
94
+ };
95
+ }
96
+ export function recordWindow() {
97
+ return {
98
+ name: 'record-window',
99
+ async handle(ctx, next) {
100
+ if (ctx.event.kind === 'message') {
101
+ await ctx.rt.window.recordInbound(ctx.event.data.threadId, ctx.event.data.timestamp);
102
+ }
103
+ return next();
104
+ },
105
+ };
106
+ }
107
+ export function consentStop() {
108
+ return {
109
+ name: 'consent-stop',
110
+ async handle(ctx, next) {
111
+ if (ctx.event.kind === 'message' &&
112
+ ctx.rt.consent &&
113
+ ctx.event.data.text?.trim().toUpperCase() === 'STOP') {
114
+ await ctx.rt.consent.optOut(ctx.event.data.customerId);
115
+ return { kind: 'short', by: 'consent-stop', reason: 'opted-out' };
116
+ }
117
+ return next();
118
+ },
119
+ };
120
+ }
121
+ export function ownershipGate(options) {
122
+ return {
123
+ name: 'ownership',
124
+ async handle(ctx, next) {
125
+ if (ctx.event.kind !== 'message')
126
+ return next();
127
+ const resolved = await options.resolveSession(ctx.event.data);
128
+ ctx.sessionId = resolved.sessionId;
129
+ ctx.userId = resolved.userId;
130
+ if (ctx.rt.ownership && (await ctx.rt.ownership.owner(ctx.event.data.threadId)) === 'human') {
131
+ await options.recordHumanOwned(ctx.event.data, resolved.sessionId, resolved.userId);
132
+ return { kind: 'short', by: 'ownership', reason: 'human-owned' };
133
+ }
134
+ return next();
135
+ },
136
+ };
137
+ }
138
+ export function resolveAndAttachMedia(inboundChain) {
139
+ return {
140
+ name: 'resolve-media',
141
+ async handle(ctx, next) {
142
+ if (ctx.event.kind !== 'message')
143
+ return next();
144
+ const { input: resolvedInput, selection } = await inboundChain.resolve(ctx.event.data);
145
+ ctx.input = await ctx.rt.media.resolve(ctx.event.data, resolvedInput);
146
+ ctx.selection = selection;
147
+ return next();
148
+ },
149
+ };
150
+ }
151
+ export function coalesceMessages(coalescer) {
152
+ return {
153
+ name: 'coalesce',
154
+ async handle(ctx, next) {
155
+ if (!coalescer || ctx.event.kind !== 'message')
156
+ return next();
157
+ if (ctx.input === undefined)
158
+ return { kind: 'short', by: 'coalesce', reason: 'no-input' };
159
+ const seq = typeof ctx.locals.seq === 'number' ? ctx.locals.seq : eventSeq(ctx.event);
160
+ if (seq === undefined)
161
+ return next();
162
+ coalescer.push(ctx.event.data.threadId, {
163
+ key: ctx.key,
164
+ eventId: ctx.event.id,
165
+ seq,
166
+ input: ctx.input,
167
+ selection: ctx.selection,
168
+ sessionId: ctx.sessionId ?? `${ctx.key.platform}:${ctx.key.businessId}:${ctx.key.threadId}`,
169
+ userId: ctx.userId,
170
+ message: ctx.event.data,
171
+ platform: ctx.key.platform,
172
+ ctx,
173
+ }, async (items) => {
174
+ await runCoalesced(items);
175
+ });
176
+ return { kind: 'buffered' };
177
+ },
178
+ };
179
+ }
180
+ async function runCoalesced(items) {
181
+ if (items.length === 0)
182
+ return;
183
+ const last = items[items.length - 1];
184
+ const input = mergeUserInputContents(items.map((item) => item.input)) ?? '';
185
+ const result = await last.ctx.rt.runtime.runTurn({
186
+ key: last.key,
187
+ input,
188
+ selection: last.selection,
189
+ sessionId: last.sessionId,
190
+ userId: last.userId,
191
+ });
192
+ await last.ctx.rt.sender.send(last.ctx, result);
193
+ if (result.handoffToHuman && last.ctx.rt.ownership && last.ctx.event.kind === 'message') {
194
+ await last.ctx.rt.ownership.claim(last.ctx.event.data.threadId, 'human');
195
+ }
196
+ for (const item of items) {
197
+ await item.ctx.rt.ledger.complete(item.key, item.eventId);
198
+ }
199
+ const throughSeq = Math.max(...items.map((item) => item.seq));
200
+ await last.ctx.rt.ledger.commitCursor(last.key, throughSeq, throughSeq - items.length);
201
+ }
202
+ export function runTurn() {
203
+ return {
204
+ name: 'run-turn',
205
+ async handle(ctx) {
206
+ let result;
207
+ if (ctx.event.kind === 'signal') {
208
+ result = await ctx.rt.runtime.deliverSignal({
209
+ key: ctx.key,
210
+ sessionId: ctx.sessionId ?? `${ctx.key.platform}:${ctx.key.businessId}:${ctx.key.threadId}`,
211
+ signal: {
212
+ signalId: ctx.event.data.signalId,
213
+ name: ctx.event.data.name,
214
+ payload: ctx.event.data.payload,
215
+ },
216
+ });
217
+ }
218
+ else if (ctx.event.kind === 'message' && ctx.input !== undefined) {
219
+ result = await ctx.rt.runtime.runTurn({
220
+ key: ctx.key,
221
+ input: ctx.input,
222
+ selection: ctx.selection,
223
+ sessionId: ctx.sessionId,
224
+ userId: ctx.userId,
225
+ });
226
+ }
227
+ else {
228
+ return { kind: 'short', by: 'run-turn', reason: 'no-input' };
229
+ }
230
+ await ctx.rt.sender.send(ctx, result);
231
+ if (result.handoffToHuman && ctx.rt.ownership && ctx.event.kind === 'message') {
232
+ await ctx.rt.ownership.claim(ctx.event.data.threadId, 'human');
233
+ }
234
+ if (result.suspended)
235
+ return { kind: 'suspended', signalId: result.suspended.signalId };
236
+ return { kind: 'ran', parts: result.parts };
237
+ },
238
+ };
239
+ }
@@ -0,0 +1,18 @@
1
+ import type { UserInputContent } from '@kuralle-agents/core';
2
+ import type { InboundMessage } from '../types/messages.js';
3
+ import type { PlatformClient } from '../types/client.js';
4
+ export interface MediaResolver {
5
+ resolve(message: InboundMessage, input: UserInputContent): Promise<UserInputContent>;
6
+ }
7
+ export declare class PlatformMediaResolver implements MediaResolver {
8
+ private readonly client;
9
+ constructor(client: Pick<PlatformClient, 'downloadMedia'>);
10
+ resolve(message: InboundMessage, input: UserInputContent): Promise<UserInputContent>;
11
+ }
12
+ export declare const systemClock: {
13
+ now: () => number;
14
+ };
15
+ export declare const noopCoalesceScheduler: {
16
+ arm: () => Promise<void>;
17
+ cancel: () => Promise<void>;
18
+ };
@@ -0,0 +1,17 @@
1
+ export class PlatformMediaResolver {
2
+ client;
3
+ constructor(client) {
4
+ this.client = client;
5
+ }
6
+ async resolve(message, input) {
7
+ const { attachInboundMedia } = await import('../adapter/inbound-media.js');
8
+ return attachInboundMedia(message, input, this.client);
9
+ }
10
+ }
11
+ export const systemClock = {
12
+ now: () => Date.now(),
13
+ };
14
+ export const noopCoalesceScheduler = {
15
+ arm: async () => { },
16
+ cancel: async () => { },
17
+ };
@@ -0,0 +1,116 @@
1
+ import type { HarnessStreamPart, ResolvedSelection, SignalDelivery, UserInputContent } from '@kuralle-agents/core';
2
+ import type { ConsentStore } from '../adapter/consent-store.js';
3
+ import type { MediaResolver } from './ports.js';
4
+ import type { OwnershipStore } from '../adapter/ownership-store.js';
5
+ import type { ReactionData, StatusUpdate, InboundMessage } from '../types/messages.js';
6
+ import type { WindowStore } from '../adapter/window-store.js';
7
+ import type { ConversationKey, InboundLedger } from './ledger.js';
8
+ export type NormalizedWebhookError = {
9
+ code?: string | number;
10
+ title?: string;
11
+ message?: string;
12
+ phoneNumberId?: string;
13
+ raw?: unknown;
14
+ };
15
+ export type InboundEvent = {
16
+ kind: 'message';
17
+ id: string;
18
+ ts: number;
19
+ data: InboundMessage;
20
+ } | {
21
+ kind: 'status';
22
+ id: string;
23
+ ts: number;
24
+ data: StatusUpdate;
25
+ } | {
26
+ kind: 'reaction';
27
+ id: string;
28
+ ts: number;
29
+ data: ReactionData;
30
+ } | {
31
+ kind: 'error';
32
+ id: string;
33
+ ts: number;
34
+ data: NormalizedWebhookError;
35
+ } | {
36
+ kind: 'signal';
37
+ id: string;
38
+ ts: number;
39
+ data: {
40
+ name: string;
41
+ signalId: string;
42
+ payload?: unknown;
43
+ };
44
+ };
45
+ export interface TurnResult {
46
+ parts: HarnessStreamPart[];
47
+ suspended?: {
48
+ signalId: string;
49
+ };
50
+ handoffToHuman?: boolean;
51
+ }
52
+ export interface TurnRunner {
53
+ runTurn(a: {
54
+ key: ConversationKey;
55
+ input: UserInputContent;
56
+ selection?: ResolvedSelection;
57
+ userId?: string;
58
+ sessionId?: string;
59
+ signal?: AbortSignal;
60
+ }): Promise<TurnResult>;
61
+ deliverSignal(a: {
62
+ key: ConversationKey;
63
+ signal: SignalDelivery;
64
+ sessionId?: string;
65
+ signal2?: AbortSignal;
66
+ }): Promise<TurnResult>;
67
+ }
68
+ export interface CoalesceScheduler {
69
+ arm(key: ConversationKey, atMs: number): Promise<void>;
70
+ cancel(key: ConversationKey): Promise<void>;
71
+ }
72
+ export interface Clock {
73
+ now(): number;
74
+ }
75
+ export interface OutboundSender {
76
+ send(ctx: InboundContext, result: TurnResult): Promise<void>;
77
+ }
78
+ export interface InboundRuntime {
79
+ ledger: InboundLedger;
80
+ window: WindowStore;
81
+ consent?: ConsentStore;
82
+ ownership?: OwnershipStore;
83
+ media: MediaResolver;
84
+ sender: OutboundSender;
85
+ runtime: TurnRunner;
86
+ scheduler: CoalesceScheduler;
87
+ clock: Clock;
88
+ }
89
+ export type InboundOutcome = {
90
+ kind: 'ran';
91
+ parts: HarnessStreamPart[];
92
+ } | {
93
+ kind: 'suspended';
94
+ signalId: string;
95
+ } | {
96
+ kind: 'buffered';
97
+ } | {
98
+ kind: 'short';
99
+ by: string;
100
+ reason: 'duplicate' | 'opted-out' | 'human-owned' | 'window-closed' | 'no-input' | 'in-progress' | string;
101
+ };
102
+ export interface InboundContext {
103
+ key: ConversationKey;
104
+ event: InboundEvent;
105
+ rt: InboundRuntime;
106
+ input?: UserInputContent;
107
+ selection?: ResolvedSelection;
108
+ sessionId?: string;
109
+ userId?: string;
110
+ locals: Record<string, unknown>;
111
+ }
112
+ export type InboundNext = () => Promise<InboundOutcome>;
113
+ export interface InboundMiddleware {
114
+ readonly name: string;
115
+ handle(ctx: InboundContext, next: InboundNext): Promise<InboundOutcome>;
116
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
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';
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, InboundEvent, InboundRuntime, InboundOutcome, InboundMiddleware, InboundContext, TurnRunner, TurnResult, OutboundSender, CoalesceScheduler, Clock, } 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
4
  export { createInputCoalescer } from './adapter/input-coalescer.js';
@@ -18,7 +18,12 @@ export { redisSetSucceeded } from './adapter/redis-client.js';
18
18
  export { createRedisWindowStore } from './adapter/redis-window-store.js';
19
19
  export type { OwnershipStore, ConversationOwner } from './adapter/ownership-store.js';
20
20
  export type { ConsentStore } from './adapter/consent-store.js';
21
- export { MessageDeduplicator } from './shared/deduplicator.js';
21
+ export { InMemoryInboundLedger, conversationKeyToString, eventSeq, } from './inbound/ledger.js';
22
+ export type { ClaimResult, ConversationKey, ConvKeyStr, InboundLedger, SequencedInboundEvent, } from './inbound/ledger.js';
23
+ export { createInboundPipeline, claimAndAppend, statusReactionErrorPhase, recordWindow, consentStop, ownershipGate, resolveAndAttachMedia, coalesceMessages, runTurn, } from './inbound/pipeline.js';
24
+ export type { CoalescedPipelineItem } from './inbound/pipeline.js';
25
+ export { PlatformMediaResolver, noopCoalesceScheduler, systemClock, } from './inbound/ports.js';
26
+ export type { MediaResolver } from './inbound/ports.js';
22
27
  export { passthroughFormatter } from './shared/format-base.js';
23
28
  export type { MessageFormatter } from './shared/format-base.js';
24
29
  export { MediaCache } from './shared/media-cache.js';
package/dist/index.js CHANGED
@@ -16,10 +16,12 @@ export { WindowTracker } from './adapter/window-tracker.js';
16
16
  export { InMemoryWindowStore } from './adapter/window-store.js';
17
17
  export { redisSetSucceeded } from './adapter/redis-client.js';
18
18
  export { createRedisWindowStore } from './adapter/redis-window-store.js';
19
+ export { InMemoryInboundLedger, conversationKeyToString, eventSeq, } from './inbound/ledger.js';
20
+ export { createInboundPipeline, claimAndAppend, statusReactionErrorPhase, recordWindow, consentStop, ownershipGate, resolveAndAttachMedia, coalesceMessages, runTurn, } from './inbound/pipeline.js';
21
+ export { PlatformMediaResolver, noopCoalesceScheduler, systemClock, } from './inbound/ports.js';
19
22
  // ====================================
20
23
  // Shared
21
24
  // ====================================
22
- export { MessageDeduplicator } from './shared/deduplicator.js';
23
25
  export { passthroughFormatter } from './shared/format-base.js';
24
26
  export { MediaCache } from './shared/media-cache.js';
25
27
  export { HttpUrlStrategy, UploadStrategy, FileHashDedupStrategy, } from './shared/media-strategy.js';
@@ -14,6 +14,8 @@ import type { OutboundMiddleware } from './outbound.js';
14
14
  import type { InboundMessage, InteractiveMessage, MediaPayload } from './messages.js';
15
15
  import type { SendResult } from './responses.js';
16
16
  import type { PlatformClient, StatusHandler } from './client.js';
17
+ import type { Clock, CoalesceScheduler } from '../inbound/types.js';
18
+ import type { InboundLedger } from '../inbound/ledger.js';
17
19
  /**
18
20
  * Resolves an inbound message to a session identifier for the Kuralle runtime.
19
21
  */
@@ -61,6 +63,12 @@ export interface MessagingRouterConfig {
61
63
  outbound?: OutboundMiddleware[];
62
64
  /** Pluggable window store; defaults to in-memory (fail-closed on miss). */
63
65
  windowStore?: WindowStore;
66
+ /** Pluggable inbound ledger; defaults to in-memory (single-process/dev). */
67
+ ledger?: InboundLedger;
68
+ /** Host scheduler port; Node defaults to an in-process no-op for M-01. */
69
+ scheduler?: CoalesceScheduler;
70
+ /** Injectable clock for deterministic pipeline tests. */
71
+ clock?: Clock;
64
72
  /** When set, human-owned threads skip `runtime.run` on inbound (REQ-21). */
65
73
  ownership?: OwnershipStore;
66
74
  /** When set, STOP inbound opts the customer out; outbound uses `consentGate` (REQ-11). */
package/dist/types.d.ts CHANGED
@@ -8,3 +8,5 @@ export type { ContactInfo, LocationData, MediaPayload, MediaReference, MediaHand
8
8
  export type { SendResult, FormatConverter } from './types/responses.js';
9
9
  export type { MessageHandler, StatusHandler, ReactionHandler, PlatformClient, HealthCheckResult, } from './types/client.js';
10
10
  export type { SessionResolver, ResponseContext, ResponseMapper, ErrorContext, MessagingRouterConfig, InboundCoalescingConfig, CoalescedInboundItem, StreamMapperOptions, } from './types/adapter.js';
11
+ export type { InboundEvent, NormalizedWebhookError, InboundRuntime, TurnRunner, TurnResult, CoalesceScheduler, Clock, OutboundSender, InboundOutcome, InboundContext, InboundNext, InboundMiddleware, } from './inbound/types.js';
12
+ export type { ClaimResult, ConversationKey, ConvKeyStr, InboundLedger, SequencedInboundEvent, } from './inbound/ledger.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.9.0",
9
+ "version": "0.11.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.9.0"
29
+ "@kuralle-agents/core": "0.11.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "typescript": "^5.8.2",
@@ -1,35 +0,0 @@
1
- /**
2
- * LRU-based message deduplication to prevent processing the same
3
- * webhook event twice. Uses a Map (which preserves insertion order)
4
- * as the underlying data structure with TTL-based expiration.
5
- */
6
- export declare class MessageDeduplicator {
7
- private readonly cache;
8
- private readonly maxSize;
9
- private readonly ttlMs;
10
- /**
11
- * Create a new deduplicator.
12
- * @param maxSize - Maximum number of message IDs to track. Default: 10000.
13
- * @param ttlMs - Time-to-live for entries in milliseconds. Default: 300000 (5 minutes).
14
- */
15
- constructor(maxSize?: number, ttlMs?: number);
16
- /**
17
- * Check if a message ID has been seen recently.
18
- *
19
- * If the message ID is new, it is recorded and `false` is returned.
20
- * If it has been seen within the TTL window, `true` is returned.
21
- * Expired entries are treated as new.
22
- *
23
- * @param messageId - The platform-specific message identifier.
24
- * @returns `true` if this message was already processed (duplicate), `false` if new.
25
- */
26
- isDuplicate(messageId: string): boolean;
27
- /**
28
- * Remove expired entries, then evict oldest if still at capacity.
29
- */
30
- private evict;
31
- /** Return the current number of tracked message IDs. */
32
- get size(): number;
33
- /** Clear all tracked message IDs. */
34
- clear(): void;
35
- }
@@ -1,79 +0,0 @@
1
- /**
2
- * LRU-based message deduplication to prevent processing the same
3
- * webhook event twice. Uses a Map (which preserves insertion order)
4
- * as the underlying data structure with TTL-based expiration.
5
- */
6
- export class MessageDeduplicator {
7
- cache;
8
- maxSize;
9
- ttlMs;
10
- /**
11
- * Create a new deduplicator.
12
- * @param maxSize - Maximum number of message IDs to track. Default: 10000.
13
- * @param ttlMs - Time-to-live for entries in milliseconds. Default: 300000 (5 minutes).
14
- */
15
- constructor(maxSize = 10_000, ttlMs = 300_000) {
16
- this.cache = new Map();
17
- this.maxSize = maxSize;
18
- this.ttlMs = ttlMs;
19
- }
20
- /**
21
- * Check if a message ID has been seen recently.
22
- *
23
- * If the message ID is new, it is recorded and `false` is returned.
24
- * If it has been seen within the TTL window, `true` is returned.
25
- * Expired entries are treated as new.
26
- *
27
- * @param messageId - The platform-specific message identifier.
28
- * @returns `true` if this message was already processed (duplicate), `false` if new.
29
- */
30
- isDuplicate(messageId) {
31
- const now = Date.now();
32
- const existing = this.cache.get(messageId);
33
- if (existing !== undefined) {
34
- // Check if the entry has expired
35
- if (now - existing < this.ttlMs) {
36
- return true;
37
- }
38
- // Expired — delete so we can re-insert at the end
39
- this.cache.delete(messageId);
40
- }
41
- // Evict oldest entries if at capacity
42
- if (this.cache.size >= this.maxSize) {
43
- this.evict(now);
44
- }
45
- // Record this message
46
- this.cache.set(messageId, now);
47
- return false;
48
- }
49
- /**
50
- * Remove expired entries, then evict oldest if still at capacity.
51
- */
52
- evict(now) {
53
- // First pass: remove all expired entries
54
- for (const [key, timestamp] of this.cache) {
55
- if (now - timestamp >= this.ttlMs) {
56
- this.cache.delete(key);
57
- }
58
- }
59
- // If still at capacity, remove oldest entries (Map iterates in insertion order)
60
- if (this.cache.size >= this.maxSize) {
61
- const toRemove = this.cache.size - this.maxSize + 1;
62
- let removed = 0;
63
- for (const key of this.cache.keys()) {
64
- if (removed >= toRemove)
65
- break;
66
- this.cache.delete(key);
67
- removed++;
68
- }
69
- }
70
- }
71
- /** Return the current number of tracked message IDs. */
72
- get size() {
73
- return this.cache.size;
74
- }
75
- /** Clear all tracked message IDs. */
76
- clear() {
77
- this.cache.clear();
78
- }
79
- }