@poncho-ai/harness 0.23.0 → 0.25.0

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.
@@ -89,6 +89,59 @@ export const createWriteTool = (workingDir: string): ToolDefinition =>
89
89
  },
90
90
  });
91
91
 
92
+ export const createEditTool = (workingDir: string): ToolDefinition =>
93
+ defineTool({
94
+ name: "edit_file",
95
+ description:
96
+ "Edit a file by replacing an exact string match with new content. " +
97
+ "The old_str must match exactly one location in the file (including whitespace and indentation). " +
98
+ "Use an empty new_str to delete matched content.",
99
+ inputSchema: {
100
+ type: "object",
101
+ properties: {
102
+ path: {
103
+ type: "string",
104
+ description: "File path relative to working directory",
105
+ },
106
+ old_str: {
107
+ type: "string",
108
+ description:
109
+ "The exact text to find and replace (must be unique in the file). " +
110
+ "Include surrounding context lines if needed to ensure uniqueness.",
111
+ },
112
+ new_str: {
113
+ type: "string",
114
+ description: "The replacement text (use empty string to delete the matched content)",
115
+ },
116
+ },
117
+ required: ["path", "old_str", "new_str"],
118
+ additionalProperties: false,
119
+ },
120
+ handler: async (input) => {
121
+ const path = typeof input.path === "string" ? input.path : "";
122
+ const oldStr = typeof input.old_str === "string" ? input.old_str : "";
123
+ const newStr = typeof input.new_str === "string" ? input.new_str : "";
124
+ if (!oldStr) throw new Error("old_str must not be empty.");
125
+ const resolved = resolveSafePath(workingDir, path);
126
+ const content = await readFile(resolved, "utf8");
127
+ const first = content.indexOf(oldStr);
128
+ if (first === -1) {
129
+ throw new Error(
130
+ "old_str not found in file. Make sure it matches exactly, including whitespace and line breaks.",
131
+ );
132
+ }
133
+ const last = content.lastIndexOf(oldStr);
134
+ if (first !== last) {
135
+ throw new Error(
136
+ "old_str appears multiple times in the file. Please provide more context to ensure a unique match.",
137
+ );
138
+ }
139
+ const newContent = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
140
+ await writeFile(resolved, newContent, "utf8");
141
+ return { path, edited: true };
142
+ },
143
+ });
144
+
92
145
  export const createDeleteTool = (workingDir: string): ToolDefinition =>
