@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,5 +1,37 @@
1
1
  import path from 'node:path';
2
2
 
3
+ type BncrConversationContextMedia = {
4
+ type?: string;
5
+ path?: string;
6
+ contentType?: string;
7
+ kind?: string;
8
+ messageId?: string;
9
+ };
10
+
11
+ type BncrConversationContextEntry = {
12
+ messageId?: string;
13
+ timestamp?: number;
14
+ role?: 'user' | 'assistant' | 'system';
15
+ sender?: string;
16
+ senderId?: string;
17
+ content?: string;
18
+ media?: BncrConversationContextMedia[];
19
+ };
20
+
21
+ type BncrParticipantRecord = Record<
22
+ string,
23
+ {
24
+ name: string;
25
+ username?: string;
26
+ isBot?: boolean;
27
+ isAdmin?: boolean;
28
+ isOwner?: boolean;
29
+ isAuthorizedSender?: boolean;
30
+ role?: 'owner' | 'admin' | 'user';
31
+ displayName?: string;
32
+ }
33
+ >;
34
+
3
35
  export type BncrStructuredContextFactsInput = {
4
36
  channelId: string;
5
37
  accountId: string;
@@ -57,18 +89,10 @@ export type BncrStructuredContextFactsInput = {
57
89
  kind?: string;
58
90
  messageId?: string;
59
91
  }>;
60
- pendingMediaContext?: Array<{
61
- messageId?: string;
62
- sender?: string;
63
- senderId?: string;
64
- body?: string;
65
- media?: Array<{
66
- path?: string;
67
- contentType?: string;
68
- kind?: string;
69
- messageId?: string;
70
- }>;
71
- }>;
92
+ conversationContext?: BncrConversationContextEntry[];
93
+ participants?: BncrParticipantRecord | null;
94
+ isGroupChat?: boolean;
95
+ groupSubject?: string;
72
96
  };
73
97
 
74
98
  function toBncrPromptMediaPath(id: string): string | undefined {
@@ -166,18 +190,24 @@ export function buildBncrStructuredContextFacts(input: BncrStructuredContextFact
166
190
  kind: item.kind,
167
191
  messageId: item.messageId,
168
192
  })),
169
- pendingMediaContext: (input.pendingMediaContext || []).map((entry) => ({
193
+ conversationContext: (input.conversationContext || []).map((entry) => ({
170
194
  messageId: entry.messageId,
195
+ timestamp: entry.timestamp,
196
+ role: entry.role,
171
197
  sender: entry.sender,
172
198
  senderId: entry.senderId,
173
- body: entry.body,
199
+ content: entry.content,
174
200
  media: (entry.media || []).map((item) => ({
201
+ type: item.type || item.kind || 'document',
175
202
  path: item.path,
176
203
  contentType: item.contentType,
177
- kind: item.kind,
178
204
  messageId: item.messageId,
179
205
  })),
180
206
  })),
207
+ participants: input.participants ?? null,
208
+ isGroupChat: input.isGroupChat,
209
+ groupSubject: input.groupSubject,
210
+ accountId: input.accountId,
181
211
  };
182
212
  }
183
213
 
@@ -210,18 +240,11 @@ export function buildBncrPromptVisibleContextFacts(
210
240
  kind?: string;
211
241
  messageId?: string;
212
242
  }>;
213
- pendingMediaContext?: Array<{
214
- messageId?: string;
215
- sender?: string;
216
- senderId?: string;
217
- body?: string;
218
- media?: Array<{
219
- path?: string;
220
- contentType?: string;
221
- kind?: string;
222
- messageId?: string;
223
- }>;
224
- }>;
243
+ conversation_context?: Array<Record<string, unknown>>;
244
+ participants?: BncrParticipantRecord;
245
+ is_group_chat?: boolean;
246
+ group_subject?: string;
247
+ account_id?: string;
225
248
  } = {};
226
249
 
227
250
  if (facts.platform) {
@@ -272,27 +295,39 @@ export function buildBncrPromptVisibleContextFacts(
272
295
  return Object.values(payload).some((field) => field !== undefined) ? [payload] : [];
273
296
  });
274
297
  }
