@newbase-clawchat/openclaw-clawchat 2026.5.12-2 → 2026.5.12-21

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 (99) hide show
  1. package/README.md +39 -17
  2. package/dist/index.js +3 -1
  3. package/dist/src/api-client.js +71 -12
  4. package/dist/src/api-types.test-d.js +10 -0
  5. package/dist/src/channel.js +5 -5
  6. package/dist/src/channel.setup.js +4 -17
  7. package/dist/src/clawchat-memory.js +290 -0
  8. package/dist/src/clawchat-metadata.js +235 -0
  9. package/dist/src/client.js +31 -93
  10. package/dist/src/commands.js +3 -3
  11. package/dist/src/config.js +58 -3
  12. package/dist/src/group-message-coalescer.js +107 -0
  13. package/dist/src/inbound.js +24 -28
  14. package/dist/src/login.runtime.js +82 -19
  15. package/dist/src/media-runtime.js +2 -3
  16. package/dist/src/message-mapper.js +1 -1
  17. package/dist/src/mock-transport.js +31 -0
  18. package/dist/src/outbound.js +281 -56
  19. package/dist/src/plugin-prompts.js +76 -0
  20. package/dist/src/profile-prompt.js +150 -0
  21. package/dist/src/profile-sync.js +169 -0
  22. package/dist/src/prompt-injection.js +25 -0
  23. package/dist/src/protocol-types.js +63 -0
  24. package/dist/src/protocol-types.typecheck.js +1 -0
  25. package/dist/src/protocol.js +2 -2
  26. package/dist/src/reply-dispatcher.js +143 -40
  27. package/dist/src/runtime.js +813 -109
  28. package/dist/src/storage.js +636 -0
  29. package/dist/src/tools-schema.js +70 -10
  30. package/dist/src/tools.js +600 -112
  31. package/dist/src/ws-alignment.js +8 -0
  32. package/dist/src/ws-client.js +588 -0
  33. package/index.ts +6 -1
  34. package/openclaw.plugin.json +44 -4
  35. package/package.json +4 -3
  36. package/prompts/platform.md +7 -0
  37. package/skills/clawchat/SKILL.md +90 -0
  38. package/src/api-client.test.ts +360 -15
  39. package/src/api-client.ts +127 -25
  40. package/src/api-types.test-d.ts +12 -0
  41. package/src/api-types.ts +71 -4
  42. package/src/buffered-stream.test.ts +1 -1
  43. package/src/buffered-stream.ts +1 -1
  44. package/src/channel.outbound.test.ts +270 -60
  45. package/src/channel.setup.ts +9 -18
  46. package/src/channel.test.ts +33 -25
  47. package/src/channel.ts +5 -7
  48. package/src/clawchat-memory.test.ts +372 -0
  49. package/src/clawchat-memory.ts +363 -0
  50. package/src/clawchat-metadata.test.ts +350 -0
  51. package/src/clawchat-metadata.ts +352 -0
  52. package/src/client.test.ts +57 -48
  53. package/src/client.ts +37 -129
  54. package/src/commands.test.ts +2 -2
  55. package/src/commands.ts +3 -3
  56. package/src/config.test.ts +169 -4
  57. package/src/config.ts +86 -6
  58. package/src/group-message-coalescer.test.ts +223 -0
  59. package/src/group-message-coalescer.ts +154 -0
  60. package/src/inbound.test.ts +106 -19
  61. package/src/inbound.ts +31 -35
  62. package/src/login.runtime.test.ts +294 -11
  63. package/src/login.runtime.ts +90 -21
  64. package/src/manifest.test.ts +86 -14
  65. package/src/media-runtime.test.ts +31 -2
  66. package/src/media-runtime.ts +7 -10
  67. package/src/message-mapper.test.ts +2 -2
  68. package/src/message-mapper.ts +2 -2
  69. package/src/mock-transport.test.ts +35 -0
  70. package/src/mock-transport.ts +38 -0
  71. package/src/outbound.test.ts +811 -95
  72. package/src/outbound.ts +332 -65
  73. package/src/plugin-entry.test.ts +3 -1
  74. package/src/plugin-prompts.test.ts +78 -0
  75. package/src/plugin-prompts.ts +92 -0
  76. package/src/profile-prompt.test.ts +435 -0
  77. package/src/profile-prompt.ts +208 -0
  78. package/src/profile-sync.test.ts +611 -0
  79. package/src/profile-sync.ts +268 -0
  80. package/src/prompt-injection.test.ts +39 -0
  81. package/src/prompt-injection.ts +45 -0
  82. package/src/protocol-types.test.ts +69 -0
  83. package/src/protocol-types.ts +296 -0
  84. package/src/protocol-types.typecheck.ts +89 -0
  85. package/src/protocol.ts +2 -2
  86. package/src/reply-dispatcher.test.ts +720 -135
  87. package/src/reply-dispatcher.ts +174 -42
  88. package/src/runtime.test.ts +3884 -337
  89. package/src/runtime.ts +956 -128
  90. package/src/storage.test.ts +692 -0
  91. package/src/storage.ts +989 -0
  92. package/src/streaming.test.ts +1 -1
  93. package/src/streaming.ts +1 -1
  94. package/src/tools-schema.ts +115 -13
  95. package/src/tools.test.ts +501 -10
  96. package/src/tools.ts +739 -133
  97. package/src/ws-alignment.ts +9 -0
  98. package/src/ws-client.test.ts +1218 -0
  99. package/src/ws-client.ts +662 -0
package/src/runtime.ts CHANGED
@@ -4,17 +4,25 @@ import {
4
4
  ProtocolError,
5
5
  StateError,
6
6
  TransportError,
7
- type ClawlingChatClient,
7
+ EVENT,
8
8
  type Envelope,
9
9
  type Transport,
10
- } from "@newbase-clawchat/sdk";
10
+ } from "./protocol-types.ts";
11
11
  import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
12
12
  import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
13
+ import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
13
14
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
14
15
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
15
16
  import { createOpenclawClawlingClient } from "./client.ts";
16
- import { CHANNEL_ID, type ResolvedOpenclawClawlingAccount } from "./config.ts";
17
- import { dispatchOpenclawClawlingInbound } from "./inbound.ts";
17
+ import { createOpenclawClawlingApiClient } from "./api-client.ts";
18
+ import { ClawlingApiError, type ConversationDetails } from "./api-types.ts";
19
+ import {
20
+ CHANNEL_ID,
21
+ effectiveGroupCommandMode,
22
+ type ResolvedOpenclawClawlingAccount,
23
+ } from "./config.ts";
24
+ import type { ClawlingChatClient } from "./ws-client.ts";
25
+ import { dispatchOpenclawClawlingInbound, type IngestTurnParams } from "./inbound.ts";
18
26
  import { fetchInboundMedia } from "./media-runtime.ts";
19
27
  import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
20
28
  import { sendStreamingText } from "./streaming.ts";
@@ -25,8 +33,47 @@ import {
25
33
  } from "./outbound.ts";
26
34
  import { formatWsLog } from "./ws-log.ts";
