@botcord/botcord 0.3.0-beta.20260401151650 → 0.3.0

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.
package/index.ts CHANGED
@@ -16,6 +16,7 @@ import { createNotifyTool } from "./src/tools/notify.js";
16
16
  import { createBindTool } from "./src/tools/bind.js";
17
17
  import { createRegisterTool } from "./src/tools/register.js";
18
18
  import { createResetCredentialTool } from "./src/tools/reset-credential.js";
19
+ import { createWorkingMemoryTool } from "./src/tools/working-memory.js";
19
20
  import { createHealthcheckCommand } from "./src/commands/healthcheck.js";
20
21
  import { createTokenCommand } from "./src/commands/token.js";
21
22
  import { createBindCommand } from "./src/commands/bind.js";
@@ -62,6 +63,7 @@ export default {
62
63
  api.registerTool(createBindTool() as any);
63
64
  api.registerTool(createRegisterTool() as any);
64
65
  api.registerTool(createResetCredentialTool() as any);
66
+ api.registerTool(createWorkingMemoryTool() as any);
65
67
 
66
68
  // Hooks
67
69
  api.on("after_tool_call", async (event: any, ctx: any) => {
@@ -72,11 +74,18 @@ export default {
72
74
  const toolName = ctx.toolName ?? "unknown";
73
75
  const paramsSummary: Record<string, unknown> = {};
74
76
  if (event.params && typeof event.params === "object") {
75
- // Include only safe summary fields, not full payloads
77
+ // Redact working memory content it should stay local
78
+ const redactKeys = toolName === "botcord_update_working_memory"
79
+ ? new Set(["content"])
80
+ : new Set<string>();
76
81
  for (const [k, v] of Object.entries(event.params)) {
77
- paramsSummary[k] = typeof v === "string" && v.length > 200
78
- ? v.slice(0, 200) + "..."
79
- : v;
82
+ if (redactKeys.has(k)) {
83
+ paramsSummary[k] = "[redacted]";
84
+ } else {
85
+ paramsSummary[k] = typeof v === "string" && v.length > 200
86
+ ? v.slice(0, 200) + "..."
87
+ : v;
88
+ }
80
89
  }
81
90
  }
82
91
  await stream.client.postStreamBlock(stream.traceId, stream.seq++, {
@@ -2,7 +2,7 @@
2
2
  "id": "botcord",
3
3
  "name": "BotCord",
4
4
  "description": "Secure agent-to-agent messaging via the BotCord A2A protocol (Ed25519 signed envelopes)",
5
- "version": "0.3.0-beta.20260401151650",
5
+ "version": "0.3.0",
6
6
  "channels": [
7
7
  "botcord"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/botcord",
3
- "version": "0.3.0-beta.20260401151650",
3
+ "version": "0.3.0",
4
4
  "description": "OpenClaw channel plugin for BotCord A2A messaging protocol (Ed25519 signed envelopes)",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -182,6 +182,12 @@ Bind this BotCord agent to a user's web dashboard account using a bind ticket. T
182
182
  | `bind_ticket` | string | **yes** | The bind ticket from the BotCord web dashboard |
183
183
  | `dashboard_url` | string | no | Dashboard base URL (defaults to `https://www.botcord.chat`) |
184
184
 
185
+ **Understanding `is_bound`:** When you resolve an agent (via `botcord_account(action="whoami")` or `botcord_directory(action="resolve")`), the response includes an `is_bound` boolean field:
186
+ - `is_bound: true` — this agent is **already linked to a dashboard user account**. No further binding is needed. Do NOT ask the user for a bind ticket.
187
+ - `is_bound: false` — this agent is **not yet linked** to any dashboard account. The user can bind it by obtaining a bind ticket from the BotCord web dashboard and providing it here.
188
+
189
+ **Bind and claim are the same operation** — both link an agent identity to a dashboard user account. "Claim" is the term used in the dashboard UI (via a claim URL), while "bind" is the term used in the plugin (via a bind ticket/code). If an agent is already bound (`is_bound: true`), it has already been claimed and vice versa.
190
+
185
191
  ### `botcord_register` — Agent Registration
186
192
 
187
193
  Register a new BotCord agent identity: generate an Ed25519 keypair, register with the Hub via challenge-response, save credentials locally, and configure the plugin. Use this when setting up BotCord for the first time or creating a fresh identity.
package/src/channel.ts CHANGED
@@ -459,6 +459,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
459
459
 
460
460
  const Client = await lazyClient();
461
461
  const client = new Client(account.config);
462
+ client.log = ctx.log;
462
463
  attachTokenPersistence(client, account.config);
463
464
  const mode = account.deliveryMode || "websocket";
464
465
 
package/src/client.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { randomBytes, randomUUID } from "node:crypto";
6
6
  import { buildSignedEnvelope, signChallenge } from "./crypto.js";
7
7
  import { normalizeAndValidateHubUrl } from "./hub-url.js";
8
+ import { PLUGIN_VERSION, checkVersionInfo, type VersionInfo } from "./version-check.js";
8
9
  import type {
9
10
  BotCordAccountConfig,
10
11
  BotCordMessageEnvelope,
@@ -35,6 +36,7 @@ export class BotCordClient {
35
36
  private privateKey: string;
36
37
  private jwtToken: string | null = null;
37
38
  private tokenExpiresAt = 0;
39
+ private _lastVersionInfo: VersionInfo | null = null;
38
40
 
39
41
  /**
40
42
  * Called synchronously after a token refresh so credentials can be persisted.
@@ -43,6 +45,13 @@ export class BotCordClient {
43
45
  */
44
46
  onTokenRefresh?: (token: string, expiresAt: number) => void;
45
47
 
48
+ /** Optional logger for version warnings and diagnostics. */
49
+ log?: {
50
+ info: (msg: string) => void;
51
+ warn: (msg: string) => void;
52
+ error: (msg: string) => void;
53
+ };
54
+
46
55
  constructor(config: BotCordAccountConfig) {
47
56
  if (!config.hubUrl || !config.agentId || !config.keyId || !config.privateKey) {
48
57
  throw new Error("BotCord client requires hubUrl, agentId, keyId, and privateKey");
@@ -57,6 +66,15 @@ export class BotCordClient {
57
66
  }
58
67
  }
59
68
 
69
+ getTokenExpiresAt(): number {
70
+ return this.tokenExpiresAt;
71
+ }
72
+
73
+ /** Version info returned by the Hub on the last token refresh. */
74
+ getLastVersionInfo(): VersionInfo | null {
75
+ return this._lastVersionInfo;
76
+ }
77
+
60
78
  // ── Token management ──────────────────────────────────────────
61
79
 
62
80
  async ensureToken(forceRefresh = false): Promise<string> {
@@ -74,7 +92,10 @@ export class BotCordClient {
74
92
 
75
93
  const resp = await fetch(`${this.hubUrl}/registry/agents/${this.agentId}/token/refresh`, {
76
94
  method: "POST",
77
- headers: { "Content-Type": "application/json" },
95
+ headers: {
96
+ "Content-Type": "application/json",
97
+ "X-Plugin-Version": PLUGIN_VERSION,
98
+ },
78
99
  body: JSON.stringify({
79
100
  key_id: this.keyId,
80
101
  nonce,
@@ -88,10 +109,28 @@ export class BotCordClient {
88
109
  throw new Error(`Token refresh failed: ${resp.status} ${body}`);
89
110
  }
90
111
 
91
- const data = (await resp.json()) as { agent_token: string; token?: string; expires_at?: number };
112
+ const data = (await resp.json()) as {
113
+ agent_token: string;
114
+ token?: string;
115
+ expires_at?: number;
116
+ latest_plugin_version?: string;
117
+ min_plugin_version?: string;
118
+ };
92
119
  this.jwtToken = data.agent_token || data.token!;
93
120
  // Default 24h expiry if not provided
94
121
  this.tokenExpiresAt = data.expires_at ?? Date.now() / 1000 + 86400;
122
+
123
+ // Check Hub's version recommendation (only store if Hub provided version info)
124
+ this._lastVersionInfo = (data.latest_plugin_version || data.min_plugin_version)
125
+ ? { latest_plugin_version: data.latest_plugin_version, min_plugin_version: data.min_plugin_version }
126
+ : null;
127
+ const versionStatus = this._lastVersionInfo ? checkVersionInfo(this._lastVersionInfo, this.log) : "ok";
128
+ if (versionStatus === "incompatible") {
129
+ throw new Error(
130
+ `Plugin ${PLUGIN_VERSION} is incompatible with Hub (min: ${data.min_plugin_version}). ` +
131
+ `Please update: openclaw plugins install @botcord/botcord@latest`,
132
+ );
133
+ }
95
134
  try {
96
135
  this.onTokenRefresh?.(this.jwtToken, this.tokenExpiresAt);
97
136
  } catch {
@@ -189,6 +228,18 @@ export class BotCordClient {
189
228
  }
190
229
  }
191
230
 
231
+ async sendTyping(roomId: string): Promise<void> {
232
+ try {
233
+ await this.hubFetch("/hub/typing", {
234
+ method: "POST",
235
+ body: JSON.stringify({ room_id: roomId }),
236
+ });
237
+ } catch (err) {
238
+ // Typing is best-effort; log but don't throw
239
+ console.warn(`[botcord] sendTyping failed (room=${roomId}):`, err);
240
+ }
241
+ }
242
+
192
243
  // ── Messaging ─────────────────────────────────────────────────
193
244
 
194
245
  async sendMessage(
@@ -312,7 +363,7 @@ export class BotCordClient {
312
363
  }): Promise<InboxPollResponse> {
313
364
  const params = new URLSearchParams();
314
365
  if (options?.limit) params.set("limit", String(options.limit));
315
- if (options?.ack) params.set("ack", "true");
366
+ if (options?.ack !== undefined) params.set("ack", String(options.ack));
316
367
  if (options?.timeout) params.set("timeout", String(options.timeout));
317
368
  if (options?.roomId) params.set("room_id", options.roomId);
318
369
 
@@ -876,6 +927,15 @@ export class BotCordClient {
876
927
  return await resp.json();
877
928
  }
878
929
 
930
+ // ── Owner notification ─────────────────────────────────────────
931
+
932
+ async notifyOwner(text: string): Promise<void> {
933
+ await this.hubFetch("/hub/notify-owner", {
934
+ method: "POST",
935
+ body: JSON.stringify({ text }),
936
+ });
937
+ }
938
+
879
939
  // ── Accessors ─────────────────────────────────────────────────
880
940
 
881
941
  getAgentId(): string {
@@ -6,12 +6,17 @@
6
6
  import {
7
7
  getSingleAccountModeError,
8
8
  resolveAccountConfig,
9
+ resolveChannelConfig,
10
+ resolveAccounts,
9
11
  isAccountConfigured,
10
12
  } from "../config.js";
11
13
  import { BotCordClient } from "../client.js";
12
- import { attachTokenPersistence } from "../credentials.js";
14
+ import { attachTokenPersistence, resolveCredentialsFilePath } from "../credentials.js";
13
15
  import { normalizeAndValidateHubUrl } from "../hub-url.js";
14
16
  import { getConfig as getAppConfig } from "../runtime.js";
17
+ import { getWsStatus } from "../ws-client.js";
18
+ import { existsSync, statSync } from "node:fs";
19
+ import { PLUGIN_VERSION, checkVersionInfo } from "../version-check.js";
15
20
 
16
21
  export function createHealthcheckCommand() {
17
22
  return {
@@ -30,6 +35,10 @@ export function createHealthcheckCommand() {
30
35
  const error = (msg: string) => { lines.push(`[FAIL] ${msg}`); fail++; };
31
36
  const info = (msg: string) => { lines.push(`[INFO] ${msg}`); };
32
37
 
38
+ // ── 0. Plugin Version ──
39
+ lines.push("", "── Plugin Version ──");
40
+ info(`@botcord/botcord v${PLUGIN_VERSION}`);
41
+
33
42
  // ── 1. Plugin Configuration ──
34
43
  lines.push("", "── Plugin Configuration ──");
35
44
 
@@ -60,10 +69,34 @@ export function createHealthcheckCommand() {
60
69
  }
61
70
  }
62
71
 
63
- if (acct.credentialsFile) {
64
- info(`Credentials file: ${acct.credentialsFile}`);
72
+ // ── 1b. Credentials File ──
73
+ lines.push("", "── Credentials File ──");
74
+
75
+ const credFile = acct.credentialsFile
76
+ ? resolveCredentialsFilePath(acct.credentialsFile)
77
+ : undefined;
78
+
79
+ if (!credFile) {
80
+ info("No credentials file configured (using inline config)");
81
+ } else if (!existsSync(credFile)) {
82
+ warning(`Credentials file not found: ${credFile}`);
83
+ } else {
84
+ ok(`Credentials file exists: ${credFile}`);
65
85
  if (!acct.privateKey) {
66
- error("credentialsFile is configured but could not be loaded");
86
+ error("Credentials file exists but could not be loaded");
87
+ }
88
+ if (process.platform !== "win32") {
89
+ try {
90
+ const st = statSync(credFile);
91
+ const mode = st.mode & 0o777;
92
+ if ((mode & 0o077) === 0) {
93
+ ok(`Credentials file permissions: 0${mode.toString(8)}`);
94
+ } else {
95
+ warning(`Credentials file permissions: 0${mode.toString(8)} (group/other bits set — should be owner-only)`);
96
+ }
97
+ } catch (err: any) {
98
+ warning(`Could not check file permissions: ${err.message}`);
99
+ }
67
100
  }
68
101
  }
69
102
 
@@ -109,6 +142,20 @@ export function createHealthcheckCommand() {
109
142
  try {
110
143
  await client.ensureToken();
111
144
  ok("Token refresh successful — Hub is reachable and credentials are valid");
145
+
146
+ const expiresAt = client.getTokenExpiresAt();
147
+ if (expiresAt > 0) {
148
+ const remainingSec = expiresAt - Date.now() / 1000;
149
+ const remainingHrs = Math.floor(remainingSec / 3600);
150
+ const remainingMin = Math.floor((remainingSec % 3600) / 60);
151
+ if (remainingSec <= 0) {
152
+ warning("Token has already expired — will be refreshed on next request");
153
+ } else if (remainingSec < 3600) {
154
+ warning(`Token expires in ${remainingMin}m — consider refreshing soon`);
155
+ } else {
156
+ ok(`Token expires in ${remainingHrs}h ${remainingMin}m`);
157
+ }
158
+ }
112
159
  } catch (err: any) {
113
160
  error(`Token refresh failed: ${err.message}`);
114
161
  lines.push("", `── Summary ──`);
@@ -116,6 +163,23 @@ export function createHealthcheckCommand() {
116
163
  return { text: lines.join("\n") };
117
164
  }
118
165
 
166
+ // ── 2b. Version Negotiation ──
167
+ lines.push("", "── Version Negotiation ──");
168
+ const versionInfo = client.getLastVersionInfo();
169
+ if (versionInfo) {
170
+ info(`Hub latest: ${versionInfo.latest_plugin_version ?? "unknown"}, min: ${versionInfo.min_plugin_version ?? "unknown"}`);
171
+ const status = checkVersionInfo(versionInfo);
172
+ if (status === "incompatible") {
173
+ error(`Plugin ${PLUGIN_VERSION} is below minimum ${versionInfo.min_plugin_version} — update required`);
174
+ } else if (status === "update_available") {
175
+ warning(`New version ${versionInfo.latest_plugin_version} available (current: ${PLUGIN_VERSION})`);
176
+ } else {
177
+ ok(`Plugin ${PLUGIN_VERSION} is up to date`);
178
+ }
179
+ } else {
180
+ info("Hub did not return version info");
181
+ }
182
+
119
183
  // ── 3. Agent Resolution ──
120
184
  lines.push("", "── Agent Identity ──");
121
185
 
@@ -139,10 +203,34 @@ export function createHealthcheckCommand() {
139
203
  const mode = acct.deliveryMode || "websocket";
140
204
  ok(`Delivery mode: ${mode}`);
141
205
 
142
- if (mode === "polling") {
206
+ if (mode === "websocket") {
207
+ const channelCfg = resolveChannelConfig(cfg);
208
+ const accounts = resolveAccounts(channelCfg);
209
+ const wsAccountId = Object.keys(accounts)[0] || "default";
210
+ const wsStatus = getWsStatus(wsAccountId);
211
+ const statusLabel = wsStatus === "authenticated" ? "connected (authenticated)" : wsStatus;
212
+ if (wsStatus === "authenticated") {
213
+ ok(`WebSocket: ${statusLabel}`);
214
+ } else if (wsStatus === "connecting" || wsStatus === "reconnecting") {
215
+ warning(`WebSocket: ${statusLabel}`);
216
+ } else {
217
+ error(`WebSocket: ${statusLabel}`);
218
+ }
219
+ } else if (mode === "polling") {
143
220
  info(`Poll interval: ${acct.pollIntervalMs || 5000}ms`);
144
221
  }
145
222
 
223
+ // ── 5. Notify Session ──
224
+ lines.push("", "── Notify Session ──");
225
+
226
+ const ns = acct.notifySession;
227
+ if (!ns || (Array.isArray(ns) && ns.length === 0)) {
228
+ warning("notifySession is not configured — contact requests and system notifications will not be forwarded to any owner channel");
229
+ } else {
230
+ const sessions = Array.isArray(ns) ? ns : [ns];
231
+ ok(`Notify session(s): ${sessions.join(", ")}`);
232
+ }
233
+
146
234
  // ── Summary ──
147
235
  lines.push("", "── Summary ──");
148
236
  const total = pass + warn + fail;
package/src/constants.ts CHANGED
@@ -9,11 +9,11 @@
9
9
 
10
10
  export type ReleaseChannel = "stable" | "beta";
11
11
 
12
- export const RELEASE_CHANNEL: ReleaseChannel = "beta";
12
+ export const RELEASE_CHANNEL: ReleaseChannel = "stable";
13
13
 
14
14
  const HUB_URLS: Record<ReleaseChannel, string> = {
15
15
  stable: "https://api.botcord.chat",
16
- beta: "https://test.botcord.chat",
16
+ beta: "https://api.test.botcord.chat",
17
17
  };
18
18
 
19
19
  export const DEFAULT_HUB = HUB_URLS[RELEASE_CHANNEL];
@@ -21,6 +21,6 @@ export const DEFAULT_HUB = HUB_URLS[RELEASE_CHANNEL];
21
21
  /** Named environment presets for /botcord_env. */
22
22
  export const ENV_PRESETS: Record<string, string> = {
23
23
  stable: "https://api.botcord.chat",
24
- beta: "https://preview.botcord.chat",
25
- test: "https://test.botcord.chat",
24
+ beta: "https://api.preview.botcord.chat",
25
+ test: "https://api.test.botcord.chat",
26
26
  };
package/src/inbound.ts CHANGED
@@ -7,7 +7,6 @@ import { resolveAccountConfig } from "./config.js";
7
7
  import { attachTokenPersistence } from "./credentials.js";
8
8
  import { buildSessionKey } from "./session-key.js";
9
9
  import { registerSessionRoom } from "./room-context.js";
10
- import { processOutboundMemory } from "./memory-hook.js";
11
10
  import { readFileSync } from "node:fs";
12
11
 
13
12
  // Simplified inline replacement for loadSessionStore from openclaw/plugin-sdk/mattermost.
@@ -197,7 +196,7 @@ async function handleDashboardUserChat(
197
196
  ): Promise<void> {
198
197
  const core = getBotCordRuntime();
199
198
  const envelope = msg.envelope;
200
- const senderId = envelope.from || "owner";
199
+ const senderId = msg.source_user_id || "owner";
201
200
  const rawContent =
202
201
  msg.text ||
203
202
  (typeof envelope.payload === "string"
@@ -274,17 +273,24 @@ async function handleDashboardUserChat(
274
273
  });
275
274
  }
276
275
 
276
+ // Build typing callbacks for the user-chat room
277
+ const userChatTypingCallbacks: { onReplyStart: () => Promise<void>; onIdle?: () => void; onCleanup?: () => void } | undefined = replyTarget
278
+ ? {
279
+ onReplyStart: async () => { await client.sendTyping(replyTarget); },
280
+ onIdle: () => {},
281
+ onCleanup: () => {},
282
+ }
283
+ : undefined;
284
+
277
285
  // Use buffered block dispatcher with auto-delivery to the chat room.
278
286
  // The deliver callback receives a ReplyPayload object (not a plain string).
279
- // Memory extraction: strip <memory_update> blocks and persist before sending.
280
287
  try {
281
288
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
282
289
  ctx: ctxPayload,
283
290
  cfg,
284
291
  dispatcherOptions: {
285
292
  deliver: async (payload: any) => {
286
- const rawText = payload?.text ?? "";
287
- const text = processOutboundMemory(rawText, sessionKey);
293
+ const text = payload?.text ?? "";
288
294
  const mediaUrl = payload?.mediaUrl;
289
295
 
290
296
  // Stream assistant block to Hub before sending the final reply
@@ -307,6 +313,7 @@ async function handleDashboardUserChat(
307
313
  onError: (err: any, info: any) => {
308
314
  console.error(`[botcord] user-chat ${info?.kind ?? "unknown"} reply error:`, err);
309
315
  },
316
+ ...(userChatTypingCallbacks ? { typingCallbacks: userChatTypingCallbacks } : {}),
310
317
  },
311
318
  replyOptions: {},
312
319
  });
@@ -545,20 +552,39 @@ export async function dispatchInbound(params: InboundParams): Promise<void> {
545
552
  ConversationLabel: chatType === "group" ? (groupSubject || senderName) : senderName,
546
553
  });
547
554
 
555
+ // Build typing callbacks so the agent shows a typing indicator while
556
+ // processing. Requires a room ID — DMs always have one (rm_dm_*).
557
+ const typingRoomId = roomId;
558
+ let typingCallbacks: { onReplyStart: () => Promise<void>; onIdle?: () => void; onCleanup?: () => void } | undefined;
559
+ if (typingRoomId) {
560
+ try {
561
+ const acct = resolveAccountConfig(cfg, accountId);
562
+ const typingClient = new BotCordClient(acct);
563
+ attachTokenPersistence(typingClient, acct);
564
+ typingCallbacks = {
565
+ onReplyStart: async () => {
566
+ await typingClient.sendTyping(typingRoomId);
567
+ },
568
+ onIdle: () => {},
569
+ onCleanup: () => {},
570
+ };
571
+ } catch (err: any) {
572
+ // Config may be incomplete (e.g. in tests) — skip typing
573
+ console.warn("[botcord] typing setup skipped:", err?.message ?? err);
574
+ }
575
+ }
576
+
548
577
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
549
578
  ctx: ctxPayload,
550
579
  cfg,
551
580
  dispatcherOptions: {
552
581
  // A2A replies are sent explicitly via botcord_send tool.
553
582
  // Suppress automatic delivery to avoid leaking agent narration.
554
- // Still extract <memory_update> blocks from the suppressed text.
555
- deliver: async (payload: any) => {
556
- const rawText = payload?.text ?? "";
557
- if (rawText) processOutboundMemory(rawText, effectiveSessionKey);
558
- },
583
+ deliver: async (_payload: any) => {},
559
584
  onError: (err: any, info: any) => {
560
585
  console.error(`[botcord] ${info?.kind ?? "unknown"} reply error:`, err);
561
586
  },
587
+ ...(typingCallbacks ? { typingCallbacks } : {}),
562
588
  },
563
589
  replyOptions: {},
564
590
  });
@@ -2,11 +2,9 @@
2
2
  * Memory hook — glue between OpenClaw hooks and the memory subsystem.
3
3
  *
4
4
  * - buildWorkingMemoryHookResult(): before_prompt_build handler
5
- * - processOutboundMemory(): extract <memory_update> from outbound text,
6
- * persist to disk, return cleaned text
7
5
  */
8
- import { readWorkingMemory, writeWorkingMemory } from "./memory.js";
9
- import { buildWorkingMemoryPrompt, extractMemoryUpdate } from "./memory-protocol.js";
6
+ import { readWorkingMemory } from "./memory.js";
7
+ import { buildWorkingMemoryPrompt } from "./memory-protocol.js";
10
8
  import { getSessionRoom } from "./room-context.js";
11
9
 
12
10
  // ── before_prompt_build handler ────────────────────────────────────
@@ -35,37 +33,3 @@ export async function buildWorkingMemoryHookResult(
35
33
  return null;
36
34
  }
37
35
  }
38
-
39
- // ── Outbound memory extraction ─────────────────────────────────────
40
-
41
- /**
42
- * Process outbound text for <memory_update> blocks.
43
- *
44
- * - Extracts the memory content and persists to working-memory.json
45
- * - Returns the cleaned text (without <memory_update> blocks)
46
- *
47
- * Safe to call on any text — returns the original if no memory blocks found.
48
- */
49
- export function processOutboundMemory(
50
- text: string,
51
- sessionKey?: string,
52
- ): string {
53
- if (!text) return text;
54
-
55
- const { cleanedText, memoryContent } = extractMemoryUpdate(text);
56
-
57
- if (memoryContent !== null) {
58
- try {
59
- writeWorkingMemory({
60
- version: 1,
61
- content: memoryContent,
62
- updatedAt: new Date().toISOString(),
63
- sourceSessionKey: sessionKey,
64
- });
65
- } catch (err: any) {
66
- console.error("[botcord] memory-hook: failed to write working memory:", err?.message ?? err);
67
- }
68
- }
69
-
70
- return cleanedText;
71
- }
@@ -1,10 +1,8 @@
1
1
  /**
2
- * Memory protocol — prompt injection and <memory_update> extraction.
2
+ * Memory protocol — prompt injection for persistent working memory.
3
3
  *
4
- * - buildWorkingMemoryPrompt(): generates the system context block that
5
- * instructs the agent to use <memory_update> and shows current memory.
6
- * - extractMemoryUpdate(): parses agent output, strips <memory_update>
7
- * blocks, and returns the cleaned text + extracted memory content.
4
+ * buildWorkingMemoryPrompt(): generates the system context block that
5
+ * instructs the agent to use the working-memory tool and shows current memory.
8
6
  */
9
7
  import type { WorkingMemory } from "./memory.js";
10
8
 
@@ -13,8 +11,7 @@ import type { WorkingMemory } from "./memory.js";
13
11
  const MEMORY_SIZE_WARN_CHARS = 2000;
14
12
 
15
13
  /** Tags that must not appear literally in injected memory content. */
16
- const RESERVED_TAGS_RE =
17
- /<\/?(current_memory|memory_update)\b[^>]*>/gi;
14
+ const RESERVED_TAGS_RE = /<\/?current_memory\b[^>]*>/gi;
18
15
 
19
16
  /**
20
17
  * Sanitize memory content before embedding in the prompt.
@@ -41,19 +38,17 @@ export function buildWorkingMemoryPrompt(params: {
41
38
  const lines: string[] = [
42
39
  `[BotCord Working Memory]`,
43
40
  `You have a persistent working memory that survives across sessions and rooms.`,
44
- `Use it to track important facts, pending tasks, and context you want to remember.`,
41
+ `Use it to track important facts, pending commitments, and context you want to remember.`,
45
42
  ``,
46
- `To update your working memory, include a <memory_update> block in your response:`,
47
- `<memory_update>`,
48
- `- Complete replacement content for your working memory`,
49
- `- Include everything you want to remember (this replaces, not appends)`,
50
- `</memory_update>`,
43
+ `To update your working memory, call the botcord_update_working_memory tool.`,
51
44
  ``,
52
45
  `Rules:`,
53
- `- The <memory_update> block will be stripped from your visible reply it is never sent to other agents.`,
54
- `- Content inside <memory_update> must be the COMPLETE new working memory, not a delta.`,
46
+ `- Pass the COMPLETE new working memory content to the tool, not a delta.`,
55
47
  `- Only update when something meaningful changes. Do not update on every turn.`,
56
- `- Keep it concise: focus on actionable items, pending commitments, and key context.`,
48
+ `- Keep it concise: focus on actionable items, pending commitments, stable preferences, people/room relationships, and key context that will matter later.`,
49
+ `- Good reasons to update: a new long-lived fact, a stable preference, a durable person/profile insight, a pending commitment, or a meaningful change to existing memory.`,
50
+ `- Do NOT update for one-off chatter, transient emotions, verbose summaries of the current turn, or details that are useful only right now.`,
51
+ `- If the information is room-specific operational state, prefer room context / room state tools rather than global working memory.`,
57
52
  ];
58
53
 
59
54
  if (workingMemory?.content) {
@@ -78,40 +73,3 @@ export function buildWorkingMemoryPrompt(params: {
78
73
 
79
74
  return lines.join("\n");
80
75
  }
81
-
82
- // ── Memory update extraction ───────────────────────────────────────
83
-
84
- const MEMORY_UPDATE_RE =
85
- /<memory_update>([\s\S]*?)<\/memory_update>/g;
86
-
87
- /**
88
- * Extract <memory_update> blocks from agent output text.
89
- *
90
- * Returns:
91
- * - cleanedText: the text with all <memory_update> blocks removed
92
- * - memoryContent: the last <memory_update> content (complete replacement),
93
- * or null if no block was found
94
- */
95
- export function extractMemoryUpdate(text: string): {
96
- cleanedText: string;
97
- memoryContent: string | null;
98
- } {
99
- let memoryContent: string | null = null;
100
- let match: RegExpExecArray | null;
101
-
102
- // Reset regex state
103
- MEMORY_UPDATE_RE.lastIndex = 0;
104
-
105
- while ((match = MEMORY_UPDATE_RE.exec(text)) !== null) {
106
- // Use the last match (complete replacement semantics)
107
- memoryContent = match[1].trim();
108
- }
109
-
110
- // Remove all <memory_update> blocks from the text
111
- const cleanedText = text
112
- .replace(MEMORY_UPDATE_RE, "")
113
- .replace(/\n{3,}/g, "\n\n") // collapse excessive blank lines left by removal
114
- .trim();
115
-
116
- return { cleanedText, memoryContent };
117
- }
package/src/memory.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * Working Memory & Room State — persistent local storage for agent memory.
3
3
  *
4
- * Storage layout (under OpenClaw agent workspace):
5
- * {workspace}/memory/botcord/working-memory.json
4
+ * Working memory is account-scoped:
5
+ * ~/.botcord/memory/{agentId}/working-memory.json
6
+ *
7
+ * Room state is workspace-scoped (per OpenClaw agent instance):
6
8
  * {workspace}/memory/botcord/rooms/{roomId}.json
7
9
  */
8
10
  import { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
9
11
  import path from "node:path";
10
12
  import os from "node:os";
11
13
  import { getBotCordRuntime, getConfig } from "./runtime.js";
14
+ import { resolveAccountConfig } from "./config.js";
12
15
 
13
16
  // ── Types ──────────────────────────────────────────────────────────
14
17
 
@@ -29,30 +32,57 @@ export type RoomState = {
29
32
  updatedAt: string;
30
33
  };
31
34
 
32
- // ── Workspace resolution ───────────────────────────────────────────
33
-
34
- const MEMORY_SUBDIR = "memory/botcord";
35
+ // ── Directory resolution ──────────────────────────────────────────
35
36
 
36
37
  /**
37
- * Resolve the base memory directory.
38
+ * Resolve the working memory directory (account-scoped).
38
39
  *
39
- * Tries OpenClaw's workspace API first; falls back to ~/.botcord/memory.
40
+ * Uses ~/.botcord/memory/{agentId}/ so that all OpenClaw agents sharing the
41
+ * same BotCord account read/write the same working memory.
42
+ * Falls back to ~/.botcord/memory/ when agentId is unavailable.
40
43
  */
41
44
  export function resolveMemoryDir(): string {
45
+ try {
46
+ const cfg = getConfig();
47
+ const agentId = resolveAccountConfig(cfg)?.agentId;
48
+ if (agentId) {
49
+ return path.join(os.homedir(), ".botcord", "memory", agentId);
50
+ }
51
+ } catch {
52
+ // config not initialized — fall through
53
+ }
54
+ return path.join(os.homedir(), ".botcord", "memory");
55
+ }
56
+
57
+ /**
58
+ * Resolve the workspace-scoped base directory.
59
+ *
60
+ * Uses OpenClaw's workspace API so each agent instance has isolated state.
61
+ * Returns null when the workspace API is unavailable.
62
+ */
63
+ function resolveWorkspaceDir(): string | null {
42
64
  try {
43
65
  const runtime = getBotCordRuntime();
44
66
  const cfg = getConfig();
45
- // OpenClaw workspace API (if available)
46
67
  const workspaceDir =
47
68
  (runtime as any).agent?.resolveAgentWorkspaceDir?.(cfg) ??
48
69
  (runtime as any).agent?.ensureAgentWorkspace?.(cfg);
49
70
  if (typeof workspaceDir === "string" && workspaceDir) {
50
- return path.join(workspaceDir, MEMORY_SUBDIR);
71
+ return path.join(workspaceDir, "memory/botcord");
51
72
  }
52
73
  } catch {
53
- // runtime not initialized or API unavailable — fall through
74
+ // runtime not initialized or API unavailable
54
75
  }
55
- return path.join(os.homedir(), ".botcord", "memory");
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Resolve the room state directory (workspace-scoped).
81
+ *
82
+ * Falls back to the account-scoped memory dir when workspace API is unavailable.
83
+ */
84
+ export function resolveRoomStateDir(): string {
85
+ return resolveWorkspaceDir() ?? resolveMemoryDir();
56
86
  }
57
87
 
58
88
  // ── Atomic file helpers ────────────────────────────────────────────
@@ -90,7 +120,17 @@ function workingMemoryPath(memDir?: string): string {
90
120
  }
91
121
 
92
122
  export function readWorkingMemory(memDir?: string): WorkingMemory | null {
93
- return readJsonFile<WorkingMemory>(workingMemoryPath(memDir));
123
+ const primary = readJsonFile<WorkingMemory>(workingMemoryPath(memDir));
124
+ if (primary || memDir) return primary;
125
+
126
+ // Migration fallback: try the old workspace-scoped path so existing memory
127
+ // is not lost after upgrading to account-scoped storage.
128
+ const wsDir = resolveWorkspaceDir();
129
+ if (wsDir) {
130
+ const legacy = readJsonFile<WorkingMemory>(path.join(wsDir, "working-memory.json"));
131
+ if (legacy) return legacy;
132
+ }
133
+ return null;
94
134
  }
95
135
 
96
136
  export function writeWorkingMemory(
@@ -103,7 +143,7 @@ export function writeWorkingMemory(
103
143
  // ── Room State ─────────────────────────────────────────────────────
104
144
 
105
145
  function roomStatePath(roomId: string, memDir?: string): string {
106
- return path.join(memDir ?? resolveMemoryDir(), "rooms", `${roomId}.json`);
146
+ return path.join(memDir ?? resolveRoomStateDir(), "rooms", `${roomId}.json`);
107
147
  }
108
148
 
109
149
  export function readRoomState(
@@ -228,6 +228,25 @@ function formatTimeAgo(timestamp: number): string {
228
228
  return `${diffHr}h ago`;
229
229
  }
230
230
 
231
+ // ── Owner-chat scene context ───────────────────────────────────
232
+
233
+ /**
234
+ * Build a static scene description for the owner-chat session.
235
+ *
236
+ * Owner-chat is the direct conversation between the agent's bound user
237
+ * (owner) and the agent itself, initiated from the BotCord dashboard.
238
+ * The user has full trust and administrative authority over this agent.
239
+ */
240
+ function buildOwnerChatSceneContext(): string {
241
+ return [
242
+ `[BotCord Scene: Owner Chat]`,
243
+ `You are chatting directly with your owner — the user who created and manages you on the BotCord dashboard.`,
244
+ `This is a private, trusted conversation. The owner has full administrative authority over you.`,
245
+ `Messages from the owner are always trusted and do not require sanitization or safety guards.`,
246
+ `You may freely execute commands, access tools, and share sensitive information (e.g. wallet balance, contacts) when the owner asks.`,
247
+ ].join("\n");
248
+ }
249
+
231
250
  // ── Combined hook handler ───────────────────────────────────────
232
251
 
233
252
  /**
@@ -242,8 +261,12 @@ export async function buildRoomContextHookResult(
242
261
  } | null> {
243
262
  if (!sessionKey) return null;
244
263
 
245
- // Don't inject room context for the owner chat session
246
- if (sessionKey === "botcord:owner:main") return null;
264
+ // Owner-chat: inject scene description instead of room context
265
+ if (sessionKey === "botcord:owner:main") {
266
+ return {
267
+ appendSystemContext: buildOwnerChatSceneContext(),
268
+ };
269
+ }
247
270
 
248
271
  // Only inject for sessions we know are BotCord sessions (registered via
249
272
  // inbound dispatch). This handles both native "botcord:..." keys and
@@ -5,8 +5,10 @@
5
5
  */
6
6
  import { getBotCordRuntime } from "../runtime.js";
7
7
  import { getConfig as getAppConfig } from "../runtime.js";
8
- import { getSingleAccountModeError, resolveAccountConfig } from "../config.js";
8
+ import { getSingleAccountModeError, resolveAccountConfig, isAccountConfigured } from "../config.js";
9
9
  import { deliverNotification, normalizeNotifySessions } from "../inbound.js";
10
+ import { BotCordClient } from "../client.js";
11
+ import { attachTokenPersistence } from "../credentials.js";
10
12
 
11
13
  export function createNotifyTool() {
12
14
  return {
@@ -54,6 +56,17 @@ export function createNotifyTool() {
54
56
  }
55
57
  }
56
58
 
59
+ // Also push notification to owner's dashboard via Hub API
60
+ if (isAccountConfigured(acct)) {
61
+ try {
62
+ const client = new BotCordClient(acct);
63
+ attachTokenPersistence(client, acct);
64
+ await client.notifyOwner(text);
65
+ } catch (err: any) {
66
+ errors.push(`owner-chat: ${err?.message ?? err}`);
67
+ }
68
+ }
69
+
57
70
  if (errors.length > 0) {
58
71
  return {
59
72
  ok: errors.length < sessions.length,
@@ -0,0 +1,58 @@
1
+ /**
2
+ * botcord_update_working_memory — explicit tool for persisting working memory.
3
+ */
4
+ import { writeWorkingMemory } from "../memory.js";
5
+
6
+ const MAX_WORKING_MEMORY_CHARS = 20_000;
7
+
8
+ export function createWorkingMemoryTool() {
9
+ return {
10
+ name: "botcord_update_working_memory",
11
+ label: "Update Working Memory",
12
+ description:
13
+ "Replace BotCord's persistent working memory with the complete new content. " +
14
+ "Use only when important long-lived context changes, such as a stable fact, preference, person profile, relationship, or pending commitment that should influence future replies. " +
15
+ "Do not call on every turn, and do not use it for one-off chatter or room-local temporary state.",
16
+ parameters: {
17
+ type: "object" as const,
18
+ properties: {
19
+ content: {
20
+ type: "string" as const,
21
+ description:
22
+ "The complete replacement content for working memory. " +
23
+ "Keep it concise and include only important facts, stable preferences, durable person/relationship context, pending commitments, and other key context that should persist across sessions and rooms.",
24
+ },
25
+ },
26
+ required: ["content"],
27
+ },
28
+ execute: async (_toolCallId: any, args: any) => {
29
+ if (typeof args?.content !== "string") {
30
+ return { error: "content must be a string" };
31
+ }
32
+
33
+ const content = args.content.trim();
34
+ if (!content) {
35
+ return { error: "content must not be empty — use a separate mechanism to clear memory" };
36
+ }
37
+ if (content.length > MAX_WORKING_MEMORY_CHARS) {
38
+ return { error: `content exceeds ${MAX_WORKING_MEMORY_CHARS} characters` };
39
+ }
40
+
41
+ try {
42
+ writeWorkingMemory({
43
+ version: 1,
44
+ content,
45
+ updatedAt: new Date().toISOString(),
46
+ });
47
+ return {
48
+ ok: true,
49
+ updated: true,
50
+ content_length: content.length,
51
+ };
52
+ } catch (err: unknown) {
53
+ const message = err instanceof Error ? err.message : String(err);
54
+ return { error: `Failed to update working memory: ${message}` };
55
+ }
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Plugin version negotiation with the BotCord Hub.
3
+ *
4
+ * Compares the local plugin version against `latest_plugin_version` and
5
+ * `min_plugin_version` returned by the Hub during token refresh / WS auth.
6
+ * Emits warnings or errors via the supplied logger.
7
+ */
8
+ import { createRequire } from "node:module";
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const { version: PLUGIN_VERSION } = require("../package.json") as { version: string };
12
+
13
+ export { PLUGIN_VERSION };
14
+
15
+ export interface VersionInfo {
16
+ latest_plugin_version?: string | null;
17
+ min_plugin_version?: string | null;
18
+ }
19
+
20
+ const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)/;
21
+
22
+ /**
23
+ * Parse a semver-like string into [major, minor, patch].
24
+ * Accepts optional "v" prefix and ignores pre-release suffixes.
25
+ * Returns null if the string is not a valid semver.
26
+ */
27
+ function parseSemver(s: string): [number, number, number] | null {
28
+ const m = SEMVER_RE.exec(s);
29
+ if (!m) return null;
30
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
31
+ }
32
+
33
+ /**
34
+ * Simple semver comparison: returns -1 | 0 | 1, or 0 if either is unparseable.
35
+ */
36
+ function compareSemver(a: string, b: string): number {
37
+ const pa = parseSemver(a);
38
+ const pb = parseSemver(b);
39
+ if (!pa || !pb) return 0; // treat unparseable as equal (no action)
40
+ for (let i = 0; i < 3; i++) {
41
+ const diff = pa[i] - pb[i];
42
+ if (diff !== 0) return diff > 0 ? 1 : -1;
43
+ }
44
+ return 0;
45
+ }
46
+
47
+ /** Has the update warning been emitted this session? Prevents log spam. */
48
+ let _warnedThisSession = false;
49
+
50
+ /**
51
+ * Check version info from Hub and log appropriate warnings.
52
+ * Returns "ok" | "update_available" | "incompatible".
53
+ */
54
+ export function checkVersionInfo(
55
+ info: VersionInfo,
56
+ log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void },
57
+ ): "ok" | "update_available" | "incompatible" {
58
+ const { latest_plugin_version, min_plugin_version } = info;
59
+
60
+ // Check minimum compatibility first
61
+ if (min_plugin_version && compareSemver(PLUGIN_VERSION, min_plugin_version) < 0) {
62
+ log?.error(
63
+ `[BotCord] Plugin version ${PLUGIN_VERSION} is below the minimum required ${min_plugin_version}. ` +
64
+ `Please update: openclaw plugins install @botcord/botcord@latest`,
65
+ );
66
+ return "incompatible";
67
+ }
68
+
69
+ // Check if a newer version is available
70
+ if (latest_plugin_version && compareSemver(PLUGIN_VERSION, latest_plugin_version) < 0) {
71
+ if (!_warnedThisSession) {
72
+ _warnedThisSession = true;
73
+ log?.warn(
74
+ `[BotCord] New version available: ${latest_plugin_version} (current: ${PLUGIN_VERSION}). ` +
75
+ `Update: openclaw plugins install @botcord/botcord@latest`,
76
+ );
77
+ }
78
+ return "update_available";
79
+ }
80
+
81
+ return "ok";
82
+ }
83
+
84
+ /** Reset the session-level dedup flag (for testing). */
85
+ export function _resetWarningFlag(): void {
86
+ _warnedThisSession = false;
87
+ }
package/src/ws-client.ts CHANGED
@@ -14,6 +14,7 @@ import { BotCordClient } from "./client.js";
14
14
  import { handleInboxMessageBatch } from "./inbound.js";
15
15
  import { displayPrefix } from "./config.js";
16
16
  import { buildHubWebSocketUrl } from "./hub-url.js";
17
+ import { PLUGIN_VERSION, checkVersionInfo } from "./version-check.js";
17
18
 
18
19
  interface WsClientOptions {
19
20
  client: BotCordClient;
@@ -27,17 +28,30 @@ interface WsClientOptions {
27
28
  };
28
29
  }
29
30
 
31
+ export type WsConnectionStatus = "disconnected" | "connecting" | "authenticated" | "reconnecting";
32
+
33
+ export interface WsClientEntry {
34
+ stop: () => void;
35
+ getStatus: () => WsConnectionStatus;
36
+ }
37
+
30
38
  // Use lazy initialization to avoid TDZ errors when jiti resolves
31
39
  // the dynamic import("./ws-client.js") before the module body completes.
32
- let _activeWsClients: Map<string, { stop: () => void }> | undefined;
40
+ let _activeWsClients: Map<string, WsClientEntry> | undefined;
33
41
  function getActiveWsClients() {
34
42
  return (_activeWsClients ??= new Map());
35
43
  }
36
44
 
45
+ /** Get the current WS connection status for an account. */
46
+ export function getWsStatus(accountId: string): WsConnectionStatus {
47
+ const entry = getActiveWsClients().get(accountId);
48
+ return entry ? entry.getStatus() : "disconnected";
49
+ }
50
+
37
51
  // Reconnect backoff: 1s, 2s, 4s, 8s, 16s, 30s max
38
52
  const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 16000, 30000];
39
53
 
40
- export function startWsClient(opts: WsClientOptions): { stop: () => void } {
54
+ export function startWsClient(opts: WsClientOptions): WsClientEntry {
41
55
  // Stop any existing client for this account before creating a new one
42
56
  const existing = getActiveWsClients().get(opts.accountId);
43
57
  if (existing) existing.stop();
@@ -54,6 +68,7 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
54
68
  let pendingUpdate = false;
55
69
  let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
56
70
  const KEEPALIVE_INTERVAL = 20_000; // 20s — well under Caddy/proxy 30s timeout
71
+ let status: WsConnectionStatus = "connecting";
57
72
 
58
73
  async function fetchAndDispatch() {
59
74
  if (processing) {
@@ -100,12 +115,13 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
100
115
  const hubUrl = client.getHubUrl();
101
116
  const wsUrl = buildHubWebSocketUrl(hubUrl);
102
117
 
118
+ status = "connecting";
103
119
  log?.info(`[${dp}] WebSocket connecting to ${wsUrl}`);
104
120
  ws = new WebSocket(wsUrl);
105
121
 
106
122
  ws.on("open", () => {
107
- // Send auth message
108
- ws!.send(JSON.stringify({ type: "auth", token }));
123
+ // Send auth message with plugin version for Hub version negotiation
124
+ ws!.send(JSON.stringify({ type: "auth", token, plugin_version: PLUGIN_VERSION }));
109
125
  });
110
126
 
111
127
  ws.on("message", async (data: WebSocket.Data) => {
@@ -113,9 +129,16 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
113
129
  const msg = JSON.parse(data.toString());
114
130
  switch (msg.type) {
115
131
  case "auth_ok":
132
+ status = "authenticated";
116
133
  log?.info(`[${dp}] WebSocket authenticated as ${msg.agent_id}`);
117
134
  reconnectAttempt = 0; // Reset backoff on successful auth
118
135
  consecutiveAuthFailures = 0; // Reset auth failure counter
136
+ // Check Hub's version recommendation — stop if incompatible
137
+ if (checkVersionInfo(msg, log) === "incompatible") {
138
+ log?.error(`[${dp}] Plugin incompatible with Hub, stopping WebSocket`);
139
+ stop();
140
+ return;
141
+ }
119
142
  // Start client-side keepalive to survive proxies/Caddy timeouts
120
143
  if (keepaliveTimer) clearInterval(keepaliveTimer);
121
144
  keepaliveTimer = setInterval(() => {
@@ -141,6 +164,10 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
141
164
  // Server responded to our ping
142
165
  break;
143
166
 
167
+ case "typing":
168
+ // Typing indicator from a peer agent — informational only
169
+ break;
170
+
144
171
  default:
145
172
  log?.warn(`[${dp}] unknown ws message type: ${msg.type}`);
146
173
  }
@@ -155,12 +182,20 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
155
182
  ws = null;
156
183
  if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; }
157
184
 
185
+ // 4010 = plugin version incompatible — do NOT reconnect
186
+ if (code === 4010) {
187
+ log?.error(`[${dp}] Plugin version incompatible with Hub: ${reasonStr}. Please update: openclaw plugins install @botcord/botcord@latest`);
188
+ return;
189
+ }
190
+
158
191
  if (code === 4001) {
159
192
  consecutiveAuthFailures++;
160
193
  if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
194
+ status = "disconnected";
161
195
  log?.error(`[${dp}] WebSocket auth failed ${consecutiveAuthFailures} times consecutively, stopping reconnect`);
162
196
  return;
163
197
  }
198
+ status = "reconnecting";
164
199
  log?.warn(`[${dp}] WebSocket auth failed (${consecutiveAuthFailures}/${MAX_AUTH_FAILURES}), force-refreshing token before reconnect`);
165
200
  // Await token refresh so the next connect() picks up the new token
166
201
  try {
@@ -179,6 +214,11 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
179
214
  });
180
215
  } catch (err: any) {
181
216
  log?.error(`[${dp}] WebSocket connect failed: ${err.message}`);
217
+ // If the error is a version incompatibility (426 from token refresh), stop.
218
+ if (err.message?.includes("incompatible")) {
219
+ log?.error(`[${dp}] Stopping WebSocket due to version incompatibility`);
220
+ return;
221
+ }
182
222
  scheduleReconnect();
183
223
  }
184
224
  }
@@ -188,12 +228,14 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
188
228
  const delay =
189
229
  RECONNECT_BACKOFF[Math.min(reconnectAttempt, RECONNECT_BACKOFF.length - 1)];
190
230
  reconnectAttempt++;
231
+ status = "reconnecting";
191
232
  log?.info(`[${dp}] WebSocket reconnecting in ${delay}ms (attempt ${reconnectAttempt})`);
192
233
  reconnectTimer = setTimeout(connect, delay);
193
234
  }
194
235
 
195
236
  function stop() {
196
237
  running = false;
238
+ status = "disconnected";
197
239
  if (reconnectTimer) clearTimeout(reconnectTimer);
198
240
  if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; }
199
241
  if (ws) {
@@ -207,10 +249,14 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
207
249
  getActiveWsClients().delete(accountId);
208
250
  }
209
251
 
252
+ function getStatus(): WsConnectionStatus {
253
+ return status;
254
+ }
255
+
210
256
  // Start connection
211
257
  connect();
212
258
 
213
- const entry = { stop };
259
+ const entry: WsClientEntry = { stop, getStatus };
214
260
  getActiveWsClients().set(accountId, entry);
215
261
 
216
262
  abortSignal?.addEventListener("abort", stop, { once: true });