@xmoxmo/bncr 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { DEFAULT_GROUP_HISTORY_LIMIT } from 'openclaw/plugin-sdk/reply-history';
1
+ import { DEFAULT_GROUP_HISTORY_LIMIT as DEFAULT_HISTORY_LIMIT } from 'openclaw/plugin-sdk/reply-history';
2
2
  import { emitBncrLogLine } from '../../core/logging.ts';
3
3
  import type { BncrSceneRecord } from '../../plugin/channel-runtime-types.ts';
4
4
  import { buildSceneKey } from '../../plugin/scene-registry.ts';
@@ -10,6 +10,15 @@ import type {
10
10
  BncrInboundLogger,
11
11
  BncrRememberSessionRoute,
12
12
  } from './contracts.ts';
13
+ import {
14
+ type BncrConversationHistoryMap,
15
+ type BncrHistoryEntry,
16
+ buildBncrConversationHistoryKey,
17
+ clearBncrPendingConversationHistory,
18
+ readBncrPendingConversationHistorySnapshot,
19
+ recordBncrPendingConversationMedia,
20
+ recordBncrPendingConversationText,
21
+ } from './conversation-history.ts';
13
22
  import {
14
23
  assertInboundMediaBase64Size,
15
24
  decodeInboundMediaBase64,
@@ -18,18 +27,8 @@ import {
18
27
  prepareBncrInboundDispatch,
19
28
  resolveBncrInboundConversation,
20
29
  } from './dispatch-prep.ts';
21
- import {
22
- type BncrGroupHistoryMap,
23
- type BncrHistoryEntry,
24
- buildBncrGroupHistoryKey,
25
- clearBncrPendingGroupHistory,
26
- readBncrPendingGroupHistorySnapshot,
27
- recordBncrPendingGroupMedia,
28
- recordBncrPendingGroupText,
29
- } from './group-history.ts';
30
30
  import {
31
31
  type BncrOutboundReplayCache,
32
- type BncrOutboundReplayEntry,
33
32
  clearBncrOutboundReplay,
34
33
  readBncrOutboundReplaySnapshot,
35
34
  } from './outbound-replay-cache.ts';
@@ -53,7 +52,7 @@ export async function dispatchBncrInbound(params: {
53
52
  shouldAccumulate?: boolean;
54
53
  resolvedAgentId?: string;
55
54
  sceneRegistry: Map<string, BncrSceneRecord>;
56
- groupHistories: BncrGroupHistoryMap;
55
+ conversationHistories: BncrConversationHistoryMap;
57
56
  outboundReplayCache?: BncrOutboundReplayCache;
58
57
  defaultAdminAgentId: string;
59
58
  defaultPublicAgentId: string;
@@ -74,7 +73,7 @@ export async function dispatchBncrInbound(params: {
74
73
  shouldAccumulate = initialShouldDispatch,
75
74
  resolvedAgentId,
76
75
  sceneRegistry = new Map(),
77
- groupHistories = new Map(),
76
+ conversationHistories = new Map(),
78
77
  outboundReplayCache = new Map(),
79
78
  defaultAdminAgentId,
80
79
  defaultPublicAgentId,
@@ -145,31 +144,29 @@ export async function dispatchBncrInbound(params: {
145
144
  );
146
145
  }
147
146
  const sceneHistoryLimit =
148
- parsed.peer.kind === 'group'
149
- ? (sceneRegistry.get(buildSceneKey(parsed))?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT)
150
- : DEFAULT_GROUP_HISTORY_LIMIT;
147
+ sceneRegistry.get(buildSceneKey(parsed))?.historyLimit ?? DEFAULT_HISTORY_LIMIT;
151
148
  const resolvedHistoryLimit =
152
149
  typeof sceneHistoryLimit === 'number' &&
153
150
  Number.isFinite(sceneHistoryLimit) &&
154
151
  sceneHistoryLimit >= 0
155
152
  ? Math.floor(sceneHistoryLimit)
156
- : DEFAULT_GROUP_HISTORY_LIMIT;
153
+ : DEFAULT_HISTORY_LIMIT;
157
154
 
158
155
  let silentHistoryFlush = false;
159
156
  let pendingHistoryEntries: BncrHistoryEntry[] = [];
160
- if (!shouldDispatch && shouldAccumulate) {
161
- const historyKey = buildBncrGroupHistoryKey(parsed);
157
+ if (shouldAccumulate) {
158
+ const historyKey = buildBncrConversationHistoryKey(parsed);
162
159
  // Record the message first so overflow context includes it
163
- recordBncrPendingGroupText({
164
- historyMap: groupHistories,
160
+ recordBncrPendingConversationText({
161
+ historyMap: conversationHistories,
165
162
  parsed,
166
163
  senderDisplayName,
167
164
  senderId: senderIdForContext,
168
165
  bodyText: rawBody,
169
166
  historyLimit: resolvedHistoryLimit,
170
167
  });
171
- await recordBncrPendingGroupMedia({
172
- historyMap: groupHistories,
168
+ await recordBncrPendingConversationMedia({
169
+ historyMap: conversationHistories,
173
170
  parsed,
174
171
  senderDisplayName,
175
172
  senderId: senderIdForContext,
@@ -178,48 +175,35 @@ export async function dispatchBncrInbound(params: {
178
175
  mediaContentType: mediaContentType || mimeType,
179
176
  historyLimit: resolvedHistoryLimit,
180
177
  });
181
- if (historyKey && parsed.peer.kind === 'group') {
182
- const entries = groupHistories.get(historyKey);
178
+ if (historyKey) {
179
+ const entries = conversationHistories.get(historyKey);
183
180
  if (entries && resolvedHistoryLimit > 0 && entries.length >= resolvedHistoryLimit) {
184
181
  const sceneForHistory = sceneRegistry.get(buildSceneKey(parsed));
185
182
  const historyForce = sceneForHistory?.historyForce !== false;
186
183
  if (historyForce) {
187
- pendingHistoryEntries = readBncrPendingGroupHistorySnapshot({
188
- historyMap: groupHistories,
189
- parsed,
190
- historyLimit: resolvedHistoryLimit,
191
- });
192
- clearBncrPendingGroupHistory({
193
- historyMap: groupHistories,
194
- parsed,
195
- historyLimit: resolvedHistoryLimit,
196
- });
197
- shouldDispatch = true;
198
184
  silentHistoryFlush = true;
185
+ shouldDispatch = true;
199
186
  }
200
187
  }
201
188
  }
202
189
  }
203
190
 
204
- if (shouldDispatch && !silentHistoryFlush) {
205
- pendingHistoryEntries = readBncrPendingGroupHistorySnapshot({
206
- historyMap: groupHistories,
191
+ if (shouldDispatch) {
192
+ pendingHistoryEntries = readBncrPendingConversationHistorySnapshot({
193
+ historyMap: conversationHistories,
207
194
  parsed,
208
195
  historyLimit: resolvedHistoryLimit,
209
196
  });
210
197
  if (pendingHistoryEntries.length > 0) {
211
- clearBncrPendingGroupHistory({
212
- historyMap: groupHistories,
198
+ clearBncrPendingConversationHistory({
199
+ historyMap: conversationHistories,
213
200
  parsed,
214
201
  historyLimit: resolvedHistoryLimit,
215
202
  });
216
203
  }
217
- }
218
-
219
- let outboundReplayEntries: BncrOutboundReplayEntry[] = [];
220
- if (shouldDispatch && !silentHistoryFlush) {
221
- outboundReplayEntries = readBncrOutboundReplaySnapshot({
204
+ const legacyOutboundEntries = readBncrOutboundReplaySnapshot({
222
205
  cache: outboundReplayCache,
206
+ conversationHistories,
223
207
  parsed,
224
208
  accountId,
225
209
  excludeMessageId: msgId,
@@ -229,6 +213,48 @@ export async function dispatchBncrInbound(params: {
229
213
  parsed,
230
214
  accountId,
231
215
  });
216
+ const knownMessageIds = new Set(
217
+ pendingHistoryEntries
218
+ .map((entry) => entry.messageId)
219
+ .filter((messageId): messageId is string => Boolean(messageId)),
220
+ );
221
+ for (const entry of legacyOutboundEntries) {
222
+ if (entry.messageId && knownMessageIds.has(entry.messageId)) continue;
223
+ if (entry.messageId) knownMessageIds.add(entry.messageId);
224
+ pendingHistoryEntries.push({
225
+ sender: entry.sender,
226
+ ...(entry.senderId ? { senderId: entry.senderId } : {}),
227
+ body: entry.body,
228
+ ...(typeof entry.timestamp === 'number' ? { timestamp: entry.timestamp } : {}),
229
+ ...(entry.messageId ? { messageId: entry.messageId } : {}),
230
+ role: 'assistant',
231
+ ...(Array.isArray(entry.media)
232
+ ? {
233
+ media: entry.media.map((item) => ({
234
+ path: item.path,
235
+ contentType: item.contentType,
236
+ kind: item.kind,
237
+ messageId: item.messageId,
238
+ })),
239
+ }
240
+ : {}),
241
+ });
242
+ }
243
+ pendingHistoryEntries.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
244
+ if (resolvedHistoryLimit > 0 && pendingHistoryEntries.length > resolvedHistoryLimit) {
245
+ const currentMessageId = String(msgId || '').trim();
246
+ const currentEntry = currentMessageId
247
+ ? pendingHistoryEntries.find((entry) => entry.messageId === currentMessageId)
248
+ : undefined;
249
+ pendingHistoryEntries = pendingHistoryEntries.slice(-resolvedHistoryLimit);
250
+ if (currentEntry && !pendingHistoryEntries.includes(currentEntry)) {
251
+ const withoutCurrent = pendingHistoryEntries.filter((entry) => entry !== currentEntry);
252
+ pendingHistoryEntries = [
253
+ ...withoutCurrent.slice(-(resolvedHistoryLimit - 1)),
254
+ currentEntry,
255
+ ];
256
+ }
257
+ }
232
258
  }
233
259
 
234
260
  const ctxPayload = await buildBncrInboundTurnContext({
@@ -246,11 +272,10 @@ export async function dispatchBncrInbound(params: {
246
272
  historyLimit: resolvedHistoryLimit,
247
273
  resolution,
248
274
  prepared,
249
- groupHistories,
275
+ conversationHistories,
250
276
  shouldDispatch,
251
277
  silentHistoryFlush,
252
278
  pendingHistoryEntries,
253
- outboundReplayEntries,
254
279
  });
255
280
 
256
281
  await runBncrInboundReplyDispatch({
@@ -55,7 +55,7 @@ const BNCR_HELP_TEXT = [
55
55
  ' • /bncr list pending [filters...]',
56
56
  ' • /bncr list scenes [filters...]',
57
57
  '',
58
- '📋 Group history',
58
+ '📋 Conversation history',
59
59
  ' • /bncr history-help',
60
60
  ' • /bncr history-limit [<number>|clear] [<SceneId>]',
61
61
  ' • /bncr history-force on|off|clear [<SceneId>]',
@@ -4,6 +4,12 @@ import type {
4
4
  BncrOutboundReplayStatus,
5
5
  OutboxEntry,
6
6
  } from '../../core/types.ts';
7
+ import {
8
+ type BncrConversationHistoryMap,
9
+ buildBncrConversationHistoryKey,
10
+ buildBncrConversationHistoryKeyFromRoute,
11
+ recordBncrBotReply,
12
+ } from './conversation-history.ts';
7
13
  import type { ParsedInbound } from './dispatch-prep.ts';
8
14
 
9
15
  export type {
@@ -152,6 +158,8 @@ function isAppMsg(type: string | undefined, msg: string): boolean {
152
158
 
153
159
  export function recordBncrOutboundReplay(args: {
154
160
  cache: BncrOutboundReplayCache;
161
+ conversationHistories?: BncrConversationHistoryMap;
162
+ historyLimit?: number;
155
163
  entry: OutboxEntry;
156
164
  sender: string;
157
165
  senderId?: string;
@@ -179,6 +187,38 @@ export function recordBncrOutboundReplay(args: {
179
187
  ? buildMediaBody('document')
180
188
  : normalizeTextBody(rawBody) || (isMedia ? buildMediaBody(kind) : '');
181
189
  if (!body) return;
190
+ const recordTimestamp = args.entry.lastPushAt ?? Date.now();
191
+
192
+ 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
+ if (historyKey) {
199
+ recordBncrBotReply({
200
+ historyMap: args.conversationHistories,
201
+ historyKey,
202
+ sender: args.sender,
203
+ senderId: args.senderId,
204
+ body,
205
+ timestamp: recordTimestamp,
206
+ messageId: args.entry.messageId,
207
+ historyLimit: args.historyLimit,
208
+ ...(mediaUrl
209
+ ? {
210
+ media: [
211
+ {
212
+ path: mediaUrl,
213
+ kind,
214
+ messageId: args.entry.messageId,
215
+ },
216
+ ],
217
+ }
218
+ : {}),
219
+ });
220
+ }
221
+ }
182
222
 
183
223
  recordOutboundReplayEntry({
184
224
  cache: args.cache,
@@ -187,7 +227,7 @@ export function recordBncrOutboundReplay(args: {
187
227
  sender: args.sender,
188
228
  ...(args.senderId ? { senderId: args.senderId } : {}),
189
229
  body,
190
- timestamp: Date.now(),
230
+ timestamp: recordTimestamp,
191
231
  messageId: args.entry.messageId,
192
232
  accountId: args.entry.accountId,
193
233
  sessionKey: args.entry.sessionKey,
@@ -213,16 +253,52 @@ export function recordBncrOutboundReplay(args: {
213
253
 
214
254
  export function readBncrOutboundReplaySnapshot(args: {
215
255
  cache: BncrOutboundReplayCache;
256
+ conversationHistories?: BncrConversationHistoryMap;
216
257
  parsed: ParsedInbound;
217
258
  accountId: string;
218
259
  excludeMessageId?: string | null;
219
260
  }): BncrOutboundReplayEntry[] {
220
- const cacheKey = buildBncrOutboundReplayKey(args.parsed, args.accountId);
221
- if (!cacheKey) return [];
222
261
  const excludedMessageId = String(args.excludeMessageId || '').trim();
223
- return (args.cache.get(cacheKey) || [])
224
- .filter((entry) => !excludedMessageId || entry.messageId !== excludedMessageId)
225
- .map(cloneBncrOutboundReplayEntry);
262
+ const cacheKey = buildBncrOutboundReplayKey(args.parsed, args.accountId);
263
+ const legacyEntries = cacheKey
264
+ ? (args.cache.get(cacheKey) || [])
265
+ .filter((entry) => !excludedMessageId || entry.messageId !== excludedMessageId)
266
+ .map(cloneBncrOutboundReplayEntry)
267
+ : [];
268
+ if (!args.conversationHistories) return legacyEntries;
269
+
270
+ const historyKey = buildBncrConversationHistoryKey(args.parsed);
271
+ const historyEntries = historyKey
272
+ ? (args.conversationHistories.get(historyKey) || [])
273
+ .filter((entry) => entry.role === 'assistant')
274
+ .filter((entry) => !excludedMessageId || entry.messageId !== excludedMessageId)
275
+ .map((entry) => ({
276
+ sender: entry.sender,
277
+ ...(entry.senderId ? { senderId: entry.senderId } : {}),
278
+ body: entry.body,
279
+ ...(typeof entry.timestamp === 'number' ? { timestamp: entry.timestamp } : {}),
280
+ ...(entry.messageId ? { messageId: entry.messageId } : {}),
281
+ ...(Array.isArray(entry.media)
282
+ ? {
283
+ media: entry.media.map((item) => ({
284
+ path: item.path,
285
+ contentType: item.contentType,
286
+ kind: item.kind,
287
+ messageId: item.messageId,
288
+ })),
289
+ }
290
+ : {}),
291
+ }))
292
+ : [];
293
+ const knownMessageIds = new Set(
294
+ historyEntries
295
+ .map((entry) => entry.messageId)
296
+ .filter((messageId): messageId is string => Boolean(messageId)),
297
+ );
298
+ return [
299
+ ...historyEntries,
300
+ ...legacyEntries.filter((entry) => !entry.messageId || !knownMessageIds.has(entry.messageId)),
301
+ ].sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
226
302
  }
227
303
 
228
304
  export function clearBncrOutboundReplay(args: {
@@ -49,7 +49,7 @@ const MODE_HELP_TEXT = [
49
49
  ].join('\n');
50
50
 
51
51
  export const HISTORY_HELP_TEXT = [
52
- '📋 Bncr Group History Configuration',
52
+ '📋 Bncr Conversation History Configuration',
53
53
  '',
54
54
  'Commands:',
55
55
  ' • /bncr history-limit <number> [<SceneId>]',
@@ -88,6 +88,22 @@ function resolveCurrentGroupSceneKey(parsed: ParsedInbound): string | null {
88
88
  return `${platform}:${groupId}`;
89
89
  }
90
90
 
91
+ function resolveCurrentSceneKey(parsed: ParsedInbound): string | null {
92
+ const platform = normalizeToken(parsed.platform);
93
+ if (!platform) return null;
94
+ if (parsed.peer.kind === 'group') {
95
+ const groupId = normalizeToken(parsed.groupId);
96
+ if (!groupId || groupId === '0') return null;
97
+ return `${platform}:${groupId}`;
98
+ }
99
+ if (parsed.peer.kind === 'direct') {
100
+ const userId = normalizeToken(parsed.userId);
101
+ if (!userId || userId === '0') return null;
102
+ return `${platform}:${userId}`;
103
+ }
104
+ return null;
105
+ }
106
+
91
107
  export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdminCommand {
92
108
  const args = splitArgs(command.argsText);
93
109
  switch (command.command) {
@@ -340,8 +356,7 @@ function formatSceneDetailsLine(scene: BncrSceneRecord): string {
340
356
  const historyLimitVal = scene.historyLimit;
341
357
  const historyForceVal = scene.historyForce;
342
358
  const hasNonDefaultHistory =
343
- scene.kind === 'group' &&
344
- ((typeof historyLimitVal === 'number' && historyLimitVal !== 50) || historyForceVal === false);
359
+ (typeof historyLimitVal === 'number' && historyLimitVal !== 50) || historyForceVal === false;
345
360
  const historyPart = hasNonDefaultHistory
346
361
  ? ` historyLimit=${historyLimitVal ?? 50} autoFlush=${historyForceVal !== false ? 'on' : 'off'}`
347
362
  : '';
@@ -475,7 +490,7 @@ export function executeSceneAdminCommand(args: {
475
490
  }
476
491
 
477
492
  if (command.kind === 'history-limit-get') {
478
- const qSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
493
+ const qSceneKey = command.sceneKey || resolveCurrentSceneKey(parsed);
479
494
  if (!qSceneKey) {
480
495
  return { ok: true, text: 'Default history limit is 50.' };
481
496
  }
@@ -485,9 +500,9 @@ export function executeSceneAdminCommand(args: {
485
500
  }
486
501
 
487
502
  if (command.kind === 'history-limit-set') {
488
- const sSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
503
+ const sSceneKey = command.sceneKey || resolveCurrentSceneKey(parsed);
489
504
  if (!sSceneKey) {
490
- return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
505
+ return { ok: false, text: 'Current conversation shortcut requires a valid chat.' };
491
506
  }
492
507
  const sExisting = sceneRegistry.get(sSceneKey);
493
508
  if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
@@ -520,7 +535,7 @@ export function executeSceneAdminCommand(args: {
520
535
  }
521
536
 
522
537
  if (command.kind === 'history-force-get') {
523
- const qSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
538
+ const qSceneKey = command.sceneKey || resolveCurrentSceneKey(parsed);
524
539
  if (!qSceneKey) {
525
540
  return { ok: true, text: 'Default history auto flush is on.' };
526
541
  }
@@ -534,9 +549,9 @@ export function executeSceneAdminCommand(args: {
534
549
  }
535
550
 
536
551
  if (command.kind === 'history-force-set') {
537
- const sSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
552
+ const sSceneKey = command.sceneKey || resolveCurrentSceneKey(parsed);
538
553
  if (!sSceneKey) {
539
- return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
554
+ return { ok: false, text: 'Current conversation shortcut requires a valid chat.' };
540
555
  }
541
556
  const sExisting = sceneRegistry.get(sSceneKey);
542
557
  if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
@@ -9,20 +9,88 @@ import {
9
9
  buildBncrStructuredContextFactsFromInboundParts,
10
10
  } from './context-facts.ts';
11
11
  import type { BncrInboundApi, BncrInboundConfig, BncrInboundContextPayload } from './contracts.ts';
12
+ import {
13
+ type BncrConversationHistoryMap,
14
+ type BncrHistoryEntry,
15
+ collectBncrHistoryMediaFromEntries,
16
+ } from './conversation-history.ts';
12
17
  import type {
13
18
  BncrInboundConversationResolution,
14
19
  BncrPreparedInboundSessionContext,
15
20
  ParsedInbound,
16
21
  } from './dispatch-prep.ts';
17
- import {
18
- type BncrGroupHistoryMap,
19
- type BncrHistoryEntry,
20
- buildBncrGroupContextFromEntries,
21
- collectBncrHistoryMediaFromEntries,
22
- } from './group-history.ts';
23
- import type { BncrOutboundReplayEntry } from './outbound-replay-cache.ts';
24
22
  import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
25
23
 
24
+ function dedupeBncrTurnMedia(entries: readonly HistoryMediaEntry[]): HistoryMediaEntry[] {
25
+ const seen = new Set<string>();
26
+ const result: HistoryMediaEntry[] = [];
27
+ for (const item of entries) {
28
+ const key = `${item.path ?? ''}:${item.messageId ?? ''}`;
29
+ if (key && seen.has(key)) continue;
30
+ seen.add(key);
31
+ result.push(item);
32
+ }
33
+ return result;
34
+ }
35
+
36
+ function buildBncrParticipantsMap(args: {
37
+ entries: readonly BncrHistoryEntry[];
38
+ parsed: ParsedInbound;
39
+ currentSenderId: string;
40
+ currentSenderName: string;
41
+ currentSenderUsername?: string;
42
+ currentSenderIsAdmin: boolean;
43
+ currentSenderIsOwner: boolean;
44
+ currentSenderIsAuthorized: boolean;
45
+ }): Record<
46
+ string,
47
+ {
48
+ name: string;
49
+ username?: string;
50
+ isBot?: boolean;
51
+ isAdmin?: boolean;
52
+ isOwner?: boolean;
53
+ isAuthorizedSender?: boolean;
54
+ role?: 'owner' | 'admin' | 'user';
55
+ displayName?: string;
56
+ }
57
+ > {
58
+ const map: Record<
59
+ string,
60
+ {
61
+ name: string;
62
+ username?: string;
63
+ isBot?: boolean;
64
+ isAdmin?: boolean;
65
+ isOwner?: boolean;
66
+ isAuthorizedSender?: boolean;
67
+ role?: 'owner' | 'admin' | 'user';
68
+ displayName?: string;
69
+ }
70
+ > = {};
71
+ map[args.currentSenderId] = {
72
+ name: args.currentSenderName,
73
+ ...(args.currentSenderUsername ? { username: args.currentSenderUsername } : {}),
74
+ isBot: false,
75
+ ...(args.currentSenderIsAdmin ? { isAdmin: true } : {}),
76
+ ...(args.currentSenderIsOwner ? { isOwner: true } : {}),
77
+ ...(args.currentSenderIsAuthorized ? { isAuthorizedSender: true } : {}),
78
+ role: args.currentSenderIsOwner ? 'owner' : args.currentSenderIsAdmin ? 'admin' : 'user',
79
+ displayName: args.currentSenderName,
80
+ };
81
+ for (const entry of args.entries) {
82
+ if (!entry.senderId || map[entry.senderId]) continue;
83
+ const isBot = entry.role === 'assistant';
84
+ map[entry.senderId] = {
85
+ name: entry.sender || entry.senderId,
86
+ ...(isBot ? { isBot: true } : {}),
87
+ role: isBot ? 'admin' : 'user',
88
+ displayName: entry.sender || entry.senderId,
89
+ };
90
+ }
91
+ return map;
92
+ }
93
+
26
94
  function parseSlashCommandName(body: string): string | undefined {
27
95
  const raw = String(body || '').trim();
28
96
  if (!raw.startsWith('/')) return undefined;
@@ -74,12 +142,11 @@ export function buildBncrInboundTurnContext(args: {
74
142
  bridgeSenderName?: string;
75
143
  resolution: BncrInboundConversationResolution;
76
144
  prepared: BncrPreparedInboundSessionContext;
77
- groupHistories: BncrGroupHistoryMap;
145
+ conversationHistories: BncrConversationHistoryMap;
78
146
  shouldDispatch: boolean;
79
147
  historyLimit?: number;
80
148
  silentHistoryFlush?: boolean;
81
149
  pendingHistoryEntries?: readonly BncrHistoryEntry[];
82
- outboundReplayEntries?: readonly BncrOutboundReplayEntry[];
83
150
  }): BncrInboundContextPayload | Promise<BncrInboundContextPayload> {
84
151
  const {
85
152
  api,
@@ -100,17 +167,12 @@ export function buildBncrInboundTurnContext(args: {
100
167
  const pendingHistoryEntries = Array.isArray(args.pendingHistoryEntries)
101
168
  ? args.pendingHistoryEntries
102
169
  : [];
103
- const outboundReplayEntries = Array.isArray(args.outboundReplayEntries)
104
- ? args.outboundReplayEntries
105
- : [];
106
170
  const silentHistoryFlush = args.silentHistoryFlush === true;
107
- const contextEntries = silentHistoryFlush
108
- ? pendingHistoryEntries
109
- : [...pendingHistoryEntries, ...outboundReplayEntries];
171
+ const contextEntries = pendingHistoryEntries;
110
172
  const flushSenderId = 'bncr-history-system';
111
173
  const flushSenderName = 'Bncr History System';
112
174
  const flushRawBody =
113
- 'This message was automatically generated by the system for group context accumulation. Do not reply to the user. Output exactly NO_REPLY.';
175
+ 'This message was automatically generated by the system for conversation context accumulation. Do not reply to the user. Output exactly NO_REPLY.';
114
176
  const effectiveSenderId = silentHistoryFlush ? flushSenderId : senderIdForContext;
115
177
  const effectiveSenderDisplayName = silentHistoryFlush ? flushSenderName : senderDisplayName;
116
178
  const effectiveRawBody = silentHistoryFlush ? flushRawBody : prepared.rawBody;
@@ -137,17 +199,32 @@ export function buildBncrInboundTurnContext(args: {
137
199
  const structuredContextEntries = shouldDispatch
138
200
  ? contextEntries.map((entry) => ({
139
201
  messageId: entry.messageId,
202
+ timestamp: entry.timestamp,
203
+ role: entry.role || 'user',
140
204
  sender: entry.sender,
141
205
  senderId: entry.senderId,
142
- body: entry.body,
206
+ content: entry.body,
143
207
  media: (entry.media || []).map((item: HistoryMediaEntry) => ({
208
+ type: item.kind || 'document',
144
209
  path: item.path,
145
210
  contentType: item.contentType,
146
- kind: item.kind,
147
211
  messageId: item.messageId,
148
212
  })),
149
213
  }))
150
214
  : [];
215
+ const isGroupChat = parsed.isGroup === true;
216
+ const participants = isGroupChat
217
+ ? buildBncrParticipantsMap({
218
+ entries: contextEntries,
219
+ parsed,
220
+ currentSenderId: effectiveSenderId,
221
+ currentSenderName: effectiveSenderDisplayName,
222
+ currentSenderUsername: parsed.userName,
223
+ currentSenderIsAdmin: parsed.isAdmin === true,
224
+ currentSenderIsOwner: senderIsOwner,
225
+ currentSenderIsAuthorized: senderIsAuthorized,
226
+ })
227
+ : null;
151
228
  const structuredContextFacts = buildBncrStructuredContextFactsFromInboundParts({
152
229
  channelId,
153
230
  parsed,
@@ -163,7 +240,10 @@ export function buildBncrInboundTurnContext(args: {
163
240
  bridgeSenderName,
164
241
  senderIsOwner,
165
242
  senderIsAuthorized,
166
- pendingHistoryContext: structuredContextEntries,
243
+ conversationHistoryContext: structuredContextEntries,
244
+ participants,
245
+ isGroupChat,
246
+ groupSubject: parsed.groupName,
167
247
  });
168
248
  const promptVisibleContextFacts = buildBncrPromptVisibleContextFacts(structuredContextFacts);
169
249
  const supplementalUntrustedContext = [
@@ -184,30 +264,14 @@ export function buildBncrInboundTurnContext(args: {
184
264
  const platformWantsReply = parsed.shouldRespond === true;
185
265
  const wasMentioned = parsed.isBotMentioned === true || platformWantsReply;
186
266
  const isAuthorizedTextCommand = Boolean(ownerAllowFrom?.length);
187
- const bodyForAgent =
188
- shouldDispatch && contextEntries.length > 0
189
- ? buildBncrGroupContextFromEntries({
190
- api,
191
- entries: contextEntries,
192
- channelLabel: resolution.canonicalTo,
193
- currentTimestamp: Date.now(),
194
- previousTimestamp: readBncrSessionUpdatedAt(api, {
195
- storePath: prepared.storePath,
196
- sessionKey: resolution.dispatchSessionKey,
197
- }),
198
- envelope: envelopeOptions,
199
- currentMessage: typeof effectiveBody === 'string' ? effectiveBody : prepared.body,
200
- })
201
- : typeof effectiveBody === 'string'
202
- ? effectiveBody
203
- : prepared.body;
267
+ const bodyForAgent = typeof effectiveBody === 'string' ? effectiveBody : prepared.body;
204
268
  const currentTurnMedia = (silentHistoryFlush ? [] : prepared.mediaItems).map((item) => ({
205
269
  path: item.path,
206
270
  contentType: item.contentType,
207
271
  kind: item.kind,
208
272
  messageId: msgId ?? undefined,
209
273
  }));
210
- const media = [...contextMedia, ...currentTurnMedia];
274
+ const media = dedupeBncrTurnMedia([...contextMedia, ...currentTurnMedia]);
211
275
 
212
276
  return Promise.resolve(
213
277
  resolveBncrChannelInboundRuntime(api).buildContext({
@@ -7,6 +7,7 @@ import {
7
7
  } from '../core/outbox-queue.ts';
8
8
  import { formatDisplayScope } from '../core/targets.ts';
9
9
  import type { BncrRecentOutboundEntry, OutboxEntry } from '../core/types.ts';
10
+ import type { BncrConversationHistoryMap } from '../messaging/inbound/conversation-history.ts';
10
11
  import {
11
12
  type BncrOutboundReplayCache,
12
13
  type BncrOutboundReplayEntry,
@@ -42,6 +43,8 @@ export function createBncrBridgeOutboxFacade(runtime: {
42
43
  deadLetterSinceStartByAccount: Map<string, number>;
43
44
  lastOutboundByAccount: Map<string, number>;
44
45
  outboundReplayCache?: BncrOutboundReplayCache;
46
+ conversationHistories?: BncrConversationHistoryMap;
47
+ resolveOutboundHistoryLimit?: (entry: OutboxEntry) => number;
45
48
  resolveOutboundSender?: (entry: OutboxEntry) => { sender: string; senderId?: string };
46
49
  isOutboundAckRequired?: (accountId: string) => boolean;
47
50
  scheduleSave: () => void;
@@ -64,6 +67,12 @@ export function createBncrBridgeOutboxFacade(runtime: {
64
67
  };
65
68
  recordBncrOutboundReplay({
66
69
  cache: runtime.outboundReplayCache,
70
+ ...(runtime.conversationHistories
71
+ ? { conversationHistories: runtime.conversationHistories }
72
+ : {}),
73
+ ...(runtime.resolveOutboundHistoryLimit
74
+ ? { historyLimit: runtime.resolveOutboundHistoryLimit(entry) }
75
+ : {}),
67
76
  entry,
68
77
  sender: senderInfo.sender,
69
78
  senderId: senderInfo.senderId,