27
35
  import { createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.ts";
36
+ import {
37
+ clawChatDbPathForStateDir,
38
+ getClawChatStore,
39
+ type ClawChatStore,
40
+ } from "./storage.ts";
41
+ import { getClawChatGroupPrompt, getClawChatUserPrompt } from "./plugin-prompts.ts";
42
+ import {
43
+ loadClawChatPromptMetadata,
44
+ renderClawChatProfilePrompt,
45
+ resolveSenderRelation,
46
+ } from "./profile-prompt.ts";
47
+ import { refreshGroupProfile, syncFirstSeenClawChatProfiles } from "./profile-sync.ts";
48
+ import { pullGroupMetadata, pullOwnerMetadata } from "./clawchat-metadata.ts";
49
+ import {
50
+ clearClawChatPromptInjectionForSession,
51
+ stageClawChatPromptInjection,
52
+ } from "./prompt-injection.ts";
53
+ import { createGroupMessageCoalescer } from "./group-message-coalescer.ts";
28
54
 
29
55
  type Log = { info?: (m: string) => void; error?: (m: string) => void };
56
+ type RuntimeConnectionStore = Pick<
57
+ ClawChatStore,
58
+ "startConnection" | "markConnectSent" | "markConnectionReady" | "finishConnection"
59
+ > &
60
+ Partial<
61
+ Pick<
62
+ ClawChatStore,
63
+ | "insertMessage"
64
+ | "claimMessageOnce"
65
+ | "updateMessageByIdentity"
66
+ | "claimPendingActivationBootstrap"
67
+ | "releaseActivationBootstrapClaim"
68
+ | "markActivationBootstrapSent"
69
+ | "upsertConversationSummary"
70
+ | "upsertConversationDetails"
71
+ | "deleteConversationCache"
72
+ | "listCachedConversationIds"
73
+ | "getActivationConversation"
74
+ | "getCachedConversation"
75
+ >
76
+ >;
30
77
 
31
78
  const { setRuntime: setOpenclawClawlingRuntime, getRuntime: getOpenclawClawlingRuntime } =
32
79
  createPluginRuntimeStore<PluginRuntime>("openclaw-clawchat runtime not initialized");
@@ -34,11 +81,67 @@ const { setRuntime: setOpenclawClawlingRuntime, getRuntime: getOpenclawClawlingR
34
81
  export { setOpenclawClawlingRuntime, getOpenclawClawlingRuntime };
35
82
 
36
83
  const activeClients = new Map<string, ClawlingChatClient>();
84
+ const CLAWCHAT_PLUGIN_SLASH_COMMANDS = new Set(["clawchat-activate"]);
85
+ const CLAWCHAT_MEMORY_ROOT_UNAVAILABLE =
86
+ "ClawChat memory root unavailable: OpenClaw workspaceDir could not be resolved";
87
+ const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
88
+ "approve",
89
+ "deny",
90
+ "always",
91
+ "cancel",
92
+ "yes",
93
+ "no",
94
+ "ok",
95
+ "confirm",
96
+ "remember",
97
+ "nevermind",
98
+ ]);
99
+
100
+ function parseSlashCommandName(rawBody: string): string | null {
101
+ const stripped = rawBody.trimStart();
102
+ if (!stripped.startsWith("/")) return null;
103
+ const token = stripped.split(/\s+/, 1)[0] ?? "";
104
+ const name = token.slice(1).replace(/_/g, "-").toLowerCase();
105
+ if (!name || name.includes("/")) return null;
106
+ return name;
107
+ }
108
+
109
+ function isKnownOpenClawGroupSlashCommand(rawBody: string, cfg: OpenClawConfig): boolean {
110
+ const name = parseSlashCommandName(rawBody);
111
+ if (!name) return false;
112
+ return hasControlCommand(rawBody, cfg)
113
+ || CLAWCHAT_PLUGIN_SLASH_COMMANDS.has(name)
114
+ || OPENCLAW_CONFIRM_SLASH_COMMANDS.has(name);
115
+ }
37
116
 
38
117
  export function getOpenclawClawlingClient(accountId: string): ClawlingChatClient | undefined {
39
118
  return activeClients.get(accountId);
40
119
  }
41
120
 
