@xmoxmo/bncr 0.4.3 → 0.4.5

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.3",
3
+ "version": "0.4.5",
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
@@ -1727,6 +1727,10 @@ class BncrBridgeRuntime {
1727
1727
  handleFileTransferPushFailure: (args) => this.handleFileTransferPushFailure(args),
1728
1728
  handleTextPushFailure: (args) => this.handleTextPushFailure(args),
1729
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
+ },
1730
1734
  scheduleSave: () => this.scheduleSave(),
1731
1735
  logInfo: (scope, message, options) => this.logInfo(scope, message, options),
1732
1736
  logWarn: (scope, message, options) => this.logWarn(scope, message, options),
@@ -1,3 +1,6 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
1
4
  type RuntimeMediaLoaded = {
2
5
  buffer: Buffer;
3
6
  contentType?: string;
@@ -40,6 +43,23 @@ export function isOpenClawRemoteHttpMediaUrl(mediaUrl: string): boolean {
40
43
  return /^https?:\/\//i.test(String(mediaUrl || '').trim());
41
44
  }
42
45
 
46
+ /**
47
+ * Try to resolve a relative media path against each local root.
48
+ * Returns the first absolute path that exists on disk, or the original
49
+ * relative path if nothing is found (the host will then emit its own error).
50
+ */
51
+ export function resolveRelativeMediaPath(mediaUrl: string, localRoots?: readonly string[]): string {
52
+ if (!mediaUrl || !localRoots?.length) return mediaUrl;
53
+ if (path.isAbsolute(mediaUrl)) return mediaUrl;
54
+ // HTTP / file:// / data: / ~ paths are handled elsewhere
55
+ if (/^(https?|file|data):/i.test(mediaUrl) || mediaUrl.startsWith('~')) return mediaUrl;
56
+ for (const root of localRoots) {
57
+ const candidate = path.resolve(root, mediaUrl);
58
+ if (existsSync(candidate)) return candidate;
59
+ }
60
+ return mediaUrl;
61
+ }
62
+
43
63
  export async function loadOpenClawWebMedia(
44
64
  api: RuntimeApiHolder,
45
65
  mediaUrl: string,
@@ -54,7 +74,11 @@ export async function loadOpenClawWebMedia(
54
74
  if (typeof loadWebMedia !== 'function') {
55
75
  throw new Error('OpenClaw runtime media loadWebMedia API is unavailable');
56
76
  }
57
- return loadWebMedia(mediaUrl, options);
77
+
78
+ // Resolve relative paths against local roots before handing off to the host
79
+ const resolvedUrl = resolveRelativeMediaPath(mediaUrl, options?.localRoots);
80
+
81
+ return loadWebMedia(resolvedUrl, options);
58
82
  }
59
83
 
60
84
  export async function saveOpenClawChannelMediaBuffer(
@@ -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,
@@ -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
  }