@xmoxmo/bncr 0.3.4 → 0.3.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.
Files changed (38) hide show
  1. package/dist/index.js +7 -3
  2. package/index.ts +6 -0
  3. package/openclaw.plugin.json +21 -0
  4. package/package.json +1 -1
  5. package/scripts/check-pack.mjs +97 -17
  6. package/scripts/check-register-drift.mjs +91 -65
  7. package/scripts/selfcheck.mjs +79 -3
  8. package/src/channel.ts +477 -635
  9. package/src/core/connection-capability.ts +2 -2
  10. package/src/core/connection-reachability.ts +106 -0
  11. package/src/core/dead-letter-diagnostics.ts +91 -0
  12. package/src/core/diagnostic-counters.ts +61 -0
  13. package/src/core/diagnostics.ts +9 -5
  14. package/src/core/downlink-health.ts +12 -7
  15. package/src/core/extended-diagnostics.ts +2 -0
  16. package/src/core/logging.ts +98 -0
  17. package/src/core/outbox-entry-builders.ts +13 -2
  18. package/src/core/persisted-outbox-entry.ts +53 -0
  19. package/src/core/probe.ts +33 -13
  20. package/src/core/register-trace.ts +48 -0
  21. package/src/core/status-meta.ts +77 -0
  22. package/src/core/status.ts +50 -57
  23. package/src/messaging/inbound/commands.ts +25 -86
  24. package/src/messaging/inbound/dispatch.ts +9 -36
  25. package/src/messaging/inbound/last-route.ts +46 -0
  26. package/src/messaging/inbound/native-command.ts +49 -0
  27. package/src/messaging/inbound/native-reply-delivery.ts +43 -0
  28. package/src/messaging/outbound/diagnostics.ts +221 -2
  29. package/src/messaging/outbound/reply-enqueue.ts +24 -1
  30. package/src/messaging/outbound/reply-target-policy.ts +4 -1
  31. package/src/messaging/outbound/send-params.ts +56 -0
  32. package/src/openclaw/runtime-surface.ts +29 -0
  33. package/src/plugin/gateway-methods.ts +2 -0
  34. package/src/plugin/status.ts +10 -4
  35. package/src/runtime/outbound-ack-timeout.ts +73 -0
  36. package/src/runtime/register-trace-runtime.ts +102 -0
  37. package/src/runtime/status-snapshots.ts +7 -3
  38. package/src/runtime/status-worker.ts +70 -11
@@ -81,6 +81,54 @@ export function dominantRegisterBucket(sourceBuckets: Record<string, number>) {
81
81
  return winner;
82
82
  }
83
83
 
