@botcord/botcord 0.3.2-beta.20260407032921 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +24 -25
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/botcord/SKILL.md +41 -0
- package/src/client.ts +10 -1
- package/src/commands/healthcheck.ts +28 -1
- package/src/constants.ts +1 -1
- package/src/credentials.ts +38 -0
- package/src/dynamic-context.ts +77 -0
- package/src/inbound.ts +11 -0
- package/src/memory-protocol.ts +48 -28
- package/src/memory.ts +61 -4
- package/src/onboarding-hook.ts +68 -0
- package/src/room-context.ts +42 -27
- package/src/tools/notify.ts +48 -31
- package/src/tools/tool-result.ts +119 -0
- package/src/tools/with-client.ts +93 -0
- package/src/tools/working-memory.ts +112 -23
- package/src/ws-client.ts +27 -3
package/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/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}` };
|