@xmoxmo/bncr 0.4.2 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmoxmo/bncr",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import fs from 'node:fs';
2
3
  import { createRequire } from 'node:module';
3
4
  import path from 'node:path';
@@ -182,6 +183,19 @@ const readPackageVersion = () => {
182
183
  return typeof pkg?.version === 'string' ? pkg.version.trim() : '';
183
184
  };
184
185
 
186
+ const readNpmLatestVersion = (packageName) => {
187
+ try {
188
+ const raw = execFileSync('npm', ['view', packageName, 'version'], {
189
+ encoding: 'utf8',
190
+ stdio: ['ignore', 'pipe', 'pipe'],
191
+ timeout: 10000,
192
+ }).trim();
193
+ return raw || null;
194
+ } catch {
195
+ return null;
196
+ }
197
+ };
198
+
185
199
  const requiredOpenClawSdkSubpaths = [
186
200
  'openclaw/plugin-sdk',
187
201
  'openclaw/plugin-sdk/boolean-param',
@@ -213,7 +227,7 @@ const resolveOpenClawSdkSubpaths = () => {
213
227
  });
214
228
  };
215
229
 
216
- const validateVersionPolicy = (version) => {
230
+ const validateVersionPolicy = (version, latestVersion) => {
217
231
  const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
218
232
  if (!match) {
219
233
  return {
@@ -232,12 +246,101 @@ const validateVersionPolicy = (version) => {
232
246
  };
233
247
  }
234
248
 
249
+ // Prevent jumping minor when current minor still has unused patch slots
250
+ if (latestVersion) {
251
+ const lm = latestVersion.match(/^(\d+)\.(\d+)\.(\d+)$/);
252
+ if (lm) {
253
+ const vMajor = Number.parseInt(match[1], 10);
254
+ const vMinor = Number.parseInt(match[2], 10);
255
+ const lMajor = Number.parseInt(lm[1], 10);
256
+ const lMinor = Number.parseInt(lm[2], 10);
257
+ const lPatch = Number.parseInt(lm[3], 10);
258
+
259
+ // Version unchanged
260
+ if (vMajor === lMajor && vMinor === lMinor && patch === lPatch) {
261
+ return { ok: false, reason: `version unchanged: ${version}`, version };
262
+ }
263
+
264
+ // Downgrade: any component decreased
265
+ if (
266
+ vMajor < lMajor ||
267
+ (vMajor === lMajor && vMinor < lMinor) ||
268
+ (vMajor === lMajor && vMinor === lMinor && patch < lPatch)
269
+ ) {
270
+ return { ok: false, reason: `downgrade from ${latestVersion} to ${version}`, version };
271
+ }
272
+
273
+ // Patch bump: same major and minor, patch must increment by exactly +1
274
+ if (vMajor === lMajor && vMinor === lMinor) {
275
+ if (patch === lPatch + 1) return { ok: true, version };
276
+ return {
277
+ ok: false,
278
+ reason: `patch jump from ${latestVersion} to ${version} (delta=${patch - lPatch}); expected ${lMajor}.${lMinor}.${lPatch + 1}`,
279
+ version,
280
+ };
281
+ }
282
+
283
+ // Minor bump: same major, minor + 1
284
+ if (vMajor === lMajor && vMinor === lMinor + 1) {
285
+ if (lPatch === 9 && patch === 0) return { ok: true, version };
286
+ if (lPatch !== 9)
287
+ return {
288
+ ok: false,
289
+ reason: `minor bumped from ${latestVersion} to ${version} but ${9 - lPatch} patch slots remain in ${lMajor}.${lMinor}; prefer ${lMajor}.${lMinor}.${lPatch + 1}`,
290
+ version,
291
+ };
292
+ return {
293
+ ok: false,
294
+ reason: `minor bump must reset patch to 0; got ${vMajor}.${vMinor}.${patch}`,
295
+ version,
296
+ };
297
+ }
298
+
299
+ // Minor jumped by more than 1
300
+ if (vMajor === lMajor && vMinor > lMinor + 1) {
301
+ return {
302
+ ok: false,
303
+ reason: `minor jumped from ${latestVersion} to ${version}; expected ${lMajor}.${lMinor + 1}.0`,
304
+ version,
305
+ };
306
+ }
307
+
308
+ // Major bump: major + 1, requires previous minor fully exhausted
309
+ if (vMajor === lMajor + 1) {
310
+ if (lMinor === 9 && lPatch === 9 && vMinor === 0 && patch === 0)
311
+ return { ok: true, version };
312
+ if (lMinor !== 9 || lPatch !== 9)
313
+ return {
314
+ ok: false,
315
+ reason: `major bumped from ${latestVersion} to ${version} but previous major series not exhausted; expected ${lMajor + 1}.0.0 after ${lMajor}.9.9`,
316
+ version,
317
+ };
318
+ return {
319
+ ok: false,
320
+ reason: `major bump must reset to .0.0; got ${vMajor}.${vMinor}.${patch}`,
321
+ version,
322
+ };
323
+ }
324
+
325
+ // Major jumped by more than 1
326
+ if (vMajor > lMajor + 1) {
327
+ return {
328
+ ok: false,
329
+ reason: `major jumped from ${latestVersion} to ${version}; expected ${lMajor + 1}.0.0`,
330
+ version,
331
+ };
332
+ }
333
+ }
334
+ }
335
+
235
336
  return { ok: true, version };
236
337
  };
237
338
 
238
339
  const missing = requiredFiles.filter((rel) => !fs.existsSync(path.join(root, rel)));
239
340
  const version = readPackageVersion();
240
- const versionPolicy = validateVersionPolicy(version);
341
+ const packageName = '@xmoxmo/bncr';
342
+ const latestVersion = readNpmLatestVersion(packageName);
343
+ const versionPolicy = validateVersionPolicy(version, latestVersion);
241
344
  const sdkSubpaths = resolveOpenClawSdkSubpaths();
242
345
  const missingSdkSubpaths = sdkSubpaths.filter((entry) => !entry.ok);
243
346
  const result = {
package/src/channel.ts CHANGED
@@ -1164,6 +1164,7 @@ class BncrBridgeRuntime {
1164
1164
  asVoice?: boolean;
1165
1165
  audioAsVoice?: boolean;
1166
1166
  type?: string;
1167
+ extra?: Record<string, unknown>;
1167
1168
  kind?: 'tool' | 'block' | 'final';
1168
1169
  replyToId?: string;
1169
1170
  replyTargetPolicy?: OutboundReplyTargetPolicy;
@@ -1182,6 +1183,7 @@ class BncrBridgeRuntime {
1182
1183
  asVoice: params.asVoice,
1183
1184
  audioAsVoice: params.audioAsVoice,
1184
1185
  type: params.type,
1186
+ extra: params.extra,
1185
1187
  kind: params.kind,
1186
1188
  replyToId: asString(params.replyToId || '').trim() || undefined,
1187
1189
  replyTargetPolicy: params.replyTargetPolicy,
@@ -1245,6 +1247,7 @@ class BncrBridgeRuntime {
1245
1247
  mimeType: params.media.mimeType,
1246
1248
  }),
1247
1249
  hintedType: wantsVoice ? 'voice' : asString(params.meta.type || '') || undefined,
1250
+ extra: params.meta.extra as Record<string, unknown> | undefined,
1248
1251
  kind: messageKind,
1249
1252
  replyToId: normalizeReplyToId(params.meta.replyToId) || undefined,
1250
1253
  now: now(),
@@ -1724,6 +1727,10 @@ class BncrBridgeRuntime {
1724
1727
  handleFileTransferPushFailure: (args) => this.handleFileTransferPushFailure(args),
1725
1728
  handleTextPushFailure: (args) => this.handleTextPushFailure(args),
1726
1729
  isPrePushGuardDeferral: (entry) => this.isPrePushGuardDeferral(entry),
1730
+ resolveAccountIdForSession: (sessionKey) => {
1731
+ const hit = this.sessionRoutes.get(sessionKey);
1732
+ return hit ? hit.accountId : BNCR_DEFAULT_ACCOUNT_ID;
1733
+ },
1727
1734
  scheduleSave: () => this.scheduleSave(),
1728
1735
  logInfo: (scope, message, options) => this.logInfo(scope, message, options),
1729
1736
  logWarn: (scope, message, options) => this.logWarn(scope, message, options),
@@ -16,6 +16,7 @@ export function buildFileTransferOutboxEntry(args: {
16
16
  asVoice?: boolean;
17
17
  audioAsVoice?: boolean;
18
18
  type?: string;
19
+ extra?: Record<string, unknown>;
19
20
  kind?: 'tool' | 'block' | 'final';
20
21
  replyToId?: string;
21
22
  replyTargetPolicy?: OutboundReplyTargetPolicy;
@@ -38,6 +39,7 @@ export function buildFileTransferOutboxEntry(args: {
38
39
  asVoice: args.asVoice === true,
39
40
  audioAsVoice: args.audioAsVoice === true,
40
41
  type: args.type,
42
+ ...(args.extra ? { extra: { ...args.extra } } : {}),
41
43
  finalEvent: args.pushEvent,
42
44
  replyToId:
43
45
  normalizeOutboundReplyToId({
@@ -68,6 +68,7 @@ export function buildBncrMediaOutboundFrame(params: {
68
68
  mediaMsg: string;
69
69
  fileName: string;
70
70
  hintedType?: string;
71
+ extra?: Record<string, unknown>;
71
72
  kind?: 'tool' | 'block' | 'final';
72
73
  replyToId?: string;
73
74
  now: number;
@@ -96,6 +97,7 @@ export function buildBncrMediaOutboundFrame(params: {
96
97
  base64: params.media.mediaBase64 || '',
97
98
  fileName: params.fileName,
98
99
  transferMode: params.media.mode,
100
+ ...(params.extra ? { extra: { ...params.extra } } : {}),
99
101
  },
100
102
  ts: params.now,
101
103
  };
@@ -62,6 +62,7 @@ export function enqueueReplyMediaFileTransferEntry(
62
62
  asVoice: boolean;
63
63
  audioAsVoice: boolean;
64
64
  type?: string;
65
+ extra?: Record<string, unknown>;
65
66
  kind?: 'tool' | 'block' | 'final';
66
67
  replyToId?: string;
67
68
  replyTargetPolicy?: OutboundReplyTargetPolicy;
@@ -86,6 +87,7 @@ export function enqueueReplyMediaFileTransferEntry(
86
87
  asVoice: params.asVoice,
87
88
  audioAsVoice: params.audioAsVoice,
88
89
  type: params.type,
90
+ extra: params.extra,
89
91
  kind: params.kind,
90
92
  replyToId: params.replyToId || undefined,
91
93
  replyTargetPolicy: params.replyTargetPolicy,
@@ -132,6 +134,7 @@ export function enqueueSingleReplyMediaEntry(
132
134
  asVoice: params.params.payload.asVoice,
133
135
  audioAsVoice: params.params.payload.audioAsVoice,
134
136
  type: params.params.payload.type,
137
+ extra: params.params.payload.extra,
135
138
  kind: params.params.payload.kind,
136
139
  replyToId: params.params.payload.replyToId,
137
140
  replyTargetPolicy: params.params.payload.replyTargetPolicy,
@@ -19,6 +19,7 @@ export type ReplyPayloadInput = {
19
19
  asVoice?: boolean;
20
20
  audioAsVoice?: boolean;
21
21
  type?: string;
22
+ extra?: Record<string, unknown>;
22
23
  kind?: 'tool' | 'block' | 'final';
23
24
  replyToId?: string;
24
25
  };
@@ -31,6 +32,7 @@ export type NormalizedReplyPayload = {
31
32
  asVoice: boolean;
32
33
  audioAsVoice: boolean;
33
34
  type?: string;
35
+ extra?: Record<string, unknown>;
34
36
  kind?: 'tool' | 'block' | 'final';
35
37
  replyToId: string;
36
38
  replyTargetPolicy: OutboundReplyTargetPolicy;
@@ -64,6 +66,7 @@ export type ReplyMediaFileTransferParams = {
64
66
  asVoice: boolean;
65
67
  audioAsVoice: boolean;
66
68
  type?: string;
69
+ extra?: Record<string, unknown>;
67
70
  kind?: 'tool' | 'block' | 'final';
68
71
  replyToId: string;
69
72
  replyTargetPolicy: OutboundReplyTargetPolicy;
@@ -268,6 +271,7 @@ export function normalizeReplyPayload(
268
271
  asVoice: payload?.asVoice === true,
269
272
  audioAsVoice: payload?.audioAsVoice === true,
270
273
  ...(type ? { type } : {}),
274
+ ...(payload?.extra ? { extra: { ...payload.extra } } : {}),
271
275
  kind: payload?.kind,
272
276
  replyTargetPolicy: options?.replyTargetPolicy ?? 'agent-default',
273
277
  replyToId: normalizeOutboundReplyToId({
@@ -11,6 +11,7 @@ export type NormalizedBncrSendParams = {
11
11
  asVoice: boolean;
12
12
  audioAsVoice: boolean;
13
13
  type?: string;
14
+ extra?: Record<string, unknown>;
14
15
  };
15
16
 
16
17
  function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -51,6 +52,8 @@ export function normalizeBncrSendParams(input: {
51
52
  const asVoice = readOpenClawBooleanParam(paramsObj, 'asVoice') ?? false;
52
53
  const audioAsVoice = readOpenClawBooleanParam(paramsObj, 'audioAsVoice') ?? false;
53
54
  const type = readOpenClawStringParam(paramsObj, 'type') || undefined;
55
+ const rawExtra = paramsObj.extra;
56
+ const extra = isPlainObject(rawExtra) ? { ...rawExtra } : undefined;
54
57
 
55
58
  const hasMedia = Boolean(mediaUrl || dedupedMediaUrls?.length);
56
59
 
@@ -73,5 +76,6 @@ export function normalizeBncrSendParams(input: {
73
76
  asVoice,
74
77
  audioAsVoice,
75
78
  ...(type ? { type } : {}),
79
+ ...(extra ? { extra } : {}),
76
80
  };
77
81
  }
@@ -64,6 +64,7 @@ export async function sendBncrMedia(params: {
64
64
  asVoice?: boolean;
65
65
  audioAsVoice?: boolean;
66
66
  type?: string;
67
+ extra?: Record<string, unknown>;
67
68
  kind?: string;
68
69
  replyToId?: string;
69
70
  mediaLocalRoots?: readonly string[];
@@ -83,6 +84,7 @@ export async function sendBncrMedia(params: {
83
84
  asVoice?: boolean;
84
85
  audioAsVoice?: boolean;
85
86
  type?: string;
87
+ extra?: Record<string, unknown>;
86
88
  kind?: 'tool' | 'block' | 'final';
87
89
  replyToId?: string;
88
90
  };
@@ -104,6 +106,7 @@ export async function sendBncrMedia(params: {
104
106
  asVoice: params.asVoice === true ? true : undefined,
105
107
  audioAsVoice: params.audioAsVoice === true ? true : undefined,
106
108
  type: params.type,
109
+ extra: params.extra,
107
110
  kind: normalizeReplyKind(params.kind),
108
111
  replyToId: params.replyToId,
109
112
  },
@@ -19,6 +19,7 @@ export function createBncrAckOutboxRuntimeGroup(runtime: {
19
19
  isPlainObject: (value: unknown) => value is Record<string, unknown>;
20
20
  clampFiniteNumber: (value: unknown, fallback: number, min?: number, max?: number) => number;
21
21
  normalizeAccountId: (accountId: string) => string;
22
+ resolveAccountIdForSession: (sessionKey: string) => string | null;
22
23
  formatDisplayScope: (route: OutboxEntry['route']) => string;
23
24
  isFileTransferEntry: (entry: OutboxEntry) => boolean;
24
25
  recommendedAckTimeoutMaxMs: number;
@@ -235,6 +236,7 @@ export function createBncrAckOutboxRuntimeGroup(runtime: {
235
236
  handleFileTransferPushFailure: runtime.handleFileTransferPushFailure,
236
237
  handleTextPushFailure: runtime.handleTextPushFailure,
237
238
  isPrePushGuardDeferral: runtime.isPrePushGuardDeferral,
239
+ resolveAccountIdForSession: runtime.resolveAccountIdForSession,
238
240
  moveToDeadLetter: runtime.moveToDeadLetter,
239
241
  scheduleSave: runtime.scheduleSave,
240
242
  logInfo: runtime.logInfo,
@@ -152,6 +152,7 @@ export function createBncrChannelPluginSurfaceGroup(runtime: {
152
152
  asVoice: normalized.asVoice,
153
153
  audioAsVoice: normalized.audioAsVoice,
154
154
  type: normalized.type,
155
+ extra: normalized.extra,
155
156
  mediaLocalRoots,
156
157
  resolveVerifiedTarget: (to, accountId) =>
157
158
  toolActionBridge.resolveVerifiedTarget(to, accountId),
@@ -337,6 +337,7 @@ export function buildBncrMediaOrchestratorsRuntime(deps: {
337
337
  asVoice: boolean;
338
338
  audioAsVoice: boolean;
339
339
  type?: string;
340
+ extra?: Record<string, unknown>;
340
341
  kind?: 'tool' | 'block' | 'final';
341
342
  replyToId?: string;
342
343
  replyTargetPolicy?: OutboundReplyTargetPolicy;
@@ -49,6 +49,7 @@ function buildReplyMediaEntryHelpers(runtime: {
49
49
  asVoice: boolean;
50
50
  audioAsVoice: boolean;
51
51
  type?: string;
52
+ extra?: Record<string, unknown>;
52
53
  kind?: 'tool' | 'block' | 'final';
53
54
  replyToId?: string;
54
55
  replyTargetPolicy?: OutboundReplyTargetPolicy;
@@ -203,6 +204,7 @@ export function createBncrMediaOrchestratorsRuntimeGroup(runtime: {
203
204
  asVoice: boolean;
204
205
  audioAsVoice: boolean;
205
206
  type?: string;
207
+ extra?: Record<string, unknown>;
206
208
  kind?: 'tool' | 'block' | 'final';
207
209
  replyToId?: string;
208
210
  replyTargetPolicy?: OutboundReplyTargetPolicy;
@@ -20,6 +20,9 @@ type BncrOutboxDrainScheduleRuntime = {
20
20
  type BncrOutboxDrainFailureRuntime = {
21
21
  backoffMs: (retryCount: number) => number;
22
22
  outbox: Map<string, OutboxEntry>;
23
+ resolveAccountIdForSession: (sessionKey: string) => string | null;
24
+ logInfo: (scope: string, message: string, options?: { debugOnly?: boolean }) => void;
25
+ logWarn: (scope: string, message: string, options?: { debugOnly?: boolean }) => void;
23
26
  isPrePushGuardDeferral: (entry: OutboxEntry) => boolean;
24
27
  moveToDeadLetter: (entry: OutboxEntry, reason: string) => void;
25
28
  scheduleSave: () => void;
@@ -39,6 +42,39 @@ export function createBncrOutboxDrainFailure(runtime: BncrOutboxDrainFailureRunt
39
42
  const { accountId, entry, attemptedAt, updateMinOutboxDelay } = args;
40
43
  let { localNextDelay } = args;
41
44
 
45
+ // If the entry keeps hitting pre-push guard (no active connection), the
46
+ // accountId on the entry might be wrong (e.g. constructed from route data).
47
+ // Try to correct it from recent inbound session context before giving up.
48
+ if (runtime.isPrePushGuardDeferral(entry) && entry.retryCount > 0) {
49
+ const corrected = runtime.resolveAccountIdForSession(entry.sessionKey);
50
+ if (corrected && corrected !== entry.accountId) {
51
+ const oldAccountId = entry.accountId;
52
+ runtime.logWarn(
53
+ 'outbound',
54
+ `account corrected sessionKey=${entry.sessionKey} ${oldAccountId}→${corrected}`,
55
+ );
56
+ runtime.logInfo(
57
+ 'outbound',
58
+ JSON.stringify({
59
+ event: 'account-corrected',
60
+ messageId: entry.messageId,
61
+ sessionKey: entry.sessionKey,
62
+ oldAccountId,
63
+ corrected,
64
+ retryCount: entry.retryCount,
65
+ lastError: entry.lastError,
66
+ }),
67
+ { debugOnly: true },
68
+ );
69
+ entry.accountId = corrected;
70
+ entry.retryCount = 0;
71
+ entry.lastError = undefined;
72
+ runtime.outbox.set(entry.messageId, entry);
73
+ runtime.scheduleSave();
74
+ return { action: 'continue', localNextDelay };
75
+ }
76
+ }
77
+
42
78
  if (runtime.isPrePushGuardDeferral(entry)) {
43
79
  const wait = runtime.prePushGuardRetryDelayMs;
44
80
  localNextDelay = runtime.outboxDrainSchedule.scheduleAccountWait({
@@ -58,6 +58,7 @@ type BncrOutboxDrainRuntime = {
58
58
  backoffMs: (retryCount: number) => number;
59
59
  isPlainObject: (value: unknown) => value is Record<string, unknown>;
60
60
  normalizeAccountId: (accountId: string) => string;
61
+ resolveAccountIdForSession: (sessionKey: string) => string | null;
61
62
  stopped: () => boolean;
62
63
  outbox: Map<string, OutboxEntry>;
63
64
  deadLetter: () => OutboxEntry[];
@@ -132,7 +133,19 @@ type BncrOutboxDrainRuntime = {
132
133
 
133
134
  export function createBncrOutboxDrainRuntime(runtime: BncrOutboxDrainRuntime) {
134
135
  const handlePushedDrainEntry = createBncrOutboxDrainPostPush(runtime);
135
- const handleFailedDrainEntry = createBncrOutboxDrainFailure(runtime);
136
+ const handleFailedDrainEntry = createBncrOutboxDrainFailure({
137
+ backoffMs: runtime.backoffMs,
138
+ outbox: runtime.outbox,
139
+ resolveAccountIdForSession: runtime.resolveAccountIdForSession,
140
+ logInfo: runtime.logInfo,
141
+ logWarn: runtime.logWarn,
142
+ isPrePushGuardDeferral: runtime.isPrePushGuardDeferral,
143
+ moveToDeadLetter: runtime.moveToDeadLetter,
144
+ scheduleSave: runtime.scheduleSave,
145
+ outboxDrainSchedule: runtime.outboxDrainSchedule,
146
+ maxRetry: runtime.maxRetry,
147
+ prePushGuardRetryDelayMs: runtime.prePushGuardRetryDelayMs,
148
+ });
136
149
 
137
150
  return createBncrOutboxDrainLoop(runtime, {
138
151
  handlePushedDrainEntry,
@@ -133,9 +133,17 @@ export function createBncrTargetRuntime(runtime: {
133
133
  return verified;
134
134
  }
135
135
 
136
+ function resolveSessionAccountId(sessionKey: string): string | null {
137
+ const key = asString(sessionKey).trim();
138
+ if (!key) return null;
139
+ const hit = runtime.sessionRoutes.get(key);
140
+ return hit ? hit.accountId : null;
141
+ }
142
+
136
143
  return {
137
144
  rememberSessionRoute,
138
145
  resolveRouteBySession,
139
146
  resolveVerifiedTarget,
147
+ resolveSessionAccountId,
140
148
  };
141
149
  }