@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.
- package/README.md +65 -1
- package/package.json +1 -1
- package/scripts/check-pack.mjs +4 -1
- package/scripts/selfcheck.mjs +4 -1
- package/src/channel.ts +144 -5
- package/src/messaging/inbound/commands.ts +62 -26
- package/src/messaging/inbound/context-facts.ts +88 -58
- package/src/messaging/inbound/conversation-history-serial.ts +258 -0
- package/src/messaging/inbound/conversation-history.ts +450 -0
- package/src/messaging/inbound/dispatch.ts +568 -123
- package/src/messaging/inbound/history-shard-worker.ts +180 -0
- package/src/messaging/inbound/native-command.ts +89 -33
- package/src/messaging/inbound/outbound-replay-cache.ts +202 -17
- package/src/messaging/inbound/reply-dispatch.ts +7 -6
- package/src/messaging/inbound/scene-admin.ts +40 -20
- package/src/messaging/inbound/turn-context.ts +101 -37
- package/src/plugin/bridge-lifecycle.ts +8 -0
- package/src/plugin/bridge-outbox-facade.ts +15 -1
- package/src/plugin/channel-components.ts +5 -5
- package/src/plugin/channel-runtime-builders-delivery.ts +2 -2
- package/src/plugin/channel-runtime-constants.ts +2 -0
- package/src/plugin/channel-runtime-types.ts +9 -7
- package/src/plugin/inbound-acceptance.ts +9 -24
- package/src/plugin/inbound-handlers.ts +4 -4
- package/src/plugin/inbound-surface-handlers-group.ts +4 -2
- package/src/plugin/outbox-drain-post-push.ts +3 -0
- package/src/plugin/scene-registry.ts +4 -4
- package/src/plugin/sqlite-state.ts +2109 -0
- package/src/plugin/state-store.ts +731 -69
- package/src/plugin/state-transient-runtime-group.ts +17 -3
- 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
|
52
|
-
type
|
|
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
|
-
|
|
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
|
|
167
|
+
function resolvePersistedConversationHistoryLimit(key: string): number {
|
|
96
168
|
const scene = runtime.sceneRegistry.get(key);
|
|
97
|
-
const sceneLimit = scene?.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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 >=
|
|
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,
|
|
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
|
|
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:
|
|
285
|
+
const entries: BncrPersistedConversationHistoryEntry[] = [];
|
|
179
286
|
const rawEntries = Array.isArray(bucket?.entries)
|
|
180
|
-
? (bucket.entries as
|
|
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
|
-
|
|
294
|
+
let messageId = runtime.asString(entry?.messageId || '').trim();
|
|
188
295
|
|
|
189
|
-
const media:
|
|
296
|
+
const media: BncrPersistedConversationHistoryMediaEntry[] = [];
|
|
190
297
|
const rawMedia = Array.isArray(entry?.media)
|
|
191
|
-
? (entry.media as Partial<
|
|
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:
|
|
306
|
+
const normalizedMedia: BncrPersistedConversationHistoryMediaEntry = {
|
|
200
307
|
path,
|
|
201
308
|
...(contentType ? { contentType } : {}),
|
|
202
|
-
...(kind ? { kind: kind as
|
|
203
|
-
|
|
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
|
|
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.
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
234
|
-
|
|
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:
|
|
517
|
+
const media: BncrPersistedConversationHistoryMediaEntry[] = [];
|
|
265
518
|
const rawMedia = Array.isArray(entry?.media)
|
|
266
|
-
? (entry.media as Partial<
|
|
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
|
|
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
|
-
...(
|
|
289
|
-
...(
|
|
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
|
-
|
|
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,
|
|
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
|
|
318
|
-
return dumpPersistedHistoryBuckets(runtime.
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
478
|
-
|
|
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
|
-
|
|
1013
|
+
const sqliteState = await resolveSqliteState(statePath);
|
|
1014
|
+
const sqliteMode = sqliteState?.getStoreMode();
|
|
1015
|
+
if (!statePath && !(sqliteMode === 'dual' || sqliteMode === 'sqlite')) return;
|
|
491
1016
|
|
|
492
|
-
const data
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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
|
}
|