@xmoxmo/bncr 0.6.2 → 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.
@@ -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
- '📋 Conversation 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
 
@@ -6,9 +6,12 @@ import type {
6
6
  } from '../../core/types.ts';
7
7
  import {
8
8
  type BncrConversationHistoryMap,
9
+ buildBncrBotReplyMessageId,
9
10
  buildBncrConversationHistoryKey,
10
11
  buildBncrConversationHistoryKeyFromRoute,
12
+ readConversationHistoryVersion,
11
13
  recordBncrBotReply,
14
+ resolveBncrHistoryLimit,
12
15
  } from './conversation-history.ts';
13
16
  import type { ParsedInbound } from './dispatch-prep.ts';
14
17
 
@@ -89,19 +92,21 @@ function recordOutboundReplayEntry(args: {
89
92
  cache: BncrOutboundReplayCache;
90
93
  cacheKey: string;
91
94
  entry: BncrOutboundReplayEntry;
92
- }) {
95
+ historyLimit?: number;
96
+ }): boolean {
93
97
  const msgId = String(args.entry.messageId || '').trim();
94
98
  const existing = args.cache.get(args.cacheKey) || [];
95
- if (msgId && existing.some((entry) => entry.messageId === msgId)) return;
99
+ if (msgId && existing.some((entry) => entry.messageId === msgId)) return false;
96
100
 
97
- const next = [...existing, cloneBncrOutboundReplayEntry(args.entry)].sort(
98
- (a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
99
- );
101
+ const next = [...existing, cloneBncrOutboundReplayEntry(args.entry)]
102
+ .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
103
+ .slice(-resolveBncrHistoryLimit(args.historyLimit));
100
104
 
101
105
  if (args.cache.has(args.cacheKey)) {
102
106
  args.cache.delete(args.cacheKey);
103
107
  }
104
108
  args.cache.set(args.cacheKey, next);
109
+ return true;
105
110
  }
106
111
 
107
112
  function inferMediaKind(args: {
@@ -156,6 +161,12 @@ function isAppMsg(type: string | undefined, msg: string): boolean {
156
161
  );
157
162
  }
158
163
 
164
+ export type BncrOutboundReplayRecordResult = {
165
+ recorded: boolean;
166
+ historyOverflow: boolean;
167
+ historyVersion?: number;
168
+ };
169
+
159
170
  export function recordBncrOutboundReplay(args: {
160
171
  cache: BncrOutboundReplayCache;
161
172
  conversationHistories?: BncrConversationHistoryMap;
@@ -164,14 +175,14 @@ export function recordBncrOutboundReplay(args: {
164
175
  sender: string;
165
176
  senderId?: string;
166
177
  status?: BncrOutboundReplayStatus;
167
- }) {
178
+ }): BncrOutboundReplayRecordResult {
168
179
  const cacheKey = buildBncrOutboundReplayKeyFromRoute({
169
180
  accountId: args.entry.accountId,
170
181
  platform: args.entry.route?.platform,
171
182
  groupId: args.entry.route?.groupId,
172
183
  userId: args.entry.route?.userId,
173
184
  });
174
- if (!cacheKey) return;
185
+ if (!cacheKey) return { recorded: false, historyOverflow: false };
175
186
 
176
187
  const message =
177
188
  args.entry.payload?.message && typeof args.entry.payload.message === 'object'
@@ -186,15 +197,36 @@ export function recordBncrOutboundReplay(args: {
186
197
  const body = isAppMsg(type, rawBody)
187
198
  ? buildMediaBody('document')
188
199
  : normalizeTextBody(rawBody) || (isMedia ? buildMediaBody(kind) : '');
189
- if (!body) return;
200
+ if (!body) return { recorded: false, historyOverflow: false };
190
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;
191
228
 
192
229
  if (args.conversationHistories) {
193
- const historyKey = buildBncrConversationHistoryKeyFromRoute({
194
- platform: args.entry.route?.platform,
195
- groupId: args.entry.route?.groupId,
196
- userId: args.entry.route?.userId,
197
- });
198
230
  if (historyKey) {
199
231
  recordBncrBotReply({
200
232
  historyMap: args.conversationHistories,
@@ -203,7 +235,7 @@ export function recordBncrOutboundReplay(args: {
203
235
  senderId: args.senderId,
204
236
  body,
205
237
  timestamp: recordTimestamp,
206
- messageId: args.entry.messageId,
238
+ messageId,
207
239
  historyLimit: args.historyLimit,
208
240
  ...(mediaUrl
209
241
  ? {
@@ -211,24 +243,68 @@ export function recordBncrOutboundReplay(args: {
211
243
  {
212
244
  path: mediaUrl,
213
245
  kind,
214
- messageId: args.entry.messageId,
246
+ messageId,
215
247
  },
216
248
  ],
217
249
  }
218
250
  : {}),
219
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
+ };
220
296
  }
221
297
  }
222
-
223
- recordOutboundReplayEntry({
298
+ const cacheRecorded = recordOutboundReplayEntry({
224
299
  cache: args.cache,
225
300
  cacheKey,
301
+ historyLimit: args.historyLimit,
226
302
  entry: {
227
303
  sender: args.sender,
228
304
  ...(args.senderId ? { senderId: args.senderId } : {}),
229
305
  body,
230
306
  timestamp: recordTimestamp,
231
- messageId: args.entry.messageId,
307
+ messageId: messageId || args.entry.messageId,
232
308
  accountId: args.entry.accountId,
233
309
  sessionKey: args.entry.sessionKey,
234
310
  route: { ...args.entry.route },
@@ -242,13 +318,17 @@ export function recordBncrOutboundReplay(args: {
242
318
  {
243
319
  path: mediaUrl,
244
320
  kind,
245
- messageId: args.entry.messageId,
321
+ messageId: messageId || args.entry.messageId,
246
322
  },
247
323
  ],
248
324
  }
249
325
  : {}),
250
326
  },
251
327
  });
328
+ return {
329
+ recorded: cacheRecorded,
330
+ historyOverflow: false,
331
+ };
252
332
  }
253
333
 
254
334
  export function readBncrOutboundReplaySnapshot(args: {
@@ -310,3 +390,32 @@ export function clearBncrOutboundReplay(args: {
310
390
  if (!cacheKey) return;
311
391
  args.cache.delete(cacheKey);
312
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
+ }
@@ -18,7 +18,7 @@ import type {
18
18
  ParsedInbound,
19
19
  } from './dispatch-prep.ts';
20
20
  import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
21
- import { parseBncrNativeCommand } from './native-command.ts';
21
+ import { isBncrStopCommandText, parseBncrNativeCommand } from './native-command.ts';
22
22
  import { buildBncrReplyConfig } from './reply-config.ts';
23
23
  import { runBncrReplyDispatchSerial } from './reply-dispatch-serial.ts';
24
24
  import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
@@ -126,6 +126,7 @@ export async function runBncrInboundReplyDispatch(args: {
126
126
  senderDisplayName: string;
127
127
  shouldDispatch: boolean;
128
128
  silentHistoryFlush?: boolean;
129
+ deliveryId?: string;
129
130
  setInboundActivity: (accountId: string, at: number) => void;
130
131
  scheduleSave: () => void;
131
132
  enqueueFromReply: BncrEnqueueFromReply;
@@ -145,6 +146,7 @@ export async function runBncrInboundReplyDispatch(args: {
145
146
  senderIdForContext,
146
147
  shouldDispatch,
147
148
  silentHistoryFlush = false,
149
+ deliveryId,
148
150
  setInboundActivity,
149
151
  scheduleSave,
150
152
  enqueueFromReply,
@@ -210,10 +212,9 @@ export async function runBncrInboundReplyDispatch(args: {
210
212
  return;
211
213
  }
212
214
 
213
- // Stop commands should bypass the serial chain so they can interrupt a running agent.
214
- const rawTrimmed = (rawBody || '').trim();
215
- const isStopCommand =
216
- rawTrimmed === '/stop' || rawTrimmed.startsWith('/stop ') || rawTrimmed.startsWith('/stop@');
215
+ // Stop commands also bypass the reply serial chain; the inbound history serial
216
+ // bypass is handled before this function runs so they can interrupt an agent.
217
+ const isStopCommand = isBncrStopCommandText(rawBody);
217
218
  const dispatchSessionKey = resolution.dispatchSessionKey;
218
219
 
219
220
  const runStopOrSerial = () => {
@@ -225,7 +226,7 @@ export async function runBncrInboundReplyDispatch(args: {
225
226
  raw: parsed,
226
227
  adapter: {
227
228
  ingest: () => ({
228
- id: msgId ?? `${resolution.canonicalTo}:${Date.now()}`,
229
+ id: deliveryId || msgId || `${resolution.canonicalTo}:${Date.now()}`,
229
230
  timestamp: Date.now(),
230
231
  rawText: rawBody,
231
232
  textForAgent: ctxPayload.BodyForAgent,