@xmoxmo/bncr 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +8 -0
  2. package/dist/index.js +2 -0
  3. package/index.ts +1 -0
  4. package/package.json +4 -1
  5. package/scripts/check-pack.mjs +41 -1
  6. package/scripts/selfcheck.mjs +4 -1
  7. package/src/bootstrap/register-runtime-gateway.ts +1 -0
  8. package/src/bootstrap/register-runtime.ts +1 -0
  9. package/src/channel.ts +66 -5
  10. package/src/core/types.ts +41 -0
  11. package/src/messaging/inbound/context-facts.ts +88 -58
  12. package/src/messaging/inbound/{group-history.ts → conversation-history.ts} +92 -25
  13. package/src/messaging/inbound/dispatch.ts +90 -47
  14. package/src/messaging/inbound/native-command.ts +1 -1
  15. package/src/messaging/inbound/outbound-replay-cache.ts +312 -0
  16. package/src/messaging/inbound/scene-admin.ts +24 -9
  17. package/src/messaging/inbound/turn-context.ts +105 -33
  18. package/src/plugin/ack-outbox-runtime-group.ts +2 -0
  19. package/src/plugin/bridge-call.ts +168 -0
  20. package/src/plugin/bridge-outbox-facade.ts +88 -1
  21. package/src/plugin/channel-components.ts +10 -5
  22. package/src/plugin/channel-plugin-bridge-group.ts +43 -1
  23. package/src/plugin/channel-plugin-surface-group.ts +82 -7
  24. package/src/plugin/channel-runtime-builders-delivery.ts +4 -2
  25. package/src/plugin/channel-runtime-types.ts +34 -8
  26. package/src/plugin/channel-send.ts +12 -19
  27. package/src/plugin/client-rpc-runtime.ts +129 -0
  28. package/src/plugin/gateway-methods.ts +1 -0
  29. package/src/plugin/inbound-acceptance.ts +9 -24
  30. package/src/plugin/inbound-handlers.ts +8 -4
  31. package/src/plugin/inbound-surface-handlers-group.ts +7 -2
  32. package/src/plugin/outbox-ack-outcome.ts +2 -0
  33. package/src/plugin/scene-registry.ts +4 -4
  34. package/src/plugin/state-store.ts +153 -29
  35. package/src/plugin/state-transient-runtime-group.ts +10 -3
