@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 +13 -4
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/botcord/SKILL.md +6 -0
- package/src/channel.ts +1 -0
- package/src/client.ts +63 -3
- package/src/commands/healthcheck.ts +93 -5
- package/src/constants.ts +4 -4
- package/src/inbound.ts +36 -10
- package/src/memory-hook.ts +2 -38
- package/src/memory-protocol.ts +11 -53
- package/src/memory.ts +53 -13
- package/src/room-context.ts +25 -2
- package/src/tools/notify.ts +14 -1
- package/src/tools/working-memory.ts +58 -0
- package/src/version-check.ts +87 -0
- package/src/ws-client.ts +51 -5
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
|
-
//
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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++, {
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/skills/botcord/SKILL.md
CHANGED
|
@@ -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: {
|
|
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 {
|
|
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",
|
|
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
|
-
|
|
64
|
-
|
|
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("
|
|
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 === "
|
|
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 = "
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
});
|
package/src/memory-hook.ts
CHANGED
|
@@ -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
|
|
9
|
-
import { buildWorkingMemoryPrompt
|
|
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
|
-
}
|
package/src/memory-protocol.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Memory protocol — prompt injection
|
|
2
|
+
* Memory protocol — prompt injection for persistent working memory.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
41
|
+
`Use it to track important facts, pending commitments, and context you want to remember.`,
|
|
45
42
|
``,
|
|
46
|
-
`To update your working memory,
|
|
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
|
-
`-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
// ──
|
|
33
|
-
|
|
34
|
-
const MEMORY_SUBDIR = "memory/botcord";
|
|
35
|
+
// ── Directory resolution ──────────────────────────────────────────
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
|
-
* Resolve the
|
|
38
|
+
* Resolve the working memory directory (account-scoped).
|
|
38
39
|
*
|
|
39
|
-
*
|
|
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,
|
|
71
|
+
return path.join(workspaceDir, "memory/botcord");
|
|
51
72
|
}
|
|
52
73
|
} catch {
|
|
53
|
-
// runtime not initialized or API unavailable
|
|
74
|
+
// runtime not initialized or API unavailable
|
|
54
75
|
}
|
|
55
|
-
return
|
|
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
|
-
|
|
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 ??
|
|
146
|
+
return path.join(memDir ?? resolveRoomStateDir(), "rooms", `${roomId}.json`);
|
|
107
147
|
}
|
|
108
148
|
|
|
109
149
|
export function readRoomState(
|
package/src/room-context.ts
CHANGED
|
@@ -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
|
-
//
|
|
246
|
-
if (sessionKey === "botcord:owner:main")
|
|
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
|
package/src/tools/notify.ts
CHANGED
|
@@ -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,
|
|
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):
|
|
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 });
|