@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
@@ -1,7 +1,226 @@
1
- import type { BncrConnection } from '../../core/types.ts';
1
+ import type { BncrConnection, OutboxEntry } from '../../core/types.ts';
2
2
  import { OUTBOUND_TERMINAL_REASON, type OutboundScheduleSource } from './reasons.ts';
3
3
  import type { RetryRerouteDecision } from './retry-policy.ts';
4
4
 
5
+ type OutboxIncidentSummaryInput = {
6
+ pending: number;
7
+ oldestPendingAt?: number | null;
8
+ lastAttemptAt?: number | null;
9
+ lastPushAt?: number | null;
10
+ lastPushError?: string | null;
11
+ hasGatewayContext: boolean;
12
+ activeOutboundConnection: boolean;
13
+ activeOutboundConnectionCount: number;
14
+ prePushGuardSkipCount?: number;
15
+ lastPrePushGuardSkipAt?: number | null;
16
+ lastPrePushGuardSkipReason?: string | null;
17
+ lastAckQueueLatencyMs?: number | null;
18
+ lastAckPushLatencyMs?: number | null;
19
+ lastLateAckQueueLatencyMs?: number | null;
20
+ lastLateAckPushLatencyMs?: number | null;
21
+ lastLateAckOkAt?: number | null;
22
+ adaptiveAckTimeoutMs?: number | null;
23
+ adaptiveAckTimeoutReason?: string | null;
24
+ nowMs?: number;
25
+ };
26
+
27
+ function finiteNumberOrNull(value: unknown): number | null {
28
+ if (value == null) return null;
29
+ const n = Number(value);
30
+ return Number.isFinite(n) ? n : null;
31
+ }
32
+
33
+ function positiveAgeMs(nowMs: number, at: unknown): number | null {
34
+ const n = finiteNumberOrNull(at);
35
+ if (n === null || n < 0) return null;
36
+ return Math.max(0, nowMs - n);
37
+ }
38
+
39
+ export function buildOutboxIncidentSummary(input: OutboxIncidentSummaryInput) {
40
+ const pending = Math.max(0, Math.floor(finiteNumberOrNull(input.pending) || 0));
41
+ const nowMs = finiteNumberOrNull(input.nowMs) || Date.now();
42
+ const lastPushError =
43
+ typeof input.lastPushError === 'string' && input.lastPushError ? input.lastPushError : null;
44
+ const lastPrePushGuardSkipReason =
45
+ typeof input.lastPrePushGuardSkipReason === 'string' && input.lastPrePushGuardSkipReason
46
+ ? input.lastPrePushGuardSkipReason
47
+ : null;
48
+ const oldestPendingAgeMs = positiveAgeMs(nowMs, input.oldestPendingAt);
49
+ const lastLateAckAgeMs = positiveAgeMs(nowMs, input.lastLateAckOkAt);
50
+ const adaptiveAckTimeoutReason =
51
+ typeof input.adaptiveAckTimeoutReason === 'string' && input.adaptiveAckTimeoutReason
52
+ ? input.adaptiveAckTimeoutReason
53
+ : null;
54
+ const hasRecentLateAck = lastLateAckAgeMs !== null && lastLateAckAgeMs <= 3_600_000;
55
+ const hasActiveAdaptiveAck =
56
+ adaptiveAckTimeoutReason !== null &&
57
+ ![
58
+ 'no-timeout-evidence',
59
+ 'no-late-ack-evidence',
60
+ 'missing-latency',
61
+ 'late-ack-expired',
62
+ 'recovered',
63
+ ].includes(adaptiveAckTimeoutReason);
64
+
65
+ let type = 'none';
66
+ let severity: 'ok' | 'warning' | 'critical' = 'ok';
67
+ let recommendedAction = 'none';
68
+
69
+ if (pending > 0 && !input.hasGatewayContext) {
70
+ type = 'no-gateway-context';
71
+ severity = 'critical';
72
+ recommendedAction = 'check-channel-message-runtime-context';
73
+ } else if (pending > 0 && !input.activeOutboundConnection) {
74
+ type = 'no-active-outbound-connection';
75
+ severity = 'critical';
76
+ recommendedAction = 'reconnect-bncr-client';
77
+ } else if (pending > 0 && lastPrePushGuardSkipReason) {
78
+ type = lastPrePushGuardSkipReason;
79
+ severity = 'warning';
80
+ recommendedAction = 'inspect-pre-push-guard';
81
+ } else if (pending > 0 && lastPushError) {
82
+ type =
83
+ lastPushError.includes('ack') || lastPushError.includes('timeout')
84
+ ? 'ack-timeout'
85
+ : lastPushError;
86
+ severity = type === 'ack-timeout' ? 'critical' : 'warning';
87
+ recommendedAction = 'inspect-ack-and-route-state';
88
+ } else if (pending > 0 && oldestPendingAgeMs !== null && oldestPendingAgeMs >= 120_000) {
89
+ type = 'outbox-backlog';
90
+ severity = 'warning';
91
+ recommendedAction = 'inspect-outbox-drain';
92
+ } else if (pending > 0) {
93
+ type = 'pending-outbox';
94
+ severity = 'warning';
95
+ recommendedAction = 'inspect-outbox-drain';
96
+ } else if (hasRecentLateAck || hasActiveAdaptiveAck) {
97
+ type = 'slow-or-late-ack';
98
+ severity = 'warning';
99
+ recommendedAction = 'inspect-ack-latency';
100
+ }
101
+
102
+ return {
103
+ active: type !== 'none',
104
+ type,
105
+ severity,
106
+ recommendedAction,
107
+ pending,
108
+ oldestPendingAgeMs,
109
+ lastAttemptAgeMs: positiveAgeMs(nowMs, input.lastAttemptAt),
110
+ lastPushAgeMs: positiveAgeMs(nowMs, input.lastPushAt),
111
+ lastPushError,
112
+ hasGatewayContext: input.hasGatewayContext,
113
+ activeOutboundConnection: input.activeOutboundConnection,
114
+ activeOutboundConnectionCount: Math.max(
115
+ 0,
116
+ Math.floor(finiteNumberOrNull(input.activeOutboundConnectionCount) || 0),
117
+ ),
118
+ prePushGuardSkipCount: Math.max(
119
+ 0,
120
+ Math.floor(finiteNumberOrNull(input.prePushGuardSkipCount) || 0),
121
+ ),
122
+ lastPrePushGuardSkipAgeMs: positiveAgeMs(nowMs, input.lastPrePushGuardSkipAt),
123
+ lastPrePushGuardSkipReason,
124
+ ack: {
125
+ lastQueueLatencyMs: finiteNumberOrNull(input.lastAckQueueLatencyMs),
126
+ lastPushLatencyMs: finiteNumberOrNull(input.lastAckPushLatencyMs),
127
+ lastLateQueueLatencyMs: finiteNumberOrNull(input.lastLateAckQueueLatencyMs),
128
+ lastLatePushLatencyMs: finiteNumberOrNull(input.lastLateAckPushLatencyMs),
129
+ lastLateAckAgeMs,
130
+ adaptiveTimeoutMs: finiteNumberOrNull(input.adaptiveAckTimeoutMs),
131
+ adaptiveTimeoutReason: adaptiveAckTimeoutReason,
132
+ },
133
+ };
134
+ }
135
+
136
+ export function buildExtendedOutboundDiagnostics(input: {
137
+ outbox: Record<string, any>;
138
+ enqueueCount: number;
139
+ lastEnqueueAt?: number | null;
140
+ prePushGuardSkipCount: number;
141
+ lastPrePushGuardSkipAt?: number | null;
142
+ lastPrePushGuardSkipReason?: string | null;
143
+ hasGatewayContext: boolean;
144
+ lastGatewayContextAt?: number | null;
145
+ ackObservability: Record<string, any>;
146
+ nowMs?: number;
147
+ }) {
148
+ const lastEnqueueAt = finiteNumberOrNull(input.lastEnqueueAt);
149
+ const lastPrePushGuardSkipAt = finiteNumberOrNull(input.lastPrePushGuardSkipAt);
150
+ const lastGatewayContextAt = finiteNumberOrNull(input.lastGatewayContextAt);
151
+ return {
152
+ ...input.outbox,
153
+ enqueueCount: input.enqueueCount,
154
+ lastEnqueueAt,
155
+ prePushGuardSkipCount: input.prePushGuardSkipCount,
156
+ lastPrePushGuardSkipAt,
157
+ lastPrePushGuardSkipReason: input.lastPrePushGuardSkipReason || null,
158
+ hasGatewayContext: input.hasGatewayContext,
159
+ lastGatewayContextAt,
160
+ incident: buildOutboxIncidentSummary({
161
+ ...input.outbox,
162
+ hasGatewayContext: input.hasGatewayContext,
163
+ prePushGuardSkipCount: input.prePushGuardSkipCount,
164
+ lastPrePushGuardSkipAt,
165
+ lastPrePushGuardSkipReason: input.lastPrePushGuardSkipReason || null,
166
+ lastAckQueueLatencyMs: input.ackObservability.lastAckQueueLatencyMs,
167
+ lastAckPushLatencyMs: input.ackObservability.lastAckPushLatencyMs,
168
+ lastLateAckQueueLatencyMs: input.ackObservability.lastLateAckQueueLatencyMs,
169
+ lastLateAckPushLatencyMs: input.ackObservability.lastLateAckPushLatencyMs,
170
+ lastLateAckOkAt: input.ackObservability.lastLateAckOkAt,
171
+ adaptiveAckTimeoutMs: input.ackObservability.currentAckTimeoutMs,
172
+ adaptiveAckTimeoutReason: input.ackObservability.recommendedAckTimeoutReason,
173
+ nowMs: input.nowMs,
174
+ }),
175
+ };
176
+ }
177
+
178
+ export function buildOutboxQueueDiagnostics(args: {
179
+ accountId: string;
180
+ outboxEntries: Iterable<OutboxEntry>;
181
+ pendingAllAccounts: number;
182
+ pushConnIds: Iterable<string>;
183
+ }) {
184
+ const accountEntries = Array.from(args.outboxEntries).filter(
185
+ (entry) => entry.accountId === args.accountId,
186
+ );
187
+ let oldestPendingAt: number | null = null;
188
+ let newestPendingAt: number | null = null;
189
+ let lastAttemptAt: number | null = null;
190
+ let lastPushAt: number | null = null;
191
+ let lastError: string | null = null;
192
+
193
+ for (const entry of accountEntries) {
194
+ const createdAt = Number(entry.createdAt);
195
+ if (Number.isFinite(createdAt)) {
196
+ oldestPendingAt = oldestPendingAt === null ? createdAt : Math.min(oldestPendingAt, createdAt);
197
+ newestPendingAt = newestPendingAt === null ? createdAt : Math.max(newestPendingAt, createdAt);
198
+ }
199
+ const attemptAt = Number(entry.lastAttemptAt);
200
+ if (Number.isFinite(attemptAt) && (lastAttemptAt === null || attemptAt > lastAttemptAt)) {
201
+ lastAttemptAt = attemptAt;
202
+ }
203
+ const pushAt = Number(entry.lastPushAt);
204
+ if (Number.isFinite(pushAt) && (lastPushAt === null || pushAt > lastPushAt)) {
205
+ lastPushAt = pushAt;
206
+ lastError = entry.lastError || null;
207
+ }
208
+ }
209
+
210
+ const pushConnIds = Array.from(args.pushConnIds);
211
+ return {
212
+ pending: accountEntries.length,
213
+ pendingAllAccounts: args.pendingAllAccounts,
214
+ oldestPendingAt,
215
+ newestPendingAt,
216
+ lastAttemptAt,
217
+ lastPushAt,
218
+ lastPushError: lastError,
219
+ activeOutboundConnection: pushConnIds.length > 0,
220
+ activeOutboundConnectionCount: pushConnIds.length,
221
+ };
222
+ }
223
+
5
224
  export function buildOutboxScheduleDebugInfo(args: {
6
225
  bridgeId: string;
7
226
  accountId?: string | null;
@@ -187,7 +406,7 @@ export function buildOutboxDrainStuckDebugInfo(args: {
187
406
  outboxSize: args.outboxSize,
188
407
  pending: args.pending,
189
408
  runningMs: args.runningMs,
190
- runningSince: args.runningSince || null,
409
+ runningSince: args.runningSince ?? null,
191
410
  hasGatewayContext: args.hasGatewayContext,
192
411
  activeConnectionCount: args.activeConnectionCount,
193
412
  waiters: {
@@ -1,7 +1,10 @@
1
1
  import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
2
2
  import { buildReplyMediaFallbackDebugInfo } from './diagnostics.ts';
3
+ import type { OutboundReplyTargetPolicy } from './reply-target-policy.ts';
3
4
  import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
4
5
 
6
+ export type { OutboundReplyTargetPolicy } from './reply-target-policy.ts';
7
+
5
8
  export type ReplyPayloadInput = {
6
9
  text?: string;
7
10
  mediaUrl?: string;
@@ -21,6 +24,7 @@ export type NormalizedReplyPayload = {
21
24
  audioAsVoice: boolean;
22
25
  kind?: 'tool' | 'block' | 'final';
23
26
  replyToId: string;
27
+ replyTargetPolicy: OutboundReplyTargetPolicy;
24
28
  };
25
29
 
26
30
  export type ReplyMediaEntriesParams = {
@@ -51,6 +55,7 @@ export type ReplyMediaFileTransferParams = {
51
55
  audioAsVoice: boolean;
52
56
  kind?: 'tool' | 'block' | 'final';
53
57
  replyToId: string;
58
+ replyTargetPolicy: OutboundReplyTargetPolicy;
54
59
  createdAt: number;
55
60
  };
56
61
 
@@ -70,6 +75,7 @@ export type ReplyMediaFallbackTextEntryParams = {
70
75
  mediaUrl: string;
71
76
  kind?: 'tool' | 'block' | 'final';
72
77
  replyToId: string;
78
+ replyTargetPolicy: OutboundReplyTargetPolicy;
73
79
  fallback: { text: string; reason: string };
74
80
  };
75
81
 
@@ -85,6 +91,7 @@ export function buildReplyTextOutboxEntry(
85
91
  text: string;
86
92
  kind?: 'tool' | 'block' | 'final';
87
93
  replyToId: string;
94
+ replyTargetPolicy: OutboundReplyTargetPolicy;
88
95
  },
89
96
  helpers: {
90
97
  buildTextOutboxEntry: (args: {
@@ -94,6 +101,7 @@ export function buildReplyTextOutboxEntry(
94
101
  text: string;
95
102
  kind?: 'tool' | 'block' | 'final';
96
103
  replyToId?: string;
104
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
97
105
  }) => OutboxEntry;
98
106
  },
99
107
  ): OutboxEntry {
@@ -104,6 +112,7 @@ export function buildReplyTextOutboxEntry(
104
112
  text: params.text,
105
113
  kind: params.kind,
106
114
  replyToId: params.replyToId || undefined,
115
+ replyTargetPolicy: params.replyTargetPolicy,
107
116
  });
108
117
  }
109
118
 
@@ -123,6 +132,7 @@ export function enqueueReplyTextEntry(
123
132
  text: string;
124
133
  kind?: 'tool' | 'block' | 'final';
125
134
  replyToId?: string;
135
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
126
136
  }) => OutboxEntry;
127
137
  },
128
138
  ): void {
@@ -137,6 +147,7 @@ export function enqueueReplyTextEntry(
137
147
  text: params.payload.text,
138
148
  kind: params.payload.kind,
139
149
  replyToId: params.payload.replyToId,
150
+ replyTargetPolicy: params.payload.replyTargetPolicy,
140
151
  },
141
152
  { buildTextOutboxEntry: helpers.buildTextOutboxEntry },
142
153
  ),
@@ -159,6 +170,7 @@ export function enqueueReplyMediaFallbackTextEntry(
159
170
  text: string;
160
171
  kind?: 'tool' | 'block' | 'final';
161
172
  replyToId?: string;
173
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
162
174
  }) => OutboxEntry;
163
175
  },
164
176
  ): void {
@@ -176,6 +188,7 @@ export function enqueueReplyMediaFallbackTextEntry(
176
188
  text: params.fallback.text,
177
189
  kind: params.kind,
178
190
  replyToId: params.replyToId,
191
+ replyTargetPolicy: params.replyTargetPolicy,
179
192
  },
180
193
  { buildTextOutboxEntry: helpers.buildTextOutboxEntry },
181
194
  ),
