@xmoxmo/bncr 0.6.0 → 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.
Files changed (35) hide show
  1. package/README.md +8 -0
  2. package/dist/index.js +2 -0
  3. package/index.ts +1 -0
  4. package/package.json +4 -1
  5. package/scripts/check-pack.mjs +41 -1
  6. package/scripts/selfcheck.mjs +4 -1
  7. package/src/bootstrap/register-runtime-gateway.ts +1 -0
  8. package/src/bootstrap/register-runtime.ts +1 -0
  9. package/src/channel.ts +66 -5
  10. package/src/core/types.ts +41 -0
  11. package/src/messaging/inbound/context-facts.ts +88 -58
  12. package/src/messaging/inbound/{group-history.ts → conversation-history.ts} +92 -25
  13. package/src/messaging/inbound/dispatch.ts +90 -47
  14. package/src/messaging/inbound/native-command.ts +1 -1
  15. package/src/messaging/inbound/outbound-replay-cache.ts +312 -0
  16. package/src/messaging/inbound/scene-admin.ts +24 -9
  17. package/src/messaging/inbound/turn-context.ts +105 -33
  18. package/src/plugin/ack-outbox-runtime-group.ts +2 -0
  19. package/src/plugin/bridge-call.ts +168 -0
  20. package/src/plugin/bridge-outbox-facade.ts +88 -1
  21. package/src/plugin/channel-components.ts +10 -5
  22. package/src/plugin/channel-plugin-bridge-group.ts +43 -1
  23. package/src/plugin/channel-plugin-surface-group.ts +82 -7
  24. package/src/plugin/channel-runtime-builders-delivery.ts +4 -2
  25. package/src/plugin/channel-runtime-types.ts +34 -8
  26. package/src/plugin/channel-send.ts +12 -19
  27. package/src/plugin/client-rpc-runtime.ts +129 -0
  28. package/src/plugin/gateway-methods.ts +1 -0
  29. package/src/plugin/inbound-acceptance.ts +9 -24
  30. package/src/plugin/inbound-handlers.ts +8 -4
  31. package/src/plugin/inbound-surface-handlers-group.ts +7 -2
  32. package/src/plugin/outbox-ack-outcome.ts +2 -0
  33. package/src/plugin/scene-registry.ts +4 -4
  34. package/src/plugin/state-store.ts +153 -29
  35. package/src/plugin/state-transient-runtime-group.ts +10 -3
