@xmoxmo/bncr 0.6.2 → 0.6.4

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.
@@ -0,0 +1,258 @@
1
+ import { emitBncrLogLine } from '../../core/logging.ts';
2
+
3
+ export type ConversationHistorySerialPhase =
4
+ | 'queued'
5
+ | 'running'
6
+ | 'snapshot'
7
+ | 'shard_created'
8
+ | 'upload_start'
9
+ | 'upload_end'
10
+ | 'cache_delete_start'
11
+ | 'cache_delete_done'
12
+ | 'lock_clear_start'
13
+ | 'lock_clear_done'
14
+ | 'abandoned';
15
+
16
+ export type ConversationHistorySerialState = {
17
+ historyKey: string;
18
+ owner?: string;
19
+ phase: ConversationHistorySerialPhase;
20
+ updatedAt: number;
21
+ snapshotMessageIds: string[];
22
+ cacheKey?: string;
23
+ uploadCompleted: boolean;
24
+ abandoned: boolean;
25
+ };
26
+
27
+ export type ConversationHistorySerialHandle = {
28
+ phase: (
29
+ phase: ConversationHistorySerialPhase,
30
+ data?: { snapshotMessageIds?: string[]; cacheKey?: string },
31
+ ) => void;
32
+ setCleanup: (cleanup: () => void) => void;
33
+ isAbandoned: () => boolean;
34
+ owner: () => string;
35
+ };
36
+
37
+ type BncrConversationHistorySerialEntry = {
38
+ chain: Promise<void>;
39
+ abort: (reason?: unknown) => void;
40
+ state: ConversationHistorySerialState;
41
+ cleanup?: () => void;
42
+ };
43
+
44
+ const bncrConversationHistoryChains = new Map<string, BncrConversationHistorySerialEntry[]>();
45
+ let bncrConversationHistorySerialOwner = 'default';
46
+
47
+ export function setConversationHistorySerialOwner(owner: string): void {
48
+ bncrConversationHistorySerialOwner = String(owner || '').trim() || 'default';
49
+ }
50
+
51
+ export function getConversationHistorySerialOwner(): string {
52
+ return bncrConversationHistorySerialOwner;
53
+ }
54
+
55
+ function createConversationHistorySerialHandle(entry?: BncrConversationHistorySerialEntry) {
56
+ return {
57
+ phase: (
58
+ phase: ConversationHistorySerialPhase,
59
+ data?: { snapshotMessageIds?: string[]; cacheKey?: string },
60
+ ) => {
61
+ if (!entry) return;
62
+ entry.state.phase = phase;
63
+ entry.state.updatedAt = Date.now();
64
+ if (data?.snapshotMessageIds) {
65
+ entry.state.snapshotMessageIds = data.snapshotMessageIds
66
+ .map((messageId) => String(messageId || '').trim())
67
+ .filter(Boolean);
68
+ }
69
+ if (data?.cacheKey) entry.state.cacheKey = data.cacheKey;
70
+ if (phase === 'upload_end') entry.state.uploadCompleted = true;
71
+ },
72
+ setCleanup: (cleanup: () => void) => {
73
+ if (entry) entry.cleanup = cleanup;
74
+ },
75
+ isAbandoned: () => {
76
+ if (!entry) return false;
77
+ return (
78
+ entry.state.abandoned === true ||
79
+ (Boolean(entry.state.owner) && entry.state.owner !== bncrConversationHistorySerialOwner)
80
+ );
81
+ },
82
+ owner: () => entry?.state.owner || bncrConversationHistorySerialOwner,
83
+ };
84
+ }
85
+
86
+ function isUploadCompletedPhase(state: ConversationHistorySerialState): boolean {
87
+ return state.uploadCompleted === true;
88
+ }
89
+
90
+ export async function runConversationHistorySerial<T>(
91
+ historyKey: string,
92
+ task: (handle: ConversationHistorySerialHandle) => Promise<T> | T,
93
+ meta?: { to?: string | null; debugEnabled?: boolean; owner?: string },
94
+ ) {
95
+ const key = String(historyKey || '').trim();
96
+ if (!key) return await task(createConversationHistorySerialHandle());
97
+
98
+ const serialEntries = bncrConversationHistoryChains.get(key) || [];
99
+ const previous = serialEntries.at(-1)?.chain || Promise.resolve();
100
+ // A task with no predecessor has already acquired the serial slot even before
101
+ // its first microtask runs. Stale-lock cleanup must be able to distinguish
102
+ // that active slot from genuinely queued work behind an earlier lock.
103
+ const hasPredecessor = serialEntries.length > 0;
104
+ let release!: () => void;
105
+ let abort!: (reason?: unknown) => void;
106
+ const current = new Promise<void>((resolve, reject) => {
107
+ release = resolve;
108
+ abort = reject;
109
+ });
110
+ const chain = previous.then(
111
+ () => current,
112
+ () => current,
113
+ );
114
+ const serialEntry: BncrConversationHistorySerialEntry = {
115
+ chain,
116
+ abort,
117
+ state: {
118
+ historyKey: key,
119
+ owner: meta?.owner || bncrConversationHistorySerialOwner,
120
+ phase: hasPredecessor ? 'queued' : 'running',
121
+ updatedAt: Date.now(),
122
+ snapshotMessageIds: [],
123
+ uploadCompleted: false,
124
+ abandoned: false,
125
+ },
126
+ };
127
+ const handle = createConversationHistorySerialHandle(serialEntry);
128
+ serialEntries.push(serialEntry);
129
+ bncrConversationHistoryChains.set(key, serialEntries);
130
+
131
+ const debugGate = () => meta?.debugEnabled === true;
132
+ emitBncrLogLine(
133
+ 'info',
134
+ `[bncr] conversation-history-serial queued|key=${key}|to=${String(meta?.to || '-')}`,
135
+ { debugOnly: true },
136
+ debugGate,
137
+ );
138
+ try {
139
+ await previous.catch(() => {});
140
+ emitBncrLogLine(
141
+ 'info',
142
+ `[bncr] conversation-history-serial acquired|key=${key}|to=${String(meta?.to || '-')}`,
143
+ { debugOnly: true },
144
+ debugGate,
145
+ );
146
+ if (serialEntry.state.abandoned) {
147
+ return undefined as T;
148
+ }
149
+ serialEntry.state.phase = 'running';
150
+ serialEntry.state.updatedAt = Date.now();
151
+ return await task(handle);
152
+ } finally {
153
+ emitBncrLogLine(
154
+ 'info',
155
+ `[bncr] conversation-history-serial lock-clear-start|key=${key}|phase=${serialEntry.state.phase}`,
156
+ { debugOnly: true },
157
+ debugGate,
158
+ );
159
+ serialEntry.state.phase = 'lock_clear_start';
160
+ serialEntry.state.updatedAt = Date.now();
161
+ release();
162
+ serialEntry.state.phase = 'lock_clear_done';
163
+ serialEntry.state.updatedAt = Date.now();
164
+ const currentEntries = bncrConversationHistoryChains.get(key);
165
+ if (currentEntries) {
166
+ const entryIndex = currentEntries.indexOf(serialEntry);
167
+ if (entryIndex >= 0) currentEntries.splice(entryIndex, 1);
168
+ if (currentEntries.length === 0) bncrConversationHistoryChains.delete(key);
169
+ }
170
+ emitBncrLogLine(
171
+ 'info',
172
+ `[bncr] conversation-history-serial lock-clear-done|key=${key}|phase=${serialEntry.state.phase}`,
173
+ { debugOnly: true },
174
+ debugGate,
175
+ );
176
+ }
177
+ }
178
+
179
+ export function clearConversationHistorySerialLocks(reason = 'runtime-restart', owner?: string) {
180
+ bncrConversationHistorySerialOwner =
181
+ owner || `${reason}:${Date.now()}:${Math.random().toString(16).slice(2, 8)}`;
182
+ const staleEntries = Array.from(bncrConversationHistoryChains.values()).flat();
183
+ const staleCount = staleEntries.length;
184
+ for (const entry of staleEntries) {
185
+ if (entry.state.phase === 'queued') {
186
+ // Queued work is intentionally preserved after a hot rebuild; give it
187
+ // the new instance owner so it is not treated as stale when it runs.
188
+ entry.state.owner = bncrConversationHistorySerialOwner;
189
+ }
190
+ // Upload-completed locks can safely finish snapshot cleanup before being abandoned.
191
+ // Locks still uploading cannot prove delivery, so their local cache is preserved.
192
+ if (isUploadCompletedPhase(entry.state) && entry.cleanup) {
193
+ try {
194
+ entry.cleanup();
195
+ } catch (err) {
196
+ emitBncrLogLine(
197
+ 'warn',
198
+ `[bncr] conversation-history-serial stale cleanup failed|key=${entry.state.historyKey}|reason=${String(err)}`,
199
+ );
200
+ }
201
+ }
202
+ // Started locks are abandoned but remain in the chain as a barrier. A hot
203
+ // rebuild must not let new tasks run while the old upload still owns state;
204
+ // once the old task releases its lock it leaves the map and new work runs.
205
+ if (entry.state.phase !== 'queued') {
206
+ entry.state.abandoned = true;
207
+ entry.state.phase = 'abandoned';
208
+ entry.state.updatedAt = Date.now();
209
+ }
210
+ }
211
+ // Do not clear the chain map here. Abandoned started entries stay visible to
212
+ // readConversationHistorySerialHistoryKeys so shard recovery and the worker
213
+ // will not race them.
214
+ if (staleCount > 0) {
215
+ emitBncrLogLine(
216
+ 'warn',
217
+ `[bncr] conversation-history-serial settled stale locks|count=${staleCount}|reason=${reason}`,
218
+ );
219
+ }
220
+ return staleCount;
221
+ }
222
+
223
+ export function readConversationHistorySerialStates(): readonly ConversationHistorySerialState[] {
224
+ return Array.from(bncrConversationHistoryChains.values()).flatMap((entries) =>
225
+ entries.map((entry) => ({
226
+ historyKey: entry.state.historyKey,
227
+ ...(entry.state.owner ? { owner: entry.state.owner } : {}),
228
+ phase: entry.state.phase,
229
+ updatedAt: entry.state.updatedAt,
230
+ snapshotMessageIds: [...entry.state.snapshotMessageIds],
231
+ ...(entry.state.cacheKey ? { cacheKey: entry.state.cacheKey } : {}),
232
+ uploadCompleted: entry.state.uploadCompleted,
233
+ abandoned: entry.state.abandoned,
234
+ })),
235
+ );
236
+ }
237
+
238
+ export function readConversationHistorySerialHistoryKeys(): string[] {
239
+ return Array.from(bncrConversationHistoryChains.keys()).filter(
240
+ (historyKey) => (bncrConversationHistoryChains.get(historyKey)?.length ?? 0) > 0,
241
+ );
242
+ }
243
+
244
+ export function resetConversationHistorySerialForTest() {
245
+ setConversationHistorySerialOwner('default');
246
+ clearConversationHistorySerialLocks('test');
247
+ // Tests must never inherit queued chains from an earlier test. Runtime
248
+ // restarts deliberately keep queued work in the serial chain, so only the
249
+ // test helper fully abandons those entries as well.
250
+ for (const entry of Array.from(bncrConversationHistoryChains.values()).flat()) {
251
+ entry.state.abandoned = true;
252
+ entry.state.phase = 'abandoned';
253
+ entry.state.updatedAt = Date.now();
254
+ entry.chain.catch(() => {});
255
+ entry.abort(new Error('conversation-history-serial test reset'));
256
+ }
257
+ bncrConversationHistoryChains.clear();
258
+ }
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import {
2
3
  createChannelHistoryWindow,
3
4
  DEFAULT_GROUP_HISTORY_LIMIT as DEFAULT_HISTORY_LIMIT,
@@ -17,12 +18,75 @@ export type BncrConversationHistoryMap = Map<string, BncrHistoryEntry[]>;
17
18
 
18
19
  type BncrHistoryMediaKind = NonNullable<HistoryMediaEntry['kind']>;
19
20
 
21
+ const conversationHistoryVersions = new WeakMap<BncrConversationHistoryMap, Map<string, number>>();
22
+
23
+ function buildSyntheticHistoryMessageId(seed: string): string {
24
+ const digest = createHash('sha1').update(seed).digest('hex').slice(0, 24);
25
+ return `bncr-synthetic:${digest}`;
26
+ }
27
+
28
+ export function buildBncrBotReplyMessageId(args: {
29
+ historyKey: string;
30
+ sender: string;
31
+ senderId?: string;
32
+ body: string;
33
+ timestamp?: number;
34
+ messageId?: string;
35
+ media?: HistoryMediaEntry[];
36
+ }): string {
37
+ const provided = String(args.messageId || '').trim();
38
+ if (provided) return provided;
39
+ return buildSyntheticHistoryMessageId(
40
+ JSON.stringify({
41
+ historyKey: args.historyKey,
42
+ sender: args.sender,
43
+ senderId: args.senderId,
44
+ body: args.body,
45
+ timestamp: args.timestamp,
46
+ media: args.media?.map((item) => [item.path, item.contentType, item.kind, item.messageId]),
47
+ }),
48
+ );
49
+ }
50
+
51
+ function bumpConversationHistoryVersion(
52
+ historyMap: BncrConversationHistoryMap,
53
+ historyKey: string,
54
+ ): number {
55
+ let versions = conversationHistoryVersions.get(historyMap);
56
+ if (!versions) {
57
+ versions = new Map();
58
+ conversationHistoryVersions.set(historyMap, versions);
59
+ }
60
+ const next = (versions.get(historyKey) || 0) + 1;
61
+ versions.set(historyKey, next);
62
+ return next;
63
+ }
64
+
65
+ export function readConversationHistoryVersion(
66
+ historyMap: BncrConversationHistoryMap,
67
+ historyKey: string,
68
+ ): number {
69
+ return conversationHistoryVersions.get(historyMap)?.get(historyKey) ?? 0;
70
+ }
71
+
72
+ export function resetConversationHistoryVersions(historyMap: BncrConversationHistoryMap): void {
73
+ conversationHistoryVersions.delete(historyMap);
74
+ }
75
+
20
76
  function normalizeTextBody(value: string): string {
21
77
  return String(value || '')
22
78
  .replace(/\s+/g, ' ')
23
79
  .trim();
24
80
  }
25
81
 
82
+ export function resolveBncrHistoryLimit(historyLimit?: number): number {
83
+ if (typeof historyLimit !== 'number' || !Number.isFinite(historyLimit)) {
84
+ return DEFAULT_HISTORY_LIMIT;
85
+ }
86
+ const resolved = Math.floor(historyLimit);
87
+ return resolved >= 2 ? resolved : DEFAULT_HISTORY_LIMIT;
88
+ }
89
+
26
90
  export function buildBncrConversationHistoryKey(parsed: ParsedInbound): string | null {
27
91
  const platform = String(parsed.platform || '').trim();
28
92
  if (!platform) return null;
@@ -50,6 +114,11 @@ export function buildBncrConversationHistoryKeyFromRoute(args: {
50
114
  return null;
51
115
  }
52
116
 
117
+ export function resolveBncrConversationHistoryMessageId(parsed: ParsedInbound): string | undefined {
118
+ const msgId = String(parsed.msgId || '').trim();
119
+ return msgId || undefined;
120
+ }
121
+
53
122
  export function recordBncrPendingConversationText(args: {
54
123
  historyMap: BncrConversationHistoryMap;
55
124
  parsed: ParsedInbound;
@@ -58,19 +127,14 @@ export function recordBncrPendingConversationText(args: {
58
127
  bodyText: string;
59
128
  historyLimit?: number;
60
129
  }) {
61
- const limit =
62
- typeof args.historyLimit === 'number' &&
63
- Number.isFinite(args.historyLimit) &&
64
- args.historyLimit >= 0
65
- ? Math.floor(args.historyLimit)
66
- : DEFAULT_HISTORY_LIMIT;
130
+ const limit = resolveBncrHistoryLimit(args.historyLimit);
67
131
  const historyKey = buildBncrConversationHistoryKey(args.parsed);
68
132
  const body = normalizeTextBody(args.bodyText);
69
133
  if (!historyKey || !body || args.parsed.msgType !== 'text') return;
70
- const msgId = String(args.parsed.msgId || '').trim();
71
- if (msgId) {
134
+ const messageId = resolveBncrConversationHistoryMessageId(args.parsed);
135
+ if (messageId) {
72
136
  const existing = args.historyMap.get(historyKey) || [];
73
- if (existing.some((e) => e.messageId === msgId)) return;
137
+ if (existing.some((e) => e.messageId === messageId)) return;
74
138
  }
75
139
  createChannelHistoryWindow({ historyMap: args.historyMap }).record({
76
140
  historyKey,
@@ -80,10 +144,11 @@ export function recordBncrPendingConversationText(args: {
80
144
  senderId: args.senderId,
81
145
  body,
82
146
  timestamp: Date.now(),
83
- messageId: args.parsed.msgId,
147
+ ...(messageId ? { messageId } : {}),
84
148
  role: 'user',
85
149
  },
86
150
  });
151
+ bumpConversationHistoryVersion(args.historyMap, historyKey);
87
152
  }
88
153
 
89
154
  function inferBncrHistoryMediaKind(args: {
@@ -109,7 +174,7 @@ function buildBncrHistoryMediaBody(kind: BncrHistoryMediaKind): string {
109
174
  return `<media:${kind}>`;
110
175
  }
111
176
 
112
- export async function recordBncrPendingConversationMedia(args: {
177
+ export function recordBncrPendingConversationMedia(args: {
113
178
  historyMap: BncrConversationHistoryMap;
114
179
  parsed: ParsedInbound;
115
180
  senderDisplayName: string;
@@ -123,12 +188,7 @@ export async function recordBncrPendingConversationMedia(args: {
123
188
  mediaContentType?: string;
124
189
  historyLimit?: number;
125
190
  }) {
126
- const limit =
127
- typeof args.historyLimit === 'number' &&
128
- Number.isFinite(args.historyLimit) &&
129
- args.historyLimit >= 0
130
- ? Math.floor(args.historyLimit)
131
- : DEFAULT_HISTORY_LIMIT;
191
+ const limit = resolveBncrHistoryLimit(args.historyLimit);
132
192
  const historyKey = buildBncrConversationHistoryKey(args.parsed);
133
193
  if (!historyKey || args.parsed.msgType === 'text') return;
134
194
  const normalizedMediaItems = Array.isArray(args.mediaItems) ? args.mediaItems : [];
@@ -149,10 +209,10 @@ export async function recordBncrPendingConversationMedia(args: {
149
209
  });
150
210
  const body = normalizeTextBody(args.bodyText) || buildBncrHistoryMediaBody(kind);
151
211
  if (normalizedMediaItems.length === 0) {
152
- const msgId = String(args.parsed.msgId || '').trim();
153
- if (msgId) {
212
+ const messageId = resolveBncrConversationHistoryMessageId(args.parsed);
213
+ if (messageId) {
154
214
  const existing = args.historyMap.get(historyKey) || [];
155
- if (existing.some((e) => e.messageId === msgId)) return;
215
+ if (existing.some((e) => e.messageId === messageId)) return;
156
216
  }
157
217
  createChannelHistoryWindow({ historyMap: args.historyMap }).record({
158
218
  historyKey,
@@ -162,16 +222,17 @@ export async function recordBncrPendingConversationMedia(args: {
162
222
  senderId: args.senderId,
163
223
  body,
164
224
  timestamp: Date.now(),
165
- messageId: args.parsed.msgId,
225
+ ...(messageId ? { messageId } : {}),
166
226
  role: 'user',
167
227
  },
168
228
  });
229
+ bumpConversationHistoryVersion(args.historyMap, historyKey);
169
230
  return;
170
231
  }
171
- const msgId = String(args.parsed.msgId || '').trim();
172
- if (msgId) {
232
+ const messageId = resolveBncrConversationHistoryMessageId(args.parsed);
233
+ if (messageId) {
173
234
  const existing = args.historyMap.get(historyKey) || [];
174
- if (existing.some((e) => e.messageId === msgId)) return;
235
+ if (existing.some((e) => e.messageId === messageId)) return;
175
236
  }
176
237
  createChannelHistoryWindow({ historyMap: args.historyMap }).record({
177
238
  historyKey,
@@ -181,7 +242,7 @@ export async function recordBncrPendingConversationMedia(args: {
181
242
  senderId: args.senderId,
182
243
  body,
183
244
  timestamp: Date.now(),
184
- messageId: args.parsed.msgId,
245
+ ...(messageId ? { messageId } : {}),
185
246
  role: 'user',
186
247
  media: normalizedMediaItems.map(
187
248
  (item) =>
@@ -189,11 +250,12 @@ export async function recordBncrPendingConversationMedia(args: {
189
250
  path: item.path,
190
251
  contentType: item.contentType || args.mediaContentType,
191
252
  kind: item.kind || kind,
192
- messageId: args.parsed.msgId,
253
+ messageId,
193
254
  }) satisfies HistoryMediaEntry,
194
255
  ),
195
256
  },
196
257
  });
258
+ bumpConversationHistoryVersion(args.historyMap, historyKey);
197
259
  }
198
260
 
199
261
  export function recordBncrBotReply(args: {
@@ -206,20 +268,21 @@ export function recordBncrBotReply(args: {
206
268
  messageId?: string;
207
269
  media?: HistoryMediaEntry[];
208
270
  historyLimit?: number;
209
- }) {
210
- const limit =
211
- typeof args.historyLimit === 'number' &&
212
- Number.isFinite(args.historyLimit) &&
213
- args.historyLimit >= 0
214
- ? Math.floor(args.historyLimit)
215
- : DEFAULT_HISTORY_LIMIT;
271
+ }): boolean {
272
+ const limit = resolveBncrHistoryLimit(args.historyLimit);
216
273
  const body = normalizeTextBody(args.body);
217
- if (!body) return;
218
- const msgId = String(args.messageId || '').trim();
219
- if (msgId) {
220
- const existing = args.historyMap.get(args.historyKey) || [];
221
- if (existing.some((e) => e.messageId === msgId)) return;
222
- }
274
+ if (!body) return false;
275
+ const msgId = buildBncrBotReplyMessageId({
276
+ historyKey: args.historyKey,
277
+ sender: args.sender,
278
+ senderId: args.senderId,
279
+ body,
280
+ timestamp: args.timestamp,
281
+ messageId: args.messageId,
282
+ media: args.media,
283
+ });
284
+ const existing = args.historyMap.get(args.historyKey) || [];
285
+ if (existing.some((e) => e.messageId === msgId)) return false;
223
286
  createChannelHistoryWindow({ historyMap: args.historyMap }).record({
224
287
  historyKey: args.historyKey,
225
288
  limit,
@@ -228,11 +291,22 @@ export function recordBncrBotReply(args: {
228
291
  ...(args.senderId ? { senderId: args.senderId } : {}),
229
292
  body,
230
293
  timestamp: args.timestamp ?? Date.now(),
231
- ...(args.messageId ? { messageId: args.messageId } : {}),
294
+ messageId: msgId,
232
295
  role: 'assistant',
233
- ...(Array.isArray(args.media) && args.media.length > 0 ? { media: args.media } : {}),
296
+ ...(Array.isArray(args.media) && args.media.length > 0
297
+ ? {
298
+ media: args.media.map((item) => ({
299
+ ...(item?.path ? { path: item.path } : {}),
300
+ ...(item?.contentType ? { contentType: item.contentType } : {}),
301
+ ...(item?.kind ? { kind: item.kind } : {}),
302
+ messageId: item?.messageId || msgId,
303
+ })),
304
+ }
305
+ : {}),
234
306
  },
235
307
  });
308
+ bumpConversationHistoryVersion(args.historyMap, args.historyKey);
309
+ return true;
236
310
  }
237
311
 
238
312
  function cloneBncrHistoryEntry(entry: BncrHistoryEntry): BncrHistoryEntry {
@@ -261,16 +335,41 @@ export function readBncrPendingConversationHistorySnapshot(args: {
261
335
  parsed: ParsedInbound;
262
336
  historyLimit?: number;
263
337
  }): BncrHistoryEntry[] {
264
- const limit =
265
- typeof args.historyLimit === 'number' &&
266
- Number.isFinite(args.historyLimit) &&
267
- args.historyLimit >= 0
268
- ? Math.floor(args.historyLimit)
269
- : DEFAULT_HISTORY_LIMIT;
338
+ const limit = resolveBncrHistoryLimit(args.historyLimit);
270
339
  const historyKey = buildBncrConversationHistoryKey(args.parsed);
271
340
  if (!historyKey) return [];
272
- const entries = args.historyMap.get(historyKey) || [];
273
- if (entries.length === 0) return [];
341
+ const rawEntries = args.historyMap.get(historyKey) || [];
342
+ if (rawEntries.length === 0) return [];
343
+ let normalized = false;
344
+ const entries = rawEntries.map((entry, entryIndex) => {
345
+ const messageId =
346
+ String(entry.messageId || '').trim() ||
347
+ buildSyntheticHistoryMessageId(
348
+ JSON.stringify({
349
+ historyKey,
350
+ entryIndex,
351
+ sender: entry.sender,
352
+ senderId: entry.senderId,
353
+ body: entry.body,
354
+ timestamp: entry.timestamp,
355
+ }),
356
+ );
357
+ if (messageId === entry.messageId) return entry;
358
+ normalized = true;
359
+ return {
360
+ ...entry,
361
+ messageId,
362
+ ...(Array.isArray(entry.media)
363
+ ? {
364
+ media: entry.media.map((item) => ({
365
+ ...item,
366
+ messageId: item.messageId || messageId,
367
+ })),
368
+ }
369
+ : {}),
370
+ };
371
+ });
372
+ if (normalized) args.historyMap.set(historyKey, entries);
274
373
  return entries
275
374
  .slice()
276
375
  .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
@@ -306,26 +405,41 @@ export function buildBncrConversationContextFromEntries(args: {
306
405
  return `[Chat messages since your last reply - for context]\n${historyText}\n\n[Current message - respond to this]${args.currentMessage}`;
307
406
  }
308
407
 
309
- export function collectBncrHistoryMediaFromEntries(args: {
310
- entries: readonly BncrHistoryEntry[];
311
- }): HistoryMediaEntry[] {
312
- return args.entries.flatMap((entry) => (Array.isArray(entry.media) ? entry.media : []));
313
- }
314
408
  export function clearBncrPendingConversationHistory(args: {
315
409
  historyMap: BncrConversationHistoryMap;
316
410
  parsed: ParsedInbound;
317
411
  historyLimit?: number;
318
412
  }) {
319
- const limit =
320
- typeof args.historyLimit === 'number' &&
321
- Number.isFinite(args.historyLimit) &&
322
- args.historyLimit >= 0
323
- ? Math.floor(args.historyLimit)
324
- : DEFAULT_HISTORY_LIMIT;
413
+ const limit = resolveBncrHistoryLimit(args.historyLimit);
325
414
  const historyKey = buildBncrConversationHistoryKey(args.parsed);
326
415
  if (!historyKey) return;
327
416
  createChannelHistoryWindow({ historyMap: args.historyMap }).clear({
328
417
  historyKey,
329
418
  limit: limit,
330
419
  });
420
+ bumpConversationHistoryVersion(args.historyMap, historyKey);
421
+ }
422
+
423
+ export function removeBncrConversationHistoryMessageIds(args: {
424
+ historyMap: BncrConversationHistoryMap;
425
+ historyKey: string;
426
+ messageIds: ReadonlyArray<string | undefined | null>;
427
+ }): number {
428
+ const messageIds = new Set(
429
+ args.messageIds
430
+ .map((messageId) => String(messageId || '').trim())
431
+ .filter((messageId) => Boolean(messageId)),
432
+ );
433
+ if (messageIds.size === 0) return 0;
434
+ const current = args.historyMap.get(args.historyKey) || [];
435
+ if (current.length === 0) return 0;
436
+ const next = current.filter(
437
+ (entry) =>
438
+ !String(entry.messageId || '').trim() || !messageIds.has(String(entry.messageId).trim()),
439
+ );
440
+ if (next.length === current.length) return 0;
441
+ args.historyMap.set(args.historyKey, next);
442
+ // Snapshot cleanup must not invalidate flushes queued from replies written
443
+ // while the earlier snapshot was uploading.
444
+ return current.length - next.length;
331
445
  }