@xmoxmo/bncr 0.3.3 → 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 (61) hide show
  1. package/dist/index.js +7 -3
  2. package/index.ts +11 -10
  3. package/openclaw.plugin.json +21 -0
  4. package/package.json +4 -4
  5. package/scripts/check-pack.mjs +112 -22
  6. package/scripts/check-register-drift.mjs +91 -65
  7. package/scripts/selfcheck.mjs +79 -3
  8. package/src/channel.ts +549 -810
  9. package/src/core/accounts.ts +1 -1
  10. package/src/core/connection-capability.ts +2 -2
  11. package/src/core/connection-reachability.ts +112 -1
  12. package/src/core/dead-letter-diagnostics.ts +91 -0
  13. package/src/core/diagnostic-counters.ts +61 -0
  14. package/src/core/diagnostics.ts +9 -5
  15. package/src/core/downlink-health.ts +15 -10
  16. package/src/core/extended-diagnostics.ts +4 -0
  17. package/src/core/file-transfer-payloads.ts +1 -4
  18. package/src/core/logging.ts +98 -0
  19. package/src/core/outbox-entry-builders.ts +15 -2
  20. package/src/core/outbox-file-transfer-bookkeeping.ts +1 -1
  21. package/src/core/outbox-file-transfer-failure.ts +2 -5
  22. package/src/core/outbox-file-transfer-success.ts +1 -4
  23. package/src/core/outbox-text-push-failure.ts +2 -4
  24. package/src/core/outbox-text-push-success.ts +1 -1
  25. package/src/core/persisted-outbox-entry.ts +53 -0
  26. package/src/core/probe.ts +33 -13
  27. package/src/core/register-trace.ts +48 -0
  28. package/src/core/status-meta.ts +77 -0
  29. package/src/core/status.ts +50 -57
  30. package/src/messaging/inbound/commands.ts +42 -94
  31. package/src/messaging/inbound/dispatch.ts +25 -54
  32. package/src/messaging/inbound/last-route.ts +46 -0
  33. package/src/messaging/inbound/native-command.ts +49 -0
  34. package/src/messaging/inbound/native-reply-delivery.ts +43 -0
  35. package/src/messaging/inbound/parse.ts +3 -3
  36. package/src/messaging/inbound/runtime-compat.ts +8 -2
  37. package/src/messaging/outbound/build-send-action.ts +1 -2
  38. package/src/messaging/outbound/diagnostics.ts +221 -2
  39. package/src/messaging/outbound/durable-message-adapter.ts +15 -5
  40. package/src/messaging/outbound/durable-queue-adapter.ts +3 -1
  41. package/src/messaging/outbound/media.ts +2 -1
  42. package/src/messaging/outbound/queue-selectors.ts +19 -6
  43. package/src/messaging/outbound/reasons.ts +2 -0
  44. package/src/messaging/outbound/reply-enqueue.ts +29 -2
  45. package/src/messaging/outbound/reply-target-policy.ts +4 -1
  46. package/src/messaging/outbound/retry-policy.ts +16 -8
  47. package/src/messaging/outbound/send-params.ts +56 -0
  48. package/src/messaging/outbound/session-route.ts +1 -1
  49. package/src/openclaw/reply-runtime.ts +4 -5
  50. package/src/openclaw/routing-runtime.ts +0 -1
  51. package/src/openclaw/runtime-surface.ts +29 -0
  52. package/src/openclaw/sdk-helpers.ts +4 -1
  53. package/src/plugin/gateway-methods.ts +2 -0
  54. package/src/plugin/messaging.ts +2 -9
  55. package/src/plugin/status.ts +15 -5
  56. package/src/runtime/outbound-ack-timeout.ts +73 -0
  57. package/src/runtime/outbound-flags.ts +1 -1
  58. package/src/runtime/outbox-transitions.ts +4 -4
  59. package/src/runtime/register-trace-runtime.ts +102 -0
  60. package/src/runtime/status-snapshots.ts +10 -4
  61. package/src/runtime/status-worker.ts +78 -13
