@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
@@ -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,
@@ -14,9 +28,9 @@ import {
14
28
  import type {
15
29
  BncrGroupReplyMode,
16
30
  BncrPersistedAccountTimestamp,
17
- BncrPersistedGroupHistoryBucket,
18
- BncrPersistedGroupHistoryEntry,
19
- BncrPersistedGroupHistoryMediaEntry,
31
+ BncrPersistedConversationHistoryBucket,
32
+ BncrPersistedConversationHistoryEntry,
33
+ BncrPersistedConversationHistoryMediaEntry,
20
34
  BncrPersistedLastSession,
21
35
  BncrPersistedOutboundReplayBucket,
22
36
  BncrPersistedOutboundReplayEntry,
@@ -24,15 +38,25 @@ 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
- const DEFAULT_PERSISTED_GROUP_HISTORY_LIMIT = 50;
52
+ const DEFAULT_PERSISTED_CONVERSATION_HISTORY_LIMIT = 50;
30
53
 
31
54
  type BncrPersistedStateStoreInput = {
32
55
  outbox?: unknown;
33
56
  deadLetter?: unknown;
34
57
  sessionRoutes?: unknown;
35
58
  sceneRegistry?: unknown;
59
+ conversationHistories?: unknown;
36
60
  groupHistories?: unknown;
37
61
  outboundReplayCache?: unknown;
38
62
  // Legacy persisted key from before the outboundReplayCache rename.
@@ -48,11 +72,44 @@ type PersistedAccountTimestampInput = Partial<BncrPersistedAccountTimestamp>;
48
72
  type PersistedLastSessionInput = Partial<BncrPersistedLastSession>;
49
73
  type PersistedSessionRouteInput = Partial<BncrPersistedSessionRoute>;
50
74
  type PersistedSceneRecordInput = Partial<BncrSceneRecord>;
51
- type PersistedGroupHistoryBucketInput = Partial<BncrPersistedGroupHistoryBucket>;
52
- type PersistedGroupHistoryEntryInput = Partial<BncrPersistedGroupHistoryEntry>;
75
+ type PersistedConversationHistoryBucketInput = Partial<BncrPersistedConversationHistoryBucket>;
76
+ type PersistedConversationHistoryEntryInput = Partial<BncrPersistedConversationHistoryEntry>;
53
77
  type PersistedOutboundReplayBucketInput = Partial<BncrPersistedOutboundReplayBucket>;
54
78
  type PersistedOutboundReplayEntryInput = Partial<BncrOutboundReplayEntry>;
55
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
+
56
113
  export function createBncrStateStore(runtime: {
57
114
  getStatePath: () => string | null;
58
115
  now: () => number;
@@ -75,7 +132,7 @@ export function createBncrStateStore(runtime: {
75
132
  maxSessionRouteEntries: number;
76
133
  maxAccountActivityEntries: number;
77
134
  sceneRegistry: Map<string, BncrSceneRecord>;
78
- groupHistories: Map<string, BncrPersistedGroupHistoryEntry[]>;
135
+ conversationHistories: Map<string, BncrPersistedConversationHistoryEntry[]>;
79
136
  outboundReplayCache?: Map<string, BncrPersistedOutboundReplayEntry[]>;
80
137
  outbox: Map<string, OutboxEntry>;
81
138
  getDeadLetter: () => OutboxEntry[];
@@ -88,20 +145,65 @@ export function createBncrStateStore(runtime: {
88
145
  lastOutboundByAccount: Map<string, number>;
89
146
  getLastDriftSnapshot: () => BncrPersistedState['lastDriftSnapshot'];
90
147
  setLastDriftSnapshot: (value: BncrPersistedState['lastDriftSnapshot']) => void;
148
+ sqliteState?: BncrSqliteStateDatabase;
149
+ createSqliteState?: (
150
+ statePath: string | null,
151
+ ) => BncrSqliteStateDatabase | PromiseLike<BncrSqliteStateDatabase | null> | null;
91
152
  }) {
92
153
  const outboundReplayCache =
93
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
+ }
94
166
 
95
- function resolvePersistedGroupHistoryLimit(key: string): number {
167
+ function resolvePersistedConversationHistoryLimit(key: string): number {
96
168
  const scene = runtime.sceneRegistry.get(key);
97
- const sceneLimit = scene?.kind === 'group' ? scene.historyLimit : undefined;
98
- if (typeof sceneLimit === 'number' && Number.isFinite(sceneLimit) && sceneLimit >= 0) {
99
- return Math.max(
100
- DEFAULT_PERSISTED_GROUP_HISTORY_LIMIT,
101
- Math.ceil(Math.floor(sceneLimit) * 1.2),
102
- );
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
+ });
103
184
  }
104
- return DEFAULT_PERSISTED_GROUP_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));
105
207
  }
106
208
 
107
209
  function loadPersistedSceneRegistry(persisted: unknown): void {
@@ -150,10 +252,11 @@ export function createBncrStateStore(runtime: {
150
252
  : {}),
151
253
  ...(typeof item.historyLimit === 'number' &&
152
254
  Number.isFinite(item.historyLimit) &&
153
- item.historyLimit >= 0
255
+ Math.floor(item.historyLimit) >= 2
154
256
  ? { historyLimit: Math.floor(item.historyLimit) }
155
257
  : {}),
156
258
  ...(typeof item.historyForce === 'boolean' ? { historyForce: item.historyForce } : {}),
259
+ ...(typeof item.downloadMedia === 'boolean' ? { downloadMedia: item.downloadMedia } : {}),
157
260
  lastSeenAt,
158
261
  });
159
262
  }
@@ -165,30 +268,34 @@ export function createBncrStateStore(runtime: {
165
268
 
166
269
  function loadPersistedHistoryBuckets(
167
270
  persisted: unknown,
168
- target: Map<string, BncrPersistedGroupHistoryEntry[]>,
271
+ target: Map<string, BncrPersistedConversationHistoryEntry[]>,
169
272
  resolveLimit: (key: string) => number,
273
+ options?: {
274
+ assistantMessageIdsByHistoryKey?: ReadonlyMap<string, ReadonlySet<string>>;
275
+ merge?: boolean;
276
+ },
170
277
  ): void {
171
- target.clear();
278
+ if (!options?.merge) target.clear();
172
279
  const buckets = Array.isArray(persisted)
173
- ? (persisted as PersistedGroupHistoryBucketInput[])
280
+ ? (persisted as PersistedConversationHistoryBucketInput[])
174
281
  : [];
175
282
  for (const bucket of buckets) {
176
283
  const key = runtime.asString(bucket?.key || '').trim();
177
284
  if (!key) continue;
178
- const entries: BncrPersistedGroupHistoryEntry[] = [];
285
+ const entries: BncrPersistedConversationHistoryEntry[] = [];
179
286
  const rawEntries = Array.isArray(bucket?.entries)
180
- ? (bucket.entries as PersistedGroupHistoryEntryInput[])
287
+ ? (bucket.entries as PersistedConversationHistoryEntryInput[])
181
288
  : [];
182
- for (const entry of rawEntries) {
289
+ for (const [entryIndex, entry] of rawEntries.entries()) {
183
290
  const sender = runtime.asString(entry?.sender || '').trim();
184
291
  const body = runtime.asString(entry?.body || '').trim();
185
292
  if (!sender || !body) continue;
186
293
  const timestamp = runtime.finiteNumberOr(entry?.timestamp, 0);
187
- const messageId = runtime.asString(entry?.messageId || '').trim();
294
+ let messageId = runtime.asString(entry?.messageId || '').trim();
188
295
 
189
- const media: BncrPersistedGroupHistoryMediaEntry[] = [];
296
+ const media: BncrPersistedConversationHistoryMediaEntry[] = [];
190
297
  const rawMedia = Array.isArray(entry?.media)
191
- ? (entry.media as Partial<BncrPersistedGroupHistoryMediaEntry>[])
298
+ ? (entry.media as Partial<BncrPersistedConversationHistoryMediaEntry>[])
192
299
  : [];
193
300
  for (const item of rawMedia) {
194
301
  const path = runtime.asString(item?.path || '').trim();
@@ -196,42 +303,188 @@ export function createBncrStateStore(runtime: {
196
303
  const contentType = runtime.asString(item?.contentType || '').trim();
197
304
  const kind = runtime.asString(item?.kind || '').trim();
198
305
  const mediaMessageId = runtime.asString(item?.messageId || '').trim();
199
- const normalizedMedia: BncrPersistedGroupHistoryMediaEntry = {
306
+ const normalizedMedia: BncrPersistedConversationHistoryMediaEntry = {
200
307
  path,
201
308
  ...(contentType ? { contentType } : {}),
202
- ...(kind ? { kind: kind as BncrPersistedGroupHistoryMediaEntry['kind'] } : {}),
203
- ...(mediaMessageId ? { messageId: mediaMessageId } : {}),
309
+ ...(kind ? { kind: kind as BncrPersistedConversationHistoryMediaEntry['kind'] } : {}),
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 } : {}),
204
313
  };
205
314
  media.push(normalizedMedia);
206
315
  }
207
316
 
208
- const normalizedEntry: BncrPersistedGroupHistoryEntry = {
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
+
340
+ const normalizedEntry: BncrPersistedConversationHistoryEntry = {
209
341
  sender,
210
342
  ...(runtime.asString(entry?.senderId || '').trim()
211
343
  ? { senderId: runtime.asString(entry?.senderId || '').trim() }
212
344
  : {}),
345
+ role,
213
346
  body,
214
347
  ...(timestamp > 0 ? { timestamp } : {}),
215
348
  ...(messageId ? { messageId } : {}),
216
349
  ...(media.length > 0 ? { media } : {}),
217
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
+ }
218
365
  entries.push(normalizedEntry);
219
366
  }
220
367
  if (entries.length > 0) {
221
368
  const limit = resolveLimit(key);
222
- 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);
223
379
  }
224
380
  }
225
381
  }
226
382
 
227
- function loadPersistedGroupHistories(persisted: unknown): void {
228
- loadPersistedHistoryBuckets(persisted, runtime.groupHistories, (key) =>
229
- resolvePersistedGroupHistoryLimit(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,
230
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;
231
433
  }
232
434
 
233
- function loadPersistedOutboundReplayCache(persisted: unknown): void {
234
- 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();
235
488
  const buckets = Array.isArray(persisted)
236
489
  ? (persisted as PersistedOutboundReplayBucketInput[])
237
490
  : [];
@@ -261,9 +514,9 @@ export function createBncrStateStore(runtime: {
261
514
  groupId: entry?.route?.groupId,
262
515
  userId: entry?.route?.userId,
263
516
  });
264
- const media: BncrPersistedGroupHistoryMediaEntry[] = [];
517
+ const media: BncrPersistedConversationHistoryMediaEntry[] = [];
265
518
  const rawMedia = Array.isArray(entry?.media)
266
- ? (entry.media as Partial<BncrPersistedGroupHistoryMediaEntry>[])
519
+ ? (entry.media as Partial<BncrPersistedConversationHistoryMediaEntry>[])
267
520
  : [];
268
521
  for (const item of rawMedia) {
269
522
  const path = runtime.asString(item?.path || '').trim();
@@ -274,10 +527,28 @@ export function createBncrStateStore(runtime: {
274
527
  media.push({
275
528
  path,
276
529
  ...(contentType ? { contentType } : {}),
277
- ...(kind ? { kind: kind as BncrPersistedGroupHistoryMediaEntry['kind'] } : {}),
530
+ ...(kind ? { kind: kind as BncrPersistedConversationHistoryMediaEntry['kind'] } : {}),
278
531
  ...(mediaMessageId ? { messageId: mediaMessageId } : {}),
279
532
  });
280
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
+ }));
281
552
  entries.push({
282
553
  sender,
283
554
  ...(runtime.asString(entry?.senderId || '').trim()
@@ -285,8 +556,8 @@ export function createBncrStateStore(runtime: {
285
556
  : {}),
286
557
  body,
287
558
  ...(timestamp > 0 ? { timestamp } : {}),
288
- ...(messageId ? { messageId } : {}),
289
- ...(media.length > 0 ? { media } : {}),
559
+ ...(resolvedMessageId ? { messageId: resolvedMessageId } : {}),
560
+ ...(normalizedMedia.length > 0 ? { media: normalizedMedia } : {}),
290
561
  ...(accountId ? { accountId } : {}),
291
562
  ...(sessionKey ? { sessionKey } : {}),
292
563
  ...(route ? { route } : {}),
@@ -299,13 +570,24 @@ export function createBncrStateStore(runtime: {
299
570
  });
300
571
  }
301
572
  if (entries.length > 0) {
302
- 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
+ }
303
585
  }
304
586
  }
305
587
  }
306
588
 
307
589
  function dumpPersistedHistoryBuckets(
308
- source: Map<string, BncrPersistedGroupHistoryEntry[]>,
590
+ source: Map<string, BncrPersistedConversationHistoryEntry[]>,
309
591
  resolveLimit: (key: string) => number,
310
592
  ) {
311
593
  return Array.from(source.entries()).map(([key, entries]) => ({
@@ -314,19 +596,109 @@ export function createBncrStateStore(runtime: {
314
596
  }));
315
597
  }
316
598
 
317
- function dumpPersistedGroupHistories() {
318
- return dumpPersistedHistoryBuckets(runtime.groupHistories, (key) =>
319
- resolvePersistedGroupHistoryLimit(key),
599
+ function dumpPersistedConversationHistories() {
600
+ return dumpPersistedHistoryBuckets(runtime.conversationHistories, (key) =>
601
+ resolvePersistedConversationHistoryLimit(key),
320
602
  );
321
603
  }
322
604
 
323
605
  function dumpPersistedOutboundReplayCache() {
324
606
  return Array.from(outboundReplayCache.entries()).map(([key, entries]) => ({
325
607
  key,
326
- entries,
608
+ entries: normalizeOutboundReplayBucket(key, entries),
327
609
  }));
328
610
  }
329
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),
618
+ }));
619
+ }
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
+
330
702
  function loadPersistedAccountTimestampMap(target: Map<string, number>, persisted: unknown): void {
331
703
  target.clear();
332
704
  const items = Array.isArray(persisted)
@@ -444,14 +816,104 @@ export function createBncrStateStore(runtime: {
444
816
  }
445
817
  }
446
818
 
447
- 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[]) {
448
895
  const statePath = runtime.getStatePath();
449
- if (!statePath) return;
450
- 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 = {
451
909
  outbox: [],
452
910
  deadLetter: [],
453
911
  sessionRoutes: [],
454
- });
912
+ };
913
+ const loaded =
914
+ statePath && activeSqliteState?.getStoreMode() !== 'sqlite'
915
+ ? await readOpenClawJsonFileWithFallback(statePath, defaults)
916
+ : { value: defaults };
455
917
  const data = loaded.value as BncrPersistedStateStoreInput;
456
918
 
457
919
  runtime.outbox.clear();
@@ -474,38 +936,223 @@ export function createBncrStateStore(runtime: {
474
936
 
475
937
  loadPersistedSessionRoutes(data.sessionRoutes);
476
938
  loadPersistedSceneRegistry(data.sceneRegistry);
477
- loadPersistedGroupHistories(data.groupHistories);
478
- 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
+ });
479
947
  loadPersistedLastSessionMap(data.lastSessionByAccount);
480
948
  loadPersistedAccountTimestampMap(runtime.lastActivityByAccount, data.lastActivityByAccount);
481
949
  loadPersistedAccountTimestampMap(runtime.lastInboundByAccount, data.lastInboundByAccount);
482
950
  loadPersistedAccountTimestampMap(runtime.lastOutboundByAccount, data.lastOutboundByAccount);
483
951
 
484
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
+ }
485
1008
  backfillAccountActivityFromSessionRoutes();
486
1009
  }
487
1010
 
488
1011
  async function flushState() {
489
1012
  const statePath = runtime.getStatePath();
490
- if (!statePath) return;
1013
+ const sqliteState = await resolveSqliteState(statePath);
1014
+ const sqliteMode = sqliteState?.getStoreMode();
1015
+ if (!statePath && !(sqliteMode === 'dual' || sqliteMode === 'sqlite')) return;
491
1016
 
492
- const data: BncrPersistedState = {
493
- outbox: Array.from(runtime.outbox.values()),
494
- deadLetter: runtime.getDeadLetter().slice(-runtime.maxDeadLetterEntries),
495
- sessionRoutes: dumpPersistedSessionRoutes(),
496
- sceneRegistry: dumpPersistedSceneRegistry(),
497
- groupHistories: dumpPersistedGroupHistories(),
498
- outboundReplayCache: dumpPersistedOutboundReplayCache(),
499
- lastSessionByAccount: dumpPersistedLastSessionMap(),
500
- lastActivityByAccount: dumpPersistedAccountTimestampMap(runtime.lastActivityByAccount),
501
- lastInboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastInboundByAccount),
502
- lastOutboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastOutboundByAccount),
503
- lastDriftSnapshot: dumpRegisterDriftSnapshot(runtime.getLastDriftSnapshot() ?? null),
504
- };
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
+ }
505
1036
 
506
- await writeOpenClawJsonFileAtomically(statePath, data);
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();
507
1048
  }
508
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
+ }
1095
+
1096
+ sqlite.setStoreMode('sqlite');
1097
+ sqlite.setMeta('sqlite_cutover_at', String(runtime.now()));
1098
+ return { backupPath, storeMode: sqlite.getStoreMode() ?? 'sqlite' };
1099
+ }
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
+
509
1156
  return {
510
1157
  loadPersistedAccountTimestampMap,
511
1158
  dumpPersistedAccountTimestampMap,
@@ -515,12 +1162,27 @@ export function createBncrStateStore(runtime: {
515
1162
  dumpPersistedSessionRoutes,
516
1163
  loadPersistedSceneRegistry,
517
1164
  dumpPersistedSceneRegistry,
518
- loadPersistedGroupHistories,
519
- dumpPersistedGroupHistories,
1165
+ loadPersistedConversationHistories,
1166
+ dumpPersistedConversationHistories,
520
1167
  loadPersistedOutboundReplayCache,
521
1168
  dumpPersistedOutboundReplayCache,
522
1169
  backfillAccountActivityFromSessionRoutes,
523
1170
  loadState,
524
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,
525
1187
  };
526
1188
  }