Files
tg-chat-bot/src/ai/tools/list-notes.ts
T
2026-05-13 12:05:55 +03:00

318 lines
11 KiB
TypeScript

import {AiTool} from "../tool-types";
import path from "node:path";
import {readdir, readFile, unlink, writeFile} from "node:fs/promises";
import {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("notes");
export type NoteListItem = {
fileName: string;
filePath: string;
relativePath: string;
title: string;
};
export type ListNotesResult =
| { success: true; notes: NoteListItem[] }
| { success: false; error: string };
export type GetNoteContentResult =
| {
success: true;
fileName: string;
filePath: string;
relativePath: string;
title: string;
content: string;
} | { success: false; error: string };
export const listNotesTool = {
type: "function",
function: {
name: "list_notes",
description: "Display all available Markdown notes from the notes directory.",
parameters: {
type: "object",
properties: {},
required: [],
},
},
} satisfies AiTool;
export const getNoteContentTool = {
type: "function",
function: {
name: "get_note_content",
description: "Get the full Markdown content of a specific note by its file name.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description:
"The file name of the note to read. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
},
},
required: ["fileName"],
},
},
} satisfies AiTool;
export async function listNotes(): Promise<ListNotesResult> {
const startedAt = Date.now();
logger.debug("list.start");
try {
const entries = await readdir(notesDir, {withFileTypes: true});
const markdownFiles = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((fileName) => fileName.endsWith(".md"));
const notes: NoteListItem[] = await Promise.all(
markdownFiles.map(async (fileName) => {
const filePath = path.join(notesDir, fileName);
const relativePath = path.relative(path.dirname(notesRootFile), filePath);
let content = "";
try {
content = await readFile(filePath, "utf-8");
} catch {
// Ignore content read errors for individual files.
}
return {
fileName,
filePath,
relativePath,
title: extractNoteTitle(fileName, content),
};
}),
);
notes.sort((a, b) => a.title.localeCompare(b.title));
logger.debug("list.done", {notes: notes.length, duration: logger.duration(startedAt)});
return {success: true, notes};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to list notes: ${errorMessage}`};
}
}
export async function getNoteContent(
args?: Record<string, unknown>,
): Promise<GetNoteContentResult> {
const startedAt = Date.now();
logger.debug("get_content.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"};
}
try {
const content = await readFile(noteFilePath, "utf-8");
const normalizedFileName = path.basename(noteFilePath);
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
logger.debug("get_content.done", {fileName: normalizedFileName, relativePath, chars: content.length, duration: logger.duration(startedAt)});
return {
success: true,
fileName: normalizedFileName,
filePath: noteFilePath,
relativePath,
title: extractNoteTitle(normalizedFileName, content),
content,
};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to read note: ${errorMessage}`};
}
}
function extractNoteTitle(fileName: string, content: string): string {
const headingMatch = content.match(/^#\s+(.+)$/m);
const heading = headingMatch?.[1]?.trim();
if (heading) {
return heading;
}
return path.basename(fileName, ".md");
}
export function buildSafeNoteFilePath(fileName: string): string | null {
const normalizedFileName = fileName.endsWith(".md") ? fileName : `${fileName}.md`;
if (!normalizedFileName.trim().length) {
return null;
}
const unsafeFileNamePattern = /[/\\:*?"<>|\x00-\x1F]/;
if (unsafeFileNamePattern.test(normalizedFileName)) {
return null;
}
const resolvedNotesDir = path.resolve(notesDir);
const resolvedFilePath = path.resolve(notesDir, normalizedFileName);
if (!resolvedFilePath.startsWith(resolvedNotesDir + path.sep)) {
return null;
}
return resolvedFilePath;
}
export type UpdateNoteContentResult =
| { success: true; filePath: string }
| { success: false; error: string };
export type DeleteNoteResult =
| { success: true; filePath: string }
| { success: false; error: string };
export const updateNoteContentTool = {
type: "function",
function: {
name: "update_note_content",
description: "Update the full Markdown content of an existing note by its file name.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description:
"The file name of the note to update. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
},
content: {
type: "string",
description:
"The new full content of the note formatted as valid Markdown. This replaces the previous content completely.",
},
},
required: ["fileName", "content"],
},
},
} satisfies AiTool;
export const deleteNoteTool = {
type: "function",
function: {
name: "delete_note",
description: "Delete an existing Markdown note by its file name and remove its link from the notes root file if present.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description:
"The file name of the note to delete. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
},
},
required: ["fileName"],
},
},
} satisfies AiTool;
export async function updateNoteContent(
args?: Record<string, unknown>,
): Promise<UpdateNoteContentResult> {
const startedAt = Date.now();
logger.debug("update_content.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
const content = asNonEmptyString(args?.content) ?? "";
if (!content.trim().length) {
return {success: false, error: "No content provided"};
}
const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"};
}
try {
await readFile(noteFilePath, "utf-8");
await writeFile(noteFilePath, content, "utf-8");
logger.debug("update_content.done", {fileName, filePath: noteFilePath, chars: content.length, duration: logger.duration(startedAt)});
return {success: true, filePath: noteFilePath};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to update note: ${errorMessage}`};
}
}
export async function deleteNote(
args?: Record<string, unknown>,
): Promise<DeleteNoteResult> {
const startedAt = Date.now();
logger.debug("delete.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"};
}
try {
await unlink(noteFilePath);
await removeNoteLinkFromRoot(noteFilePath);
logger.debug("delete.done", {fileName, filePath: noteFilePath, duration: logger.duration(startedAt)});
return {success: true, filePath: noteFilePath};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to delete note: ${errorMessage}`};
}
}
async function removeNoteLinkFromRoot(noteFilePath: string): Promise<void> {
let rootContent: string;
try {
rootContent = await readFile(notesRootFile, "utf-8");
} catch {
return;
}
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
const normalizedRelativePath = relativePath.replaceAll("\\", "\\\\");
const escapedRelativePath = escapeRegExp(normalizedRelativePath);
const linkLinePattern = new RegExp(
`^\\s*[-*]\\s+\\[[^\\]]+]\\(${escapedRelativePath}\\)\\s*$\\n?`,
"gm",
);
const updatedRootContent = rootContent.replace(linkLinePattern, "");
if (updatedRootContent !== rootContent) {
await writeFile(notesRootFile, updatedRootContent.trimEnd() + "\n", "utf-8");
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}