Skip to content

Commit 097b6a9

Browse files
phernandezclaude
andcommitted
feat: add overwrite guard to write_note tool
Align with basic-memory PR #632 — write_note is now non-destructive by default. When a note already exists, the agent gets a helpful error with alternatives (edit_note, read_note, or explicit overwrite=true). - Add NoteAlreadyExistsError class to bm-client - Add overwrite param to BmClient.writeNote(), pass through to MCP - Detect NOTE_ALREADY_EXISTS JSON response and throw typed error - Tool handler catches conflict and returns actionable guidance - indexConversation passes overwrite=true (intentional create-or-replace) - Update description from "Create or update" to "Create a note" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f7e2e66 commit 097b6a9

File tree

4 files changed

+155
-5
lines changed

4 files changed

+155
-5
lines changed

bm-client.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,49 @@ describe("BmClient MCP behavior", () => {
109109
expect(result.action).toBe("created")
110110
})
111111

112+
it("writeNote passes overwrite flag when provided", async () => {
113+
const callTool = jest.fn().mockResolvedValue(
114+
mcpResult({
115+
title: "Note",
116+
permalink: "notes/note",
117+
file_path: "notes/note.md",
118+
action: "updated",
119+
}),
120+
)
121+
setConnected(client, callTool)
122+
123+
await client.writeNote("Note", "hello", "notes", undefined, true)
124+
125+
expect(callTool).toHaveBeenCalledWith({
126+
name: "write_note",
127+
arguments: {
128+
title: "Note",
129+
content: "hello",
130+
directory: "notes",
131+
output_format: "json",
132+
overwrite: true,
133+
},
134+
})
135+
})
136+
137+
it("writeNote throws NoteAlreadyExistsError on conflict response", async () => {
138+
const callTool = jest.fn().mockResolvedValue(
139+
mcpResult({
140+
title: "Existing",
141+
permalink: "notes/existing",
142+
file_path: null,
143+
checksum: null,
144+
action: "conflict",
145+
error: "NOTE_ALREADY_EXISTS",
146+
}),
147+
)
148+
setConnected(client, callTool)
149+
150+
await expect(
151+
client.writeNote("Existing", "content", "notes"),
152+
).rejects.toThrow("Note already exists")
153+
})
154+
112155
it("editNote calls edit_note with MCP argument names", async () => {
113156
const callTool = jest.fn().mockResolvedValue(
114157
mcpResult({
@@ -540,6 +583,9 @@ describe("BmClient MCP behavior", () => {
540583
)
541584

542585
expect((client as any).writeNote).toHaveBeenCalledTimes(1)
586+
const args = (client as any).writeNote.mock.calls[0]
587+
expect(args[3]).toBeUndefined() // project
588+
expect(args[4]).toBe(true) // overwrite
543589
})
544590

545591
it("indexConversation creates fallback note only on note-not-found errors", async () => {

bm-client.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ import { log } from "./logger.ts"
55

66
const DEFAULT_RETRY_DELAYS_MS = [500, 1000, 2000]
77

8+
export class NoteAlreadyExistsError extends Error {
9+
readonly permalink: string
10+
constructor(title: string, permalink: string) {
11+
super(`Note already exists: "${title}" (${permalink})`)
12+
this.name = "NoteAlreadyExistsError"
13+
this.permalink = permalink
14+
}
15+
}
16+
817
const REQUIRED_TOOLS = [
918
"search_notes",
1019
"read_note",
@@ -569,6 +578,7 @@ export class BmClient {
569578
content: string,
570579
folder: string,
571580
project?: string,
581+
overwrite?: boolean,
572582
): Promise<NoteResult> {
573583
const args: Record<string, unknown> = {
574584
title,
@@ -577,13 +587,21 @@ export class BmClient {
577587
output_format: "json",
578588
}
579589
if (project) args.project = project
590+
if (overwrite !== undefined) args.overwrite = overwrite
580591

581592
const payload = await this.callTool("write_note", args)
582593

583594
if (!isRecord(payload)) {
584595
throw new Error("invalid write_note response")
585596
}
586597

598+
if (payload.error === "NOTE_ALREADY_EXISTS") {
599+
throw new NoteAlreadyExistsError(
600+
asString(payload.title) ?? title,
601+
asString(payload.permalink) ?? "",
602+
)
603+
}
604+
587605
const resultTitle = asString(payload.title)
588606
const permalink = asString(payload.permalink)
589607
const filePath = asString(payload.file_path)
@@ -850,7 +868,7 @@ export class BmClient {
850868
].join("\n")
851869

852870
try {
853-
await this.writeNote(title, content, "conversations")
871+
await this.writeNote(title, content, "conversations", undefined, true)
854872
log.debug(`created conversation note: ${title}`)
855873
} catch (err) {
856874
log.error("conversation index failed", err)

tools/write-note.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { beforeEach, describe, expect, it, jest } from "bun:test"
22
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
33
import type { BmClient } from "../bm-client.ts"
4+
import { NoteAlreadyExistsError } from "../bm-client.ts"
45
import { registerWriteTool } from "./write-note.ts"
56

67
describe("write tool", () => {
@@ -26,7 +27,7 @@ describe("write tool", () => {
2627
name: "write_note",
2728
label: "Write Note",
2829
description: expect.stringContaining(
29-
"Create or update a note in the Basic Memory knowledge graph",
30+
"Create a note in the Basic Memory knowledge graph",
3031
),
3132
parameters: expect.objectContaining({
3233
type: "object",
@@ -46,6 +47,9 @@ describe("write tool", () => {
4647
project: expect.objectContaining({
4748
type: "string",
4849
}),
50+
overwrite: expect.objectContaining({
51+
type: "boolean",
52+
}),
4953
}),
5054
}),
5155
execute: expect.any(Function),
@@ -88,6 +92,7 @@ describe("write tool", () => {
8892
"This is test content",
8993
"notes",
9094
undefined,
95+
undefined,
9196
)
9297

9398
expect(result).toEqual({
@@ -128,6 +133,7 @@ describe("write tool", () => {
128133
"Root level note",
129134
"",
130135
undefined,
136+
undefined,
131137
)
132138
})
133139

@@ -154,6 +160,7 @@ describe("write tool", () => {
154160
"Nested folder note",
155161
"projects/subfolder",
156162
undefined,
163+
undefined,
157164
)
158165
})
159166

@@ -197,6 +204,7 @@ const code = "example";
197204
markdownContent,
198205
"notes",
199206
undefined,
207+
undefined,
200208
)
201209
})
202210

@@ -224,6 +232,7 @@ const code = "example";
224232
"Content",
225233
"notes",
226234
undefined,
235+
undefined,
227236
)
228237
})
229238

@@ -253,6 +262,7 @@ const code = "example";
253262
unicodeContent,
254263
"notes",
255264
undefined,
265+
undefined,
256266
)
257267
})
258268

@@ -280,6 +290,7 @@ const code = "example";
280290
longContent,
281291
"notes",
282292
undefined,
293+
undefined,
283294
)
284295
})
285296

@@ -306,6 +317,7 @@ const code = "example";
306317
"",
307318
"notes",
308319
undefined,
320+
undefined,
309321
)
310322
})
311323

