@xmoxmo/bncr 0.1.3 → 0.1.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/index.ts CHANGED
@@ -59,6 +59,18 @@ const readOpenClawPackageName = (pkgPath: string) => {
59
59
  }
60
60
  };
61
61
 
62
+ const readPluginVersion = () => {
63
+ try {
64
+ const raw = fs.readFileSync(path.join(pluginDir, 'package.json'), 'utf8');
65
+ const parsed = JSON.parse(raw);
66
+ return typeof parsed?.version === 'string' ? parsed.version : 'unknown';
67
+ } catch {
68
+ return 'unknown';
69
+ }
70
+ };
71
+
72
+ const pluginVersion = readPluginVersion();
73
+
62
74
  const findOpenClawPackageRoot = (startPath: string) => {
63
75
  let current = startPath;
64
76
  try {
@@ -273,7 +285,7 @@ const plugin = {
273
285
  const { bridge, runtime, created } = getBridgeSingleton(api);
274
286
  bridge.noteRegister?.({
275
287
  source: '~/.openclaw/workspace/plugins/bncr/index.ts',
276
- pluginVersion: '0.1.1',
288
+ pluginVersion,
277
289
  apiRebound: !created,
278
290
  apiInstanceId: meta.apiInstanceId,
279
291
  registryFingerprint: meta.registryFingerprint,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmoxmo/bncr",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/channel.ts CHANGED
@@ -70,6 +70,7 @@ const BNCR_PUSH_EVENT = 'bncr.push';
70
70
  const CONNECT_TTL_MS = 120_000;
71
71
  const MAX_RETRY = 10;
72
72
  const PUSH_DRAIN_INTERVAL_MS = 500;
73
+ const PUSH_ACK_TIMEOUT_MS = 30_000;
73
74
  const FILE_FORCE_CHUNK = true; // 统一走 WS 分块,保留 base64 仅作兜底
74
75
  const FILE_INLINE_THRESHOLD = 5 * 1024 * 1024; // fallback 阈值(仅 FILE_FORCE_CHUNK=false 时生效)
75
76
  const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
@@ -1179,7 +1180,6 @@ class BncrBridgeRuntime {
1179
1180
  })}`,
1180
1181
  );
1181
1182
  }
1182
- this.outbox.delete(entry.messageId);
1183
1183
  this.lastOutboundByAccount.set(entry.accountId, now());
1184
1184
  this.markActivity(entry.accountId);
1185
1185
  this.scheduleSave();
@@ -1249,92 +1249,6 @@ class BncrBridgeRuntime {
1249
1249
  })}`,
1250
1250
  );
1251
1251
  }
1252
- if (!online) {
1253
- const ctx = this.gatewayContext;
1254
- const directConnIds = Array.from(this.connections.values())
1255
- .filter((c) => normalizeAccountId(c.accountId) === acc && c.connId)
1256
- .map((c) => c.connId as string);
1257
-
1258
- if (BNCR_DEBUG_VERBOSE) {
1259
- this.api.logger.info?.(
1260
- `[bncr-outbox-direct-push] ${JSON.stringify({
1261
- bridge: this.bridgeId,
1262
- accountId: acc,
1263
- outboxSize: this.outbox.size,
1264
- hasGatewayContext: Boolean(ctx),
1265
- connCount: directConnIds.length,
1266
- })}`,
1267
- );
1268
- }
1269
-
1270
- if (!ctx) {
1271
- if (BNCR_DEBUG_VERBOSE) {
1272
- this.api.logger.info?.(
1273
- `[bncr-outbox-direct-push-skip] ${JSON.stringify({
1274
- bridge: this.bridgeId,
1275
- accountId: acc,
1276
- reason: 'no-gateway-context',
1277
- })}`,
1278
- );
1279
- }
1280
- continue;
1281
- }
1282
-
1283
- if (!directConnIds.length) {
1284
- if (BNCR_DEBUG_VERBOSE) {
1285
- this.api.logger.info?.(
1286
- `[bncr-outbox-direct-push-skip] ${JSON.stringify({
1287
- accountId: acc,
1288
- reason: 'no-connection',
1289
- })}`,
1290
- );
1291
- }
1292
- continue;
1293
- }
1294
-
1295
- const directPayloads = this.collectDue(acc, 50);
1296
- if (!directPayloads.length) continue;
1297
-
1298
- try {
1299
- ctx.broadcastToConnIds(
1300
- BNCR_PUSH_EVENT,
1301
- {
1302
- forcePush: true,
1303
- items: directPayloads,
1304
- },
1305
- new Set(directConnIds),
1306
- );
1307
-
1308
- const pushedIds = directPayloads
1309
- .map((item: any) => asString(item?.messageId || item?.idempotencyKey || '').trim())
1310
- .filter(Boolean);
1311
- for (const id of pushedIds) this.outbox.delete(id);
1312
- if (pushedIds.length) this.scheduleSave();
1313
-
1314
- if (BNCR_DEBUG_VERBOSE) {
1315
- this.api.logger.info?.(
1316
- `[bncr-outbox-direct-push-ok] ${JSON.stringify({
1317
- bridge: this.bridgeId,
1318
- accountId: acc,
1319
- count: directPayloads.length,
1320
- connCount: directConnIds.length,
1321
- dropped: pushedIds.length,
1322
- })}`,
1323
- );
1324
- }
1325
- } catch (error) {
1326
- if (BNCR_DEBUG_VERBOSE) {
1327
- this.api.logger.info?.(
1328
- `[bncr-outbox-direct-push-fail] ${JSON.stringify({
1329
- accountId: acc,
1330
- error: asString((error as any)?.message || error || 'direct-push-error'),
1331
- })}`,
1332
- );
1333
- }
1334
- }
1335
- continue;
1336
- }
1337
-
1338
1252
  this.pushDrainRunningAccounts.add(acc);
1339
1253
  try {
1340
1254
  let localNextDelay: number | null = null;
@@ -1346,7 +1260,6 @@ class BncrBridgeRuntime {
1346
1260
  .sort((a, b) => a.createdAt - b.createdAt);
1347
1261
 
1348
1262
  if (!entries.length) break;
1349
- if (!this.isOnline(acc)) break;
1350
1263
 
1351
1264
  const entry = entries.find((item) => item.nextAttemptAt <= t);
1352
1265
  if (!entry) {
@@ -1355,11 +1268,33 @@ class BncrBridgeRuntime {
1355
1268
  break;
1356
1269
  }
1357
1270
 
1271
+ const onlineNow = this.isOnline(acc);
1358
1272
  const pushed = this.tryPushEntry(entry);
1359
1273
  if (pushed) {
1274
+ if (onlineNow) {
1275
+ await this.waitForOutbound(acc, PUSH_ACK_TIMEOUT_MS);
1276
+ }
1277
+
1278
+ if (!this.outbox.has(entry.messageId)) {
1279
+ await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1280
+ continue;
1281
+ }
1282
+
1283
+ entry.retryCount += 1;
1284
+ entry.lastAttemptAt = now();
1285
+ if (entry.retryCount > MAX_RETRY) {
1286
+ this.moveToDeadLetter(entry, entry.lastError || 'push-ack-timeout');
1287
+ continue;
1288
+ }
1289
+ entry.nextAttemptAt = now() + backoffMs(entry.retryCount);
1290
+ entry.lastError = entry.lastError || 'push-ack-timeout';
1291
+ this.outbox.set(entry.messageId, entry);
1360
1292
  this.scheduleSave();
1293
+
1294
+ const wait = Math.max(0, entry.nextAttemptAt - now());
1295
+ localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
1361
1296
  await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1362
- continue;
1297
+ break;
1363
1298
  }
1364
1299
 
1365
1300
  const nextAttempt = entry.retryCount + 1;
@@ -2230,7 +2165,7 @@ class BncrBridgeRuntime {
2230
2165
  mediaUrls?: string[];
2231
2166
  asVoice?: boolean;
2232
2167
  audioAsVoice?: boolean;
2233
- kind?: 'block' | 'final';
2168
+ kind?: 'tool' | 'block' | 'final';
2234
2169
  };
2235
2170
  mediaLocalRoots?: readonly string[];
2236
2171
  }) {
@@ -2413,6 +2348,7 @@ class BncrBridgeRuntime {
2413
2348
  if (ok) {
2414
2349
  this.outbox.delete(messageId);
2415
2350
  this.scheduleSave();
2351
+ this.wakeAccountWaiters(accountId);
2416
2352
  respond(true, { ok: true });
2417
2353
  return;
2418
2354
  }
@@ -3189,7 +3125,6 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3189
3125
  },
3190
3126
  outbound: {
3191
3127
  deliveryMode: 'gateway' as const,
3192
- textChunkLimit: 4000,
3193
3128
  sendText: bridge.channelSendText,
3194
3129
  sendMedia: bridge.channelSendMedia,
3195
3130
  replyAction: async (ctx: any) =>
@@ -31,6 +31,12 @@ export const BncrConfigSchema = {
31
31
  },
32
32
  },
33
33
  },
34
+ allowTool: {
35
+ type: 'boolean',
36
+ default: false,
37
+ description:
38
+ 'Allow tool messages to be forwarded when streaming is enabled. Defaults to false; only explicit true enables forwarding. When enabled, bncr also requests upstream tool summaries/results.',
39
+ },
34
40
  requireMention: {
35
41
  type: 'boolean',
36
42
  default: false,
@@ -3,6 +3,7 @@ import {
3
3
  normalizeInboundSessionKey,
4
4
  withTaskSessionKey,
5
5
  } from '../../core/targets.ts';
6
+ import { buildBncrReplyConfig } from './reply-config.ts';
6
7
 
7
8
  type ParsedInbound = ReturnType<typeof import('./parse.ts')['parseBncrInboundParams']>;
8
9
 
@@ -122,20 +123,24 @@ export async function handleBncrNativeCommand(params: {
122
123
  },
123
124
  });
124
125
 
126
+ const effectiveReply = buildBncrReplyConfig(cfg);
127
+
125
128
  let responded = false;
126
129
  await api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
127
130
  ctx: ctxPayload,
128
- cfg,
129
- replyOptions: {
130
- disableBlockStreaming: true,
131
- },
131
+ cfg: effectiveReply.replyCfg,
132
132
  dispatcherOptions: {
133
133
  deliver: async (
134
134
  payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean },
135
135
  info?: { kind?: 'tool' | 'block' | 'final' },
136
136
  ) => {
137
137
  const kind = info?.kind;
138
- if (kind && kind !== 'final') return;
138
+ const shouldForwardTool = effectiveReply.blockStreaming && effectiveReply.allowTool;
139
+
140
+ if (kind === 'tool' && !shouldForwardTool) {
141
+ return;
142
+ }
143
+
139
144
  const hasPayload = Boolean(
140
145
  payload?.text ||
141
146
  payload?.mediaUrl ||
@@ -149,11 +154,15 @@ export async function handleBncrNativeCommand(params: {
149
154
  route,
150
155
  payload: {
151
156
  ...payload,
152
- kind: kind as 'block' | 'final' | undefined,
157
+ kind: kind as 'tool' | 'block' | 'final' | undefined,
153
158
  },
154
159
  });
155
160
  },
156
161
  },
162
+ replyOptions: {
163
+ disableBlockStreaming: !effectiveReply.blockStreaming,
164
+ shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
165
+ },
157
166
  });