93
146
  defineTool({
94
147
  name: "delete_file",
package/src/harness.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
2
4
  import type {
3
5
  AgentEvent,
4
6
  ContentPart,
@@ -13,9 +15,9 @@ import type {
13
15
  import { getTextContent } from "@poncho-ai/sdk";
14
16
  import type { UploadStore } from "./upload-store.js";
15
17
  import { PONCHO_UPLOAD_SCHEME, deriveUploadKey } from "./upload-store.js";
16
- import { parseAgentFile, renderAgentPrompt, type ParsedAgent, type AgentFrontmatter } from "./agent-parser.js";
18
+ import { parseAgentFile, parseAgentMarkdown, renderAgentPrompt, type ParsedAgent, type AgentFrontmatter } from "./agent-parser.js";
17
19
  import { loadPonchoConfig, resolveMemoryConfig, type PonchoConfig, type ToolAccess, type BuiltInToolToggles } from "./config.js";
18
- import { createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createWriteTool, ponchoDocsTool } from "./default-tools.js";
20
+ import { createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createWriteTool, ponchoDocsTool } from "./default-tools.js";
19
21
  import {
20
22
  createMemoryStore,
21
23
  createMemoryTools,
@@ -224,7 +226,7 @@ You are running locally in development mode. Treat this as an editable agent wor
224
226
  ## Understanding Your Environment
225
227
 
226
228
  - Built-in tools: \`list_directory\` and \`read_file\`
227
- - \`write_file\` is available in development (disabled by default in production)
229
+ - \`write_file\` and \`edit_file\` are available in development (disabled by default in production)
228
230
  - A starter local skill is included (\`starter-echo\`)
229
231
  - Bash/shell commands are **not** available unless you install and enable a shell tool/skill
230
232
  - Git operations are only available if a git-capable tool/skill is configured
@@ -485,6 +487,7 @@ Since all fields have defaults, you only need to specify \`*Env\` when your env
485
487
  - If shell/CLI access is unavailable, ask the user to run needed commands and provide exact copy-paste commands.
486
488
  - For setup, skills, MCP, auth, storage, telemetry, or "how do I..." questions, proactively read \`README.md\` with \`read_file\` before answering.
487
489
  - Prefer quoting concrete commands and examples from \`README.md\` over guessing.
490
+ - Prefer \`edit_file\` for targeted changes to existing files (uses exact string matching); use \`write_file\` only for creating new files or full rewrites.
488
491
  - Keep edits minimal, preserve unrelated settings/code, and summarize what changed.
489
492
 
490
493
  ## Detailed Documentation
@@ -562,6 +565,7 @@ export class AgentHarness {
562
565
  };
563
566
 
564
567
  private parsedAgent?: ParsedAgent;
568
+ private agentFileFingerprint = "";
565
569
  private mcpBridge?: LocalMcpBridge;
566
570
  private subagentManager?: SubagentManager;
567
571
 
@@ -585,7 +589,7 @@ export class AgentHarness {
585
589
  private isToolEnabled(name: string): boolean {
586
590
  const access = this.resolveToolAccess(name);
587
591
  if (access === false) return false;
588
- if (name === "write_file" || name === "delete_file" || name === "delete_directory") {
592
+ if (name === "write_file" || name === "edit_file" || name === "delete_file" || name === "delete_directory") {
589
593
  return this.shouldEnableWriteTool();
590
594
  }
591
595
  return true;
@@ -633,6 +637,9 @@ export class AgentHarness {
633
637
  if (this.isToolEnabled("write_file")) {
634
638
  this.registerIfMissing(createWriteTool(this.workingDir));
635
639
  }
640
+ if (this.isToolEnabled("edit_file")) {
641
+ this.registerIfMissing(createEditTool(this.workingDir));
642
+ }
636
643
  if (this.isToolEnabled("delete_file")) {
637
644
  this.registerIfMissing(createDeleteTool(this.workingDir));
638
645
  }
@@ -692,20 +699,17 @@ export class AgentHarness {
692
699
  }
693
700
 
694
701
  private getRequestedMcpPatterns(): string[] {
695
- const skillPatterns = new Set<string>();
702
+ const patterns = new Set<string>(this.getAgentMcpIntent());
696
703
  for (const skillName of this.activeSkillNames) {
697
704
  const skill = this.loadedSkills.find((entry) => entry.name === skillName);
698
705
  if (!skill) {
699
706
  continue;
700
707
  }
701
708
  for (const pattern of skill.allowedTools.mcp) {
702
- skillPatterns.add(pattern);
709
+ patterns.add(pattern);
703
710
  }
704
711
  }
705
- if (skillPatterns.size > 0) {
706
- return [...skillPatterns];
707
- }
708
- return this.getAgentMcpIntent();
712
+ return [...patterns];
709
713
  }
710
714
 
711
715
  private getRequestedScriptPatterns(): string[] {
@@ -723,20 +727,17 @@ export class AgentHarness {
723
727
  }
724
728
 
725
729
  private getRequestedMcpApprovalPatterns(): string[] {
726
- const skillPatterns = new Set<string>();
730
+ const patterns = new Set<string>(this.getAgentMcpApprovalPatterns());
727
731
  for (const skillName of this.activeSkillNames) {
728
732
  const skill = this.loadedSkills.find((entry) => entry.name === skillName);
729
733
  if (!skill) {
730
734
  continue;
731
735
  }
732
736
  for (const pattern of skill.approvalRequired.mcp) {
733
- skillPatterns.add(pattern);
737
+ patterns.add(pattern);
734
738
  }
735
739
  }
736
- if (skillPatterns.size > 0) {
737
- return [...skillPatterns];
738
- }
739
- return this.getAgentMcpApprovalPatterns();
740
+ return [...patterns];
740
741
  }
741
742
 
742
743
  private getRequestedScriptApprovalPatterns(): string[] {
@@ -887,13 +888,59 @@ export class AgentHarness {
887
888
 
888
889
  private static readonly SKILL_REFRESH_DEBOUNCE_MS = 3000;
889
890
 
890
- private async refreshSkillsIfChanged(): Promise<void> {
891
+ /**
892
+ * Re-read AGENT.md and update the parsed agent when the file has changed
893
+ * on disk. Returns `true` when the agent was actually re-parsed.
894
+ *
895
+ * Preserves the agent identity (id) across reloads so conversation
896
+ * continuity isn't broken.
897
+ */
898
+ private async refreshAgentIfChanged(): Promise<boolean> {
891
899
  if (this.environment !== "development") {
892
- return;
900
+ return false;
893
901
  }
894
- const elapsed = Date.now() - this.lastSkillRefreshAt;
895
- if (this.lastSkillRefreshAt > 0 && elapsed < AgentHarness.SKILL_REFRESH_DEBOUNCE_MS) {
896
- return;
902
+ try {
903
+ const agentFilePath = resolve(this.workingDir, "AGENT.md");
904
+ const rawContent = await readFile(agentFilePath, "utf8");
905
+ if (rawContent === this.agentFileFingerprint) {
906
+ return false;
907
+ }
908
+ const parsed = parseAgentMarkdown(rawContent);
909
+ // Preserve the resolved agent identity so existing conversations
910
+ // keep working after an AGENT.md edit.
911
+ if (!parsed.frontmatter.id && this.parsedAgent?.frontmatter.id) {
912
+ parsed.frontmatter.id = this.parsedAgent.frontmatter.id;
913
+ }
914
+ this.parsedAgent = parsed;
915
+ this.agentFileFingerprint = rawContent;
916
+ return true;
917
+ } catch (error) {
918
+ console.warn(
919
+ `[poncho][agent] Failed to refresh AGENT.md in development mode: ${
920
+ error instanceof Error ? error.message : String(error)
921
+ }`,
922
+ );
923
+ return false;
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Re-scan skill directories and update metadata, tools, and context window
929
+ * when skills have changed on disk. Returns `true` when the skill set was
930
+ * actually updated.
931
+ *
932
+ * @param force - bypass the time-based debounce (used for mid-run refreshes
933
+ * after the agent may have written new skill files).
934
+ */
935
+ private async refreshSkillsIfChanged(force = false): Promise<boolean> {
936
+ if (this.environment !== "development") {
937
+ return false;
938
+ }
939
+ if (!force) {
940
+ const elapsed = Date.now() - this.lastSkillRefreshAt;
941
+ if (this.lastSkillRefreshAt > 0 && elapsed < AgentHarness.SKILL_REFRESH_DEBOUNCE_MS) {
942
+ return false;
943
+ }
897
944
  }
898
945
  this.lastSkillRefreshAt = Date.now();
899
946
  try {
@@ -903,27 +950,44 @@ export class AgentHarness {
903
950
  );
904
951
  const nextFingerprint = this.buildSkillFingerprint(latestSkills);
905
952
  if (nextFingerprint === this.skillFingerprint) {
906
- return;
953
+ return false;
907
954
  }
908
955
  this.loadedSkills = latestSkills;
909
956
  this.skillContextWindow = buildSkillContextWindow(latestSkills);
910
957
  this.skillFingerprint = nextFingerprint;
911
958
  this.registerSkillTools(latestSkills);
912
- // Skill metadata or layout changed; force re-activation to avoid stale
913
- // instructions/tooling when files are renamed or moved during development.
914
- this.activeSkillNames.clear();
959
+ // Prune active skills that no longer exist in the updated metadata,
960
+ // but preserve ones that were merely updated (same name). This keeps
961
+ // MCP tools from active skills registered when their allowed-tools
962
+ // list changes, instead of forcing the agent to re-activate.
963
+ const latestSkillNames = new Set(latestSkills.map(s => s.name));
964
+ for (const name of this.activeSkillNames) {
965
+ if (!latestSkillNames.has(name)) {
966
+ this.activeSkillNames.delete(name);
967
+ }
968
+ }
969
+ // Re-discover MCP server catalogs so newly advertised tools are visible,
970
+ // then refresh the registered tool set with updated skill patterns.
971
+ if (this.mcpBridge) {
972
+ await this.mcpBridge.discoverTools();
973
+ }
915
974
  await this.refreshMcpTools("skills:changed");
975
+ return true;
916
976
  } catch (error) {
917
977
  console.warn(
918
978
  `[poncho][skills] Failed to refresh skills in development mode: ${
919
979
  error instanceof Error ? error.message : String(error)
920
980
  }`,
921
981
  );
982
+ return false;
922
983
  }
923
984
  }
924
985
 
925
986
  async initialize(): Promise<void> {
926
- this.parsedAgent = await parseAgentFile(this.workingDir);
987
+ const agentFilePath = resolve(this.workingDir, "AGENT.md");
988
+ const agentRawContent = await readFile(agentFilePath, "utf8");
989
+ this.parsedAgent = parseAgentMarkdown(agentRawContent);
990
+ this.agentFileFingerprint = agentRawContent;
927
991
  const identity = await ensureAgentIdentity(this.workingDir);
928
992
  if (!this.parsedAgent.frontmatter.id) {
929
993
  this.parsedAgent.frontmatter.id = identity.id;
@@ -1282,10 +1346,11 @@ export class AgentHarness {
1282
1346
  if (!this.parsedAgent) {
1283
1347
  await this.initialize();
1284
1348
  }
1285
- // Start memory fetch early so it overlaps with skill refresh I/O
1349
+ // Start memory fetch early so it overlaps with refresh I/O
1286
1350
  const memoryPromise = this.memoryStore
1287
1351
  ? this.memoryStore.getMainMemory()
1288
1352
  : undefined;
1353
+ await this.refreshAgentIfChanged();
1289
1354
  await this.refreshSkillsIfChanged();
1290
1355
 
1291
1356
  // Track which conversation/owner this run belongs to so browser & subagent tools resolve correctly
@@ -1295,7 +1360,7 @@ export class AgentHarness {
1295
1360
  this._currentRunOwnerId = ownerParam;
1296
1361
  }
1297
1362
 
1298
- const agent = this.parsedAgent as ParsedAgent;
1363
+ let agent = this.parsedAgent as ParsedAgent;
1299
1364
  const runId = `run_${randomUUID()}`;
1300
1365
  const start = now();
1301
1366
  const maxSteps = agent.frontmatter.limits?.maxSteps ?? 50;
@@ -1311,15 +1376,16 @@ export class AgentHarness {
1311
1376
  const inputMessageCount = messages.length;
1312
1377
  const events: AgentEvent[] = [];
1313
1378
 
1314
- const systemPrompt = renderAgentPrompt(agent, {
1315
- parameters: input.parameters,
1316
- runtime: {
1317
- runId,
1318
- agentId: agent.frontmatter.id ?? agent.frontmatter.name,
1319
- environment: this.environment,
1320
- workingDir: this.workingDir,
1321
- },
1322
- });
1379
+ const renderCurrentAgentPrompt = (): string =>
1380
+ renderAgentPrompt(this.parsedAgent!, {
1381
+ parameters: input.parameters,
1382
+ runtime: {
1383
+ runId,
1384
+ agentId: this.parsedAgent!.frontmatter.id ?? this.parsedAgent!.frontmatter.name,
1385
+ environment: this.environment,
1386
+ workingDir: this.workingDir,
1387
+ },
1388
+ });
1323
1389
  const developmentContext =
1324
1390
  this.environment === "development" ? `\n\n${DEVELOPMENT_MODE_CONTEXT}` : "";
1325
1391
  const browserContext = this._browserSession
@@ -1342,9 +1408,6 @@ Browser sessions (cookies, localStorage, login state) are automatically saved an
1342
1408
  ### Tabs and resources
1343
1409
  Each conversation gets its own browser tab sharing a single browser instance. Call \`browser_close\` when done to free the tab. If you don't close it, the tab stays open and the user can continue interacting with it.`
1344
1410
  : "";
1345
- const promptWithSkills = this.skillContextWindow
1346
- ? `${systemPrompt}${developmentContext}\n\n${this.skillContextWindow}${browserContext}`
1347
- : `${systemPrompt}${developmentContext}${browserContext}`;
1348
1411
  const mainMemory = await memoryPromise;
1349
1412
  const boundedMainMemory =
1350
1413
  mainMemory && mainMemory.content.length > 4000
@@ -1357,7 +1420,13 @@ Each conversation gets its own browser tab sharing a single browser instance. Ca
1357
1420
 
1358
1421
  ${boundedMainMemory.trim()}`
1359
1422
  : "";
1360
- const integrityPrompt = `${promptWithSkills}${memoryContext}
1423
+
1424
+ const buildSystemPrompt = (): string => {
1425
+ const agentPrompt = renderCurrentAgentPrompt();
1426
+ const promptWithSkills = this.skillContextWindow
1427
+ ? `${agentPrompt}${developmentContext}\n\n${this.skillContextWindow}${browserContext}`
1428
+ : `${agentPrompt}${developmentContext}${browserContext}`;
1429
+ return `${promptWithSkills}${memoryContext}
1361
1430
 
1362
1431
  ## Execution Integrity
1363
1432
 
@@ -1365,6 +1434,9 @@ ${boundedMainMemory.trim()}`
1365
1434
  - Do not fabricate "Tool Used" or "Tool Result" logs as plain text.
1366
1435
  - Never output faux execution transcripts, markdown tool logs, or "Tool Used/Result" sections.
1367
1436
  - If no suitable tool is available, explicitly say that and ask for guidance.`;
1437
+ };
1438
+ let integrityPrompt = buildSystemPrompt();
1439
+ let lastPromptFingerprint = `${this.agentFileFingerprint}\n${this.skillFingerprint}`;
1368
1440
 
1369
1441
  const pushEvent = (event: AgentEvent): AgentEvent => {
1370
1442
  events.push(event);
@@ -2256,6 +2328,22 @@ ${boundedMainMemory.trim()}`
2256
2328
  metadata: toolMsgMeta as Message["metadata"],
2257
2329
  });
2258
2330
 
2331
+ // In development, re-read AGENT.md and re-scan skills after tool
2332
+ // execution so changes are available on the next step without
2333
+ // requiring a server restart.
2334
+ if (this.environment === "development") {
2335
+ const agentChanged = await this.refreshAgentIfChanged();
2336
+ const skillsChanged = await this.refreshSkillsIfChanged(true);
2337
+ if (agentChanged || skillsChanged) {
2338
+ agent = this.parsedAgent as ParsedAgent;
2339
+ const currentFingerprint = `${this.agentFileFingerprint}\n${this.skillFingerprint}`;
2340
+ if (currentFingerprint !== lastPromptFingerprint) {
2341
+ integrityPrompt = buildSystemPrompt();
2342
+ lastPromptFingerprint = currentFingerprint;
2343
+ }
2344
+ }
2345
+ }
2346
+
2259
2347
  yield pushEvent({
2260
2348
  type: "step:completed",
2261
2349
  step,
package/src/memory.ts CHANGED
@@ -27,7 +27,7 @@ export interface MemoryConfig {
27
27
 
28
28
  export interface MemoryStore {
29
29
  getMainMemory(): Promise<MainMemory>;
30
- updateMainMemory(input: { content: string; mode?: "replace" | "append" }): Promise<MainMemory>;
30
+ updateMainMemory(input: { content: string }): Promise<MainMemory>;
31
31
  }
32
32
 
33
33
  type MainMemoryPayload = {
@@ -89,19 +89,10 @@ class InMemoryMemoryStore implements MemoryStore {
89
89
  return this.mainMemory;
90
90
  }
91
91
 
92
- async updateMainMemory(input: {
93
- content: string;
94
- mode?: "replace" | "append";
95
- }): Promise<MainMemory> {
96
- const now = Date.now();
97
- const existing = await this.getMainMemory();
98
- const nextContent =
99
- input.mode === "append" && existing.content
100
- ? `${existing.content}\n\n${input.content}`.trim()
101
- : input.content;
92
+ async updateMainMemory(input: { content: string }): Promise<MainMemory> {
102
93
  this.mainMemory = {
103
- content: nextContent.trim(),
104
- updatedAt: now,
94
+ content: input.content.trim(),
95
+ updatedAt: Date.now(),
105
96
  };
106
97
  return this.mainMemory;
107
98
  }
@@ -166,18 +157,10 @@ class FileMainMemoryStore implements MemoryStore {
166
157
  return this.mainMemory;
167
158
  }
168
159
 
169
- async updateMainMemory(input: {
170
- content: string;
171
- mode?: "replace" | "append";
172
- }): Promise<MainMemory> {
160
+ async updateMainMemory(input: { content: string }): Promise<MainMemory> {
173
161
  await this.ensureLoaded();
174
- const existing = await this.getMainMemory();
175
- const nextContent =
176
- input.mode === "append" && existing.content
177
- ? `${existing.content}\n\n${input.content}`.trim()
178
- : input.content;
179
162
  this.mainMemory = {
180
- content: nextContent.trim(),
163
+ content: input.content.trim(),
181
164
  updatedAt: Date.now(),
182
165
  };
183
166
  await this.persist();
@@ -225,7 +208,6 @@ abstract class KeyValueMainMemoryStoreBase implements MemoryStore {
225
208
  } catch {
226
209
  await this.memoryFallback.updateMainMemory({
227
210
  content: payload.main.content,
228
- mode: "replace",
229
211
  });
230
212
  }
231
213
  }
@@ -235,18 +217,11 @@ abstract class KeyValueMainMemoryStoreBase implements MemoryStore {
235
217
  return payload.main;
236
218
  }
237
219
 
238
- async updateMainMemory(input: {
239
- content: string;
240
- mode?: "replace" | "append";
241
- }): Promise<MainMemory> {
220
+ async updateMainMemory(input: { content: string }): Promise<MainMemory> {
242
221
  const key = this.key();
243
222
  const payload = await this.readPayload(key);
244
- const nextContent =
245
- input.mode === "append" && payload.main.content
246
- ? `${payload.main.content}\n\n${input.content}`.trim()
247
- : input.content;
248
223
  payload.main = {
249
- content: nextContent.trim(),
224
+ content: input.content.trim(),
250
225
  updatedAt: Date.now(),
251
226
  };
252
227
  await this.writePayload(key, payload);
@@ -590,20 +565,17 @@ export const createMemoryTools = (
590
565
  },
591
566
  }),
592
567
  defineTool({
593
- name: "memory_main_update",
568
+ name: "memory_main_write",
594
569
  description:
595
- "Update persistent main memory when new stable preferences, long-term goals, or durable facts appear. Proactively evaluate every turn whether memory should be updated, and avoid storing ephemeral details.",
570
+ "Overwrite the entire persistent main memory document. " +
571
+ "Use for initial writes or full rewrites. " +
572
+ "Prefer memory_main_edit for targeted changes to existing memory.",
596
573
  inputSchema: {
597
574
  type: "object",
598
575
  properties: {
599
- mode: {
600
- type: "string",
601
- enum: ["replace", "append"],
602
- description: "replace overwrites memory; append adds content to the end",
603
- },
604
576
  content: {
605
577
  type: "string",
606
- description: "The memory content to write",
578
+ description: "The full memory content to write",
607
579
  },
608
580
  },
609
581
  required: ["content"],
@@ -614,11 +586,56 @@ export const createMemoryTools = (
614
586
  if (!content) {
615
587
  throw new Error("content is required");
616
588
  }
617
- const mode =
618
- input.mode === "append" || input.mode === "replace"
619
- ? input.mode
620
- : "replace";
621
- const memory = await store.updateMainMemory({ content, mode });
589
+ const memory = await store.updateMainMemory({ content });
590
+ return { ok: true, memory };
591
+ },
592
+ }),
593
+ defineTool({
594
+ name: "memory_main_edit",
595
+ description:
596
+ "Edit persistent main memory by replacing an exact string match with new content. " +
597
+ "The old_str must match exactly one location in memory. " +
598
+ "Use an empty new_str to delete matched content. " +
599
+ "Proactively evaluate every turn whether memory should be updated.",
600
+ inputSchema: {
601
+ type: "object",
602
+ properties: {
603
+ old_str: {
604
+ type: "string",
605
+ description:
606
+ "The exact text to find and replace (must be unique in memory). " +
607
+ "Include surrounding context if needed to ensure uniqueness.",
608
+ },
609
+ new_str: {
610
+ type: "string",
611
+ description: "The replacement text (use empty string to delete the matched content)",
612
+ },
613
+ },
614
+ required: ["old_str", "new_str"],
615
+ additionalProperties: false,
616
+ },
617
+ handler: async (input) => {
618
+ const oldStr = typeof input.old_str === "string" ? input.old_str : "";
619
+ const newStr = typeof input.new_str === "string" ? input.new_str : "";
620
+ if (!oldStr) {
621
+ throw new Error("old_str must not be empty.");
622
+ }
623
+ const current = await store.getMainMemory();
624
+ const content = current.content;
625
+ const first = content.indexOf(oldStr);
626
+ if (first === -1) {
627
+ throw new Error(
628
+ "old_str not found in memory. Make sure it matches exactly, including whitespace and line breaks.",
629
+ );
630
+ }
631
+ const last = content.lastIndexOf(oldStr);
632
+ if (first !== last) {
633
+ throw new Error(
634
+ "old_str appears multiple times in memory. Please provide more context to ensure a unique match.",
635
+ );
636
+ }
637
+ const newContent = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
638
+ const memory = await store.updateMainMemory({ content: newContent });
622
639
  return { ok: true, memory };
623
640
  },
624
641
  }),