121
+ export function resolveClawChatMemoryRoot(
122
+ runtime: PluginRuntime,
123
+ cfg: OpenClawConfig,
124
+ agentId: string,
125
+ ): string {
126
+ const resolver = runtime.agent?.resolveAgentWorkspaceDir;
127
+ if (typeof resolver !== "function") {
128
+ throw new Error(CLAWCHAT_MEMORY_ROOT_UNAVAILABLE);
129
+ }
130
+
131
+ let workspaceDir: string;
132
+ try {
133
+ workspaceDir = resolver(cfg, agentId);
134
+ } catch {
135
+ throw new Error(CLAWCHAT_MEMORY_ROOT_UNAVAILABLE);
136
+ }
137
+
138
+ const memoryRoot = typeof workspaceDir === "string" ? workspaceDir.trim() : "";
139
+ if (!memoryRoot) {
140
+ throw new Error(CLAWCHAT_MEMORY_ROOT_UNAVAILABLE);
141
+ }
142
+ return memoryRoot;
143
+ }
144
+
42
145
  export async function waitForOpenclawClawlingClient(
43
146
  accountId: string,
44
147
  options: { timeoutMs?: number; pollMs?: number } = {},
@@ -112,6 +215,84 @@ function formatConversationSubject(peer: { kind: "direct" | "group"; id: string
112
215
  return peer.kind === "group" ? `group:${peer.id}` : peer.id;
113
216
  }
114
217
 
218
+ function parseApiTimestamp(value: unknown): number | null {
219
+ if (typeof value !== "string") return null;
220
+ const parsed = Date.parse(value);
221
+ return Number.isFinite(parsed) ? parsed : null;
222
+ }
223
+
224
+ function asRecord(value: unknown): Record<string, unknown> | null {
225
+ return value && typeof value === "object" ? value as Record<string, unknown> : null;
226
+ }
227
+
228
+ function optionalString(value: unknown): string | undefined {
229
+ return typeof value === "string" ? value : undefined;
230
+ }
231
+
232
+ function hasOwn(record: Record<string, unknown>, key: string): boolean {
233
+ return Object.prototype.hasOwnProperty.call(record, key);
234
+ }
235
+
236
+ function optionalNullableString(record: Record<string, unknown>, key: string): string | null | undefined {
237
+ if (!hasOwn(record, key)) return undefined;
238
+ const value = record[key];
239
+ if (value === null) return null;
240
+ return optionalString(value);
241
+ }
242
+
243
+ function isConversationNotFoundError(err: unknown): boolean {
244
+ if (!(err instanceof ClawlingApiError)) return false;
245
+ return err.meta?.status === 404 || err.meta?.status === 410 ||
246
+ err.meta?.code === 404 || err.meta?.code === 410 || err.meta?.code === 40401;
247
+ }
248
+
249
+ function metadataVersionFromEnvelope(env: Envelope): number | undefined {
250
+ const payload = asRecord(env.payload);
251
+ const version = payload?.version;
252
+ return typeof version === "number" && Number.isFinite(version) ? version : undefined;
253
+ }
254
+
255
+ function metadataScopesFromEnvelope(env: Envelope): string[] {
256
+ const scope = asRecord(env.payload)?.scope;
257
+ return Array.isArray(scope) ? scope.filter((item): item is string => typeof item === "string") : [];
258
+ }
259
+
260
+ function shouldRefreshBehaviorForScopes(scopes: string[]): boolean {
261
+ return scopes.includes("behavior");
262
+ }
263
+
264
+ function shouldRefreshConversationForScopes(scopes: string[]): boolean {
265
+ if (scopes.length === 0) return true;
266
+ return scopes.some((scope) => scope === "title" || scope === "description" || scope !== "behavior");
267
+ }
268
+
269
+ function buildConversationDetailsCacheInput(params: {
270
+ accountId: string;
271
+ conversation: ConversationDetails;
272
+ metadataVersion?: number;
273
+ }): Parameters<NonNullable<RuntimeConnectionStore["upsertConversationDetails"]>>[0] {
274
+ const { accountId, conversation, metadataVersion } = params;
275
+ const refreshedAt = Date.now();
276
+ const participants = Array.isArray(conversation.participants) ? conversation.participants : [];
277
+ return {
278
+ platform: "openclaw",
279
+ accountId,
280
+ conversationId: conversation.id,
281
+ conversationType: conversation.type,
282
+ ...(metadataVersion !== undefined ? { metadataVersion } : {}),
283
+ lastSeenAt: parseApiTimestamp(conversation.updated_at),
284
+ lastRefreshedAt: refreshedAt,
285
+ raw: conversation,
286
+ members: participants.map((participant) => ({
287
+ userId: participant.user_id,
288
+ role: participant.role,
289
+ raw: participant,
290
+ lastSeenAt: parseApiTimestamp(participant.joined_at),
291
+ })),
292
+ membersComplete: true,
293
+ };
294
+ }
295
+
115
296
  function withClawChatSessionScope(cfg: OpenClawConfig): OpenClawConfig {
116
297
  return {
117
298
  ...cfg,
@@ -122,6 +303,53 @@ function withClawChatSessionScope(cfg: OpenClawConfig): OpenClawConfig {
122
303
  };
123
304
  }
124
305
 
306
+ function buildActivationBootstrapText(): string {
307
+ return [
308
+ "ClawChat activation bootstrap: You are now connected to this ClawChat direct conversation.",
309
+ "Please do both:",
310
+ "1. Send a brief, friendly greeting to the user in this ClawChat direct conversation.",
311
+ "2. If you have local profile information for yourself, such as display name, bio, or avatar, update the connected ClawChat account profile using the available ClawChat tools. Use `clawchat_update_account_profile` for display name/bio/avatar URL, and use `clawchat_upload_avatar_image` first if the avatar is only available as a local image path. If you do not have local profile information, skip profile updates and only greet the user.",
312
+ "Do not ask the user for profile information just for this bootstrap.",
313
+ ].join("\n");
314
+ }
315
+
316
+ function buildActivationBootstrapEnvelope(params: {
317
+ account: ResolvedOpenclawClawlingAccount;
318
+ conversationId: string;
319
+ }): Envelope {
320
+ const text = buildActivationBootstrapText();
321
+ const now = Date.now();
322
+ return {
323
+ version: "2",
324
+ event: EVENT.MESSAGE_SEND,
325
+ trace_id: `openclaw-clawchat-bootstrap-${now}`,
326
+ emitted_at: now,
327
+ chat_id: params.conversationId,
328
+ chat_type: "direct",
329
+ to: { id: params.account.userId, type: "direct" },
330
+ sender: {
331
+ id: "clawchat-bootstrap",
332
+ type: "direct",
333
+ nick_name: "ClawChat Activation",
334
+ },
335
+ payload: {
336
+ message_id: `openclaw-clawchat-bootstrap-${params.conversationId}-${now}`,
337
+ message_mode: "normal",
338
+ message: {
339
+ body: { fragments: [{ kind: "text", text }] },
340
+ context: { mentions: [], reply: null },
341
+ streaming: {
342
+ status: "static",
343
+ sequence: 0,
344
+ mutation_policy: "sealed",
345
+ started_at: null,
346
+ completed_at: null,
347
+ },
348
+ },
349
+ },
350
+ };
351
+ }
352
+
125
353
  export interface StartGatewayParams {
126
354
  cfg: OpenClawConfig;
127
355
  account: ResolvedOpenclawClawlingAccount;
@@ -130,25 +358,59 @@ export interface StartGatewayParams {
130
358
  getStatus: () => ChannelAccountSnapshot;
131
359
  log?: Log;
132
360
  /** Test hook only. */
361
+ store?: RuntimeConnectionStore | null;
362
+ /** Test hook only. */
133
363
  transport?: Transport;
134
364
  }
135
365
 
366
+ function resolveConnectionStore(
367
+ params: StartGatewayParams,
368
+ runtime: PluginRuntime,
369
+ ): RuntimeConnectionStore | null {
370
+ if (params.store !== undefined) return params.store;
371
+ if (params.transport) return null;
372
+ try {
373
+ const stateDir = runtime.state?.resolveStateDir?.();
374
+ return getClawChatStore({
375
+ ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
376
+ log: { error: (message) => params.log?.error?.(message) },
377
+ });
378
+ } catch {
379
+ params.log?.error?.("openclaw-clawchat sqlite connection persistence unavailable; continuing.");
380
+ return null;
381
+ }
382
+ }
383
+
136
384
  export async function startOpenclawClawlingGateway(params: StartGatewayParams): Promise<void> {
137
385
  const { cfg, account, abortSignal, setStatus, getStatus, log } = params;
138
386
  // Obtain PluginRuntime from the stored runtime set via setOpenclawClawlingRuntime.
139
387
  const runtime = getOpenclawClawlingRuntime();
140
388
  const accountId = account.accountId;
389
+ const store = resolveConnectionStore(params, runtime);
390
+ let conversationApiClient: ReturnType<typeof createOpenclawClawlingApiClient> | undefined;
391
+ const getConversationApiClient = () => {
392
+ conversationApiClient ??= createOpenclawClawlingApiClient({
393
+ baseUrl: account.baseUrl,
394
+ token: account.token,
395
+ userId: account.userId,
396
+ });
397
+ return conversationApiClient;
398
+ };
141
399
 
142
400
  log?.info?.(
143
- `[${accountId}] openclaw-clawchat runtime start entered configured=${account.configured} enabled=${account.enabled} hasToken=${Boolean(account.token)} hasUserId=${Boolean(account.userId)} websocketUrl=${account.websocketUrl || "(empty)"}`,
401
+ `[${accountId}] openclaw-clawchat runtime start entered configured=${account.configured} enabled=${account.enabled} hasToken=${Boolean(account.token)} hasUserId=${Boolean(account.userId)} hasOwnerUserId=${Boolean(account.ownerUserId)} websocketUrl=${account.websocketUrl || "(empty)"}`,
144
402
  );
145
403
  let lastHelloFailTraceId = "-";
146
404
  let lastHelloFailReason = "";
147
405
  let lastConnectTraceId = "-";
406
+ let lastHelloOkDeviceId: string | undefined;
407
+ let lastHelloOkDeliveryMode: string | undefined;
148
408
  let currentAttemptStartedAt = 0;
149
409
  let authFailureLogged = false;
150
410
  let closingForAbort = false;
151
411
  let wsReady = false;
412
+ let currentConnectionId: number | null = null;
413
+ let currentConnectionFinished = false;
152
414
  const reconnectTracker = createReconnectTracker({
153
415
  accountId,
154
416
  log: (msg) => log?.info?.(msg),
@@ -162,11 +424,243 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
162
424
  state: snapshot.state === "connected" ? "ready" : snapshot.state,
163
425
  };
164
426
  };
427
+ const recordConnection = <T>(action: string, fn: () => T): T | undefined => {
428
+ try {
429
+ return fn();
430
+ } catch {
431
+ log?.error?.(`[${accountId}] openclaw-clawchat sqlite ${action} failed; continuing`);
432
+ return undefined;
433
+ }
434
+ };
435
+ const finishCurrentConnection = (input: {
436
+ state: string;
437
+ disconnectedAt?: number;
438
+ closeCode?: number | null;
439
+ closeReason?: string | null;
440
+ error?: string | null;
441
+ }) => {
442
+ if (!store || currentConnectionId == null || currentConnectionFinished) return;
443
+ const connectionId = currentConnectionId;
444
+ recordConnection("finish", () => store.finishConnection(connectionId, input));
445
+ currentConnectionFinished = true;
446
+ currentConnectionId = null;
447
+ };
448
+ const refreshConversationDetails = async (
449
+ conversationId: string,
450
+ options: { metadataVersion?: number; source: string; memoryRoot?: string },
451
+ ): Promise<void> => {
452
+ try {
453
+ const memoryRoot = options.memoryRoot;
454
+ const data = memoryRoot
455
+ ? await (async () => {
456
+ const result = await pullGroupMetadata({
457
+ memoryRoot,
458
+ groupId: conversationId,
459
+ api: getConversationApiClient(),
460
+ });
461
+ if (result.failures.length > 0) {
462
+ log?.error?.(
463
+ `[${accountId}] openclaw-clawchat group participant metadata refresh partially failed: ${result.failures.map((failure) => `${failure.targetId}: ${failure.error}`).join("; ")}`,
464
+ );
465
+ }
466
+ if (!result.conversation) throw new Error("ClawChat conversation metadata response is missing conversation");
467
+ return { conversation: result.conversation };
468
+ })()
469
+ : await getConversationApiClient().getConversation(conversationId);
470
+ if (!store?.upsertConversationDetails) return;
471
+ recordConnection("conversation details upsert", () =>
472
+ store.upsertConversationDetails?.(buildConversationDetailsCacheInput({
473
+ accountId,
474
+ conversation: data.conversation,
475
+ ...(options.metadataVersion !== undefined ? { metadataVersion: options.metadataVersion } : {}),
476
+ })),
477
+ );
478
+ } catch (err) {
479
+ if (isConversationNotFoundError(err)) {
480
+ if (store?.deleteConversationCache) {
481
+ recordConnection("conversation cache delete", () =>
482
+ store.deleteConversationCache?.({
483
+ platform: "openclaw",
484
+ accountId,
485
+ conversationId,
486
+ }),
487
+ );
488
+ }
489
+ return;
490
+ }
491
+ log?.error?.(
492
+ `[${accountId}] openclaw-clawchat metadata refresh failed source=${options.source} conversation=${conversationId}: ${err instanceof Error ? err.message : String(err)}`,
493
+ );
494
+ }
495
+ };
496
+ const refreshAgentBehavior = async (options: {
497
+ metadataVersion?: number;
498
+ source: string;
499
+ memoryRoot: string;
500
+ }): Promise<void> => {
501
+ try {
502
+ await pullOwnerMetadata({
503
+ memoryRoot: options.memoryRoot,
504
+ agentId: account.agentId,
505
+ accountUserId: account.userId,
506
+ accountOwnerUserId: account.ownerUserId,
507
+ api: getConversationApiClient(),
508
+ });
509
+ } catch (err) {
510
+ log?.error?.(
511
+ `[${accountId}] openclaw-clawchat behavior refresh failed source=${options.source} agent=${account.userId}: ${err instanceof Error ? err.message : String(err)}`,
512
+ );
513
+ }
514
+ };
515
+ const refreshConversationCacheAfterReady = async (): Promise<void> => {
516
+ if (!store) return;
517
+ const ids: string[] = [];
518
+ const seen = new Set<string>();
519
+ const addId = (id: unknown) => {
520
+ if (typeof id !== "string" || !id || seen.has(id)) return;
521
+ seen.add(id);
522
+ ids.push(id);
523
+ };
524
+
525
+ const activation = store.getActivationConversation
526
+ ? recordConnection("activation conversation read", () =>
527
+ store.getActivationConversation?.({ platform: "openclaw", accountId }),
528
+ )
529
+ : null;
530
+ addId(activation?.conversationId);
531
+
532
+ const cachedIds = store.listCachedConversationIds
533
+ ? recordConnection("cached conversation ids read", () =>
534
+ store.listCachedConversationIds?.({ platform: "openclaw", accountId, limit: 20 }),
535
+ ) ?? []
536
+ : [];
537
+ for (const id of cachedIds.slice(0, 20)) addId(id);
538
+
539
+ for (const id of ids) {
540
+ await refreshConversationDetails(id, { source: "reconnect" });
541
+ }
542
+ };
543
+ const resolveMemoryRootForPeer = (peer: { kind: "direct" | "group"; id: string }): string | null => {
544
+ try {
545
+ const route = runtime.channel.routing.resolveAgentRoute({
546
+ cfg: withClawChatSessionScope(cfg),
547
+ channel: CHANNEL_ID,
548
+ accountId,
549
+ peer,
550
+ });
551
+ return resolveClawChatMemoryRoot(runtime, cfg, route.agentId);
552
+ } catch (err) {
553
+ log?.error?.(
554
+ `[${accountId}] openclaw-clawchat metadata refresh memory root unavailable: ${err instanceof Error ? err.message : String(err)}`,
555
+ );
556
+ return null;
557
+ }
558
+ };
559
+ const handleMetadataInvalidation = async (env: Envelope): Promise<void> => {
560
+ const conversationId = typeof env.chat_id === "string" && env.chat_id.trim()
561
+ ? env.chat_id
562
+ : "";
563
+ if (!conversationId) {
564
+ log?.info?.(`[${accountId}] openclaw-clawchat metadata invalidation missing chat_id trace=${env.trace_id}`);
565
+ return;
566
+ }
567
+
568
+ const version = metadataVersionFromEnvelope(env);
569
+ const scopes = metadataScopesFromEnvelope(env);
570
+ const refreshBehavior = shouldRefreshBehaviorForScopes(scopes);
571
+ const refreshConversation = shouldRefreshConversationForScopes(scopes);
572
+ if (!refreshBehavior && !refreshConversation) return;
573
+ const peer = {
574
+ kind: env.chat_type === "group" ? "group" as const : "direct" as const,
575
+ id: conversationId,
576
+ };
577
+ const memoryRoot = resolveMemoryRootForPeer(peer);
578
+ if (!memoryRoot) return;
579
+
580
+ if (refreshBehavior) {
581
+ await refreshAgentBehavior({
582
+ source: "metadata_invalidation",
583
+ ...(version !== undefined ? { metadataVersion: version } : {}),
584
+ memoryRoot,
585
+ });
586
+ }
587
+
588
+ if (!refreshConversation) return;
589
+
590
+ await refreshConversationDetails(conversationId, {
591
+ source: "metadata_invalidation",
592
+ ...(version !== undefined ? { metadataVersion: version } : {}),
593
+ memoryRoot,
594
+ });
595
+ };
596
+ const syncMessagePathProfiles = async (turn: IngestTurnParams, memoryRoot: string): Promise<void> => {
597
+ if (turn.senderId === "clawchat-bootstrap") {
598
+ await pullOwnerMetadata({
599
+ memoryRoot,
600
+ agentId: account.agentId,
601
+ accountUserId: account.userId,
602
+ accountOwnerUserId: account.ownerUserId,
603
+ api: getConversationApiClient(),
604
+ });
605
+ return;
606
+ }
607
+ if (turn.peer.kind === "direct" && (turn.senderId === account.ownerUserId || turn.senderId === account.userId)) {
608
+ await pullOwnerMetadata({
609
+ memoryRoot,
610
+ agentId: account.agentId,
611
+ accountUserId: account.userId,
612
+ accountOwnerUserId: account.ownerUserId,
613
+ api: getConversationApiClient(),
614
+ });
615
+ return;
616
+ }
617
+ if (turn.peer.kind === "group") {
618
+ await refreshGroupProfile({
619
+ platform: "openclaw",
620
+ accountId,
621
+ conversationId: turn.peer.id,
622
+ api: getConversationApiClient(),
623
+ store: {
624
+ ...(store?.upsertConversationDetails ? { upsertConversationDetails: store.upsertConversationDetails.bind(store) } : {}),
625
+ },
626
+ memoryRoot,
627
+ log: { error: (message) => log?.error?.(`[${accountId}] ${message}`) },
628
+ });
629
+ return;
630
+ }
631
+ await syncFirstSeenClawChatProfiles({
632
+ platform: "openclaw",
633
+ accountId,
634
+ accountUserId: account.userId,
635
+ accountOwnerUserId: account.ownerUserId,
636
+ chat: {
637
+ id: turn.peer.id,
638
+ type: "direct",
639
+ lastSeenAt: turn.timestamp,
640
+ },
641
+ sender: {
642
+ id: turn.senderId,
643
+ ...(turn.senderNickName ? { nickname: turn.senderNickName } : {}),
644
+ },
645
+ api: getConversationApiClient(),
646
+ store: {
647
+ ...(store?.getCachedConversation ? { getCachedConversation: store.getCachedConversation.bind(store) } : {}),
648
+ ...(store?.upsertConversationSummary ? { upsertConversationSummary: store.upsertConversationSummary.bind(store) } : {}),
649
+ ...(store?.upsertConversationDetails ? { upsertConversationDetails: store.upsertConversationDetails.bind(store) } : {}),
650
+ },
651
+ memoryRoot,
652
+ log: { error: (message) => log?.error?.(`[${accountId}] ${message}`) },
653
+ });
654
+ };
165
655
  const client = createOpenclawClawlingClient(account, {
166
656
  ...(params.transport ? { transport: params.transport } : {}),
167
657
  wsLifecycle: {
168
658
  onConnectFrameSent: (env) => {
169
659
  lastConnectTraceId = typeof env.trace_id === "string" ? env.trace_id : "-";
660
+ if (store && currentConnectionId != null) {
661
+ const connectionId = currentConnectionId;
662
+ recordConnection("connect-sent", () => store.markConnectSent(connectionId));
663
+ }
170
664
  const deviceId =
171
665
  typeof env.payload?.device_id === "string" ? env.payload.device_id : "-";
172
666
  const current = wsLogContext();
@@ -190,6 +684,13 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
190
684
  log?.info?.(`[${accountId}] openclaw-clawchat runtime client created`);
191
685
 
192
686
  setAlignedOutboundLogContext(client, wsLogContext);
687
+ client.on("hello:ok", (env: Envelope) => {
688
+ const payload = env.payload && typeof env.payload === "object"
689
+ ? env.payload as { device_id?: unknown; delivery_mode?: unknown }
690
+ : {};
691
+ lastHelloOkDeviceId = typeof payload.device_id === "string" ? payload.device_id : undefined;
692
+ lastHelloOkDeliveryMode = typeof payload.delivery_mode === "string" ? payload.delivery_mode : undefined;
693
+ });
193
694
  const protocolControlLogger = createProtocolControlHandler({
194
695
  accountId,
195
696
  log: (msg) => log?.info?.(msg),
@@ -215,6 +716,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
215
716
  }),
216
717
  );
217
718
  };
719
+ let dispatchActivationBootstrap: () => Promise<void> = async () => {};
218
720
 
219
721
  client.on("state", ({ from, to }) => {
220
722
  log?.info?.(`[${accountId}] openclaw-clawchat state ${from} -> ${to}`);
@@ -223,6 +725,18 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
223
725
  reconnectTracker.connectStart();
224
726
  currentAttemptStartedAt = Date.now();
225
727
  const current = wsLogContext();
728
+ if (store) {
729
+ recordConnection("start", () => {
730
+ currentConnectionId = store.startConnection({
731
+ platform: "openclaw",
732
+ accountId,
733
+ attempt: current.attempt,
734
+ reconnectCount: current.reconnectCount,
735
+ connectStartedAt: currentAttemptStartedAt,
736
+ });
737
+ currentConnectionFinished = false;
738
+ });
739
+ }
226
740
  log?.info?.(
227
741
  formatWsLog({
228
742
  event: "connect_start",
@@ -242,6 +756,19 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
242
756
  const queueSize = getAlignedOutboundQueueSize(client);
243
757
  reconnectTracker.markReady();
244
758
  const current = wsLogContext();
759
+ if (store && currentConnectionId != null) {
760
+ const connectionId = currentConnectionId;
761
+ recordConnection("ready", () => {
762
+ if (lastHelloOkDeviceId === undefined && lastHelloOkDeliveryMode === undefined) {
763
+ store.markConnectionReady(connectionId);
764
+ return;
765
+ }
766
+ store.markConnectionReady(connectionId, {
767
+ ...(lastHelloOkDeviceId !== undefined ? { resolvedDeviceId: lastHelloOkDeviceId } : {}),
768
+ ...(lastHelloOkDeliveryMode !== undefined ? { deliveryMode: lastHelloOkDeliveryMode } : {}),
769
+ });
770
+ });
771
+ }
245
772
  log?.info?.(
246
773
  formatWsLog({
247
774
  event: "handshake_ok",
@@ -262,6 +789,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
262
789
  } catch {
263
790
  // The queue keeps the failed frame at the head and will retry after the next reconnect.
264
791
  }
792
+ void refreshConversationCacheAfterReady();
793
+ void dispatchActivationBootstrap();
265
794
  } else if (to === "disconnected") {
266
795
  reconnectTracker.markClosed();
267
796
  }
@@ -271,6 +800,11 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
271
800
 
272
801
  client.on("close", ({ code, reason }: { code?: number; reason?: string }) => {
273
802
  if (closingForAbort || (code === 1000 && reason === "client close")) return;
803
+ finishCurrentConnection({
804
+ state: "disconnected",
805
+ closeCode: code ?? null,
806
+ closeReason: reason ?? null,
807
+ });
274
808
  const current = wsLogContext();
275
809
  log?.info?.(
276
810
  formatWsLog({
@@ -398,9 +932,17 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
398
932
  lastHelloFailReason = typeof payload?.reason === "string" ? payload.reason : "";
399
933
  });
400
934
 
935
+ client.on("metadata:invalidated", (env: Envelope) => {
936
+ void handleMetadataInvalidation(env);
937
+ });
938
+
401
939
  client.on("error", (err: unknown) => {
402
940
  const classified = classifyClawlingClientError(err);
403
941
  if (classified.kind === "auth") {
942
+ finishCurrentConnection({
943
+ state: "auth_failed",
944
+ error: lastHelloFailReason || classified.message,
945
+ });
404
946
  logAuthFailure(classified.message);
405
947
  setStatus({
406
948
  ...getStatus(),
@@ -410,6 +952,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
410
952
  lastError: classified.message,
411
953
  });
412
954
  } else if (classified.kind === "transport") {
955
+ finishCurrentConnection({ state: "transport_error", error: classified.message });
413
956
  const current = wsLogContext();
414
957
  log?.info?.(
415
958
  formatWsLog({
@@ -436,153 +979,428 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
436
979
  } else if (classified.kind === "state") {
437
980
  log?.info?.(`[${accountId}] openclaw-clawchat state error: ${classified.message}`);
438
981
  } else {
439
- log?.error?.(`[${accountId}] openclaw-clawchat sdk error: ${classified.message}`);
982
+ log?.error?.(`[${accountId}] openclaw-clawchat client error: ${classified.message}`);
440
983
  }
441
984
  });
442
985
 
443
- client.on("message", async (env: Envelope) => {
986
+ type IngestTurnResult = "submitted" | "skipped" | "failed";
987
+ type GroupIngestTurnParams = IngestTurnParams & { peer: { kind: "group"; id: string } };
988
+
989
+ const buildPromptForTurn = async (turn: IngestTurnParams, memoryRoot: string): Promise<string> => {
990
+ const senderRelation = resolveSenderRelation({
991
+ senderId: turn.senderId,
992
+ accountUserId: account.userId,
993
+ accountOwnerUserId: account.ownerUserId,
994
+ senderProfileType: turn.senderProfileType,
995
+ });
996
+ const promptChatType = turn.peer.kind === "group" ? "group" : "dm";
997
+ const promptMetadata = await loadClawChatPromptMetadata({
998
+ memoryRoot,
999
+ turn: {
1000
+ chatType: promptChatType,
1001
+ senderId: turn.senderId,
1002
+ senderIsOwner: senderRelation === "owner",
1003
+ groupId: turn.peer.kind === "group" ? turn.peer.id : null,
1004
+ },
1005
+ log: { error: (message) => log?.error?.(`[${accountId}] ${message}`) },
1006
+ });
1007
+ const metadataSenderProfileType = promptMetadata.userMetadata?.profile_type ?? null;
1008
+ const promptSenderProfileType = metadataSenderProfileType ?? turn.senderProfileType ?? (
1009
+ senderRelation === "self_agent" || senderRelation === "peer_agent" ? "agent" : "user"
1010
+ );
1011
+ return renderClawChatProfilePrompt({
1012
+ basePrompt: turn.peer.kind === "group" ? getClawChatGroupPrompt() : getClawChatUserPrompt(),
1013
+ ...promptMetadata,
1014
+ turn: {
1015
+ chatType: promptChatType,
1016
+ senderId: turn.senderId,
1017
+ senderName: promptMetadata.userMetadata?.nickname ?? (turn.senderNickName || turn.senderId),
1018
+ senderProfileType: promptSenderProfileType,
1019
+ senderIsOwner: senderRelation === "owner",
1020
+ groupId: turn.peer.kind === "group" ? turn.peer.id : null,
1021
+ coalescedGroupBatch: turn.coalescedGroupBatch === true,
1022
+ wasMentioned: turn.wasMentioned,
1023
+ mentionedUserIds: turn.mentionedUserIds,
1024
+ },
1025
+ });
1026
+ };
1027
+
1028
+ const resolveSenderNickNameForTurn = (turn: IngestTurnParams): string => {
1029
+ if (turn.senderNickName && turn.senderNickName !== turn.senderId) return turn.senderNickName;
1030
+ return turn.senderNickName || turn.senderId;
1031
+ };
1032
+
1033
+ const resolveSenderBatchIdentityForTurn = (turn: IngestTurnParams): {
1034
+ senderRelation: NonNullable<IngestTurnParams["senderRelation"]>;
1035
+ senderProfileType: string;
1036
+ senderIsOwner: boolean;
1037
+ } => {
1038
+ const senderRelation = resolveSenderRelation({
1039
+ senderId: turn.senderId,
1040
+ accountUserId: account.userId,
1041
+ accountOwnerUserId: account.ownerUserId,
1042
+ senderProfileType: turn.senderProfileType,
1043
+ });
1044
+ return {
1045
+ senderRelation,
1046
+ senderProfileType: turn.senderProfileType ??
1047
+ (senderRelation === "self_agent" || senderRelation === "peer_agent" ? "agent" : "user"),
1048
+ senderIsOwner: senderRelation === "owner",
1049
+ };
1050
+ };
1051
+
1052
+ const claimInboundTurn = (turn: IngestTurnParams): "claimed" | "skipped" => {
1053
+ const env = turn.envelope;
1054
+ if (store?.claimMessageOnce) {
1055
+ const claimed = recordConnection("message claim", () =>
1056
+ store.claimMessageOnce?.({
1057
+ platform: "openclaw",
1058
+ accountId,
1059
+ kind: "message",
1060
+ direction: "inbound",
1061
+ eventType: String(env.event),
1062
+ traceId: turn.traceId,
1063
+ chatId: turn.peer.id,
1064
+ messageId: turn.messageId,
1065
+ text: turn.rawBody,
1066
+ raw: env,
1067
+ }),
1068
+ );
1069
+ if (claimed === false) {
1070
+ log?.info?.(
1071
+ `[${accountId}] openclaw-clawchat skip duplicate stored msg=${turn.messageId}`,
1072
+ );
1073
+ return "skipped";
1074
+ }
1075
+ }
1076
+ return "claimed";
1077
+ };
1078
+
1079
+ const dispatchTurnToAgent = async (turn: IngestTurnParams): Promise<IngestTurnResult> => {
1080
+ const rt = runtime.channel;
1081
+ const storePath = rt.session.resolveStorePath(cfg.session?.store);
1082
+ const routeCfg = withClawChatSessionScope(cfg);
1083
+ const route = rt.routing.resolveAgentRoute({
1084
+ cfg: routeCfg,
1085
+ channel: CHANNEL_ID,
1086
+ accountId,
1087
+ peer: turn.peer,
1088
+ });
1089
+ const memoryRoot = resolveClawChatMemoryRoot(runtime, cfg, route.agentId);
1090
+ const body = rt.reply.formatAgentEnvelope({
1091
+ channel: "Clawling Chat",
1092
+ from: formatConversationSubject(turn.peer),
1093
+ body: turn.rawBody,
1094
+ timestamp: turn.timestamp,
1095
+ ...rt.reply.resolveEnvelopeFormatOptions(cfg),
1096
+ });
444
1097
  try {
445
- await dispatchOpenclawClawlingInbound({
446
- envelope: env as Envelope<unknown>,
447
- cfg,
448
- runtime,
449
- account,
450
- log,
451
- ingest: async (turn) => {
452
- const rt = runtime.channel;
453
- const storePath = rt.session.resolveStorePath(cfg.session?.store);
454
- const routeCfg = withClawChatSessionScope(cfg);
455
- const route = rt.routing.resolveAgentRoute({
456
- cfg: routeCfg,
457
- channel: CHANNEL_ID,
458
- accountId,
459
- peer: turn.peer,
460
- });
461
- const body = rt.reply.formatAgentEnvelope({
462
- channel: "Clawling Chat",
463
- from: formatConversationSubject(turn.peer),
464
- body: turn.rawBody,
465
- timestamp: turn.timestamp,
466
- ...rt.reply.resolveEnvelopeFormatOptions(cfg),
467
- });
468
- const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
469
- const ctxPayload = rt.reply.finalizeInboundContext({
470
- Body: body,
471
- BodyForAgent: turn.rawBody,
472
- RawBody: turn.rawBody,
473
- CommandBody: turn.rawBody,
474
- // Clawling v2 routes by chat_id. `OriginatingTo` is what the
475
- // message tool uses as the implicit current-chat target, so keep it
476
- // on the conversation id rather than the agent account user id.
477
- From: conversationTarget,
478
- To: `${CHANNEL_ID}:${account.userId}`,
479
- SessionKey: route.sessionKey,
480
- AccountId: route.accountId ?? accountId,
481
- ChatType: turn.peer.kind,
482
- ConversationLabel: formatConversationSubject(turn.peer),
483
- SenderId: turn.senderId,
484
- Provider: CHANNEL_ID,
485
- Surface: CHANNEL_ID,
486
- MessageSid: turn.messageId,
487
- MessageSidFull: turn.messageId,
488
- Timestamp: turn.timestamp,
489
- OriginatingChannel: CHANNEL_ID,
490
- OriginatingTo: conversationTarget,
491
- });
492
- // Fetch any inbound media attachments and populate MediaPath/MediaPaths in context.
493
- const inboundPaths =
494
- turn.mediaItems.length > 0
495
- ? await fetchInboundMedia(turn.mediaItems, {
496
- runtime,
497
- log,
498
- maxBytes: 20 * 1024 * 1024,
499
- })
500
- : [];
501
- if (inboundPaths.length > 0) {
502
- (ctxPayload as Record<string, unknown>).MediaPath = inboundPaths[0];
503
- (ctxPayload as Record<string, unknown>).MediaPaths = inboundPaths;
504
- }
1098
+ await syncMessagePathProfiles(turn, memoryRoot);
1099
+ } catch (err) {
1100
+ log?.error?.(
1101
+ `[${accountId}] openclaw-clawchat message metadata refresh failed: ${err instanceof Error ? err.message : String(err)}`,
1102
+ );
1103
+ }
1104
+ const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
1105
+ const turnPrompt = await buildPromptForTurn(turn, memoryRoot);
1106
+ const ctxPayload = rt.turn.buildContext({
1107
+ channel: CHANNEL_ID,
1108
+ accountId: route.accountId ?? accountId,
1109
+ provider: CHANNEL_ID,
1110
+ surface: CHANNEL_ID,
1111
+ messageId: turn.messageId,
1112
+ messageIdFull: turn.messageId,
1113
+ timestamp: turn.timestamp,
1114
+ from: conversationTarget,
1115
+ sender: {
1116
+ id: turn.senderId,
1117
+ name: turn.senderNickName || turn.senderId,
1118
+ displayLabel: turn.senderNickName || turn.senderId,
1119
+ },
1120
+ conversation: {
1121
+ kind: turn.peer.kind,
1122
+ id: turn.peer.id,
1123
+ label: formatConversationSubject(turn.peer),
1124
+ routePeer: turn.peer,
1125
+ },
1126
+ route: {
1127
+ agentId: route.agentId,
1128
+ accountId: route.accountId ?? accountId,
1129
+ routeSessionKey: route.sessionKey,
1130
+ },
1131
+ reply: {
1132
+ to: `${CHANNEL_ID}:${account.userId}`,
1133
+ originatingTo: conversationTarget,
1134
+ },
1135
+ message: {
1136
+ body,
1137
+ rawBody: turn.rawBody,
1138
+ bodyForAgent: turn.rawBody,
1139
+ commandBody: turn.rawBody,
1140
+ envelopeFrom: conversationTarget,
1141
+ },
1142
+ access: {
1143
+ mentions: {
1144
+ canDetectMention: true,
1145
+ wasMentioned: turn.wasMentioned,
1146
+ hasAnyMention: turn.mentionedUserIds.length > 0,
1147
+ },
1148
+ },
1149
+ ...(memoryRoot ? { extra: { memoryRoot } } : {}),
1150
+ ...(turn.peer.kind === "group"
1151
+ ? { supplemental: { groupSystemPrompt: turnPrompt } }
1152
+ : {}),
1153
+ });
1154
+ if (memoryRoot) {
1155
+ (ctxPayload as Record<string, unknown>).memoryRoot = memoryRoot;
1156
+ }
1157
+ if (turn.mentionedUserIds.length > 0) {
1158
+ (ctxPayload as Record<string, unknown>).MentionedUserIds = turn.mentionedUserIds;
1159
+ }
1160
+ // Fetch any inbound media attachments and populate MediaPath/MediaPaths in context.
1161
+ const inboundPaths =
1162
+ turn.mediaItems.length > 0
1163
+ ? await fetchInboundMedia(turn.mediaItems, {
1164
+ runtime,
1165
+ log,
1166
+ maxBytes: 20 * 1024 * 1024,
1167
+ })
1168
+ : [];
1169
+ if (inboundPaths.length > 0) {
1170
+ (ctxPayload as Record<string, unknown>).MediaPath = inboundPaths[0];
1171
+ (ctxPayload as Record<string, unknown>).MediaPaths = inboundPaths;
1172
+ }
505
1173
 
506
- try {
507
- await rt.session.recordInboundSession({
508
- storePath,
509
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
510
- ctx: ctxPayload,
511
- onRecordError: (err) => {
512
- log?.error?.(
513
- `[${accountId}] openclaw-clawchat failed to record inbound session: ${String(err)}`,
514
- );
515
- },
516
- });
517
- } catch (err) {
1174
+ const resolvedSessionKey = ctxPayload.SessionKey ?? route.sessionKey;
1175
+
1176
+ clearClawChatPromptInjectionForSession(resolvedSessionKey);
1177
+ const stagedDirectPrompt = turn.peer.kind !== "group";
1178
+ if (stagedDirectPrompt) {
1179
+ stageClawChatPromptInjection({
1180
+ sessionKey: resolvedSessionKey,
1181
+ prompt: turnPrompt,
1182
+ });
1183
+ }
1184
+
1185
+ try {
1186
+ try {
1187
+ await rt.session.recordInboundSession({
1188
+ storePath,
1189
+ sessionKey: resolvedSessionKey,
1190
+ ctx: ctxPayload,
1191
+ onRecordError: (err) => {
518
1192
  log?.error?.(
519
1193
  `[${accountId}] openclaw-clawchat failed to record inbound session: ${String(err)}`,
520
1194
  );
521
- }
1195
+ },
1196
+ });
1197
+ } catch (err) {
1198
+ log?.error?.(
1199
+ `[${accountId}] openclaw-clawchat failed to record inbound session: ${String(err)}`,
1200
+ );
1201
+ }
522
1202
 
523
- const replyCtx = turn.replyCtx;
524
- const { dispatcher, replyOptions, markDispatchIdle } =
525
- createOpenclawClawlingReplyDispatcher({
526
- cfg,
527
- runtime,
528
- account,
529
- client,
530
- target: { chatId: turn.peer.id, chatType: turn.peer.kind },
531
- ...(replyCtx ? { replyCtx } : {}),
532
- inboundMessageId: turn.messageId,
533
- inboundForFinalReply: {
534
- chatId: turn.peer.id,
535
- senderId: turn.senderId,
536
- senderNickName: turn.senderNickName || turn.senderId,
537
- bodyText: turn.rawBody,
538
- },
539
- log,
540
- });
1203
+ const replyCtx = turn.replyCtx;
1204
+ const { dispatcher, replyOptions, markDispatchIdle } =
1205
+ createOpenclawClawlingReplyDispatcher({
1206
+ cfg,
1207
+ runtime,
1208
+ account,
1209
+ client,
1210
+ target: { chatId: turn.peer.id, chatType: turn.peer.kind },
1211
+ ...(replyCtx ? { replyCtx } : {}),
1212
+ inboundMessageId: turn.messageId,
1213
+ inboundForFinalReply: {
1214
+ chatId: turn.peer.id,
1215
+ senderId: turn.senderId,
1216
+ senderNickName: turn.senderNickName || turn.senderId,
1217
+ bodyText: turn.rawBody,
1218
+ },
1219
+ store: store
1220
+ ? {
1221
+ insertMessage: (input) => store.insertMessage?.(input) ?? null,
1222
+ claimMessageOnce: (input) => store.claimMessageOnce?.(input) ?? null,
1223
+ updateMessageByIdentity: (input) => store.updateMessageByIdentity?.(input),
1224
+ }
1225
+ : null,
1226
+ log,
1227
+ });
541
1228
 
542
- const agentsConfigured = Object.keys((cfg as { agents?: Record<string, unknown> }).agents ?? {});
1229
+ const agentsConfigured = Object.keys((cfg as { agents?: Record<string, unknown> }).agents ?? {});
1230
+ log?.info?.(
1231
+ `[${accountId}] openclaw-clawchat dispatching reply msg=${turn.messageId} session=${resolvedSessionKey} agent=${route.agentId} agentsConfigured=[${agentsConfigured.join(",")}]`,
1232
+ );
1233
+
1234
+ try {
1235
+ const dispatchResult = await rt.reply.withReplyDispatcher({
1236
+ dispatcher,
1237
+ onSettled: () => markDispatchIdle(),
1238
+ run: () => rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
1239
+ });
1240
+ const counts = (dispatchResult as { counts?: Record<string, number> } | undefined)?.counts ?? {};
1241
+ const queuedFinal = Boolean(
1242
+ (dispatchResult as { queuedFinal?: boolean } | undefined)?.queuedFinal,
1243
+ );
1244
+ log?.info?.(
1245
+ `[${accountId}] openclaw-clawchat dispatch complete msg=${turn.messageId} queuedFinal=${queuedFinal} counts=${JSON.stringify(counts)}`,
1246
+ );
1247
+ if (!queuedFinal && Object.values(counts).every((n) => !n)) {
543
1248
  log?.info?.(
544
- `[${accountId}] openclaw-clawchat dispatching reply msg=${turn.messageId} session=${ctxPayload.SessionKey ?? route.sessionKey} agent=${route.agentId} agentsConfigured=[${agentsConfigured.join(",")}]`,
1249
+ `[${accountId}] openclaw-clawchat NO reply was produced (no final / block / tool dispatched). ` +
1250
+ `Likely causes: agent='${route.agentId}' not configured in cfg.agents (configured: [${agentsConfigured.join(",")}]); ` +
1251
+ `or send-policy denied; or a plugin claimed the binding.`,
545
1252
  );
1253
+ }
1254
+ return "submitted";
1255
+ } catch (err) {
1256
+ log?.error?.(
1257
+ `[${accountId}] openclaw-clawchat dispatch failed msg=${turn.messageId}: ${String(err)}`,
1258
+ );
1259
+ return "failed";
1260
+ }
1261
+ } finally {
1262
+ if (stagedDirectPrompt) {
1263
+ clearClawChatPromptInjectionForSession(resolvedSessionKey);
1264
+ }
1265
+ }
1266
+ };
546
1267
 
547
- try {
548
- const dispatchResult = await rt.reply.withReplyDispatcher({
549
- dispatcher,
550
- onSettled: () => markDispatchIdle(),
551
- run: () =>
552
- rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
553
- });
554
- const counts = (dispatchResult as { counts?: Record<string, number> } | undefined)?.counts ?? {};
555
- const queuedFinal = Boolean(
556
- (dispatchResult as { queuedFinal?: boolean } | undefined)?.queuedFinal,
557
- );
558
- log?.info?.(
559
- `[${accountId}] openclaw-clawchat dispatch complete msg=${turn.messageId} queuedFinal=${queuedFinal} counts=${JSON.stringify(counts)}`,
560
- );
561
- if (!queuedFinal && Object.values(counts).every((n) => !n)) {
562
- log?.info?.(
563
- `[${accountId}] openclaw-clawchat NO reply was produced (no final / block / tool dispatched). ` +
564
- `Likely causes: agent='${route.agentId}' not configured in cfg.agents (configured: [${agentsConfigured.join(",")}]); ` +
565
- `or send-policy denied; or a plugin claimed the binding.`,
566
- );
567
- }
568
- } catch (err) {
569
- log?.error?.(
570
- `[${accountId}] openclaw-clawchat dispatch failed msg=${turn.messageId}: ${String(err)}`,
571
- );
572
- }
1268
+ const groupCoalescer = createGroupMessageCoalescer<GroupIngestTurnParams>({
1269
+ idleMs: 10_000,
1270
+ maxWaitMs: 30_000,
1271
+ dispatch: async (turn) => {
1272
+ await dispatchTurnToAgent(turn);
1273
+ },
1274
+ onError: (err) => {
1275
+ log?.error?.(
1276
+ `[${accountId}] openclaw-clawchat coalesced group dispatch failed: ${String(err)}`,
1277
+ );
1278
+ },
1279
+ onDrop: (chatId, count) => {
1280
+ log?.info?.(
1281
+ `[${accountId}] openclaw-clawchat dropped pending group batch chat_id=${chatId} count=${count} reason=shutdown`,
1282
+ );
1283
+ },
1284
+ });
1285
+
1286
+ const ingestTurn = async (rawTurn: IngestTurnParams): Promise<IngestTurnResult> => {
1287
+ const senderBatchIdentity = rawTurn.peer.kind === "group"
1288
+ ? resolveSenderBatchIdentityForTurn(rawTurn)
1289
+ : {};
1290
+ const turn: IngestTurnParams = {
1291
+ ...rawTurn,
1292
+ senderNickName: resolveSenderNickNameForTurn(rawTurn),
1293
+ ...senderBatchIdentity,
1294
+ };
1295
+ const claimed = claimInboundTurn(turn);
1296
+ if (claimed === "skipped") return "skipped";
1297
+ if (turn.peer.kind === "group") {
1298
+ if (isKnownOpenClawGroupSlashCommand(turn.rawBody, cfg)) {
1299
+ const commandMode = effectiveGroupCommandMode(account, turn.peer.id);
1300
+ const commandAllowed = commandMode === "all"
1301
+ || (commandMode === "owner" && turn.senderIsOwner === true);
1302
+ if (!commandAllowed) {
1303
+ log?.info?.(
1304
+ `[${accountId}] openclaw-clawchat group command dropped chat_id=${turn.peer.id} msg=${turn.messageId} mode=${commandMode} owner=${turn.senderIsOwner === true}`,
1305
+ );
1306
+ return "skipped";
1307
+ }
1308
+ log?.info?.(
1309
+ `[${accountId}] openclaw-clawchat dispatching group command chat_id=${turn.peer.id} msg=${turn.messageId} mode=${commandMode}`,
1310
+ );
1311
+ return dispatchTurnToAgent(turn);
1312
+ }
1313
+ if (turn.wasMentioned) {
1314
+ groupCoalescer.enqueue(turn as GroupIngestTurnParams);
1315
+ groupCoalescer.flushNow(turn.peer.id);
1316
+ log?.info?.(
1317
+ `[${accountId}] openclaw-clawchat dispatching mentioned group batch chat_id=${turn.peer.id} msg=${turn.messageId}`,
1318
+ );
1319
+ return "submitted";
1320
+ }
1321
+ groupCoalescer.enqueue(turn as GroupIngestTurnParams);
1322
+ log?.info?.(
1323
+ `[${accountId}] openclaw-clawchat queued group batch chat_id=${turn.peer.id} msg=${turn.messageId}`,
1324
+ );
1325
+ return "submitted";
1326
+ }
1327
+ return dispatchTurnToAgent(turn);
1328
+ };
1329
+
1330
+ const handleInboundEnvelope = async (env: Envelope): Promise<IngestTurnResult | undefined> => {
1331
+ let ingestResult: IngestTurnResult | undefined;
1332
+ try {
1333
+ await dispatchOpenclawClawlingInbound({
1334
+ envelope: env as Envelope<unknown>,
1335
+ cfg,
1336
+ runtime,
1337
+ account,
1338
+ log,
1339
+ ingest: async (turn) => {
1340
+ ingestResult = await ingestTurn(turn);
573
1341
  },
574
1342
  });
575
1343
  } catch (err) {
576
1344
  log?.error?.(
577
1345
  `[${accountId}] openclaw-clawchat message handler error: ${err instanceof Error ? err.stack || err.message : String(err)}`,
578
1346
  );
1347
+ return "failed";
1348
+ }
1349
+ return ingestResult;
1350
+ };
1351
+
1352
+ dispatchActivationBootstrap = async (): Promise<void> => {
1353
+ if (!store?.claimPendingActivationBootstrap || !store.markActivationBootstrapSent) return;
1354
+ let bootstrap: { conversationId: string } | null | undefined;
1355
+ const releaseBootstrap = () => {
1356
+ if (!bootstrap || !store.releaseActivationBootstrapClaim) return;
1357
+ const claimedBootstrap = bootstrap;
1358
+ recordConnection("activation bootstrap release", () =>
1359
+ store.releaseActivationBootstrapClaim?.({
1360
+ platform: "openclaw",
1361
+ accountId,
1362
+ conversationId: claimedBootstrap.conversationId,
1363
+ }),
1364
+ );
1365
+ };
1366
+ try {
1367
+ bootstrap = recordConnection("activation bootstrap claim", () =>
1368
+ store.claimPendingActivationBootstrap?.({ platform: "openclaw", accountId }),
1369
+ );
1370
+ if (!bootstrap) return;
1371
+ const claimedBootstrap = bootstrap;
1372
+ const result = await handleInboundEnvelope(
1373
+ buildActivationBootstrapEnvelope({ account, conversationId: claimedBootstrap.conversationId }),
1374
+ );
1375
+ if (result !== "submitted") {
1376
+ releaseBootstrap();
1377
+ return;
1378
+ }
1379
+ recordConnection("activation bootstrap sent", () =>
1380
+ store.markActivationBootstrapSent?.({
1381
+ platform: "openclaw",
1382
+ accountId,
1383
+ conversationId: claimedBootstrap.conversationId,
1384
+ }),
1385
+ );
1386
+ } catch (err) {
1387
+ releaseBootstrap();
1388
+ log?.error?.(
1389
+ `[${accountId}] openclaw-clawchat activation bootstrap failed: ${err instanceof Error ? err.message : String(err)}`,
1390
+ );
579
1391
  }
1392
+ };
1393
+
1394
+ client.on("message", (env: Envelope) => {
1395
+ void (async () => {
1396
+ await handleInboundEnvelope(env);
1397
+ })();
580
1398
  });
581
1399
 
582
1400
  // `client.connect()` resolves on `hello-ok` or rejects on `hello-fail`
583
1401
  // (auth). Transport failures (server unreachable, DNS error, etc.) do
584
- // NOT reject this promise — the SDK catches them internally and drives
585
- // its own exponential-backoff reconnect loop (`initialDelay * 2^attempt`
1402
+ // NOT reject this promise — the local client handles them internally and
1403
+ // drives its own exponential-backoff reconnect loop (`initialDelay * 2^attempt`
586
1404
  // capped at `maxDelay`, with jitter). So we never throw here on anything
587
1405
  // other than auth failure; on auth we tear the account down cleanly and
588
1406
  // return without throwing (which would make the gateway supervisor
@@ -601,6 +1419,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
601
1419
  lastError: classified.message,
602
1420
  });
603
1421
  if (classified.kind === "auth") {
1422
+ finishCurrentConnection({
1423
+ state: "auth_failed",
1424
+ error: lastHelloFailReason || classified.message,
1425
+ });
604
1426
  logAuthFailure(classified.message);
605
1427
  return;
606
1428
  }
@@ -623,6 +1445,12 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
623
1445
  log?.info?.(`[${accountId}] openclaw-clawchat runtime abort received; closing client`);
624
1446
  activeClients.delete(accountId);
625
1447
  closingForAbort = true;
1448
+ groupCoalescer.cancelAll();
1449
+ finishCurrentConnection({
1450
+ state: "disconnected",
1451
+ closeCode: 1000,
1452
+ closeReason: "client close",
1453
+ });
626
1454
  client.close();
627
1455
  setStatus({
628
1456
  ...getStatus(),