@botcord/botcord 0.3.3-beta.20260407052206 → 0.3.4
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 +11 -0
- 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/bind.ts +3 -5
- 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/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 {
|
|
@@ -17,6 +17,7 @@ import { getConfig as getAppConfig } from "../runtime.js";
|
|
|
17
17
|
import { getWsStatus } from "../ws-client.js";
|
|
18
18
|
import { existsSync, statSync } from "node:fs";
|
|
19
19
|
import { PLUGIN_VERSION, checkVersionInfo } from "../version-check.js";
|
|
20
|
+
import { isOnboarded, markOnboarded } from "../credentials.js";
|
|
20
21
|
|
|
21
22
|
export function createHealthcheckCommand() {
|
|
22
23
|
return {
|
|
@@ -244,6 +245,16 @@ export function createHealthcheckCommand() {
|
|
|
244
245
|
lines.push("", "All checks passed. BotCord is ready!");
|
|
245
246
|
}
|
|
246
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
|
+
|
|
247
258
|
return { text: lines.join("\n") };
|
|
248
259
|
},
|
|
249
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. Ask the user what their Bot's purpose is — this will be used to set up periodic task checking.",
|
|
25
|
+
"4. Walk the user through the Setup Hygiene checklist (items 1–6 in the guide).",
|
|
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. 问用户 Bot 是用来做什么的——用于后续设置定时任务检查。",
|
|
37
|
+
"4. 带用户过一遍安装清单(指南中的第 1–6 条)。",
|
|
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
|
+
}
|
package/src/room-context.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { getBotCordRuntime, getConfig } from "./runtime.js";
|
|
11
11
|
import { resolveAccountConfig, resolveChannelConfig, resolveAccounts, isAccountConfigured } from "./config.js";
|
|
12
12
|
import { attachTokenPersistence } from "./credentials.js";
|
|
13
|
+
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
13
14
|
import type { RoomInfo } from "./types.js";
|
|
14
15
|
|
|
15
16
|
// ── Session ↔ Room mapping ──────────────────────────────────────
|
|
@@ -117,21 +118,26 @@ export async function buildRoomStaticContext(
|
|
|
117
118
|
if (!cached) return null;
|
|
118
119
|
|
|
119
120
|
const { room, members } = cached;
|
|
121
|
+
// Sanitize all tenant-controlled fields to prevent prompt injection
|
|
122
|
+
// via room metadata that lands in appendSystemContext.
|
|
123
|
+
// Strip newlines from single-line fields (name, member names) to
|
|
124
|
+
// prevent structural reshaping of the system prompt.
|
|
125
|
+
const safeName = sanitizeUntrustedContent((room.name || "").replace(/[\r\n]+/g, " "));
|
|
120
126
|
const lines: string[] = [
|
|
121
127
|
`[BotCord Room Context]`,
|
|
122
|
-
`Room: ${
|
|
128
|
+
`Room: ${safeName} (${room.room_id})`,
|
|
123
129
|
];
|
|
124
130
|
if (room.description) {
|
|
125
|
-
lines.push(`Description: ${room.description}`);
|
|
131
|
+
lines.push(`Description: ${sanitizeUntrustedContent(room.description)}`);
|
|
126
132
|
}
|
|
127
133
|
if (room.rule) {
|
|
128
|
-
lines.push(`Rule: ${room.rule}`);
|
|
134
|
+
lines.push(`Rule: ${sanitizeUntrustedContent(room.rule)}`);
|
|
129
135
|
}
|
|
130
136
|
lines.push(`Visibility: ${room.visibility}, Join: ${room.join_policy}`);
|
|
131
137
|
|
|
132
138
|
const memberList = members
|
|
133
139
|
.map((m) => {
|
|
134
|
-
const name = m.display_name || m.agent_id;
|
|
140
|
+
const name = sanitizeUntrustedContent((m.display_name || m.agent_id).replace(/[\r\n]+/g, " "));
|
|
135
141
|
return m.role && m.role !== "member" ? `${name} (${m.role})` : name;
|
|
136
142
|
})
|
|
137
143
|
.join(", ");
|
|
@@ -184,7 +190,10 @@ export async function buildCrossRoomDigest(
|
|
|
184
190
|
];
|
|
185
191
|
|
|
186
192
|
for (const { key, entry } of toDigest) {
|
|
187
|
-
|
|
193
|
+
// Sanitize room label — tenant-controlled name could contain
|
|
194
|
+
// injection markers or newlines that reshape the digest structure.
|
|
195
|
+
const rawLabel = entry.roomName || entry.roomId;
|
|
196
|
+
const roomLabel = sanitizeUntrustedContent(rawLabel.replace(/[\r\n]+/g, " "));
|
|
188
197
|
const isDm = entry.roomId.startsWith("rm_dm_");
|
|
189
198
|
const typeLabel = isDm ? "DM" : "Room";
|
|
190
199
|
|
|
@@ -199,13 +208,28 @@ export async function buildCrossRoomDigest(
|
|
|
199
208
|
continue;
|
|
200
209
|
}
|
|
201
210
|
|
|
202
|
-
// Extract a brief summary from the last messages
|
|
211
|
+
// Extract a brief summary from the last messages.
|
|
212
|
+
// Sanitize previews to neutralize prompt injection from other rooms.
|
|
203
213
|
const previews = messages
|
|
204
214
|
.slice(-3)
|
|
205
215
|
.map((msg: any) => {
|
|
206
216
|
const role = msg.role || "unknown";
|
|
207
|
-
|
|
208
|
-
|
|
217
|
+
// Content may be a string, an array of content blocks, or
|
|
218
|
+
// missing. Coerce safely to avoid throwing on non-string shapes.
|
|
219
|
+
let rawText: string;
|
|
220
|
+
const c = msg.content ?? msg.text ?? "";
|
|
221
|
+
if (typeof c === "string") {
|
|
222
|
+
rawText = c;
|
|
223
|
+
} else if (Array.isArray(c)) {
|
|
224
|
+
rawText = c
|
|
225
|
+
.map((part: any) => (typeof part === "string" ? part : part?.text ?? ""))
|
|
226
|
+
.join(" ");
|
|
227
|
+
} else {
|
|
228
|
+
rawText = String(c);
|
|
229
|
+
}
|
|
230
|
+
const truncated = rawText.slice(0, 120);
|
|
231
|
+
const text = sanitizeUntrustedContent(truncated);
|
|
232
|
+
return ` [${role}] ${text}${rawText.length > 120 ? "…" : ""}`;
|
|
209
233
|
})
|
|
210
234
|
.join("\n");
|
|
211
235
|
|
|
@@ -250,14 +274,17 @@ function buildOwnerChatSceneContext(): string {
|
|
|
250
274
|
// ── Combined hook handler ───────────────────────────────────────
|
|
251
275
|
|
|
252
276
|
/**
|
|
253
|
-
* before_prompt_build handler that injects room context.
|
|
254
|
-
*
|
|
277
|
+
* before_prompt_build handler that injects static room context only.
|
|
278
|
+
*
|
|
279
|
+
* Returns appendSystemContext (cacheable) for room metadata and
|
|
280
|
+
* owner-chat scene description. Dynamic context (cross-room digest,
|
|
281
|
+
* working memory, loop-risk) is now handled by the context engine
|
|
282
|
+
* in context-engine.ts to avoid polluting session transcript.
|
|
255
283
|
*/
|
|
256
|
-
export async function
|
|
284
|
+
export async function buildRoomStaticContextHookResult(
|
|
257
285
|
sessionKey: string | undefined,
|
|
258
286
|
): Promise<{
|
|
259
287
|
appendSystemContext?: string;
|
|
260
|
-
prependContext?: string;
|
|
261
288
|
} | null> {
|
|
262
289
|
if (!sessionKey) return null;
|
|
263
290
|
|
|
@@ -273,20 +300,8 @@ export async function buildRoomContextHookResult(
|
|
|
273
300
|
// custom-routed keys that don't carry the prefix.
|
|
274
301
|
if (!sessionRoomMap.has(sessionKey)) return null;
|
|
275
302
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
// Layer 1: Static room context (cacheable)
|
|
303
|
+
// Static room context (cacheable)
|
|
279
304
|
const staticCtx = await buildRoomStaticContext(sessionKey);
|
|
280
|
-
if (staticCtx)
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Layer 2: Cross-room activity digest (dynamic, per-turn)
|
|
285
|
-
const digest = await buildCrossRoomDigest(sessionKey);
|
|
286
|
-
if (digest) {
|
|
287
|
-
result.prependContext = digest;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
if (!result.appendSystemContext && !result.prependContext) return null;
|
|
291
|
-
return result;
|
|
305
|
+
if (!staticCtx) return null;
|
|
306
|
+
return { appendSystemContext: staticCtx };
|
|
292
307
|
}
|
package/src/tools/bind.ts
CHANGED
|
@@ -13,14 +13,12 @@ import { BotCordClient } from "../client.js";
|
|
|
13
13
|
import { attachTokenPersistence } from "../credentials.js";
|
|
14
14
|
import { getConfig as getAppConfig } from "../runtime.js";
|
|
15
15
|
|
|
16
|
-
const DEFAULT_DASHBOARD_URL = "https://www.botcord.chat";
|
|
17
|
-
|
|
18
16
|
/**
|
|
19
17
|
* Shared bind logic used by both the tool and the command.
|
|
20
18
|
*/
|
|
21
19
|
export async function executeBind(
|
|
22
20
|
bindCredential: string,
|
|
23
|
-
|
|
21
|
+
_dashboardUrl?: string,
|
|
24
22
|
): Promise<{ ok: true; [key: string]: unknown } | { error: string }> {
|
|
25
23
|
const cfg = getAppConfig();
|
|
26
24
|
if (!cfg) return { error: "No configuration available" };
|
|
@@ -42,7 +40,7 @@ export async function executeBind(
|
|
|
42
40
|
const resolved = (await client.resolve(agentId)) as Record<string, unknown>;
|
|
43
41
|
const displayName = (resolved.display_name as string) || agentId;
|
|
44
42
|
|
|
45
|
-
const baseUrl = (
|
|
43
|
+
const baseUrl = client.getHubUrl().replace(/\/+$/, "");
|
|
46
44
|
|
|
47
45
|
const res = await fetch(`${baseUrl}/api/users/me/agents/bind`, {
|
|
48
46
|
method: "POST",
|
|
@@ -86,7 +84,7 @@ export function createBindTool() {
|
|
|
86
84
|
},
|
|
87
85
|
dashboard_url: {
|
|
88
86
|
type: "string" as const,
|
|
89
|
-
description:
|
|
87
|
+
description: "Dashboard base URL (unused, bind endpoint is resolved from Hub URL)",
|
|
90
88
|
},
|
|
91
89
|
},
|
|
92
90
|
required: ["bind_ticket"],
|
package/src/tools/notify.ts
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
* is important enough to warrant notifying the owner.
|
|
5
5
|
*/
|
|
6
6
|
import { getBotCordRuntime } from "../runtime.js";
|
|
7
|
-
import { getConfig as getAppConfig } from "../runtime.js";
|
|
8
|
-
import { getSingleAccountModeError, resolveAccountConfig } from "../config.js";
|
|
9
7
|
import { deliverNotification, normalizeNotifySessions } from "../inbound.js";
|
|
8
|
+
import { isAccountConfigured } from "../config.js";
|
|
9
|
+
import { BotCordClient } from "../client.js";
|
|
10
|
+
import { attachTokenPersistence } from "../credentials.js";
|
|
11
|
+
import { withConfig } from "./with-client.js";
|
|
12
|
+
import { validationError } from "./tool-result.js";
|
|
10
13
|
|
|
11
14
|
export function createNotifyTool() {
|
|
12
15
|
return {
|
|
@@ -28,40 +31,54 @@ export function createNotifyTool() {
|
|
|
28
31
|
required: ["text"],
|
|
29
32
|
},
|
|
30
33
|
execute: async (toolCallId: any, args: any) => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (singleAccountError) return { error: singleAccountError };
|
|
34
|
+
return withConfig(async (cfg, acct) => {
|
|
35
|
+
const sessions = normalizeNotifySessions(acct.notifySession);
|
|
36
|
+
const hasAccount = isAccountConfigured(acct);
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
if (sessions.length === 0 && !hasAccount) {
|
|
39
|
+
return validationError(
|
|
40
|
+
"No notification channel available. Configure notifySession in channels.botcord or register a BotCord account.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const core = getBotCordRuntime();
|
|
45
|
+
const text = typeof args.text === "string" ? args.text.trim() : "";
|
|
46
|
+
if (!text) {
|
|
47
|
+
return validationError("text is required");
|
|
48
|
+
}
|
|
41
49
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (!text) {
|
|
45
|
-
return { error: "text is required" };
|
|
46
|
-
}
|
|
50
|
+
const errors: string[] = [];
|
|
51
|
+
const channels: string[] = [];
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
for (const ns of sessions) {
|
|
54
|
+
try {
|
|
55
|
+
await deliverNotification(core, cfg, ns, text);
|
|
56
|
+
channels.push(ns);
|
|
57
|
+
} catch (err: any) {
|
|
58
|
+
errors.push(`${ns}: ${err?.message ?? err}`);
|
|
59
|
+
}
|
|
54
60
|
}
|
|
55
|
-
}
|
|
56
61
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
// Also push notification to owner's dashboard via Hub API
|
|
63
|
+
if (hasAccount) {
|
|
64
|
+
try {
|
|
65
|
+
const client = new BotCordClient(acct);
|
|
66
|
+
attachTokenPersistence(client, acct);
|
|
67
|
+
await client.notifyOwner(text);
|
|
68
|
+
channels.push("owner-chat");
|
|
69
|
+
} catch (err: any) {
|
|
70
|
+
errors.push(`owner-chat: ${err?.message ?? err}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (channels.length === 0) {
|
|
75
|
+
return { ok: false, errors };
|
|
76
|
+
}
|
|
77
|
+
if (errors.length > 0) {
|
|
78
|
+
return { ok: true, channels, errors };
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, channels };
|
|
81
|
+
});
|
|
65
82
|
},
|
|
66
83
|
};
|
|
67
84
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured tool result types and builder helpers.
|
|
3
|
+
*
|
|
4
|
+
* Provides a unified response envelope for all BotCord tools:
|
|
5
|
+
* - Success: { ok: true, ...data }
|
|
6
|
+
* - Failure: { ok: false, error: { type, code, message, hint? } }
|
|
7
|
+
* - DryRun: { ok: true, dry_run: true, request: { method, path, body? } }
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── Error types ─────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type ToolErrorType = "config" | "auth" | "validation" | "api" | "network";
|
|
13
|
+
|
|
14
|
+
export interface ToolError {
|
|
15
|
+
type: ToolErrorType;
|
|
16
|
+
code: string;
|
|
17
|
+
message: string;
|
|
18
|
+
hint?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Result types ────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export type ToolSuccess<T = Record<string, unknown>> = { ok: true } & T;
|
|
24
|
+
export type ToolFailure = { ok: false; error: ToolError };
|
|
25
|
+
export type ToolResult<T = Record<string, unknown>> = ToolSuccess<T> | ToolFailure;
|
|
26
|
+
|
|
27
|
+
export interface DryRunRequest {
|
|
28
|
+
method: string;
|
|
29
|
+
path: string;
|
|
30
|
+
body?: unknown;
|
|
31
|
+
query?: Record<string, string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type DryRunResult = { ok: true; dry_run: true; request: DryRunRequest };
|
|
35
|
+
|
|
36
|
+
// ── Builder helpers ─────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export function success<T extends Record<string, unknown>>(data: T): ToolSuccess<T> {
|
|
39
|
+
return { ok: true, ...data };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function fail(
|
|
43
|
+
type: ToolErrorType,
|
|
44
|
+
code: string,
|
|
45
|
+
message: string,
|
|
46
|
+
hint?: string,
|
|
47
|
+
): ToolFailure {
|
|
48
|
+
return { ok: false, error: { type, code, message, ...(hint ? { hint } : {}) } };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function configError(message: string, hint?: string): ToolFailure {
|
|
52
|
+
return fail("config", "NOT_CONFIGURED", message, hint);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validationError(message: string, hint?: string): ToolFailure {
|
|
56
|
+
return fail("validation", "INVALID_INPUT", message, hint);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function apiError(code: string, message: string, hint?: string): ToolFailure {
|
|
60
|
+
return fail("api", code, message, hint);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function dryRunResult(method: string, path: string, body?: unknown, query?: Record<string, string>): DryRunResult {
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
dry_run: true,
|
|
67
|
+
request: { method, path, ...(body !== undefined ? { body } : {}), ...(query ? { query } : {}) },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Error classifier ────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
import { HubApiError } from "../client.js";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Classify a caught error into a structured ToolFailure.
|
|
77
|
+
* Uses HubApiError's typed status and code properties.
|
|
78
|
+
*/
|
|
79
|
+
export function classifyError(err: unknown): ToolFailure {
|
|
80
|
+
if (!(err instanceof Error)) {
|
|
81
|
+
return fail("api", "UNKNOWN", String(err));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const message = err.message;
|
|
85
|
+
|
|
86
|
+
// Network-level failures
|
|
87
|
+
if (
|
|
88
|
+
err.name === "AbortError" ||
|
|
89
|
+
message.includes("fetch failed") ||
|
|
90
|
+
message.includes("ECONNREFUSED") ||
|
|
91
|
+
message.includes("ENOTFOUND") ||
|
|
92
|
+
message.includes("network")
|
|
93
|
+
) {
|
|
94
|
+
return fail("network", "CONNECTION_FAILED", message, "Check Hub URL and network connectivity");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Typed Hub API errors
|
|
98
|
+
if (err instanceof HubApiError) {
|
|
99
|
+
const { status, code } = err;
|
|
100
|
+
switch (status) {
|
|
101
|
+
case 401:
|
|
102
|
+
return fail("auth", "TOKEN_EXPIRED", message, "Token refresh may have failed — try again or re-register");
|
|
103
|
+
case 403:
|
|
104
|
+
return fail("auth", code || "FORBIDDEN", message);
|
|
105
|
+
case 404:
|
|
106
|
+
return fail("api", "NOT_FOUND", message, "Verify the target ID exists via botcord_directory(action=\"resolve\")");
|
|
107
|
+
case 409:
|
|
108
|
+
return fail("api", "CONFLICT", message);
|
|
109
|
+
case 422:
|
|
110
|
+
return fail("validation", "UNPROCESSABLE", message);
|
|
111
|
+
case 429:
|
|
112
|
+
return fail("api", "RATE_LIMITED", message, "Throttle requests — 20 msg/min global, 10 msg/min per conversation");
|
|
113
|
+
default:
|
|
114
|
+
return fail("api", code || `HTTP_${status}`, message);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return fail("api", "UNKNOWN", message);
|
|
119
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tool wrapper that eliminates boilerplate across all BotCord tools.
|
|
3
|
+
*
|
|
4
|
+
* Handles: config check → single-account guard → account resolution →
|
|
5
|
+
* client creation → token persistence → try/catch with error classification.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
getSingleAccountModeError,
|
|
9
|
+
resolveAccountConfig,
|
|
10
|
+
isAccountConfigured,
|
|
11
|
+
} from "../config.js";
|
|
12
|
+
import { BotCordClient } from "../client.js";
|
|
13
|
+
import { attachTokenPersistence } from "../credentials.js";
|
|
14
|
+
import { getConfig as getAppConfig } from "../runtime.js";
|
|
15
|
+
import type { BotCordAccountConfig } from "../types.js";
|
|
16
|
+
import { configError, classifyError, type ToolResult, type DryRunResult } from "./tool-result.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run a tool action with a fully-configured BotCordClient.
|
|
20
|
+
*
|
|
21
|
+
* The callback receives the client and resolved account config.
|
|
22
|
+
* If it returns a plain object, it is automatically wrapped in `{ ok: true, ... }`.
|
|
23
|
+
* If it returns an object that already has `ok` set, it is passed through as-is.
|
|
24
|
+
*/
|
|
25
|
+
export async function withClient(
|
|
26
|
+
fn: (client: BotCordClient, acct: BotCordAccountConfig) => Promise<ToolResult | DryRunResult | Record<string, unknown>>,
|
|
27
|
+
): Promise<ToolResult | DryRunResult> {
|
|
28
|
+
const cfg = getAppConfig();
|
|
29
|
+
if (!cfg) {
|
|
30
|
+
return configError("No configuration available", "Run /botcord_healthcheck to diagnose");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const singleErr = getSingleAccountModeError(cfg);
|
|
34
|
+
if (singleErr) {
|
|
35
|
+
return configError(singleErr);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const acct = resolveAccountConfig(cfg);
|
|
39
|
+
if (!isAccountConfigured(acct)) {
|
|
40
|
+
return configError(
|
|
41
|
+
"BotCord is not configured.",
|
|
42
|
+
"Run botcord-register to create an identity or botcord-import to restore one",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const client = new BotCordClient(acct);
|
|
48
|
+
attachTokenPersistence(client, acct);
|
|
49
|
+
const result = await fn(client, acct);
|
|
50
|
+
|
|
51
|
+
// If the callback already returned a structured result, pass through
|
|
52
|
+
if (result && typeof result === "object" && "ok" in result) {
|
|
53
|
+
return result as ToolResult | DryRunResult;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Otherwise wrap in success envelope
|
|
57
|
+
return { ok: true as const, ...result };
|
|
58
|
+
} catch (err: unknown) {
|
|
59
|
+
return classifyError(err);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Lightweight version that only checks config availability (no client creation).
|
|
65
|
+
* Used by tools that don't need a BotCordClient (e.g. register, notify).
|
|
66
|
+
*/
|
|
67
|
+
export async function withConfig(
|
|
68
|
+
fn: (cfg: any, acct: BotCordAccountConfig) => Promise<ToolResult | DryRunResult | Record<string, unknown>>,
|
|
69
|
+
): Promise<ToolResult | DryRunResult> {
|
|
70
|
+
const cfg = getAppConfig();
|
|
71
|
+
if (!cfg) {
|
|
72
|
+
return configError("No configuration available", "Run /botcord_healthcheck to diagnose");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const singleErr = getSingleAccountModeError(cfg);
|
|
76
|
+
if (singleErr) {
|
|
77
|
+
return configError(singleErr);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const acct = resolveAccountConfig(cfg);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const result = await fn(cfg, acct);
|
|
84
|
+
|
|
85
|
+
if (result && typeof result === "object" && "ok" in result) {
|
|
86
|
+
return result as ToolResult | DryRunResult;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ok: true as const, ...result };
|
|
90
|
+
} catch (err: unknown) {
|
|
91
|
+
return classifyError(err);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -1,54 +1,143 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* botcord_update_working_memory — explicit tool for persisting working memory.
|
|
3
|
+
*
|
|
4
|
+
* Supports named sections for granular updates:
|
|
5
|
+
* - { goal: "..." } → update goal only
|
|
6
|
+
* - { section: "contacts", content: "..." } → update one section
|
|
7
|
+
* - { content: "..." } → update default "notes" section
|
|
8
|
+
* - { section: "old", content: "" } → delete a section
|
|
3
9
|
*/
|
|
4
|
-
import { writeWorkingMemory } from "../memory.js";
|
|
10
|
+
import { readWorkingMemory, writeWorkingMemory } from "../memory.js";
|
|
5
11
|
|
|
6
|
-
const
|
|
12
|
+
const MAX_SECTION_CHARS = 10_000;
|
|
13
|
+
const MAX_GOAL_CHARS = 500;
|
|
14
|
+
const MAX_TOTAL_CHARS = 20_000;
|
|
15
|
+
const DEFAULT_SECTION = "notes";
|
|
7
16
|
|
|
8
17
|
export function createWorkingMemoryTool() {
|
|
9
18
|
return {
|
|
10
19
|
name: "botcord_update_working_memory",
|
|
11
20
|
label: "Update Working Memory",
|
|
12
21
|
description:
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
22
|
+
"Update BotCord's persistent working memory. Memory is organized into named sections " +
|
|
23
|
+
"that are updated independently — changing one section never affects others. " +
|
|
24
|
+
"Pass 'goal' to set your work goal (pinned, survives all updates). " +
|
|
25
|
+
"Pass 'section' + 'content' to update a specific section. " +
|
|
26
|
+
"Pass only 'content' to update the default 'notes' section. " +
|
|
27
|
+
"Pass 'section' with empty 'content' to delete a section. " +
|
|
28
|
+
"Use clear section names like 'contacts', 'pending_tasks', 'preferences' (letters, digits, underscores only).",
|
|
16
29
|
parameters: {
|
|
17
30
|
type: "object" as const,
|
|
18
31
|
properties: {
|
|
32
|
+
goal: {
|
|
33
|
+
type: "string" as const,
|
|
34
|
+
description:
|
|
35
|
+
"Set or update the agent's work goal. This is pinned and never lost when sections are updated. " +
|
|
36
|
+
`Max ${MAX_GOAL_CHARS} characters.`,
|
|
37
|
+
},
|
|
38
|
+
section: {
|
|
39
|
+
type: "string" as const,
|
|
40
|
+
description:
|
|
41
|
+
"Name of the section to update (e.g. 'contacts', 'pending_tasks', 'preferences'). " +
|
|
42
|
+
`Defaults to '${DEFAULT_SECTION}' if not specified.`,
|
|
43
|
+
},
|
|
19
44
|
content: {
|
|
20
45
|
type: "string" as const,
|
|
21
46
|
description:
|
|
22
|
-
"The complete replacement content for
|
|
23
|
-
"
|
|
47
|
+
"The complete replacement content for the specified section. " +
|
|
48
|
+
"Pass empty string to delete the section. " +
|
|
49
|
+
`Max ${MAX_SECTION_CHARS} characters per section.`,
|
|
24
50
|
},
|
|
25
51
|
},
|
|
26
|
-
required: [
|
|
52
|
+
required: [],
|
|
27
53
|
},
|
|
28
54
|
execute: async (_toolCallId: any, args: any) => {
|
|
29
|
-
|
|
30
|
-
|
|
55
|
+
// Type validation — reject wrong types explicitly
|
|
56
|
+
if (args?.goal !== undefined && typeof args.goal !== "string") {
|
|
57
|
+
return { error: "'goal' must be a string" };
|
|
58
|
+
}
|
|
59
|
+
if (args?.section !== undefined && typeof args.section !== "string") {
|
|
60
|
+
return { error: "'section' must be a string" };
|
|
61
|
+
}
|
|
62
|
+
if (args?.content !== undefined && typeof args.content !== "string") {
|
|
63
|
+
return { error: "'content' must be a string" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const goalArg = typeof args?.goal === "string" ? args.goal.trim() : undefined;
|
|
67
|
+
const sectionArg = typeof args?.section === "string" ? args.section.trim() : undefined;
|
|
68
|
+
const contentArg = typeof args?.content === "string" ? args.content : undefined;
|
|
69
|
+
|
|
70
|
+
// Must provide at least one of goal or content
|
|
71
|
+
if (goalArg === undefined && contentArg === undefined) {
|
|
72
|
+
return { error: "Must provide at least 'goal' or 'content'" };
|
|
31
73
|
}
|
|
32
74
|
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
return { error:
|
|
75
|
+
// Validate goal
|
|
76
|
+
if (goalArg !== undefined && goalArg.length > MAX_GOAL_CHARS) {
|
|
77
|
+
return { error: `goal exceeds ${MAX_GOAL_CHARS} characters` };
|
|
36
78
|
}
|
|
37
|
-
|
|
38
|
-
|
|
79
|
+
|
|
80
|
+
// Validate section name
|
|
81
|
+
const sectionName = sectionArg || DEFAULT_SECTION;
|
|
82
|
+
if (!/^[a-zA-Z0-9_]+$/.test(sectionName)) {
|
|
83
|
+
return { error: "section name must contain only letters, digits, and underscores" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Validate content
|
|
87
|
+
const content = contentArg?.trim() ?? undefined;
|
|
88
|
+
if (content !== undefined && content.length > MAX_SECTION_CHARS) {
|
|
89
|
+
return { error: `content exceeds ${MAX_SECTION_CHARS} characters for section '${sectionName}'` };
|
|
39
90
|
}
|
|
40
91
|
|
|
41
92
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
93
|
+
// Read existing memory (or start fresh)
|
|
94
|
+
const existing = readWorkingMemory() ?? {
|
|
95
|
+
version: 2 as const,
|
|
96
|
+
sections: {},
|
|
97
|
+
updatedAt: "",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Update goal if provided
|
|
101
|
+
if (goalArg !== undefined) {
|
|
102
|
+
existing.goal = goalArg || undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Update section if content provided
|
|
106
|
+
if (content !== undefined) {
|
|
107
|
+
if (content === "") {
|
|
108
|
+
delete existing.sections[sectionName];
|
|
109
|
+
} else {
|
|
110
|
+
existing.sections[sectionName] = content;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check total size
|
|
115
|
+
const totalChars =
|
|
116
|
+
(existing.goal?.length ?? 0) +
|
|
117
|
+
Object.values(existing.sections).reduce((sum, s) => sum + s.length, 0);
|
|
118
|
+
if (totalChars > MAX_TOTAL_CHARS) {
|
|
119
|
+
return { error: `total working memory exceeds ${MAX_TOTAL_CHARS} characters (current: ${totalChars})` };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
existing.updatedAt = new Date().toISOString();
|
|
123
|
+
|
|
124
|
+
writeWorkingMemory(existing);
|
|
125
|
+
|
|
126
|
+
const result: Record<string, unknown> = {
|
|
48
127
|
ok: true,
|
|
49
|
-
updated: true,
|
|
50
|
-
content_length: content.length,
|
|
51
128
|
};
|
|
129
|
+
if (goalArg !== undefined) {
|
|
130
|
+
result.goal_updated = true;
|
|
131
|
+
}
|
|
132
|
+
if (content !== undefined) {
|
|
133
|
+
result.section = sectionName;
|
|
134
|
+
result.section_updated = content !== "";
|
|
135
|
+
result.section_deleted = content === "";
|
|
136
|
+
}
|
|
137
|
+
result.total_sections = Object.keys(existing.sections).length;
|
|
138
|
+
result.total_chars = totalChars;
|
|
139
|
+
|
|
140
|
+
return result;
|
|
52
141
|
} catch (err: unknown) {
|
|
53
142
|
const message = err instanceof Error ? err.message : String(err);
|
|
54
143
|
return { error: `Failed to update working memory: ${message}` };
|