feat(llm): add note mutation tools (rename, delete, move, clone)

Add four new LLM tools for note management:
- rename_note: Change the title of an existing note
- delete_note: Delete a note with system note protection
- move_note: Move a note to a new parent using branch service
- clone_note: Clone a note to an additional parent

All mutation tools are marked with mutates: true for the tool
approval system. Protected and system notes are guarded against
modification.
This commit is contained in:
Tomáš Adámek
2026-04-07 13:19:16 +02:00
parent 372d25667f
commit d771454aa5
3 changed files with 152 additions and 1 deletions

View File

@@ -2363,7 +2363,11 @@
"web_search": "Web search",
"note_in_parent": "<Note/> in <Parent/>",
"get_attachment": "Get attachment",
"get_attachment_content": "Read attachment content"
"get_attachment_content": "Read attachment content",
"rename_note": "Rename note",
"delete_note": "Delete note",
"move_note": "Move note",
"clone_note": "Clone note"
}
}
}

View File

@@ -6,6 +6,8 @@ import { z } from "zod";
import becca from "../../../becca/becca.js";
import type BNote from "../../../becca/entities/bnote.js";
import branchService from "../../branches.js";
import cloningService from "../../cloning.js";
import { defineTools } from "./tool_registry.js";
//#region Subtree tool implementation
@@ -89,5 +91,83 @@ export const hierarchyTools = defineTools({
return buildSubtree(note, 0, depth);
}
},
move_note: {
description: "Move a note to a new parent. The note keeps its content and children but changes its location in the tree. Cannot move system notes.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to move"),
newParentNoteId: z.string().describe("The ID of the new parent note")
}),
mutates: true,
execute: ({ noteId, newParentNoteId }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (note.noteId === "root") {
return { error: "Cannot move the root note" };
}
if (note.isProtected) {
return { error: "Note is protected and cannot be moved" };
}
const targetParent = becca.getNote(newParentNoteId);
if (!targetParent) {
return { error: "Target parent note not found" };
}
// Use the first (primary) parent branch for the move
const branches = note.getParentBranches();
if (branches.length === 0) {
return { error: "Note has no parent branches" };
}
const result = branchService.moveBranchToNote(branches[0], newParentNoteId);
if (Array.isArray(result)) {
// Validation error: [statusCode, { success: false, message }]
const validation = result[1] as { success: boolean; message?: string };
return { error: validation.message || "Move validation failed" };
}
if (!result.success) {
return { error: "Failed to move note" };
}
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected(),
newParentNoteId,
newParentTitle: targetParent.getTitleOrProtected()
};
}
},
clone_note: {
description: "Clone a note to an additional parent (Trilium supports multiple parents). The note appears in both locations and stays in sync. Use this to organize notes under multiple categories.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to clone"),
parentNoteId: z.string().describe("The ID of the new additional parent note"),
prefix: z.string().optional().describe("Optional branch prefix (displayed before the note title in the tree)")
}),
mutates: true,
execute: ({ noteId, parentNoteId, prefix }) => {
const result = cloningService.cloneNoteToParentNote(noteId, parentNoteId, prefix ?? null);
if (!result.success) {
return { error: result.message || "Clone failed" };
}
const note = becca.getNote(noteId);
const parent = becca.getNote(parentNoteId);
return {
success: true,
noteId,
title: note?.getTitleOrProtected() ?? noteId,
parentNoteId,
parentTitle: parent?.getTitleOrProtected() ?? parentNoteId,
branchId: result.branchId
};
}
}
});

View File

@@ -9,9 +9,13 @@ import markdownImport from "../../import/markdown.js";
import noteService from "../../notes.js";
import SearchContext from "../../search/search_context.js";
import searchService from "../../search/services/search.js";
import TaskContext from "../../task_context.js";
import { TOOL_LIMITS, getContentPreview, getNoteContentForLlm, getNoteMeta, setNoteContentFromLlm } from "./helpers.js";
import { defineTools } from "./tool_registry.js";
/** Note IDs that must not be deleted or moved by the LLM. */
const PROTECTED_SYSTEM_NOTES = new Set(["root", "_hidden", "_share", "_lbRoot", "_globalNoteMap"]);
export const noteTools = defineTools({
search_notes: {
description: [
@@ -231,5 +235,68 @@ export const noteTools = defineTools({
return { error: err instanceof Error ? err.message : "Failed to create note" };
}
}
},
rename_note: {
description: "Change the title of an existing note.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to rename"),
newTitle: z.string().describe("The new title for the note")
}),
mutates: true,
execute: ({ noteId, newTitle }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (note.isProtected) {
return { error: "Note is protected and cannot be renamed" };
}
const trimmedTitle = newTitle.trim();
if (!trimmedTitle) {
return { error: "Title cannot be empty" };
}
note.title = trimmedTitle;
note.save();
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected()
};
}
},
delete_note: {
description: "Delete a note and all its branches (parent links). This is irreversible. The note will be marked as deleted. Cannot delete system notes (root, _hidden, etc.).",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to delete")
}),
mutates: true,
execute: ({ noteId }) => {
if (PROTECTED_SYSTEM_NOTES.has(noteId)) {
return { error: "Cannot delete system notes" };
}
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (note.isProtected) {
return { error: "Note is protected and cannot be deleted" };
}
const title = note.getTitleOrProtected();
const taskContext = new TaskContext("no-progress-reporting", "deleteNotes", null);
note.deleteNote(null, taskContext);
return {
success: true,
noteId,
deletedTitle: title
};
}
}
});