@xmoxmo/bncr 0.5.0 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmoxmo/bncr",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -193,7 +193,10 @@ const output = execFileSync('npm', ['pack', '--dry-run', '--json'], {
193
193
  encoding: 'utf8',
194
194
  stdio: ['ignore', 'pipe', 'pipe'],
195
195
  });
196
- const [pack] = JSON.parse(output);
196
+ const raw = JSON.parse(output);
197
+ const pack = Array.isArray(raw)
198
+ ? raw[0]
199
+ : raw[Object.keys(raw).find((k) => k.startsWith('@')) || Object.keys(raw)[0]];
197
200
  const packedFiles = new Set((pack?.files ?? []).map((file) => file.path));
198
201
  const missing = requiredPackFiles.filter((file) => !packedFiles.has(file));
199
202
  const channelSource = fs.readFileSync(path.join(root, 'src/channel.ts'), 'utf8');
package/src/channel.ts CHANGED
@@ -2354,7 +2354,7 @@ class BncrBridgeRuntime {
2354
2354
  // 严谨目标解析:
2355
2355
  // 1) 标准 to 仅认 Bncr:<platform>:0:<userId> / Bncr:<platform>:<groupId>:0
2356
2356
  // 2) 仍接受 strict sessionKey 作为内部兼容输入
2357
- // 3) 输入侧额外兼容 Bncr:<platform>:user:<userId> / Bncr:<platform>:group:<groupId>
2357
+ // 3) 输入侧额外兼容 Bncr:<platform>:User:<userId> / Bncr:<platform>:Group:<groupId>
2358
2358
  // 4) 其他旧格式直接失败,并输出标准格式提示日志
2359
2359
  resolveVerifiedTarget(
2360
2360
  rawTarget: string,
@@ -428,6 +428,15 @@ export function formatDisplayScope(route: BncrRoute): string {
428
428
  return `Bncr:${platform}:0:${userId}`;
429
429
  }
430
430
 
431
+ export function formatHumanDisplayScope(route: BncrRoute): string {
432
+ const platform = asTargetString(route?.platform).trim();
433
+ const groupId = asTargetString(route?.groupId || '0').trim() || '0';
434
+ const userId = asTargetString(route?.userId || '0').trim() || '0';
435
+ if (groupId !== '0') return `Bncr:${platform}:Group:${groupId}`;
436
+ if (userId !== '0') return `Bncr:${platform}:User:${userId}`;
437
+ return formatDisplayScope(route);
438
+ }
439
+
431
440
  export function buildDisplayScopeCandidates(route: BncrRoute): string[] {
432
441
  const candidates = [formatDisplayScope(route)].filter(Boolean);
433
442
  return Array.from(new Set(candidates.map((x) => asTargetString(x).trim()).filter(Boolean)));
@@ -515,12 +515,6 @@ export async function handleBncrNativeCommand(params: {
515
515
  result: 'rejected',
516
516
  }),
517
517
  );
518
- await recordAndPatchBncrInboundSessionEntry({
519
- storePath,
520
- sessionKey,
521
- ctx: ctxPayload,
522
- patch: sessionIdentityPatch,
523
- });
524
518
  if (!silentNonAdminGroupReject) {
525
519
  await enqueueFromReply({
526
520
  accountId,
@@ -1,5 +1,7 @@
1
+ import { DEFAULT_GROUP_HISTORY_LIMIT } from 'openclaw/plugin-sdk/reply-history';
1
2
  import { emitBncrLogLine } from '../../core/logging.ts';
2
3
  import type { BncrSceneRecord } from '../../plugin/channel-runtime-types.ts';
4
+ import { buildSceneKey } from '../../plugin/scene-registry.ts';
3
5
  import { handleBncrNativeCommand } from './commands.ts';
4
6
  import type {
5
7
  BncrEnqueueFromReply,
@@ -18,7 +20,10 @@ import {
18
20
  } from './dispatch-prep.ts';
19
21
  import {
20
22
  type BncrGroupHistoryMap,
23
+ type BncrHistoryEntry,
24
+ buildBncrGroupHistoryKey,
21
25
  clearBncrPendingGroupHistory,
26
+ readBncrPendingGroupHistorySnapshot,
22
27
  recordBncrPendingGroupMedia,
23
28
  recordBncrPendingGroupText,
24
29
  } from './group-history.ts';
@@ -58,10 +63,10 @@ export async function dispatchBncrInbound(params: {
58
63
  cfg,
59
64
  parsed,
60
65
  canonicalAgentId,
61
- shouldDispatch = true,
62
- shouldAccumulate = shouldDispatch,
66
+ shouldDispatch: initialShouldDispatch = true,
67
+ shouldAccumulate = initialShouldDispatch,
63
68
  resolvedAgentId,
64
- sceneRegistry,
69
+ sceneRegistry = new Map(),
65
70
  groupHistories = new Map(),
66
71
  defaultAdminAgentId,
67
72
  defaultPublicAgentId,
@@ -73,6 +78,7 @@ export async function dispatchBncrInbound(params: {
73
78
  logger,
74
79
  } = params;
75
80
  const { accountId, clientId, msgId, extracted, mimeType, peer } = parsed;
81
+ let shouldDispatch = initialShouldDispatch;
76
82
 
77
83
  const nativeCommand = await handleBncrNativeCommand({
78
84
  api,
@@ -130,31 +136,29 @@ export async function dispatchBncrInbound(params: {
130
136
  '[bncr] inbound missing clientId for chat identity; using route identity fallback',
131
137
  );
132
138
  }
133
- const ctxPayload = await buildBncrInboundTurnContext({
134
- api,
135
- cfg,
136
- channelId,
137
- parsed,
138
- msgId,
139
- peer,
140
- senderIdForContext,
141
- senderDisplayName,
142
- ownerAllowFrom,
143
- bridgeSenderId,
144
- bridgeSenderName,
145
- resolution,
146
- prepared,
147
- groupHistories,
148
- shouldDispatch,
149
- });
139
+ const sceneHistoryLimit =
140
+ parsed.peer.kind === 'group'
141
+ ? (sceneRegistry.get(buildSceneKey(parsed))?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT)
142
+ : DEFAULT_GROUP_HISTORY_LIMIT;
143
+ const resolvedHistoryLimit =
144
+ typeof sceneHistoryLimit === 'number' &&
145
+ Number.isFinite(sceneHistoryLimit) &&
146
+ sceneHistoryLimit >= 0
147
+ ? Math.floor(sceneHistoryLimit)
148
+ : DEFAULT_GROUP_HISTORY_LIMIT;
150
149
 
150
+ let silentHistoryFlush = false;
151
+ let pendingHistoryEntries: BncrHistoryEntry[] = [];
151
152
  if (!shouldDispatch && shouldAccumulate) {
153
+ const historyKey = buildBncrGroupHistoryKey(parsed);
154
+ // Record the message first so overflow context includes it
152
155
  recordBncrPendingGroupText({
153
156
  historyMap: groupHistories,
154
157
  parsed,
155
158
  senderDisplayName,
156
159
  senderId: senderIdForContext,
157
160
  bodyText: rawBody,
161
+ historyLimit: resolvedHistoryLimit,
158
162
  });
159
163
  await recordBncrPendingGroupMedia({
160
164
  historyMap: groupHistories,
@@ -164,9 +168,67 @@ export async function dispatchBncrInbound(params: {
164
168
  bodyText: rawBody,
165
169
  mediaItems,
166
170
  mediaContentType: mediaContentType || mimeType,
171
+ historyLimit: resolvedHistoryLimit,
172
+ });
173
+ if (historyKey && parsed.peer.kind === 'group') {
174
+ const entries = groupHistories.get(historyKey);
175
+ if (entries && resolvedHistoryLimit > 0 && entries.length >= resolvedHistoryLimit) {
176
+ const sceneForHistory = sceneRegistry.get(buildSceneKey(parsed));
177
+ const historyForce = sceneForHistory?.historyForce !== false;
178
+ if (historyForce) {
179
+ pendingHistoryEntries = readBncrPendingGroupHistorySnapshot({
180
+ historyMap: groupHistories,
181
+ parsed,
182
+ historyLimit: resolvedHistoryLimit,
183
+ });
184
+ clearBncrPendingGroupHistory({
185
+ historyMap: groupHistories,
186
+ parsed,
187
+ historyLimit: resolvedHistoryLimit,
188
+ });
189
+ shouldDispatch = true;
190
+ silentHistoryFlush = true;
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ if (shouldDispatch && !silentHistoryFlush && parsed.peer.kind === 'group') {
197
+ pendingHistoryEntries = readBncrPendingGroupHistorySnapshot({
198
+ historyMap: groupHistories,
199
+ parsed,
200
+ historyLimit: resolvedHistoryLimit,
167
201
  });
202
+ if (pendingHistoryEntries.length > 0) {
203
+ clearBncrPendingGroupHistory({
204
+ historyMap: groupHistories,
205
+ parsed,
206
+ historyLimit: resolvedHistoryLimit,
207
+ });
208
+ }
168
209
  }
169
210
 
211
+ const ctxPayload = await buildBncrInboundTurnContext({
212
+ api,
213
+ cfg,
214
+ channelId,
215
+ parsed,
216
+ msgId,
217
+ peer,
218
+ senderIdForContext,
219
+ senderDisplayName,
220
+ ownerAllowFrom,
221
+ bridgeSenderId,
222
+ bridgeSenderName,
223
+ historyLimit: resolvedHistoryLimit,
224
+ resolution,
225
+ prepared,
226
+ groupHistories,
227
+ shouldDispatch,
228
+ silentHistoryFlush,
229
+ pendingHistoryEntries,
230
+ });
231
+
170
232
  await runBncrInboundReplyDispatch({
171
233
  api,
172
234
  channelId,
@@ -182,13 +244,18 @@ export async function dispatchBncrInbound(params: {
182
244
  senderIdForContext,
183
245
  senderDisplayName,
184
246
  shouldDispatch,
247
+ silentHistoryFlush,
185
248
  setInboundActivity,
186
249
  scheduleSave,
187
250
  enqueueFromReply,
188
251
  });
189
252
 
190
- if (shouldDispatch) {
191
- clearBncrPendingGroupHistory({ historyMap: groupHistories, parsed });
253
+ if (shouldDispatch && pendingHistoryEntries.length === 0) {
254
+ clearBncrPendingGroupHistory({
255
+ historyMap: groupHistories,
256
+ parsed,
257
+ historyLimit: resolvedHistoryLimit,
258
+ });
192
259
  }
193
260
 
194
261
  return {
@@ -37,13 +37,25 @@ export function recordBncrPendingGroupText(args: {
37
37
  senderDisplayName: string;
38
38
  senderId: string;
39
39
  bodyText: string;
40
+ historyLimit?: number;
40
41
  }) {
42
+ const limit =
43
+ typeof args.historyLimit === 'number' &&
44
+ Number.isFinite(args.historyLimit) &&
45
+ args.historyLimit >= 0
46
+ ? Math.floor(args.historyLimit)
47
+ : DEFAULT_GROUP_HISTORY_LIMIT;
41
48
  const historyKey = buildBncrGroupHistoryKey(args.parsed);
42
49
  const body = normalizeTextBody(args.bodyText);
43
50
  if (!historyKey || !body || args.parsed.msgType !== 'text') return;
51
+ const msgId = String(args.parsed.msgId || '').trim();
52
+ if (msgId) {
53
+ const existing = args.historyMap.get(historyKey) || [];
54
+ if (existing.some((e) => e.messageId === msgId)) return;
55
+ }
44
56
  createChannelHistoryWindow({ historyMap: args.historyMap }).record({
45
57
  historyKey,
46
- limit: DEFAULT_GROUP_HISTORY_LIMIT,
58
+ limit,
47
59
  entry: {
48
60
  sender: args.senderDisplayName,
49
61
  senderId: args.senderId,
@@ -89,7 +101,14 @@ export async function recordBncrPendingGroupMedia(args: {
89
101
  kind?: BncrHistoryMediaKind;
90
102
  }>;
91
103
  mediaContentType?: string;
104
+ historyLimit?: number;
92
105
  }) {
106
+ const limit =
107
+ typeof args.historyLimit === 'number' &&
108
+ Number.isFinite(args.historyLimit) &&
109
+ args.historyLimit >= 0
110
+ ? Math.floor(args.historyLimit)
111
+ : DEFAULT_GROUP_HISTORY_LIMIT;
93
112
  const historyKey = buildBncrGroupHistoryKey(args.parsed);
94
113
  if (!historyKey || args.parsed.msgType === 'text') return;
95
114
  const normalizedMediaItems = Array.isArray(args.mediaItems) ? args.mediaItems : [];
@@ -110,9 +129,14 @@ export async function recordBncrPendingGroupMedia(args: {
110
129
  });
111
130
  const body = normalizeTextBody(args.bodyText) || buildBncrHistoryMediaBody(kind);
112
131
  if (normalizedMediaItems.length === 0) {
132
+ const msgId = String(args.parsed.msgId || '').trim();
133
+ if (msgId) {
134
+ const existing = args.historyMap.get(historyKey) || [];
135
+ if (existing.some((e) => e.messageId === msgId)) return;
136
+ }
113
137
  createChannelHistoryWindow({ historyMap: args.historyMap }).record({
114
138
  historyKey,
115
- limit: DEFAULT_GROUP_HISTORY_LIMIT,
139
+ limit,
116
140
  entry: {
117
141
  sender: args.senderDisplayName,
118
142
  senderId: args.senderId,
@@ -123,9 +147,14 @@ export async function recordBncrPendingGroupMedia(args: {
123
147
  });
124
148
  return;
125
149
  }
150
+ const msgId = String(args.parsed.msgId || '').trim();
151
+ if (msgId) {
152
+ const existing = args.historyMap.get(historyKey) || [];
153
+ if (existing.some((e) => e.messageId === msgId)) return;
154
+ }
126
155
  createChannelHistoryWindow({ historyMap: args.historyMap }).record({
127
156
  historyKey,
128
- limit: DEFAULT_GROUP_HISTORY_LIMIT,
157
+ limit,
129
158
  entry: {
130
159
  sender: args.senderDisplayName,
131
160
  senderId: args.senderId,
@@ -145,23 +174,56 @@ export async function recordBncrPendingGroupMedia(args: {
145
174
  });
146
175
  }
147
176
 
148
- export function buildBncrPendingGroupContext(args: {
149
- api: BncrInboundApi;
177
+ function cloneBncrHistoryEntry(entry: BncrHistoryEntry): BncrHistoryEntry {
178
+ return {
179
+ sender: entry.sender,
180
+ ...(entry.messageId ? { messageId: entry.messageId } : {}),
181
+ ...(typeof entry.timestamp === 'number' ? { timestamp: entry.timestamp } : {}),
182
+ ...(entry.senderId ? { senderId: entry.senderId } : {}),
183
+ body: entry.body,
184
+ ...(Array.isArray(entry.media)
185
+ ? {
186
+ media: entry.media.map((item) => ({
187
+ ...(item?.path ? { path: item.path } : {}),
188
+ ...(item?.contentType ? { contentType: item.contentType } : {}),
189
+ ...(item?.kind ? { kind: item.kind } : {}),
190
+ ...(item?.messageId ? { messageId: item.messageId } : {}),
191
+ })),
192
+ }
193
+ : {}),
194
+ };
195
+ }
196
+
197
+ export function readBncrPendingGroupHistorySnapshot(args: {
150
198
  historyMap: BncrGroupHistoryMap;
151
199
  parsed: ParsedInbound;
200
+ historyLimit?: number;
201
+ }): BncrHistoryEntry[] {
202
+ const limit =
203
+ typeof args.historyLimit === 'number' &&
204
+ Number.isFinite(args.historyLimit) &&
205
+ args.historyLimit >= 0
206
+ ? Math.floor(args.historyLimit)
207
+ : DEFAULT_GROUP_HISTORY_LIMIT;
208
+ const historyKey = buildBncrGroupHistoryKey(args.parsed);
209
+ if (!historyKey) return [];
210
+ const entries = args.historyMap.get(historyKey) || [];
211
+ if (entries.length === 0) return [];
212
+ return entries.slice(-limit).map(cloneBncrHistoryEntry);
213
+ }
214
+
215
+ export function buildBncrGroupContextFromEntries(args: {
216
+ api: BncrInboundApi;
217
+ entries: readonly BncrHistoryEntry[];
152
218
  channelLabel: string;
153
219
  currentTimestamp: number;
154
220
  previousTimestamp?: unknown;
155
221
  envelope?: unknown;
156
222
  currentMessage: string;
157
223
  }) {
158
- const historyKey = buildBncrGroupHistoryKey(args.parsed);
159
- if (!historyKey) return args.currentMessage;
160
- return createChannelHistoryWindow({ historyMap: args.historyMap }).buildPendingContext({
161
- historyKey,
162
- limit: DEFAULT_GROUP_HISTORY_LIMIT,
163
- currentMessage: args.currentMessage,
164
- formatEntry: (entry) =>
224
+ if (args.entries.length === 0) return args.currentMessage;
225
+ const historyText = args.entries
226
+ .map((entry) =>
165
227
  formatOpenClawAgentEnvelope(args.api, {
166
228
  channel: 'Bncr',
167
229
  from: args.channelLabel,
@@ -170,17 +232,15 @@ export function buildBncrPendingGroupContext(args: {
170
232
  envelope: args.envelope,
171
233
  body: entry.body,
172
234
  }),
173
- });
235
+ )
236
+ .join('\n');
237
+ return `[Chat messages since your last reply - for context]\n${historyText}\n\n[Current message - respond to this]${args.currentMessage}`;
174
238
  }
175
239
 
176
- export function collectBncrPendingHistoryMedia(args: {
177
- historyMap: BncrGroupHistoryMap;
178
- parsed: ParsedInbound;
240
+ export function collectBncrHistoryMediaFromEntries(args: {
241
+ entries: readonly BncrHistoryEntry[];
179
242
  }): HistoryMediaEntry[] {
180
- const historyKey = buildBncrGroupHistoryKey(args.parsed);
181
- if (!historyKey) return [];
182
- const entries = args.historyMap.get(historyKey) || [];
183
- return entries.flatMap((entry) => (Array.isArray(entry.media) ? entry.media : []));
243
+ return args.entries.flatMap((entry) => (Array.isArray(entry.media) ? entry.media : []));
184
244
  }
185
245
 
186
246
  function toBncrPromptMediaPath(value: string | undefined): string | undefined {
@@ -230,16 +290,12 @@ function buildHistoryWindowMedias(media: HistoryMediaEntry[] | undefined) {
230
290
  });
231
291
  }
232
292
 
233
- export function buildBncrPendingHistoryWindowContext(args: {
234
- historyMap: BncrGroupHistoryMap;
235
- parsed: ParsedInbound;
293
+ export function buildBncrHistoryWindowContextFromEntries(args: {
294
+ entries: readonly BncrHistoryEntry[];
236
295
  channelId: string;
237
296
  }) {
238
- const historyKey = buildBncrGroupHistoryKey(args.parsed);
239
- if (!historyKey) return undefined;
240
- const entries = args.historyMap.get(historyKey) || [];
241
- if (entries.length === 0) return undefined;
242
- const messages = entries.slice(-DEFAULT_GROUP_HISTORY_LIMIT).map((entry) => ({
297
+ if (args.entries.length === 0) return undefined;
298
+ const messages = args.entries.map((entry) => ({
243
299
  ...(entry.messageId ? { messageId: entry.messageId } : {}),
244
300
  sender: entry.sender,
245
301
  senderId: entry.senderId,
@@ -263,11 +319,18 @@ export function buildBncrPendingHistoryWindowContext(args: {
263
319
  export function clearBncrPendingGroupHistory(args: {
264
320
  historyMap: BncrGroupHistoryMap;
265
321
  parsed: ParsedInbound;
322
+ historyLimit?: number;
266
323
  }) {
324
+ const limit =
325
+ typeof args.historyLimit === 'number' &&
326
+ Number.isFinite(args.historyLimit) &&
327
+ args.historyLimit >= 0
328
+ ? Math.floor(args.historyLimit)
329
+ : DEFAULT_GROUP_HISTORY_LIMIT;
267
330
  const historyKey = buildBncrGroupHistoryKey(args.parsed);
268
331
  if (!historyKey) return;
269
332
  createChannelHistoryWindow({ historyMap: args.historyMap }).clear({
270
333
  historyKey,
271
- limit: DEFAULT_GROUP_HISTORY_LIMIT,
334
+ limit: limit,
272
335
  });
273
336
  }
@@ -49,12 +49,16 @@ const BNCR_HELP_TEXT = [
49
49
  ' • /bncr allow [<SceneId>]',
50
50
  ' • /bncr deny [<SceneId>]',
51
51
  ' • /bncr bind <agentId> [<SceneId>]',
52
- ' • /bncr mode',
53
52
  ' • /bncr mode help',
54
53
  ' • /bncr mode <admin|mention|hybrid|all> [<SceneId>]',
55
54
  ' • /bncr revoke [<SceneId>]',
56
55
  ' • /bncr list pending',
57
56
  ' • /bncr list scenes',
57
+ '',
58
+ '📋 Group history',
59
+ ' • /bncr history-help',
60
+ ' • /bncr history-limit [<number>] [<SceneId>]',
61
+ ' • /bncr history-force on|off [<SceneId>]',
58
62
  ].join('\n');
59
63
 
60
64
  const BNCR_NATIVE_COMMANDS = new Set([
@@ -70,9 +74,18 @@ const BNCR_NATIVE_COMMANDS = new Set([
70
74
  'mode',
71
75
  'revoke',
72
76
  'list',
77
+ 'history-limit',
78
+ 'history-help',
79
+ 'history-force',
73
80
  ]);
74
81
 
75
- export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set(['whoami', 'status', 'new', 'reset']);
82
+ export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set([
83
+ 'whoami',
84
+ 'status',
85
+ 'new',
86
+ 'reset',
87
+ 'stop',
88
+ ]);
76
89
 
77
90
  export const BNCR_DIRECT_ALLOWED_SUBCOMMANDS = new Set([
78
91
  'help',
@@ -125,6 +125,7 @@ export async function runBncrInboundReplyDispatch(args: {
125
125
  senderIdForContext: string;
126
126
  senderDisplayName: string;
127
127
  shouldDispatch: boolean;
128
+ silentHistoryFlush?: boolean;
128
129
  setInboundActivity: (accountId: string, at: number) => void;
129
130
  scheduleSave: () => void;
130
131
  enqueueFromReply: BncrEnqueueFromReply;
@@ -143,6 +144,7 @@ export async function runBncrInboundReplyDispatch(args: {
143
144
  replyRouteFact,
144
145
  senderIdForContext,
145
146
  shouldDispatch,
147
+ silentHistoryFlush = false,
146
148
  setInboundActivity,
147
149
  scheduleSave,
148
150
  enqueueFromReply,
@@ -230,15 +232,16 @@ export async function runBncrInboundReplyDispatch(args: {
230
232
  textForCommands: ctxPayload.CommandBody,
231
233
  raw: parsed,
232
234
  }),
233
- preflight: () =>
234
- shouldDispatch
235
+ preflight: () => {
236
+ return shouldDispatch
235
237
  ? undefined
236
238
  : {
237
239
  admission: {
238
240
  kind: 'observeOnly' as const,
239
241
  reason: 'bncr-group-mode-no-reply',
240
242
  },
241
- },
243
+ };
244
+ },
242
245
  resolveTurn: () => {
243
246
  const sessionMetaBarrier = createBncrSessionMetaTaskBarrier();
244
247
  return {
@@ -291,6 +294,10 @@ export async function runBncrInboundReplyDispatch(args: {
291
294
  return;
292
295
  }
293
296
 
297
+ if (silentHistoryFlush) {
298
+ return;
299
+ }
300
+
294
301
  await enqueueFromReply({
295
302
  accountId: replyRouteFact.accountId,
296
303
  sessionKey: replyRouteFact.sessionKey,
@@ -14,12 +14,19 @@ export type BncrSceneAdminCommand =
14
14
  | { kind: 'mode-help' }
15
15
  | { kind: 'mode-get'; sceneKey?: string }
16
16
  | { kind: 'mode'; sceneKey: string; mode: BncrGroupReplyMode }
17
- | { kind: 'list'; scope: 'pending' | 'scenes' };
17
+ | { kind: 'list'; scope: 'pending' | 'scenes' }
18
+ | { kind: 'history-limit-get'; sceneKey?: string }
19
+ | { kind: 'history-limit-set'; sceneKey: string; limit: number }
20
+ | { kind: 'history-force-get'; sceneKey?: string }
21
+ | { kind: 'history-force-set'; sceneKey: string; enabled: boolean }
22
+ | { kind: 'history-help' };
18
23
 
19
24
  const GROUP_REPLY_MODES = new Set<BncrGroupReplyMode>(['admin', 'mention', 'hybrid', 'all']);
20
25
 
21
26
  const MODE_HELP_TEXT = [
22
- '💬 Group reply modes',
27
+ '💬 Bncr Group Reply Mode Configuration',
28
+ '',
29
+ 'Modes:',
23
30
  ' • default: admin',
24
31
  ' • admin: 仅管理员|消息上送并逐条回复',
25
32
  ' • mention: 全员|消息上送 仅指定消息触发回复',
@@ -32,9 +39,18 @@ const MODE_HELP_TEXT = [
32
39
  ' • platform-marked should-respond messages',
33
40
  '',
34
41
  'Usage:',
35
- ' • /bncr mode',
36
- ' • /bncr mode help',
37
- ' • /bncr mode <admin|mention|hybrid|all> [<platform>:<groupId>]',
42
+ ' • /bncr mode <admin|mention|hybrid|all> [<SceneId>]',
43
+ ].join('\n');
44
+
45
+ export const HISTORY_HELP_TEXT = [
46
+ '📋 Bncr Group History Configuration',
47
+ '',
48
+ 'Commands:',
49
+ ' • /bncr history-limit <number> [<SceneId>]',
50
+ ' Set history limit (default: 50)',
51
+ ' • /bncr history-force on|off [<SceneId>]',
52
+ ' When on, overflow triggers auto context at limit (default: on)',
53
+ ' When off, oldest messages trim silently without context',
38
54
  ].join('\n');
39
55
 
40
56
  export type ParsedSceneAdminCommand =
@@ -141,6 +157,65 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
141
157
  return { matched: true, valid: true, command: { kind: 'list', scope: 'scenes' } };
142
158
  }
143
159
  return { matched: true, valid: false, text: 'Usage: /bncr list <pending|scenes>' };
160
+ case 'history-limit':
161
+ if (args.length === 0) {
162
+ return { matched: true, valid: true, command: { kind: 'history-limit-get' } };
163
+ }
164
+ if (args.length === 1) {
165
+ const num = parseInt(args[0], 10);
166
+ if (Number.isFinite(num)) {
167
+ return {
168
+ matched: true,
169
+ valid: true,
170
+ command: { kind: 'history-limit-set', sceneKey: '', limit: num },
171
+ };
172
+ }
173
+ return {
174
+ matched: true,
175
+ valid: true,
176
+ command: { kind: 'history-limit-get', sceneKey: args[0] },
177
+ };
178
+ }
179
+ if (args.length === 2) {
180
+ const num = parseInt(args[0], 10);
181
+ if (Number.isFinite(num)) {
182
+ return {
183
+ matched: true,
184
+ valid: true,
185
+ command: { kind: 'history-limit-set', sceneKey: args[1], limit: num },
186
+ };
187
+ }
188
+ }
189
+ return {
190
+ matched: true,
191
+ valid: false,
192
+ text: 'Usage: /bncr history-limit [<number>] [<sceneKey>]',
193
+ };
194
+ case 'history-force':
195
+ if (args.length === 0) {
196
+ return { matched: true, valid: true, command: { kind: 'history-force-get' } };
197
+ }
198
+ if (args[0] === 'on' || args[0] === 'off') {
199
+ if (args.length === 1) {
200
+ return {
201
+ matched: true,
202
+ valid: true,
203
+ command: { kind: 'history-force-set', sceneKey: '', enabled: args[0] === 'on' },
204
+ };
205
+ }
206
+ return {
207
+ matched: true,
208
+ valid: true,
209
+ command: { kind: 'history-force-set', sceneKey: args[1], enabled: args[0] === 'on' },
210
+ };
211
+ }
212
+ return {
213
+ matched: true,
214
+ valid: true,
215
+ command: { kind: 'history-force-get', sceneKey: args[0] },
216
+ };
217
+ case 'history-help':
218
+ return { matched: true, valid: true, command: { kind: 'history-help' } };
144
219
  default:
145
220
  return { matched: false };
146
221
  }
@@ -151,7 +226,15 @@ function formatSceneDetailsLine(scene: BncrSceneRecord): string {
151
226
  scene.kind === 'group' ? scene.groupId || scene.sceneKey : scene.userId || scene.sceneKey;
152
227
  const namePart = scene.kind === 'group' ? scene.groupName || '' : scene.userName || '';
153
228
  const labelPart = namePart ? ` name=${namePart}` : '';
154
- return ` Details: status=${scene.status} id=${idPart}${labelPart}`;
229
+ const historyLimitVal = scene.historyLimit;
230
+ const historyForceVal = scene.historyForce;
231
+ const hasNonDefaultHistory =
232
+ scene.kind === 'group' &&
233
+ ((typeof historyLimitVal === 'number' && historyLimitVal !== 50) || historyForceVal === false);
234
+ const historyPart = hasNonDefaultHistory
235
+ ? ` historyLimit=${historyLimitVal ?? 50} autoFlush=${historyForceVal !== false ? 'on' : 'off'}`
236
+ : '';
237
+ return ` Details: status=${scene.status} id=${idPart}${labelPart}${historyPart}`;
155
238
  }
156
239
 
157
240
  function formatSceneEntry(scene: BncrSceneRecord): string {
@@ -209,6 +292,10 @@ export function executeSceneAdminCommand(args: {
209
292
  return { ok: false, text: 'Admin permission required.' };
210
293
  }
211
294
 
295
+ if (command.kind === 'history-help') {
296
+ return { ok: true, text: HISTORY_HELP_TEXT };
297
+ }
298
+
212
299
  if (command.kind === 'list') {
213
300
  const scenes = Array.from(sceneRegistry.values())
214
301
  .filter((scene) => (command.scope === 'pending' ? scene.status === 'pending' : true))
@@ -244,6 +331,78 @@ export function executeSceneAdminCommand(args: {
244
331
  };
245
332
  }
246
333
 
334
+ if (command.kind === 'history-limit-get') {
335
+ const qSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
336
+ if (!qSceneKey) {
337
+ return { ok: true, text: 'Default history limit is 50.' };
338
+ }
339
+ const s = sceneRegistry.get(qSceneKey);
340
+ if (!s) return { ok: true, text: 'Default history limit is 50.' };
341
+ return { ok: true, text: `Current ${qSceneKey} history limit is ${s.historyLimit ?? 50}.` };
342
+ }
343
+
344
+ if (command.kind === 'history-limit-set') {
345
+ const sSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
346
+ if (!sSceneKey) {
347
+ return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
348
+ }
349
+ const sExisting = sceneRegistry.get(sSceneKey);
350
+ if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
351
+ const rawLimit = command.limit;
352
+ if (!Number.isFinite(rawLimit) || Number.isNaN(rawLimit)) {
353
+ return { ok: false, text: 'Invalid number.' };
354
+ }
355
+ let resolvedLimit = rawLimit;
356
+ if (resolvedLimit >= 51) {
357
+ resolvedLimit = Math.min(Math.floor(resolvedLimit), 10000);
358
+ } else if (resolvedLimit < 0 && Math.abs(resolvedLimit) >= 3) {
359
+ resolvedLimit = Math.floor(Math.abs(resolvedLimit));
360
+ } else {
361
+ return {
362
+ ok: false,
363
+ text: `Value too small, must be >= 51, or use negative number (abs >= 3) for hidden override.`,
364
+ };
365
+ }
366
+ sceneRegistry.set(sSceneKey, {
367
+ ...sExisting,
368
+ historyLimit: resolvedLimit,
369
+ lastSeenAt: now(),
370
+ });
371
+ return { ok: true, text: `Set ${sSceneKey} history limit to ${resolvedLimit}.` };
372
+ }
373
+
374
+ if (command.kind === 'history-force-get') {
375
+ const qSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
376
+ if (!qSceneKey) {
377
+ return { ok: true, text: 'Default history auto flush is on.' };
378
+ }
379
+ const s = sceneRegistry.get(qSceneKey);
380
+ if (!s) return { ok: true, text: 'Default history auto flush is on.' };
381
+ const enabled = s.historyForce !== false;
382
+ return {
383
+ ok: true,
384
+ text: `Current ${qSceneKey} history auto flush is ${enabled ? 'on' : 'off'}.`,
385
+ };
386
+ }
387
+
388
+ if (command.kind === 'history-force-set') {
389
+ const sSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
390
+ if (!sSceneKey) {
391
+ return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
392
+ }
393
+ const sExisting = sceneRegistry.get(sSceneKey);
394
+ if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
395
+ sceneRegistry.set(sSceneKey, {
396
+ ...sExisting,
397
+ historyForce: command.enabled,
398
+ lastSeenAt: now(),
399
+ });
400
+ return {
401
+ ok: true,
402
+ text: `Set ${sSceneKey} history auto flush to ${command.enabled ? 'on' : 'off'}.`,
403
+ };
404
+ }
405
+
247
406
  const sceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
248
407
  if (!sceneKey) {
249
408
  return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
@@ -1,4 +1,5 @@
1
1
  import { emitBncrLogLine } from '../../core/logging.ts';
2
+ import { formatHumanDisplayScope, parseRouteFromDisplayScope } from '../../core/targets.ts';
2
3
  import type { OpenClawChannelRuntimeContext } from '../../openclaw/channel-runtime-contracts.ts';
3
4
  import {
4
5
  recordBncrSessionMetaFromInbound,
@@ -7,12 +8,8 @@ import {
7
8
 
8
9
  function formatBncrSessionLabel(displayTo: string): string {
9
10
  const raw = String(displayTo || '').trim();
10
- const parts = raw.split(':');
11
- if (parts.length === 4 && parts[0] === 'Bncr') {
12
- const [, platform, groupId, userId] = parts;
13
- if (platform && groupId && groupId !== '0') return `Bncr:${platform}:Group:${groupId}`;
14
- if (platform && userId && userId !== '0') return `Bncr:${platform}:User:${userId}`;
15
- }
11
+ const route = parseRouteFromDisplayScope(raw);
12
+ if (route) return formatHumanDisplayScope(route);
16
13
  return raw;
17
14
  }
18
15
 
@@ -1,5 +1,8 @@
1
1
  import { readBncrSessionUpdatedAt } from '../../openclaw/inbound-session-runtime.ts';
2
- import { resolveOpenClawEnvelopeFormatOptions } from '../../openclaw/reply-runtime.ts';
2
+ import {
3
+ formatOpenClawAgentEnvelope,
4
+ resolveOpenClawEnvelopeFormatOptions,
5
+ } from '../../openclaw/reply-runtime.ts';
3
6
  import {
4
7
  buildBncrPromptVisibleContextFacts,
5
8
  buildBncrStructuredContextFactsFromInboundParts,
@@ -12,9 +15,9 @@ import type {
12
15
  } from './dispatch-prep.ts';
13
16
  import {
14
17
  type BncrGroupHistoryMap,
15
- buildBncrPendingGroupContext,
16
- buildBncrPendingHistoryWindowContext,
17
- collectBncrPendingHistoryMedia,
18
+ type BncrHistoryEntry,
19
+ buildBncrGroupContextFromEntries,
20
+ collectBncrHistoryMediaFromEntries,
18
21
  } from './group-history.ts';
19
22
  import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
20
23
 
@@ -28,13 +31,15 @@ function applyBncrLegacyCommandFields(args: {
28
31
  ctx: BncrInboundContextPayload;
29
32
  channelId: string;
30
33
  senderIdForContext: string;
34
+ fromOverride?: string;
31
35
  resolution: BncrInboundConversationResolution;
32
36
  rawBody: string;
33
37
  ownerAllowFrom?: string[];
34
38
  isAuthorizedTextCommand: boolean;
35
39
  }) {
36
- const { ctx, channelId, senderIdForContext, resolution, rawBody, ownerAllowFrom } = args;
37
- ctx.From = senderIdForContext;
40
+ const { ctx, channelId, senderIdForContext, fromOverride, resolution, rawBody, ownerAllowFrom } =
41
+ args;
42
+ ctx.From = fromOverride || senderIdForContext;
38
43
  ctx.To = resolution.canonicalTo;
39
44
  ctx.SenderId = senderIdForContext;
40
45
  ctx.OriginatingChannel = channelId;
@@ -69,6 +74,9 @@ export function buildBncrInboundTurnContext(args: {
69
74
  prepared: BncrPreparedInboundSessionContext;
70
75
  groupHistories: BncrGroupHistoryMap;
71
76
  shouldDispatch: boolean;
77
+ historyLimit?: number;
78
+ silentHistoryFlush?: boolean;
79
+ pendingHistoryEntries?: readonly BncrHistoryEntry[];
72
80
  }): BncrInboundContextPayload | Promise<BncrInboundContextPayload> {
73
81
  const {
74
82
  api,
@@ -84,31 +92,50 @@ export function buildBncrInboundTurnContext(args: {
84
92
  bridgeSenderName,
85
93
  resolution,
86
94
  prepared,
87
- groupHistories,
88
95
  shouldDispatch,
89
96
  } = args;
97
+ const pendingHistoryEntries = Array.isArray(args.pendingHistoryEntries)
98
+ ? args.pendingHistoryEntries
99
+ : [];
100
+ const silentHistoryFlush = args.silentHistoryFlush === true;
101
+ const flushSenderId = 'bncr-history-system';
102
+ const flushSenderName = 'Bncr History System';
103
+ const flushRawBody =
104
+ 'This message was automatically generated by the system for group context accumulation. Do not reply to the user. Output exactly NO_REPLY.';
105
+ const effectiveSenderId = silentHistoryFlush ? flushSenderId : senderIdForContext;
106
+ const effectiveSenderDisplayName = silentHistoryFlush ? flushSenderName : senderDisplayName;
107
+ const effectiveRawBody = silentHistoryFlush ? flushRawBody : prepared.rawBody;
108
+ const envelopeOptions = resolveOpenClawEnvelopeFormatOptions(api, cfg);
109
+ const effectiveBody = silentHistoryFlush
110
+ ? formatOpenClawAgentEnvelope(api, {
111
+ channel: 'Bncr',
112
+ from: resolution.canonicalTo,
113
+ timestamp: Date.now(),
114
+ previousTimestamp: readBncrSessionUpdatedAt(api, {
115
+ storePath: prepared.storePath,
116
+ sessionKey: resolution.dispatchSessionKey,
117
+ }),
118
+ envelope: envelopeOptions,
119
+ body: effectiveRawBody,
120
+ })
121
+ : prepared.body;
90
122
  const senderIsOwner = parsed.isAdmin === true || Boolean(ownerAllowFrom?.length);
91
123
  const senderIsAuthorized = senderIsOwner;
92
124
  const inboundHistory = undefined;
93
- const pendingHistoryMedia =
94
- shouldDispatch && parsed.peer.kind === 'group'
95
- ? collectBncrPendingHistoryMedia({ historyMap: groupHistories, parsed })
96
- : [];
97
- const pendingHistoryWindowContext =
98
- shouldDispatch && parsed.peer.kind === 'group'
99
- ? buildBncrPendingHistoryWindowContext({ historyMap: groupHistories, parsed, channelId })
100
- : undefined;
125
+ const pendingHistoryMedia = shouldDispatch
126
+ ? collectBncrHistoryMediaFromEntries({ entries: pendingHistoryEntries })
127
+ : [];
101
128
  const structuredContextFacts = buildBncrStructuredContextFactsFromInboundParts({
102
129
  channelId,
103
130
  parsed,
104
131
  resolution,
105
132
  prepared: {
106
- rawBody: prepared.rawBody,
107
- body: prepared.body,
108
- mediaItems: prepared.mediaItems,
133
+ rawBody: effectiveRawBody,
134
+ body: typeof effectiveBody === 'string' ? effectiveBody : prepared.body,
135
+ mediaItems: silentHistoryFlush ? [] : prepared.mediaItems,
109
136
  },
110
- senderIdForContext,
111
- senderDisplayName,
137
+ senderIdForContext: effectiveSenderId,
138
+ senderDisplayName: effectiveSenderDisplayName,
112
139
  bridgeSenderId,
113
140
  bridgeSenderName,
114
141
  senderIsOwner,
@@ -116,14 +143,16 @@ export function buildBncrInboundTurnContext(args: {
116
143
  });
117
144
  const promptVisibleContextFacts = buildBncrPromptVisibleContextFacts(structuredContextFacts);
118
145
  const supplementalUntrustedContext = [
119
- ...(pendingHistoryWindowContext ? [pendingHistoryWindowContext] : []),
120
146
  ...(Object.keys(promptVisibleContextFacts).length
121
147
  ? [
122
148
  {
123
149
  label: 'Bncr inbound context',
124
150
  source: channelId,
125
151
  type: 'bncr.inbound_context',
126
- payload: promptVisibleContextFacts,
152
+ payload: {
153
+ ...promptVisibleContextFacts,
154
+ ...(silentHistoryFlush ? { historyFlush: true } : {}),
155
+ },
127
156
  },
128
157
  ]
129
158
  : []),
@@ -132,22 +161,23 @@ export function buildBncrInboundTurnContext(args: {
132
161
  const wasMentioned = parsed.isBotMentioned === true || platformWantsReply;
133
162
  const isAuthorizedTextCommand = Boolean(ownerAllowFrom?.length);
134
163
  const bodyForAgent =
135
- shouldDispatch && parsed.peer.kind === 'group'
136
- ? buildBncrPendingGroupContext({
164
+ shouldDispatch && pendingHistoryEntries.length > 0
165
+ ? buildBncrGroupContextFromEntries({
137
166
  api,
138
- historyMap: groupHistories,
139
- parsed,
167
+ entries: pendingHistoryEntries,
140
168
  channelLabel: resolution.canonicalTo,
141
169
  currentTimestamp: Date.now(),
142
170
  previousTimestamp: readBncrSessionUpdatedAt(api, {
143
171
  storePath: prepared.storePath,
144
172
  sessionKey: resolution.dispatchSessionKey,
145
173
  }),
146
- envelope: resolveOpenClawEnvelopeFormatOptions(api, cfg),
147
- currentMessage: prepared.body,
174
+ envelope: envelopeOptions,
175
+ currentMessage: typeof effectiveBody === 'string' ? effectiveBody : prepared.body,
148
176
  })
149
- : prepared.body;
150
- const currentTurnMedia = prepared.mediaItems.map((item) => ({
177
+ : typeof effectiveBody === 'string'
178
+ ? effectiveBody
179
+ : prepared.body;
180
+ const currentTurnMedia = (silentHistoryFlush ? [] : prepared.mediaItems).map((item) => ({
151
181
  path: item.path,
152
182
  contentType: item.contentType,
153
183
  kind: item.kind,
@@ -163,11 +193,13 @@ export function buildBncrInboundTurnContext(args: {
163
193
  accountId: resolution.accountId,
164
194
  messageId: msgId,
165
195
  timestamp: Date.now(),
166
- from: senderIdForContext,
196
+ from: effectiveSenderId,
167
197
  sender: {
168
- id: senderIdForContext,
169
- name: senderDisplayName,
170
- username: parsed.userName || senderDisplayName,
198
+ id: effectiveSenderId,
199
+ name: effectiveSenderDisplayName,
200
+ username: silentHistoryFlush
201
+ ? effectiveSenderDisplayName
202
+ : parsed.userName || effectiveSenderDisplayName,
171
203
  },
172
204
  conversation: {
173
205
  kind: resolution.chatType,
@@ -191,13 +223,13 @@ export function buildBncrInboundTurnContext(args: {
191
223
  },
192
224
  message: {
193
225
  inboundEventKind: 'user_request',
194
- body: prepared.body,
195
- rawBody: prepared.rawBody,
226
+ body: typeof effectiveBody === 'string' ? effectiveBody : prepared.body,
227
+ rawBody: effectiveRawBody,
196
228
  bodyForAgent,
197
229
  inboundHistory,
198
- commandBody: prepared.rawBody,
230
+ commandBody: effectiveRawBody,
199
231
  envelopeFrom: resolution.originatingTo,
200
- senderLabel: senderDisplayName,
232
+ senderLabel: effectiveSenderDisplayName,
201
233
  },
202
234
  ...(isAuthorizedTextCommand
203
235
  ? {
@@ -242,9 +274,10 @@ export function buildBncrInboundTurnContext(args: {
242
274
  applyBncrLegacyCommandFields({
243
275
  ctx,
244
276
  channelId,
245
- senderIdForContext,
277
+ senderIdForContext: effectiveSenderId,
278
+ fromOverride: effectiveSenderId,
246
279
  resolution,
247
- rawBody: prepared.rawBody,
280
+ rawBody: effectiveRawBody,
248
281
  ownerAllowFrom,
249
282
  isAuthorizedTextCommand,
250
283
  }),
@@ -61,11 +61,14 @@ export type BncrSceneRecord = {
61
61
  groupName?: string;
62
62
  agentId?: string;
63
63
  groupReplyMode?: BncrGroupReplyMode;
64
+ historyLimit?: number;
65
+ historyForce?: boolean;
64
66
  lastSeenAt: number;
65
67
  };
66
68
 
67
69
  export type BncrPersistedGroupHistoryEntry = {
68
70
  sender: string;
71
+ senderId?: string;
69
72
  body: string;
70
73
  timestamp?: number;
71
74
  messageId?: string;
@@ -85,7 +85,10 @@ function shouldAccumulateForScene(args: {
85
85
  if (isAdminOpenClawNativeCommand) return true;
86
86
 
87
87
  const mode = admission.scene.groupReplyMode || 'admin';
88
- if (mode === 'admin') return parsed.isAdmin === true;
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
89
92
  return true;
90
93
  }
91
94
 
@@ -1,6 +1,6 @@
1
1
  import type { ChatType } from 'openclaw/plugin-sdk/core';
2
2
  import { BNCR_DEFAULT_ACCOUNT_ID, normalizeAccountId } from '../core/accounts.ts';
3
- import { formatDisplayScope, parseExplicitTarget } from '../core/targets.ts';
3
+ import { formatHumanDisplayScope, parseExplicitTarget } from '../core/targets.ts';
4
4
  import type { BncrRoute } from '../core/types.ts';
5
5
  import { asSanitizedString } from '../core/value-sanitize.ts';
6
6
  import { resolveBncrOutboundSessionRoute } from '../messaging/outbound/session-route.ts';
@@ -11,12 +11,7 @@ import {
11
11
  import type { BncrChannelConfigRoot } from './channel-runtime-types.ts';
12
12
 
13
13
  function formatBncrHumanDisplay(route: BncrRoute): string {
14
- const platform = asSanitizedString(route?.platform).trim();
15
- const groupId = asSanitizedString(route?.groupId).trim();
16
- const userId = asSanitizedString(route?.userId).trim();
17
- if (platform && groupId && groupId !== '0') return `Bncr:${platform}:Group:${groupId}`;
18
- if (platform && userId && userId !== '0') return `Bncr:${platform}:User:${userId}`;
19
- return formatDisplayScope(route);
14
+ return formatHumanDisplayScope(route);
20
15
  }
21
16
 
22
17
  type BncrMessagingRuntimeBridge = {
@@ -56,6 +56,8 @@ export 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
61
  lastSeenAt,
60
62
  };
61
63
  }
@@ -88,6 +90,8 @@ export function decideSceneAdmission(args: {
88
90
  ...(kind === 'group' && !existing.groupReplyMode
89
91
  ? { groupReplyMode: defaultGroupReplyMode(kind) }
90
92
  : {}),
93
+ ...(kind === 'group' && existing.historyLimit === undefined ? { historyLimit: 50 } : {}),
94
+ ...(kind === 'group' && existing.historyForce === undefined ? { historyForce: true } : {}),
91
95
  lastSeenAt: now,
92
96
  } satisfies BncrSceneRecord;
93
97
 
@@ -20,6 +20,7 @@ import type {
20
20
  } from './channel-runtime-types.ts';
21
21
 
22
22
  const GROUP_REPLY_MODES = new Set<BncrGroupReplyMode>(['admin', 'mention', 'hybrid', 'all']);
23
+ const DEFAULT_PERSISTED_GROUP_HISTORY_LIMIT = 50;
23
24
 
24
25
  type BncrPersistedStateStoreInput = {
25
26
  outbox?: unknown;
@@ -76,6 +77,18 @@ export function createBncrStateStore(runtime: {
76
77
  getLastDriftSnapshot: () => BncrPersistedState['lastDriftSnapshot'];
77
78
  setLastDriftSnapshot: (value: BncrPersistedState['lastDriftSnapshot']) => void;
78
79
  }) {
80
+ function resolvePersistedGroupHistoryLimit(key: string): number {
81
+ const scene = runtime.sceneRegistry.get(key);
82
+ const sceneLimit = scene?.kind === 'group' ? scene.historyLimit : undefined;
83
+ if (typeof sceneLimit === 'number' && Number.isFinite(sceneLimit) && sceneLimit >= 0) {
84
+ return Math.max(
85
+ DEFAULT_PERSISTED_GROUP_HISTORY_LIMIT,
86
+ Math.ceil(Math.floor(sceneLimit) * 1.2),
87
+ );
88
+ }
89
+ return DEFAULT_PERSISTED_GROUP_HISTORY_LIMIT;
90
+ }
91
+
79
92
  function loadPersistedSceneRegistry(persisted: unknown): void {
80
93
  runtime.sceneRegistry.clear();
81
94
  const items = Array.isArray(persisted) ? (persisted as PersistedSceneRecordInput[]) : [];
@@ -120,6 +133,12 @@ export function createBncrStateStore(runtime: {
120
133
  .trim() as BncrGroupReplyMode,
121
134
  }
122
135
  : {}),
136
+ ...(typeof item.historyLimit === 'number' &&
137
+ Number.isFinite(item.historyLimit) &&
138
+ item.historyLimit >= 0
139
+ ? { historyLimit: Math.floor(item.historyLimit) }
140
+ : {}),
141
+ ...(typeof item.historyForce === 'boolean' ? { historyForce: item.historyForce } : {}),
123
142
  lastSeenAt,
124
143
  });
125
144
  }
@@ -169,6 +188,9 @@ export function createBncrStateStore(runtime: {
169
188
 
170
189
  const normalizedEntry: BncrPersistedGroupHistoryEntry = {
171
190
  sender,
191
+ ...(runtime.asString(entry?.senderId || '').trim()
192
+ ? { senderId: runtime.asString(entry?.senderId || '').trim() }
193
+ : {}),
172
194
  body,
173
195
  ...(timestamp > 0 ? { timestamp } : {}),
174
196
  ...(messageId ? { messageId } : {}),
@@ -176,14 +198,16 @@ export function createBncrStateStore(runtime: {
176
198
  };
177
199
  entries.push(normalizedEntry);
178
200
  }
179
- if (entries.length > 0) runtime.groupHistories.set(key, entries.slice(-50));
201
+ if (entries.length > 0) {
202
+ runtime.groupHistories.set(key, entries.slice(-resolvePersistedGroupHistoryLimit(key)));
203
+ }
180
204
  }
181
205
  }
182
206
 
183
207
  function dumpPersistedGroupHistories() {
184
208
  return Array.from(runtime.groupHistories.entries()).map(([key, entries]) => ({
185
209
  key,
186
- entries: entries.slice(-50),
210
+ entries: entries.slice(-resolvePersistedGroupHistoryLimit(key)),
187
211
  }));
188
212
  }
189
213
 
@@ -95,11 +95,11 @@ export function createBncrTargetRuntime(runtime: {
95
95
  if (!route) {
96
96
  runtime.logWarn(
97
97
  'target',
98
- `invalid raw=${raw} accountId=${acc} reason=unparseable-or-unknown canonicalTo=Bncr:<platform>:0:<userId>|Bncr:<platform>:<groupId>:0 acceptedAliases=Bncr:<platform>:user:<userId>|Bncr:<platform>:group:<groupId> standardSessionKey=agent:<agentId>:bncr:<direct|group>:<hex(scope)>`,
98
+ `invalid raw=${raw} accountId=${acc} reason=unparseable-or-unknown canonicalTo=Bncr:<platform>:0:<userId>|Bncr:<platform>:<groupId>:0 acceptedAliases=Bncr:<platform>:User:<userId>|Bncr:<platform>:Group:<groupId> standardSessionKey=agent:<agentId>:bncr:<direct|group>:<hex(scope)>`,
99
99
  { debugOnly: true },
100
100
  );
101
101
  throw new Error(
102
- `bncr invalid target(canonical: Bncr:<platform>:0:<userId> | Bncr:<platform>:<groupId>:0; aliases: Bncr:<platform>:user:<userId> | Bncr:<platform>:group:<groupId>): ${raw}`,
102
+ `bncr invalid target(canonical: Bncr:<platform>:0:<userId> | Bncr:<platform>:<groupId>:0; aliases: Bncr:<platform>:User:<userId> | Bncr:<platform>:Group:<groupId>): ${raw}`,
103
103
  );
104
104
  }
105
105