569 lines
16 KiB
TypeScript
569 lines
16 KiB
TypeScript
import { createClient, FileStat, ResponseDataDetailed } from "webdav";
|
|
import { z } from "zod";
|
|
import { zodFunction } from ".";
|
|
import { RunnableToolFunction } from "openai/lib/RunnableFunction.mjs";
|
|
import Fuse from "fuse.js";
|
|
import { ask, get_transcription } from "./ask";
|
|
import { Message } from "../interfaces/message";
|
|
import { memory_manager_guide, memory_manager_init } from "./memory-manager";
|
|
import { semantic_search_notes, syncVectorStore } from "./notes-vectors";
|
|
import { readFileSync, writeFileSync } from "fs";
|
|
import { join } from "path";
|
|
import { tmpdir } from "os";
|
|
|
|
// Initialize WebDAV client
|
|
const client = createClient("http://192.168.29.85/remote.php/dav/files/raj/", {
|
|
username: process.env.NEXTCLOUD_USERNAME!,
|
|
password: process.env.NEXTCLOUD_PASSWORD!,
|
|
});
|
|
|
|
// Types
|
|
export type OperationResult = { success: boolean; message: string | object };
|
|
|
|
// Schemas for function parameters
|
|
export const CreateFileParams = z.object({
|
|
path: z.string().describe("The path for the new file."),
|
|
content: z.string().describe("The content for the new file."),
|
|
});
|
|
export type CreateFileParams = z.infer<typeof CreateFileParams>;
|
|
|
|
export const CreateDirectoryParams = z.object({
|
|
path: z.string().describe("The path for the new directory."),
|
|
});
|
|
export type CreateDirectoryParams = z.infer<typeof CreateDirectoryParams>;
|
|
|
|
export const DeleteItemParams = z.object({
|
|
path: z.string().describe("The path to the file or directory to be deleted."),
|
|
});
|
|
export type DeleteItemParams = z.infer<typeof DeleteItemParams>;
|
|
|
|
export const MoveItemParams = z.object({
|
|
source_path: z
|
|
.string()
|
|
.describe("The current path of the file or directory."),
|
|
destination_path: z
|
|
.string()
|
|
.describe("The new path where the file or directory will be moved."),
|
|
});
|
|
export type MoveItemParams = z.infer<typeof MoveItemParams>;
|
|
|
|
export const SearchFilesParams = z.object({
|
|
query: z
|
|
.string()
|
|
.describe("The query string to search for file names or content."),
|
|
});
|
|
export type SearchFilesParams = z.infer<typeof SearchFilesParams>;
|
|
|
|
export const TagParams = z.object({
|
|
tag: z.string().describe("The tag to add to the file."),
|
|
});
|
|
export type TagParams = z.infer<typeof TagParams>;
|
|
|
|
export const FetchFileContentsParams = z.object({
|
|
path: z
|
|
.string()
|
|
.describe("The path to the file whose content is to be fetched."),
|
|
});
|
|
export type FetchFileContentsParams = z.infer<typeof FetchFileContentsParams>;
|
|
|
|
export const UpdateFileParams = z.object({
|
|
path: z.string().describe("The path to the note file to be updated."),
|
|
new_content: z
|
|
.string()
|
|
.describe("The new content to replace the existing content."),
|
|
});
|
|
export type UpdateFileParams = z.infer<typeof UpdateFileParams>;
|
|
|
|
export const NotesManagerParams = z.object({
|
|
request: z.string().describe("User's request regarding notes."),
|
|
});
|
|
export type NotesManagerParams = z.infer<typeof NotesManagerParams>;
|
|
|
|
export const SemanticSearchNotesParams = z.object({
|
|
query: z
|
|
.string()
|
|
.describe(
|
|
"The query to search for semantically similar notes, this can be something some content or even file name."
|
|
),
|
|
});
|
|
|
|
type SemanticSearchNotesParams = z.infer<typeof SemanticSearchNotesParams>;
|
|
|
|
async function semanticSearchNotes({
|
|
query,
|
|
}: SemanticSearchNotesParams): Promise<OperationResult> {
|
|
try {
|
|
const results = await semantic_search_notes(query, 4);
|
|
return {
|
|
success: true,
|
|
message: results.map((r) => r.pageContent),
|
|
};
|
|
} catch (error: any) {
|
|
return { success: false, message: error.message };
|
|
}
|
|
}
|
|
|
|
// Helper function to normalize paths
|
|
function normalizePath(path: string): string {
|
|
if (path.startsWith("/notes/")) return path.substring(7);
|
|
if (path.startsWith("notes/")) return path.substring(6);
|
|
if (path === "/notes" || path === "notes") return "";
|
|
return path;
|
|
}
|
|
|
|
// File and directory operations
|
|
export async function createFile({
|
|
path,
|
|
content,
|
|
}: CreateFileParams): Promise<OperationResult> {
|
|
try {
|
|
await client.putFileContents(`/notes/${normalizePath(path)}`, content);
|
|
await syncVectorStore();
|
|
return { success: true, message: "File created successfully" };
|
|
} catch (error: any) {
|
|
return { success: false, message: error.message };
|
|
}
|
|
}
|
|
|
|
export async function createDirectory({
|
|
path,
|
|
}: CreateDirectoryParams): Promise<OperationResult> {
|
|
try {
|
|
await client.createDirectory(`/notes/${normalizePath(path)}`);
|
|
await syncVectorStore();
|
|
return { success: true, message: "Directory created successfully" };
|
|
} catch (error: any) {
|
|
return { success: false, message: error.message };
|
|
}
|
|
}
|
|
|
|
export async function deleteItem({
|
|
path,
|
|
}: DeleteItemParams): Promise<OperationResult> {
|
|
try {
|
|
await client.deleteFile(`/notes/${normalizePath(path)}`);
|
|
await syncVectorStore();
|
|
return { success: true, message: "Deleted successfully" };
|
|
} catch (error: any) {
|
|
return { success: false, message: error.message };
|
|
}
|
|
}
|
|
|
|
export async function moveItem({
|
|
source_path,
|
|
destination_path,
|
|
}: MoveItemParams): Promise<OperationResult> {
|
|
try {
|
|
await client.moveFile(
|
|
`/notes/${normalizePath(source_path)}`,
|
|
`/notes/${normalizePath(destination_path)}`
|
|
);
|
|
await syncVectorStore();
|
|
return { success: true, message: "Moved successfully" };
|
|
} catch (error: any) {
|
|
return { success: false, message: error.message };
|
|
}
|
|
}
|
|
|
|
// Search functions
|
|
export async function searchFilesByContent({
|
|
query,
|
|
}: SearchFilesParams): Promise<OperationResult> {
|
|
try {
|
|
const files = await client.getDirectoryContents("notes", {
|
|
details: true,
|
|
deep: true,
|
|
});
|
|
|
|
const fileList = Array.isArray(files) ? files : files.data;
|
|
const matchingFiles: string[] = [];
|
|
|
|
// Search by filename using Fuse.js
|
|
const fuseFilename = new Fuse(fileList, {
|
|
keys: ["basename"],
|
|
threshold: 0.3,
|
|
});
|
|
const matchingFilesByName = fuseFilename
|
|
.search(query)
|
|
.map((result) => result.item.filename);
|
|
|
|
// Search by file content
|
|
for (const file of fileList) {
|
|
if (file.type === "file") {
|
|
const content = await client.getFileContents(file.filename, {
|
|
format: "text",
|
|
});
|
|
if (typeof content === "string" && content.includes(query)) {
|
|
matchingFiles.push(normalizePath(file.filename));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Combine and deduplicate results
|
|
const combinedResults = [
|
|
...new Set([...matchingFilesByName, ...matchingFiles]),
|
|
];
|
|
|
|
return {
|
|
success: true,
|
|
message:
|
|
combinedResults.length > 0
|
|
? combinedResults.join(", ")
|
|
: "No matching files found",
|
|
};
|
|
} catch (error: any) {
|
|
return { success: false, message: error.message };
|
|
}
|
|
}
|
|
|
|
export async function searchFilesByTag({ tag }: TagParams) {
|
|
const files = await client.getDirectoryContents("notes", {
|
|
details: true,
|
|
deep: true,
|
|
});
|
|
|
|
const fileList = Array.isArray(files) ? files : files.data;
|
|
const matchingFiles: Array<{ filename: string; content: string }> = [];
|
|
|
|
for (const file of fileList) {
|
|
if (file.type === "file") {
|
|
const fileContent = await client.getFileContents(file.filename, {
|
|
format: "text",
|
|
});
|
|
if (typeof fileContent === "string" && fileContent.includes(tag)) {
|
|
matchingFiles.push({ filename: file.filename, content: fileContent });
|
|
}
|
|
}
|
|
}
|
|
|
|
return matchingFiles;
|
|
}
|
|
|
|
// Notes list caching
|
|
let cachedNotesList: string | null = null;
|
|
let lastFetchTime: number | null = null;
|
|
|
|
export async function getNotesList(): Promise<OperationResult> {
|
|
try {
|
|
const currentTime = Date.now();
|
|
if (
|
|
cachedNotesList &&
|
|
lastFetchTime &&
|
|
currentTime - lastFetchTime < 5000
|
|
) {
|
|
return { success: true, message: cachedNotesList };
|
|
}
|
|
|
|
const directoryContents = await fetchDirectoryContents("notes");
|
|
const treeStructure = buildTree(directoryContents);
|
|
cachedNotesList = JSON.stringify(treeStructure, null, 2);
|
|
lastFetchTime = currentTime;
|
|
|
|
return { success: true, message: cachedNotesList };
|
|
} catch (error: any) {
|
|
return { success: false, message: error.message };
|
|
}
|
|
}
|
|
|
|
async function fetchDirectoryContents(path: string): Promise<FileStat[]> {
|
|
let contents = await client.getDirectoryContents(path);
|
|
|
|
// Normalize contents to always be an array of FileStat
|
|
if (!Array.isArray(contents)) {
|
|
contents = contents.data; // Assuming it's ResponseDataDetailed<FileStat[]>
|
|
}
|
|
|
|
// Recursively fetch the contents of subdirectories
|
|
for (const item of contents) {
|
|
if (item.type === "directory") {
|
|
const subdirectoryContents = await fetchDirectoryContents(item.filename);
|
|
contents = contents.concat(subdirectoryContents);
|
|
}
|
|
}
|
|
|
|
return contents;
|
|
}
|
|
|
|
function buildTree(files: any[]): any {
|
|
const tree: any = {};
|
|
|
|
files.forEach((file) => {
|
|
const parts: string[] = file.filename.replace(/^\/notes\//, "").split("/");
|
|
|
|
// Ignore files inside dot folders
|
|
if (parts.some((part) => part.startsWith(".obsidian"))) {
|
|
return;
|
|
}
|
|
|
|
let current = tree;
|
|
|
|
parts.forEach((part, index) => {
|
|
if (!current[part]) {
|
|
current[part] = index === parts.length - 1 ? null : {};
|
|
}
|
|
current = current[part];
|
|
});
|
|
});
|
|
|
|
return tree;
|
|
}
|
|
|
|
// File content operations
|
|
export async function fetchFileContents({
|
|
path,
|
|
}: FetchFileContentsParams): Promise<OperationResult> {
|
|
try {
|
|
const fileContent = await client.getFileContents(
|
|
`/notes/${normalizePath(path)}`,
|
|
{ format: "text", details: true }
|
|
);
|
|
|
|
if (typeof fileContent === "string") {
|
|
// Should not happen when details is true
|
|
return { success: true, message: fileContent };
|
|
} else if ("data" in fileContent) {
|
|
return { success: true, message: fileContent.data };
|
|
} else {
|
|
return {
|
|
success: false,
|
|
message: "Unexpected response format from getFileContents.",
|
|
};
|
|
}
|
|
} catch (error: any) {
|
|
return { success: false, message: error.message };
|
|
}
|
|
}
|
|
|
|
export async function updateNote({
|
|
path,
|
|
new_content,
|
|
}: UpdateFileParams): Promise<OperationResult> {
|
|
try {
|
|
await client.putFileContents(`/notes/${normalizePath(path)}`, new_content);
|
|
await syncVectorStore();
|
|
return { success: true, message: "Note updated successfully" };
|
|
} catch (error: any) {
|
|
return { success: false, message: error.message };
|
|
}
|
|
}
|
|
|
|
// Caching for tag-based searches
|
|
let cachedFiles: Array<{ filename: string; content: string }> | null = null;
|
|
let isUpdatingCache = false;
|
|
|
|
export async function searchFilesByTagWithCache({ tag }: TagParams) {
|
|
if (cachedFiles) {
|
|
if (!isUpdatingCache) {
|
|
console.log("Updating cache");
|
|
setTimeout(() => updateCache(tag), 0);
|
|
}
|
|
return cachedFiles;
|
|
}
|
|
|
|
cachedFiles = await updateCache(tag);
|
|
return cachedFiles;
|
|
}
|
|
|
|
async function updateCache(
|
|
tag: string
|
|
): Promise<Array<{ filename: string; content: string }>> {
|
|
if (isUpdatingCache) {
|
|
return cachedFiles || [];
|
|
}
|
|
|
|
isUpdatingCache = true;
|
|
const files = await client.getDirectoryContents("notes", {
|
|
details: true,
|
|
deep: true,
|
|
});
|
|
|
|
const fileList = Array.isArray(files) ? files : files.data;
|
|
const matchingFiles: Array<{ filename: string; content: string }> = [];
|
|
|
|
for (const file of fileList) {
|
|
if (
|
|
file.type === "file" &&
|
|
(file.filename.endsWith(".md") || file.filename.endsWith(".txt"))
|
|
) {
|
|
const fileContent = await client.getFileContents(file.filename, {
|
|
format: "text",
|
|
});
|
|
if (typeof fileContent === "string" && fileContent.includes(tag)) {
|
|
matchingFiles.push({ filename: file.filename, content: fileContent });
|
|
}
|
|
}
|
|
}
|
|
|
|
cachedFiles = matchingFiles;
|
|
isUpdatingCache = false;
|
|
return matchingFiles;
|
|
}
|
|
|
|
// Notes manager integration
|
|
export async function notesManager(
|
|
{ request }: NotesManagerParams,
|
|
context_message: Message
|
|
) {
|
|
const notesManagerPromptFiles = await searchFilesByTagWithCache({
|
|
tag: "#notes-manager",
|
|
});
|
|
|
|
const tools = webdav_tools.concat(
|
|
memory_manager_init(context_message, "notes_manager")
|
|
);
|
|
|
|
const potentially_relavent_files = await semantic_search_notes(request, 4);
|
|
const potentially_relavent_files_paths = potentially_relavent_files.map(
|
|
(f) => f.metadata.filename
|
|
);
|
|
|
|
const response = await ask({
|
|
model: "gpt-4o",
|
|
prompt: `You are an Obsidian vault manager.
|
|
|
|
Ensure the vault remains organized, filenames and paths are correct, and relavent files are linked to each other.
|
|
You can try creating canvas files that use the open json canvas format
|
|
|
|
- **Today's Date:** ${new Date().toDateString()}
|
|
|
|
- **ALL Vault's File structure for context:**
|
|
---
|
|
${(await getNotesList()).message}
|
|
---
|
|
${
|
|
potentially_relavent_files_paths.length > 0
|
|
? `
|
|
- **Potentially relevant files:**
|
|
|
|
You can use these files to get more context or to link to the notes you are creating/updating.
|
|
|
|
---
|
|
${potentially_relavent_files_paths.join("\n")}
|
|
---`
|
|
: ""
|
|
}
|
|
|
|
- **User Notes/Instructions for you:**
|
|
---
|
|
${notesManagerPromptFiles.map((f) => f.content).join("\n")}
|
|
---
|
|
|
|
Note: When the user is trying to create/add a note, check the templates directory for any relevant templates if available. If available, fetch the relevant template and create the note based on the template.
|
|
`,
|
|
message: request,
|
|
seed: `notes-${context_message.channelId}`,
|
|
tools: tools as any,
|
|
});
|
|
|
|
return { response };
|
|
}
|
|
|
|
// Schema for the transcription function parameters
|
|
export const TranscriptionParams = z.object({
|
|
file_path: z
|
|
.string()
|
|
.describe("The path to the audio file to be transcribed."),
|
|
});
|
|
export type TranscriptionParams = z.infer<typeof TranscriptionParams>;
|
|
|
|
// Tool for handling transcription requests
|
|
export async function transcribeAudioFile({
|
|
file_path,
|
|
}: TranscriptionParams): Promise<OperationResult> {
|
|
try {
|
|
// Download the audio file from WebDAV
|
|
const audioFileBuffer = await client.getFileContents(
|
|
`/notes/${normalizePath(file_path)}`,
|
|
{
|
|
format: "binary",
|
|
}
|
|
);
|
|
|
|
if (!Buffer.isBuffer(audioFileBuffer)) {
|
|
throw new Error("Failed to download audio file as Buffer.");
|
|
}
|
|
|
|
// Convert the Buffer to a base64 string
|
|
const audioFileBase64 = audioFileBuffer.toString("base64");
|
|
|
|
// Transcribe the audio file
|
|
const transcription = await get_transcription(
|
|
audioFileBase64,
|
|
true,
|
|
file_path
|
|
);
|
|
return {
|
|
success: true,
|
|
message: transcription,
|
|
};
|
|
} catch (error: any) {
|
|
return { success: false, message: error.message };
|
|
}
|
|
}
|
|
|
|
// Integration into runnable tools
|
|
export let webdav_tools: RunnableToolFunction<any>[] = [
|
|
zodFunction({
|
|
function: getNotesList,
|
|
name: "getNotesList",
|
|
schema: z.object({}),
|
|
description: "Get the list of note files and directories.",
|
|
}),
|
|
zodFunction({
|
|
function: transcribeAudioFile,
|
|
name: "transcribeAudioFile",
|
|
schema: TranscriptionParams,
|
|
description:
|
|
"Transcribe an audio file specified by the provided file path.",
|
|
}),
|
|
zodFunction({
|
|
function: fetchFileContents,
|
|
name: "fetchNoteFileContents",
|
|
schema: FetchFileContentsParams,
|
|
description: "Fetch the contents of a specific note file.",
|
|
}),
|
|
zodFunction({
|
|
function: createFile,
|
|
name: "createNoteFile",
|
|
schema: CreateFileParams,
|
|
description: "Create a new note file.",
|
|
}),
|
|
zodFunction({
|
|
function: updateNote,
|
|
name: "updateNote",
|
|
schema: UpdateFileParams,
|
|
description: "Update an existing note.",
|
|
}),
|
|
zodFunction({
|
|
function: createDirectory,
|
|
name: "createNoteDir",
|
|
schema: CreateDirectoryParams,
|
|
description: "Create a new directory in notes.",
|
|
}),
|
|
zodFunction({
|
|
function: deleteItem,
|
|
name: "deleteNoteItem",
|
|
schema: DeleteItemParams,
|
|
description: "Delete a note file or directory.",
|
|
}),
|
|
zodFunction({
|
|
function: moveItem,
|
|
name: "moveNote",
|
|
schema: MoveItemParams,
|
|
description: "Move a note file or directory.",
|
|
}),
|
|
zodFunction({
|
|
function: semanticSearchNotes,
|
|
name: "semanticSearchNotes",
|
|
schema: SemanticSearchNotesParams,
|
|
description: `Search notes by their semantically.
|
|
|
|
You can use this to search by:
|
|
1. Topic
|
|
2. Content
|
|
3. File Name
|
|
4. Tags
|
|
`,
|
|
}),
|
|
];
|