@xmoxmo/bncr 0.6.1 → 0.6.3

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 (31) hide show
  1. package/README.md +65 -1
  2. package/package.json +1 -1
  3. package/scripts/check-pack.mjs +4 -1
  4. package/scripts/selfcheck.mjs +4 -1
  5. package/src/channel.ts +144 -5
  6. package/src/messaging/inbound/commands.ts +62 -26
  7. package/src/messaging/inbound/context-facts.ts +88 -58
  8. package/src/messaging/inbound/conversation-history-serial.ts +258 -0
  9. package/src/messaging/inbound/conversation-history.ts +450 -0
  10. package/src/messaging/inbound/dispatch.ts +568 -123
  11. package/src/messaging/inbound/history-shard-worker.ts +180 -0
  12. package/src/messaging/inbound/native-command.ts +89 -33
  13. package/src/messaging/inbound/outbound-replay-cache.ts +202 -17
  14. package/src/messaging/inbound/reply-dispatch.ts +7 -6
  15. package/src/messaging/inbound/scene-admin.ts +40 -20
  16. package/src/messaging/inbound/turn-context.ts +101 -37
  17. package/src/plugin/bridge-lifecycle.ts +8 -0
  18. package/src/plugin/bridge-outbox-facade.ts +15 -1
  19. package/src/plugin/channel-components.ts +5 -5
  20. package/src/plugin/channel-runtime-builders-delivery.ts +2 -2
  21. package/src/plugin/channel-runtime-constants.ts +2 -0
  22. package/src/plugin/channel-runtime-types.ts +9 -7
  23. package/src/plugin/inbound-acceptance.ts +9 -24
  24. package/src/plugin/inbound-handlers.ts +4 -4
  25. package/src/plugin/inbound-surface-handlers-group.ts +4 -2
  26. package/src/plugin/outbox-drain-post-push.ts +3 -0
  27. package/src/plugin/scene-registry.ts +4 -4
  28. package/src/plugin/sqlite-state.ts +2109 -0
  29. package/src/plugin/state-store.ts +731 -69
  30. package/src/plugin/state-transient-runtime-group.ts +17 -3
  31. package/src/messaging/inbound/group-history.ts +0 -264