84
+ export function normalizeRegisterDriftSnapshot(raw: unknown): RegisterDriftSnapshot | null {
85
+ if (!raw || typeof raw !== 'object') return null;
86
+ const data = raw as Record<string, unknown>;
87
+ const finiteOrNull = (value: unknown): number | null => {
88
+ const n = Number(value);
89
+ return Number.isFinite(n) ? n : null;
90
+ };
91
+ const cleanString = (value: unknown): string | null => {
92
+ const text = value == null ? '' : String(value);
93
+ return text.trim() || null;
94
+ };
95
+
96
+ return {
97
+ capturedAt: finiteNumberOr(data.capturedAt, 0),
98
+ registerCount: finiteOrNull(data.registerCount),
99
+ apiGeneration: finiteOrNull(data.apiGeneration),
100
+ postWarmupRegisterCount: finiteOrNull(data.postWarmupRegisterCount),
101
+ apiInstanceId: cleanString(data.apiInstanceId),
102
+ registryFingerprint: cleanString(data.registryFingerprint),
103
+ dominantBucket: cleanString(data.dominantBucket),
104
+ sourceBuckets:
105
+ data.sourceBuckets && typeof data.sourceBuckets === 'object'
106
+ ? { ...(data.sourceBuckets as Record<string, number>) }
107
+ : {},
108
+ traceWindowSize: finiteNumberOr(data.traceWindowSize, 0),
109
+ traceRecent: Array.isArray(data.traceRecent)
110
+ ? [...(data.traceRecent as Array<Record<string, unknown>>)]
111
+ : [],
112
+ };
113
+ }
114
+
115
+ export function dumpRegisterDriftSnapshot(snapshot: RegisterDriftSnapshot | null) {
116
+ return snapshot
117
+ ? {
118
+ capturedAt: snapshot.capturedAt,
119
+ registerCount: snapshot.registerCount,
120
+ apiGeneration: snapshot.apiGeneration,
121
+ postWarmupRegisterCount: snapshot.postWarmupRegisterCount,
122
+ apiInstanceId: snapshot.apiInstanceId,
123
+ registryFingerprint: snapshot.registryFingerprint,
124
+ dominantBucket: snapshot.dominantBucket,
125
+ sourceBuckets: { ...snapshot.sourceBuckets },
126
+ traceWindowSize: snapshot.traceWindowSize,
127
+ traceRecent: snapshot.traceRecent.map((trace) => ({ ...trace })),
128
+ }
129
+ : null;
130
+ }
131
+
84
132
  export function buildRegisterTraceEntry(args: {
85
133
  ts: number;
86
134
  bridgeId: string;
@@ -0,0 +1,77 @@
1
+ import type { PendingAdmission } from './types.ts';
2
+
3
+ type RuntimeStatusMetaDisplayInput = {
4
+ pendingAdmissions?: PendingAdmission[];
5
+ lastSession?: { scope: string; updatedAt: number } | null;
6
+ lastActivityAt?: number | null;
7
+ lastInboundAt?: number | null;
8
+ lastOutboundAt?: number | null;
9
+ };
10
+
11
+ function now() {
12
+ return Date.now();
13
+ }
14
+
15
+ function finiteNumberOrNull(value: unknown): number | null {
16
+ const n = Number(value);
17
+ return Number.isFinite(n) ? n : null;
18
+ }
19
+
20
+ function formatPendingAdmissionScope(route: unknown): string | null {
21
+ if (!route || typeof route !== 'object') return null;
22
+ const record = route as Record<string, unknown>;
23
+ if (
24
+ typeof record.platform !== 'string' ||
25
+ typeof record.groupId !== 'string' ||
26
+ typeof record.userId !== 'string'
27
+ ) {
28
+ return null;
29
+ }
30
+ return `${record.platform}:${record.groupId}:${record.userId}`;
31
+ }
32
+
33
+ function fmtAgo(ts?: number | null): string {
34
+ if (!ts || !Number.isFinite(ts) || ts <= 0) return '-';
35
+ const diff = Math.max(0, now() - ts);
36
+ if (diff < 1_000) return 'just now';
37
+ if (diff < 60_000) return `${Math.floor(diff / 1_000)}s ago`;
38
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
39
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
40
+ return `${Math.floor(diff / 86_400_000)}d ago`;
41
+ }
42
+
43
+ export function buildRuntimeStatusMetaDisplay(input: RuntimeStatusMetaDisplayInput) {
44
+ const lastSessionAt = finiteNumberOrNull(input.lastSession?.updatedAt);
45
+ const lastActivityAt = finiteNumberOrNull(input.lastActivityAt);
46
+ const lastInboundAt = finiteNumberOrNull(input.lastInboundAt);
47
+ const lastOutboundAt = finiteNumberOrNull(input.lastOutboundAt);
48
+
49
+ return {
50
+ pendingAdmissionsCount: Array.isArray(input.pendingAdmissions)
51
+ ? input.pendingAdmissions.length
52
+ : 0,
53
+ pendingAdmissions: Array.isArray(input.pendingAdmissions)
54
+ ? input.pendingAdmissions.map((item) => ({
55
+ clientId: item.clientId,
56
+ scope: formatPendingAdmissionScope(item.route),
57
+ scopes: Array.isArray(item.routes)
58
+ ? item.routes
59
+ .map((route) => formatPendingAdmissionScope(route))
60
+ .filter((scope): scope is string => scope !== null)
61
+ : [],
62
+ firstSeenAt: item.firstSeenAt,
63
+ lastSeenAt: item.lastSeenAt,
64
+ attempts: item.attempts,
65
+ }))
66
+ : [],
67
+ lastSessionScope: input.lastSession?.scope || null,
68
+ lastSessionAt,
69
+ lastSessionAgo: fmtAgo(lastSessionAt),
70
+ lastActivityAt,
71
+ lastActivityAgo: fmtAgo(lastActivityAt),
72
+ lastInboundAt,
73
+ lastInboundAgo: fmtAgo(lastInboundAt),
74
+ lastOutboundAt,
75
+ lastOutboundAgo: fmtAgo(lastOutboundAt),
76
+ };
77
+ }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { buildRuntimeStatusMetaDisplay } from './status-meta.ts';
3
4
  import type { BncrDiagnosticsSummary, PendingAdmission } from './types.ts';
4
5
 
5
6
  type RuntimeStatusInput = {
@@ -35,44 +36,50 @@ function finiteNumberOr(value: unknown, fallback: number): number {
35
36
  return Number.isFinite(n) ? n : fallback;
36
37
  }
37
38
 
38
- function fmtAgo(ts?: number | null): string {
39
- if (!ts || !Number.isFinite(ts) || ts <= 0) return '-';
40
- const diff = Math.max(0, now() - ts);
41
- if (diff < 1_000) return 'just now';
42
- if (diff < 60_000) return `${Math.floor(diff / 1_000)}s ago`;
43
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
44
- if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
45
- return `${Math.floor(diff / 86_400_000)}d ago`;
39
+ function nonNegativeFiniteNumberOr(value: unknown, fallback: number): number {
40
+ return Math.max(0, finiteNumberOr(value, fallback));
46
41
  }
47
42
 
48
43
  export function buildIntegratedDiagnostics(input: RuntimeStatusInput): BncrDiagnosticsSummary {
49
44
  const root = input.channelRoot || path.join(process.cwd(), 'plugins', 'bncr');
50
45
  const pluginIndexExists = fs.existsSync(path.join(root, 'index.ts'));
51
46
  const pluginChannelExists = fs.existsSync(path.join(root, 'src', 'channel.ts'));
47
+ const currentTime = now();
48
+ const startedAt = finiteNumberOr(input.startedAt, currentTime);
49
+ const pending = nonNegativeFiniteNumberOr(input.pending, 0);
50
+ const deadLetter = nonNegativeFiniteNumberOr(input.deadLetter, 0);
51
+ const activeConnections = nonNegativeFiniteNumberOr(input.activeConnections, 0);
52
+ const connectEvents = nonNegativeFiniteNumberOr(input.connectEvents, 0);
53
+ const inboundEvents = nonNegativeFiniteNumberOr(input.inboundEvents, 0);
54
+ const activityEvents = nonNegativeFiniteNumberOr(input.activityEvents, 0);
55
+ const ackEvents = nonNegativeFiniteNumberOr(input.ackEvents, 0);
56
+ const sessionRoutesCount = nonNegativeFiniteNumberOr(input.sessionRoutesCount, 0);
57
+ const invalidOutboxSessionKeys = nonNegativeFiniteNumberOr(input.invalidOutboxSessionKeys, 0);
58
+ const legacyAccountResidue = nonNegativeFiniteNumberOr(input.legacyAccountResidue, 0);
52
59
 
53
60
  return {
54
61
  health: {
55
62
  connected: input.connected,
56
- pending: input.pending,
63
+ pending,
57
64
  pendingAdmissions: Array.isArray(input.pendingAdmissions)
58
65
  ? input.pendingAdmissions.length
59
66
  : 0,
60
- deadLetter: input.deadLetter,
61
- activeConnections: input.activeConnections,
62
- connectEvents: input.connectEvents,
63
- inboundEvents: input.inboundEvents,
64
- activityEvents: input.activityEvents,
65
- ackEvents: input.ackEvents,
66
- uptimeSec: Math.floor((now() - input.startedAt) / 1000),
67
+ deadLetter,
68
+ activeConnections,
69
+ connectEvents,
70
+ inboundEvents,
71
+ activityEvents,
72
+ ackEvents,
73
+ uptimeSec: Math.max(0, Math.floor((currentTime - startedAt) / 1000)),
67
74
  },
68
75
  regression: {
69
76
  pluginFilesPresent: pluginIndexExists && pluginChannelExists,
70
77
  pluginIndexExists,
71
78
  pluginChannelExists,
72
- totalKnownRoutes: input.sessionRoutesCount,
73
- invalidOutboxSessionKeys: input.invalidOutboxSessionKeys,
74
- legacyAccountResidue: input.legacyAccountResidue,
75
- ok: input.invalidOutboxSessionKeys === 0 && input.legacyAccountResidue === 0,
79
+ totalKnownRoutes: sessionRoutesCount,
80
+ invalidOutboxSessionKeys,
81
+ legacyAccountResidue,
82
+ ok: invalidOutboxSessionKeys === 0 && legacyAccountResidue === 0,
76
83
  },
77
84
  };
78
85
  }
@@ -91,35 +98,21 @@ export function buildStatusHeadlineFromRuntime(input: RuntimeStatusInput): strin
91
98
 
92
99
  export function buildStatusMetaFromRuntime(input: RuntimeStatusInput) {
93
100
  const diagnostics = buildIntegratedDiagnostics(input);
101
+ const display = buildRuntimeStatusMetaDisplay(input);
94
102
  return {
95
- pending: input.pending,
96
- pendingAdmissionsCount: Array.isArray(input.pendingAdmissions)
97
- ? input.pendingAdmissions.length
98
- : 0,
99
- pendingAdmissions: Array.isArray(input.pendingAdmissions)
100
- ? input.pendingAdmissions.map((item) => ({
101
- clientId: item.clientId,
102
- scope: item.route
103
- ? `${item.route.platform}:${item.route.groupId}:${item.route.userId}`
104
- : null,
105
- scopes: Array.isArray(item.routes)
106
- ? item.routes.map((route) => `${route.platform}:${route.groupId}:${route.userId}`)
107
- : [],
108
- firstSeenAt: item.firstSeenAt,
109
- lastSeenAt: item.lastSeenAt,
110
- attempts: item.attempts,
111
- }))
112
- : [],
113
- deadLetter: input.deadLetter,
114
- lastSessionScope: input.lastSession?.scope || null,
115
- lastSessionAt: input.lastSession?.updatedAt || null,
116
- lastSessionAgo: fmtAgo(input.lastSession?.updatedAt || null),
117
- lastActivityAt: input.lastActivityAt || null,
118
- lastActivityAgo: fmtAgo(input.lastActivityAt || null),
119
- lastInboundAt: input.lastInboundAt || null,
120
- lastInboundAgo: fmtAgo(input.lastInboundAt || null),
121
- lastOutboundAt: input.lastOutboundAt || null,
122
- lastOutboundAgo: fmtAgo(input.lastOutboundAt || null),
103
+ pending: diagnostics.health.pending,
104
+ pendingAdmissionsCount: display.pendingAdmissionsCount,
105
+ pendingAdmissions: display.pendingAdmissions,
106
+ deadLetter: diagnostics.health.deadLetter,
107
+ lastSessionScope: display.lastSessionScope,
108
+ lastSessionAt: display.lastSessionAt,
109
+ lastSessionAgo: display.lastSessionAgo,
110
+ lastActivityAt: display.lastActivityAt,
111
+ lastActivityAgo: display.lastActivityAgo,
112
+ lastInboundAt: display.lastInboundAt,
113
+ lastInboundAgo: display.lastInboundAgo,
114
+ lastOutboundAt: display.lastOutboundAt,
115
+ lastOutboundAgo: display.lastOutboundAgo,
123
116
  diagnostics,
124
117
  };
125
118
  }
@@ -131,17 +124,17 @@ export function buildAccountRuntimeSnapshot(input: RuntimeStatusInput) {
131
124
  running: input.running ?? true,
132
125
  connected: input.connected,
133
126
  linked: input.connected,
134
- lastEventAt: input.lastActivityAt || null,
135
- lastInboundAt: input.lastInboundAt || null,
136
- lastOutboundAt: input.lastOutboundAt || null,
127
+ lastEventAt: meta.lastActivityAt,
128
+ lastInboundAt: meta.lastInboundAt,
129
+ lastOutboundAt: meta.lastOutboundAt,
137
130
  mode: input.connected ? 'linked' : 'configured',
138
131
  lastError: input.lastError ?? null,
139
- pending: input.pending,
140
- deadLetter: input.deadLetter,
132
+ pending: meta.pending,
133
+ deadLetter: meta.deadLetter,
141
134
  lastSessionKey: input.lastSession?.sessionKey || null,
142
135
  lastSessionScope: input.lastSession?.scope || null,
143
- lastSessionAt: input.lastSession?.updatedAt || null,
144
- lastActivityAt: input.lastActivityAt || null,
136
+ lastSessionAt: meta.lastSessionAt,
137
+ lastActivityAt: meta.lastActivityAt,
145
138
  diagnostics: meta.diagnostics,
146
139
  meta,
147
140
  };
@@ -156,8 +149,8 @@ export function buildAccountStatusSnapshot(input: {
156
149
  const rt = input.runtime || {};
157
150
  const meta = rt?.meta || {};
158
151
 
159
- const pending = finiteNumberOr(rt?.pending ?? meta.pending, 0);
160
- const deadLetter = finiteNumberOr(rt?.deadLetter ?? meta.deadLetter, 0);
152
+ const pending = nonNegativeFiniteNumberOr(rt?.pending ?? meta.pending, 0);
153
+ const deadLetter = nonNegativeFiniteNumberOr(rt?.deadLetter ?? meta.deadLetter, 0);
161
154
  const lastSessionKey = rt?.lastSessionKey ?? meta.lastSessionKey ?? null;
162
155
  const lastSessionScope = rt?.lastSessionScope ?? meta.lastSessionScope ?? null;
163
156
  const lastSessionAt = rt?.lastSessionAt ?? meta.lastSessionAt ?? null;
@@ -11,10 +11,11 @@ import {
11
11
  resolveBncrPinnedMainDmOwnerFromAllowlist,
12
12
  } from '../../openclaw/inbound-session-runtime.ts';
13
13
  import { dispatchOpenClawReplyWithBufferedBlockDispatcher } from '../../openclaw/reply-runtime.ts';
14
- import {
15
- resolveOpenClawAgentRoute,
16
- resolveOpenClawInboundLastRouteSessionKey,
17
- } from '../../openclaw/routing-runtime.ts';
14
+ import { resolveOpenClawAgentRoute } from '../../openclaw/routing-runtime.ts';
15
+ import type { OutboundReplyTargetPolicy } from '../outbound/reply-target-policy.ts';
16
+ import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
17
+ import { parseBncrNativeCommand, resolveBncrNativeVerboseCommand } from './native-command.ts';
18
+ import { buildBncrNativeReplyDeliveryPayload } from './native-reply-delivery.ts';
18
19
  import { buildBncrReplyConfig } from './reply-config.ts';
19
20
  import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
20
21
  import {
@@ -25,38 +26,6 @@ import {
25
26
 
26
27
  type ParsedInbound = ReturnType<typeof import('./parse.ts')['parseBncrInboundParams']>;
27
28
 
28
- type NativeCommand = {
29
- command: string;
30
- raw: string;
31
- body: string;
32
- };
33
-
34
- type NativeVerboseCommand = {
35
- handled: true;
36
- verboseLevel?: 'on' | 'off' | 'full';
37
- text: string;
38
- };
39
-
40
- function resolveBncrNativeVerboseCommand(command: NativeCommand): NativeVerboseCommand | null {
41
- if (command.command !== 'verbose') return null;
42
- const rawLevel = String(command.raw.slice('/verbose'.length) || '')
43
- .trim()
44
- .toLowerCase();
45
- if (!rawLevel || rawLevel === 'status') {
46
- return { handled: true, text: 'Current verbose level is unchanged.' };
47
- }
48
- if (rawLevel === 'on')
49
- return { handled: true, verboseLevel: 'on', text: 'Verbose logging enabled.' };
50
- if (rawLevel === 'off')
51
- return { handled: true, verboseLevel: 'off', text: 'Verbose logging disabled.' };
52
- if (rawLevel === 'full')
53
- return { handled: true, verboseLevel: 'full', text: 'Verbose logging set to full.' };
54
- return {
55
- handled: true,
56
- text: `Unrecognized verbose level "${rawLevel}". Valid levels: off, on, full.`,
57
- };
58
- }
59
-
60
29
  function logBncrNativeCommandEvent(
61
30
  event: string,
62
31
  fields: Record<string, unknown>,
@@ -66,21 +35,7 @@ function logBncrNativeCommandEvent(
66
35
  emitBncrLogLine('info', `[bncr] native-command ${JSON.stringify({ event, ...fields })}`);
67
36
  }
68
37
 
69
- export function parseBncrNativeCommand(text: string): NativeCommand | null {
70
- const raw = String(text || '').trim();
71
- if (!raw.startsWith('/')) return null;
72
- const match = raw.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/i);
73
- if (!match) return null;
74
-
75
- const command = String(match[1] || '')
76
- .trim()
77
- .toLowerCase();
78
- if (!command) return null;
79
-
80
- const rest = String(match[2] || '').trim();
81
- const body = command === 'help' ? ['/commands', rest].filter(Boolean).join(' ') : raw;
82
- return { command, raw, body };
83
- }
38
+ export { parseBncrNativeCommand } from './native-command.ts';
84
39
 
85
40
  export async function handleBncrNativeCommand(params: {
86
41
  api: any;
@@ -95,6 +50,7 @@ export async function handleBncrNativeCommand(params: {
95
50
  route: any;
96
51
  payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
97
52
  mediaLocalRoots?: readonly string[];
53
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
98
54
  }) => Promise<void>;
99
55
  logger?: { warn?: (msg: string) => void; error?: (msg: string) => void };
100
56
  }): Promise<
@@ -284,9 +240,15 @@ export async function handleBncrNativeCommand(params: {
284
240
  normalizeEntry: (entry: string) => String(entry || '').trim(),
285
241
  })
286
242
  : null;
287
- const inboundLastRouteSessionKey = resolveOpenClawInboundLastRouteSessionKey({
288
- route: resolvedRoute,
243
+ const updateLastRoute = buildBncrInboundRecordUpdateLastRoute({
244
+ channelId,
245
+ peerKind: peer.kind,
246
+ senderIdForContext,
247
+ accountId,
248
+ to: displayTo,
249
+ resolvedRoute,
289
250
  sessionKey,
251
+ pinnedMainDmOwner,
290
252
  });
291
253
 
292
254
  let responded = false;
@@ -325,22 +287,7 @@ export async function handleBncrNativeCommand(params: {
325
287
  expectedLabel: displayTo,
326
288
  }),
327
289
  record: {
328
- updateLastRoute:
329
- peer.kind === 'direct'
330
- ? {
331
- sessionKey: inboundLastRouteSessionKey,
332
- channel: channelId,
333
- to: displayTo,
334
- accountId,
335
- mainDmOwnerPin:
336
- inboundLastRouteSessionKey === resolvedRoute.mainSessionKey && pinnedMainDmOwner
337
- ? {
338
- ownerRecipient: pinnedMainDmOwner,
339
- senderRecipient: senderIdForContext,
340
- }
341
- : undefined,
342
- }
343
- : undefined,
290
+ updateLastRoute,
344
291
  onRecordError: (err: unknown) => {
345
292
  emitBncrLogLine(
346
293
  'warn',
@@ -363,18 +310,13 @@ export async function handleBncrNativeCommand(params: {
363
310
  info?: { kind?: 'tool' | 'block' | 'final' },
364
311
  ) => {
365
312
  const kind = info?.kind;
366
- const shouldForwardTool = effectiveReply.blockStreaming && effectiveReply.allowTool;
367
-
368
- if (kind === 'tool' && !shouldForwardTool) {
369
- return;
370
- }
371
-
372
- const hasPayload = Boolean(
373
- payload?.text ||
374
- payload?.mediaUrl ||
375
- (Array.isArray(payload?.mediaUrls) && payload.mediaUrls.length > 0),
376
- );
377
- if (!hasPayload) return;
313
+ const deliveryPayload = buildBncrNativeReplyDeliveryPayload({
314
+ payload,
315
+ kind,
316
+ effectiveReply,
317
+ msgId,
318
+ });
319
+ if (!deliveryPayload) return;
378
320
  if (!responded) {
379
321
  logBncrNativeCommandEvent(
380
322
  'payload-produced',
@@ -395,11 +337,8 @@ export async function handleBncrNativeCommand(params: {
395
337
  accountId,
396
338
  sessionKey,
397
339
  route,
398
- payload: {
399
- ...payload,
400
- kind: kind as 'tool' | 'block' | 'final' | undefined,
401
- replyToId: msgId || undefined,
402
- },
340
+ payload: deliveryPayload,
341
+ replyTargetPolicy: 'preserve',
403
342
  });
404
343
  },
405
344
  },
@@ -18,15 +18,14 @@ import {
18
18
  formatOpenClawAgentEnvelope,
19
19
  resolveOpenClawEnvelopeFormatOptions,
20
20
  } from '../../openclaw/reply-runtime.ts';
21
- import {
22
- resolveOpenClawAgentRoute,
23
- resolveOpenClawInboundLastRouteSessionKey,
24
- } from '../../openclaw/routing-runtime.ts';
21
+ import { resolveOpenClawAgentRoute } from '../../openclaw/routing-runtime.ts';
22
+ import type { OutboundReplyTargetPolicy } from '../outbound/reply-target-policy.ts';
25
23
  import { handleBncrNativeCommand } from './commands.ts';
26
24
  import {
27
25
  buildBncrPromptVisibleContextFacts,
28
26
  buildBncrStructuredContextFactsFromInboundParts,
29
27
  } from './context-facts.ts';
28
+ import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
30
29
  import { buildBncrReplyConfig } from './reply-config.ts';
31
30
  import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
32
31
  import { wrapBncrInboundRecordSessionLabelCorrection } from './session-label.ts';
@@ -330,36 +329,6 @@ function buildBncrInboundTurnContext(args: {
330
329
  });
331
330
  }
332
331
 
333
- function buildBncrInboundRecordUpdateLastRoute(args: {
334
- channelId: string;
335
- peer: ParsedInbound['peer'];
336
- senderIdForContext: string;
337
- resolution: BncrInboundConversationResolution;
338
- pinnedMainDmOwner: string | null;
339
- }) {
340
- const { channelId, peer, senderIdForContext, resolution, pinnedMainDmOwner } = args;
341
- if (peer.kind !== 'direct') return undefined;
342
-
343
- const sessionKey = resolveOpenClawInboundLastRouteSessionKey({
344
- route: resolution.resolvedRoute,
345
- sessionKey: resolution.dispatchSessionKey,
346
- });
347
-
348
- return {
349
- sessionKey,
350
- channel: channelId,
351
- to: resolution.canonicalTo,
352
- accountId: resolution.accountId,
353
- mainDmOwnerPin:
354
- sessionKey === resolution.resolvedRoute.mainSessionKey && pinnedMainDmOwner
355
- ? {
356
- ownerRecipient: pinnedMainDmOwner,
357
- senderRecipient: senderIdForContext,
358
- }
359
- : undefined,
360
- };
361
- }
362
-
363
332
  function buildBncrInboundReplyRouteFact(
364
333
  resolution: BncrInboundConversationResolution,
365
334
  ): BncrInboundReplyRouteFact {
@@ -386,6 +355,7 @@ export async function dispatchBncrInbound(params: {
386
355
  route: any;
387
356
  payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
388
357
  mediaLocalRoots?: readonly string[];
358
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
389
359
  }) => Promise<void>;
390
360
  setInboundActivity: (accountId: string, at: number) => void;
391
361
  scheduleSave: () => void;
@@ -478,9 +448,12 @@ export async function dispatchBncrInbound(params: {
478
448
  : null;
479
449
  const updateLastRoute = buildBncrInboundRecordUpdateLastRoute({
480
450
  channelId,
481
- peer,
451
+ peerKind: peer.kind,
482
452
  senderIdForContext,
483
- resolution,
453
+ accountId: resolution.accountId,
454
+ to: resolution.canonicalTo,
455
+ resolvedRoute: resolution.resolvedRoute,
456
+ sessionKey: resolution.dispatchSessionKey,
484
457
  pinnedMainDmOwner,
485
458
  });
486
459
 
@@ -0,0 +1,46 @@
1
+ import { resolveOpenClawInboundLastRouteSessionKey } from '../../openclaw/routing-runtime.ts';
2
+
3
+ export function buildBncrInboundRecordUpdateLastRoute(args: {
4
+ channelId: string;
5
+ peerKind: 'direct' | 'group';
6
+ senderIdForContext: string;
7
+ accountId: string;
8
+ to: string;
9
+ resolvedRoute: {
10
+ sessionKey: string;
11
+ mainSessionKey?: string;
12
+ };
13
+ sessionKey: string;
14
+ pinnedMainDmOwner: string | null;
15
+ }) {
16
+ const {
17
+ channelId,
18
+ peerKind,
19
+ senderIdForContext,
20
+ accountId,
21
+ to,
22
+ resolvedRoute,
23
+ sessionKey,
24
+ pinnedMainDmOwner,
25
+ } = args;
26
+ if (peerKind !== 'direct') return undefined;
27
+
28
+ const inboundLastRouteSessionKey = resolveOpenClawInboundLastRouteSessionKey({
29
+ route: resolvedRoute,
30
+ sessionKey,
31
+ });
32
+
33
+ return {
34
+ sessionKey: inboundLastRouteSessionKey,
35
+ channel: channelId,
36
+ to,
37
+ accountId,
38
+ mainDmOwnerPin:
39
+ inboundLastRouteSessionKey === resolvedRoute.mainSessionKey && pinnedMainDmOwner
40
+ ? {
41
+ ownerRecipient: pinnedMainDmOwner,
42
+ senderRecipient: senderIdForContext,
43
+ }
44
+ : undefined,
45
+ };
46
+ }
@@ -0,0 +1,49 @@
1
+ export type NativeCommand = {
2
+ command: string;
3
+ raw: string;
4
+ body: string;
5
+ };
6
+
7
+ export type NativeVerboseCommand = {
8
+ handled: true;
9
+ verboseLevel?: 'on' | 'off' | 'full';
10
+ text: string;
11
+ };
12
+
13
+ export function parseBncrNativeCommand(text: string): NativeCommand | null {
14
+ const raw = String(text || '').trim();
15
+ if (!raw.startsWith('/')) return null;
16
+ const match = raw.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/i);
17
+ if (!match) return null;
18
+
19
+ const command = String(match[1] || '')
20
+ .trim()
21
+ .toLowerCase();
22
+ if (!command) return null;
23
+
24
+ const rest = String(match[2] || '').trim();
25
+ const body = command === 'help' ? ['/commands', rest].filter(Boolean).join(' ') : raw;
26
+ return { command, raw, body };
27
+ }
28
+
29
+ export function resolveBncrNativeVerboseCommand(
30
+ command: NativeCommand,
31
+ ): NativeVerboseCommand | null {
32
+ if (command.command !== 'verbose') return null;
33
+ const rawLevel = String(command.raw.slice('/verbose'.length) || '')
34
+ .trim()
35
+ .toLowerCase();
36
+ if (!rawLevel || rawLevel === 'status') {
37
+ return { handled: true, text: 'Current verbose level is unchanged.' };
38
+ }
39
+ if (rawLevel === 'on')
40
+ return { handled: true, verboseLevel: 'on', text: 'Verbose logging enabled.' };
41
+ if (rawLevel === 'off')
42
+ return { handled: true, verboseLevel: 'off', text: 'Verbose logging disabled.' };
43
+ if (rawLevel === 'full')
44
+ return { handled: true, verboseLevel: 'full', text: 'Verbose logging set to full.' };
45
+ return {
46
+ handled: true,
47
+ text: `Unrecognized verbose level "${rawLevel}". Valid levels: off, on, full.`,
48
+ };
49
+ }
@@ -0,0 +1,43 @@
1
+ type BncrNativeReplyKind = 'tool' | 'block' | 'final';
2
+
3
+ export type BncrNativeReplyDeliveryPayload = {
4
+ text?: string;
5
+ mediaUrl?: string;
6
+ mediaUrls?: string[];
7
+ audioAsVoice?: boolean;
8
+ replyToId?: string;
9
+ };
10
+
11
+ export function buildBncrNativeReplyDeliveryPayload(args: {
12
+ payload?: {
13
+ text?: string;
14
+ mediaUrl?: string;
15
+ mediaUrls?: string[];
16
+ audioAsVoice?: boolean;
17
+ };
18
+ kind?: BncrNativeReplyKind;
19
+ effectiveReply: {
20
+ blockStreaming: boolean;
21
+ allowTool: boolean;
22
+ };
23
+ msgId?: string;
24
+ }): BncrNativeReplyDeliveryPayload | null {
25
+ const { payload, kind, effectiveReply, msgId } = args;
26
+ const shouldForwardTool = effectiveReply.blockStreaming && effectiveReply.allowTool;
27
+
28
+ if (kind === 'tool' && !shouldForwardTool) {
29
+ return null;
30
+ }
31
+
32
+ const hasPayload = Boolean(
33
+ payload?.text ||
34
+ payload?.mediaUrl ||
35
+ (Array.isArray(payload?.mediaUrls) && payload.mediaUrls.length > 0),
36
+ );
37
+ if (!hasPayload) return null;
38
+
39
+ return {
40
+ ...payload,
41
+ replyToId: msgId || undefined,
42
+ };
43
+ }