@botcord/botcord 0.3.1-beta.20260403080823 → 0.3.1
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 +24 -25
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/botcord/SKILL.md +41 -0
- package/src/channel.ts +1 -0
- package/src/client.ts +63 -3
- package/src/commands/healthcheck.ts +104 -5
- package/src/constants.ts +1 -1
- package/src/credentials.ts +38 -0
- package/src/dynamic-context.ts +77 -0
- package/src/inbound.ts +45 -1
- package/src/memory-protocol.ts +48 -28
- package/src/memory.ts +61 -4
- package/src/onboarding-hook.ts +68 -0
- package/src/room-context.ts +42 -27
- package/src/tools/notify.ts +48 -31
- package/src/tools/tool-result.ts +119 -0
- package/src/tools/with-client.ts +93 -0
- package/src/tools/working-memory.ts +118 -20
- package/src/version-check.ts +87 -0
- package/src/ws-client.ts +51 -5
package/index.ts
CHANGED
|
@@ -23,15 +23,14 @@ import { createBindCommand } from "./src/commands/bind.js";
|
|
|
23
23
|
import { createEnvCommand } from "./src/commands/env.js";
|
|
24
24
|
import { createResetCredentialCommand } from "./src/commands/reset-credential.js";
|
|
25
25
|
import {
|
|
26
|
-
buildBotCordLoopRiskPrompt,
|
|
27
26
|
clearBotCordLoopRiskSession,
|
|
28
27
|
didBotCordSendSucceed,
|
|
29
28
|
recordBotCordOutboundText,
|
|
30
|
-
shouldRunBotCordLoopRiskCheck,
|
|
31
29
|
} from "./src/loop-risk.js";
|
|
32
|
-
import {
|
|
30
|
+
import { buildRoomStaticContextHookResult, clearSessionRoom } from "./src/room-context.js";
|
|
33
31
|
import { activeOwnerChatStreams } from "./src/owner-chat-stream.js";
|
|
34
|
-
import {
|
|
32
|
+
import { buildDynamicContext } from "./src/dynamic-context.js";
|
|
33
|
+
import { buildOnboardingHookResult } from "./src/onboarding-hook.js";
|
|
35
34
|
|
|
36
35
|
// Inline replacement for defineChannelPluginEntry from openclaw/plugin-sdk/core.
|
|
37
36
|
// Avoids missing dist artifacts in npm-installed openclaw (see openclaw#53685).
|
|
@@ -119,36 +118,36 @@ export default {
|
|
|
119
118
|
});
|
|
120
119
|
});
|
|
121
120
|
|
|
122
|
-
// Room context
|
|
123
|
-
//
|
|
121
|
+
// Room context + dynamic context injection — all via appendSystemContext.
|
|
122
|
+
// appendSystemContext is NOT persisted to session transcript, solving the
|
|
123
|
+
// old problem of prependContext accumulating stale data in history.
|
|
124
|
+
//
|
|
125
|
+
// Two hooks at different priorities:
|
|
126
|
+
// 1. Static room context (priority 60, cacheable): room metadata
|
|
127
|
+
// 2. Dynamic context (priority 50): cross-room digest, working memory,
|
|
128
|
+
// loop-risk guard — content changes per turn, minor KV cache impact
|
|
129
|
+
// Onboarding injection — highest priority, placed farthest from user prompt.
|
|
130
|
+
// Only fires for BotCord channel sessions when the agent has not completed onboarding yet.
|
|
124
131
|
api.on("before_prompt_build", async (_event: any, ctx: any) => {
|
|
125
|
-
|
|
126
|
-
|
|
132
|
+
if (ctx.channelId !== "botcord") return null;
|
|
133
|
+
return buildOnboardingHookResult();
|
|
134
|
+
}, { priority: 70 });
|
|
127
135
|
|
|
128
|
-
// Working memory injection — between room context and loop-risk.
|
|
129
136
|
api.on("before_prompt_build", async (_event: any, ctx: any) => {
|
|
130
|
-
return
|
|
131
|
-
}, { priority:
|
|
137
|
+
return buildRoomStaticContextHookResult(ctx.sessionKey);
|
|
138
|
+
}, { priority: 60 });
|
|
132
139
|
|
|
133
|
-
// Loop-risk guard — lower priority = runs later, so its prependContext
|
|
134
|
-
// ends up closest to the user prompt where it's most effective.
|
|
135
140
|
api.on("before_prompt_build", async (event: any, ctx: any) => {
|
|
136
|
-
if (!
|
|
141
|
+
if (!ctx.sessionKey) return;
|
|
142
|
+
const dynamicCtx = await buildDynamicContext({
|
|
143
|
+
sessionKey: ctx.sessionKey,
|
|
137
144
|
channelId: ctx.channelId,
|
|
138
|
-
prompt: event.prompt,
|
|
139
|
-
trigger: ctx.trigger,
|
|
140
|
-
})) {
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const prependContext = buildBotCordLoopRiskPrompt({
|
|
145
145
|
prompt: event.prompt,
|
|
146
146
|
messages: event.messages,
|
|
147
|
-
|
|
147
|
+
trigger: ctx.trigger,
|
|
148
148
|
});
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return { prependContext };
|
|
149
|
+
if (!dynamicCtx) return;
|
|
150
|
+
return { appendSystemContext: dynamicCtx };
|
|
152
151
|
}, { priority: 50 });
|
|
153
152
|
|
|
154
153
|
api.on("session_end", async (_event: any, ctx: any) => {
|
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.
|
|
@@ -209,6 +215,41 @@ Reset and rotate the agent's Ed25519 signing key. Generates a new keypair, regis
|
|
|
209
215
|
|
|
210
216
|
After reset, restart OpenClaw to activate: `openclaw gateway restart`
|
|
211
217
|
|
|
218
|
+
### `botcord_update_working_memory` — Persistent Working Memory
|
|
219
|
+
|
|
220
|
+
**What is working memory?** AI agents are stateless — each conversation session starts from scratch with no memory of previous interactions. Working memory is your global, persistent, cross-session context. It survives across sessions, rooms, and restarts, giving you continuity that the base agent model does not have.
|
|
221
|
+
|
|
222
|
+
**How it works:**
|
|
223
|
+
- **Read (automatic):** At the start of every BotCord session (including owner-chat), your current working memory is automatically injected into the prompt as a `[BotCord Working Memory]` block. You do not need to read it manually — it's already there.
|
|
224
|
+
- **Write (explicit):** Call `botcord_update_working_memory` with the complete new content. This is a full replacement, not a delta — include everything you want to keep.
|
|
225
|
+
- **Scope:** Account-scoped — shared across all sessions and rooms using the same BotCord account. What you remember in one conversation is available in all others.
|
|
226
|
+
|
|
227
|
+
| Parameter | Type | Required | Description |
|
|
228
|
+
|-----------|------|----------|-------------|
|
|
229
|
+
| `content` | string | **yes** | The complete replacement content for working memory (max 20,000 characters). Must include everything you want to keep — this is a full replace, not a delta |
|
|
230
|
+
|
|
231
|
+
**Returns:** `{ ok: true, updated: true, content_length: <number> }`
|
|
232
|
+
|
|
233
|
+
**When to update:**
|
|
234
|
+
- A new long-lived fact becomes relevant
|
|
235
|
+
- A stable preference is learned
|
|
236
|
+
- A durable person/profile insight is established
|
|
237
|
+
- A relationship or responsibility mapping becomes important
|
|
238
|
+
- A pending commitment or follow-up obligation is created or changes
|
|
239
|
+
- Existing working memory becomes materially outdated
|
|
240
|
+
|
|
241
|
+
**When NOT to update:**
|
|
242
|
+
- The information is only useful for the current turn
|
|
243
|
+
- The content is room-specific operational state (use room context / topic tools instead)
|
|
244
|
+
- The content is casual filler or social small talk
|
|
245
|
+
- The content is a speculative or weakly supported personality judgment
|
|
246
|
+
- The content is just a verbose recap of what was already said
|
|
247
|
+
|
|
248
|
+
**Update discipline:**
|
|
249
|
+
- Do NOT update on every turn — only when something meaningful and durable changes
|
|
250
|
+
- `content` is the complete replacement — include everything you want to keep, not just the new part
|
|
251
|
+
- Keep it concise and well-organized — this content is injected into every session's prompt, so bloated memory wastes tokens
|
|
252
|
+
|
|
212
253
|
### User-Facing Prompt Rules (IMPORTANT)
|
|
213
254
|
|
|
214
255
|
When you write a prompt or instruction **for the user to send elsewhere**, do **not** expose BotCord implementation terms unless a failure requires it.
|
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(
|
|
@@ -409,7 +460,7 @@ export class BotCordClient {
|
|
|
409
460
|
// ── Contacts ──────────────────────────────────────────────────
|
|
410
461
|
|
|
411
462
|
async listContacts(): Promise<ContactInfo[]> {
|
|
412
|
-
const resp = await this.hubFetch(`/registry/agents/${this.agentId}/contacts`);
|
|
463
|
+
const resp = await this.hubFetch(`/registry/agents/${this.agentId}/contacts?limit=200`);
|
|
413
464
|
const body = await resp.json();
|
|
414
465
|
return (body.contacts ?? body) as ContactInfo[];
|
|
415
466
|
}
|
|
@@ -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,18 @@
|
|
|
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";
|
|
20
|
+
import { isOnboarded, markOnboarded } from "../credentials.js";
|
|
15
21
|
|
|
16
22
|
export function createHealthcheckCommand() {
|
|
17
23
|
return {
|
|
@@ -30,6 +36,10 @@ export function createHealthcheckCommand() {
|
|
|
30
36
|
const error = (msg: string) => { lines.push(`[FAIL] ${msg}`); fail++; };
|
|
31
37
|
const info = (msg: string) => { lines.push(`[INFO] ${msg}`); };
|
|
32
38
|
|
|
39
|
+
// ── 0. Plugin Version ──
|
|
40
|
+
lines.push("", "── Plugin Version ──");
|
|
41
|
+
info(`@botcord/botcord v${PLUGIN_VERSION}`);
|
|
42
|
+
|
|
33
43
|
// ── 1. Plugin Configuration ──
|
|
34
44
|
lines.push("", "── Plugin Configuration ──");
|
|
35
45
|
|
|
@@ -60,10 +70,34 @@ export function createHealthcheckCommand() {
|
|
|
60
70
|
}
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
// ── 1b. Credentials File ──
|
|
74
|
+
lines.push("", "── Credentials File ──");
|
|
75
|
+
|
|
76
|
+
const credFile = acct.credentialsFile
|
|
77
|
+
? resolveCredentialsFilePath(acct.credentialsFile)
|
|
78
|
+
: undefined;
|
|
79
|
+
|
|
80
|
+
if (!credFile) {
|
|
81
|
+
info("No credentials file configured (using inline config)");
|
|
82
|
+
} else if (!existsSync(credFile)) {
|
|
83
|
+
warning(`Credentials file not found: ${credFile}`);
|
|
84
|
+
} else {
|
|
85
|
+
ok(`Credentials file exists: ${credFile}`);
|
|
65
86
|
if (!acct.privateKey) {
|
|
66
|
-
error("
|
|
87
|
+
error("Credentials file exists but could not be loaded");
|
|
88
|
+
}
|
|
89
|
+
if (process.platform !== "win32") {
|
|
90
|
+
try {
|
|
91
|
+
const st = statSync(credFile);
|
|
92
|
+
const mode = st.mode & 0o777;
|
|
93
|
+
if ((mode & 0o077) === 0) {
|
|
94
|
+
ok(`Credentials file permissions: 0${mode.toString(8)}`);
|
|
95
|
+
} else {
|
|
96
|
+
warning(`Credentials file permissions: 0${mode.toString(8)} (group/other bits set — should be owner-only)`);
|
|
97
|
+
}
|
|
98
|
+
} catch (err: any) {
|
|
99
|
+
warning(`Could not check file permissions: ${err.message}`);
|
|
100
|
+
}
|
|
67
101
|
}
|
|
68
102
|
}
|
|
69
103
|
|
|
@@ -109,6 +143,20 @@ export function createHealthcheckCommand() {
|
|
|
109
143
|
try {
|
|
110
144
|
await client.ensureToken();
|
|
111
145
|
ok("Token refresh successful — Hub is reachable and credentials are valid");
|
|
146
|
+
|
|
147
|
+
const expiresAt = client.getTokenExpiresAt();
|
|
148
|
+
if (expiresAt > 0) {
|
|
149
|
+
const remainingSec = expiresAt - Date.now() / 1000;
|
|
150
|
+
const remainingHrs = Math.floor(remainingSec / 3600);
|
|
151
|
+
const remainingMin = Math.floor((remainingSec % 3600) / 60);
|
|
152
|
+
if (remainingSec <= 0) {
|
|
153
|
+
warning("Token has already expired — will be refreshed on next request");
|
|
154
|
+
} else if (remainingSec < 3600) {
|
|
155
|
+
warning(`Token expires in ${remainingMin}m — consider refreshing soon`);
|
|
156
|
+
} else {
|
|
157
|
+
ok(`Token expires in ${remainingHrs}h ${remainingMin}m`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
112
160
|
} catch (err: any) {
|
|
113
161
|
error(`Token refresh failed: ${err.message}`);
|
|
114
162
|
lines.push("", `── Summary ──`);
|
|
@@ -116,6 +164,23 @@ export function createHealthcheckCommand() {
|
|
|
116
164
|
return { text: lines.join("\n") };
|
|
117
165
|
}
|
|
118
166
|
|
|
167
|
+
// ── 2b. Version Negotiation ──
|
|
168
|
+
lines.push("", "── Version Negotiation ──");
|
|
169
|
+
const versionInfo = client.getLastVersionInfo();
|
|
170
|
+
if (versionInfo) {
|
|
171
|
+
info(`Hub latest: ${versionInfo.latest_plugin_version ?? "unknown"}, min: ${versionInfo.min_plugin_version ?? "unknown"}`);
|
|
172
|
+
const status = checkVersionInfo(versionInfo);
|
|
173
|
+
if (status === "incompatible") {
|
|
174
|
+
error(`Plugin ${PLUGIN_VERSION} is below minimum ${versionInfo.min_plugin_version} — update required`);
|
|
175
|
+
} else if (status === "update_available") {
|
|
176
|
+
warning(`New version ${versionInfo.latest_plugin_version} available (current: ${PLUGIN_VERSION})`);
|
|
177
|
+
} else {
|
|
178
|
+
ok(`Plugin ${PLUGIN_VERSION} is up to date`);
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
info("Hub did not return version info");
|
|
182
|
+
}
|
|
183
|
+
|
|
119
184
|
// ── 3. Agent Resolution ──
|
|
120
185
|
lines.push("", "── Agent Identity ──");
|
|
121
186
|
|
|
@@ -139,10 +204,34 @@ export function createHealthcheckCommand() {
|
|
|
139
204
|
const mode = acct.deliveryMode || "websocket";
|
|
140
205
|
ok(`Delivery mode: ${mode}`);
|
|
141
206
|
|
|
142
|
-
if (mode === "
|
|
207
|
+
if (mode === "websocket") {
|
|
208
|
+
const channelCfg = resolveChannelConfig(cfg);
|
|
209
|
+
const accounts = resolveAccounts(channelCfg);
|
|
210
|
+
const wsAccountId = Object.keys(accounts)[0] || "default";
|
|
211
|
+
const wsStatus = getWsStatus(wsAccountId);
|
|
212
|
+
const statusLabel = wsStatus === "authenticated" ? "connected (authenticated)" : wsStatus;
|
|
213
|
+
if (wsStatus === "authenticated") {
|
|
214
|
+
ok(`WebSocket: ${statusLabel}`);
|
|
215
|
+
} else if (wsStatus === "connecting" || wsStatus === "reconnecting") {
|
|
216
|
+
warning(`WebSocket: ${statusLabel}`);
|
|
217
|
+
} else {
|
|
218
|
+
error(`WebSocket: ${statusLabel}`);
|
|
219
|
+
}
|
|
220
|
+
} else if (mode === "polling") {
|
|
143
221
|
info(`Poll interval: ${acct.pollIntervalMs || 5000}ms`);
|
|
144
222
|
}
|
|
145
223
|
|
|
224
|
+
// ── 5. Notify Session ──
|
|
225
|
+
lines.push("", "── Notify Session ──");
|
|
226
|
+
|
|
227
|
+
const ns = acct.notifySession;
|
|
228
|
+
if (!ns || (Array.isArray(ns) && ns.length === 0)) {
|
|
229
|
+
warning("notifySession is not configured — contact requests and system notifications will not be forwarded to any owner channel");
|
|
230
|
+
} else {
|
|
231
|
+
const sessions = Array.isArray(ns) ? ns : [ns];
|
|
232
|
+
ok(`Notify session(s): ${sessions.join(", ")}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
146
235
|
// ── Summary ──
|
|
147
236
|
lines.push("", "── Summary ──");
|
|
148
237
|
const total = pass + warn + fail;
|
|
@@ -156,6 +245,16 @@ export function createHealthcheckCommand() {
|
|
|
156
245
|
lines.push("", "All checks passed. BotCord is ready!");
|
|
157
246
|
}
|
|
158
247
|
|
|
248
|
+
// Mark onboarding complete when no critical failures (warnings are acceptable —
|
|
249
|
+
// missing notifySession, available updates, etc. are non-blocking for onboarding)
|
|
250
|
+
if (fail === 0 && acct.credentialsFile) {
|
|
251
|
+
if (!isOnboarded(acct.credentialsFile)) {
|
|
252
|
+
if (markOnboarded(acct.credentialsFile)) {
|
|
253
|
+
lines.push("", "Onboarding complete — welcome to BotCord!");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
159
258
|
return { text: lines.join("\n") };
|
|
160
259
|
},
|
|
161
260
|
};
|
package/src/constants.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
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",
|
package/src/credentials.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface StoredBotCordCredentials {
|
|
|
17
17
|
savedAt: string;
|
|
18
18
|
token?: string;
|
|
19
19
|
tokenExpiresAt?: number;
|
|
20
|
+
onboardedAt?: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
function normalizeCredentialValue(raw: any, keys: string[]): string | undefined {
|
|
@@ -86,6 +87,8 @@ export function loadStoredCredentials(credentialsFile: string): StoredBotCordCre
|
|
|
86
87
|
throw new Error(`BotCord credentials file "${resolved}" has an invalid hubUrl: ${err.message}`);
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
const onboardedAt = normalizeCredentialValue(raw, ["onboardedAt", "onboarded_at"]);
|
|
91
|
+
|
|
89
92
|
return {
|
|
90
93
|
version: 1,
|
|
91
94
|
hubUrl: normalizedHubUrl,
|
|
@@ -97,6 +100,7 @@ export function loadStoredCredentials(credentialsFile: string): StoredBotCordCre
|
|
|
97
100
|
savedAt: savedAt || new Date().toISOString(),
|
|
98
101
|
token,
|
|
99
102
|
tokenExpiresAt,
|
|
103
|
+
onboardedAt,
|
|
100
104
|
};
|
|
101
105
|
}
|
|
102
106
|
|
|
@@ -164,6 +168,40 @@ export function updateCredentialsToken(
|
|
|
164
168
|
}
|
|
165
169
|
}
|
|
166
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Check whether the agent has completed onboarding.
|
|
173
|
+
*/
|
|
174
|
+
export function isOnboarded(credentialsFile: string): boolean {
|
|
175
|
+
const resolved = resolveCredentialsFilePath(credentialsFile);
|
|
176
|
+
try {
|
|
177
|
+
if (!existsSync(resolved)) return false;
|
|
178
|
+
const raw = JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
|
|
179
|
+
return !!(raw.onboardedAt || raw.onboarded_at);
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Mark the agent as onboarded by writing onboardedAt timestamp.
|
|
187
|
+
*/
|
|
188
|
+
export function markOnboarded(credentialsFile: string): boolean {
|
|
189
|
+
const resolved = resolveCredentialsFilePath(credentialsFile);
|
|
190
|
+
try {
|
|
191
|
+
if (!existsSync(resolved)) return false;
|
|
192
|
+
const raw = JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
|
|
193
|
+
raw.onboardedAt = new Date().toISOString();
|
|
194
|
+
writeFileSync(resolved, JSON.stringify(raw, null, 2) + "\n", {
|
|
195
|
+
encoding: "utf8",
|
|
196
|
+
mode: 0o600,
|
|
197
|
+
});
|
|
198
|
+
chmodSync(resolved, 0o600);
|
|
199
|
+
return true;
|
|
200
|
+
} catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
167
205
|
/**
|
|
168
206
|
* Attach token persistence to a BotCordClient.
|
|
169
207
|
* If the account was loaded from a credentialsFile, refreshed tokens
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BotCord dynamic context builder.
|
|
3
|
+
*
|
|
4
|
+
* Builds ephemeral context (cross-room digest, working memory, loop-risk)
|
|
5
|
+
* for injection via before_prompt_build hook's appendSystemContext.
|
|
6
|
+
*
|
|
7
|
+
* Using appendSystemContext (instead of the old prependContext) means:
|
|
8
|
+
* - Content is NOT persisted to the session transcript
|
|
9
|
+
* - Each turn gets fresh context, old context doesn't accumulate
|
|
10
|
+
* - Content stays at system-prompt priority (not user-priority)
|
|
11
|
+
* - Minor KV cache impact when content changes between turns
|
|
12
|
+
*
|
|
13
|
+
* Note: We considered using a Context Engine plugin (assemble() +
|
|
14
|
+
* systemPromptAddition) but that requires explicit slot activation
|
|
15
|
+
* via plugins.slots.contextEngine config. The hook approach works
|
|
16
|
+
* out of the box with zero config.
|
|
17
|
+
*/
|
|
18
|
+
import { buildCrossRoomDigest, getSessionRoom } from "./room-context.js";
|
|
19
|
+
import { readWorkingMemory } from "./memory.js";
|
|
20
|
+
import { buildWorkingMemoryPrompt } from "./memory-protocol.js";
|
|
21
|
+
import {
|
|
22
|
+
buildBotCordLoopRiskPrompt,
|
|
23
|
+
shouldRunBotCordLoopRiskCheck,
|
|
24
|
+
} from "./loop-risk.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build the dynamic context for a BotCord session.
|
|
28
|
+
* Returns appendSystemContext string, or null if no context needed.
|
|
29
|
+
*
|
|
30
|
+
* Called from the before_prompt_build hook in index.ts.
|
|
31
|
+
*/
|
|
32
|
+
export async function buildDynamicContext(params: {
|
|
33
|
+
sessionKey: string;
|
|
34
|
+
channelId?: string;
|
|
35
|
+
prompt?: string;
|
|
36
|
+
messages?: unknown[];
|
|
37
|
+
trigger?: string;
|
|
38
|
+
}): Promise<string | null> {
|
|
39
|
+
const { sessionKey, channelId, prompt, messages, trigger } = params;
|
|
40
|
+
|
|
41
|
+
const isOwnerChat = sessionKey === "botcord:owner:main";
|
|
42
|
+
const isBotCordSession = isOwnerChat || !!getSessionRoom(sessionKey);
|
|
43
|
+
|
|
44
|
+
if (!isBotCordSession) return null;
|
|
45
|
+
|
|
46
|
+
const parts: string[] = [];
|
|
47
|
+
|
|
48
|
+
// 1. Cross-room activity digest
|
|
49
|
+
const digest = await buildCrossRoomDigest(sessionKey);
|
|
50
|
+
if (digest) parts.push(digest);
|
|
51
|
+
|
|
52
|
+
// 2. Working memory
|
|
53
|
+
try {
|
|
54
|
+
const wm = readWorkingMemory();
|
|
55
|
+
const memoryPrompt = buildWorkingMemoryPrompt({ workingMemory: wm });
|
|
56
|
+
parts.push(memoryPrompt);
|
|
57
|
+
} catch (err: unknown) {
|
|
58
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
59
|
+
console.warn("[botcord] dynamic-context: failed to read working memory:", msg);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. Loop-risk guard
|
|
63
|
+
if (prompt && shouldRunBotCordLoopRiskCheck({
|
|
64
|
+
channelId: channelId ?? "botcord",
|
|
65
|
+
prompt,
|
|
66
|
+
trigger,
|
|
67
|
+
})) {
|
|
68
|
+
const loopRisk = buildBotCordLoopRiskPrompt({
|
|
69
|
+
prompt,
|
|
70
|
+
messages: messages ?? [],
|
|
71
|
+
sessionKey,
|
|
72
|
+
});
|
|
73
|
+
if (loopRisk) parts.push(loopRisk);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return parts.length > 0 ? parts.join("\n\n") : null;
|
|
77
|
+
}
|