@xmoxmo/bncr 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +65 -1
  2. package/package.json +1 -1
  3. package/scripts/check-pack.mjs +4 -1
  4. package/scripts/selfcheck.mjs +4 -1
  5. package/src/channel.ts +144 -5
  6. package/src/messaging/inbound/commands.ts +62 -26
  7. package/src/messaging/inbound/context-facts.ts +88 -58
  8. package/src/messaging/inbound/conversation-history-serial.ts +258 -0
  9. package/src/messaging/inbound/conversation-history.ts +450 -0
  10. package/src/messaging/inbound/dispatch.ts +568 -123
  11. package/src/messaging/inbound/history-shard-worker.ts +180 -0
  12. package/src/messaging/inbound/native-command.ts +89 -33
  13. package/src/messaging/inbound/outbound-replay-cache.ts +202 -17
  14. package/src/messaging/inbound/reply-dispatch.ts +7 -6
  15. package/src/messaging/inbound/scene-admin.ts +40 -20
  16. package/src/messaging/inbound/turn-context.ts +101 -37
  17. package/src/plugin/bridge-lifecycle.ts +8 -0
  18. package/src/plugin/bridge-outbox-facade.ts +15 -1
  19. package/src/plugin/channel-components.ts +5 -5
  20. package/src/plugin/channel-runtime-builders-delivery.ts +2 -2
  21. package/src/plugin/channel-runtime-constants.ts +2 -0
  22. package/src/plugin/channel-runtime-types.ts +9 -7
  23. package/src/plugin/inbound-acceptance.ts +9 -24
  24. package/src/plugin/inbound-handlers.ts +4 -4
  25. package/src/plugin/inbound-surface-handlers-group.ts +4 -2
  26. package/src/plugin/outbox-drain-post-push.ts +3 -0
  27. package/src/plugin/scene-registry.ts +4 -4
  28. package/src/plugin/sqlite-state.ts +2109 -0
  29. package/src/plugin/state-store.ts +731 -69
  30. package/src/plugin/state-transient-runtime-group.ts +17 -3
  31. package/src/messaging/inbound/group-history.ts +0 -264
@@ -18,7 +18,7 @@ import type {
18
18
  ParsedInbound,
19
19
  } from './dispatch-prep.ts';
20
20
  import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
21
- import { parseBncrNativeCommand } from './native-command.ts';
21
+ import { isBncrStopCommandText, parseBncrNativeCommand } from './native-command.ts';
22
22
  import { buildBncrReplyConfig } from './reply-config.ts';
23
23
  import { runBncrReplyDispatchSerial } from './reply-dispatch-serial.ts';
24
24
  import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
