318 lines
11 KiB
TypeScript
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, "\\$&");
|
|
} |