mirror of
https://github.com/zadam/trilium.git
synced 2025-11-06 21:36:05 +01:00
464 lines
12 KiB
TypeScript
464 lines
12 KiB
TypeScript
|
|
/**
|
||
|
|
* Note Structure Navigator Tool
|
||
|
|
*
|
||
|
|
* This tool enables the LLM agent to navigate through the hierarchical
|
||
|
|
* structure of notes in the knowledge base. It provides methods for:
|
||
|
|
* - Finding paths between notes
|
||
|
|
* - Exploring parent-child relationships
|
||
|
|
* - Discovering note attributes and metadata
|
||
|
|
* - Understanding the context of a note within the broader structure
|
||
|
|
*
|
||
|
|
* This helps the LLM agent provide more accurate and contextually relevant responses.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import becca from '../../../becca/becca.js';
|
||
|
|
import log from '../../log.js';
|
||
|
|
import type BNote from '../../../becca/entities/bnote.js';
|
||
|
|
import type BAttribute from '../../../becca/entities/battribute.js';
|
||
|
|
|
||
|
|
export interface NoteInfo {
|
||
|
|
noteId: string;
|
||
|
|
title: string;
|
||
|
|
type: string;
|
||
|
|
mime?: string;
|
||
|
|
dateCreated?: string;
|
||
|
|
dateModified?: string;
|
||
|
|
isProtected: boolean;
|
||
|
|
isArchived: boolean;
|
||
|
|
attributeNames: string[];
|
||
|
|
hasChildren: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface NotePathInfo {
|
||
|
|
notePath: string[];
|
||
|
|
notePathTitles: string[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface NoteHierarchyLevel {
|
||
|
|
noteId: string;
|
||
|
|
title: string;
|
||
|
|
level: number;
|
||
|
|
children?: NoteHierarchyLevel[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export class NoteNavigatorTool {
|
||
|
|
private maxPathLength: number = 20;
|
||
|
|
private maxBreadth: number = 100;
|
||
|
|
private maxDepth: number = 5;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get detailed information about a note
|
||
|
|
*/
|
||
|
|
getNoteInfo(noteId: string): NoteInfo | null {
|
||
|
|
try {
|
||
|
|
const note = becca.notes[noteId];
|
||
|
|
if (!note) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get attribute names for this note
|
||
|
|
const attributeNames = note.ownedAttributes
|
||
|
|
.map(attr => attr.name)
|
||
|
|
.filter((value, index, self) => self.indexOf(value) === index); // unique values
|
||
|
|
|
||
|
|
return {
|
||
|
|
noteId: note.noteId,
|
||
|
|
title: note.title,
|
||
|
|
type: note.type,
|
||
|
|
mime: note.mime,
|
||
|
|
dateCreated: note.dateCreated,
|
||
|
|
dateModified: note.dateModified,
|
||
|
|
isProtected: note.isProtected ?? false,
|
||
|
|
isArchived: note.isArchived || false,
|
||
|
|
attributeNames,
|
||
|
|
hasChildren: note.children.length > 0
|
||
|
|
};
|
||
|
|
} catch (error: any) {
|
||
|
|
log.error(`Error getting note info: ${error.message}`);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get all paths to a note from the root
|
||
|
|
*/
|
||
|
|
getNotePathsFromRoot(noteId: string): NotePathInfo[] {
|
||
|
|
try {
|
||
|
|
const note = becca.notes[noteId];
|
||
|
|
if (!note) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get all possible paths to this note
|
||
|
|
const allPaths = note.getAllNotePaths();
|
||
|
|
if (!allPaths || allPaths.length === 0) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Convert path IDs to titles
|
||
|
|
return allPaths.map(path => {
|
||
|
|
const titles = path.map(id => {
|
||
|
|
const pathNote = becca.notes[id];
|
||
|
|
return pathNote ? pathNote.title : id;
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
notePath: path,
|
||
|
|
notePathTitles: titles
|
||
|
|
};
|
||
|
|
}).sort((a, b) => a.notePath.length - b.notePath.length); // Sort by path length, shortest first
|
||
|
|
} catch (error: any) {
|
||
|
|
log.error(`Error getting note paths: ${error.message}`);
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the parent notes of a given note
|
||
|
|
*/
|
||
|
|
getParentNotes(noteId: string): NoteInfo[] {
|
||
|
|
try {
|
||
|
|
const note = becca.notes[noteId];
|
||
|
|
if (!note || !note.parents) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
return note.parents
|
||
|
|
.map(parent => this.getNoteInfo(parent.noteId))
|
||
|
|
.filter((info): info is NoteInfo => info !== null);
|
||
|
|
} catch (error: any) {
|
||
|
|
log.error(`Error getting parent notes: ${error.message}`);
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the children notes of a given note
|
||
|
|
*/
|
||
|
|
getChildNotes(noteId: string, maxChildren: number = this.maxBreadth): NoteInfo[] {
|
||
|
|
try {
|
||
|
|
const note = becca.notes[noteId];
|
||
|
|
if (!note || !note.children) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
return note.children
|
||
|
|
.slice(0, maxChildren)
|
||
|
|
.map(child => this.getNoteInfo(child.noteId))
|
||
|
|
.filter((info): info is NoteInfo => info !== null);
|
||
|
|
} catch (error: any) {
|
||
|
|
log.error(`Error getting child notes: ${error.message}`);
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get a note's hierarchy (children up to specified depth)
|
||
|
|
* This is useful for the LLM to understand the structure within a note's subtree
|
||
|
|
*/
|
||
|
|
getNoteHierarchy(noteId: string, depth: number = 2): NoteHierarchyLevel | null {
|
||
|
|
if (depth < 0 || depth > this.maxDepth) {
|
||
|
|
depth = this.maxDepth;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const note = becca.notes[noteId];
|
||
|
|
if (!note) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const result: NoteHierarchyLevel = {
|
||
|
|
noteId: note.noteId,
|
||
|
|
title: note.title,
|
||
|
|
level: 0
|
||
|
|
};
|
||
|
|
|
||
|
|
// Recursively get children if depth allows
|
||
|
|
if (depth > 0 && note.children.length > 0) {
|
||
|
|
result.children = note.children
|
||
|
|
.slice(0, this.maxBreadth)
|
||
|
|
.map(child => this._getHierarchyLevel(child.noteId, 1, depth))
|
||
|
|
.filter((node): node is NoteHierarchyLevel => node !== null);
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
} catch (error: any) {
|
||
|
|
log.error(`Error getting note hierarchy: ${error.message}`);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Recursive helper for getNoteHierarchy
|
||
|
|
*/
|
||
|
|
private _getHierarchyLevel(noteId: string, currentLevel: number, maxDepth: number): NoteHierarchyLevel | null {
|
||
|
|
try {
|
||
|
|
const note = becca.notes[noteId];
|
||
|
|
if (!note) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const result: NoteHierarchyLevel = {
|
||
|
|
noteId: note.noteId,
|
||
|
|
title: note.title,
|
||
|
|
level: currentLevel
|
||
|
|
};
|
||
|
|
|
||
|
|
// Recursively get children if depth allows
|
||
|
|
if (currentLevel < maxDepth && note.children.length > 0) {
|
||
|
|
result.children = note.children
|
||
|
|
.slice(0, this.maxBreadth)
|
||
|
|
.map(child => this._getHierarchyLevel(child.noteId, currentLevel + 1, maxDepth))
|
||
|
|
.filter((node): node is NoteHierarchyLevel => node !== null);
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
} catch (error) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get attributes of a note
|
||
|
|
*/
|
||
|
|
getNoteAttributes(noteId: string): BAttribute[] {
|
||
|
|
try {
|
||
|
|
const note = becca.notes[noteId];
|
||
|
|
if (!note) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
return note.ownedAttributes;
|
||
|
|
} catch (error: any) {
|
||
|
|
log.error(`Error getting note attributes: ${error.message}`);
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find the shortest path between two notes
|
||
|
|
*/
|
||
|
|
findPathBetweenNotes(fromNoteId: string, toNoteId: string): NotePathInfo | null {
|
||
|
|
try {
|
||
|
|
if (fromNoteId === toNoteId) {
|
||
|
|
const note = becca.notes[fromNoteId];
|
||
|
|
if (!note) return null;
|
||
|
|
|
||
|
|
return {
|
||
|
|
notePath: [fromNoteId],
|
||
|
|
notePathTitles: [note.title]
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Simple breadth-first search to find shortest path
|
||
|
|
const visited = new Set<string>();
|
||
|
|
const queue: Array<{noteId: string, path: string[], titles: string[]}> = [];
|
||
|
|
|
||
|
|
// Initialize with the starting note
|
||
|
|
const startNote = becca.notes[fromNoteId];
|
||
|
|
if (!startNote) return null;
|
||
|
|
|
||
|
|
queue.push({
|
||
|
|
noteId: fromNoteId,
|
||
|
|
path: [fromNoteId],
|
||
|
|
titles: [startNote.title]
|
||
|
|
});
|
||
|
|
|
||
|
|
visited.add(fromNoteId);
|
||
|
|
|
||
|
|
while (queue.length > 0 && queue[0].path.length <= this.maxPathLength) {
|
||
|
|
const {noteId, path, titles} = queue.shift()!;
|
||
|
|
const note = becca.notes[noteId];
|
||
|
|
|
||
|
|
if (!note) continue;
|
||
|
|
|
||
|
|
// Get IDs of all connected notes (parents and children)
|
||
|
|
const connections: string[] = [
|
||
|
|
...note.parents.map(p => p.noteId),
|
||
|
|
...note.children.map(c => c.noteId)
|
||
|
|
];
|
||
|
|
|
||
|
|
for (const connectedId of connections) {
|
||
|
|
if (visited.has(connectedId)) continue;
|
||
|
|
|
||
|
|
const connectedNote = becca.notes[connectedId];
|
||
|
|
if (!connectedNote) continue;
|
||
|
|
|
||
|
|
const newPath = [...path, connectedId];
|
||
|
|
const newTitles = [...titles, connectedNote.title];
|
||
|
|
|
||
|
|
// Check if we found the target
|
||
|
|
if (connectedId === toNoteId) {
|
||
|
|
return {
|
||
|
|
notePath: newPath,
|
||
|
|
notePathTitles: newTitles
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Continue BFS
|
||
|
|
queue.push({
|
||
|
|
noteId: connectedId,
|
||
|
|
path: newPath,
|
||
|
|
titles: newTitles
|
||
|
|
});
|
||
|
|
|
||
|
|
visited.add(connectedId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// No path found
|
||
|
|
return null;
|
||
|
|
} catch (error: any) {
|
||
|
|
log.error(`Error finding path between notes: ${error.message}`);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Search for notes by title
|
||
|
|
*/
|
||
|
|
searchNotesByTitle(searchTerm: string, limit: number = 10): NoteInfo[] {
|
||
|
|
try {
|
||
|
|
if (!searchTerm || searchTerm.trim().length === 0) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
searchTerm = searchTerm.toLowerCase();
|
||
|
|
const results: NoteInfo[] = [];
|
||
|
|
|
||
|
|
// Simple in-memory search through all notes
|
||
|
|
for (const noteId in becca.notes) {
|
||
|
|
if (results.length >= limit) break;
|
||
|
|
|
||
|
|
const note = becca.notes[noteId];
|
||
|
|
if (!note || note.isDeleted) continue;
|
||
|
|
|
||
|
|
if (note.title.toLowerCase().includes(searchTerm)) {
|
||
|
|
const info = this.getNoteInfo(noteId);
|
||
|
|
if (info) results.push(info);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return results;
|
||
|
|
} catch (error: any) {
|
||
|
|
log.error(`Error searching notes by title: ${error.message}`);
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get clones of a note (if any)
|
||
|
|
*/
|
||
|
|
getNoteClones(noteId: string): NoteInfo[] {
|
||
|
|
try {
|
||
|
|
const note = becca.notes[noteId];
|
||
|
|
if (!note) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
// A note has clones if it has multiple parents
|
||
|
|
if (note.parents.length <= 1) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Return parent notes, which represent different contexts for this note
|
||
|
|
return this.getParentNotes(noteId);
|
||
|
|
} catch (error: any) {
|
||
|
|
log.error(`Error getting note clones: ${error.message}`);
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate a readable overview of a note's position in the hierarchy
|
||
|
|
* This is useful for the LLM to understand the context of a note
|
||
|
|
*/
|
||
|
|
getNoteContextDescription(noteId: string): string {
|
||
|
|
try {
|
||
|
|
const note = becca.notes[noteId];
|
||
|
|
if (!note) {
|
||
|
|
return "Note not found.";
|
||
|
|
}
|
||
|
|
|
||
|
|
const paths = this.getNotePathsFromRoot(noteId);
|
||
|
|
if (paths.length === 0) {
|
||
|
|
return `Note "${note.title}" exists but has no path from root.`;
|
||
|
|
}
|
||
|
|
|
||
|
|
let result = "";
|
||
|
|
|
||
|
|
// Basic note info
|
||
|
|
result += `Note: "${note.title}" (${note.type})\n`;
|
||
|
|
|
||
|
|
// Is it cloned?
|
||
|
|
if (paths.length > 1) {
|
||
|
|
result += `This note appears in ${paths.length} different locations:\n`;
|
||
|
|
|
||
|
|
// Show max 3 paths to avoid overwhelming context
|
||
|
|
for (let i = 0; i < Math.min(3, paths.length); i++) {
|
||
|
|
const path = paths[i];
|
||
|
|
result += `${i+1}. ${path.notePathTitles.join(' > ')}\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (paths.length > 3) {
|
||
|
|
result += `... and ${paths.length - 3} more locations\n`;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Just one path
|
||
|
|
const path = paths[0];
|
||
|
|
result += `Path: ${path.notePathTitles.join(' > ')}\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Children info
|
||
|
|
const children = this.getChildNotes(noteId, 5);
|
||
|
|
if (children.length > 0) {
|
||
|
|
result += `\nContains ${note.children.length} child notes`;
|
||
|
|
if (children.length < note.children.length) {
|
||
|
|
result += ` (showing first ${children.length})`;
|
||
|
|
}
|
||
|
|
result += `:\n`;
|
||
|
|
|
||
|
|
for (const child of children) {
|
||
|
|
result += `- ${child.title} (${child.type})\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (children.length < note.children.length) {
|
||
|
|
result += `... and ${note.children.length - children.length} more\n`;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
result += "\nThis note has no child notes.\n";
|
||
|
|
}
|
||
|
|
|
||
|
|
// Attributes summary
|
||
|
|
const attributes = this.getNoteAttributes(noteId);
|
||
|
|
if (attributes.length > 0) {
|
||
|
|
result += `\nNote has ${attributes.length} attributes.\n`;
|
||
|
|
|
||
|
|
// Group attributes by name
|
||
|
|
const attrMap: Record<string, string[]> = {};
|
||
|
|
for (const attr of attributes) {
|
||
|
|
if (!attrMap[attr.name]) {
|
||
|
|
attrMap[attr.name] = [];
|
||
|
|
}
|
||
|
|
attrMap[attr.name].push(attr.value);
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const [name, values] of Object.entries(attrMap)) {
|
||
|
|
if (values.length === 1) {
|
||
|
|
result += `- ${name}: ${values[0]}\n`;
|
||
|
|
} else {
|
||
|
|
result += `- ${name}: ${values.length} values\n`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
} catch (error: any) {
|
||
|
|
log.error(`Error getting note context: ${error.message}`);
|
||
|
|
return "Error generating note context description.";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export default NoteNavigatorTool;
|