158
167
 
159
168
  if (!responded) {
@@ -1,10 +1,10 @@
1
- import fs from 'node:fs';
2
1
  import {
3
2
  formatDisplayScope,
4
3
  normalizeInboundSessionKey,
5
4
  withTaskSessionKey,
6
5
  } from '../../core/targets.ts';
7
6
  import { handleBncrNativeCommand } from './commands.ts';
7
+ import { buildBncrReplyConfig } from './reply-config.ts';
8
8
 
9
9
  type ParsedInbound = ReturnType<typeof import('./parse.ts')['parseBncrInboundParams']>;
10
10
 
@@ -179,19 +179,22 @@ export async function dispatchBncrInbound(params: {
179
179
  setInboundActivity(accountId, inboundAt);
180
180
  scheduleSave();
181
181
 
182
+ const effectiveReply = buildBncrReplyConfig(cfg);
183
+
182
184
  await api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
183
185
  ctx: ctxPayload,
184
- cfg,
185
- replyOptions: {
186
- disableBlockStreaming: true,
187
- },
186
+ cfg: effectiveReply.replyCfg,
188
187
  dispatcherOptions: {
189
188
  deliver: async (
190
189
  payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean },
191
190
  info?: { kind?: 'tool' | 'block' | 'final' },
192
191
  ) => {
193
192
  const kind = info?.kind;
194
- if (kind && kind !== 'final') return;
193
+ const shouldForwardTool = effectiveReply.blockStreaming && effectiveReply.allowTool;
194
+
195
+ if (kind === 'tool' && !shouldForwardTool) {
196
+ return;
197
+ }
195
198
 
196
199
  await enqueueFromReply({
197
200
  accountId,
@@ -199,7 +202,7 @@ export async function dispatchBncrInbound(params: {
199
202
  route,
200
203
  payload: {
201
204
  ...payload,
202
- kind: kind as 'block' | 'final' | undefined,
205
+ kind: kind as 'tool' | 'block' | 'final' | undefined,
203
206
  },
204
207
  });
205
208
  },
@@ -207,6 +210,10 @@ export async function dispatchBncrInbound(params: {
207
210
  logger?.error?.(`bncr reply failed: ${String(err)}`);
208
211
  },
209
212
  },
213
+ replyOptions: {
214
+ disableBlockStreaming: !effectiveReply.blockStreaming,
215
+ shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
216
+ },
210
217
  });
211
218
 
212
219
  return {
@@ -0,0 +1,63 @@
1
+ type BncrReplyConfigResult = {
2
+ blockStreaming: boolean;
3
+ allowTool: boolean;
4
+ replyCfg: any;
5
+ };
6
+
7
+ function parseBooleanLike(value: unknown): boolean | undefined {
8
+ if (typeof value === 'boolean') return value;
9
+ if (typeof value === 'number') {
10
+ if (value === 1) return true;
11
+ if (value === 0) return false;
12
+ return undefined;
13
+ }
14
+ if (typeof value !== 'string') return undefined;
15
+
16
+ const normalized = value.trim().toLowerCase();
17
+ if (!normalized) return undefined;
18
+ if (['true', 'on', '1', 'yes'].includes(normalized)) return true;
19
+ if (['false', 'off', '0', 'no'].includes(normalized)) return false;
20
+ return undefined;
21
+ }
22
+
23
+ export function resolveBncrBlockStreaming(cfg: any): boolean {
24
+ const channelValue = parseBooleanLike(cfg?.channels?.bncr?.blockStreaming);
25
+ if (channelValue !== undefined) return channelValue;
26
+
27
+ const globalValue = parseBooleanLike(cfg?.agents?.defaults?.blockStreamingDefault);
28
+ if (globalValue !== undefined) return globalValue;
29
+
30
+ return true;
31
+ }
32
+
33
+ export function resolveBncrAllowTool(cfg: any): boolean {
34
+ return cfg?.channels?.bncr?.allowTool === true;
35
+ }
36
+
37
+ export function buildBncrReplyConfig(cfg: any): BncrReplyConfigResult {
38
+ const blockStreaming = resolveBncrBlockStreaming(cfg);
39
+ const allowTool = resolveBncrAllowTool(cfg);
40
+
41
+ const replyCfg = {
42
+ ...cfg,
43
+ agents: {
44
+ ...(cfg?.agents ?? {}),
45
+ defaults: {
46
+ ...(cfg?.agents?.defaults ?? {}),
47
+ },
48
+ },
49
+ };
50
+
51
+ if (replyCfg.agents.defaults.blockStreamingBreak == null) {
52
+ replyCfg.agents.defaults.blockStreamingBreak = 'message_end';
53
+ }
54
+
55
+ if (replyCfg.agents.defaults.blockStreamingChunk == null) {
56
+ replyCfg.agents.defaults.blockStreamingChunk = {
57
+ minChars: 500,
58
+ maxChars: 4096,
59
+ };
60
+ }
61
+
62
+ return { blockStreaming, allowTool, replyCfg };
63
+ }