@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.
@@ -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
  }
@@ -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}` };