@@ -197,6 +210,7 @@ export function enqueueReplyMediaFileTransferEntry(
197
210
  audioAsVoice: boolean;
198
211
  kind?: 'tool' | 'block' | 'final';
199
212
  replyToId?: string;
213
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
200
214
  }) => OutboxEntry;
201
215
  rememberRecentMediaSend: (args: {
202
216
  sessionKey: string;
@@ -219,6 +233,7 @@ export function enqueueReplyMediaFileTransferEntry(
219
233
  audioAsVoice: params.audioAsVoice,
220
234
  kind: params.kind,
221
235
  replyToId: params.replyToId || undefined,
236
+ replyTargetPolicy: params.replyTargetPolicy,
222
237
  }),
223
238
  );
224
239
  helpers.rememberRecentMediaSend({
@@ -245,6 +260,7 @@ export function enqueueSingleReplyMediaEntry(
245
260
  mediaUrl: params.mediaUrl,
246
261
  kind: params.params.payload.kind,
247
262
  replyToId: params.params.payload.replyToId,
263
+ replyTargetPolicy: params.params.payload.replyTargetPolicy,
248
264
  fallback: params.fallback,
249
265
  });
250
266
  return;
@@ -262,6 +278,7 @@ export function enqueueSingleReplyMediaEntry(
262
278
  audioAsVoice: params.params.payload.audioAsVoice,
263
279
  kind: params.params.payload.kind,
264
280
  replyToId: params.params.payload.replyToId,
281
+ replyTargetPolicy: params.params.payload.replyTargetPolicy,
265
282
  createdAt: params.currentTime,
266
283
  });
