shitton
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import path from "node:path";
|
||||
import {readFile, writeFile} from "node:fs/promises";
|
||||
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import fs from "node:fs";
|
||||
|
||||
export type CreateNoteResult =
|
||||
| { success: true; filePath: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export const createNoteTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_note",
|
||||
description: "Create a new Markdown note with a valid file name, optional title, and Markdown-formatted content.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description: "The valid file name for the note. It must be suitable for use as a file name and must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters. Use a clear, concise name based on the note topic. Include the .md extension if the user provides it or if Markdown files are expected."
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "The title of the note. Use a concise, human-readable title based on the user's request or the note content."
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description: "The full content of the note formatted as valid Markdown. Preserve existing Markdown formatting when provided. If the source content has little or no formatting, add appropriate Markdown structure such as headings, paragraphs, lists, links, code blocks, tables, or emphasis where useful, without changing the meaning."
|
||||
}
|
||||
},
|
||||
required: ["fileName", "content"],
|
||||
}
|
||||
}
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function createNote(
|
||||
args?: Record<string, unknown>
|
||||
): Promise<CreateNoteResult> {
|
||||
console.log("CREATE_NOTE; ARGS: ", args);
|
||||
|
||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||
if (!fileName.trim().length) {
|
||||
return {success: false, error: "No file name provided"};
|
||||
}
|
||||
const title = asNonEmptyString(args?.title) ?? fileName;
|
||||
|
||||
const content = asNonEmptyString(args?.content) ?? "";
|
||||
if (!content.trim().length) {
|
||||
return {success: false, error: "No content provided"};
|
||||
}
|
||||
|
||||
const newFilePath = path.join(notesDir, fileName.endsWith(".md") ? fileName : fileName + ".md");
|
||||
const linkMarkdown = `* [${title}](${path.relative(path.dirname(notesRootFile), newFilePath)})`;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(newFilePath)) {
|
||||
return {success: false, error: "File already exists"};
|
||||
}
|
||||
|
||||
await writeFile(newFilePath, content, "utf-8");
|
||||
|
||||
let rootContent: string;
|
||||
try {
|
||||
rootContent = await readFile(notesRootFile, "utf-8");
|
||||
} catch (e) {
|
||||
rootContent = "";
|
||||
}
|
||||
|
||||
const notesHeaderIndex = rootContent.indexOf(NOTES_HEADER);
|
||||
if (notesHeaderIndex >= 0) {
|
||||
rootContent += "\n" + linkMarkdown;
|
||||
} else {
|
||||
rootContent = NOTES_HEADER + "\n" + linkMarkdown;
|
||||
}
|
||||
|
||||
await writeFile(notesRootFile, rootContent, "utf-8");
|
||||
return {success: true, filePath: newFilePath};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to process files: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
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";
|
||||
|
||||
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> {
|
||||
console.log("LIST_NOTES");
|
||||
|
||||
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));
|
||||
|
||||
return {success: true, notes};
|
||||
} catch (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> {
|
||||
console.log("GET_NOTE_CONTENT; ARGS: ", 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);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileName: normalizedFileName,
|
||||
filePath: noteFilePath,
|
||||
relativePath,
|
||||
title: extractNoteTitle(normalizedFileName, content),
|
||||
content,
|
||||
};
|
||||
} catch (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> {
|
||||
console.log("UPDATE_NOTE_CONTENT; ARGS: ", 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");
|
||||
|
||||
return {success: true, filePath: noteFilePath};
|
||||
} catch (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> {
|
||||
console.log("DELETE_NOTE; ARGS: ", 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);
|
||||
|
||||
return {success: true, filePath: noteFilePath};
|
||||
} catch (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, "\\$&");
|
||||
}
|
||||
@@ -25,11 +25,31 @@ import {
|
||||
updateFile,
|
||||
updateFileTool
|
||||
} from "./file-system";
|
||||
import {createNote, createNoteTool} from "./create-note";
|
||||
import {
|
||||
deleteNote,
|
||||
deleteNoteTool,
|
||||
getNoteContent,
|
||||
getNoteContentTool,
|
||||
listNotes,
|
||||
listNotesTool,
|
||||
updateNoteContent,
|
||||
updateNoteContentTool
|
||||
} from "./list-notes";
|
||||
import {getNoteFile, getNoteFileTool} from "./send-note-file";
|
||||
import {searchNotes, searchNotesTool} from "./search-notes";
|
||||
|
||||
export const getTools = () => {
|
||||
const tools: AiTool[] = [
|
||||
getCurrentDateTimeTool,
|
||||
getMarketRatesTool,
|
||||
createNoteTool,
|
||||
listNotesTool,
|
||||
getNoteContentTool,
|
||||
updateNoteContentTool,
|
||||
deleteNoteTool,
|
||||
getNoteFileTool,
|
||||
searchNotesTool
|
||||
];
|
||||
|
||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||
@@ -61,13 +81,30 @@ export const getTools = () => {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return tools;
|
||||
// return [
|
||||
// createNoteTool,
|
||||
// listNotesTool,
|
||||
// getNoteContentTool,
|
||||
// updateNoteContentTool,
|
||||
// deleteNoteTool,
|
||||
// getNoteFileTool,
|
||||
// searchNotesTool
|
||||
// ];
|
||||
};
|
||||
|
||||
export const getToolHandlers = () => {
|
||||
let handlers: Record<string, ToolHandler> = {
|
||||
get_datetime: getCurrentDateTime,
|
||||
get_market_rates: getMarketRates,
|
||||
create_note: createNote,
|
||||
list_notes: listNotes,
|
||||
get_note_content: getNoteContent,
|
||||
update_note_content: updateNoteContent,
|
||||
delete_note: deleteNote,
|
||||
get_note_file: getNoteFile,
|
||||
search_notes: searchNotes
|
||||
};
|
||||
|
||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import path from "node:path";
|
||||
import {readdir, readFile} from "node:fs/promises";
|
||||
import {notesDir, notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
|
||||
export type SearchNoteMatchedField = "file_name" | "title" | "content";
|
||||
|
||||
export type SearchNoteItem = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
title: string;
|
||||
score: number;
|
||||
matchedFields: SearchNoteMatchedField[];
|
||||
snippet?: string;
|
||||
};
|
||||
|
||||
export type SearchNotesResult =
|
||||
| { success: true; results: SearchNoteItem[] }
|
||||
| { success: false; error: string };
|
||||
|
||||
export const searchNotesTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_notes",
|
||||
description:
|
||||
"Search Markdown notes by file name, note title, and full note content. Supports fuzzy matching. Use this when the user refers to a note by title, topic, partial title, approximate name, keyword, or something written inside the note. Returns success=true and results[], where each result contains fileName, title, score, matchedFields, relativePath, and optional snippet. Later note tools should use results[0].fileName unless multiple results are ambiguous.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description:
|
||||
"Search query for finding notes by file name, title, topic, keywords, or content. Can be partial, approximate, or contain typos. Use a short clean phrase, not the full user sentence.",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Maximum number of search results to return. Defaults to 3. Maximum is 10.",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
default: 3,
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function searchNotes(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<SearchNotesResult> {
|
||||
console.log("SEARCH_NOTES; ARGS: ", args);
|
||||
|
||||
const query = asNonEmptyString(args?.query) ?? "";
|
||||
if (!query.trim().length) {
|
||||
return {success: false, error: "No query provided"};
|
||||
}
|
||||
|
||||
const limit = parseSearchLimit(args?.limit);
|
||||
|
||||
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 = 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.
|
||||
}
|
||||
|
||||
const title = extractNoteTitle(fileName, content);
|
||||
const fileNameWithoutExtension = path.basename(fileName, ".md");
|
||||
|
||||
const fileNameScore = calculateFuzzyScore(query, fileNameWithoutExtension);
|
||||
const titleScore = calculateFuzzyScore(query, title);
|
||||
const contentScore = calculateContentScore(query, content);
|
||||
|
||||
const matchedFields: SearchNoteMatchedField[] = [];
|
||||
|
||||
if (fileNameScore > 0) {
|
||||
matchedFields.push("file_name");
|
||||
}
|
||||
|
||||
if (titleScore > 0) {
|
||||
matchedFields.push("title");
|
||||
}
|
||||
|
||||
if (contentScore > 0) {
|
||||
matchedFields.push("content");
|
||||
}
|
||||
|
||||
const score = Math.max(
|
||||
fileNameScore,
|
||||
titleScore,
|
||||
contentScore,
|
||||
);
|
||||
|
||||
return {
|
||||
fileName,
|
||||
filePath,
|
||||
relativePath,
|
||||
title,
|
||||
score,
|
||||
matchedFields,
|
||||
snippet:
|
||||
contentScore > 0
|
||||
? buildContentSnippet(query, content)
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const results = notes
|
||||
.filter((note) => note.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
|
||||
return {success: true, results};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to search notes: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
|
||||
function parseSearchLimit(value: unknown): number {
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number.parseInt(value, 10)
|
||||
: 3;
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(10, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
function calculateFuzzyScore(query: string, value: string): number {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
const normalizedValue = normalizeSearchText(value);
|
||||
|
||||
if (!normalizedQuery.length || !normalizedValue.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (normalizedValue === normalizedQuery) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (normalizedValue.startsWith(normalizedQuery)) {
|
||||
return 90;
|
||||
}
|
||||
|
||||
if (normalizedValue.includes(normalizedQuery)) {
|
||||
return 85;
|
||||
}
|
||||
|
||||
const queryWords = normalizedQuery.split(" ").filter(Boolean);
|
||||
const valueWords = normalizedValue.split(" ").filter(Boolean);
|
||||
|
||||
const wordMatchScore = calculateWordMatchScore(queryWords, valueWords);
|
||||
const subsequenceScore = isSubsequence(normalizedQuery, normalizedValue) ? 55 : 0;
|
||||
const distanceScore = calculateLevenshteinScore(normalizedQuery, normalizedValue);
|
||||
|
||||
return Math.max(wordMatchScore, subsequenceScore, distanceScore);
|
||||
}
|
||||
|
||||
function calculateContentScore(query: string, content: string): number {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
const normalizedContent = normalizeSearchText(content);
|
||||
|
||||
if (!normalizedQuery.length || !normalizedContent.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (normalizedContent.includes(normalizedQuery)) {
|
||||
return 70;
|
||||
}
|
||||
|
||||
const queryWords = normalizedQuery.split(" ").filter(Boolean);
|
||||
const contentWords = new Set(normalizedContent.split(" ").filter(Boolean));
|
||||
|
||||
if (!queryWords.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let matchedWords = 0;
|
||||
|
||||
for (const queryWord of queryWords) {
|
||||
if (contentWords.has(queryWord)) {
|
||||
matchedWords++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasPartialMatch = [...contentWords].some((contentWord) => {
|
||||
if (contentWord.includes(queryWord) || queryWord.includes(contentWord)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (queryWord.length < 4 || contentWord.length < 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const distance = levenshteinDistance(queryWord, contentWord);
|
||||
const maxLength = Math.max(queryWord.length, contentWord.length);
|
||||
const similarity = 1 - distance / maxLength;
|
||||
|
||||
return similarity >= 0.75;
|
||||
});
|
||||
|
||||
if (hasPartialMatch) {
|
||||
matchedWords += 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
const matchRatio = matchedWords / queryWords.length;
|
||||
|
||||
if (matchRatio <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(matchRatio * 60);
|
||||
}
|
||||
|
||||
function normalizeSearchText(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/ё/g, "е")
|
||||
.replace(/[^a-zа-я0-9\s-]/gi, " ")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function calculateWordMatchScore(queryWords: string[], valueWords: string[]): number {
|
||||
if (!queryWords.length || !valueWords.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let matchedWords = 0;
|
||||
|
||||
for (const queryWord of queryWords) {
|
||||
const bestWordScore = Math.max(
|
||||
...valueWords.map((valueWord) => {
|
||||
if (valueWord === queryWord) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (valueWord.startsWith(queryWord) || valueWord.includes(queryWord)) {
|
||||
return 0.85;
|
||||
}
|
||||
|
||||
const distance = levenshteinDistance(queryWord, valueWord);
|
||||
const maxLength = Math.max(queryWord.length, valueWord.length);
|
||||
const similarity = 1 - distance / maxLength;
|
||||
|
||||
return similarity >= 0.7 ? similarity : 0;
|
||||
}),
|
||||
);
|
||||
|
||||
if (bestWordScore > 0) {
|
||||
matchedWords += bestWordScore;
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = matchedWords / queryWords.length;
|
||||
return Math.round(ratio * 75);
|
||||
}
|
||||
|
||||
function calculateLevenshteinScore(query: string, value: string): number {
|
||||
const distance = levenshteinDistance(query, value);
|
||||
const maxLength = Math.max(query.length, value.length);
|
||||
|
||||
if (maxLength === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const similarity = 1 - distance / maxLength;
|
||||
|
||||
if (similarity < 0.45) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(similarity * 65);
|
||||
}
|
||||
|
||||
function isSubsequence(query: string, value: string): boolean {
|
||||
let queryIndex = 0;
|
||||
|
||||
for (const valueChar of value) {
|
||||
if (valueChar === query[queryIndex]) {
|
||||
queryIndex++;
|
||||
}
|
||||
|
||||
if (queryIndex === query.length) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
const matrix: number[][] = Array.from({length: a.length + 1}, () =>
|
||||
Array.from({length: b.length + 1}, () => 0),
|
||||
);
|
||||
|
||||
for (let i = 0; i <= a.length; i++) {
|
||||
matrix[i][0] = i;
|
||||
}
|
||||
|
||||
for (let j = 0; j <= b.length; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j - 1] + cost,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[a.length][b.length];
|
||||
}
|
||||
|
||||
function buildContentSnippet(query: string, content: string): string | undefined {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const normalizedContent = content.toLowerCase();
|
||||
|
||||
let matchIndex = normalizedContent.indexOf(normalizedQuery);
|
||||
|
||||
if (matchIndex < 0) {
|
||||
const queryWords = normalizeSearchText(query)
|
||||
.split(" ")
|
||||
.filter((word) => word.length >= 3);
|
||||
|
||||
for (const word of queryWords) {
|
||||
matchIndex = normalizedContent.indexOf(word);
|
||||
if (matchIndex >= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const snippetRadius = 120;
|
||||
const start = Math.max(0, matchIndex - snippetRadius);
|
||||
const end = Math.min(content.length, matchIndex + normalizedQuery.length + snippetRadius);
|
||||
|
||||
const prefix = start > 0 ? "..." : "";
|
||||
const suffix = end < content.length ? "..." : "";
|
||||
|
||||
return `${prefix}${content.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import path from "node:path";
|
||||
import {readFile, stat} from "node:fs/promises";
|
||||
import {notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import {buildSafeNoteFilePath} from "./list-notes";
|
||||
import z from "zod";
|
||||
|
||||
export type NoteFileAttachment = {
|
||||
type: "local_file";
|
||||
fileName: string;
|
||||
// filePath: string;
|
||||
relativePath: string;
|
||||
mimeType: "text/markdown";
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export type GetNoteFileResult =
|
||||
| {
|
||||
success: true;
|
||||
attachment: NoteFileAttachment;
|
||||
} | { success: false; error: string };
|
||||
|
||||
export const NoteFileAttachmentSchema = z.object({
|
||||
type: z.literal("local_file"),
|
||||
fileName: z.string(),
|
||||
// filePath: z.string(),
|
||||
relativePath: z.string(),
|
||||
mimeType: z.literal("text/markdown"),
|
||||
sizeBytes: z.number(),
|
||||
});
|
||||
|
||||
export const GetNoteFileResultSchema = z.discriminatedUnion("success", [
|
||||
z.object({
|
||||
success: z.literal(true),
|
||||
attachment: NoteFileAttachmentSchema,
|
||||
}),
|
||||
z.object({
|
||||
success: z.literal(false),
|
||||
error: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const getNoteFileTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_note_file",
|
||||
description:
|
||||
"Prepare a Markdown note file to be sent to the user as a .md attachment. Returns a local file descriptor that the host application should use to upload or send the file.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"The file name of the note to send. 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 getNoteFile(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<GetNoteFileResult> {
|
||||
console.log("GET_NOTE_FILE; ARGS: ", 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 readFile(noteFilePath, "utf-8");
|
||||
|
||||
const fileStat = await stat(noteFilePath);
|
||||
if (!fileStat.isFile()) {
|
||||
return {success: false, error: "Note path is not a file"};
|
||||
}
|
||||
|
||||
const normalizedFileName = path.basename(noteFilePath);
|
||||
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
|
||||
|
||||
const result: GetNoteFileResult = {
|
||||
success: true,
|
||||
attachment: {
|
||||
type: "local_file",
|
||||
fileName: normalizedFileName,
|
||||
// filePath: noteFilePath,
|
||||
relativePath,
|
||||
mimeType: "text/markdown",
|
||||
sizeBytes: fileStat.size,
|
||||
},
|
||||
};
|
||||
|
||||
console.log("GET_NOTE_FILE; RESULT: ", result);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to prepare note file: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -1,4 +1,5 @@
|
||||
import {Ollama} from "ollama";
|
||||
import {z} from "zod";
|
||||
|
||||
export function asNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
@@ -78,7 +79,7 @@ export async function unloadAllOllamaModels(ollama: Ollama, exceptFor?: string[]
|
||||
);
|
||||
|
||||
await Promise.all(unloadPromises);
|
||||
console.log("All models have been requested to unload" + exceptFor?.length ? ` except for [${exceptFor?.join(", ")}].` : ".");
|
||||
console.log("All models have been requested to unload" + (exceptFor?.length ? ` except for [${exceptFor?.join(", ")}].` : "."));
|
||||
} catch (error) {
|
||||
console.error("Error unloading models:", error);
|
||||
}
|
||||
@@ -100,3 +101,25 @@ export async function loadOllamaModel(model: string, ollama: Ollama, contextLeng
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type ToolPlanStep = {
|
||||
t: string;
|
||||
h: string;
|
||||
from: string;
|
||||
};
|
||||
|
||||
export type RouterPlan = {
|
||||
s: ToolPlanStep[];
|
||||
m: string;
|
||||
};
|
||||
|
||||
export const ToolPlanStepSchema = z.object({
|
||||
t: z.string(),
|
||||
h: z.string(),
|
||||
from: z.string(),
|
||||
});
|
||||
|
||||
export const RouterPlanSchema = z.object({
|
||||
s: z.array(ToolPlanStepSchema),
|
||||
m: z.string()
|
||||
});
|
||||
Reference in New Issue
Block a user