@@ -0,0 +1,312 @@
1
+ import type {
2
+ BncrOutboundReplayEntry,
3
+ BncrOutboundReplayMediaEntry,
4
+ BncrOutboundReplayStatus,
5
+ OutboxEntry,
6
+ } from '../../core/types.ts';
7
+ import {
8
+ type BncrConversationHistoryMap,
9
+ buildBncrConversationHistoryKey,
10
+ buildBncrConversationHistoryKeyFromRoute,
11
+ recordBncrBotReply,
12
+ } from './conversation-history.ts';
13
+ import type { ParsedInbound } from './dispatch-prep.ts';
14
+
15
+ export type {
16
+ BncrOutboundReplayEntry,
17
+ BncrOutboundReplayMediaEntry,
18
+ BncrOutboundReplayStatus,
19
+ } from '../../core/types.ts';
20
+
21
+ export type BncrOutboundReplayCache = Map<string, BncrOutboundReplayEntry[]>;
22
+
23
+ function normalizeTextBody(value: string): string {
24
+ return String(value || '')
25
+ .replace(/\s+/g, ' ')
26
+ .trim();
27
+ }
28
+
29
+ function cloneMediaEntry(entry: BncrOutboundReplayMediaEntry): BncrOutboundReplayMediaEntry {
30
+ return {
31
+ ...(entry?.path ? { path: entry.path } : {}),
32
+ ...(entry?.contentType ? { contentType: entry.contentType } : {}),
33
+ ...(entry?.kind ? { kind: entry.kind } : {}),
34
+ ...(entry?.messageId ? { messageId: entry.messageId } : {}),
35
+ };
36
+ }
37
+
38
+ export function cloneBncrOutboundReplayEntry(
39
+ entry: BncrOutboundReplayEntry,
40
+ ): BncrOutboundReplayEntry {
41
+ return {
42
+ sender: entry.sender,
43
+ ...(entry.senderId ? { senderId: entry.senderId } : {}),
44
+ body: entry.body,
45
+ ...(typeof entry.timestamp === 'number' ? { timestamp: entry.timestamp } : {}),
46
+ ...(entry.messageId ? { messageId: entry.messageId } : {}),
47
+ ...(Array.isArray(entry.media)
48
+ ? { media: entry.media.map((item) => cloneMediaEntry(item)) }
49
+ : {}),
50
+ ...(entry.accountId ? { accountId: entry.accountId } : {}),
51
+ ...(entry.sessionKey ? { sessionKey: entry.sessionKey } : {}),
52
+ ...(entry.route ? { route: { ...entry.route } } : {}),
53
+ ...(entry.type ? { type: entry.type } : {}),
54
+ ...(entry.mediaUrl ? { mediaUrl: entry.mediaUrl } : {}),
55
+ ...(typeof entry.createdAt === 'number' ? { createdAt: entry.createdAt } : {}),
56
+ ...(entry.status ? { status: entry.status } : {}),
57
+ };
58
+ }
59
+
60
+ export function buildBncrOutboundReplayKeyFromRoute(args: {
61
+ accountId?: string;
62
+ platform?: string;
63
+ groupId?: string;
64
+ userId?: string;
65
+ }): string | null {
66
+ const accountId = String(args.accountId || '').trim() || 'default';
67
+ const platform = String(args.platform || '').trim();
68
+ const groupId = String(args.groupId || '0').trim() || '0';
69
+ const userId = String(args.userId || '0').trim() || '0';
70
+ if (!platform) return null;
71
+ if (groupId !== '0') return `${accountId}:${platform}:${groupId}`;
72
+ if (userId !== '0') return `${accountId}:${platform}:${userId}`;
73
+ return null;
74
+ }
75
+
76
+ export function buildBncrOutboundReplayKey(
77
+ parsed: ParsedInbound,
78
+ accountId: string,
79
+ ): string | null {
80
+ return buildBncrOutboundReplayKeyFromRoute({
81
+ accountId,
82
+ platform: parsed.platform,
83
+ groupId: parsed.groupId,
84
+ userId: parsed.userId,
85
+ });
86
+ }
87
+
88
+ function recordOutboundReplayEntry(args: {
89
+ cache: BncrOutboundReplayCache;
90
+ cacheKey: string;
91
+ entry: BncrOutboundReplayEntry;
92
+ }) {
93
+ const msgId = String(args.entry.messageId || '').trim();
94
+ const existing = args.cache.get(args.cacheKey) || [];
95
+ if (msgId && existing.some((entry) => entry.messageId === msgId)) return;
96
+
97
+ const next = [...existing, cloneBncrOutboundReplayEntry(args.entry)].sort(
98
+ (a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
99
+ );
100
+
101
+ if (args.cache.has(args.cacheKey)) {
102
+ args.cache.delete(args.cacheKey);
103
+ }
104
+ args.cache.set(args.cacheKey, next);
105
+ }
106
+
107
+ function inferMediaKind(args: {
108
+ msgType?: string;
109
+ contentType?: string;
110
+ mediaUrl?: string;
111
+ type?: string;
112
+ }): NonNullable<BncrOutboundReplayMediaEntry['kind']> {
113
+ const msgType = String(args.msgType || '')
114
+ .trim()
115
+ .toLowerCase();
116
+ const type = String(args.type || '')
117
+ .trim()
118
+ .toLowerCase();
119
+ const contentType = String(args.contentType || '')
120
+ .trim()
121
+ .toLowerCase();
122
+ const mediaUrl = String(args.mediaUrl || '')
123
+ .trim()
124
+ .toLowerCase();
125
+
126
+ if (msgType === 'image' || type === 'image' || contentType.startsWith('image/')) return 'image';
127
+ if (msgType === 'video' || type === 'video' || contentType.startsWith('video/')) return 'video';
128
+ if (
129
+ msgType === 'audio' ||
130
+ msgType === 'voice' ||
131
+ type === 'audio' ||
132
+ type === 'voice' ||
133
+ contentType.startsWith('audio/')
134
+ ) {
135
+ return 'audio';
136
+ }
137
+ if (type === 'file' || type === 'document' || msgType === 'file' || msgType === 'document') {
138
+ return 'document';
139
+ }
140
+ if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(mediaUrl)) return 'image';
141
+ if (/\.(mp4|mov|webm|mkv|avi)$/.test(mediaUrl)) return 'video';
142
+ if (/\.(mp3|ogg|oga|silk|amr|wav|m4a|aac|flac)$/.test(mediaUrl)) return 'audio';
143
+ if (contentType) return 'document';
144
+ return 'unknown';
145
+ }
146
+
147
+ function buildMediaBody(kind: NonNullable<BncrOutboundReplayMediaEntry['kind']>): string {
148
+ return `<media:${kind}>`;
149
+ }
150
+
151
+ function isAppMsg(type: string | undefined, msg: string): boolean {
152
+ return (
153
+ String(type || '')
154
+ .trim()
155
+ .toLowerCase() === 'appmsg' || /^<appmsg/i.test(msg)
156
+ );
157
+ }
158
+
159
+ export function recordBncrOutboundReplay(args: {
160
+ cache: BncrOutboundReplayCache;
161
+ conversationHistories?: BncrConversationHistoryMap;
162
+ historyLimit?: number;
163
+ entry: OutboxEntry;
164
+ sender: string;
165
+ senderId?: string;
166
+ status?: BncrOutboundReplayStatus;
167
+ }) {
168
+ const cacheKey = buildBncrOutboundReplayKeyFromRoute({
169
+ accountId: args.entry.accountId,
170
+ platform: args.entry.route?.platform,
171
+ groupId: args.entry.route?.groupId,
172
+ userId: args.entry.route?.userId,
173
+ });
174
+ if (!cacheKey) return;
175
+
176
+ const message =
177
+ args.entry.payload?.message && typeof args.entry.payload.message === 'object'
178
+ ? (args.entry.payload.message as Record<string, unknown>)
179
+ : {};
180
+ const type = typeof message.type === 'string' ? message.type : undefined;
181
+ const rawBody = typeof message.msg === 'string' ? message.msg : '';
182
+ const mediaUrl = typeof message.mediaUrl === 'string' ? message.mediaUrl.trim() : '';
183
+ const kind = inferMediaKind({ type, mediaUrl });
184
+ const isMedia =
185
+ message.transferMode === 'media' || Boolean(mediaUrl) || (!!type && type !== 'text');
186
+ const body = isAppMsg(type, rawBody)
187
+ ? buildMediaBody('document')
188
+ : normalizeTextBody(rawBody) || (isMedia ? buildMediaBody(kind) : '');
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
+ }
222
+
223
+ recordOutboundReplayEntry({
224
+ cache: args.cache,
225
+ cacheKey,
226
+ entry: {
227
+ sender: args.sender,
228
+ ...(args.senderId ? { senderId: args.senderId } : {}),
229
+ body,
230
+ timestamp: recordTimestamp,
231
+ messageId: args.entry.messageId,
232
+ accountId: args.entry.accountId,
233
+ sessionKey: args.entry.sessionKey,
234
+ route: { ...args.entry.route },
235
+ ...(type ? { type } : {}),
236
+ ...(mediaUrl ? { mediaUrl } : {}),
237
+ createdAt: args.entry.createdAt,
238
+ ...(args.status ? { status: args.status } : {}),
239
+ ...(mediaUrl
240
+ ? {
241
+ media: [
242
+ {
243
+ path: mediaUrl,
244
+ kind,
245
+ messageId: args.entry.messageId,
246
+ },
247
+ ],
248
+ }
249
+ : {}),
250
+ },
251
+ });
252
+ }
253
+
254
+ export function readBncrOutboundReplaySnapshot(args: {
255
+ cache: BncrOutboundReplayCache;
256
+ conversationHistories?: BncrConversationHistoryMap;
257
+ parsed: ParsedInbound;
258
+ accountId: string;
259
+ excludeMessageId?: string | null;
260
+ }): BncrOutboundReplayEntry[] {
261
+ const excludedMessageId = String(args.excludeMessageId || '').trim();
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));
302
+ }
303
+
304
+ export function clearBncrOutboundReplay(args: {
305
+ cache: BncrOutboundReplayCache;
306
+ parsed: ParsedInbound;
307
+ accountId: string;
308
+ }) {
309
+ const cacheKey = buildBncrOutboundReplayKey(args.parsed, args.accountId);
310
+ if (!cacheKey) return;
311
+ args.cache.delete(cacheKey);
312
+ }
@@ -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,19 +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
22
  import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