@@ -0,0 +1,53 @@
1
+ import { normalizeAccountId } from './accounts.ts';
2
+ import { normalizeStoredSessionKey, parseRouteLike } from './targets.ts';
3
+ import type { OutboxEntry } from './types.ts';
4
+
5
+ function asString(v: unknown, fallback = ''): string {
6
+ if (typeof v === 'string') return v;
7
+ if (v == null) return fallback;
8
+ return String(v);
9
+ }
10
+
11
+ function finiteNumberOr(value: unknown, fallback: number): number {
12
+ const n = Number(value);
13
+ return Number.isFinite(n) ? n : fallback;
14
+ }
15
+
16
+ function optionalFiniteNumber(value: unknown): number | undefined {
17
+ if (value == null || value === '') return undefined;
18
+ const n = Number(value);
19
+ return Number.isFinite(n) ? n : undefined;
20
+ }
21
+
22
+ export function normalizePersistedOutboxEntry(args: {
23
+ entry: any;
24
+ canonicalAgentId: string;
25
+ now: () => number;
26
+ }): OutboxEntry | null {
27
+ const { entry, canonicalAgentId } = args;
28
+ if (!entry?.messageId) return null;
29
+ const accountId = normalizeAccountId(entry.accountId);
30
+ const sessionKey = asString(entry.sessionKey || '').trim();
31
+ const normalized = normalizeStoredSessionKey(sessionKey, canonicalAgentId);
32
+ if (!normalized) return null;
33
+
34
+ const route = parseRouteLike(entry.route) || normalized.route;
35
+ const payload = entry.payload && typeof entry.payload === 'object' ? { ...entry.payload } : {};
36
+ (payload as any).sessionKey = normalized.sessionKey;
37
+ (payload as any).platform = route.platform;
38
+ (payload as any).groupId = route.groupId;
39
+ (payload as any).userId = route.userId;
40
+
41
+ return {
42
+ ...entry,
43
+ accountId,
44
+ sessionKey: normalized.sessionKey,
45
+ route,
46
+ payload,
47
+ createdAt: finiteNumberOr(entry.createdAt, args.now()),
48
+ retryCount: finiteNumberOr(entry.retryCount, 0),
49
+ nextAttemptAt: finiteNumberOr(entry.nextAttemptAt, args.now()),
50
+ lastAttemptAt: optionalFiniteNumber(entry.lastAttemptAt),
51
+ lastError: entry.lastError ? asString(entry.lastError) : undefined,
52
+ };
53
+ }
package/src/core/probe.ts CHANGED
@@ -1,3 +1,19 @@
1
+ function finiteNumberOr(value: unknown, fallback: number): number {
2
+ if (value == null) return fallback;
3
+ const n = Number(value);
4
+ return Number.isFinite(n) ? n : fallback;
5
+ }
6
+
7
+ function finiteNumberOrNull(value: unknown): number | null {
8
+ if (value == null) return null;
9
+ const n = Number(value);
10
+ return Number.isFinite(n) ? n : null;
11
+ }
12
+
13
+ function nonNegativeFiniteNumberOr(value: unknown, fallback: number): number {
14
+ return Math.max(0, finiteNumberOr(value, fallback));
15
+ }
16
+
1
17
  export function probeBncrAccount(params: {
2
18
  accountId: string;
3
19
  connected: boolean;
@@ -14,18 +30,22 @@ export function probeBncrAccount(params: {
14
30
  };
15
31
  }) {
16
32
  const issues: string[] = [];
33
+ const pending = nonNegativeFiniteNumberOr(params.pending, 0);
34
+ const deadLetter = nonNegativeFiniteNumberOr(params.deadLetter, 0);
35
+ const activeConnections = nonNegativeFiniteNumberOr(params.activeConnections, 0);
36
+ const invalidOutboxSessionKeys = nonNegativeFiniteNumberOr(params.invalidOutboxSessionKeys, 0);
37
+ const legacyAccountResidue = nonNegativeFiniteNumberOr(params.legacyAccountResidue, 0);
17
38
 
18
39
  if (!params.connected) issues.push('not-connected');
19
- if (params.pending > 20) issues.push('pending-high');
20
- if (params.deadLetter > 0) issues.push('dead-letter');
21
- if (params.activeConnections > 3) issues.push('too-many-connections');
22
- if (params.invalidOutboxSessionKeys > 0) issues.push('invalid-session-keys');
23
- if (params.legacyAccountResidue > 0) issues.push('legacy-account-residue');
40
+ if (pending > 20) issues.push('pending-high');
41
+ if (deadLetter > 0) issues.push('dead-letter');
42
+ if (activeConnections > 3) issues.push('too-many-connections');
43
+ if (invalidOutboxSessionKeys > 0) issues.push('invalid-session-keys');
44
+ if (legacyAccountResidue > 0) issues.push('legacy-account-residue');
24
45
 
25
46
  let level: 'ok' | 'warn' | 'error' = 'ok';
26
47
  if (issues.length > 0) level = 'warn';
27
- if (!params.connected && (params.deadLetter > 0 || params.invalidOutboxSessionKeys > 0))
28
- level = 'error';
48
+ if (!params.connected && (deadLetter > 0 || invalidOutboxSessionKeys > 0)) level = 'error';
29
49
 
30
50
  return {
31
51
  ok: level === 'ok',
@@ -34,12 +54,12 @@ export function probeBncrAccount(params: {
34
54
  details: {
35
55
  accountId: params.accountId,
36
56
  connected: params.connected,
37
- pending: params.pending,
38
- deadLetter: params.deadLetter,
39
- activeConnections: params.activeConnections,
40
- invalidOutboxSessionKeys: params.invalidOutboxSessionKeys,
41
- legacyAccountResidue: params.legacyAccountResidue,
42
- lastActivityAt: params.lastActivityAt ?? null,
57
+ pending,
58
+ deadLetter,
59
+ activeConnections,
60
+ invalidOutboxSessionKeys,
61
+ legacyAccountResidue,
62
+ lastActivityAt: finiteNumberOrNull(params.lastActivityAt),
43
63
  structure: params.structure ?? null,
44
64
  },
45
65
  };
@@ -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;
@@ -5,6 +5,17 @@ import {
5
5
  normalizeInboundSessionKey,
6
6
  withTaskSessionKey,
7
7
  } from '../../core/targets.ts';
8
+ import {
9
+ recordBncrInboundSession,
10
+ resolveBncrInboundSessionStorePath,
11
+ resolveBncrPinnedMainDmOwnerFromAllowlist,
12
+ } from '../../openclaw/inbound-session-runtime.ts';
13
+ import { dispatchOpenClawReplyWithBufferedBlockDispatcher } from '../../openclaw/reply-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';
8
19
  import { buildBncrReplyConfig } from './reply-config.ts';
9
20
  import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
10
21
  import {
@@ -12,43 +23,9 @@ import {
12
23
  recordAndPatchBncrInboundSessionEntry,
13
24
  wrapBncrInboundRecordSessionLabelCorrection,
14
25
  } from './session-label.ts';
15
- import { dispatchOpenClawReplyWithBufferedBlockDispatcher } from '../../openclaw/reply-runtime.ts';
16
- import {
17
- resolveOpenClawAgentRoute,
18
- resolveOpenClawInboundLastRouteSessionKey,
19
- } from '../../openclaw/routing-runtime.ts';
20
- import {
21
- recordBncrInboundSession,
22
- resolveBncrInboundSessionStorePath,
23
- resolveBncrPinnedMainDmOwnerFromAllowlist,
24
- } from '../../openclaw/inbound-session-runtime.ts';
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) || '').trim().toLowerCase();
43
- if (!rawLevel || rawLevel === 'status') {
44
- return { handled: true, text: 'Current verbose level is unchanged.' };
45
- }
46
- if (rawLevel === 'on') return { handled: true, verboseLevel: 'on', text: 'Verbose logging enabled.' };
47
- if (rawLevel === 'off') return { handled: true, verboseLevel: 'off', text: 'Verbose logging disabled.' };
48
- if (rawLevel === 'full') return { handled: true, verboseLevel: 'full', text: 'Verbose logging set to full.' };
49
- return { handled: true, text: `Unrecognized verbose level "${rawLevel}". Valid levels: off, on, full.` };
50
- }
51
-
52
29
  function logBncrNativeCommandEvent(
53
30
  event: string,
54
31
  fields: Record<string, unknown>,
@@ -58,21 +35,7 @@ function logBncrNativeCommandEvent(
58
35
  emitBncrLogLine('info', `[bncr] native-command ${JSON.stringify({ event, ...fields })}`);
59
36
  }
60
37
 
61
- export function parseBncrNativeCommand(text: string): NativeCommand | null {
62
- const raw = String(text || '').trim();
63
- if (!raw.startsWith('/')) return null;
64
- const match = raw.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/i);
65
- if (!match) return null;
66
-
67
- const command = String(match[1] || '')
68
- .trim()
69
- .toLowerCase();
70
- if (!command) return null;
71
-
72
- const rest = String(match[2] || '').trim();
73
- const body = command === 'help' ? ['/commands', rest].filter(Boolean).join(' ') : raw;
74
- return { command, raw, body };
75
- }
38
+ export { parseBncrNativeCommand } from './native-command.ts';
76
39
 
77
40
  export async function handleBncrNativeCommand(params: {
78
41
  api: any;
@@ -87,23 +50,25 @@ export async function handleBncrNativeCommand(params: {
87
50
  route: any;
88
51
  payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
89
52
  mediaLocalRoots?: readonly string[];
53
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
90
54
  }) => Promise<void>;
91
55
  logger?: { warn?: (msg: string) => void; error?: (msg: string) => void };
92
56
  }): Promise<
93
57
  | { handled: false }
94
58
  | { handled: true; command: string; sessionKey: string; fallbackToAgent?: boolean }
95
59
  > {
60
+ const { api, channelId, cfg, parsed, canonicalAgentId, rememberSessionRoute, enqueueFromReply } =
61
+ params;
96
62
  const {
97
- api,
98
- channelId,
99
- cfg,
100
- parsed,
101
- canonicalAgentId,
102
- rememberSessionRoute,
103
- enqueueFromReply,
104
- logger,
105
- } = params;
106
- const { accountId, route, peer, sessionKeyfromroute, providedOriginatingTo, clientId, extracted, msgId } = parsed;
63
+ accountId,
64
+ route,
65
+ peer,
66
+ sessionKeyfromroute,
67
+ providedOriginatingTo,
68
+ clientId,
69
+ extracted,
70
+ msgId,
71
+ } = parsed;
107
72
  const command = parseBncrNativeCommand(extracted.text);
108
73
  if (!command) return { handled: false };
109
74
  const nativeCommandDebugEnabled = cfg?.channels?.[channelId]?.debug?.verbose === true;
@@ -275,9 +240,15 @@ export async function handleBncrNativeCommand(params: {
275
240
  normalizeEntry: (entry: string) => String(entry || '').trim(),
276
241
  })
277
242
  : null;
278
- const inboundLastRouteSessionKey = resolveOpenClawInboundLastRouteSessionKey({
279
- route: resolvedRoute,
243
+ const updateLastRoute = buildBncrInboundRecordUpdateLastRoute({
244
+ channelId,
245
+ peerKind: peer.kind,
246
+ senderIdForContext,
247
+ accountId,
248
+ to: displayTo,
249
+ resolvedRoute,
280
250
  sessionKey,
251
+ pinnedMainDmOwner,
281
252
  });
282
253
 
283
254
  let responded = false;
@@ -316,22 +287,7 @@ export async function handleBncrNativeCommand(params: {
316
287
  expectedLabel: displayTo,
317
288
  }),
318
289
  record: {
319
- updateLastRoute:
320
- peer.kind === 'direct'
321
- ? {
322
- sessionKey: inboundLastRouteSessionKey,
323
- channel: channelId,
324
- to: displayTo,
325
- accountId,
326
- mainDmOwnerPin:
327
- inboundLastRouteSessionKey === resolvedRoute.mainSessionKey && pinnedMainDmOwner
328
- ? {
329
- ownerRecipient: pinnedMainDmOwner,
330
- senderRecipient: senderIdForContext,
331
- }
332
- : undefined,
333
- }
334
- : undefined,
290
+ updateLastRoute,
335
291
  onRecordError: (err: unknown) => {
336
292
  emitBncrLogLine(
337
293
  'warn',
@@ -354,18 +310,13 @@ export async function handleBncrNativeCommand(params: {
354
310
  info?: { kind?: 'tool' | 'block' | 'final' },
355
311
  ) => {
356
312
  const kind = info?.kind;
357
- const shouldForwardTool = effectiveReply.blockStreaming && effectiveReply.allowTool;
358
-
359
- if (kind === 'tool' && !shouldForwardTool) {
360
- return;
361
- }
362
-
363
- const hasPayload = Boolean(
364
- payload?.text ||
365
- payload?.mediaUrl ||
366
- (Array.isArray(payload?.mediaUrls) && payload.mediaUrls.length > 0),
367
- );
368
- if (!hasPayload) return;
313
+ const deliveryPayload = buildBncrNativeReplyDeliveryPayload({
314
+ payload,
315
+ kind,
316
+ effectiveReply,
317
+ msgId,
318
+ });
319
+ if (!deliveryPayload) return;
369
320
  if (!responded) {
370
321
  logBncrNativeCommandEvent(
371
322
  'payload-produced',
@@ -386,11 +337,8 @@ export async function handleBncrNativeCommand(params: {
386
337
  accountId,
387
338
  sessionKey,
388
339
  route,
389
- payload: {
390
- ...payload,
391
- kind: kind as 'tool' | 'block' | 'final' | undefined,
392
- replyToId: msgId || undefined,
393
- },
340
+ payload: deliveryPayload,
341
+ replyTargetPolicy: 'preserve',
394
342
  });
395
343
  },
396
344
  },