@xmoxmo/bncr 0.5.5 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmoxmo/bncr",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/channel.ts CHANGED
@@ -288,6 +288,9 @@ class BncrBridgeRuntime {
288
288
  { accountId: string; route: BncrRoute; updatedAt: number }
289
289
  >();
290
290
  private sceneRegistry = new Map<string, BncrSceneRecord>();
291
+ getSceneRegistry(): Map<string, BncrSceneRecord> {
292
+ return this.sceneRegistry;
293
+ }
291
294
  private groupHistories: BncrGroupHistoryMap = new Map();
292
295
  private routeAliases = new Map<
293
296
  string,
@@ -1186,6 +1189,7 @@ class BncrBridgeRuntime {
1186
1189
  kind?: 'tool' | 'block' | 'final';
1187
1190
  replyToId?: string;
1188
1191
  replyTargetPolicy?: OutboundReplyTargetPolicy;
1192
+ downloadMedia?: boolean;
1189
1193
  }): OutboxEntry {
1190
1194
  return buildFileTransferOutboxEntryFromRuntime({
1191
1195
  createMessageId: () => randomUUID(),
@@ -1205,6 +1209,7 @@ class BncrBridgeRuntime {
1205
1209
  kind: params.kind,
1206
1210
  replyToId: asString(params.replyToId || '').trim() || undefined,
1207
1211
  replyTargetPolicy: params.replyTargetPolicy,
1212
+ downloadMedia: params.downloadMedia === true ? true : undefined,
1208
1213
  });
1209
1214
  }
1210
1215
 
@@ -1222,6 +1227,8 @@ class BncrBridgeRuntime {
1222
1227
  this.mediaDedupeRuntime.rememberRecentMediaSend(params);
1223
1228
  }
1224
1229
 
1230
+ private HTTP_URL_RE = /^https?:\/\//i;
1231
+
1225
1232
  private tryBuildMediaDedupeFallback(params: {
1226
1233
  sessionKey: string;
1227
1234
  mediaUrl: string;
@@ -1229,6 +1236,8 @@ class BncrBridgeRuntime {
1229
1236
  replyToId: string;
1230
1237
  currentTime?: number;
1231
1238
  }): { text: string; reason: 'same-text-sent-checkmark' | 'text-changed-downgrade' } | null {
1239
+ // Only deduplicate local file paths - HTTP URLs bypass dedup
1240
+ if (this.HTTP_URL_RE.test(params.mediaUrl)) return null;
1232
1241
  return this.mediaDedupeRuntime.tryBuildMediaDedupeFallback(params);
1233
1242
  }
1234
1243
 
@@ -1246,6 +1255,17 @@ class BncrBridgeRuntime {
1246
1255
  ? params.meta.messageKind
1247
1256
  : undefined;
1248
1257
 
1258
+ const hasPayload = !!(params.media.base64 || params.media.path);
1259
+ if (hasPayload) {
1260
+ console.log(
1261
+ '[bncr] outbound-media download=true|media=' +
1262
+ (params.media.path || `base64:${(params.media.base64 || '').substring(0, 12)}...`) +
1263
+ '|url=' +
1264
+ params.mediaUrl.split('?')[0],
1265
+ );
1266
+ } else {
1267
+ console.log(`[bncr] outbound-media pass-through|url=${params.mediaUrl.split('?')[0]}`);
1268
+ }
1249
1269
  return buildBncrMediaOutboundFrame({
1250
1270
  messageId: params.entry.messageId,
1251
1271
  sessionKey: params.entry.sessionKey,
@@ -1254,7 +1274,7 @@ class BncrBridgeRuntime {
1254
1274
  mode: params.media.path ? 'chunk' : 'base64',
1255
1275
  mimeType: params.media.mimeType,
1256
1276
  fileName: params.media.fileName,
1257
- mediaBase64: params.media.base64,
1277
+ base64: params.media.base64,
1258
1278
  path: params.media.path,
1259
1279
  },
1260
1280
  mediaUrl: params.mediaUrl,
@@ -1281,9 +1301,11 @@ class BncrBridgeRuntime {
1281
1301
  sessionKey: string;
1282
1302
  route: BncrRoute;
1283
1303
  text: string;
1304
+ extra?: Record<string, unknown>;
1284
1305
  kind?: 'tool' | 'block' | 'final';
1285
1306
  replyToId?: string;
1286
1307
  replyTargetPolicy?: OutboundReplyTargetPolicy;
1308
+ downloadMedia?: boolean;
1287
1309
  }): OutboxEntry {
1288
1310
  return buildTextOutboxEntryFromRuntime({
1289
1311
  createMessageId: () => randomUUID(),
@@ -1294,9 +1316,11 @@ class BncrBridgeRuntime {
1294
1316
  sessionKey: params.sessionKey,
1295
1317
  route: params.route,
1296
1318
  text: params.text,
1319
+ extra: params.extra && Object.keys(params.extra).length > 0 ? { ...params.extra } : undefined,
1297
1320
  kind: params.kind,
1298
1321
  replyToId: params.replyToId,
1299
1322
  replyTargetPolicy: params.replyTargetPolicy,
1323
+ downloadMedia: params.downloadMedia === true ? true : undefined,
1300
1324
  });
1301
1325
  }
1302
1326
 
@@ -2722,7 +2746,7 @@ class BncrBridgeRuntime {
2722
2746
  mode: 'base64' | 'chunk';
2723
2747
  mimeType?: string;
2724
2748
  fileName?: string;
2725
- mediaBase64?: string;
2749
+ base64?: string;
2726
2750
  path?: string;
2727
2751
  }> {
2728
2752
  return this.bridgeMediaFacade.transferMediaToBncrClient(params);
@@ -2985,6 +3009,22 @@ class BncrBridgeRuntime {
2985
3009
  await stopBncrStatusWorker(this.buildStatusWorkerRuntime(), ctx);
2986
3010
  };
2987
3011
 
3012
+ /**
3013
+ * Merge host-level outbound fields (forceDocument, gifPlayback, silent)
3014
+ * into extra before dispatching to channel-send runtime.
3015
+ */
3016
+ private mergeHostFields(ctx: BncrChannelSendContext): BncrChannelSendContext {
3017
+ if (!ctx.forceDocument && !ctx.gifPlayback && !ctx.silent) {
3018
+ return ctx;
3019
+ }
3020
+ const extra = { ...(typeof ctx.extra === 'object' && ctx.extra !== null ? ctx.extra : {}) };
3021
+ if (ctx.forceDocument === true) extra.forceDocument = true;
3022
+ if (ctx.gifPlayback === true) extra.gifPlayback = true;
3023
+ if (ctx.silent === true) extra.silent = true;
3024
+ // Clear original fields so resolveUnifiedOutboundExtra doesn't re-add and override marker values
3025
+ return { ...ctx, extra, forceDocument: undefined, gifPlayback: undefined, silent: undefined };
3026
+ }
3027
+
2988
3028
  // Channel send surface assembly ------------------------------------------
2989
3029
  // This group backs the public channel send APIs and should stay adjacent to
2990
3030
  // the final exposed channel* methods for quick top-down scanning.
@@ -2996,19 +3036,19 @@ class BncrBridgeRuntime {
2996
3036
  private readonly channelSendRuntime = this.channelSendRuntimeGroup.channelSendRuntime;
2997
3037
 
2998
3038
  channelSendText = async (ctx: BncrChannelSendContext) =>
2999
- this.channelSendRuntime.channelSendText(ctx);
3039
+ this.channelSendRuntime.channelSendText(this.mergeHostFields(ctx));
3000
3040
 
3001
3041
  channelSendMedia = async (ctx: BncrChannelSendContext) =>
3002
- this.channelSendRuntime.channelSendMedia(ctx);
3042
+ this.channelSendRuntime.channelSendMedia(this.mergeHostFields(ctx));
3003
3043
 
3004
3044
  channelMessageSendText = async (ctx: BncrChannelSendContext) =>
3005
- this.channelSendRuntime.channelMessageSendText(ctx);
3045
+ this.channelSendRuntime.channelMessageSendText(this.mergeHostFields(ctx));
3006
3046
 
3007
3047
  channelMessageSendMedia = async (ctx: BncrChannelSendContext) =>
3008
- this.channelSendRuntime.channelMessageSendMedia(ctx);
3048
+ this.channelSendRuntime.channelMessageSendMedia(this.mergeHostFields(ctx));
3009
3049
 
3010
3050
  channelMessageSendPayload = async (ctx: BncrChannelSendContext) =>
3011
- this.channelSendRuntime.channelMessageSendPayload(ctx);
3051
+ this.channelSendRuntime.channelMessageSendPayload(this.mergeHostFields(ctx));
3012
3052
  }
3013
3053
 
3014
3054
  // Plugin surface export -----------------------------------------------------
@@ -3029,6 +3069,7 @@ export function createBncrChannelPlugin(getBridge: () => BncrBridgeRuntime) {
3029
3069
 
3030
3070
  return createBncrChannelPluginSurfaceGroup({
3031
3071
  channelId: CHANNEL_ID,
3072
+ sceneRegistry: getBridge().getSceneRegistry(),
3032
3073
  getMessageSendBridge: bridgeGroup.getMessageSendBridge,
3033
3074
  getOutboundBridge: bridgeGroup.getOutboundBridge,
3034
3075
  getMessagingBridge: bridgeGroup.getMessagingBridge,
@@ -20,6 +20,7 @@ export function buildFileTransferOutboxEntry(args: {
20
20
  kind?: 'tool' | 'block' | 'final';
21
21
  replyToId?: string;
22
22
  replyTargetPolicy?: OutboundReplyTargetPolicy;
23
+ downloadMedia?: boolean;
23
24
  }): OutboxEntry {
24
25
  const messageId = args.createMessageId();
25
26
  const createdAt = args.now();
@@ -39,6 +40,7 @@ export function buildFileTransferOutboxEntry(args: {
39
40
  asVoice: args.asVoice === true,
40
41
  audioAsVoice: args.audioAsVoice === true,
41
42
  type: args.type,
43
+ downloadMedia: args.downloadMedia === true,
42
44
  ...(args.extra ? { extra: { ...args.extra } } : {}),
43
45
  finalEvent: args.pushEvent,
44
46
  replyToId:
@@ -62,12 +64,14 @@ export function buildTextOutboxEntry(args: {
62
64
  normalizeAccountId: (accountId?: string | null) => string;
63
65
  normalizeReplyToId: (value?: string | null) => string;
64
66
  accountId: string;
67
+ extra?: Record<string, unknown>;
65
68
  sessionKey: string;
66
69
  route: BncrRoute;
67
70
  text: string;
68
71
  kind?: 'tool' | 'block' | 'final';
69
72
  replyToId?: string;
70
73
  replyTargetPolicy?: OutboundReplyTargetPolicy;
74
+ downloadMedia?: boolean;
71
75
  }): OutboxEntry {
72
76
  const messageId = args.createMessageId();
73
77
  const createdAt = args.now();
@@ -92,6 +96,9 @@ export function buildTextOutboxEntry(args: {
92
96
  path: '',
93
97
  base64: '',
94
98
  fileName: '',
99
+ // Extra fields from marker/pipeline spread for adapter-specific data
100
+ // (e.g. type="appmsg", msg="<appmsg>..." from [BncrParam:...])
101
+ ...(args.extra ? { ...args.extra } : {}),
95
102
  },
96
103
  ts: createdAt,
97
104
  };
@@ -50,15 +50,18 @@ const BNCR_HELP_TEXT = [
50
50
  ' • /bncr deny [<SceneId>]',
51
51
  ' • /bncr bind <agentId> [<SceneId>]',
52
52
  ' • /bncr mode help',
53
- ' • /bncr mode <admin|mention|hybrid|all> [<SceneId>]',
53
+ ' • /bncr mode <admin|mention|hybrid|all|clear> [<SceneId>]',
54
54
  ' • /bncr revoke [<SceneId>]',
55
55
  ' • /bncr list pending [filters...]',
56
56
  ' • /bncr list scenes [filters...]',
57
57
  '',
58
58
  '📋 Group history',
59
59
  ' • /bncr history-help',
60
- ' • /bncr history-limit [<number>] [<SceneId>]',
61
- ' • /bncr history-force on|off [<SceneId>]',
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>]',
62
65
  ].join('\n');
63
66
 
64
67
  const BNCR_NATIVE_COMMANDS = new Set([
@@ -77,6 +80,7 @@ const BNCR_NATIVE_COMMANDS = new Set([
77
80
  'history-limit',
78
81
  'history-help',
79
82
  'history-force',
83
+ 'download-media',
80
84
  ]);
81
85
 
82
86
  export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set([
@@ -13,16 +13,22 @@ export type BncrSceneAdminCommand =
13
13
  | { kind: 'bind'; sceneKey?: string; agentId: string }
14
14
  | { kind: 'mode-help' }
15
15
  | { kind: 'mode-get'; sceneKey?: string }
16
- | { kind: 'mode'; sceneKey: string; mode: BncrGroupReplyMode }
16
+ | { kind: 'mode'; sceneKey: string; mode: string }
17
17
  | { kind: 'list'; scope: 'pending' | 'scenes'; filters?: string[] }
18
18
  | { kind: 'history-limit-get'; sceneKey?: string }
19
- | { kind: 'history-limit-set'; sceneKey: string; limit: number }
19
+ | { kind: 'history-limit-set'; sceneKey: string; limit: number | 'clear' }
20
20
  | { kind: 'history-force-get'; sceneKey?: string }
21
- | { kind: 'history-force-set'; sceneKey: string; enabled: boolean }
22
- | { kind: 'history-help' };
21
+ | { kind: 'history-force-set'; sceneKey: string; enabled: boolean | 'clear' }
22
+ | { kind: 'history-help' }
23
+ | { kind: 'download-media-get'; sceneKey?: string }
24
+ | { kind: 'download-media-set'; sceneKey: string; enabled: boolean }
25
+ | { kind: 'download-media-global-get' }
26
+ | { kind: 'download-media-global-set'; enabled: boolean };
23
27
 
24
28
  const GROUP_REPLY_MODES = new Set<BncrGroupReplyMode>(['admin', 'mention', 'hybrid', 'all']);
25
29
 
30
+ const GLOBAL_SCENE_KEY = '__global__';
31
+
26
32
  const MODE_HELP_TEXT = [
27
33
  '💬 Bncr Group Reply Mode Configuration',
28
34
  '',
@@ -122,6 +128,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
122
128
  return { matched: true, valid: true, command: { kind: 'mode-get' } };
123
129
  }
124
130
  if (args.length === 1) {
131
+ if (args[0] === 'clear') {
132
+ return {
133
+ matched: true,
134
+ valid: true,
135
+ command: { kind: 'mode', sceneKey: '', mode: 'clear' as any },
136
+ };
137
+ }
125
138
  if (args[0] === 'help') {
126
139
  return {
127
140
  matched: true,
@@ -142,6 +155,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
142
155
  command: { kind: 'mode-get', sceneKey: args[0] },
143
156
  };
144
157
  }
158
+ if (args[0] === 'clear' && args[1]) {
159
+ return {
160
+ matched: true,
161
+ valid: true,
162
+ command: { kind: 'mode', sceneKey: args[1], mode: 'clear' as any },
163
+ };
164
+ }
145
165
  if (GROUP_REPLY_MODES.has(args[0] as BncrGroupReplyMode) && args[1]) {
146
166
  return {
147
167
  matched: true,
@@ -181,6 +201,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
181
201
  return { matched: true, valid: true, command: { kind: 'history-limit-get' } };
182
202
  }
183
203
  if (args.length === 1) {
204
+ if (args[0] === 'clear') {
205
+ return {
206
+ matched: true,
207
+ valid: true,
208
+ command: { kind: 'history-limit-set', sceneKey: '', limit: 'clear' as any },
209
+ };
210
+ }
184
211
  const num = parseInt(args[0], 10);
185
212
  if (Number.isFinite(num)) {
186
213
  return {
@@ -196,6 +223,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
196
223
  };
197
224
  }
198
225
  if (args.length === 2) {
226
+ if (args[0] === 'clear') {
227
+ return {
228
+ matched: true,
229
+ valid: true,
230
+ command: { kind: 'history-limit-set', sceneKey: args[1], limit: 'clear' as any },
231
+ };
232
+ }
199
233
  const num = parseInt(args[0], 10);
200
234
  if (Number.isFinite(num)) {
201
235
  return {
@@ -214,6 +248,20 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
214
248
  if (args.length === 0) {
215
249
  return { matched: true, valid: true, command: { kind: 'history-force-get' } };
216
250
  }
251
+ if (args[0] === 'clear') {
252
+ if (args.length === 1) {
253
+ return {
254
+ matched: true,
255
+ valid: true,
256
+ command: { kind: 'history-force-set', sceneKey: '', enabled: 'clear' as any },
257
+ };
258
+ }
259
+ return {
260
+ matched: true,
261
+ valid: true,
262
+ command: { kind: 'history-force-set', sceneKey: args[1], enabled: 'clear' as any },
263
+ };
264
+ }
217
265
  if (args[0] === 'on' || args[0] === 'off') {
218
266
  if (args.length === 1) {
219
267
  return {
@@ -233,6 +281,50 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
233
281
  valid: true,
234
282
  command: { kind: 'history-force-get', sceneKey: args[0] },
235
283
  };
284
+ case 'download-media':
285
+ if (args.length === 0) {
286
+ return { matched: true, valid: true, command: { kind: 'download-media-get' } };
287
+ }
288
+ if (args[0] === 'default') {
289
+ if (args[1] === 'on' || args[1] === 'off') {
290
+ return {
291
+ matched: true,
292
+ valid: true,
293
+ command: { kind: 'download-media-global-set', enabled: args[1] === 'on' },
294
+ };
295
+ }
296
+ return { matched: true, valid: true, command: { kind: 'download-media-global-get' } };
297
+ }
298
+ if (args[0] === 'clear') {
299
+ return {
300
+ matched: true,
301
+ valid: true,
302
+ command: {
303
+ kind: 'download-media-set',
304
+ sceneKey: args[1] || '',
305
+ enabled: undefined as any,
306
+ },
307
+ };
308
+ }
309
+ if (args[0] === 'on' || args[0] === 'off') {
310
+ if (args.length === 1) {
311
+ return {
312
+ matched: true,
313
+ valid: true,
314
+ command: { kind: 'download-media-set', sceneKey: '', enabled: args[0] === 'on' },
315
+ };
316
+ }
317
+ return {
318
+ matched: true,
319
+ valid: true,
320
+ command: { kind: 'download-media-set', sceneKey: args[1], enabled: args[0] === 'on' },
321
+ };
322
+ }
323
+ return {
324
+ matched: true,
325
+ valid: true,
326
+ command: { kind: 'download-media-get', sceneKey: args[0] },
327
+ };
236
328
  case 'history-help':
237
329
  return { matched: true, valid: true, command: { kind: 'history-help' } };
238
330
  default:
@@ -399,6 +491,11 @@ export function executeSceneAdminCommand(args: {
399
491
  }
400
492
  const sExisting = sceneRegistry.get(sSceneKey);
401
493
  if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
494
+ if (command.limit === 'clear') {
495
+ const { historyLimit: _, ...rest } = sExisting;
496
+ sceneRegistry.set(sSceneKey, { ...rest, lastSeenAt: now() });
497
+ return { ok: true, text: `Cleared ${sSceneKey} history limit. Will use default (50).` };
498
+ }
402
499
  const rawLimit = command.limit;
403
500
  if (!Number.isFinite(rawLimit) || Number.isNaN(rawLimit)) {
404
501
  return { ok: false, text: 'Invalid number.' };
@@ -443,6 +540,11 @@ export function executeSceneAdminCommand(args: {
443
540
  }
444
541
  const sExisting = sceneRegistry.get(sSceneKey);
445
542
  if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
543
+ if (command.enabled === 'clear') {
544
+ const { historyForce: _, ...rest } = sExisting;
545
+ sceneRegistry.set(sSceneKey, { ...rest, lastSeenAt: now() });
546
+ return { ok: true, text: `Cleared ${sSceneKey} history auto flush. Will use default (on).` };
547
+ }
446
548
  sceneRegistry.set(sSceneKey, {
447
549
  ...sExisting,
448
550
  historyForce: command.enabled,
@@ -454,6 +556,75 @@ export function executeSceneAdminCommand(args: {
454
556
  };
455
557
  }
456
558
 
559
+ if (command.kind === 'download-media-global-get') {
560
+ const g = sceneRegistry.get(GLOBAL_SCENE_KEY);
561
+ const enabled = g?.downloadMedia === true;
562
+ return { ok: true, text: `Global default download remote media is ${enabled ? 'on' : 'off'}.` };
563
+ }
564
+
565
+ if (command.kind === 'download-media-global-set') {
566
+ const g = sceneRegistry.get(GLOBAL_SCENE_KEY);
567
+ sceneRegistry.set(GLOBAL_SCENE_KEY, {
568
+ sceneKey: GLOBAL_SCENE_KEY,
569
+ kind: 'group',
570
+ status: 'allowed',
571
+ ...(g || { platform: 'bncr' }),
572
+ downloadMedia: command.enabled,
573
+ lastSeenAt: now(),
574
+ });
575
+ return {
576
+ ok: true,
577
+ text: `Global default download remote media set to ${command.enabled ? 'on' : 'off'}.`,
578
+ };
579
+ }
580
+
581
+ if (command.kind === 'download-media-get') {
582
+ const qSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
583
+ if (!qSceneKey) {
584
+ return {
585
+ ok: true,
586
+ text: `Global default download remote media is ${sceneRegistry.get(GLOBAL_SCENE_KEY)?.downloadMedia === true ? 'on' : 'off'}.`,
587
+ };
588
+ }
589
+ const s = sceneRegistry.get(qSceneKey);
590
+ if (!s) return { ok: true, text: 'Default download remote media is off.' };
591
+ const enabled = s.downloadMedia === true;
592
+ return {
593
+ ok: true,
594
+ text: `Current ${qSceneKey} download remote media is ${enabled ? 'on' : 'off'}.`,
595
+ };
596
+ }
597
+
598
+ if (command.kind === 'download-media-set') {
599
+ const sSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
600
+ if (!sSceneKey) {
601
+ return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
602
+ }
603
+ const sExisting = sceneRegistry.get(sSceneKey);
604
+ if (command.enabled === undefined) {
605
+ // Clear per-group setting
606
+ if (sExisting) {
607
+ const { downloadMedia: _, ...rest } = sExisting;
608
+ sceneRegistry.set(sSceneKey, { ...rest, lastSeenAt: now() });
609
+ return {
610
+ ok: true,
611
+ text: `Cleared ${sSceneKey} download remote media. Will use global default.`,
612
+ };
613
+ }
614
+ return { ok: true, text: `No per-group config to clear for ${sSceneKey}.` };
615
+ }
616
+ if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
617
+ sceneRegistry.set(sSceneKey, {
618
+ ...sExisting,
619
+ downloadMedia: command.enabled,
620
+ lastSeenAt: now(),
621
+ });
622
+ return {
623
+ ok: true,
624
+ text: `Set ${sSceneKey} download remote media to ${command.enabled ? 'on' : 'off'}.`,
625
+ };
626
+ }
627
+
457
628
  const sceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
458
629
  if (!sceneKey) {
459
630
  return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
@@ -482,9 +653,14 @@ export function executeSceneAdminCommand(args: {
482
653
  if (existing.kind !== 'group') {
483
654
  return { ok: false, text: `Scene ${sceneKey} is not a group scene.` };
484
655
  }
656
+ if (command.mode === 'clear') {
657
+ const { groupReplyMode: _, ...rest } = existing;
658
+ sceneRegistry.set(sceneKey, { ...rest, lastSeenAt: now() });
659
+ return { ok: true, text: `Cleared ${sceneKey} reply mode. Will use default (admin).` };
660
+ }
485
661
  sceneRegistry.set(sceneKey, {
486
662
  ...existing,
487
- groupReplyMode: command.mode,
663
+ groupReplyMode: command.mode as BncrGroupReplyMode,
488
664
  lastSeenAt: now(),
489
665
  });
490
666
  return { ok: true, text: `Set ${sceneKey} reply mode to ${command.mode}.` };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * [BncrParam:JSON] marker parser.
3
+ *
4
+ * Extracts embedded JSON control parameters from agent text, removes the
5
+ * marker from the visible message, and returns the parsed key-value pairs.
6
+ *
7
+ * Format: [BncrParam:{"key":"val","asVoice":true}]
8
+ *
9
+ * Incomplete markers (missing closing ]) are left in text unchanged.
10
+ * Invalid JSON within a complete marker - marker is removed but no params.
11
+ */
12
+
13
+ const PREFIX = '[BncrParam:';
14
+
15
+ export function parseBncrMarker(text: string): {
16
+ cleanText: string;
17
+ params: Record<string, unknown>;
18
+ } {
19
+ const idx = text.indexOf(PREFIX);
20
+ if (idx === -1) return { cleanText: text, params: {} };
21
+
22
+ const start = idx + PREFIX.length;
23
+ if (start >= text.length) return { cleanText: text, params: {} };
24
+
25
+ // Scan character-by-character tracking brace/bracket depth so that
26
+ // ] inside JSON values (e.g. CDATA content) is not mistaken for
27
+ // the closing marker bracket.
28
+ let depth = 0;
29
+ let inString = false;
30
+ let esc = false;
31
+ let end = -1;
32
+ let started = false;
33
+
34
+ for (let i = start; i < text.length; i++) {
35
+ const ch = text[i];
36
+
37
+ if (esc) {
38
+ esc = false;
39
+ continue;
40
+ }
41
+ if (ch === '\\' && inString) {
42
+ esc = true;
43
+ continue;
44
+ }
45
+ if (ch === '"') {
46
+ inString = !inString;
47
+ continue;
48
+ }
49
+
50
+ if (!inString) {
51
+ if (!started) {
52
+ if (ch === '{' || ch === '[') {
53
+ started = true;
54
+ depth = 1;
55
+ }
56
+ continue;
57
+ }
58
+
59
+ if (ch === '{' || ch === '[') {
60
+ depth++;
61
+ } else if (ch === '}' || ch === ']') {
62
+ depth--;
63
+ if (depth === 0) {
64
+ // JSON content complete - expect ] to close the marker
65
+ if (i + 1 < text.length && text[i + 1] === ']') {
66
+ end = i + 1;
67
+ }
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ if (end === -1) return { cleanText: text, params: {} };
75
+
76
+ const rawJson = text.slice(start, end);
77
+ let params: Record<string, unknown> = {};
78
+
79
+ try {
80
+ const parsed = JSON.parse(rawJson);
81
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
82
+ params = parsed as Record<string, unknown>;
83
+ }
84
+ } catch {
85
+ // Invalid JSON — remove marker but don't apply params
86
+ }
87
+
88
+ const cleanText = (text.slice(0, idx) + text.slice(end + 1)).replace(/ {2,}/g, ' ').trim();
89
+ return { cleanText, params };
90
+ }
91
+
92
+ /**
93
+ * Media types that the pipeline internally routes as media sends.
94
+ * Only these types are consumed (stripped) from extra.
95
+ * Non-media types like "appmsg" remain in extra for downstream passthrough.
96
+ */
97
+ const MEDIA_CONSUMPTION_TYPES = new Set(['file', 'image', 'video', 'audio', 'voice']);
98
+
99
+ /**
100
+ * Known fields that the bncr plugin unconditionally consumes during outbound
101
+ * processing. These are stripped from extra at entry and set as independent
102
+ * pipeline params. `type` is NOT in this set — it is consumed conditionally
103
+ * based on whether its value is a recognized media type.
104
+ */
105
+ const CONSUMPTION_FIELDS_NO_TYPE = new Set([
106
+ 'asVoice',
107
+ 'audioAsVoice',
108
+ 'kind',
109
+ 'replyToId',
110
+ 'downloadMedia',
111
+ ]);
112
+
113
+ const CONSUMPTION_FIELDS = new Set([...CONSUMPTION_FIELDS_NO_TYPE]);
114
+
115
+ export function extractConsumptionFields(extra: Record<string, unknown> | undefined): {
116
+ consumed: Partial<{
117
+ asVoice: boolean;
118
+ audioAsVoice: boolean;
119
+ type: string;
120
+ kind: string;
121
+ replyToId: string;
122
+ downloadMedia: boolean;
123
+ }>;
124
+ remaining: Record<string, unknown>;
125
+ } {
126
+ const consumed: Record<string, unknown> = {};
127
+ const remaining: Record<string, unknown> = {};
128
+
129
+ if (!extra) return { consumed: {}, remaining: {} };
130
+
131
+ for (const [key, value] of Object.entries(extra)) {
132
+ // type is only consumed when its value is a recognized media type
133
+ // (file/image/video/audio/voice). Non-media types like "appmsg"
134
+ // stay in remaining for downstream passthrough.
135
+ if (key === 'type' && typeof value === 'string') {
136
+ if (MEDIA_CONSUMPTION_TYPES.has(value.trim().toLowerCase())) {
137
+ consumed[key] = value;
138
+ } else {
139
+ remaining[key] = value;
140
+ }
141
+ continue;
142
+ }
143
+ if (CONSUMPTION_FIELDS.has(key)) {
144
+ consumed[key] = value;
145
+ } else {
146
+ remaining[key] = value;
147
+ }
148
+ }
149
+
150
+ return {
151
+ consumed: consumed as Record<string, unknown>,
152
+ remaining,
153
+ };
154
+ }