@xmoxmo/bncr 0.6.0 → 0.6.1

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.
@@ -1,5 +1,11 @@
1
+ import type { TSchema } from 'typebox';
1
2
  import type { RegisterDriftSnapshot } from '../core/register-trace.ts';
2
- import type { BncrDiagnosticsSummary, BncrRoute, OutboxEntry } from '../core/types.ts';
3
+ import type {
4
+ BncrDiagnosticsSummary,
5
+ BncrOutboundReplayEntry,
6
+ BncrRoute,
7
+ OutboxEntry,
8
+ } from '../core/types.ts';
3
9
  import type { OpenClawChannelToolSend, openClawJsonResult } from '../openclaw/sdk-helpers.ts';
4
10
 
5
11
  type OpenClawJsonResultPayload = ReturnType<typeof openClawJsonResult>;
@@ -88,6 +94,13 @@ export type BncrPersistedGroupHistoryBucket = {
88
94
  entries: BncrPersistedGroupHistoryEntry[];
89
95
  };
90
96
 
97
+ export type BncrPersistedOutboundReplayEntry = BncrOutboundReplayEntry;
98
+ export type BncrPersistedOutboundReplayMediaEntry = BncrPersistedGroupHistoryMediaEntry;
99
+ export type BncrPersistedOutboundReplayBucket = {
100
+ key: string;
101
+ entries: BncrPersistedOutboundReplayEntry[];
102
+ };
103
+
91
104
  export type BncrStatusRuntimeSnapshot = {
92
105
  connected?: boolean;
93
106
  running?: boolean;
@@ -143,10 +156,20 @@ export type BncrChannelSendContext = {
143
156
  extra?: Record<string, unknown>;
144
157
  };
145
158
 
159
+ /**
160
+ * TypeBox schema contribution for the shared `message` tool.
161
+ * The host wraps each property with Type.Optional before validation.
162
+ */
163
+ export type BncrMessageToolSchemaContribution = {
164
+ visibility?: 'current-channel' | 'all-configured';
165
+ properties: Record<string, TSchema>;
166
+ };
167
+
146
168
  export type ChannelMessageActionAdapter = {
147
169
  describeMessageTool: (ctx: { cfg: BncrChannelConfigRoot }) => {
148
- actions: readonly ['send'];
170
+ actions: readonly ('send' | 'delete' | 'unsend')[];
149
171
  capabilities: readonly [];
172
+ schema?: BncrMessageToolSchemaContribution;
150
173
  } | null;
151
174
  supportsAction: (ctx: { action: string }) => boolean;
152
175
  extractToolSend: (ctx: { args: unknown }) => OpenClawChannelToolSend | null;
@@ -164,6 +187,7 @@ export type PersistedState = {
164
187
  sessionRoutes: BncrPersistedSessionRoute[];
165
188
  sceneRegistry?: BncrSceneRecord[];
166
189
  groupHistories?: BncrPersistedGroupHistoryBucket[];
190
+ outboundReplayCache?: BncrPersistedOutboundReplayBucket[];
167
191
  lastSessionByAccount?: BncrPersistedLastSession[];
168
192
  lastActivityByAccount?: BncrPersistedAccountTimestamp[];
169
193
  lastInboundByAccount?: BncrPersistedAccountTimestamp[];
@@ -1,4 +1,3 @@
1
- import { randomUUID } from 'node:crypto';
2
1
  import { normalizeAccountId } from '../core/accounts.ts';
3
2
  import { buildBncrDebugJsonMessage } from '../core/logging.ts';
4
3
  import type { BncrRoute, OutboxEntry } from '../core/types.ts';
@@ -211,28 +210,22 @@ export function createBncrChannelSendRuntime(runtime: BncrChannelSendRuntime) {
211
210
  const verified = runtime.resolveVerifiedTarget(to, accountId);
212
211
  runtime.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
213
212
 
214
- await runtime.enqueueFromReply({
215
- accountId,
216
- sessionKey: verified.sessionKey,
217
- route: verified.route,
218
- payload: {
219
- text: text,
220
- mediaUrl: mediaUrl || undefined,
221
- mediaUrls: Array.isArray(mediaUrls) && mediaUrls.length ? mediaUrls : undefined,
222
- asVoice: asVoice || undefined,
223
- audioAsVoice: audioAsVoice || undefined,
224
- downloadMedia,
225
- type,
226
- extra,
227
- kind: kind as 'tool' | 'block' | 'final' | undefined,
228
- replyToId,
229
- },
230
- mediaLocalRoots: ctx.mediaLocalRoots,
213
+ const entry = await enqueueChannelMessageHandoff(runtime, ctx, {
214
+ text: text,
215
+ mediaUrl: mediaUrl || undefined,
216
+ mediaUrls: Array.isArray(mediaUrls) && mediaUrls.length ? mediaUrls : undefined,
217
+ asVoice: asVoice || undefined,
218
+ audioAsVoice: audioAsVoice || undefined,
219
+ downloadMedia,
220
+ type,
221
+ extra,
222
+ kind: kind as 'tool' | 'block' | 'final' | undefined,
223
+ replyToId,
231
224
  });
232
225
 
233
226
  return {
234
227
  channel: runtime.channelId,
235
- messageId: randomUUID(),
228
+ messageId: entry.messageId,
236
229
  chatId: verified.sessionKey,
237
230
  };
238
231
  }
@@ -0,0 +1,129 @@
1
+ import type { GatewayRequestHandlerOptions } from 'openclaw/plugin-sdk/core';
2
+
3
+ export const BNCR_CLIENT_RPC_REQUEST_EVENT = 'plugin.bncr.rpc.request';
4
+ export const BNCR_CLIENT_RPC_RESPONSE_METHOD = 'bncr.rpc.response';
5
+ export const BNCR_CLIENT_RPC_TIMEOUT_MS = 15000;
6
+
7
+ type BncrClientRpcPending = {
8
+ method: string;
9
+ resolve: (result: Record<string, unknown>) => void;
10
+ reject: (error: Error) => void;
11
+ timer: NodeJS.Timeout;
12
+ };
13
+
14
+ export type BncrClientRpcRuntime = {
15
+ call: (
16
+ method: string,
17
+ args: Record<string, unknown>,
18
+ accountId: string,
19
+ ) => Promise<Record<string, unknown>>;
20
+ handleResponse: (ctx: GatewayRequestHandlerOptions) => void;
21
+ pendingCount: () => number;
22
+ shutdown: () => void;
23
+ };
24
+
25
+ export function createBncrClientRpcRuntime(runtime: {
26
+ resolvePushConnIds: (accountId: string) => ReadonlySet<string>;
27
+ broadcastToConnIds: (event: string, payload: unknown, connIds: ReadonlySet<string>) => void;
28
+ now: () => number;
29
+ logInfo: (scope: string, message: string, options?: { debugOnly?: boolean }) => void;
30
+ timeoutMs?: number;
31
+ }): BncrClientRpcRuntime {
32
+ const pending = new Map<string, BncrClientRpcPending>();
33
+ let requestSeq = 0;
34
+ const timeoutMs = Math.max(Number(runtime.timeoutMs ?? BNCR_CLIENT_RPC_TIMEOUT_MS), 1);
35
+
36
+ const cleanup = (requestId: string) => {
37
+ const entry = pending.get(requestId);
38
+ if (!entry) return;
39
+ clearTimeout(entry.timer);
40
+ pending.delete(requestId);
41
+ };
42
+
43
+ const call = (method: string, args: Record<string, unknown>, accountId: string) => {
44
+ const normalizedMethod = String(method || '').trim();
45
+ if (!normalizedMethod) {
46
+ return Promise.reject(new Error('bncr client RPC requires a method name'));
47
+ }
48
+
49
+ const connIds = runtime.resolvePushConnIds(accountId);
50
+ if (!connIds.size) {
51
+ return Promise.reject(new Error(`no active bncr client connection for account ${accountId}`));
52
+ }
53
+ const targetConnIds = new Set([Array.from(connIds)[0]]);
54
+
55
+ const requestId = `rpc_${runtime.now().toString(36)}_${String(process.pid)}_${++requestSeq}`;
56
+ return new Promise<Record<string, unknown>>((resolve, reject) => {
57
+ const timer = setTimeout(() => {
58
+ cleanup(requestId);
59
+ reject(
60
+ new Error(
61
+ `bncr client RPC timeout method=${normalizedMethod}|accountId=${accountId}|timeoutMs=${timeoutMs}`,
62
+ ),
63
+ );
64
+ }, timeoutMs);
65
+ pending.set(requestId, {
66
+ method: normalizedMethod,
67
+ resolve,
68
+ reject,
69
+ timer,
70
+ });
71
+ runtime.broadcastToConnIds(
72
+ BNCR_CLIENT_RPC_REQUEST_EVENT,
73
+ {
74
+ requestId,
75
+ method: normalizedMethod,
76
+ args: args ?? {},
77
+ },
78
+ targetConnIds,
79
+ );
80
+ });
81
+ };
82
+
83
+ const handleResponse = (ctx: GatewayRequestHandlerOptions) => {
84
+ const params =
85
+ ctx.params && typeof ctx.params === 'object' ? (ctx.params as Record<string, unknown>) : {};
86
+ const requestId = typeof params.requestId === 'string' ? params.requestId.trim() : '';
87
+ const entry = requestId ? pending.get(requestId) : undefined;
88
+ if (!entry) {
89
+ runtime.logInfo('client-rpc', `rpc response ignored requestId=${requestId || '(missing)'}`, {
90
+ debugOnly: true,
91
+ });
92
+ ctx.respond(true, { ok: true, consumed: false, requestId });
93
+ return;
94
+ }
95
+
96
+ cleanup(requestId);
97
+ if (params.ok === true) {
98
+ const result =
99
+ params.result && typeof params.result === 'object'
100
+ ? (params.result as Record<string, unknown>)
101
+ : { value: params.result };
102
+ entry.resolve({
103
+ ok: true,
104
+ method: entry.method,
105
+ result,
106
+ });
107
+ } else {
108
+ const message =
109
+ typeof params.error === 'string'
110
+ ? params.error
111
+ : `bncr client RPC failed method=${entry.method}`;
112
+ entry.reject(new Error(message));
113
+ }
114
+ ctx.respond(true, { ok: true, consumed: true, requestId });
115
+ };
116
+
117
+ return {
118
+ call,
119
+ handleResponse,
120
+ pendingCount: () => pending.size,
121
+ shutdown: () => {
122
+ for (const [requestId, entry] of pending.entries()) {
123
+ clearTimeout(entry.timer);
124
+ entry.reject(new Error(`bncr client RPC cancelled method=${entry.method}`));
125
+ pending.delete(requestId);
126
+ }
127
+ },
128
+ };
129
+ }
@@ -6,6 +6,7 @@ export const BNCR_GATEWAY_METHODS = [
6
6
  'bncr.diagnostics',
7
7
  'bncr.deadLetter.inspect',
8
8
  'bncr.deadLetter.prune',
9
+ 'bncr.rpc.response',
9
10
  'bncr.file.init',
10
11
  'bncr.file.chunk',
11
12
  'bncr.file.complete',
@@ -2,6 +2,7 @@ import type { GatewayRequestHandlerOptions } from 'openclaw/plugin-sdk/core';
2
2
  import { buildCanonicalBncrSessionKey } from '../core/targets.ts';
3
3
  import type { BncrConnection, BncrRoute } from '../core/types.ts';
4
4
  import type { BncrGroupHistoryMap } from '../messaging/inbound/group-history.ts';
5
+ import type { BncrOutboundReplayCache } from '../messaging/inbound/outbound-replay-cache.ts';
5
6
  import type { parseBncrInboundParams } from '../messaging/inbound/parse.ts';
6
7
  import type { ReplyPayloadInput } from '../messaging/outbound/reply-enqueue.ts';
7
8
  import type {
@@ -134,6 +135,7 @@ export type BncrInboundHandlersRuntime = {
134
135
  defaultPublicAgentId: () => string;
135
136
  sceneRegistry: Map<string, BncrSceneRecord>;
136
137
  groupHistories: BncrGroupHistoryMap;
138
+ outboundReplayCache: BncrOutboundReplayCache;
137
139
  prepareInboundAcceptance: (args: {
138
140
  parsed: ParsedInboundParams;
139
141
  canonicalAgentId: string;
@@ -164,6 +166,7 @@ export type BncrInboundHandlersRuntime = {
164
166
  shouldAccumulate: boolean;
165
167
  sceneRegistry: Map<string, BncrSceneRecord>;
166
168
  groupHistories: BncrGroupHistoryMap;
169
+ outboundReplayCache: BncrOutboundReplayCache;
167
170
  defaultAdminAgentId: string;
168
171
  defaultPublicAgentId: string;
169
172
  now: () => number;
@@ -333,6 +336,7 @@ export function createBncrInboundHandlers(runtime: Omit<BncrInboundHandlersRunti
333
336
  shouldAccumulate,
334
337
  sceneRegistry: runtime.sceneRegistry,
335
338
  groupHistories: runtime.groupHistories,
339
+ outboundReplayCache: runtime.outboundReplayCache,
336
340
  defaultAdminAgentId,
337
341
  defaultPublicAgentId: runtime.defaultPublicAgentId(),
338
342
  now: runtime.now,
@@ -1,5 +1,6 @@
1
1
  import type { GatewayRequestHandlerOptions } from 'openclaw/plugin-sdk/core';
2
2
  import type { BncrConnection, BncrRoute } from '../core/types.ts';
3
+ import type { BncrOutboundReplayCache } from '../messaging/inbound/outbound-replay-cache.ts';
3
4
  import {
4
5
  createBncrFileInboundHandlersComponent,
5
6
  createBncrInboundHandlersComponent,
@@ -86,6 +87,7 @@ export function createBncrInboundSurfaceHandlersGroup(runtime: {
86
87
  >[0]['defaultPublicAgentId'];
87
88
  sceneRegistry: Parameters<typeof createBncrInboundHandlersComponent>[0]['sceneRegistry'];
88
89
  groupHistories: Parameters<typeof createBncrInboundHandlersComponent>[0]['groupHistories'];
90
+ outboundReplayCache: BncrOutboundReplayCache;
89
91
  prepareInboundAcceptance: Parameters<
90
92
  typeof createBncrInboundHandlersComponent
91
93
  >[0]['prepareInboundAcceptance'];
@@ -152,6 +154,7 @@ export function createBncrInboundSurfaceHandlersGroup(runtime: {
152
154
  defaultPublicAgentId: runtime.defaultPublicAgentId,
153
155
  sceneRegistry: runtime.sceneRegistry,
154
156
  groupHistories: runtime.groupHistories,
157
+ outboundReplayCache: runtime.outboundReplayCache,
155
158
  prepareInboundAcceptance: runtime.prepareInboundAcceptance,
156
159
  logInboundSummary: runtime.logInboundSummary,
157
160
  flushPushQueueBestEffort: runtime.flushPushQueueBestEffort,
@@ -26,6 +26,7 @@ export type BncrOutboxAckOutcomeRuntime = {
26
26
  scheduleSave: () => void;
27
27
  resolveMessageAck: (messageId: string, result: 'acked' | 'timeout') => void;
28
28
  moveToDeadLetter: (entry: OutboxEntry, reason: string) => void;
29
+ markRecentOutboundAcked?: (entry: OutboxEntry) => void;
29
30
  logOutboxAckSummary: (
30
31
  scope: 'outbox ack ok' | 'outbox ack ok late' | 'outbox ack retry' | 'outbox ack fatal',
31
32
  args: {
@@ -69,6 +70,7 @@ export function createBncrOutboxAckOutcome(runtime: BncrOutboxAckOutcomeRuntime)
69
70
  runtime.deleteOutboxEntry(args.messageId);
70
71
  runtime.scheduleSave();
71
72
  runtime.resolveMessageAck(args.messageId, 'acked');
73
+ runtime.markRecentOutboundAcked?.(args.entry);
72
74
  runtime.logOutboxAckSummary(
73
75
  telemetryPatch.lateAccepted ? 'outbox ack ok late' : 'outbox ack ok',
74
76
  {
@@ -3,6 +3,10 @@ import {
3
3
  normalizeRegisterDriftSnapshot,
4
4
  } from '../core/register-trace.ts';
5
5
  import type { BncrRoute, OutboxEntry } from '../core/types.ts';
6
+ import type {
7
+ BncrOutboundReplayEntry,
8
+ BncrOutboundReplayStatus,
9
+ } from '../messaging/inbound/outbound-replay-cache.ts';
6
10
  import {
7
11
  readOpenClawJsonFileWithFallback,
8
12
  writeOpenClawJsonFileAtomically,
@@ -14,6 +18,8 @@ import type {
14
18
  BncrPersistedGroupHistoryEntry,
15
19
  BncrPersistedGroupHistoryMediaEntry,
16
20
  BncrPersistedLastSession,
21
+ BncrPersistedOutboundReplayBucket,
22
+ BncrPersistedOutboundReplayEntry,
17
23
  BncrPersistedSessionRoute,
18
24
  PersistedState as BncrPersistedState,
19
25
  BncrSceneRecord,
@@ -28,6 +34,9 @@ type BncrPersistedStateStoreInput = {
28
34
  sessionRoutes?: unknown;
29
35
  sceneRegistry?: unknown;
30
36
  groupHistories?: unknown;
37
+ outboundReplayCache?: unknown;
38
+ // Legacy persisted key from before the outboundReplayCache rename.
39
+ messageCache?: unknown;
31
40
  lastSessionByAccount?: unknown;
32
41
  lastActivityByAccount?: unknown;
33
42
  lastInboundByAccount?: unknown;
@@ -41,6 +50,8 @@ type PersistedSessionRouteInput = Partial<BncrPersistedSessionRoute>;
41
50
  type PersistedSceneRecordInput = Partial<BncrSceneRecord>;
42
51
  type PersistedGroupHistoryBucketInput = Partial<BncrPersistedGroupHistoryBucket>;
43
52
  type PersistedGroupHistoryEntryInput = Partial<BncrPersistedGroupHistoryEntry>;
53
+ type PersistedOutboundReplayBucketInput = Partial<BncrPersistedOutboundReplayBucket>;
54
+ type PersistedOutboundReplayEntryInput = Partial<BncrOutboundReplayEntry>;
44
55
 
45
56
  export function createBncrStateStore(runtime: {
46
57
  getStatePath: () => string | null;
@@ -65,6 +76,7 @@ export function createBncrStateStore(runtime: {
65
76
  maxAccountActivityEntries: number;
66
77
  sceneRegistry: Map<string, BncrSceneRecord>;
67
78
  groupHistories: Map<string, BncrPersistedGroupHistoryEntry[]>;
79
+ outboundReplayCache?: Map<string, BncrPersistedOutboundReplayEntry[]>;
68
80
  outbox: Map<string, OutboxEntry>;
69
81
  getDeadLetter: () => OutboxEntry[];
70
82
  setDeadLetter: (entries: OutboxEntry[]) => void;
@@ -77,6 +89,9 @@ export function createBncrStateStore(runtime: {
77
89
  getLastDriftSnapshot: () => BncrPersistedState['lastDriftSnapshot'];
78
90
  setLastDriftSnapshot: (value: BncrPersistedState['lastDriftSnapshot']) => void;
79
91
  }) {
92
+ const outboundReplayCache =
93
+ runtime.outboundReplayCache ?? new Map<string, BncrPersistedOutboundReplayEntry[]>();
94
+
80
95
  function resolvePersistedGroupHistoryLimit(key: string): number {
81
96
  const scene = runtime.sceneRegistry.get(key);
82
97
  const sceneLimit = scene?.kind === 'group' ? scene.historyLimit : undefined;
@@ -148,8 +163,12 @@ export function createBncrStateStore(runtime: {
148
163
  return Array.from(runtime.sceneRegistry.values()).sort((a, b) => a.lastSeenAt - b.lastSeenAt);
149
164
  }
150
165
 
151
- function loadPersistedGroupHistories(persisted: unknown): void {
152
- runtime.groupHistories.clear();
166
+ function loadPersistedHistoryBuckets(
167
+ persisted: unknown,
168
+ target: Map<string, BncrPersistedGroupHistoryEntry[]>,
169
+ resolveLimit: (key: string) => number,
170
+ ): void {
171
+ target.clear();
153
172
  const buckets = Array.isArray(persisted)
154
173
  ? (persisted as PersistedGroupHistoryBucketInput[])
155
174
  : [];
@@ -199,15 +218,112 @@ export function createBncrStateStore(runtime: {
199
218
  entries.push(normalizedEntry);
200
219
  }
201
220
  if (entries.length > 0) {
202
- runtime.groupHistories.set(key, entries.slice(-resolvePersistedGroupHistoryLimit(key)));
221
+ const limit = resolveLimit(key);
222
+ target.set(key, Number.isFinite(limit) ? entries.slice(-limit) : entries);
203
223
  }
204
224
  }
205
225
  }
206
226
 
227
+ function loadPersistedGroupHistories(persisted: unknown): void {
228
+ loadPersistedHistoryBuckets(persisted, runtime.groupHistories, (key) =>
229
+ resolvePersistedGroupHistoryLimit(key),
230
+ );
231
+ }
232
+
233
+ function loadPersistedOutboundReplayCache(persisted: unknown): void {
234
+ outboundReplayCache.clear();
235
+ const buckets = Array.isArray(persisted)
236
+ ? (persisted as PersistedOutboundReplayBucketInput[])
237
+ : [];
238
+ for (const bucket of buckets) {
239
+ const key = runtime.asString(bucket?.key || '').trim();
240
+ if (!key) continue;
241
+ const entries: BncrOutboundReplayEntry[] = [];
242
+ const rawEntries = Array.isArray(bucket?.entries)
243
+ ? (bucket.entries as PersistedOutboundReplayEntryInput[])
244
+ : [];
245
+ for (const entry of rawEntries) {
246
+ const sender = runtime.asString(entry?.sender || '').trim();
247
+ const body = runtime.asString(entry?.body || '').trim();
248
+ if (!sender || !body) continue;
249
+ const timestamp = runtime.finiteNumberOr(entry?.timestamp, 0);
250
+ const messageId = runtime.asString(entry?.messageId || '').trim();
251
+ const accountId = runtime.asString(entry?.accountId || '').trim();
252
+ const sessionKey = runtime.asString(entry?.sessionKey || '').trim();
253
+ const type = runtime.asString(entry?.type || '').trim();
254
+ const mediaUrl = runtime.asString(entry?.mediaUrl || '').trim();
255
+ const createdAt = runtime.finiteNumberOr(entry?.createdAt, 0);
256
+ const status = runtime.asString(entry?.status || '').trim();
257
+ const route =
258
+ runtime.parseRouteLike(entry?.route) ||
259
+ runtime.parseRouteLike({
260
+ platform: entry?.route?.platform,
261
+ groupId: entry?.route?.groupId,
262
+ userId: entry?.route?.userId,
263
+ });
264
+ const media: BncrPersistedGroupHistoryMediaEntry[] = [];
265
+ const rawMedia = Array.isArray(entry?.media)
266
+ ? (entry.media as Partial<BncrPersistedGroupHistoryMediaEntry>[])
267
+ : [];
268
+ for (const item of rawMedia) {
269
+ const path = runtime.asString(item?.path || '').trim();
270
+ if (!path) continue;
271
+ const contentType = runtime.asString(item?.contentType || '').trim();
272
+ const kind = runtime.asString(item?.kind || '').trim();
273
+ const mediaMessageId = runtime.asString(item?.messageId || '').trim();
274
+ media.push({
275
+ path,
276
+ ...(contentType ? { contentType } : {}),
277
+ ...(kind ? { kind: kind as BncrPersistedGroupHistoryMediaEntry['kind'] } : {}),
278
+ ...(mediaMessageId ? { messageId: mediaMessageId } : {}),
279
+ });
280
+ }
281
+ entries.push({
282
+ sender,
283
+ ...(runtime.asString(entry?.senderId || '').trim()
284
+ ? { senderId: runtime.asString(entry?.senderId || '').trim() }
285
+ : {}),
286
+ body,
287
+ ...(timestamp > 0 ? { timestamp } : {}),
288
+ ...(messageId ? { messageId } : {}),
289
+ ...(media.length > 0 ? { media } : {}),
290
+ ...(accountId ? { accountId } : {}),
291
+ ...(sessionKey ? { sessionKey } : {}),
292
+ ...(route ? { route } : {}),
293
+ ...(type ? { type } : {}),
294
+ ...(mediaUrl ? { mediaUrl } : {}),
295
+ ...(createdAt > 0 ? { createdAt } : {}),
296
+ ...(status === 'pushed' || status === 'acked'
297
+ ? { status: status as BncrOutboundReplayStatus }
298
+ : {}),
299
+ });
300
+ }
301
+ if (entries.length > 0) {
302
+ outboundReplayCache.set(key, entries);
303
+ }
304
+ }
305
+ }
306
+
307
+ function dumpPersistedHistoryBuckets(
308
+ source: Map<string, BncrPersistedGroupHistoryEntry[]>,
309
+ resolveLimit: (key: string) => number,
310
+ ) {
311
+ return Array.from(source.entries()).map(([key, entries]) => ({
312
+ key,
313
+ entries: Number.isFinite(resolveLimit(key)) ? entries.slice(-resolveLimit(key)) : entries,
314
+ }));
315
+ }
316
+
207
317
  function dumpPersistedGroupHistories() {
208
- return Array.from(runtime.groupHistories.entries()).map(([key, entries]) => ({
318
+ return dumpPersistedHistoryBuckets(runtime.groupHistories, (key) =>
319
+ resolvePersistedGroupHistoryLimit(key),
320
+ );
321
+ }
322
+
323
+ function dumpPersistedOutboundReplayCache() {
324
+ return Array.from(outboundReplayCache.entries()).map(([key, entries]) => ({
209
325
  key,
210
- entries: entries.slice(-resolvePersistedGroupHistoryLimit(key)),
326
+ entries,
211
327
  }));
212
328
  }
213
329
 
@@ -359,6 +475,7 @@ export function createBncrStateStore(runtime: {
359
475
  loadPersistedSessionRoutes(data.sessionRoutes);
360
476
  loadPersistedSceneRegistry(data.sceneRegistry);
361
477
  loadPersistedGroupHistories(data.groupHistories);
478
+ loadPersistedOutboundReplayCache(data.outboundReplayCache ?? data.messageCache);
362
479
  loadPersistedLastSessionMap(data.lastSessionByAccount);
363
480
  loadPersistedAccountTimestampMap(runtime.lastActivityByAccount, data.lastActivityByAccount);
364
481
  loadPersistedAccountTimestampMap(runtime.lastInboundByAccount, data.lastInboundByAccount);
@@ -378,6 +495,7 @@ export function createBncrStateStore(runtime: {
378
495
  sessionRoutes: dumpPersistedSessionRoutes(),
379
496
  sceneRegistry: dumpPersistedSceneRegistry(),
380
497
  groupHistories: dumpPersistedGroupHistories(),
498
+ outboundReplayCache: dumpPersistedOutboundReplayCache(),
381
499
  lastSessionByAccount: dumpPersistedLastSessionMap(),
382
500
  lastActivityByAccount: dumpPersistedAccountTimestampMap(runtime.lastActivityByAccount),
383
501
  lastInboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastInboundByAccount),
@@ -399,6 +517,8 @@ export function createBncrStateStore(runtime: {
399
517
  dumpPersistedSceneRegistry,
400
518
  loadPersistedGroupHistories,
401
519
  dumpPersistedGroupHistories,
520
+ loadPersistedOutboundReplayCache,
521
+ dumpPersistedOutboundReplayCache,
402
522
  backfillAccountActivityFromSessionRoutes,
403
523
  loadState,
404
524
  flushState,
@@ -6,7 +6,11 @@ import type {
6
6
  FileSendTransferState,
7
7
  OutboxEntry,
8
8
  } from '../core/types.ts';
9
- import type { BncrPersistedGroupHistoryEntry, BncrSceneRecord } from './channel-runtime-types.ts';
9
+ import type {
10
+ BncrPersistedGroupHistoryEntry,
11
+ BncrPersistedOutboundReplayEntry,
12
+ BncrSceneRecord,
13
+ } from './channel-runtime-types.ts';
10
14
  import { createBncrStateStore } from './state-store.ts';
11
15
  import { createBncrTransientStateRuntime } from './transient-state-runtime.ts';
12
16
 
@@ -34,6 +38,7 @@ export function createBncrStateTransientRuntimeGroup(runtime: {
34
38
  maxAccountActivityEntries: number;
35
39
  sceneRegistry: Map<string, BncrSceneRecord>;
36
40
  groupHistories: Map<string, BncrPersistedGroupHistoryEntry[]>;
41
+ outboundReplayCache?: Map<string, BncrPersistedOutboundReplayEntry[]>;
37
42
  outbox: Map<string, OutboxEntry>;
38
43
  getDeadLetter: () => OutboxEntry[];
39
44
  setDeadLetter: (entries: OutboxEntry[]) => void;
@@ -74,6 +79,8 @@ export function createBncrStateTransientRuntimeGroup(runtime: {
74
79
  maxAccountActivityEntries: runtime.maxAccountActivityEntries,
75
80
  sceneRegistry: runtime.sceneRegistry,
76
81
  groupHistories: runtime.groupHistories,
82
+ outboundReplayCache:
83
+ runtime.outboundReplayCache ?? new Map<string, BncrPersistedOutboundReplayEntry[]>(),
77
84
  outbox: runtime.outbox,
78
85
  getDeadLetter: runtime.getDeadLetter,
79
86
  setDeadLetter: runtime.setDeadLetter,