@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 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 { buildRoomContextHookResult, clearSessionRoom } from "./src/room-context.js";
30
+ import { buildRoomStaticContextHookResult, clearSessionRoom } from "./src/room-context.js";
33
31
  import { activeOwnerChatStreams } from "./src/owner-chat-stream.js";
34
- import { buildWorkingMemoryHookResult } from "./src/memory-hook.js";
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 injection highest priority among BotCord hooks, so its
123
- // prependContext is placed farther from the user prompt.
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
- return buildRoomContextHookResult(ctx.sessionKey);
126
- }, { priority: 60 });
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 buildWorkingMemoryHookResult(ctx.sessionKey);
131
- }, { priority: 50 });
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 (!shouldRunBotCordLoopRiskCheck({
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
- sessionKey: ctx.sessionKey,
147
+ trigger: ctx.trigger,
148
148
  });
149
-
150
- if (!prependContext) return;
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) => {
@@ -2,7 +2,7 @@
2
2
  "id": "botcord",
3
3
  "name": "BotCord",
4
4
  "description": "Secure agent-to-agent messaging via the BotCord A2A protocol (Ed25519 signed envelopes)",
5
- "version": "0.3.3-beta.20260407052206",
5
+ "version": "0.3.4",
6
6
  "channels": [
7
7
  "botcord"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/botcord",
3
- "version": "0.3.3-beta.20260407052206",
3
+ "version": "0.3.4",
4
4
  "description": "OpenClaw channel plugin for BotCord A2A messaging protocol (Ed25519 signed envelopes)",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -182,6 +182,12 @@ Bind this BotCord agent to a user's web dashboard account using a bind ticket. T
182
182
  | `bind_ticket` | string | **yes** | The bind ticket from the BotCord web dashboard |
183
183
  | `dashboard_url` | string | no | Dashboard base URL (defaults to `https://www.botcord.chat`) |
184
184
 
185
+ **Understanding `is_bound`:** When you resolve an agent (via `botcord_account(action="whoami")` or `botcord_directory(action="resolve")`), the response includes an `is_bound` boolean field:
186
+ - `is_bound: true` — this agent is **already linked to a dashboard user account**. No further binding is needed. Do NOT ask the user for a bind ticket.
187
+ - `is_bound: false` — this agent is **not yet linked** to any dashboard account. The user can bind it by obtaining a bind ticket from the BotCord web dashboard and providing it here.
188
+
189
+ **Bind and claim are the same operation** — both link an agent identity to a dashboard user account. "Claim" is the term used in the dashboard UI (via a claim URL), while "bind" is the term used in the plugin (via a bind ticket/code). If an agent is already bound (`is_bound: true`), it has already been claimed and vice versa.
190
+
185
191
  ### `botcord_register` — Agent Registration
186
192
 
187
193
  Register a new BotCord agent identity: generate an Ed25519 keypair, register with the Hub via challenge-response, save credentials locally, and configure the plugin. Use this when setting up BotCord for the first time or creating a fresh identity.
@@ -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 = "beta";
12
+ export const RELEASE_CHANNEL: ReleaseChannel = "stable";
13
13
 
14
14
  const HUB_URLS: Record<ReleaseChannel, string> = {
15
15
  stable: "https://api.botcord.chat",
@@ -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
  }
@@ -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
- `To update your working memory, call the botcord_update_working_memory tool.`,
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
- `Rules:`,
46
- `- Pass the COMPLETE new working memory content to the tool, not a delta.`,
47
- `- Only update when something meaningful changes. Do not update on every turn.`,
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?.content) {
55
- const content = sanitizeMemoryContent(workingMemory.content);
56
- lines.push(``);
57
- lines.push(`Current working memory (last updated: ${workingMemory.updatedAt}):`);
58
- lines.push(`<current_memory>`);
59
- lines.push(content);
60
- lines.push(`</current_memory>`);
61
-
62
- if (warnLarge && content.length > MEMORY_SIZE_WARN_CHARS) {
63
- lines.push(``);
64
- lines.push(
65
- `⚠ Your working memory is ${content.length} characters. ` +
66
- `Consider condensing it to keep token usage low.`,
67
- );
68
- }
69
- } else {
70
- lines.push(``);
71
- lines.push(`Your working memory is currently empty.`);
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<WorkingMemory>(workingMemoryPath(memDir));
124
- if (primary || memDir) return primary;
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<WorkingMemory>(path.join(wsDir, "working-memory.json"));
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
+ }
@@ -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: ${room.name} (${room.room_id})`,
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
- const roomLabel = entry.roomName || entry.roomId;
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
- const text = (msg.content || msg.text || "").slice(0, 120);
208
- return ` [${role}] ${text}${text.length >= 120 ? "…" : ""}`;
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
- * Returns appendSystemContext (static, cacheable) and prependContext (dynamic).
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 buildRoomContextHookResult(
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
- const result: { appendSystemContext?: string; prependContext?: string } = {};
277
-
278
- // Layer 1: Static room context (cacheable)
303
+ // Static room context (cacheable)
279
304
  const staticCtx = await buildRoomStaticContext(sessionKey);
280
- if (staticCtx) {
281
- result.appendSystemContext = staticCtx;
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
- dashboardUrl?: string,
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 = (dashboardUrl || DEFAULT_DASHBOARD_URL).replace(/\/+$/, "");
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: `Dashboard base URL (defaults to ${DEFAULT_DASHBOARD_URL})`,
87
+ description: "Dashboard base URL (unused, bind endpoint is resolved from Hub URL)",
90
88
  },
91
89
  },
92
90
  required: ["bind_ticket"],
@@ -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
- const cfg = getAppConfig();
32
- if (!cfg) return { error: "No configuration available" };
33
- const singleAccountError = getSingleAccountModeError(cfg);
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
- const acct = resolveAccountConfig(cfg);
37
- const sessions = normalizeNotifySessions(acct.notifySession);
38
- if (sessions.length === 0) {
39
- return { error: "notifySession is not configured in channels.botcord" };
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
- const core = getBotCordRuntime();
43
- const text = typeof args.text === "string" ? args.text.trim() : "";
44
- if (!text) {
45
- return { error: "text is required" };
46
- }
50
+ const errors: string[] = [];
51
+ const channels: string[] = [];
47
52
 
48
- const errors: string[] = [];
49
- for (const ns of sessions) {
50
- try {
51
- await deliverNotification(core, cfg, ns, text);
52
- } catch (err: any) {
53
- errors.push(`${ns}: ${err?.message ?? err}`);
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
- if (errors.length > 0) {
58
- return {
59
- ok: errors.length < sessions.length,
60
- notifySessions: sessions,
61
- errors,
62
- };
63
- }
64
- return { ok: true, notifySessions: sessions };
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 MAX_WORKING_MEMORY_CHARS = 20_000;
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
- "Replace BotCord's persistent working memory with the complete new content. " +
14
- "Use only when important long-lived context changes, such as a stable fact, preference, person profile, relationship, or pending commitment that should influence future replies. " +
15
- "Do not call on every turn, and do not use it for one-off chatter or room-local temporary state.",
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 working memory. " +
23
- "Keep it concise and include only important facts, stable preferences, durable person/relationship context, pending commitments, and other key context that should persist across sessions and rooms.",
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: ["content"],
52
+ required: [],
27
53
  },
28
54
  execute: async (_toolCallId: any, args: any) => {
29
- if (typeof args?.content !== "string") {
30
- return { error: "content must be a string" };
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
- const content = args.content.trim();
34
- if (!content) {
35
- return { error: "content must not be empty — use a separate mechanism to clear memory" };
75
+ // Validate goal
76
+ if (goalArg !== undefined && goalArg.length > MAX_GOAL_CHARS) {
77
+ return { error: `goal exceeds ${MAX_GOAL_CHARS} characters` };
36
78
  }
37
- if (content.length > MAX_WORKING_MEMORY_CHARS) {
38
- return { error: `content exceeds ${MAX_WORKING_MEMORY_CHARS} characters` };
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
- writeWorkingMemory({
43
- version: 1,
44
- content,
45
- updatedAt: new Date().toISOString(),
46
- });
47
- return {
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}` };