@@ -126,6 +126,7 @@ export async function runBncrInboundReplyDispatch(args: {
126
126
  senderDisplayName: string;
127
127
  shouldDispatch: boolean;
128
128
  silentHistoryFlush?: boolean;
129
+ deliveryId?: string;
129
130
  setInboundActivity: (accountId: string, at: number) => void;
130
131
  scheduleSave: () => void;
131
132
  enqueueFromReply: BncrEnqueueFromReply;
@@ -145,6 +146,7 @@ export async function runBncrInboundReplyDispatch(args: {
145
146
  senderIdForContext,
146
147
  shouldDispatch,
147
148
  silentHistoryFlush = false,
149
+ deliveryId,
148
150
  setInboundActivity,
149
151
  scheduleSave,
150
152
  enqueueFromReply,
@@ -210,10 +212,9 @@ export async function runBncrInboundReplyDispatch(args: {
210
212
  return;
211
213
  }
212
214
 
213
- // Stop commands should bypass the serial chain so they can interrupt a running agent.
214
- const rawTrimmed = (rawBody || '').trim();
215
- const isStopCommand =
216
- rawTrimmed === '/stop' || rawTrimmed.startsWith('/stop ') || rawTrimmed.startsWith('/stop@');
215
+ // Stop commands also bypass the reply serial chain; the inbound history serial
216
+ // bypass is handled before this function runs so they can interrupt an agent.
217
+ const isStopCommand = isBncrStopCommandText(rawBody);
217
218
  const dispatchSessionKey = resolution.dispatchSessionKey;
218
219
 
219
220
  const runStopOrSerial = () => {
@@ -225,7 +226,7 @@ export async function runBncrInboundReplyDispatch(args: {
225
226
  raw: parsed,
226
227
  adapter: {
227
228
  ingest: () => ({
228
- id: msgId ?? `${resolution.canonicalTo}:${Date.now()}`,
229
+ id: deliveryId || msgId || `${resolution.canonicalTo}:${Date.now()}`,
229
230
  timestamp: Date.now(),
230
231
  rawText: rawBody,
231
232
  textForAgent: ctxPayload.BodyForAgent,
@@ -3,6 +3,7 @@ import type {
3
3
  BncrSceneRecord,
4
4
  BncrSceneStatus,
5
5
  } from '../../plugin/channel-runtime-types.ts';
6
+ import { resolveBncrHistoryLimit } from './conversation-history.ts';
6
7
  import type { ParsedInbound } from './dispatch-prep.ts';
7
8
  import type { NativeCommand } from './native-command.ts';
8
9
 
@@ -49,12 +50,12 @@ const MODE_HELP_TEXT = [
49
50
  ].join('\n');
50
51
 
51
52
  export const HISTORY_HELP_TEXT = [
52
- '📋 Bncr Group History Configuration',
53
+ '📋 Bncr Conversation History Configuration',
53
54
  '',
54
55
  'Commands:',
55
- ' • /bncr history-limit <number> [<SceneId>]',
56
- ' Set history limit (default: 50)',
57
- ' • /bncr history-force on|off [<SceneId>]',
56
+ ' • /bncr history-limit [<number>|clear] [<SceneId>]',
57
+ ' Set history limit (default: 50, minimum: 2)',
58
+ ' • /bncr history-force on|off|clear [<SceneId>]',
58
59
  ' When on, overflow triggers auto context at limit (default: on)',
59
60
  ' When off, oldest messages trim silently without context',
60
61
  ].join('\n');
@@ -88,6 +89,22 @@ function resolveCurrentGroupSceneKey(parsed: ParsedInbound): string | null {
88
89
  return `${platform}:${groupId}`;
89
90
  }
90
91
 
92
+ function resolveCurrentSceneKey(parsed: ParsedInbound): string | null {
93
+ const platform = normalizeToken(parsed.platform);
94
+ if (!platform) return null;
95
+ if (parsed.peer.kind === 'group') {
96
+ const groupId = normalizeToken(parsed.groupId);
97
+ if (!groupId || groupId === '0') return null;
98
+ return `${platform}:${groupId}`;
99
+ }
100
+ if (parsed.peer.kind === 'direct') {
101
+ const userId = normalizeToken(parsed.userId);
102
+ if (!userId || userId === '0') return null;
103
+ return `${platform}:${userId}`;
104
+ }
105
+ return null;
106
+ }
107
+
91
108
  export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdminCommand {
92
109
  const args = splitArgs(command.argsText);
93
110
  switch (command.command) {
@@ -242,7 +259,7 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
242
259
  return {
243
260
  matched: true,
244
261
  valid: false,
245
- text: 'Usage: /bncr history-limit [<number>] [<sceneKey>]',
262
+ text: 'Usage: /bncr history-limit [<number>|clear] [<sceneKey>]',
246
263
  };
247
264
  case 'history-force':
248
265
  if (args.length === 0) {
@@ -337,13 +354,11 @@ function formatSceneDetailsLine(scene: BncrSceneRecord): string {
337
354
  scene.kind === 'group' ? scene.groupId || scene.sceneKey : scene.userId || scene.sceneKey;
338
355
  const namePart = scene.kind === 'group' ? scene.groupName || '' : scene.userName || '';
339
356
  const labelPart = namePart ? ` name=${namePart}` : '';
340
- const historyLimitVal = scene.historyLimit;
357
+ const historyLimitVal = resolveBncrHistoryLimit(scene.historyLimit);
341
358
  const historyForceVal = scene.historyForce;
342
- const hasNonDefaultHistory =
343
- scene.kind === 'group' &&
344
- ((typeof historyLimitVal === 'number' && historyLimitVal !== 50) || historyForceVal === false);
359
+ const hasNonDefaultHistory = historyLimitVal !== 50 || historyForceVal === false;
345
360
  const historyPart = hasNonDefaultHistory
346
- ? ` historyLimit=${historyLimitVal ?? 50} autoFlush=${historyForceVal !== false ? 'on' : 'off'}`
361
+ ? ` historyLimit=${historyLimitVal} autoFlush=${historyForceVal !== false ? 'on' : 'off'}`
347
362
  : '';
348
363
  return ` Details: status=${scene.status} id=${idPart}${labelPart}${historyPart}`;
349
364
  }
@@ -475,19 +490,22 @@ 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
  }
482
497
  const s = sceneRegistry.get(qSceneKey);
483
498
  if (!s) return { ok: true, text: 'Default history limit is 50.' };
484
- return { ok: true, text: `Current ${qSceneKey} history limit is ${s.historyLimit ?? 50}.` };
499
+ return {
500
+ ok: true,
501
+ text: `Current ${qSceneKey} history limit is ${resolveBncrHistoryLimit(s.historyLimit)}.`,
502
+ };
485
503
  }
486
504
 
487
505
  if (command.kind === 'history-limit-set') {
488
- const sSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
506
+ const sSceneKey = command.sceneKey || resolveCurrentSceneKey(parsed);
489
507
  if (!sSceneKey) {
490
- return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
508
+ return { ok: false, text: 'Current conversation shortcut requires a valid chat.' };
491
509
  }
492
510
  const sExisting = sceneRegistry.get(sSceneKey);
493
511
  if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
@@ -503,12 +521,14 @@ export function executeSceneAdminCommand(args: {
503
521
  let resolvedLimit = rawLimit;
504
522
  if (resolvedLimit >= 51) {
505
523
  resolvedLimit = Math.min(Math.floor(resolvedLimit), 10000);
506
- } else if (resolvedLimit < 0 && Math.abs(resolvedLimit) >= 3) {
507
- resolvedLimit = Math.floor(Math.abs(resolvedLimit));
524
+ } else if (resolvedLimit >= 2) {
525
+ resolvedLimit = Math.floor(resolvedLimit);
526
+ } else if (resolvedLimit < 0 && Math.abs(resolvedLimit) >= 2) {
527
+ resolvedLimit = Math.min(Math.floor(Math.abs(resolvedLimit)), 10000);
508
528
  } else {
509
529
  return {
510
530
  ok: false,
511
- text: `Value too small, must be >= 51, or use negative number (abs >= 3) for hidden override.`,
531
+ text: `Value too small, must be >= 2, or use negative number (abs >= 2) for hidden override.`,
512
532
  };
513
533
  }
514
534
  sceneRegistry.set(sSceneKey, {
@@ -520,7 +540,7 @@ export function executeSceneAdminCommand(args: {
520
540
  }
521
541
 
522
542
  if (command.kind === 'history-force-get') {
523
- const qSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
543
+ const qSceneKey = command.sceneKey || resolveCurrentSceneKey(parsed);
524
544
  if (!qSceneKey) {
525
545
  return { ok: true, text: 'Default history auto flush is on.' };
526
546
  }
@@ -534,9 +554,9 @@ export function executeSceneAdminCommand(args: {
534
554
  }
535
555
 
536
556
  if (command.kind === 'history-force-set') {
537
- const sSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
557
+ const sSceneKey = command.sceneKey || resolveCurrentSceneKey(parsed);
538
558
  if (!sSceneKey) {
539
- return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
559
+ return { ok: false, text: 'Current conversation shortcut requires a valid chat.' };
540
560
  }
541
561
  const sExisting = sceneRegistry.get(sSceneKey);
542
562
  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({
@@ -44,6 +44,7 @@ export function startBncrBridgeService(
44
44
  options?: { debugOnly?: boolean },
45
45
  ) => void;
46
46
  loadState: () => Promise<void>;
47
+ cutoverToSqlite?: () => Promise<{ backupPath: string | null; storeMode: string }>;
47
48
  setDebugFlag: (value: boolean) => void;
48
49
  refreshDebugFlagFromConfig: (options?: { forceLog?: boolean }) => Promise<void>;
49
50
  buildIntegratedDiagnostics: (accountId: string) => {
@@ -75,6 +76,13 @@ export function startBncrBridgeService(
75
76
  // ignore startup canonical agent initialization errors
76
77
  }
77
78
  await runtime.loadState();
79
+ if (process.env.BNCR_SQLITE_CUTOVER === '1' && runtime.cutoverToSqlite) {
80
+ const cutover = await runtime.cutoverToSqlite();
81
+ runtime.logInfo(
82
+ 'sqlite',
83
+ `cutover completed backup=${cutover.backupPath || 'none'} storeMode=${cutover.storeMode}`,
84
+ );
85
+ }
78
86
  if (typeof debug === 'boolean') runtime.setDebugFlag(debug);
79
87
  await runtime.refreshDebugFlagFromConfig({ forceLog: true });
80
88
  const bootDiag = runtime.buildIntegratedDiagnostics(BNCR_DEFAULT_ACCOUNT_ID);
@@ -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,7 +43,11 @@ 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;
48
+ resolveOutboundHistoryForce?: (entry: OutboxEntry) => boolean;
45
49
  resolveOutboundSender?: (entry: OutboxEntry) => { sender: string; senderId?: string };
50
+ onConversationHistoryOverflow?: (entry: OutboxEntry, historyVersion?: number) => void;
46
51
  isOutboundAckRequired?: (accountId: string) => boolean;
47
52
  scheduleSave: () => void;
48
53
  flushPushQueueBestEffort: (args?: {
@@ -62,13 +67,22 @@ export function createBncrBridgeOutboxFacade(runtime: {
62
67
  sender: entry.accountId,
63
68
  senderId: entry.accountId,
64
69
  };
65
- recordBncrOutboundReplay({
70
+ const replayResult = recordBncrOutboundReplay({
66
71
  cache: runtime.outboundReplayCache,
72
+ ...(runtime.conversationHistories
73
+ ? { conversationHistories: runtime.conversationHistories }
74
+ : {}),
75
+ ...(runtime.resolveOutboundHistoryLimit
76
+ ? { historyLimit: runtime.resolveOutboundHistoryLimit(entry) }
77
+ : {}),
67
78
  entry,
68
79
  sender: senderInfo.sender,
69
80
  senderId: senderInfo.senderId,
70
81
  status,
71
82
  });
83
+ if (replayResult.historyOverflow && runtime.resolveOutboundHistoryForce?.(entry) !== false) {
84
+ runtime.onConversationHistoryOverflow?.(entry, replayResult.historyVersion);
85
+ }
72
86
  };
73
87
 
74
88
  const markRecentOutboundAcked = (entry: OutboxEntry) => {
@@ -4,8 +4,8 @@ import { emitBncrLogLine } from '../core/logging.ts';
4
4
  import { formatDisplayScope, normalizeStoredSessionKey, parseRouteLike } from '../core/targets.ts';
5
5
  import type { BncrRoute } from '../core/types.ts';
6
6
  import type { BncrInboundParamsInput } from '../messaging/inbound/contracts.ts';
7
+ import type { BncrConversationHistoryMap } from '../messaging/inbound/conversation-history.ts';
7
8
  import { dispatchBncrInbound } from '../messaging/inbound/dispatch.ts';
8
- import type { BncrGroupHistoryMap } from '../messaging/inbound/group-history.ts';
9
9
  import type { BncrOutboundReplayCache } from '../messaging/inbound/outbound-replay-cache.ts';
10
10
  import { parseBncrInboundParams } from '../messaging/inbound/parse.ts';
11
11
  import { OUTBOUND_FLUSH_REASON, OUTBOUND_FLUSH_TRIGGER } from '../messaging/outbound/reasons.ts';
@@ -211,7 +211,7 @@ export function createBncrInboundHandlersComponent(runtime: {
211
211
  defaultAdminAgentId: Parameters<typeof createBncrInboundHandlers>[0]['defaultAdminAgentId'];
212
212
  defaultPublicAgentId: Parameters<typeof createBncrInboundHandlers>[0]['defaultPublicAgentId'];
213
213
  sceneRegistry: Parameters<typeof createBncrInboundHandlers>[0]['sceneRegistry'];
214
- groupHistories: BncrGroupHistoryMap;
214
+ conversationHistories: BncrConversationHistoryMap;
215
215
  outboundReplayCache: BncrOutboundReplayCache;
216
216
  prepareInboundAcceptance: Parameters<
217
217
  typeof createBncrInboundHandlers
@@ -257,7 +257,7 @@ export function createBncrInboundHandlersComponent(runtime: {
257
257
  defaultAdminAgentId: runtime.defaultAdminAgentId,
258
258
  defaultPublicAgentId: runtime.defaultPublicAgentId,
259
259
  sceneRegistry: runtime.sceneRegistry,
260
- groupHistories: runtime.groupHistories,
260
+ conversationHistories: runtime.conversationHistories,
261
261
  outboundReplayCache: runtime.outboundReplayCache,
262
262
  prepareInboundAcceptance: runtime.prepareInboundAcceptance,
263
263
  formatDisplayScope,
@@ -277,7 +277,7 @@ export function createBncrInboundHandlersComponent(runtime: {
277
277
  shouldDispatch,
278
278
  shouldAccumulate,
279
279
  sceneRegistry,
280
- groupHistories,
280
+ conversationHistories,
281
281
  outboundReplayCache,
282
282
  defaultAdminAgentId,
283
283
  defaultPublicAgentId,
@@ -293,7 +293,7 @@ export function createBncrInboundHandlersComponent(runtime: {
293
293
  shouldDispatch,
294
294
  shouldAccumulate,
295
295
  sceneRegistry,
296
- groupHistories,
296
+ conversationHistories,
297
297
  outboundReplayCache,
298
298
  defaultAdminAgentId,
299
299
  defaultPublicAgentId,
@@ -1,6 +1,6 @@
1
1
  import type { GatewayRequestHandlerOptions } from 'openclaw/plugin-sdk/core';
2
2
  import type { BncrRoute, OutboxEntry } from '../core/types.ts';
3
- import type { BncrGroupHistoryMap } from '../messaging/inbound/group-history.ts';
3
+ import type { BncrConversationHistoryMap } from '../messaging/inbound/conversation-history.ts';
4
4
  import type { BncrOutboundReplayCache } from '../messaging/inbound/outbound-replay-cache.ts';
5
5
  import type { parseBncrInboundParams } from '../messaging/inbound/parse.ts';
6
6
  import type { ReplyPayloadInput } from '../messaging/outbound/reply-enqueue.ts';
@@ -107,7 +107,7 @@ export function buildBncrInboundSurfaceRuntime(deps: {
107
107
  }) => string;
108
108
  defaultPublicAgentId: () => string;
109
109
  sceneRegistry: Map<string, BncrSceneRecord>;
110
- groupHistories: BncrGroupHistoryMap;
110
+ conversationHistories: BncrConversationHistoryMap;
111
111
  outboundReplayCache: BncrOutboundReplayCache;
112
112
  prepareInboundAcceptance: (args: {
113
113
  parsed: ReturnType<typeof parseBncrInboundParams>;
@@ -12,6 +12,8 @@ export const MAX_SESSION_ROUTE_ENTRIES = 1000;
12
12
  export const MAX_ACCOUNT_ACTIVITY_ENTRIES = 1000;
13
13
  export const PUSH_DRAIN_INTERVAL_MS = 500;
14
14
  export const PUSH_DRAIN_ACCOUNT_BUDGET = 5;
15
+ export const HISTORY_SHARD_WORKER_INTERVAL_MS = 1_000;
16
+ export const HISTORY_SHARD_WORKER_BATCH_LIMIT = 5;
15
17
  export const PUSH_DRAIN_ACCOUNT_TIME_BUDGET_MS = 2_000;
16
18
  export const PUSH_DRAIN_EXCEPTION_RETRY_LIMIT = 3;
17
19
  export const PUSH_DRAIN_EXCEPTION_RETRY_DELAY_MS = 1_000;
@@ -73,29 +73,30 @@ export type BncrSceneRecord = {
73
73
  lastSeenAt: number;
74
74
  };
75
75
 
76
- export type BncrPersistedGroupHistoryEntry = {
76
+ export type BncrPersistedConversationHistoryEntry = {
77
77
  sender: string;
78
78
  senderId?: string;
79
+ role?: 'user' | 'assistant' | 'system';
79
80
  body: string;
80
81
  timestamp?: number;
81
82
  messageId?: string;
82
- media?: BncrPersistedGroupHistoryMediaEntry[];
83
+ media?: BncrPersistedConversationHistoryMediaEntry[];
83
84
  };
84
85
 
85
- export type BncrPersistedGroupHistoryMediaEntry = {
86
+ export type BncrPersistedConversationHistoryMediaEntry = {
86
87
  path?: string;
87
88
  contentType?: string;
88
89
  kind?: 'image' | 'video' | 'audio' | 'document' | 'unknown';
89
90
  messageId?: string;
90
91
  };
91
92
 
92
- export type BncrPersistedGroupHistoryBucket = {
93
+ export type BncrPersistedConversationHistoryBucket = {
93
94
  key: string;
94
- entries: BncrPersistedGroupHistoryEntry[];
95
+ entries: BncrPersistedConversationHistoryEntry[];
95
96
  };
96
97
 
97
98
  export type BncrPersistedOutboundReplayEntry = BncrOutboundReplayEntry;
98
- export type BncrPersistedOutboundReplayMediaEntry = BncrPersistedGroupHistoryMediaEntry;
99
+ export type BncrPersistedOutboundReplayMediaEntry = BncrPersistedConversationHistoryMediaEntry;
99
100
  export type BncrPersistedOutboundReplayBucket = {
100
101
  key: string;
101
102
  entries: BncrPersistedOutboundReplayEntry[];
@@ -186,7 +187,8 @@ export type PersistedState = {
186
187
  deadLetter: OutboxEntry[];
187
188
  sessionRoutes: BncrPersistedSessionRoute[];
188
189
  sceneRegistry?: BncrSceneRecord[];
189
- groupHistories?: BncrPersistedGroupHistoryBucket[];
190
+ conversationHistories?: BncrPersistedConversationHistoryBucket[];
191
+ groupHistories?: BncrPersistedConversationHistoryBucket[];
190
192
  outboundReplayCache?: BncrPersistedOutboundReplayBucket[];
191
193
  lastSessionByAccount?: BncrPersistedLastSession[];
192
194
  lastActivityByAccount?: BncrPersistedAccountTimestamp[];
@@ -66,30 +66,12 @@ function shouldDispatchForScene(args: {
66
66
  }
67
67
 
68
68
  function shouldAccumulateForScene(args: {
69
- parsed: ReturnType<typeof parseBncrInboundParams>;
69
+ shouldDispatch: boolean;
70
70
  admission: ReturnType<typeof decideSceneAdmission>;
71
- cfg: BncrChannelConfigRoot;
72
71
  }) {
73
- const { parsed, admission, cfg } = args;
74
- if (parsed.peer.kind === 'direct') return true;
75
- if (!admission.allowed) return false;
76
-
77
- const isBncrNativeCommand =
78
- parseBncrNativeCommand(parsed.extracted.text, {
79
- allowBareWhoami: parsed.isAdmin !== true,
80
- }) !== null;
81
- const isAdminOpenClawNativeCommand =
82
- parsed.isAdmin === true &&
83
- !isBncrNativeCommand &&
84
- hasControlCommand(parsed.extracted.text, cfg as Parameters<typeof hasControlCommand>[1]);
85
- if (isAdminOpenClawNativeCommand) return true;
86
-
87
- const mode = admission.scene.groupReplyMode || 'admin';
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
92
- return true;
72
+ if (args.shouldDispatch) return true;
73
+ const mode = args.admission.scene.groupReplyMode || 'admin';
74
+ return mode === 'mention' || mode === 'hybrid';
93
75
  }
94
76
 
95
77
  export async function prepareBncrInboundAcceptance(args: {
@@ -206,6 +188,9 @@ export async function prepareBncrInboundAcceptance(args: {
206
188
  resolveAgentRoute: args.resolveAgentRoute,
207
189
  });
208
190
 
191
+ const shouldDispatch = shouldDispatchForScene({ parsed, admission, cfg });
192
+ const shouldAccumulate = shouldAccumulateForScene({ shouldDispatch, admission });
193
+
209
194
  return {
210
195
  ok: true as const,
211
196
  accountId,
@@ -213,8 +198,8 @@ export async function prepareBncrInboundAcceptance(args: {
213
198
  inboundText,
214
199
  hasMedia: Boolean(mediaBase64 || mediaPathFromTransfer),
215
200
  resolvedAgentId: admission.agentId,
216
- shouldDispatch: shouldDispatchForScene({ parsed, admission, cfg }),
217
- shouldAccumulate: shouldAccumulateForScene({ parsed, admission, cfg }),
201
+ shouldDispatch,
202
+ shouldAccumulate,
218
203
  dispatchBy: resolveDispatchBy({ parsed, admission, cfg }),
219
204
  };
220
205
  }