@xmoxmo/bncr 0.3.4 → 0.3.6

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 +56 -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,13 +75,29 @@ 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
 
82
+ const MEDIA_TEXT_SPLIT_THRESHOLD = 1020;
83
+
76
84
  export function hasReplyMediaEntries(payload: NormalizedReplyPayload) {
77
85
  return payload.mediaList.length > 0;
78
86
  }
79
87
 
88
+ export function shouldSplitReplyMediaText(payload: NormalizedReplyPayload) {
89
+ if (!payload.text) return false;
90
+ if (payload.mediaList.length > 1) return true;
91
+ return payload.text.length > MEDIA_TEXT_SPLIT_THRESHOLD;
92
+ }
93
+
94
+ function withoutReplyMediaText(payload: NormalizedReplyPayload): NormalizedReplyPayload {
95
+ return {
96
+ ...payload,
97
+ text: '',
98
+ };
99
+ }
100
+
80
101
  export function buildReplyTextOutboxEntry(
81
102
  params: {
82
103
  accountId: string;
@@ -85,6 +106,7 @@ export function buildReplyTextOutboxEntry(
85
106
  text: string;
86
107
  kind?: 'tool' | 'block' | 'final';
87
108
  replyToId: string;
109
+ replyTargetPolicy: OutboundReplyTargetPolicy;
88
110
  },
89
111
  helpers: {
90
112
  buildTextOutboxEntry: (args: {
@@ -94,6 +116,7 @@ export function buildReplyTextOutboxEntry(
94
116
  text: string;
95
117
  kind?: 'tool' | 'block' | 'final';
96
118
  replyToId?: string;
119
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
97
120
  }) => OutboxEntry;
98
121
  },
99
122
  ): OutboxEntry {
@@ -104,6 +127,7 @@ export function buildReplyTextOutboxEntry(
104
127
  text: params.text,
105
128
  kind: params.kind,
106
129
  replyToId: params.replyToId || undefined,
130
+ replyTargetPolicy: params.replyTargetPolicy,
107
131
  });
108
132
  }
109
133
 
@@ -123,6 +147,7 @@ export function enqueueReplyTextEntry(
123
147
  text: string;
124
148
  kind?: 'tool' | 'block' | 'final';
125
149
  replyToId?: string;
150
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
126
151
  }) => OutboxEntry;
127
152
  },
128
153
  ): void {
@@ -137,6 +162,7 @@ export function enqueueReplyTextEntry(
137
162
  text: params.payload.text,
138
163
  kind: params.payload.kind,
139
164
  replyToId: params.payload.replyToId,
165
+ replyTargetPolicy: params.payload.replyTargetPolicy,
140
166
  },
141
167
  { buildTextOutboxEntry: helpers.buildTextOutboxEntry },
142
168
  ),
@@ -159,6 +185,7 @@ export function enqueueReplyMediaFallbackTextEntry(
159
185
  text: string;
160
186
  kind?: 'tool' | 'block' | 'final';
161
187
  replyToId?: string;
188
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
162
189
  }) => OutboxEntry;
163
190
  },
164
191
  ): void {
@@ -176,6 +203,7 @@ export function enqueueReplyMediaFallbackTextEntry(
176
203
  text: params.fallback.text,
177
204
  kind: params.kind,
178
205
  replyToId: params.replyToId,
206
+ replyTargetPolicy: params.replyTargetPolicy,
179
207
  },
180
208
  { buildTextOutboxEntry: helpers.buildTextOutboxEntry },
181
209
  ),
@@ -197,6 +225,7 @@ export function enqueueReplyMediaFileTransferEntry(
197
225
  audioAsVoice: boolean;
198
226
  kind?: 'tool' | 'block' | 'final';
199
227
  replyToId?: string;
228
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
200
229
  }) => OutboxEntry;
201
230
  rememberRecentMediaSend: (args: {
202
231
  sessionKey: string;
@@ -219,6 +248,7 @@ export function enqueueReplyMediaFileTransferEntry(
219
248
  audioAsVoice: params.audioAsVoice,
220
249
  kind: params.kind,
221
250
  replyToId: params.replyToId || undefined,
251
+ replyTargetPolicy: params.replyTargetPolicy,
222
252
  }),
223
253
  );
224
254
  helpers.rememberRecentMediaSend({
@@ -245,6 +275,7 @@ export function enqueueSingleReplyMediaEntry(
245
275
  mediaUrl: params.mediaUrl,
246
276
  kind: params.params.payload.kind,
247
277
  replyToId: params.params.payload.replyToId,
278
+ replyTargetPolicy: params.params.payload.replyTargetPolicy,
248
279
  fallback: params.fallback,
249
280
  });
250
281
  return;
@@ -262,6 +293,7 @@ export function enqueueSingleReplyMediaEntry(
262
293
  audioAsVoice: params.params.payload.audioAsVoice,
263
294
  kind: params.params.payload.kind,
264
295
  replyToId: params.params.payload.replyToId,
296
+ replyTargetPolicy: params.params.payload.replyTargetPolicy,
265
297
  createdAt: params.currentTime,
266
298
  });
267
299
  }
@@ -293,6 +325,23 @@ export function enqueueNormalizedReplyPayload(
293
325
  });
294
326
 
295
327
  if (helpers.hasReplyMediaEntries(params.payload)) {
328
+ if (shouldSplitReplyMediaText(params.payload)) {
329
+ helpers.enqueueReplyTextEntry({
330
+ accountId: params.accountId,
331
+ sessionKey: params.sessionKey,
332
+ route: params.route,
333
+ payload: params.payload,
334
+ });
335
+ helpers.enqueueReplyMediaEntries({
336
+ accountId: params.accountId,
337
+ sessionKey: params.sessionKey,
338
+ route: params.route,
339
+ payload: withoutReplyMediaText(params.payload),
340
+ mediaLocalRoots: params.mediaLocalRoots,
341
+ });
342
+ return;
343
+ }
344
+
296
345
  helpers.enqueueReplyMediaEntries({
297
346
  accountId: params.accountId,
298
347
  sessionKey: params.sessionKey,
@@ -314,6 +363,7 @@ export function enqueueNormalizedReplyPayload(
314
363
  export function normalizeReplyPayload(
315
364
  payload: ReplyPayloadInput,
316
365
  helpers: { asString: (value: unknown, fallback?: string) => string },
366
+ options?: { replyTargetPolicy?: OutboundReplyTargetPolicy },
317
367
  ): NormalizedReplyPayload {
318
368
  const text = helpers.asString(payload?.text || '').trim();
319
369
  const mediaUrl = helpers.asString(payload?.mediaUrl || '').trim();
@@ -328,6 +378,11 @@ export function normalizeReplyPayload(
328
378
  asVoice: payload?.asVoice === true,
329
379
  audioAsVoice: payload?.audioAsVoice === true,
330
380
  kind: payload?.kind,
331
- replyToId: normalizeOutboundReplyToId({ kind: payload?.kind, replyToId: payload?.replyToId }),
381
+ replyTargetPolicy: options?.replyTargetPolicy ?? 'agent-default',
382
+ replyToId: normalizeOutboundReplyToId({
383
+ kind: payload?.kind,
384
+ replyToId: payload?.replyToId,
385
+ replyTargetPolicy: options?.replyTargetPolicy,
386
+ }),
332
387
  };
333
388
  }
@@ -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
+ }