@botcord/botcord 0.3.2-beta.20260407032921 → 0.3.2
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/client.ts +10 -1
- package/src/commands/healthcheck.ts +28 -1
- package/src/constants.ts +1 -1
- package/src/credentials.ts +38 -0
- package/src/dynamic-context.ts +77 -0
- package/src/inbound.ts +11 -0
- 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 +112 -23
- package/src/ws-client.ts +27 -3
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/client.ts
CHANGED
|
@@ -460,7 +460,7 @@ export class BotCordClient {
|
|
|
460
460
|
// ── Contacts ──────────────────────────────────────────────────
|
|
461
461
|
|
|
462
462
|
async listContacts(): Promise<ContactInfo[]> {
|
|
463
|
-
const resp = await this.hubFetch(`/registry/agents/${this.agentId}/contacts`);
|
|
463
|
+
const resp = await this.hubFetch(`/registry/agents/${this.agentId}/contacts?limit=200`);
|
|
464
464
|
const body = await resp.json();
|
|
465
465
|
return (body.contacts ?? body) as ContactInfo[];
|
|
466
466
|
}
|
|
@@ -927,6 +927,15 @@ export class BotCordClient {
|
|
|
927
927
|
return await resp.json();
|
|
928
928
|
}
|
|
929
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
|
+
|
|
930
939
|
// ── Accessors ─────────────────────────────────────────────────
|
|
931
940
|
|
|
932
941
|
getAgentId(): string {
|
|
@@ -6,14 +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
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";
|
|
15
18
|
import { existsSync, statSync } from "node:fs";
|
|
16
19
|
import { PLUGIN_VERSION, checkVersionInfo } from "../version-check.js";
|
|
20
|
+
import { isOnboarded, markOnboarded } from "../credentials.js";
|
|
17
21
|
|
|
18
22
|
export function createHealthcheckCommand() {
|
|
19
23
|
return {
|
|
@@ -200,7 +204,20 @@ export function createHealthcheckCommand() {
|
|
|
200
204
|
const mode = acct.deliveryMode || "websocket";
|
|
201
205
|
ok(`Delivery mode: ${mode}`);
|
|
202
206
|
|
|
203
|
-
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") {
|
|
204
221
|
info(`Poll interval: ${acct.pollIntervalMs || 5000}ms`);
|
|
205
222
|
}
|
|
206
223
|
|
|
@@ -228,6 +245,16 @@ export function createHealthcheckCommand() {
|
|
|
228
245
|
lines.push("", "All checks passed. BotCord is ready!");
|
|
229
246
|
}
|
|
230
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
|
+
|
|
231
258
|
return { text: lines.join("\n") };
|
|
232
259
|
},
|
|
233
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
|
+
}
|
package/src/inbound.ts
CHANGED
|
@@ -783,4 +783,15 @@ export async function deliverNotification(
|
|
|
783
783
|
accountId: delivery.accountId,
|
|
784
784
|
threadId: delivery.threadId,
|
|
785
785
|
});
|
|
786
|
+
|
|
787
|
+
// Inject into session history so the AI remembers the notification
|
|
788
|
+
try {
|
|
789
|
+
core.channel.session.injectMessage({
|
|
790
|
+
sessionKey,
|
|
791
|
+
message: text,
|
|
792
|
+
label: "BotCord Notification",
|
|
793
|
+
});
|
|
794
|
+
} catch {
|
|
795
|
+
// Best-effort — don't fail the notification if injection fails
|
|
796
|
+
}
|
|
786
797
|
}
|
package/src/memory-protocol.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { WorkingMemory } from "./memory.js";
|
|
|
11
11
|
const MEMORY_SIZE_WARN_CHARS = 2000;
|
|
12
12
|
|
|
13
13
|
/** Tags that must not appear literally in injected memory content. */
|
|
14
|
-
const RESERVED_TAGS_RE = /<\/?current_memory\b[^>]*>/gi;
|
|
14
|
+
const RESERVED_TAGS_RE = /<\/?(?:current_memory|section_\w+)\b[^>]*>/gi;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Sanitize memory content before embedding in the prompt.
|
|
@@ -38,37 +38,57 @@ export function buildWorkingMemoryPrompt(params: {
|
|
|
38
38
|
const lines: string[] = [
|
|
39
39
|
`[BotCord Working Memory]`,
|
|
40
40
|
`You have a persistent working memory that survives across sessions and rooms.`,
|
|
41
|
-
`Use it to track important facts, pending commitments, and context you want to remember.`,
|
|
41
|
+
`Use it to track your goal, important facts, pending commitments, and context you want to remember.`,
|
|
42
42
|
``,
|
|
43
|
-
`
|
|
43
|
+
`Memory is organized into named sections. Use botcord_update_working_memory to update:`,
|
|
44
|
+
`- Pass "goal" to set/update your work goal (pinned, never lost during section updates).`,
|
|
45
|
+
`- Pass "section" + "content" to update a specific section (other sections are untouched).`,
|
|
46
|
+
`- Pass "section" + empty "content" to delete a section.`,
|
|
47
|
+
`- Without "section", updates the default "notes" section.`,
|
|
44
48
|
``,
|
|
45
|
-
`
|
|
46
|
-
|
|
47
|
-
|
|
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.`,
|
|
49
|
+
`Section naming: use clear names like "contacts", "pending_tasks", "preferences", etc.`,
|
|
50
|
+
`Only update when something meaningful changes. Do not update on every turn.`,
|
|
51
|
+
`Keep each section concise and focused on its topic.`,
|
|
52
52
|
];
|
|
53
53
|
|
|
54
|
-
if (workingMemory
|
|
55
|
-
|
|
56
|
-
lines.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
54
|
+
if (!workingMemory) {
|
|
55
|
+
lines.push(``, `Your working memory is currently empty.`);
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const sectionEntries = Object.entries(workingMemory.sections || {});
|
|
60
|
+
const hasGoal = !!workingMemory.goal;
|
|
61
|
+
const hasSections = sectionEntries.length > 0;
|
|
62
|
+
|
|
63
|
+
if (!hasGoal && !hasSections) {
|
|
64
|
+
lines.push(``, `Your working memory is currently empty.`);
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
lines.push(``, `Current working memory (last updated: ${workingMemory.updatedAt}):`);
|
|
69
|
+
|
|
70
|
+
let totalChars = 0;
|
|
71
|
+
|
|
72
|
+
if (hasGoal) {
|
|
73
|
+
// Collapse newlines to prevent prompt injection via goal field
|
|
74
|
+
const goal = sanitizeMemoryContent(workingMemory.goal!.replace(/[\r\n]+/g, " ").trim());
|
|
75
|
+
lines.push(``, `Goal: ${goal}`);
|
|
76
|
+
totalChars += goal.length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const [name, content] of sectionEntries) {
|
|
80
|
+
if (!content) continue;
|
|
81
|
+
const sanitized = sanitizeMemoryContent(content);
|
|
82
|
+
lines.push(``, `<section_${name}>`, sanitized, `</section_${name}>`);
|
|
83
|
+
totalChars += sanitized.length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (warnLarge && totalChars > MEMORY_SIZE_WARN_CHARS) {
|
|
87
|
+
lines.push(
|
|
88
|
+
``,
|
|
89
|
+
`⚠ Your working memory is ${totalChars} characters. ` +
|
|
90
|
+
`Consider condensing sections to keep token usage low.`,
|
|
91
|
+
);
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
return lines.join("\n");
|
package/src/memory.ts
CHANGED
|
@@ -16,12 +16,31 @@ import { resolveAccountConfig } from "./config.js";
|
|
|
16
16
|
// ── Types ──────────────────────────────────────────────────────────
|
|
17
17
|
|
|
18
18
|
export type WorkingMemory = {
|
|
19
|
+
version: 2;
|
|
20
|
+
goal?: string;
|
|
21
|
+
sections: Record<string, string>;
|
|
22
|
+
updatedAt: string;
|
|
23
|
+
sourceSessionKey?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Legacy v1 format — single content string. */
|
|
27
|
+
type WorkingMemoryV1 = {
|
|
19
28
|
version: 1;
|
|
20
29
|
content: string;
|
|
21
30
|
updatedAt: string;
|
|
22
31
|
sourceSessionKey?: string;
|
|
23
32
|
};
|
|
24
33
|
|
|
34
|
+
/** Migrate v1 to v2: move content into a "notes" section. */
|
|
35
|
+
function migrateV1toV2(v1: WorkingMemoryV1): WorkingMemory {
|
|
36
|
+
return {
|
|
37
|
+
version: 2,
|
|
38
|
+
sections: v1.content ? { notes: v1.content } : {},
|
|
39
|
+
updatedAt: v1.updatedAt,
|
|
40
|
+
sourceSessionKey: v1.sourceSessionKey,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
export type RoomState = {
|
|
26
45
|
version: 1;
|
|
27
46
|
checkpointMsgId?: string;
|
|
@@ -119,16 +138,54 @@ function workingMemoryPath(memDir?: string): string {
|
|
|
119
138
|
return path.join(memDir ?? resolveMemoryDir(), "working-memory.json");
|
|
120
139
|
}
|
|
121
140
|
|
|
141
|
+
const VALID_SECTION_KEY_RE = /^[a-zA-Z0-9_]+$/;
|
|
142
|
+
|
|
143
|
+
function sanitizeSections(sections: unknown): Record<string, string> {
|
|
144
|
+
if (!sections || typeof sections !== "object" || Array.isArray(sections)) return {};
|
|
145
|
+
const result: Record<string, string> = {};
|
|
146
|
+
for (const [key, value] of Object.entries(sections as Record<string, unknown>)) {
|
|
147
|
+
if (VALID_SECTION_KEY_RE.test(key) && typeof value === "string") {
|
|
148
|
+
result[key] = value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeWorkingMemory(raw: any): WorkingMemory | null {
|
|
155
|
+
if (!raw || typeof raw !== "object") return null;
|
|
156
|
+
// Already v2
|
|
157
|
+
if (raw.version === 2 && typeof raw.sections === "object") {
|
|
158
|
+
return {
|
|
159
|
+
version: 2,
|
|
160
|
+
goal: typeof raw.goal === "string" ? raw.goal : undefined,
|
|
161
|
+
sections: sanitizeSections(raw.sections),
|
|
162
|
+
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : "",
|
|
163
|
+
sourceSessionKey: typeof raw.sourceSessionKey === "string" ? raw.sourceSessionKey : undefined,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// v1 → v2 migration
|
|
167
|
+
if (raw.version === 1 && typeof raw.content === "string") {
|
|
168
|
+
return migrateV1toV2(raw as WorkingMemoryV1);
|
|
169
|
+
}
|
|
170
|
+
// Unknown or no version — try to treat as v1 if content exists
|
|
171
|
+
if (typeof raw.content === "string") {
|
|
172
|
+
return migrateV1toV2({ version: 1, content: raw.content, updatedAt: raw.updatedAt ?? "" });
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
122
177
|
export function readWorkingMemory(memDir?: string): WorkingMemory | null {
|
|
123
|
-
const primary = readJsonFile<
|
|
124
|
-
|
|
178
|
+
const primary = readJsonFile<unknown>(workingMemoryPath(memDir));
|
|
179
|
+
const normalized = primary ? normalizeWorkingMemory(primary) : null;
|
|
180
|
+
if (normalized) return normalized;
|
|
181
|
+
if (memDir) return null;
|
|
125
182
|
|
|
126
183
|
// Migration fallback: try the old workspace-scoped path so existing memory
|
|
127
184
|
// is not lost after upgrading to account-scoped storage.
|
|
128
185
|
const wsDir = resolveWorkspaceDir();
|
|
129
186
|
if (wsDir) {
|
|
130
|
-
const legacy = readJsonFile<
|
|
131
|
-
if (legacy) return legacy;
|
|
187
|
+
const legacy = readJsonFile<unknown>(path.join(wsDir, "working-memory.json"));
|
|
188
|
+
if (legacy) return normalizeWorkingMemory(legacy);
|
|
132
189
|
}
|
|
133
190
|
return null;
|
|
134
191
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding hook — injects onboarding guidance for first-time users.
|
|
3
|
+
*
|
|
4
|
+
* Checks the credential file for `onboardedAt`. If absent, injects a
|
|
5
|
+
* prependContext that instructs the agent to fetch and follow the
|
|
6
|
+
* best-practices guide, then mark onboarding as complete.
|
|
7
|
+
*/
|
|
8
|
+
import { isOnboarded } from "./credentials.js";
|
|
9
|
+
import { resolveAccountConfig, isAccountConfigured } from "./config.js";
|
|
10
|
+
import { getConfig } from "./runtime.js";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_DOCS_BASE_URL = "https://botcord.chat";
|
|
13
|
+
const BEST_PRACTICES_PATH = "/openclaw-best-practices.md";
|
|
14
|
+
|
|
15
|
+
function buildOnboardingPrompt(docsBaseUrl: string): string {
|
|
16
|
+
const bestPracticesUrl = `${docsBaseUrl}${BEST_PRACTICES_PATH}`;
|
|
17
|
+
return [
|
|
18
|
+
"[BotCord Onboarding] This is a newly installed BotCord plugin. The user has not been guided through setup yet.",
|
|
19
|
+
"",
|
|
20
|
+
"You MUST do the following before handling any other request:",
|
|
21
|
+
"",
|
|
22
|
+
`1. Fetch and read the BotCord guide: ${bestPracticesUrl}`,
|
|
23
|
+
"2. Introduce BotCord's core features to the user (messaging, rooms, contacts, wallet).",
|
|
24
|
+
"3. Walk the user through the Setup Hygiene checklist (items 1–6 in the guide).",
|
|
25
|
+
"4. Ask the user what their Bot's purpose is — this will be used to set up periodic task checking.",
|
|
26
|
+
"5. Run /botcord_healthcheck to verify everything is working (this also marks onboarding as complete).",
|
|
27
|
+
"",
|
|
28
|
+
"Be a guide — don't just show URLs. Explain each step conversationally.",
|
|
29
|
+
"",
|
|
30
|
+
"[BotCord 新手引导] 这是新安装的 BotCord 插件,用户还没有完成引导。",
|
|
31
|
+
"",
|
|
32
|
+
"在处理任何其他请求之前,你必须:",
|
|
33
|
+
"",
|
|
34
|
+
`1. 获取并阅读 BotCord 指南:${bestPracticesUrl}`,
|
|
35
|
+
"2. 向用户介绍 BotCord 的核心功能(消息、群聊、联系人、钱包)。",
|
|
36
|
+
"3. 带用户过一遍安装清单(指南中的第 1–6 条)。",
|
|
37
|
+
"4. 问用户 Bot 是用来做什么的——用于后续设置定时任务检查。",
|
|
38
|
+
"5. 执行 /botcord_healthcheck 确认一切正常(同时标记引导完成)。",
|
|
39
|
+
"",
|
|
40
|
+
"做一个引导者——不要只给 URL,用对话的方式解释每一步。",
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── before_prompt_build handler ────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the onboarding hook result for injection into the agent prompt.
|
|
48
|
+
* Only injects when the agent has not been onboarded yet.
|
|
49
|
+
*/
|
|
50
|
+
export function buildOnboardingHookResult(): { prependContext?: string } | null {
|
|
51
|
+
try {
|
|
52
|
+
const cfg = getConfig();
|
|
53
|
+
if (!cfg) return null;
|
|
54
|
+
|
|
55
|
+
const acct = resolveAccountConfig(cfg);
|
|
56
|
+
if (!isAccountConfigured(acct)) return null;
|
|
57
|
+
|
|
58
|
+
// If no credentialsFile, skip (inline config — likely advanced user)
|
|
59
|
+
if (!acct.credentialsFile) return null;
|
|
60
|
+
|
|
61
|
+
if (isOnboarded(acct.credentialsFile)) return null;
|
|
62
|
+
|
|
63
|
+
const docsBaseUrl = (acct.docsBaseUrl || DEFAULT_DOCS_BASE_URL).replace(/\/+$/, "");
|
|
64
|
+
return { prependContext: buildOnboardingPrompt(docsBaseUrl) };
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|