mirror of
https://github.com/zadam/trilium.git
synced 2025-11-06 21:36:05 +01:00
set up agentic thinking
This commit is contained in:
463
src/services/llm/agent_tools/note_navigator_tool.ts
Normal file
463
src/services/llm/agent_tools/note_navigator_tool.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user