275
- if (facts.pendingMediaContext?.length) {
276
- result.pendingMediaContext = facts.pendingMediaContext.flatMap((entry) => {
277
- const media = (entry.media || []).flatMap((item) => {
298
+ if (facts.conversationContext?.length) {
299
+ result.conversation_context = facts.conversationContext.flatMap((entry) => {
300
+ const items: Array<Record<string, unknown>> = [];
301
+ if (entry.content) {
302
+ items.push({
303
+ messageId: entry.messageId,
304
+ timestamp: entry.timestamp,
305
+ role: entry.role,
306
+ sender: entry.sender,
307
+ senderId: entry.senderId,
308
+ content: entry.content,
309
+ });
310
+ }
311
+ for (const item of entry.media || []) {
278
312
  const promptMediaPath = resolveBncrPromptMediaPath(item.path);
279
313
  const payload = {
314
+ messageId: item.messageId ?? entry.messageId,
315
+ timestamp: entry.timestamp,
316
+ role: entry.role,
317
+ sender: entry.sender,
318
+ senderId: entry.senderId,
319
+ media_type: item.type || 'document',
280
320
  ...(promptMediaPath ? { path: promptMediaPath } : {}),
281
- contentType: item.contentType,
282
- kind: item.kind,
283
- messageId: item.messageId,
321
+ ...(item.contentType ? { contentType: item.contentType } : {}),
284
322
  };
285
- return Object.values(payload).some((field) => field !== undefined) ? [payload] : [];
286
- });
287
- const payload = {
288
- messageId: entry.messageId,
289
- sender: entry.sender,
290
- senderId: entry.senderId,
291
- body: entry.body,
292
- ...(media.length > 0 ? { media } : {}),
293
- };
294
- return Object.values(payload).some((field) => field !== undefined) ? [payload] : [];
323
+ if (Object.values(payload).some((field) => field !== undefined)) items.push(payload);
324
+ }
325
+ return items;
295
326
  });
327
+ if (facts.participants) result.participants = facts.participants;
328
+ if (facts.isGroupChat !== undefined) result.is_group_chat = facts.isGroupChat;
329
+ if (facts.groupSubject) result.group_subject = facts.groupSubject;
330
+ if (facts.accountId) result.account_id = facts.accountId;
296
331
  }
297
332
 
298
333
  return result;
@@ -358,25 +393,17 @@ export type BncrStructuredContextFactsFromInboundPartsInput = {
358
393
  bridgeSenderName?: string;
359
394
  senderIsOwner?: boolean;
360
395
  senderIsAuthorized?: boolean;
361
- pendingHistoryContext?: Array<{
362
- messageId?: string;
363
- sender?: string;
364
- senderId?: string;
365
- body?: string;
366
- media?: Array<{
367
- path?: string;
368
- contentType?: string;
369
- kind?: string;
370
- messageId?: string;
371
- }>;
372
- }>;
396
+ conversationHistoryContext?: BncrConversationContextEntry[];
397
+ participants?: BncrParticipantRecord | null;
398
+ isGroupChat?: boolean;
399
+ groupSubject?: string;
373
400
  };
374
401
  export function buildBncrStructuredContextFactsFromInboundParts(
375
402
  input: BncrStructuredContextFactsFromInboundPartsInput,
376
403
  ) {
377
404
  const mediaItems = Array.isArray(input.prepared.mediaItems) ? input.prepared.mediaItems : [];
378
- const pendingHistoryContext = Array.isArray(input.pendingHistoryContext)
379
- ? input.pendingHistoryContext
405
+ const conversationHistoryContext = Array.isArray(input.conversationHistoryContext)
406
+ ? input.conversationHistoryContext
380
407
  : [];
381
408
  return buildBncrStructuredContextFacts({
382
409
  channelId: input.channelId,
@@ -435,6 +462,9 @@ export function buildBncrStructuredContextFactsFromInboundParts(
435
462
  kind: item.kind || inferBncrStructuredMediaKind(item.contentType),
436
463
  messageId: input.parsed.msgId,
437
464
  })),
438
- pendingMediaContext: pendingHistoryContext,
465
+ conversationContext: conversationHistoryContext,
466
+ participants: input.participants ?? null,
467
+ isGroupChat: input.isGroupChat,
468
+ groupSubject: input.groupSubject,
439
469
  });
440
470
  }
@@ -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
+ }