@@ -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',
@@ -66,30 +66,12 @@ function shouldDispatchForScene(args: {
66
66
  }
67
67
 
68
68
  function shouldAccumulateForScene(args: {
69
- parsed: ReturnType<typeof parseBncrInboundParams>;
69
+ shouldDispatch: boolean;
70
70
  admission: ReturnType<typeof decideSceneAdmission>;
71
- cfg: BncrChannelConfigRoot;
72
71
  }) {
73
- const { parsed, admission, cfg } = args;
74
- if (parsed.peer.kind === 'direct') return true;
75
- if (!admission.allowed) return false;
76
-
77
- const isBncrNativeCommand =
78
- parseBncrNativeCommand(parsed.extracted.text, {
79
- allowBareWhoami: parsed.isAdmin !== true,
80
- }) !== null;
81
- const isAdminOpenClawNativeCommand =
82
- parsed.isAdmin === true &&
83
- !isBncrNativeCommand &&
84
- hasControlCommand(parsed.extracted.text, cfg as Parameters<typeof hasControlCommand>[1]);
85
- if (isAdminOpenClawNativeCommand) return true;
86
-
87
- const mode = admission.scene.groupReplyMode || 'admin';
88
- // admin and all: no accumulation needed - messages are either dispatched directly or discarded
89
- if (mode === 'admin') return false;
90
- if (mode === 'all') return false;
91
- // mention and hybrid: non-trigger messages accumulate for future context
92
- return true;
72
+ if (args.shouldDispatch) return true;
73
+ const mode = args.admission.scene.groupReplyMode || 'admin';
74
+ return mode === 'mention' || mode === 'hybrid';
93
75
  }
94
76
 
95
77
  export async function prepareBncrInboundAcceptance(args: {
@@ -206,6 +188,9 @@ export async function prepareBncrInboundAcceptance(args: {
206
188
  resolveAgentRoute: args.resolveAgentRoute,
207
189
  });
208
190
 
191
+ const shouldDispatch = shouldDispatchForScene({ parsed, admission, cfg });
192
+ const shouldAccumulate = shouldAccumulateForScene({ shouldDispatch, admission });
193
+
209
194
  return {
210
195
  ok: true as const,
211
196
  accountId,
@@ -213,8 +198,8 @@ export async function prepareBncrInboundAcceptance(args: {
213
198
  inboundText,
214
199
  hasMedia: Boolean(mediaBase64 || mediaPathFromTransfer),
215
200
  resolvedAgentId: admission.agentId,
216
- shouldDispatch: shouldDispatchForScene({ parsed, admission, cfg }),
217
- shouldAccumulate: shouldAccumulateForScene({ parsed, admission, cfg }),
201
+ shouldDispatch,
202
+ shouldAccumulate,
218
203
  dispatchBy: resolveDispatchBy({ parsed, admission, cfg }),
219
204
  };
220
205
  }
@@ -1,7 +1,8 @@
1
1
  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
- import type { BncrGroupHistoryMap } from '../messaging/inbound/group-history.ts';
4
+ import type { BncrConversationHistoryMap } from '../messaging/inbound/conversation-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 {
@@ -133,7 +134,8 @@ export type BncrInboundHandlersRuntime = {
133
134
  }) => string;
134
135
  defaultPublicAgentId: () => string;
135
136
  sceneRegistry: Map<string, BncrSceneRecord>;
136
- groupHistories: BncrGroupHistoryMap;
137
+ conversationHistories: BncrConversationHistoryMap;
138
+ outboundReplayCache: BncrOutboundReplayCache;
137
139
  prepareInboundAcceptance: (args: {
138
140
  parsed: ParsedInboundParams;
139
141
  canonicalAgentId: string;
@@ -163,7 +165,8 @@ export type BncrInboundHandlersRuntime = {
163
165
  shouldDispatch: boolean;
164
166
  shouldAccumulate: boolean;
165
167
  sceneRegistry: Map<string, BncrSceneRecord>;
166
- groupHistories: BncrGroupHistoryMap;
168
+ conversationHistories: BncrConversationHistoryMap;
169
+ outboundReplayCache: BncrOutboundReplayCache;
167
170
  defaultAdminAgentId: string;
168
171
  defaultPublicAgentId: string;
169
172
  now: () => number;
@@ -332,7 +335,8 @@ export function createBncrInboundHandlers(runtime: Omit<BncrInboundHandlersRunti
332
335
  shouldDispatch,
333
336
  shouldAccumulate,
334
337
  sceneRegistry: runtime.sceneRegistry,
335
- groupHistories: runtime.groupHistories,
338
+ conversationHistories: runtime.conversationHistories,
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,
@@ -85,7 +86,10 @@ export function createBncrInboundSurfaceHandlersGroup(runtime: {
85
86
  typeof createBncrInboundHandlersComponent
86
87
  >[0]['defaultPublicAgentId'];
87
88
  sceneRegistry: Parameters<typeof createBncrInboundHandlersComponent>[0]['sceneRegistry'];
88
- groupHistories: Parameters<typeof createBncrInboundHandlersComponent>[0]['groupHistories'];
89
+ conversationHistories: Parameters<
90
+ typeof createBncrInboundHandlersComponent
91
+ >[0]['conversationHistories'];
92
+ outboundReplayCache: BncrOutboundReplayCache;
89
93
  prepareInboundAcceptance: Parameters<
90
94
  typeof createBncrInboundHandlersComponent
91
95
  >[0]['prepareInboundAcceptance'];
@@ -151,7 +155,8 @@ export function createBncrInboundSurfaceHandlersGroup(runtime: {
151
155
  defaultAdminAgentId: runtime.defaultAdminAgentId,
152
156
  defaultPublicAgentId: runtime.defaultPublicAgentId,
153
157
  sceneRegistry: runtime.sceneRegistry,
154
- groupHistories: runtime.groupHistories,
158
+ conversationHistories: runtime.conversationHistories,
159
+ outboundReplayCache: runtime.outboundReplayCache,
155
160
  prepareInboundAcceptance: runtime.prepareInboundAcceptance,
156
161
  logInboundSummary: runtime.logInboundSummary,
157
162
  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
  {
@@ -56,8 +56,8 @@ function buildSceneRecord(args: {
56
56
  ...(kind === 'group' && parsed.groupName ? { groupName: parsed.groupName } : {}),
57
57
  ...(agentId ? { agentId } : {}),
58
58
  ...(kind === 'group' ? { groupReplyMode: defaultGroupReplyMode(kind) } : {}),
59
- ...(kind === 'group' ? { historyLimit: 50 } : {}),
60
- ...(kind === 'group' ? { historyForce: true } : {}),
59
+ historyLimit: 50,
60
+ historyForce: true,
61
61
  lastSeenAt,
62
62
  };
63
63
  }
@@ -90,8 +90,8 @@ export function decideSceneAdmission(args: {
90
90
  ...(kind === 'group' && !existing.groupReplyMode
91
91
  ? { groupReplyMode: defaultGroupReplyMode(kind) }
92
92
  : {}),
93
- ...(kind === 'group' && existing.historyLimit === undefined ? { historyLimit: 50 } : {}),
94
- ...(kind === 'group' && existing.historyForce === undefined ? { historyForce: true } : {}),
93
+ ...(existing.historyLimit === undefined ? { historyLimit: 50 } : {}),
94
+ ...(existing.historyForce === undefined ? { historyForce: true } : {}),
95
95
  lastSeenAt: now,
96
96
  } satisfies BncrSceneRecord;
97
97
 
@@ -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,
@@ -10,24 +14,30 @@ import {
10
14
  import type {
11
15
  BncrGroupReplyMode,
12
16
  BncrPersistedAccountTimestamp,
13
- BncrPersistedGroupHistoryBucket,
14
- BncrPersistedGroupHistoryEntry,
15
- BncrPersistedGroupHistoryMediaEntry,
17
+ BncrPersistedConversationHistoryBucket,
18
+ BncrPersistedConversationHistoryEntry,
19
+ BncrPersistedConversationHistoryMediaEntry,
16
20
  BncrPersistedLastSession,
21
+ BncrPersistedOutboundReplayBucket,
22
+ BncrPersistedOutboundReplayEntry,
17
23
  BncrPersistedSessionRoute,
18
24
  PersistedState as BncrPersistedState,
19
25
  BncrSceneRecord,
20
26
  } from './channel-runtime-types.ts';
21
27
 
22
28
  const GROUP_REPLY_MODES = new Set<BncrGroupReplyMode>(['admin', 'mention', 'hybrid', 'all']);
23
- const DEFAULT_PERSISTED_GROUP_HISTORY_LIMIT = 50;
29
+ const DEFAULT_PERSISTED_CONVERSATION_HISTORY_LIMIT = 50;
24
30
 
25
31
  type BncrPersistedStateStoreInput = {
26
32
  outbox?: unknown;
27
33
  deadLetter?: unknown;
28
34
  sessionRoutes?: unknown;
29
35
  sceneRegistry?: unknown;
36
+ conversationHistories?: unknown;
30
37
  groupHistories?: unknown;
38
+ outboundReplayCache?: unknown;
39
+ // Legacy persisted key from before the outboundReplayCache rename.
40
+ messageCache?: unknown;
31
41
  lastSessionByAccount?: unknown;
32
42
  lastActivityByAccount?: unknown;
33
43
  lastInboundByAccount?: unknown;
@@ -39,8 +49,10 @@ type PersistedAccountTimestampInput = Partial<BncrPersistedAccountTimestamp>;
39
49
  type PersistedLastSessionInput = Partial<BncrPersistedLastSession>;
40
50
  type PersistedSessionRouteInput = Partial<BncrPersistedSessionRoute>;
41
51
  type PersistedSceneRecordInput = Partial<BncrSceneRecord>;
42
- type PersistedGroupHistoryBucketInput = Partial<BncrPersistedGroupHistoryBucket>;
43
- type PersistedGroupHistoryEntryInput = Partial<BncrPersistedGroupHistoryEntry>;
52
+ type PersistedConversationHistoryBucketInput = Partial<BncrPersistedConversationHistoryBucket>;
53
+ type PersistedConversationHistoryEntryInput = Partial<BncrPersistedConversationHistoryEntry>;
54
+ type PersistedOutboundReplayBucketInput = Partial<BncrPersistedOutboundReplayBucket>;
55
+ type PersistedOutboundReplayEntryInput = Partial<BncrOutboundReplayEntry>;
44
56
 
45
57
  export function createBncrStateStore(runtime: {
46
58
  getStatePath: () => string | null;
@@ -64,7 +76,8 @@ export function createBncrStateStore(runtime: {
64
76
  maxSessionRouteEntries: number;
65
77
  maxAccountActivityEntries: number;
66
78
  sceneRegistry: Map<string, BncrSceneRecord>;
67
- groupHistories: Map<string, BncrPersistedGroupHistoryEntry[]>;
79
+ conversationHistories: Map<string, BncrPersistedConversationHistoryEntry[]>;
80
+ outboundReplayCache?: Map<string, BncrPersistedOutboundReplayEntry[]>;
68
81
  outbox: Map<string, OutboxEntry>;
69
82
  getDeadLetter: () => OutboxEntry[];
70
83
  setDeadLetter: (entries: OutboxEntry[]) => void;
@@ -77,16 +90,19 @@ export function createBncrStateStore(runtime: {
77
90
  getLastDriftSnapshot: () => BncrPersistedState['lastDriftSnapshot'];
78
91
  setLastDriftSnapshot: (value: BncrPersistedState['lastDriftSnapshot']) => void;
79
92
  }) {
80
- function resolvePersistedGroupHistoryLimit(key: string): number {
93
+ const outboundReplayCache =
94
+ runtime.outboundReplayCache ?? new Map<string, BncrPersistedOutboundReplayEntry[]>();
95
+
96
+ function resolvePersistedConversationHistoryLimit(key: string): number {
81
97
  const scene = runtime.sceneRegistry.get(key);
82
- const sceneLimit = scene?.kind === 'group' ? scene.historyLimit : undefined;
98
+ const sceneLimit = scene?.historyLimit;
83
99
  if (typeof sceneLimit === 'number' && Number.isFinite(sceneLimit) && sceneLimit >= 0) {
84
100
  return Math.max(
85
- DEFAULT_PERSISTED_GROUP_HISTORY_LIMIT,
101
+ DEFAULT_PERSISTED_CONVERSATION_HISTORY_LIMIT,
86
102
  Math.ceil(Math.floor(sceneLimit) * 1.2),
87
103
  );
88
104
  }
89
- return DEFAULT_PERSISTED_GROUP_HISTORY_LIMIT;
105
+ return DEFAULT_PERSISTED_CONVERSATION_HISTORY_LIMIT;
90
106
  }
91
107
 
92
108
  function loadPersistedSceneRegistry(persisted: unknown): void {
@@ -148,17 +164,21 @@ export function createBncrStateStore(runtime: {
148
164
  return Array.from(runtime.sceneRegistry.values()).sort((a, b) => a.lastSeenAt - b.lastSeenAt);
149
165
  }
150
166
 
151
- function loadPersistedGroupHistories(persisted: unknown): void {
152
- runtime.groupHistories.clear();
167
+ function loadPersistedHistoryBuckets(
168
+ persisted: unknown,
169
+ target: Map<string, BncrPersistedConversationHistoryEntry[]>,
170
+ resolveLimit: (key: string) => number,
171
+ ): void {
172
+ target.clear();
153
173
  const buckets = Array.isArray(persisted)
154
- ? (persisted as PersistedGroupHistoryBucketInput[])
174
+ ? (persisted as PersistedConversationHistoryBucketInput[])
155
175
  : [];
156
176
  for (const bucket of buckets) {
157
177
  const key = runtime.asString(bucket?.key || '').trim();
158
178
  if (!key) continue;
159
- const entries: BncrPersistedGroupHistoryEntry[] = [];
179
+ const entries: BncrPersistedConversationHistoryEntry[] = [];
160
180
  const rawEntries = Array.isArray(bucket?.entries)
161
- ? (bucket.entries as PersistedGroupHistoryEntryInput[])
181
+ ? (bucket.entries as PersistedConversationHistoryEntryInput[])
162
182
  : [];
163
183
  for (const entry of rawEntries) {
164
184
  const sender = runtime.asString(entry?.sender || '').trim();
@@ -167,9 +187,9 @@ export function createBncrStateStore(runtime: {
167
187
  const timestamp = runtime.finiteNumberOr(entry?.timestamp, 0);
168
188
  const messageId = runtime.asString(entry?.messageId || '').trim();
169
189
 
170
- const media: BncrPersistedGroupHistoryMediaEntry[] = [];
190
+ const media: BncrPersistedConversationHistoryMediaEntry[] = [];
171
191
  const rawMedia = Array.isArray(entry?.media)
172
- ? (entry.media as Partial<BncrPersistedGroupHistoryMediaEntry>[])
192
+ ? (entry.media as Partial<BncrPersistedConversationHistoryMediaEntry>[])
173
193
  : [];
174
194
  for (const item of rawMedia) {
175
195
  const path = runtime.asString(item?.path || '').trim();
@@ -177,20 +197,23 @@ export function createBncrStateStore(runtime: {
177
197
  const contentType = runtime.asString(item?.contentType || '').trim();
178
198
  const kind = runtime.asString(item?.kind || '').trim();
179
199
  const mediaMessageId = runtime.asString(item?.messageId || '').trim();
180
- const normalizedMedia: BncrPersistedGroupHistoryMediaEntry = {
200
+ const normalizedMedia: BncrPersistedConversationHistoryMediaEntry = {
181
201
  path,
182
202
  ...(contentType ? { contentType } : {}),
183
- ...(kind ? { kind: kind as BncrPersistedGroupHistoryMediaEntry['kind'] } : {}),
203
+ ...(kind ? { kind: kind as BncrPersistedConversationHistoryMediaEntry['kind'] } : {}),
184
204
  ...(mediaMessageId ? { messageId: mediaMessageId } : {}),
185
205
  };
186
206
  media.push(normalizedMedia);
187
207
  }
188
208
 
189
- const normalizedEntry: BncrPersistedGroupHistoryEntry = {
209
+ const normalizedEntry: BncrPersistedConversationHistoryEntry = {
190
210
  sender,
191
211
  ...(runtime.asString(entry?.senderId || '').trim()
192
212
  ? { senderId: runtime.asString(entry?.senderId || '').trim() }
193
213
  : {}),
214
+ ...(entry?.role === 'user' || entry?.role === 'assistant' || entry?.role === 'system'
215
+ ? { role: entry.role }
216
+ : {}),
194
217
  body,
195
218
  ...(timestamp > 0 ? { timestamp } : {}),
196
219
  ...(messageId ? { messageId } : {}),
@@ -199,15 +222,112 @@ export function createBncrStateStore(runtime: {
199
222
  entries.push(normalizedEntry);
200
223
  }
201
224
  if (entries.length > 0) {
202
- runtime.groupHistories.set(key, entries.slice(-resolvePersistedGroupHistoryLimit(key)));
225
+ const limit = resolveLimit(key);
226
+ target.set(key, Number.isFinite(limit) ? entries.slice(-limit) : entries);
203
227
  }
204
228
  }
205
229
  }
206
230
 
207
- function dumpPersistedGroupHistories() {
208
- return Array.from(runtime.groupHistories.entries()).map(([key, entries]) => ({
231
+ function loadPersistedConversationHistories(persisted: unknown): void {
232
+ loadPersistedHistoryBuckets(persisted, runtime.conversationHistories, (key) =>
233
+ resolvePersistedConversationHistoryLimit(key),
234
+ );
235
+ }
236
+
237
+ function loadPersistedOutboundReplayCache(persisted: unknown): void {
238
+ outboundReplayCache.clear();
239
+ const buckets = Array.isArray(persisted)
240
+ ? (persisted as PersistedOutboundReplayBucketInput[])
241
+ : [];
242
+ for (const bucket of buckets) {
243
+ const key = runtime.asString(bucket?.key || '').trim();
244
+ if (!key) continue;
245
+ const entries: BncrOutboundReplayEntry[] = [];
246
+ const rawEntries = Array.isArray(bucket?.entries)
247
+ ? (bucket.entries as PersistedOutboundReplayEntryInput[])
248
+ : [];
249
+ for (const entry of rawEntries) {
250
+ const sender = runtime.asString(entry?.sender || '').trim();
251
+ const body = runtime.asString(entry?.body || '').trim();
252
+ if (!sender || !body) continue;
253
+ const timestamp = runtime.finiteNumberOr(entry?.timestamp, 0);
254
+ const messageId = runtime.asString(entry?.messageId || '').trim();
255
+ const accountId = runtime.asString(entry?.accountId || '').trim();
256
+ const sessionKey = runtime.asString(entry?.sessionKey || '').trim();
257
+ const type = runtime.asString(entry?.type || '').trim();
258
+ const mediaUrl = runtime.asString(entry?.mediaUrl || '').trim();
259
+ const createdAt = runtime.finiteNumberOr(entry?.createdAt, 0);
260
+ const status = runtime.asString(entry?.status || '').trim();
261
+ const route =
262
+ runtime.parseRouteLike(entry?.route) ||
263
+ runtime.parseRouteLike({
264
+ platform: entry?.route?.platform,
265
+ groupId: entry?.route?.groupId,
266
+ userId: entry?.route?.userId,
267
+ });
268
+ const media: BncrPersistedConversationHistoryMediaEntry[] = [];
269
+ const rawMedia = Array.isArray(entry?.media)
270
+ ? (entry.media as Partial<BncrPersistedConversationHistoryMediaEntry>[])
271
+ : [];
272
+ for (const item of rawMedia) {
273
+ const path = runtime.asString(item?.path || '').trim();
274
+ if (!path) continue;
275
+ const contentType = runtime.asString(item?.contentType || '').trim();
276
+ const kind = runtime.asString(item?.kind || '').trim();
277
+ const mediaMessageId = runtime.asString(item?.messageId || '').trim();
278
+ media.push({
279
+ path,
280
+ ...(contentType ? { contentType } : {}),
281
+ ...(kind ? { kind: kind as BncrPersistedConversationHistoryMediaEntry['kind'] } : {}),
282
+ ...(mediaMessageId ? { messageId: mediaMessageId } : {}),
283
+ });
284
+ }
285
+ entries.push({
286
+ sender,
287
+ ...(runtime.asString(entry?.senderId || '').trim()
288
+ ? { senderId: runtime.asString(entry?.senderId || '').trim() }
289
+ : {}),
290
+ body,
291
+ ...(timestamp > 0 ? { timestamp } : {}),
292
+ ...(messageId ? { messageId } : {}),
293
+ ...(media.length > 0 ? { media } : {}),
294
+ ...(accountId ? { accountId } : {}),
295
+ ...(sessionKey ? { sessionKey } : {}),
296
+ ...(route ? { route } : {}),
297
+ ...(type ? { type } : {}),
298
+ ...(mediaUrl ? { mediaUrl } : {}),
299
+ ...(createdAt > 0 ? { createdAt } : {}),
300
+ ...(status === 'pushed' || status === 'acked'
301
+ ? { status: status as BncrOutboundReplayStatus }
302
+ : {}),
303
+ });
304
+ }
305
+ if (entries.length > 0) {
306
+ outboundReplayCache.set(key, entries);
307
+ }
308
+ }
309
+ }
310
+
311
+ function dumpPersistedHistoryBuckets(
312
+ source: Map<string, BncrPersistedConversationHistoryEntry[]>,
313
+ resolveLimit: (key: string) => number,
314
+ ) {
315
+ return Array.from(source.entries()).map(([key, entries]) => ({
316
+ key,
317
+ entries: Number.isFinite(resolveLimit(key)) ? entries.slice(-resolveLimit(key)) : entries,
318
+ }));
319
+ }
320
+
321
+ function dumpPersistedConversationHistories() {
322
+ return dumpPersistedHistoryBuckets(runtime.conversationHistories, (key) =>
323
+ resolvePersistedConversationHistoryLimit(key),
324
+ );
325
+ }
326
+
327
+ function dumpPersistedOutboundReplayCache() {
328
+ return Array.from(outboundReplayCache.entries()).map(([key, entries]) => ({
209
329
  key,
210
- entries: entries.slice(-resolvePersistedGroupHistoryLimit(key)),
330
+ entries,
211
331
  }));
212
332
  }
213
333
 
@@ -358,7 +478,8 @@ export function createBncrStateStore(runtime: {
358
478
 
359
479
  loadPersistedSessionRoutes(data.sessionRoutes);
360
480
  loadPersistedSceneRegistry(data.sceneRegistry);
361
- loadPersistedGroupHistories(data.groupHistories);
481
+ loadPersistedConversationHistories(data.conversationHistories ?? data.groupHistories);
482
+ loadPersistedOutboundReplayCache(data.outboundReplayCache ?? data.messageCache);
362
483
  loadPersistedLastSessionMap(data.lastSessionByAccount);
363
484
  loadPersistedAccountTimestampMap(runtime.lastActivityByAccount, data.lastActivityByAccount);
364
485
  loadPersistedAccountTimestampMap(runtime.lastInboundByAccount, data.lastInboundByAccount);
@@ -377,7 +498,8 @@ export function createBncrStateStore(runtime: {
377
498
  deadLetter: runtime.getDeadLetter().slice(-runtime.maxDeadLetterEntries),
378
499
  sessionRoutes: dumpPersistedSessionRoutes(),
379
500
  sceneRegistry: dumpPersistedSceneRegistry(),
380
- groupHistories: dumpPersistedGroupHistories(),
501
+ conversationHistories: dumpPersistedConversationHistories(),
502
+ outboundReplayCache: dumpPersistedOutboundReplayCache(),
381
503
  lastSessionByAccount: dumpPersistedLastSessionMap(),
382
504
  lastActivityByAccount: dumpPersistedAccountTimestampMap(runtime.lastActivityByAccount),
383
505
  lastInboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastInboundByAccount),
@@ -397,8 +519,10 @@ export function createBncrStateStore(runtime: {
397
519
  dumpPersistedSessionRoutes,
398
520
  loadPersistedSceneRegistry,
399
521
  dumpPersistedSceneRegistry,
400
- loadPersistedGroupHistories,
401
- dumpPersistedGroupHistories,
522
+ loadPersistedConversationHistories,
523
+ dumpPersistedConversationHistories,
524
+ loadPersistedOutboundReplayCache,
525
+ dumpPersistedOutboundReplayCache,
402
526
  backfillAccountActivityFromSessionRoutes,
403
527
  loadState,
404
528
  flushState,