@@ -329,9 +341,56 @@ const code = "example";
329341
"content",
330342
"notes",
331343
"other-project",
344+
undefined,
332345
)
333346
})
334347

348+
it("should pass overwrite=true to client.writeNote", async () => {
349+
;(mockClient.writeNote as jest.MockedFunction<any>).mockResolvedValue({
350+
title: "Note",
351+
permalink: "note",
352+
content: "content",
353+
file_path: "notes/note.md",
354+
action: "updated",
355+
})
356+
357+
const result = await executeFunction("tool-call-id", {
358+
title: "Note",
359+
content: "content",
360+
folder: "notes",
361+
overwrite: true,
362+
})
363+
364+
expect(mockClient.writeNote).toHaveBeenCalledWith(
365+
"Note",
366+
"content",
367+
"notes",
368+
undefined,
369+
true,
370+
)
371+
372+
expect(result.content[0].text).toContain("Note saved: Note")
373+
})
374+
375+
it("should return helpful hint when note already exists", async () => {
376+
;(mockClient.writeNote as jest.MockedFunction<any>).mockRejectedValue(
377+
new NoteAlreadyExistsError("Existing Note", "notes/existing-note"),
378+
)
379+
380+
const result = await executeFunction("tool-call-id", {
381+
title: "Existing Note",
382+
content: "New content",
383+
folder: "notes",
384+
})
385+
386+
const text = result.content[0].text
387+
expect(text).toContain('Note already exists: "Existing Note"')
388+
expect(text).toContain("notes/existing-note")
389+
expect(text).toContain("edit_note")
390+
expect(text).toContain("overwrite=true")
391+
expect(text).toContain("read_note")
392+
})
393+
335394
it("should handle write errors gracefully", async () => {
336395
const writeError = new Error("Failed to write note")
337396
;(mockClient.writeNote as jest.MockedFunction<any>).mockRejectedValue(
@@ -385,6 +444,7 @@ Normal line
385444
formattedContent,
386445
"notes",
387446
undefined,
447+
undefined,
388448
)
389449
})
390450

