@xmoxmo/bncr 0.6.2 → 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.
@@ -1,8 +1,22 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, rename } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
1
4
  import {
2
5
  dumpRegisterDriftSnapshot,
3
6
  normalizeRegisterDriftSnapshot,
4
7
  } from '../core/register-trace.ts';
5
8
  import type { BncrRoute, OutboxEntry } from '../core/types.ts';
9
+ import {
10
+ type BncrConversationHistoryMap,
11
+ buildBncrBotReplyMessageId,
12
+ buildBncrConversationHistoryKeyFromRoute,
13
+ resetConversationHistoryVersions,
14
+ resolveBncrHistoryLimit,
15
+ } from '../messaging/inbound/conversation-history.ts';
16
+ import {
17
+ getConversationHistorySerialOwner,
18
+ readConversationHistorySerialHistoryKeys,
19
+ } from '../messaging/inbound/conversation-history-serial.ts';
6
20
  import type {
7
21
  BncrOutboundReplayEntry,
8
22
  BncrOutboundReplayStatus,
@@ -24,6 +38,15 @@ import type {
24
38
  PersistedState as BncrPersistedState,
25
39
  BncrSceneRecord,
26
40
  } from './channel-runtime-types.ts';
41
+ import type {
42
+ BncrHistoryShardCreateInput,
43
+ BncrHistoryShardCreateResult,
44
+ BncrHistoryShardQueue,
45
+ BncrSqliteControlState,
46
+ BncrSqliteHistoryState,
47
+ BncrSqliteOutboundState,
48
+ BncrSqliteStateDatabase,
49
+ } from './sqlite-state.ts';
27
50
 
28
51
  const GROUP_REPLY_MODES = new Set<BncrGroupReplyMode>(['admin', 'mention', 'hybrid', 'all']);
29
52
  const DEFAULT_PERSISTED_CONVERSATION_HISTORY_LIMIT = 50;
@@ -54,6 +77,39 @@ type PersistedConversationHistoryEntryInput = Partial<BncrPersistedConversationH
54
77
  type PersistedOutboundReplayBucketInput = Partial<BncrPersistedOutboundReplayBucket>;
55
78
  type PersistedOutboundReplayEntryInput = Partial<BncrOutboundReplayEntry>;
56
79
 
80
+ const bncrHistoryShardQueues = new WeakMap<BncrConversationHistoryMap, BncrHistoryShardQueue>();
81
+
82
+ export function registerBncrHistoryShardQueue(
83
+ historyMap: BncrConversationHistoryMap,
84
+ queue: BncrHistoryShardQueue,
85
+ ): void {
86
+ bncrHistoryShardQueues.set(historyMap, queue);
87
+ }
88
+
89
+ export function getBncrHistoryShardQueue(
90
+ historyMap: BncrConversationHistoryMap,
91
+ ): BncrHistoryShardQueue | undefined {
92
+ return bncrHistoryShardQueues.get(historyMap);
93
+ }
94
+
95
+ function buildMigratedHistoryMessageId(seed: string): string {
96
+ const digest = createHash('sha1').update(seed).digest('hex').slice(0, 24);
97
+ return `bncr-synthetic:migrated:${digest}`;
98
+ }
99
+
100
+ function formatSqliteCutoverTimestamp(timestamp: number): string {
101
+ const date = new Date(timestamp);
102
+ const pad = (value: number) => String(value).padStart(2, '0');
103
+ return [
104
+ date.getFullYear(),
105
+ pad(date.getMonth() + 1),
106
+ pad(date.getDate()),
107
+ pad(date.getHours()),
108
+ pad(date.getMinutes()),
109
+ pad(date.getSeconds()),
110
+ ].join('');
111
+ }
112
+
57
113
  export function createBncrStateStore(runtime: {
58
114
  getStatePath: () => string | null;
59
115
  now: () => number;
@@ -89,20 +145,65 @@ export function createBncrStateStore(runtime: {
89
145
  lastOutboundByAccount: Map<string, number>;
90
146
  getLastDriftSnapshot: () => BncrPersistedState['lastDriftSnapshot'];
91
147
  setLastDriftSnapshot: (value: BncrPersistedState['lastDriftSnapshot']) => void;
148
+ sqliteState?: BncrSqliteStateDatabase;
149
+ createSqliteState?: (
150
+ statePath: string | null,
151
+ ) => BncrSqliteStateDatabase | PromiseLike<BncrSqliteStateDatabase | null> | null;
92
152
  }) {
93
153
  const outboundReplayCache =
94
154
  runtime.outboundReplayCache ?? new Map<string, BncrPersistedOutboundReplayEntry[]>();
155
+ let sqliteState: BncrSqliteStateDatabase | undefined;
156
+
157
+ async function resolveSqliteState(
158
+ statePath: string | null,
159
+ ): Promise<BncrSqliteStateDatabase | undefined> {
160
+ if (!sqliteState) {
161
+ sqliteState =
162
+ runtime.sqliteState ?? (await runtime.createSqliteState?.(statePath)) ?? undefined;
163
+ }
164
+ return sqliteState;
165
+ }
95
166
 
96
167
  function resolvePersistedConversationHistoryLimit(key: string): number {
97
168
  const scene = runtime.sceneRegistry.get(key);
98
- const sceneLimit = scene?.historyLimit;
99
- if (typeof sceneLimit === 'number' && Number.isFinite(sceneLimit) && sceneLimit >= 0) {
100
- return Math.max(
101
- DEFAULT_PERSISTED_CONVERSATION_HISTORY_LIMIT,
102
- Math.ceil(Math.floor(sceneLimit) * 1.2),
103
- );
169
+ const sceneLimit = resolveBncrHistoryLimit(scene?.historyLimit);
170
+ return Math.max(DEFAULT_PERSISTED_CONVERSATION_HISTORY_LIMIT, Math.ceil(sceneLimit * 1.2));
171
+ }
172
+
173
+ function resolveOutboundReplayBucketHistoryKey(
174
+ bucketKey: string,
175
+ entries: readonly BncrOutboundReplayEntry[],
176
+ ): string | null {
177
+ const route = entries.find((entry) => runtime.parseRouteLike(entry?.route))?.route;
178
+ if (route) {
179
+ return buildBncrConversationHistoryKeyFromRoute({
180
+ platform: route.platform,
181
+ groupId: route.groupId,
182
+ userId: route.userId,
183
+ });
104
184
  }
105
- return DEFAULT_PERSISTED_CONVERSATION_HISTORY_LIMIT;
185
+ const separator = bucketKey.indexOf(':');
186
+ if (separator < 0 || separator >= bucketKey.length - 1) return null;
187
+ return bucketKey.slice(separator + 1).trim() || null;
188
+ }
189
+
190
+ function resolveOutboundReplayBucketLimit(
191
+ bucketKey: string,
192
+ entries: readonly BncrOutboundReplayEntry[],
193
+ ): number {
194
+ const historyKey = resolveOutboundReplayBucketHistoryKey(bucketKey, entries);
195
+ if (!historyKey) return DEFAULT_PERSISTED_CONVERSATION_HISTORY_LIMIT;
196
+ return resolveBncrHistoryLimit(runtime.sceneRegistry.get(historyKey)?.historyLimit);
197
+ }
198
+
199
+ function normalizeOutboundReplayBucket(
200
+ bucketKey: string,
201
+ entries: readonly BncrOutboundReplayEntry[],
202
+ ): BncrOutboundReplayEntry[] {
203
+ return entries
204
+ .slice()
205
+ .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
206
+ .slice(-resolveOutboundReplayBucketLimit(bucketKey, entries));
106
207
  }
107
208
 
108
209
  function loadPersistedSceneRegistry(persisted: unknown): void {
@@ -151,10 +252,11 @@ export function createBncrStateStore(runtime: {
151
252
  : {}),
152
253
  ...(typeof item.historyLimit === 'number' &&
153
254
  Number.isFinite(item.historyLimit) &&
154
- item.historyLimit >= 0
255
+ Math.floor(item.historyLimit) >= 2
155
256
  ? { historyLimit: Math.floor(item.historyLimit) }
156
257
  : {}),
157
258
  ...(typeof item.historyForce === 'boolean' ? { historyForce: item.historyForce } : {}),
259
+ ...(typeof item.downloadMedia === 'boolean' ? { downloadMedia: item.downloadMedia } : {}),
158
260
  lastSeenAt,
159
261
  });
160
262
  }
@@ -168,8 +270,12 @@ export function createBncrStateStore(runtime: {
168
270
  persisted: unknown,
169
271
  target: Map<string, BncrPersistedConversationHistoryEntry[]>,
170
272
  resolveLimit: (key: string) => number,
273
+ options?: {
274
+ assistantMessageIdsByHistoryKey?: ReadonlyMap<string, ReadonlySet<string>>;
275
+ merge?: boolean;
276
+ },
171
277
  ): void {
172
- target.clear();
278
+ if (!options?.merge) target.clear();
173
279
  const buckets = Array.isArray(persisted)
174
280
  ? (persisted as PersistedConversationHistoryBucketInput[])
175
281
  : [];
@@ -180,12 +286,12 @@ export function createBncrStateStore(runtime: {
180
286
  const rawEntries = Array.isArray(bucket?.entries)
181
287
  ? (bucket.entries as PersistedConversationHistoryEntryInput[])
182
288
  : [];
183
- for (const entry of rawEntries) {
289
+ for (const [entryIndex, entry] of rawEntries.entries()) {
184
290
  const sender = runtime.asString(entry?.sender || '').trim();
185
291
  const body = runtime.asString(entry?.body || '').trim();
186
292
  if (!sender || !body) continue;
187
293
  const timestamp = runtime.finiteNumberOr(entry?.timestamp, 0);
188
- const messageId = runtime.asString(entry?.messageId || '').trim();
294
+ let messageId = runtime.asString(entry?.messageId || '').trim();
189
295
 
190
296
  const media: BncrPersistedConversationHistoryMediaEntry[] = [];
191
297
  const rawMedia = Array.isArray(entry?.media)
@@ -201,41 +307,184 @@ export function createBncrStateStore(runtime: {
201
307
  path,
202
308
  ...(contentType ? { contentType } : {}),
203
309
  ...(kind ? { kind: kind as BncrPersistedConversationHistoryMediaEntry['kind'] } : {}),
204
- ...(mediaMessageId ? { messageId: mediaMessageId } : {}),
310
+ // Legacy entries without platform ids are back-filled with a stable
311
+ // synthetic id so snapshot cleanup can still remove them exactly.
312
+ ...(mediaMessageId || messageId ? { messageId: mediaMessageId || messageId } : {}),
205
313
  };
206
314
  media.push(normalizedMedia);
207
315
  }
208
316
 
317
+ const rawRole = entry?.role;
318
+ const assistantMessageIds = options?.assistantMessageIdsByHistoryKey?.get(key);
319
+ const role =
320
+ rawRole === 'user' || rawRole === 'assistant' || rawRole === 'system'
321
+ ? rawRole
322
+ : assistantMessageIds?.has(messageId)
323
+ ? 'assistant'
324
+ : 'user';
325
+ if (!messageId) {
326
+ messageId = buildMigratedHistoryMessageId(
327
+ JSON.stringify({
328
+ key,
329
+ entryIndex,
330
+ sender,
331
+ senderId: runtime.asString(entry?.senderId || '').trim(),
332
+ role,
333
+ body,
334
+ timestamp,
335
+ media: media.map((item) => [item.path, item.contentType, item.kind, item.messageId]),
336
+ }),
337
+ );
338
+ }
339
+
209
340
  const normalizedEntry: BncrPersistedConversationHistoryEntry = {
210
341
  sender,
211
342
  ...(runtime.asString(entry?.senderId || '').trim()
212
343
  ? { senderId: runtime.asString(entry?.senderId || '').trim() }
213
344
  : {}),
214
- ...(entry?.role === 'user' || entry?.role === 'assistant' || entry?.role === 'system'
215
- ? { role: entry.role }
216
- : {}),
345
+ role,
217
346
  body,
218
347
  ...(timestamp > 0 ? { timestamp } : {}),
219
348
  ...(messageId ? { messageId } : {}),
220
349
  ...(media.length > 0 ? { media } : {}),
221
350
  };
351
+ if (
352
+ messageId &&
353
+ Array.isArray(normalizedEntry.media) &&
354
+ normalizedEntry.media.some((item) => !item.messageId)
355
+ ) {
356
+ normalizedEntry.media = normalizedEntry.media.map((item) =>
357
+ item.messageId
358
+ ? item
359
+ : {
360
+ ...item,
361
+ messageId,
362
+ },
363
+ );
364
+ }
222
365
  entries.push(normalizedEntry);
223
366
  }
224
367
  if (entries.length > 0) {
225
368
  const limit = resolveLimit(key);
226
- target.set(key, Number.isFinite(limit) ? entries.slice(-limit) : entries);
369
+ const merged = options?.merge ? [...(target.get(key) || []), ...entries] : entries;
370
+ const seenMessageIds = new Set<string>();
371
+ const deduped: BncrPersistedConversationHistoryEntry[] = [];
372
+ for (const entry of merged) {
373
+ const messageId = runtime.asString(entry.messageId || '').trim();
374
+ if (messageId && seenMessageIds.has(messageId)) continue;
375
+ if (messageId) seenMessageIds.add(messageId);
376
+ deduped.push(entry);
377
+ }
378
+ target.set(key, Number.isFinite(limit) ? deduped.slice(-limit) : deduped);
227
379
  }
228
380
  }
229
381
  }
230
382
 
231
- function loadPersistedConversationHistories(persisted: unknown): void {
232
- loadPersistedHistoryBuckets(persisted, runtime.conversationHistories, (key) =>
233
- resolvePersistedConversationHistoryLimit(key),
383
+ function loadPersistedConversationHistories(
384
+ persisted: unknown,
385
+ options?: { assistantMessageIdsByHistoryKey?: ReadonlyMap<string, ReadonlySet<string>> },
386
+ ): void {
387
+ loadPersistedHistoryBuckets(
388
+ persisted,
389
+ runtime.conversationHistories,
390
+ (key) => resolvePersistedConversationHistoryLimit(key),
391
+ options,
234
392
  );
393
+ // Persisted replacement invalidates freshness markers used by queued flushes.
394
+ resetConversationHistoryVersions(runtime.conversationHistories);
395
+ }
396
+
397
+ function collectPersistedOutboundAssistantMessageIds(
398
+ persisted: unknown,
399
+ ): Map<string, Set<string>> {
400
+ const messageIdsByHistoryKey = new Map<string, Set<string>>();
401
+ const buckets = Array.isArray(persisted)
402
+ ? (persisted as Array<{ key?: unknown; entries?: unknown }>)
403
+ : [];
404
+ for (const bucket of buckets) {
405
+ if (!bucket || typeof bucket !== 'object') continue;
406
+ const bucketKey = runtime.asString(bucket?.key || '').trim();
407
+ const rawEntries = Array.isArray(bucket.entries) ? bucket.entries : [];
408
+ for (const entry of rawEntries as Array<{
409
+ sender?: unknown;
410
+ body?: unknown;
411
+ messageId?: unknown;
412
+ route?: { platform?: unknown; groupId?: unknown; userId?: unknown };
413
+ }>) {
414
+ const sender = runtime.asString(entry?.sender || '').trim();
415
+ const body = runtime.asString(entry?.body || '').trim();
416
+ if (!sender || !body) continue;
417
+ const messageId = runtime.asString(entry?.messageId || '').trim();
418
+ if (!messageId) continue;
419
+ const route = entry?.route;
420
+ const historyKey =
421
+ buildBncrConversationHistoryKeyFromRoute({
422
+ platform: typeof route?.platform === 'string' ? route.platform : undefined,
423
+ groupId: typeof route?.groupId === 'string' ? route.groupId : undefined,
424
+ userId: typeof route?.userId === 'string' ? route.userId : undefined,
425
+ }) ?? (bucketKey.includes(':') ? bucketKey.slice(bucketKey.indexOf(':') + 1) : '');
426
+ if (!historyKey) continue;
427
+ const scopedMessageIds = messageIdsByHistoryKey.get(historyKey) ?? new Set<string>();
428
+ scopedMessageIds.add(messageId);
429
+ messageIdsByHistoryKey.set(historyKey, scopedMessageIds);
430
+ }
431
+ }
432
+ return messageIdsByHistoryKey;
235
433
  }
236
434
 
237
- function loadPersistedOutboundReplayCache(persisted: unknown): void {
238
- outboundReplayCache.clear();
435
+ function resolvePersistedOutboundReplayMessageId(args: {
436
+ bucketKey: string;
437
+ sender: string;
438
+ senderId?: string;
439
+ body: string;
440
+ timestamp: number;
441
+ media: BncrPersistedConversationHistoryMediaEntry[];
442
+ route?: BncrRoute | null;
443
+ historyMap?: Map<string, BncrPersistedConversationHistoryEntry[]>;
444
+ }): string | undefined {
445
+ const route = args.route;
446
+ const historyKey = route
447
+ ? buildBncrConversationHistoryKeyFromRoute({
448
+ platform: route.platform,
449
+ groupId: route.groupId,
450
+ userId: route.userId,
451
+ })
452
+ : args.bucketKey.includes(':')
453
+ ? args.bucketKey.slice(args.bucketKey.indexOf(':') + 1).trim()
454
+ : '';
455
+ if (!historyKey) return undefined;
456
+
457
+ const matchingHistory = args.historyMap
458
+ ?.get(historyKey)
459
+ ?.find(
460
+ (candidate) =>
461
+ candidate.role === 'assistant' &&
462
+ candidate.sender === args.sender &&
463
+ candidate.body === args.body &&
464
+ (args.timestamp <= 0 || candidate.timestamp === args.timestamp),
465
+ );
466
+ const matchingMessageId = runtime.asString(matchingHistory?.messageId || '').trim();
467
+ if (matchingMessageId) return matchingMessageId;
468
+
469
+ return buildBncrBotReplyMessageId({
470
+ historyKey,
471
+ sender: args.sender,
472
+ ...(args.senderId ? { senderId: args.senderId } : {}),
473
+ body: args.body,
474
+ ...(args.timestamp > 0 ? { timestamp: args.timestamp } : {}),
475
+ media: args.media.length > 0 ? args.media : undefined,
476
+ });
477
+ }
478
+
479
+ function loadPersistedOutboundReplayCache(
480
+ persisted: unknown,
481
+ options?: {
482
+ merge?: boolean;
483
+ historyMap?: Map<string, BncrPersistedConversationHistoryEntry[]>;
484
+ },
485
+ target: Map<string, BncrPersistedOutboundReplayEntry[]> = outboundReplayCache,
486
+ ): void {
487
+ if (!options?.merge) target.clear();
239
488
  const buckets = Array.isArray(persisted)
240
489
  ? (persisted as PersistedOutboundReplayBucketInput[])
241
490
  : [];
@@ -282,6 +531,24 @@ export function createBncrStateStore(runtime: {
282
531
  ...(mediaMessageId ? { messageId: mediaMessageId } : {}),
283
532
  });
284
533
  }
534
+ const resolvedMessageId =
535
+ messageId ||
536
+ resolvePersistedOutboundReplayMessageId({
537
+ bucketKey: key,
538
+ sender,
539
+ senderId: runtime.asString(entry?.senderId || '').trim(),
540
+ body,
541
+ timestamp,
542
+ media,
543
+ route,
544
+ historyMap: options?.historyMap,
545
+ });
546
+ const normalizedMedia = media.map((item) => ({
547
+ ...item,
548
+ ...(item.messageId || resolvedMessageId
549
+ ? { messageId: item.messageId || resolvedMessageId }
550
+ : {}),
551
+ }));
285
552
  entries.push({
286
553
  sender,
287
554
  ...(runtime.asString(entry?.senderId || '').trim()
@@ -289,8 +556,8 @@ export function createBncrStateStore(runtime: {
289
556
  : {}),
290
557
  body,
291
558
  ...(timestamp > 0 ? { timestamp } : {}),
292
- ...(messageId ? { messageId } : {}),
293
- ...(media.length > 0 ? { media } : {}),
559
+ ...(resolvedMessageId ? { messageId: resolvedMessageId } : {}),
560
+ ...(normalizedMedia.length > 0 ? { media: normalizedMedia } : {}),
294
561
  ...(accountId ? { accountId } : {}),
295
562
  ...(sessionKey ? { sessionKey } : {}),
296
563
  ...(route ? { route } : {}),
@@ -303,7 +570,18 @@ export function createBncrStateStore(runtime: {
303
570
  });
304
571
  }
305
572
  if (entries.length > 0) {
306
- outboundReplayCache.set(key, entries);
573
+ const combined = options?.merge ? [...(target.get(key) || []), ...entries] : entries;
574
+ const seenMessageIds = new Set<string>();
575
+ const deduped: BncrOutboundReplayEntry[] = [];
576
+ for (const entry of combined) {
577
+ const messageId = runtime.asString(entry.messageId || '').trim();
578
+ if (messageId && seenMessageIds.has(messageId)) continue;
579
+ if (messageId) seenMessageIds.add(messageId);
580
+ deduped.push(entry);
581
+ }
582
+ if (deduped.length > 0) {
583
+ target.set(key, normalizeOutboundReplayBucket(key, deduped));
584
+ }
307
585
  }
308
586
  }
309
587
  }
@@ -327,10 +605,100 @@ export function createBncrStateStore(runtime: {
327
605
  function dumpPersistedOutboundReplayCache() {
328
606
  return Array.from(outboundReplayCache.entries()).map(([key, entries]) => ({
329
607
  key,
330
- entries,
608
+ entries: normalizeOutboundReplayBucket(key, entries),
609
+ }));
610
+ }
611
+
612
+ function dumpPersistedReplayBucketsFromMap(
613
+ source: Map<string, BncrPersistedOutboundReplayEntry[]>,
614
+ ): BncrPersistedOutboundReplayBucket[] {
615
+ return Array.from(source.entries()).map(([key, entries]) => ({
616
+ key,
617
+ entries: normalizeOutboundReplayBucket(key, entries),
331
618
  }));
332
619
  }
333
620
 
621
+ function buildMergedSqliteHistoryState(
622
+ state: BncrSqliteStateDatabase,
623
+ skipHistoryKeys: readonly string[],
624
+ current?: BncrSqliteHistoryState,
625
+ ): BncrSqliteHistoryState {
626
+ const sqliteHistory =
627
+ current ?? state.loadHistoryState(skipHistoryKeys, getConversationHistorySerialOwner());
628
+ const historyTarget = new Map<string, BncrPersistedConversationHistoryEntry[]>();
629
+ loadPersistedHistoryBuckets(sqliteHistory.historyBuckets, historyTarget, (key) =>
630
+ resolvePersistedConversationHistoryLimit(key),
631
+ );
632
+ loadPersistedHistoryBuckets(
633
+ dumpPersistedConversationHistories(),
634
+ historyTarget,
635
+ (key) => resolvePersistedConversationHistoryLimit(key),
636
+ { merge: true },
637
+ );
638
+
639
+ const replayTarget = new Map<string, BncrPersistedOutboundReplayEntry[]>();
640
+ loadPersistedOutboundReplayCache(
641
+ sqliteHistory.replayBuckets,
642
+ { historyMap: runtime.conversationHistories },
643
+ replayTarget,
644
+ );
645
+ loadPersistedOutboundReplayCache(
646
+ dumpPersistedOutboundReplayCache(),
647
+ { merge: true, historyMap: runtime.conversationHistories },
648
+ replayTarget,
649
+ );
650
+
651
+ return {
652
+ historyBuckets: dumpPersistedHistoryBuckets(historyTarget, (key) =>
653
+ resolvePersistedConversationHistoryLimit(key),
654
+ ),
655
+ replayBuckets: dumpPersistedReplayBucketsFromMap(replayTarget),
656
+ };
657
+ }
658
+
659
+ function saveMergedSqliteHistoryState(
660
+ state: BncrSqliteStateDatabase,
661
+ skipHistoryKeys: readonly string[],
662
+ ): void {
663
+ for (let attempt = 0; attempt < 8; attempt += 1) {
664
+ // Optimistic revision protects rows written by another instance between
665
+ // our read and write; a conflict reloads and merges before retrying.
666
+ const current = state.loadHistoryState(skipHistoryKeys, getConversationHistorySerialOwner());
667
+ const merged = buildMergedSqliteHistoryState(state, skipHistoryKeys, current);
668
+ const revision = state.getHistoryStateRevision();
669
+ try {
670
+ state.saveHistoryState(merged.historyBuckets, merged.replayBuckets, revision);
671
+ return;
672
+ } catch (error) {
673
+ const message = error instanceof Error ? error.message : String(error);
674
+ if (message.includes('history revision conflict') && attempt < 7) continue;
675
+ throw error;
676
+ }
677
+ }
678
+ }
679
+
680
+ async function reconcileHistoryMemoryWithSqlite(historyKey?: string): Promise<void> {
681
+ const state = await resolveSqliteState(runtime.getStatePath());
682
+ if (!state) return;
683
+ const mode = state.getStoreMode();
684
+ if (mode !== 'dual' && mode !== 'sqlite') return;
685
+ const skipHistoryKeys = historyKey ? [historyKey] : readConversationHistorySerialHistoryKeys();
686
+ saveMergedSqliteHistoryState(state, skipHistoryKeys);
687
+
688
+ const sqliteHistory = state.loadHistoryState(
689
+ skipHistoryKeys,
690
+ getConversationHistorySerialOwner(),
691
+ );
692
+ loadPersistedHistoryBuckets(
693
+ sqliteHistory.historyBuckets,
694
+ runtime.conversationHistories,
695
+ (key) => resolvePersistedConversationHistoryLimit(key),
696
+ );
697
+ loadPersistedOutboundReplayCache(sqliteHistory.replayBuckets, {
698
+ historyMap: runtime.conversationHistories,
699
+ });
700
+ }
701
+
334
702
  function loadPersistedAccountTimestampMap(target: Map<string, number>, persisted: unknown): void {
335
703
  target.clear();
336
704
  const items = Array.isArray(persisted)
@@ -448,14 +816,104 @@ export function createBncrStateStore(runtime: {
448
816
  }
449
817
  }
450
818
 
451
- async function loadState() {
819
+ function dumpSqliteControlState(): BncrSqliteControlState {
820
+ return {
821
+ sessionRoutes: dumpPersistedSessionRoutes(),
822
+ sceneRegistry: dumpPersistedSceneRegistry(),
823
+ lastSessionByAccount: dumpPersistedLastSessionMap(),
824
+ lastActivityByAccount: dumpPersistedAccountTimestampMap(runtime.lastActivityByAccount),
825
+ lastInboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastInboundByAccount),
826
+ lastOutboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastOutboundByAccount),
827
+ lastDriftSnapshot: dumpRegisterDriftSnapshot(runtime.getLastDriftSnapshot() ?? null),
828
+ };
829
+ }
830
+
831
+ function applySqliteOutboundState(sqliteOutbound: BncrSqliteOutboundState): void {
832
+ const jsonOutbox = new Map(runtime.outbox);
833
+ const jsonDead = new Map(
834
+ runtime.getDeadLetter().map((entry) => [entry.messageId, entry] as const),
835
+ );
836
+ const sqliteOutbox = new Map<string, OutboxEntry>();
837
+ const sqliteDead = new Map<string, OutboxEntry>();
838
+ for (const entry of sqliteOutbound.outbox) {
839
+ const normalized = runtime.normalizePersistedOutboxEntry(entry);
840
+ if (normalized) sqliteOutbox.set(normalized.messageId, normalized);
841
+ }
842
+ for (const entry of sqliteOutbound.deadLetter) {
843
+ const normalized = runtime.normalizePersistedOutboxEntry(entry);
844
+ if (normalized) sqliteDead.set(normalized.messageId, normalized);
845
+ }
846
+
847
+ const knownMessageIds = new Set<string>([...sqliteOutbox.keys(), ...sqliteDead.keys()]);
848
+ runtime.outbox.clear();
849
+ for (const entry of sqliteOutbox.values()) runtime.outbox.set(entry.messageId, entry);
850
+ for (const [messageId, entry] of jsonOutbox) {
851
+ if (!knownMessageIds.has(messageId)) runtime.outbox.set(messageId, entry);
852
+ }
853
+
854
+ const deadLetter = [...sqliteDead.values()];
855
+ for (const [messageId, entry] of jsonDead) {
856
+ if (!knownMessageIds.has(messageId)) deadLetter.push(entry);
857
+ }
858
+ runtime.setDeadLetter(deadLetter.slice(-runtime.maxDeadLetterEntries));
859
+ }
860
+
861
+ function buildPersistedStateSnapshot(): BncrPersistedState {
862
+ return {
863
+ outbox: Array.from(runtime.outbox.values()),
864
+ deadLetter: runtime.getDeadLetter().slice(-runtime.maxDeadLetterEntries),
865
+ sessionRoutes: dumpPersistedSessionRoutes(),
866
+ sceneRegistry: dumpPersistedSceneRegistry(),
867
+ conversationHistories: dumpPersistedConversationHistories(),
868
+ outboundReplayCache: dumpPersistedOutboundReplayCache(),
869
+ lastSessionByAccount: dumpPersistedLastSessionMap(),
870
+ lastActivityByAccount: dumpPersistedAccountTimestampMap(runtime.lastActivityByAccount),
871
+ lastInboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastInboundByAccount),
872
+ lastOutboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastOutboundByAccount),
873
+ lastDriftSnapshot: dumpRegisterDriftSnapshot(runtime.getLastDriftSnapshot() ?? null),
874
+ };
875
+ }
876
+
877
+ function createHistoryShard(
878
+ input: BncrHistoryShardCreateInput,
879
+ ): BncrHistoryShardCreateResult | null {
880
+ if (!sqliteState) return null;
881
+ const mode = sqliteState.getStoreMode();
882
+ if (mode !== 'dual' && mode !== 'sqlite') return null;
883
+ const skipHistoryKeys = input.historyKey
884
+ ? [input.historyKey]
885
+ : readConversationHistorySerialHistoryKeys();
886
+ saveMergedSqliteHistoryState(sqliteState, skipHistoryKeys);
887
+ return sqliteState.createHistoryShard(input);
888
+ }
889
+
890
+ function completeHistoryShard(shardId: number): void {
891
+ sqliteState?.completeHistoryShard(shardId, getConversationHistorySerialOwner());
892
+ }
893
+
894
+ async function loadState(skipHistoryKeys?: readonly string[]) {
452
895
  const statePath = runtime.getStatePath();
453
- if (!statePath) return;
454
- const loaded = await readOpenClawJsonFileWithFallback(statePath, {
896
+ const activeSqliteState = await resolveSqliteState(statePath);
897
+ if (!statePath && !activeSqliteState) return;
898
+ let legacyJsonSha256: string | undefined;
899
+ if (statePath && activeSqliteState && !activeSqliteState.isControlStateImported()) {
900
+ try {
901
+ legacyJsonSha256 = createHash('sha256')
902
+ .update(await readFile(statePath))
903
+ .digest('hex');
904
+ } catch {
905
+ // A missing JSON file is handled by the fallback below; it is not a migration source.
906
+ }
907
+ }
908
+ const defaults = {
455
909
  outbox: [],
456
910
  deadLetter: [],
457
911
  sessionRoutes: [],
458
- });
912
+ };
913
+ const loaded =
914
+ statePath && activeSqliteState?.getStoreMode() !== 'sqlite'
915
+ ? await readOpenClawJsonFileWithFallback(statePath, defaults)
916
+ : { value: defaults };
459
917
  const data = loaded.value as BncrPersistedStateStoreInput;
460
918
 
461
919
  runtime.outbox.clear();
@@ -478,38 +936,223 @@ export function createBncrStateStore(runtime: {
478
936
 
479
937
  loadPersistedSessionRoutes(data.sessionRoutes);
480
938
  loadPersistedSceneRegistry(data.sceneRegistry);
481
- loadPersistedConversationHistories(data.conversationHistories ?? data.groupHistories);
482
- loadPersistedOutboundReplayCache(data.outboundReplayCache ?? data.messageCache);
939
+ const persistedOutboundReplay = data.outboundReplayCache ?? data.messageCache;
940
+ loadPersistedConversationHistories(data.conversationHistories ?? data.groupHistories, {
941
+ assistantMessageIdsByHistoryKey:
942
+ collectPersistedOutboundAssistantMessageIds(persistedOutboundReplay),
943
+ });
944
+ loadPersistedOutboundReplayCache(persistedOutboundReplay, {
945
+ historyMap: runtime.conversationHistories,
946
+ });
483
947
  loadPersistedLastSessionMap(data.lastSessionByAccount);
484
948
  loadPersistedAccountTimestampMap(runtime.lastActivityByAccount, data.lastActivityByAccount);
485
949
  loadPersistedAccountTimestampMap(runtime.lastInboundByAccount, data.lastInboundByAccount);
486
950
  loadPersistedAccountTimestampMap(runtime.lastOutboundByAccount, data.lastOutboundByAccount);
487
951
 
488
952
  runtime.setLastDriftSnapshot(normalizeRegisterDriftSnapshot(data.lastDriftSnapshot));
953
+ if (activeSqliteState) {
954
+ const mode = activeSqliteState.getStoreMode();
955
+ if ((mode === 'dual' || mode === 'sqlite') && activeSqliteState.isControlStateImported()) {
956
+ const control = activeSqliteState.loadControlState();
957
+ loadPersistedSessionRoutes(control.sessionRoutes);
958
+ loadPersistedSceneRegistry(control.sceneRegistry);
959
+ loadPersistedLastSessionMap(control.lastSessionByAccount);
960
+ loadPersistedAccountTimestampMap(
961
+ runtime.lastActivityByAccount,
962
+ control.lastActivityByAccount,
963
+ );
964
+ loadPersistedAccountTimestampMap(
965
+ runtime.lastInboundByAccount,
966
+ control.lastInboundByAccount,
967
+ );
968
+ loadPersistedAccountTimestampMap(
969
+ runtime.lastOutboundByAccount,
970
+ control.lastOutboundByAccount,
971
+ );
972
+ runtime.setLastDriftSnapshot(control.lastDriftSnapshot);
973
+ } else if (!activeSqliteState.isControlStateImported()) {
974
+ activeSqliteState.importControlState(dumpSqliteControlState(), {
975
+ ...(statePath ? { legacyJsonPath: statePath } : {}),
976
+ ...(legacyJsonSha256 ? { legacyJsonSha256 } : {}),
977
+ storeMode: 'dual',
978
+ });
979
+ }
980
+ const activeMode = activeSqliteState.getStoreMode();
981
+ if (activeMode === 'dual' || activeMode === 'sqlite') {
982
+ if (activeSqliteState.isHistoryImported()) {
983
+ const sqliteHistory = activeSqliteState.loadHistoryState(
984
+ skipHistoryKeys,
985
+ getConversationHistorySerialOwner(),
986
+ );
987
+ loadPersistedConversationHistories(sqliteHistory.historyBuckets);
988
+ loadPersistedOutboundReplayCache(sqliteHistory.replayBuckets, {
989
+ historyMap: runtime.conversationHistories,
990
+ });
991
+ } else {
992
+ saveMergedSqliteHistoryState(activeSqliteState, skipHistoryKeys ?? []);
993
+ }
994
+ if (activeSqliteState.isOutboundImported()) {
995
+ applySqliteOutboundState(activeSqliteState.loadOutboundState());
996
+ activeSqliteState.saveOutboundState(
997
+ Array.from(runtime.outbox.values()),
998
+ runtime.getDeadLetter(),
999
+ );
1000
+ } else {
1001
+ activeSqliteState.importOutboundState(
1002
+ Array.from(runtime.outbox.values()),
1003
+ runtime.getDeadLetter(),
1004
+ );
1005
+ }
1006
+ }
1007
+ }
489
1008
  backfillAccountActivityFromSessionRoutes();
490
1009
  }
491
1010
 
492
1011
  async function flushState() {
493
1012
  const statePath = runtime.getStatePath();
494
- if (!statePath) return;
1013
+ const sqliteState = await resolveSqliteState(statePath);
1014
+ const sqliteMode = sqliteState?.getStoreMode();
1015
+ if (!statePath && !(sqliteMode === 'dual' || sqliteMode === 'sqlite')) return;
495
1016
 
496
- const data: BncrPersistedState = {
497
- outbox: Array.from(runtime.outbox.values()),
498
- deadLetter: runtime.getDeadLetter().slice(-runtime.maxDeadLetterEntries),
499
- sessionRoutes: dumpPersistedSessionRoutes(),
500
- sceneRegistry: dumpPersistedSceneRegistry(),
501
- conversationHistories: dumpPersistedConversationHistories(),
502
- outboundReplayCache: dumpPersistedOutboundReplayCache(),
503
- lastSessionByAccount: dumpPersistedLastSessionMap(),
504
- lastActivityByAccount: dumpPersistedAccountTimestampMap(runtime.lastActivityByAccount),
505
- lastInboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastInboundByAccount),
506
- lastOutboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastOutboundByAccount),
507
- lastDriftSnapshot: dumpRegisterDriftSnapshot(runtime.getLastDriftSnapshot() ?? null),
508
- };
1017
+ const data = buildPersistedStateSnapshot();
1018
+
1019
+ if (sqliteState && (sqliteMode === 'dual' || sqliteMode === 'sqlite')) {
1020
+ sqliteState.saveControlState(dumpSqliteControlState());
1021
+ const skipHistoryKeys = readConversationHistorySerialHistoryKeys();
1022
+ saveMergedSqliteHistoryState(sqliteState, skipHistoryKeys);
1023
+ if (sqliteState.isOutboundImported()) {
1024
+ sqliteState.saveOutboundState(Array.from(runtime.outbox.values()), runtime.getDeadLetter());
1025
+ } else {
1026
+ sqliteState.importOutboundState(
1027
+ Array.from(runtime.outbox.values()),
1028
+ runtime.getDeadLetter(),
1029
+ );
1030
+ }
1031
+ }
1032
+ if (statePath && sqliteMode !== 'sqlite') {
1033
+ await writeOpenClawJsonFileAtomically(statePath, data);
1034
+ }
1035
+ }
1036
+
1037
+ async function exportSqliteStateToJson(): Promise<BncrPersistedState> {
1038
+ const statePath = runtime.getStatePath();
1039
+ const sqlite = await resolveSqliteState(statePath);
1040
+ if (!sqlite) throw new Error('bncr sqlite export requires an active sqlite backend');
1041
+ const mode = sqlite.getStoreMode();
1042
+ if (mode !== 'dual' && mode !== 'sqlite') {
1043
+ throw new Error(`bncr sqlite export requires dual or sqlite mode, got ${String(mode)}`);
1044
+ }
1045
+ await flushState();
1046
+ await loadState();
1047
+ return buildPersistedStateSnapshot();
1048
+ }
1049
+
1050
+ async function cutoverToSqlite(): Promise<{ backupPath: string | null; storeMode: string }> {
1051
+ const statePath = runtime.getStatePath();
1052
+ const sqlite = await resolveSqliteState(statePath);
1053
+ if (!sqlite) throw new Error('bncr sqlite cutover requires an active sqlite backend');
1054
+ const mode = sqlite.getStoreMode();
1055
+ if (mode !== 'dual' && mode !== 'sqlite') {
1056
+ throw new Error(`bncr sqlite cutover requires dual or sqlite mode, got ${String(mode)}`);
1057
+ }
1058
+ if (
1059
+ !sqlite.isControlStateImported() ||
1060
+ !sqlite.isHistoryImported() ||
1061
+ !sqlite.isOutboundImported()
1062
+ ) {
1063
+ throw new Error('bncr sqlite cutover requires control, history, and outbound state imported');
1064
+ }
1065
+
1066
+ await flushState();
1067
+ const outbound = sqlite.loadOutboundState();
1068
+ if (outbound.outbox.length !== runtime.outbox.size) {
1069
+ throw new Error('bncr sqlite cutover outbox count mismatch');
1070
+ }
1071
+ if (outbound.deadLetter.length !== runtime.getDeadLetter().length) {
1072
+ throw new Error('bncr sqlite cutover dead-letter count mismatch');
1073
+ }
1074
+ const outstandingHistoryShards = sqlite.listHistoryShards();
1075
+ if (outstandingHistoryShards.length > 0) {
1076
+ throw new Error('bncr sqlite cutover requires no outstanding history shards');
1077
+ }
1078
+
1079
+ const timestamp = formatSqliteCutoverTimestamp(runtime.now());
1080
+ const computedBackupPath = statePath
1081
+ ? join(dirname(statePath), `${basename(statePath, '.json')}.pre-sqlite-${timestamp}.json`)
1082
+ : null;
1083
+ const backupPath =
1084
+ mode === 'sqlite'
1085
+ ? (sqlite.getMeta('sqlite_cutover_backup_path') ?? computedBackupPath)
1086
+ : computedBackupPath;
1087
+ if (statePath && mode !== 'sqlite') {
1088
+ try {
1089
+ await rename(statePath, backupPath!);
1090
+ sqlite.setMeta('sqlite_cutover_backup_path', backupPath!);
1091
+ } catch (error) {
1092
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
1093
+ }
1094
+ }
509
1095
 
510
- await writeOpenClawJsonFileAtomically(statePath, data);
1096
+ sqlite.setStoreMode('sqlite');
1097
+ sqlite.setMeta('sqlite_cutover_at', String(runtime.now()));
1098
+ return { backupPath, storeMode: sqlite.getStoreMode() ?? 'sqlite' };
511
1099
  }
512
1100
 
1101
+ const historyShardQueue: BncrHistoryShardQueue = {
1102
+ createHistoryShard,
1103
+ reconcileHistoryMemory: (historyKey) => reconcileHistoryMemoryWithSqlite(historyKey),
1104
+ claimNextHistoryShard: () => {
1105
+ if (!sqliteState) return null;
1106
+ sqliteState.cleanupCompletedHistoryShards();
1107
+ return (
1108
+ sqliteState.claimNextHistoryShard(
1109
+ readConversationHistorySerialHistoryKeys(),
1110
+ getConversationHistorySerialOwner(),
1111
+ ) ?? null
1112
+ );
1113
+ },
1114
+ completeHistoryShard: (shardId, owner) =>
1115
+ sqliteState?.completeHistoryShard(shardId, owner || getConversationHistorySerialOwner()),
1116
+ markHistoryShardProcessing: (shardId, owner) =>
1117
+ sqliteState?.markHistoryShardProcessing(
1118
+ shardId,
1119
+ owner || getConversationHistorySerialOwner(),
1120
+ ) ?? false,
1121
+ markHistoryShardFailed: (shardId, error, owner) => {
1122
+ const result = sqliteState?.markHistoryShardFailed(
1123
+ shardId,
1124
+ error,
1125
+ owner || getConversationHistorySerialOwner(),
1126
+ ) ?? {
1127
+ attempts: 0,
1128
+ terminal: false,
1129
+ };
1130
+ if (result.terminal && sqliteState) {
1131
+ const restored = sqliteState.restoreTerminalHistoryShardsToActive();
1132
+ loadPersistedHistoryBuckets(
1133
+ restored.historyBuckets,
1134
+ runtime.conversationHistories,
1135
+ resolvePersistedConversationHistoryLimit,
1136
+ { merge: true },
1137
+ );
1138
+ loadPersistedOutboundReplayCache(restored.replayBuckets, {
1139
+ merge: true,
1140
+ historyMap: runtime.conversationHistories,
1141
+ });
1142
+ }
1143
+ return result;
1144
+ },
1145
+ markHistoryShardCompleted: (shardId, owner) =>
1146
+ sqliteState?.markHistoryShardCompleted(
1147
+ shardId,
1148
+ owner || getConversationHistorySerialOwner(),
1149
+ ) ?? false,
1150
+ renewHistoryShardLease: (shardId, owner) =>
1151
+ sqliteState?.renewHistoryShardLease(shardId, owner || getConversationHistorySerialOwner()) ??
1152
+ false,
1153
+ };
1154
+ registerBncrHistoryShardQueue(runtime.conversationHistories, historyShardQueue);
1155
+
513
1156
  return {
514
1157
  loadPersistedAccountTimestampMap,
515
1158
  dumpPersistedAccountTimestampMap,
@@ -526,5 +1169,20 @@ export function createBncrStateStore(runtime: {
526
1169
  backfillAccountActivityFromSessionRoutes,
527
1170
  loadState,
528
1171
  flushState,
1172
+ exportSqliteStateToJson,
1173
+ cutoverToSqlite,
1174
+ createHistoryShard,
1175
+ completeHistoryShard,
1176
+ recoverInFlightHistoryShards: async (skipHistoryKeys?: readonly string[]) => {
1177
+ const statePath = runtime.getStatePath();
1178
+ const sqlite = await resolveSqliteState(statePath);
1179
+ return (
1180
+ sqlite?.recoverInFlightHistoryShards(
1181
+ skipHistoryKeys,
1182
+ getConversationHistorySerialOwner(),
1183
+ ) ?? 0
1184
+ );
1185
+ },
1186
+ historyShardQueue,
529
1187
  };
530
1188
  }