267
284
  }
@@ -314,6 +331,7 @@ export function enqueueNormalizedReplyPayload(
314
331
  export function normalizeReplyPayload(
315
332
  payload: ReplyPayloadInput,
316
333
  helpers: { asString: (value: unknown, fallback?: string) => string },
334
+ options?: { replyTargetPolicy?: OutboundReplyTargetPolicy },
317
335
  ): NormalizedReplyPayload {
318
336
  const text = helpers.asString(payload?.text || '').trim();
319
337
  const mediaUrl = helpers.asString(payload?.mediaUrl || '').trim();
@@ -328,6 +346,11 @@ export function normalizeReplyPayload(
328
346
  asVoice: payload?.asVoice === true,
329
347
  audioAsVoice: payload?.audioAsVoice === true,
330
348
  kind: payload?.kind,
331
- replyToId: normalizeOutboundReplyToId({ kind: payload?.kind, replyToId: payload?.replyToId }),
349
+ replyTargetPolicy: options?.replyTargetPolicy ?? 'agent-default',
350
+ replyToId: normalizeOutboundReplyToId({
351
+ kind: payload?.kind,
352
+ replyToId: payload?.replyToId,
353
+ replyTargetPolicy: options?.replyTargetPolicy,
354
+ }),
332
355
  };
333
356
  }
@@ -1,13 +1,16 @@
1
1
  import { normalizeReplyToId } from './media-dedupe.ts';
2
2
 
3
3
  export type OutboundReplyKind = 'tool' | 'block' | 'final';
4
+ export type OutboundReplyTargetPolicy = 'agent-default' | 'preserve';
4
5
 
5
6
  const STRIP_TOOL_REPLY_TO_ID = true;
6
7
 
7
8
  export function normalizeOutboundReplyToId(params: {
8
9
  kind?: OutboundReplyKind;
9
10
  replyToId?: string | null;
11
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
10
12
  }) {
11
- if (params.kind === 'tool' && STRIP_TOOL_REPLY_TO_ID) return '';
13
+ if (params.kind === 'tool' && STRIP_TOOL_REPLY_TO_ID && params.replyTargetPolicy !== 'preserve')
14
+ return '';
12
15
  return normalizeReplyToId(params.replyToId);
13
16
  }
@@ -0,0 +1,56 @@
1
+ import { normalizeAccountId } from '../../core/accounts.ts';
2
+ import { readOpenClawBooleanParam, readOpenClawStringParam } from '../../openclaw/sdk-helpers.ts';
3
+
4
+ export type NormalizedBncrSendParams = {
5
+ to: string;
6
+ accountId: string;
7
+ message: string;
8
+ caption: string;
9
+ mediaUrl?: string;
10
+ asVoice: boolean;
11
+ audioAsVoice: boolean;
12
+ };
13
+
14
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ }
17
+
18
+ export function normalizeBncrSendParams(input: {
19
+ params: unknown;
20
+ accountId: string;
21
+ }): NormalizedBncrSendParams {
22
+ const paramsObj = isPlainObject(input.params) ? input.params : {};
23
+ const to = readOpenClawStringParam(paramsObj, 'to', { required: true });
24
+ const resolvedAccountId = normalizeAccountId(
25
+ readOpenClawStringParam(paramsObj, 'accountId') ?? input.accountId,
26
+ );
27
+
28
+ const message = readOpenClawStringParam(paramsObj, 'message', { allowEmpty: true }) ?? '';
29
+ const caption = readOpenClawStringParam(paramsObj, 'caption', { allowEmpty: true }) ?? '';
30
+ const mediaUrl =
31
+ readOpenClawStringParam(paramsObj, 'media', { trim: false }) ??
32
+ readOpenClawStringParam(paramsObj, 'path', { trim: false }) ??
33
+ readOpenClawStringParam(paramsObj, 'filePath', { trim: false }) ??
34
+ readOpenClawStringParam(paramsObj, 'mediaUrl', { trim: false });
35
+ const asVoice = readOpenClawBooleanParam(paramsObj, 'asVoice') ?? false;
36
+ const audioAsVoice = readOpenClawBooleanParam(paramsObj, 'audioAsVoice') ?? false;
37
+
38
+ if (asVoice && !mediaUrl) throw new Error('send voice requires media path');
39
+
40
+ const normalizedMessage = mediaUrl ? '' : message || caption || '';
41
+ const normalizedCaption = mediaUrl ? caption || message || '' : '';
42
+
43
+ if (!normalizedMessage.trim() && !normalizedCaption.trim() && !mediaUrl) {
44
+ throw new Error('send requires message or media');
45
+ }
46
+
47
+ return {
48
+ to,
49
+ accountId: resolvedAccountId,
50
+ message: normalizedMessage,
51
+ caption: normalizedCaption,
52
+ mediaUrl: mediaUrl || undefined,
53
+ asVoice,
54
+ audioAsVoice,
55
+ };
56
+ }
@@ -0,0 +1,29 @@
1
+ export type OpenClawChannelRuntimeSurfaceDiagnostics = {
2
+ channel: {
3
+ inbound: boolean;
4
+ media: boolean;
5
+ reply: boolean;
6
+ routing: boolean;
7
+ session: boolean;
8
+ };
9
+ missing: string[];
10
+ };
11
+
12
+ export function buildOpenClawChannelRuntimeSurfaceDiagnostics(
13
+ api: unknown,
14
+ ): OpenClawChannelRuntimeSurfaceDiagnostics {
15
+ const channelRuntime = (api as any)?.runtime?.channel;
16
+ const surfaces = {
17
+ inbound: Boolean(channelRuntime?.inbound),
18
+ media: Boolean(channelRuntime?.media),
19
+ reply: Boolean(channelRuntime?.reply),
20
+ routing: Boolean(channelRuntime?.routing),
21
+ session: Boolean(channelRuntime?.session),
22
+ };
23
+ return {
24
+ channel: surfaces,
25
+ missing: Object.entries(surfaces)
26
+ .filter(([, present]) => !present)
27
+ .map(([name]) => name),
28
+ };
29
+ }
@@ -4,6 +4,8 @@ export const BNCR_GATEWAY_METHODS = [
4
4
  'bncr.activity',
5
5
  'bncr.ack',
6
6
  'bncr.diagnostics',
7
+ 'bncr.deadLetter.inspect',
8
+ 'bncr.deadLetter.prune',
7
9
  'bncr.file.init',
8
10
  'bncr.file.chunk',
9
11
  'bncr.file.complete',
@@ -22,13 +22,19 @@ export function createBncrStatusSurface(getBridge: () => BncrStatusBridge) {
22
22
  },
23
23
  buildAccountSnapshot: async ({ account, runtime }: any) => {
24
24
  const runtimeBridge = getBridge();
25
- const rt = runtime || runtimeBridge.getAccountRuntimeSnapshot(account?.accountId);
25
+ const accountId = account?.accountId || BNCR_DEFAULT_ACCOUNT_ID;
26
+ const snapshotAccount = {
27
+ accountId,
28
+ name: account?.name,
29
+ enabled: account?.enabled,
30
+ };
31
+ const rt = runtime || runtimeBridge.getAccountRuntimeSnapshot(accountId);
26
32
  return buildAccountStatusSnapshot({
27
- account,
33
+ account: snapshotAccount,
28
34
  runtime: rt,
29
- healthSummary: runtimeBridge.getStatusHeadline(account?.accountId),
35
+ healthSummary: runtimeBridge.getStatusHeadline(accountId),
30
36
  // default 名不可隐藏时,统一展示稳定默认值
31
- displayName: resolveDefaultDisplayName(account?.name, account?.accountId),
37
+ displayName: resolveDefaultDisplayName(account?.name, accountId),
32
38
  });
33
39
  },
34
40
  resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
@@ -65,6 +65,15 @@ export function computeBncrRecommendedAckTimeoutMs(args: ComputeBncrRecommendedA
65
65
  return Math.min(args.maxAckTimeoutMs, Math.max(args.minAckTimeoutMs, recommended));
66
66
  }
67
67
 
68
+ export function resolveBncrRuntimeAckTimeoutDecision(args: ComputeBncrRecommendedAckTimeoutArgs) {
69
+ const timeoutMs = computeBncrRecommendedAckTimeoutMs(args);
70
+ const reason = computeBncrRecommendedAckTimeoutReason({
71
+ ...args,
72
+ recommendedAckTimeoutMs: timeoutMs,
73
+ });
74
+ return { timeoutMs, reason };
75
+ }
76
+
68
77
  function finiteNumberOr(value: unknown, fallback: number) {
69
78
  return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
70
79
  }
@@ -94,3 +103,67 @@ export function buildBncrRuntimeAckStrategy(args: {
94
103
  recovered: ackObservability.adaptiveAckRecovered === true,
95
104
  };
96
105
  }
106
+
107
+ export function buildBncrRuntimeAckObservability(args: {
108
+ lastAckOkAt: number | null;
109
+ lastAckTimeoutAt: number | null;
110
+ recentAckTimeoutCount: number;
111
+ lateAckOkCount: number;
112
+ lastLateAckOkAt: number | null;
113
+ adaptiveAckRecoveryOkCount: number;
114
+ lastAckQueueLatencyMs: number | null;
115
+ lastAckPushLatencyMs: number | null;
116
+ lastLateAckQueueLatencyMs: number | null;
117
+ lastLateAckPushLatencyMs: number | null;
118
+ adaptiveAckTimeoutEnabled: boolean;
119
+ defaultAckTimeoutMs: number;
120
+ currentAckTimeoutMs: number;
121
+ minAckTimeoutMs: number;
122
+ maxAckTimeoutMs: number;
123
+ lateAckObservationTtlMs: number;
124
+ recoveryOkThreshold: number;
125
+ nowMs: number;
126
+ }) {
127
+ const lastLateAckAgeMs =
128
+ typeof args.lastLateAckOkAt === 'number' && args.lastLateAckOkAt > 0
129
+ ? Math.max(0, args.nowMs - args.lastLateAckOkAt)
130
+ : null;
131
+ const lateAckObservationExpired =
132
+ typeof lastLateAckAgeMs === 'number' && lastLateAckAgeMs > args.lateAckObservationTtlMs;
133
+ const adaptiveAckRecovered = args.adaptiveAckRecoveryOkCount >= args.recoveryOkThreshold;
134
+ const ackTimeoutDecision = resolveBncrRuntimeAckTimeoutDecision({
135
+ lateAckOkCount: args.lateAckOkCount,
136
+ recentAckTimeoutCount: args.recentAckTimeoutCount,
137
+ lastLateAckPushLatencyMs: args.lastLateAckPushLatencyMs,
138
+ lastLateAckOkAt: args.lastLateAckOkAt,
139
+ adaptiveAckRecoveryOkCount: args.adaptiveAckRecoveryOkCount,
140
+ nowMs: args.nowMs,
141
+ defaultAckTimeoutMs: args.defaultAckTimeoutMs,
142
+ minAckTimeoutMs: args.minAckTimeoutMs,
143
+ maxAckTimeoutMs: args.maxAckTimeoutMs,
144
+ lateAckObservationTtlMs: args.lateAckObservationTtlMs,
145
+ recoveryOkThreshold: args.recoveryOkThreshold,
146
+ });
147
+ return {
148
+ lastAckOkAt: args.lastAckOkAt,
149
+ lastAckTimeoutAt: args.lastAckTimeoutAt,
150
+ recentAckTimeoutCount: args.recentAckTimeoutCount,
151
+ lateAckOkCount: args.lateAckOkCount,
152
+ lastLateAckOkAt: args.lastLateAckOkAt,
153
+ lastLateAckAgeMs,
154
+ lateAckObservationTtlMs: args.lateAckObservationTtlMs,
155
+ lateAckObservationExpired,
156
+ adaptiveAckRecoveryOkCount: args.adaptiveAckRecoveryOkCount,
157
+ adaptiveAckRecoveryOkThreshold: args.recoveryOkThreshold,
158
+ adaptiveAckRecovered,
159
+ lastAckQueueLatencyMs: args.lastAckQueueLatencyMs,
160
+ lastAckPushLatencyMs: args.lastAckPushLatencyMs,
161
+ lastLateAckQueueLatencyMs: args.lastLateAckQueueLatencyMs,
162
+ lastLateAckPushLatencyMs: args.lastLateAckPushLatencyMs,
163
+ adaptiveAckTimeoutEnabled: args.adaptiveAckTimeoutEnabled,
164
+ defaultAckTimeoutMs: args.defaultAckTimeoutMs,
165
+ currentAckTimeoutMs: args.currentAckTimeoutMs,
166
+ recommendedAckTimeoutMs: ackTimeoutDecision.timeoutMs,
167
+ recommendedAckTimeoutReason: ackTimeoutDecision.reason,
168
+ };
169
+ }