@@ -0,0 +1,180 @@
1
+ import { emitBncrLogLine } from '../../core/logging.ts';
2
+ import type { BncrHistoryShardQueue, BncrHistoryShardRow } from '../../plugin/sqlite-state.ts';
3
+ import type { BncrInboundContextPayload } from './contracts.ts';
4
+ import type { BncrConversationHistoryMap } from './conversation-history.ts';
5
+ import { removeBncrConversationHistoryMessageIds } from './conversation-history.ts';
6
+ import {
7
+ getConversationHistorySerialOwner,
8
+ runConversationHistorySerial,
9
+ } from './conversation-history-serial.ts';
10
+ import type {
11
+ BncrInboundConversationResolution,
12
+ BncrInboundReplyRouteFact,
13
+ ParsedInbound,
14
+ } from './dispatch-prep.ts';
15
+ import type { BncrOutboundReplayCache } from './outbound-replay-cache.ts';
16
+ import { removeBncrOutboundReplayMessageIds } from './outbound-replay-cache.ts';
17
+
18
+ export type BncrHistoryShardDispatchPayload = {
19
+ parsed: ParsedInbound;
20
+ msgId?: string | null;
21
+ peer: ParsedInbound['peer'];
22
+ rawBody: string;
23
+ storePath: string;
24
+ ctxPayload: BncrInboundContextPayload;
25
+ resolution: BncrInboundConversationResolution;
26
+ replyRouteFact: BncrInboundReplyRouteFact;
27
+ senderIdForContext: string;
28
+ senderDisplayName: string;
29
+ shouldDispatch: boolean;
30
+ silentHistoryFlush?: boolean;
31
+ deliveryId?: string;
32
+ };
33
+
34
+ export type BncrHistoryShardPayloadEnvelope = {
35
+ version: 2;
36
+ historyKey: string;
37
+ accountId?: string;
38
+ messageIds: string[];
39
+ bufferKeys: string[];
40
+ dispatch: BncrHistoryShardDispatchPayload;
41
+ };
42
+
43
+ export const HISTORY_SHARD_LEASE_RENEW_INTERVAL_MS = 60_000;
44
+
45
+ export async function runBncrHistoryShardWithLeaseRenewal<T>(args: {
46
+ shardId: number | null;
47
+ historyShardQueue?: BncrHistoryShardQueue;
48
+ task: () => Promise<T>;
49
+ intervalMs?: number;
50
+ owner?: string;
51
+ }): Promise<T> {
52
+ if (args.shardId === null || !args.historyShardQueue?.renewHistoryShardLease) {
53
+ return args.task();
54
+ }
55
+ const renew = () => {
56
+ try {
57
+ args.historyShardQueue?.renewHistoryShardLease?.(args.shardId!, args.owner);
58
+ } catch {
59
+ // Lease renewal is best-effort; the claim itself protects against a stale lease.
60
+ }
61
+ };
62
+ const timer = setInterval(renew, args.intervalMs ?? HISTORY_SHARD_LEASE_RENEW_INTERVAL_MS);
63
+ timer.unref?.();
64
+ try {
65
+ return await args.task();
66
+ } finally {
67
+ clearInterval(timer);
68
+ }
69
+ }
70
+
71
+ export function parseBncrHistoryShardPayload(value: string): BncrHistoryShardPayloadEnvelope {
72
+ const parsed = JSON.parse(value) as Partial<BncrHistoryShardPayloadEnvelope>;
73
+ if (parsed?.version !== 2) {
74
+ throw new Error(`unsupported history shard payload version=${String(parsed?.version)}`);
75
+ }
76
+ if (!parsed.dispatch || !parsed.historyKey) {
77
+ throw new Error('invalid history shard payload: missing dispatch payload');
78
+ }
79
+ return parsed as BncrHistoryShardPayloadEnvelope;
80
+ }
81
+
82
+ export async function processBncrHistoryShardSlot(args: {
83
+ shard: BncrHistoryShardRow;
84
+ historyShardQueue: BncrHistoryShardQueue;
85
+ conversationHistories?: BncrConversationHistoryMap;
86
+ outboundReplayCache?: BncrOutboundReplayCache;
87
+ runDispatch: (payload: BncrHistoryShardDispatchPayload) => Promise<void>;
88
+ leaseRenewIntervalMs?: number;
89
+ }) {
90
+ const shardOwner = args.shard.owner ?? getConversationHistorySerialOwner();
91
+ let payload: BncrHistoryShardPayloadEnvelope;
92
+ try {
93
+ payload = parseBncrHistoryShardPayload(args.shard.payloadJson);
94
+ } catch (error) {
95
+ try {
96
+ args.historyShardQueue.markHistoryShardFailed(args.shard.id, error, shardOwner);
97
+ } catch (markError) {
98
+ emitBncrLogLine(
99
+ 'warn',
100
+ `[bncr] history shard failed mark failed|key=${args.shard.historyKey}|shard=${args.shard.id}|reason=${String(markError)}`,
101
+ );
102
+ }
103
+ throw error;
104
+ }
105
+ await runBncrHistoryShardWithLeaseRenewal({
106
+ shardId: args.shard.id,
107
+ historyShardQueue: args.historyShardQueue,
108
+ intervalMs: args.leaseRenewIntervalMs,
109
+ owner: shardOwner,
110
+ task: () =>
111
+ runConversationHistorySerial(
112
+ args.shard.historyKey,
113
+ async (handle) => {
114
+ handle.phase('snapshot');
115
+ if (
116
+ args.historyShardQueue.markHistoryShardProcessing(args.shard.id, shardOwner) === false
117
+ ) {
118
+ throw new Error(
119
+ `history shard activation lost|key=${args.shard.historyKey}|shard=${args.shard.id}`,
120
+ );
121
+ }
122
+ try {
123
+ await args.runDispatch({
124
+ ...payload.dispatch,
125
+ deliveryId: `bncr-history-shard:${args.shard.id}`,
126
+ });
127
+ } catch (error) {
128
+ try {
129
+ args.historyShardQueue.markHistoryShardFailed(args.shard.id, error, shardOwner);
130
+ } catch (markError) {
131
+ emitBncrLogLine(
132
+ 'warn',
133
+ `[bncr] history shard failed mark failed|key=${args.shard.historyKey}|shard=${args.shard.id}|reason=${String(markError)}`,
134
+ );
135
+ }
136
+ throw error;
137
+ }
138
+ handle.phase('upload_end');
139
+ try {
140
+ args.historyShardQueue.markHistoryShardCompleted(args.shard.id, shardOwner);
141
+ } catch (error) {
142
+ // Completion marking is observability only; cleanup still deletes the
143
+ // shard-owned rows after the upload has settled.
144
+ emitBncrLogLine(
145
+ 'warn',
146
+ `[bncr] history shard complete mark failed|key=${args.shard.historyKey}|shard=${args.shard.id}|reason=${String(error)}`,
147
+ );
148
+ }
149
+ handle.phase('cache_delete_start');
150
+ if (args.conversationHistories) {
151
+ removeBncrConversationHistoryMessageIds({
152
+ historyMap: args.conversationHistories,
153
+ historyKey: args.shard.historyKey,
154
+ messageIds: payload.messageIds,
155
+ });
156
+ }
157
+ if (args.outboundReplayCache && payload.bufferKeys.length > 0) {
158
+ removeBncrOutboundReplayMessageIds({
159
+ cache: args.outboundReplayCache,
160
+ parsed: payload.dispatch.parsed,
161
+ accountId: payload.dispatch.resolution.accountId,
162
+ messageIds: payload.messageIds,
163
+ });
164
+ }
165
+ handle.phase('cache_delete_done');
166
+ try {
167
+ args.historyShardQueue.completeHistoryShard(args.shard.id, shardOwner);
168
+ } catch (error) {
169
+ // A failed cleanup is recovered from the DB on restart. Do not retry
170
+ // the same shard here because the upload itself already succeeded.
171
+ emitBncrLogLine(
172
+ 'warn',
173
+ `[bncr] history shard complete failed|key=${args.shard.historyKey}|shard=${args.shard.id}|reason=${String(error)}`,
174
+ );
175
+ }
176
+ },
177
+ { owner: shardOwner },
178
+ ),
179
+ });
180
+ }
@@ -11,12 +11,24 @@ export type ParseBncrNativeCommandOptions = {
11
11
  allowBareSessionReset?: boolean;
12
12
  };