24
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
+
25
94
  function parseSlashCommandName(body: string): string | undefined {
26
95
  const raw = String(body || '').trim();
27
96
  if (!raw.startsWith('/')) return undefined;
@@ -73,7 +142,7 @@ export function buildBncrInboundTurnContext(args: {
73
142
  bridgeSenderName?: string;
74
143
  resolution: BncrInboundConversationResolution;
75
144
  prepared: BncrPreparedInboundSessionContext;
76
- groupHistories: BncrGroupHistoryMap;
145
+ conversationHistories: BncrConversationHistoryMap;
77
146
  shouldDispatch: boolean;
78
147
  historyLimit?: number;
79
148
  silentHistoryFlush?: boolean;
@@ -99,10 +168,11 @@ export function buildBncrInboundTurnContext(args: {
99
168
  ? args.pendingHistoryEntries
100
169
  : [];
101
170
  const silentHistoryFlush = args.silentHistoryFlush === true;
171
+ const contextEntries = pendingHistoryEntries;
102
172
  const flushSenderId = 'bncr-history-system';
103
173
  const flushSenderName = 'Bncr History System';
104
174
  const flushRawBody =
105
- '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.';
106
176
  const effectiveSenderId = silentHistoryFlush ? flushSenderId : senderIdForContext;
107
177
  const effectiveSenderDisplayName = silentHistoryFlush ? flushSenderName : senderDisplayName;
108
178
  const effectiveRawBody = silentHistoryFlush ? flushRawBody : prepared.rawBody;
@@ -123,23 +193,38 @@ export function buildBncrInboundTurnContext(args: {
123
193
  const senderIsOwner = parsed.isAdmin === true || Boolean(ownerAllowFrom?.length);
124
194
  const senderIsAuthorized = senderIsOwner;
125
195
  const inboundHistory = undefined;
126
- const pendingHistoryMedia = shouldDispatch
127
- ? collectBncrHistoryMediaFromEntries({ entries: pendingHistoryEntries })
196
+ const contextMedia = shouldDispatch
197
+ ? collectBncrHistoryMediaFromEntries({ entries: contextEntries })
128
198
  : [];
129
- const pendingHistoryContext = shouldDispatch
130
- ? pendingHistoryEntries.map((entry) => ({
199
+ const structuredContextEntries = shouldDispatch
200
+ ? contextEntries.map((entry) => ({
131
201
  messageId: entry.messageId,
202
+ timestamp: entry.timestamp,
203
+ role: entry.role || 'user',
132
204
  sender: entry.sender,
133
205
  senderId: entry.senderId,
134
- body: entry.body,
206
+ content: entry.body,
135
207
  media: (entry.media || []).map((item: HistoryMediaEntry) => ({
208
+ type: item.kind || 'document',
136
209
  path: item.path,
137
210
  contentType: item.contentType,
138
- kind: item.kind,
139
211
  messageId: item.messageId,
140
212
  })),
141
213
  }))
142
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;
143
228
  const structuredContextFacts = buildBncrStructuredContextFactsFromInboundParts({
144
229
  channelId,
145
230
  parsed,
@@ -155,7 +240,10 @@ export function buildBncrInboundTurnContext(args: {
155
240
  bridgeSenderName,
156
241
  senderIsOwner,
157
242
  senderIsAuthorized,
158
- pendingHistoryContext,
243
+ conversationHistoryContext: structuredContextEntries,
244
+ participants,
245
+ isGroupChat,
246
+ groupSubject: parsed.groupName,
159
247
  });
160
248
  const promptVisibleContextFacts = buildBncrPromptVisibleContextFacts(structuredContextFacts);
161
249
  const supplementalUntrustedContext = [
@@ -176,30 +264,14 @@ export function buildBncrInboundTurnContext(args: {
176
264
  const platformWantsReply = parsed.shouldRespond === true;
177
265
  const wasMentioned = parsed.isBotMentioned === true || platformWantsReply;
178
266
  const isAuthorizedTextCommand = Boolean(ownerAllowFrom?.length);
179
- const bodyForAgent =
180
- shouldDispatch && pendingHistoryEntries.length > 0
181
- ? buildBncrGroupContextFromEntries({
182
- api,
183
- entries: pendingHistoryEntries,
184
- channelLabel: resolution.canonicalTo,
185
- currentTimestamp: Date.now(),
186
- previousTimestamp: readBncrSessionUpdatedAt(api, {
187
- storePath: prepared.storePath,
188
- sessionKey: resolution.dispatchSessionKey,
189
- }),
190
- envelope: envelopeOptions,
191
- currentMessage: typeof effectiveBody === 'string' ? effectiveBody : prepared.body,
192
- })
193
- : typeof effectiveBody === 'string'
194
- ? effectiveBody
195
- : prepared.body;
267
+ const bodyForAgent = typeof effectiveBody === 'string' ? effectiveBody : prepared.body;
196
268
  const currentTurnMedia = (silentHistoryFlush ? [] : prepared.mediaItems).map((item) => ({
197
269
  path: item.path,
198
270
  contentType: item.contentType,
199
271
  kind: item.kind,
200
272
  messageId: msgId ?? undefined,
201
273
  }));
202
- const media = [...pendingHistoryMedia, ...currentTurnMedia];
274
+ const media = dedupeBncrTurnMedia([...contextMedia, ...currentTurnMedia]);
203
275
 
204
276
  return Promise.resolve(
205
277
  resolveBncrChannelInboundRuntime(api).buildContext({
@@ -105,6 +105,7 @@ export function createBncrAckOutboxRuntimeGroup(runtime: {
105
105
  setOutboxEntry: (messageId: string, entry: OutboxEntry) => void;
106
106
  resolveMessageAck: (messageId: string, result: 'acked' | 'timeout') => boolean;
107
107
  moveToDeadLetter: (entry: OutboxEntry, reason: string) => void;
108
+ markRecentOutboundAcked?: (entry: OutboxEntry) => void;
108
109
  recordAckTimeoutTelemetry: (accountId: string) => void;
109
110
  degradeOutboundCapability: (args: {
110
111
  accountId: string;
@@ -154,6 +155,7 @@ export function createBncrAckOutboxRuntimeGroup(runtime: {
154
155
  runtime.resolveMessageAck(messageId, result);
155
156
  },
156
157
  moveToDeadLetter: runtime.moveToDeadLetter,
158
+ markRecentOutboundAcked: runtime.markRecentOutboundAcked,
157
159
  logOutboxAckSummary: outboxAckLogs.logOutboxAckSummary,
158
160
  });
159
161