@@ -412,6 +472,7 @@ Normal line
412472
"Content",
413473
"notes",
414474
undefined,
475+
undefined,
415476
)
416477
})
417478

tools/write-note.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Type } from "@sinclair/typebox"
22
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
33
import type { BmClient } from "../bm-client.ts"
4+
import { NoteAlreadyExistsError } from "../bm-client.ts"
45
import { log } from "../logger.ts"
56

67
export function registerWriteTool(
@@ -12,9 +13,9 @@ export function registerWriteTool(
1213
name: "write_note",
1314
label: "Write Note",
1415
description:
15-
"Create or update a note in the Basic Memory knowledge graph. " +
16-
"Notes are stored as Markdown files with semantic structure " +
17-
"(observations, relations) that build a connected knowledge graph.",
16+
"Create a note in the Basic Memory knowledge graph. " +
17+
"If the note already exists, returns an error by default. " +
18+
"Pass overwrite=true to replace, or use edit_note for incremental updates.",
1819
parameters: Type.Object({
1920
title: Type.String({ description: "Note title" }),
2021
content: Type.String({
@@ -26,6 +27,12 @@ export function registerWriteTool(
2627
description: "Target project name (defaults to current project)",
2728
}),
2829
),
30+
overwrite: Type.Optional(
31+
Type.Boolean({
32+
description:
33+
"Set to true to replace an existing note. Defaults to false.",
34+
}),
35+
),
2936
}),
3037
async execute(
3138
_toolCallId: string,
@@ -34,6 +41,7 @@ export function registerWriteTool(
3441
content: string
3542
folder: string
3643
project?: string
44+
overwrite?: boolean
3745
},
3846
) {
3947
log.debug(`write_note: title=${params.title} folder=${params.folder}`)
@@ -44,6 +52,7 @@ export function registerWriteTool(
4452
params.content,
4553
params.folder,
4654
params.project,
55+
params.overwrite,
4756
)
4857

4958
const msg = `Note saved: ${note.title} (${note.permalink})`
@@ -61,6 +70,22 @@ export function registerWriteTool(
6170
},
6271
}
6372
} catch (err) {
73+
if (err instanceof NoteAlreadyExistsError) {
74+
const hint = [
75+
`Note already exists: "${params.title}" (${err.permalink})`,
76+
"",
77+
"To update this note, use one of:",
78+
` - edit_note("${err.permalink}", operation="append", content="...")`,
79+
` - edit_note("${err.permalink}", operation="replace_section", section="...", content="...")`,
80+
` - write_note("${params.title}", ..., overwrite=true) to fully replace`,
81+
` - read_note("${err.permalink}") to inspect current content first`,
82+
].join("\n")
83+
84+
return {
85+
content: [{ type: "text" as const, text: hint }],
86+
}
87+
}
88+
6489
log.error("write_note failed", err)
6590
return {
6691
content: [

0 commit comments

Comments
 (0)