13
13
 
14
+ export function isBncrStopCommandText(rawBody: string): boolean {
15
+ const raw = String(rawBody || '')
16
+ .trim()
17
+ .toLowerCase();
18
+ return raw === '/stop';
19
+ }
20
+
14
21
  export type NativeVerboseCommand = {
15
22
  handled: true;
16
23
  verboseLevel?: 'on' | 'off' | 'full';
17
24
  text: string;
18
25
  };
19
26
 
27
+ export type NativeHelpCommandOptions = {
28
+ isAdmin?: boolean;
29
+ peerKind?: 'direct' | 'group';
30
+ };
31
+
20
32
  export type NativeHelpCommand = {
21
33
  handled: true;
22
34
  text: string;
@@ -38,32 +50,6 @@ export type NativeSessionResetCommand = {
38
50
  text: string;
39
51
  };
40
52
 
41
- const BNCR_HELP_TEXT = [
42
- '🦞 Bncr command usage',
43
- '',
44
- '📌 Bncr builtins',
45
- ' • /bncr whoami',
46
- ' • /bncr verbose on|off|full',
47
- '',
48
- '🛡 Scene approval',
49
- ' • /bncr allow [<SceneId>]',
50
- ' • /bncr deny [<SceneId>]',
51
- ' • /bncr bind <agentId> [<SceneId>]',
52
- ' • /bncr mode help',
53
- ' • /bncr mode <admin|mention|hybrid|all|clear> [<SceneId>]',
54
- ' • /bncr revoke [<SceneId>]',
55
- ' • /bncr list pending [filters...]',
56
- ' • /bncr list scenes [filters...]',
57
- '',
58
- '📋 Group history',
59
- ' • /bncr history-help',
60
- ' • /bncr history-limit [<number>|clear] [<SceneId>]',
61
- ' • /bncr history-force on|off|clear [<SceneId>]',
62
- '',
63
- '🌐 Remote media',
64
- ' • /bncr download-media on|off|clear|default on|off [<SceneId>]',
65
- ].join('\n');
66
-
67
53
  const BNCR_NATIVE_COMMANDS = new Set([
68
54
  'help',
69
55
  'new',
@@ -120,7 +106,7 @@ export function parseBncrNativeCommand(
120
106
  return { command: 'whoami', raw, body: '/whoami', argsText: '' };
121
107
  }
122
108
  if (!raw.startsWith('/bncr')) return null;
123
- const match = raw.match(/^\/bncr(?:@[A-Za-z0-9_]+)?(?:\s+([^\s]+)(?:\s+([\s\S]*))?)?$/i);
109
+ const match = raw.match(/^\/bncr(?:\s+([^\s]+)(?:\s+([\s\S]*))?)?$/i);
124
110
  if (!match) return null;
125
111
 
126
112
  const command = String(match[1] || 'help')
@@ -142,7 +128,7 @@ export function parseBncrUnsupportedDirectCommand(text: string): {
142
128
  const raw = String(text || '').trim();
143
129
  if (!raw.startsWith('/')) return null;
144
130
 
145
- const bareMatch = raw.match(/^\/([A-Za-z0-9_]+)(?:@[A-Za-z0-9_]+)?(?:\s+.*)?$/);
131
+ const bareMatch = raw.match(/^\/([A-Za-z0-9_]+)(?:\s+.*)?$/);
146
132
  if (!bareMatch) return null;
147
133
  const bareCommand = String(bareMatch[1] || '')
148
134
  .trim()
@@ -150,7 +136,7 @@ export function parseBncrUnsupportedDirectCommand(text: string): {
150
136
  if (!bareCommand) return null;
151
137
 
152
138
  if (bareCommand === 'bncr') {
153
- const subMatch = raw.match(/^\/bncr(?:@[A-Za-z0-9_]+)?(?:\s+([A-Za-z0-9_-]+))?/i);
139
+ const subMatch = raw.match(/^\/bncr(?:\s+([A-Za-z0-9_-]+))?/i);
154
140
  const subCommand =
155
141
  String(subMatch?.[1] || '')
156
142
  .trim()
@@ -163,9 +149,77 @@ export function parseBncrUnsupportedDirectCommand(text: string): {
163
149
  return { command: bareCommand, raw };
164
150
  }
165
151
 
166
- export function resolveBncrNativeHelpCommand(command: NativeCommand): NativeHelpCommand | null {
152
+ const BNCR_HELP_SECTIONS = [
153
+ {
154
+ title: '📌 Bncr builtins',
155
+ audience: 'all',
156
+ commands: [
157
+ { label: '/bncr whoami', scopes: ['admin', 'direct', 'group'] },
158
+ { label: '/bncr status', scopes: ['admin', 'direct', 'group'] },
159
+ { label: '/bncr new', scopes: ['admin', 'direct'] },
160
+ { label: '/bncr reset', scopes: ['admin', 'direct'] },
161
+ { label: '/bncr verbose on|off|full', scopes: ['admin'] },
162
+ ],
163
+ },
164
+ {
165
+ title: '🛡 Scene approval',
166
+ audience: 'admin',
167
+ commands: [
168
+ { label: '/bncr allow [<SceneId>]', scopes: ['admin'] },
169
+ { label: '/bncr deny [<SceneId>]', scopes: ['admin'] },
170
+ { label: '/bncr bind <agentId> [<SceneId>]', scopes: ['admin'] },
171
+ { label: '/bncr mode help', scopes: ['admin'] },
172
+ {
173
+ label: '/bncr mode <admin|mention|hybrid|all|clear> [<SceneId>]',
174
+ scopes: ['admin'],
175
+ },
176
+ { label: '/bncr revoke [<SceneId>]', scopes: ['admin'] },
177
+ { label: '/bncr list pending [filters...]', scopes: ['admin'] },
178
+ { label: '/bncr list scenes [filters...]', scopes: ['admin'] },
179
+ ],
180
+ },
181
+ {
182
+ title: '📋 Conversation history',
183
+ audience: 'admin',
184
+ commands: [
185
+ { label: '/bncr history-help', scopes: ['admin'] },
186
+ { label: '/bncr history-limit [<number>|clear] [<SceneId>]', scopes: ['admin'] },
187
+ { label: '/bncr history-force on|off|clear [<SceneId>]', scopes: ['admin'] },
188
+ ],
189
+ },
190
+ {
191
+ title: '🌐 Remote media',
192
+ audience: 'admin',
193
+ commands: [
194
+ {
195
+ label: '/bncr download-media on|off|clear|default on|off [<SceneId>]',
196
+ scopes: ['admin'],
197
+ },
198
+ ],
199
+ },
200
+ ];
201
+
202
+ export function resolveBncrNativeHelpCommand(
203
+ command: NativeCommand,
204
+ options?: NativeHelpCommandOptions,
205
+ ): NativeHelpCommand | null {
167
206
  if (command.command !== 'help') return null;
168
- return { handled: true, text: BNCR_HELP_TEXT };
207
+
208
+ const isAdmin = options?.isAdmin === true;
209
+ const isGroup = options?.peerKind === 'group';
210
+ const audience = isAdmin ? 'admin' : isGroup ? 'group' : 'direct';
211
+ const sections: string[] = ['🦞 Bncr command usage', ''];
212
+
213
+ for (const section of BNCR_HELP_SECTIONS) {
214
+ if (section.audience !== 'all' && section.audience !== audience) continue;
215
+ const visible = section.commands.filter((item) => item.scopes.includes(audience));
216
+ if (visible.length === 0) continue;
217
+ sections.push(section.title);
218
+ for (const item of visible) sections.push(` • ${item.label}`);
219
+ sections.push('');
220
+ }
221
+
222
+ return { handled: true, text: sections.join('\n') };
169
223
  }
170
224
 
171
225
  export function resolveBncrNativeWhoamiCommand(args: {
@@ -214,16 +268,18 @@ export function resolveBncrNativeStatusCommand(args: {
214
268
 
215
269
  export function resolveBncrNativeSessionResetCommand(args: {
216
270
  command: NativeCommand;
271
+ peerKind: 'direct' | 'group';
217
272
  }): NativeSessionResetCommand | null {
218
273
  if (args.command.command !== 'new' && args.command.command !== 'reset') return null;
219
274
  const reason = args.command.command === 'new' ? 'new' : 'reset';
275
+ const scope = args.peerKind === 'group' ? 'this group chat' : 'this private chat';
220
276
  return {
221
277
  handled: true,
222
278
  reason,
223
279
  text:
224
280
  reason === 'new'
225
- ? 'Started a new session for this private chat.'
226
- : 'Reset the current session for this private chat.',
281
+ ? `Started a new session for ${scope}.`
282
+ : `Reset the current session for ${scope}.`,
227
283
  };
228
284
  }
229
285
 
@@ -4,6 +4,15 @@ import type {
4
4
  BncrOutboundReplayStatus,
5
5
  OutboxEntry,
6
6
  } from '../../core/types.ts';
7
+ import {
8
+ type BncrConversationHistoryMap,
9
+ buildBncrBotReplyMessageId,
10
+ buildBncrConversationHistoryKey,
11
+ buildBncrConversationHistoryKeyFromRoute,
12
+ readConversationHistoryVersion,
13
+ recordBncrBotReply,
14
+ resolveBncrHistoryLimit,
15
+ } from './conversation-history.ts';
7
16
  import type { ParsedInbound } from './dispatch-prep.ts';
8
17
 
9
18
  export type {
@@ -83,19 +92,21 @@ function recordOutboundReplayEntry(args: {
83
92
  cache: BncrOutboundReplayCache;
84
93
  cacheKey: string;
85
94
  entry: BncrOutboundReplayEntry;
86
- }) {
95
+ historyLimit?: number;
96
+ }): boolean {
87
97
  const msgId = String(args.entry.messageId || '').trim();
88
98
  const existing = args.cache.get(args.cacheKey) || [];
89
- if (msgId && existing.some((entry) => entry.messageId === msgId)) return;
99
+ if (msgId && existing.some((entry) => entry.messageId === msgId)) return false;
90
100
 
91
- const next = [...existing, cloneBncrOutboundReplayEntry(args.entry)].sort(
92
- (a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
93
- );
101
+ const next = [...existing, cloneBncrOutboundReplayEntry(args.entry)]
102
+ .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
103
+ .slice(-resolveBncrHistoryLimit(args.historyLimit));
94
104
 
95
105
  if (args.cache.has(args.cacheKey)) {
96
106
  args.cache.delete(args.cacheKey);
97
107
  }
98
108
  args.cache.set(args.cacheKey, next);
109
+ return true;
99
110
  }
100
111
 
101
112
  function inferMediaKind(args: {
@@ -150,20 +161,28 @@ function isAppMsg(type: string | undefined, msg: string): boolean {
150
161
  );
151
162
  }
152
163
 
164
+ export type BncrOutboundReplayRecordResult = {
165
+ recorded: boolean;
166
+ historyOverflow: boolean;
167
+ historyVersion?: number;
168
+ };
169
+
153
170
  export function recordBncrOutboundReplay(args: {
154
171
  cache: BncrOutboundReplayCache;
172
+ conversationHistories?: BncrConversationHistoryMap;
173
+ historyLimit?: number;
155
174
  entry: OutboxEntry;
156
175
  sender: string;
157
176
  senderId?: string;
158
177
  status?: BncrOutboundReplayStatus;
159
- }) {
178
+ }): BncrOutboundReplayRecordResult {
160
179
  const cacheKey = buildBncrOutboundReplayKeyFromRoute({
161
180
  accountId: args.entry.accountId,
162
181
  platform: args.entry.route?.platform,
163
182
  groupId: args.entry.route?.groupId,
164
183
  userId: args.entry.route?.userId,
165
184
  });
166
- if (!cacheKey) return;
185
+ if (!cacheKey) return { recorded: false, historyOverflow: false };
167
186
 
168
187
  const message =
169
188
  args.entry.payload?.message && typeof args.entry.payload.message === 'object'
@@ -178,17 +197,114 @@ export function recordBncrOutboundReplay(args: {
178
197
  const body = isAppMsg(type, rawBody)
179
198
  ? buildMediaBody('document')
180
199
  : normalizeTextBody(rawBody) || (isMedia ? buildMediaBody(kind) : '');
181
- if (!body) return;
200
+ if (!body) return { recorded: false, historyOverflow: false };
201
+ const recordTimestamp = args.entry.lastPushAt ?? Date.now();
202
+ const historyKey = buildBncrConversationHistoryKeyFromRoute({
203
+ platform: args.entry.route?.platform,
204
+ groupId: args.entry.route?.groupId,
205
+ userId: args.entry.route?.userId,
206
+ });
207
+ const messageId = historyKey
208
+ ? buildBncrBotReplyMessageId({
209
+ historyKey,
210
+ sender: args.sender,
211
+ senderId: args.senderId,
212
+ body,
213
+ timestamp: recordTimestamp,
214
+ messageId: args.entry.messageId,
215
+ media: mediaUrl
216
+ ? [
217
+ {
218
+ path: mediaUrl,
219
+ kind,
220
+ messageId: args.entry.messageId,
221
+ },
222
+ ]
223
+ : undefined,
224
+ })
225
+ : undefined;
226
+ let historyOverflow = false;
227
+ let historyVersion: number | undefined;
182
228
 
183
- recordOutboundReplayEntry({
229
+ if (args.conversationHistories) {
230
+ if (historyKey) {
231
+ recordBncrBotReply({
232
+ historyMap: args.conversationHistories,
233
+ historyKey,
234
+ sender: args.sender,
235
+ senderId: args.senderId,
236
+ body,
237
+ timestamp: recordTimestamp,
238
+ messageId,
239
+ historyLimit: args.historyLimit,
240
+ ...(mediaUrl
241
+ ? {
242
+ media: [
243
+ {
244
+ path: mediaUrl,
245
+ kind,
246
+ messageId,
247
+ },
248
+ ],
249
+ }
250
+ : {}),
251
+ });
252
+ const entries = args.conversationHistories.get(historyKey) || [];
253
+ const limit = resolveBncrHistoryLimit(args.historyLimit);
254
+ // Overflow is decided by the current active window after the outbound
255
+ // event, not by whether this call newly wrote a row. A restored or
256
+ // repeated ack must still be able to force a flush.
257
+ historyOverflow = entries.length >= limit;
258
+ if (historyOverflow) {
259
+ historyVersion = readConversationHistoryVersion(args.conversationHistories, historyKey);
260
+ }
261
+ const cacheRecorded = recordOutboundReplayEntry({
262
+ cache: args.cache,
263
+ cacheKey,
264
+ historyLimit: args.historyLimit,
265
+ entry: {
266
+ sender: args.sender,
267
+ ...(args.senderId ? { senderId: args.senderId } : {}),
268
+ body,
269
+ timestamp: recordTimestamp,
270
+ messageId,
271
+ accountId: args.entry.accountId,
272
+ sessionKey: args.entry.sessionKey,
273
+ route: { ...args.entry.route },
274
+ ...(type ? { type } : {}),
275
+ ...(mediaUrl ? { mediaUrl } : {}),
276
+ createdAt: args.entry.createdAt,
277
+ ...(args.status ? { status: args.status } : {}),
278
+ ...(mediaUrl
279
+ ? {
280
+ media: [
281
+ {
282
+ path: mediaUrl,
283
+ kind,
284
+ messageId,
285
+ },
286
+ ],
287
+ }
288
+ : {}),
289
+ },
290
+ });
291
+ return {
292
+ recorded: cacheRecorded,
293
+ historyOverflow,
294
+ ...(historyOverflow && historyVersion !== undefined ? { historyVersion } : {}),
295
+ };
296
+ }
297
+ }
298
+ const cacheRecorded = recordOutboundReplayEntry({
184
299
  cache: args.cache,
185
300
  cacheKey,
301
+ historyLimit: args.historyLimit,
186
302
  entry: {
187
303
  sender: args.sender,
188
304
  ...(args.senderId ? { senderId: args.senderId } : {}),
189
305
  body,
190
- timestamp: Date.now(),
191
- messageId: args.entry.messageId,
306
+ timestamp: recordTimestamp,
307
+ messageId: messageId || args.entry.messageId,
192
308
  accountId: args.entry.accountId,
193
309
  sessionKey: args.entry.sessionKey,
194
310
  route: { ...args.entry.route },
@@ -202,27 +318,67 @@ export function recordBncrOutboundReplay(args: {
202
318
  {
203
319
  path: mediaUrl,
204
320
  kind,
205
- messageId: args.entry.messageId,
321
+ messageId: messageId || args.entry.messageId,
206
322
  },
207
323
  ],
208
324
  }
209
325
  : {}),
210
326
  },
211
327
  });
328
+ return {
329
+ recorded: cacheRecorded,
330
+ historyOverflow: false,
331
+ };
212
332
  }
213
333
 
214
334
  export function readBncrOutboundReplaySnapshot(args: {
215
335
  cache: BncrOutboundReplayCache;
336
+ conversationHistories?: BncrConversationHistoryMap;
216
337
  parsed: ParsedInbound;
217
338
  accountId: string;
218
339
  excludeMessageId?: string | null;
219
340
  }): BncrOutboundReplayEntry[] {
220
- const cacheKey = buildBncrOutboundReplayKey(args.parsed, args.accountId);
221
- if (!cacheKey) return [];
222
341
  const excludedMessageId = String(args.excludeMessageId || '').trim();
223
- return (args.cache.get(cacheKey) || [])
224
- .filter((entry) => !excludedMessageId || entry.messageId !== excludedMessageId)
225
- .map(cloneBncrOutboundReplayEntry);
342
+ const cacheKey = buildBncrOutboundReplayKey(args.parsed, args.accountId);
343
+ const legacyEntries = cacheKey
344
+ ? (args.cache.get(cacheKey) || [])
345
+ .filter((entry) => !excludedMessageId || entry.messageId !== excludedMessageId)
346
+ .map(cloneBncrOutboundReplayEntry)
347
+ : [];
348
+ if (!args.conversationHistories) return legacyEntries;
349
+
350
+ const historyKey = buildBncrConversationHistoryKey(args.parsed);
351
+ const historyEntries = historyKey
352
+ ? (args.conversationHistories.get(historyKey) || [])
353
+ .filter((entry) => entry.role === 'assistant')
354
+ .filter((entry) => !excludedMessageId || entry.messageId !== excludedMessageId)
355
+ .map((entry) => ({
356
+ sender: entry.sender,
357
+ ...(entry.senderId ? { senderId: entry.senderId } : {}),
358
+ body: entry.body,
359
+ ...(typeof entry.timestamp === 'number' ? { timestamp: entry.timestamp } : {}),
360
+ ...(entry.messageId ? { messageId: entry.messageId } : {}),
361
+ ...(Array.isArray(entry.media)
362
+ ? {
363
+ media: entry.media.map((item) => ({
364
+ path: item.path,
365
+ contentType: item.contentType,
366
+ kind: item.kind,
367
+ messageId: item.messageId,
368
+ })),
369
+ }
370
+ : {}),
371
+ }))
372
+ : [];
373
+ const knownMessageIds = new Set(
374
+ historyEntries
375
+ .map((entry) => entry.messageId)
376
+ .filter((messageId): messageId is string => Boolean(messageId)),
377
+ );
378
+ return [
379
+ ...historyEntries,
380
+ ...legacyEntries.filter((entry) => !entry.messageId || !knownMessageIds.has(entry.messageId)),
381
+ ].sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
226
382
  }
227
383
 
228
384
  export function clearBncrOutboundReplay(args: {
@@ -234,3 +390,32 @@ export function clearBncrOutboundReplay(args: {
234
390
  if (!cacheKey) return;
235
391
  args.cache.delete(cacheKey);
236
392
  }
393
+
394
+ export function removeBncrOutboundReplayMessageIds(args: {
395
+ cache: BncrOutboundReplayCache;
396
+ parsed: ParsedInbound;
397
+ accountId: string;
398
+ messageIds: ReadonlyArray<string | undefined | null>;
399
+ }): number {
400
+ const cacheKey = buildBncrOutboundReplayKey(args.parsed, args.accountId);
401
+ if (!cacheKey) return 0;
402
+ const messageIds = new Set(
403
+ args.messageIds
404
+ .map((messageId) => String(messageId || '').trim())
405
+ .filter((messageId) => Boolean(messageId)),
406
+ );
407
+ if (messageIds.size === 0) return 0;
408
+ const current = args.cache.get(cacheKey) || [];
409
+ if (current.length === 0) return 0;
410
+ const next = current.filter(
411
+ (entry) =>
412
+ !String(entry.messageId || '').trim() || !messageIds.has(String(entry.messageId).trim()),
413
+ );
414
+ if (next.length === current.length) return 0;
415
+ if (next.length === 0) {
416
+ args.cache.delete(cacheKey);
417
+ } else {
418
+ args.cache.set(cacheKey, next);
419
+ }
420
+ return current.length - next.length;
421
+ }