diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 9605212f6e..6cfdcd069b 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2363,7 +2363,11 @@ "web_search": "Web search", "note_in_parent": " in ", "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" } } } diff --git a/apps/server/src/services/llm/tools/hierarchy_tools.ts b/apps/server/src/services/llm/tools/hierarchy_tools.ts index cb75941c5e..2e39f2ed31 100644 --- a/apps/server/src/services/llm/tools/hierarchy_tools.ts +++ b/apps/server/src/services/llm/tools/hierarchy_tools.ts @@ -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 + }; + } } }); diff --git a/apps/server/src/services/llm/tools/note_tools.ts b/apps/server/src/services/llm/tools/note_tools.ts index 2daa55a1ca..e3d2d12432 100644 --- a/apps/server/src/services/llm/tools/note_tools.ts +++ b/apps/server/src/services/llm/tools/note_tools.ts @@ -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 + }; + } } });