set up agentic thinking

This commit is contained in:
perf3ct 2025-03-19 16:19:48 +00:00
parent 1a8ce967d9
commit f6afb1d963
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
7 changed files with 1807 additions and 0 deletions

View File

@ -0,0 +1,361 @@
/**
* Contextual Thinking Tool
*
* Provides a way for the LLM agent to expose its reasoning process to the user,
* showing how it explores knowledge and reaches conclusions. This makes the
* agent's thinking more transparent and allows users to understand the context
* behind answers.
*
* Features:
* - Capture and structure the agent's thinking steps
* - Visualize reasoning chains for complex queries
* - Expose confidence levels for different assertions
* - Show how different sources of evidence are weighed
*/
import log from '../../log.js';
/**
* Represents a single reasoning step taken by the agent
*/
export interface ThinkingStep {
id: string;
content: string;
type: 'observation' | 'hypothesis' | 'question' | 'evidence' | 'conclusion';
confidence?: number;
sources?: string[];
parentId?: string;
children?: string[];
metadata?: Record<string, any>;
}
/**
* Contains the full reasoning process
*/
export interface ThinkingProcess {
id: string;
query: string;
steps: ThinkingStep[];
status: 'in_progress' | 'completed';
startTime: number;
endTime?: number;
}
export class ContextualThinkingTool {
private static thinkingCounter = 0;
private static stepCounter = 0;
private activeProcId?: string;
private processes: Record<string, ThinkingProcess> = {};
/**
* Start a new thinking process for a given query
*
* @param query The user query that initiated the thinking process
* @returns The ID of the new thinking process
*/
startThinking(query: string): string {
const id = this.generateProcessId();
this.processes[id] = {
id,
query,
steps: [],
status: 'in_progress',
startTime: Date.now()
};
this.activeProcId = id;
return id;
}
/**
* Add a thinking step to the current active process
*
* @param content The content of the thinking step
* @param type The type of thinking step
* @param options Additional options for the step
* @returns The ID of the new step
*/
addThinkingStep(
content: string,
type: ThinkingStep['type'],
options: {
confidence?: number;
sources?: string[];
parentId?: string;
metadata?: Record<string, any>;
} = {}
): string | null {
if (!this.activeProcId || !this.processes[this.activeProcId]) {
log.error("No active thinking process to add step to");
return null;
}
const stepId = this.generateStepId();
const step: ThinkingStep = {
id: stepId,
content,
type,
...options
};
// Add to parent's children if a parent is specified
if (options.parentId) {
const parentIdx = this.processes[this.activeProcId].steps.findIndex(
s => s.id === options.parentId
);
if (parentIdx >= 0) {
const parent = this.processes[this.activeProcId].steps[parentIdx];
if (!parent.children) {
parent.children = [];
}
parent.children.push(stepId);
this.processes[this.activeProcId].steps[parentIdx] = parent;
}
}
this.processes[this.activeProcId].steps.push(step);
return stepId;
}
/**
* Complete the current thinking process
*
* @param processId The ID of the process to complete (defaults to active process)
* @returns The completed thinking process
*/
completeThinking(processId?: string): ThinkingProcess | null {
const id = processId || this.activeProcId;
if (!id || !this.processes[id]) {
log.error(`Thinking process ${id} not found`);
return null;
}
this.processes[id].status = 'completed';
this.processes[id].endTime = Date.now();
if (id === this.activeProcId) {
this.activeProcId = undefined;
}
return this.processes[id];
}
/**
* Get a thinking process by ID
*/
getThinkingProcess(processId: string): ThinkingProcess | null {
return this.processes[processId] || null;
}
/**
* Get the active thinking process
*/
getActiveThinkingProcess(): ThinkingProcess | null {
if (!this.activeProcId) return null;
return this.processes[this.activeProcId] || null;
}
/**
* Generate a user-friendly HTML representation of the thinking process
*
* @param processId The ID of the process to visualize
* @returns HTML string representing the thinking process
*/
visualizeThinking(processId: string): string {
const process = this.getThinkingProcess(processId);
if (!process) {
return `<div class="thinking-error">Thinking process ${processId} not found</div>`;
}
let html = `
<div class="thinking-process">
<div class="thinking-header">
<h3>Thinking Process for: "${process.query}"</h3>
<div class="thinking-metadata">
<span>Status: ${process.status}</span>
<span>Steps: ${process.steps.length}</span>
<span>Time: ${this.formatDuration(process.startTime, process.endTime || Date.now())}</span>
</div>
</div>
<div class="thinking-steps">
`;
// Find root steps (those without parents)
const rootSteps = process.steps.filter(step => !step.parentId);
// Recursively render the thinking tree
for (const rootStep of rootSteps) {
html += this.renderStepTree(rootStep, process.steps);
}
html += `
</div>
</div>
`;
return html;
}
/**
* Generate a concise text representation of the thinking process
* that can be displayed inline in the chat for transparency
*
* @param processId The ID of the process to summarize
* @returns Text summary of the reasoning process
*/
getThinkingSummary(processId?: string): string {
const id = processId || this.activeProcId;
if (!id || !this.processes[id]) {
return "No thinking process available.";
}
const process = this.processes[id];
let summary = `Thinking about: "${process.query}"\n\n`;
// Group steps by type
const stepsByType: Record<string, ThinkingStep[]> = {};
for (const step of process.steps) {
if (!stepsByType[step.type]) {
stepsByType[step.type] = [];
}
stepsByType[step.type].push(step);
}
// Show observations first
if (stepsByType['observation'] && stepsByType['observation'].length > 0) {
summary += "🔍 Observations:\n";
for (const step of stepsByType['observation'].slice(0, 3)) {
summary += `- ${step.content}\n`;
}
if (stepsByType['observation'].length > 3) {
summary += `- ...and ${stepsByType['observation'].length - 3} more observations\n`;
}
summary += "\n";
}
// Show questions the agent asked itself
if (stepsByType['question'] && stepsByType['question'].length > 0) {
summary += "❓ Questions considered:\n";
for (const step of stepsByType['question'].slice(0, 3)) {
summary += `- ${step.content}\n`;
}
if (stepsByType['question'].length > 3) {
summary += `- ...and ${stepsByType['question'].length - 3} more questions\n`;
}
summary += "\n";
}
// Show evidence
if (stepsByType['evidence'] && stepsByType['evidence'].length > 0) {
summary += "📋 Evidence found:\n";
for (const step of stepsByType['evidence'].slice(0, 3)) {
summary += `- ${step.content}\n`;
}
if (stepsByType['evidence'].length > 3) {
summary += `- ...and ${stepsByType['evidence'].length - 3} more pieces of evidence\n`;
}
summary += "\n";
}
// Show conclusions
if (stepsByType['conclusion'] && stepsByType['conclusion'].length > 0) {
summary += "✅ Conclusions:\n";
for (const step of stepsByType['conclusion']) {
const confidence = step.confidence ? ` (${Math.round(step.confidence * 100)}% confidence)` : '';
summary += `- ${step.content}${confidence}\n`;
}
}
return summary;
}
/**
* Reset the active thinking process
*/
resetActiveThinking(): void {
this.activeProcId = undefined;
}
/**
* Generate a unique ID for a thinking process
*/
private generateProcessId(): string {
return `thinking_${Date.now()}_${ContextualThinkingTool.thinkingCounter++}`;
}
/**
* Generate a unique ID for a thinking step
*/
private generateStepId(): string {
return `step_${Date.now()}_${ContextualThinkingTool.stepCounter++}`;
}
/**
* Format duration between two timestamps
*/
private formatDuration(start: number, end: number): string {
const durationMs = end - start;
if (durationMs < 1000) {
return `${durationMs}ms`;
} else if (durationMs < 60000) {
return `${Math.round(durationMs / 1000)}s`;
} else {
return `${Math.round(durationMs / 60000)}m ${Math.round((durationMs % 60000) / 1000)}s`;
}
}
/**
* Recursively render a step and its children
*/
private renderStepTree(step: ThinkingStep, allSteps: ThinkingStep[]): string {
const typeIcons: Record<string, string> = {
'observation': '🔍',
'hypothesis': '🤔',
'question': '❓',
'evidence': '📋',
'conclusion': '✅'
};
const icon = typeIcons[step.type] || '•';
const confidenceDisplay = step.confidence !== undefined
? `<span class="confidence">${Math.round(step.confidence * 100)}%</span>`
: '';
let html = `
<div class="thinking-step thinking-${step.type}">
<div class="step-header">
<span class="step-icon">${icon}</span>
<span class="step-type">${step.type}</span>
${confidenceDisplay}
</div>
<div class="step-content">${step.content}</div>
`;
// Add sources if available
if (step.sources && step.sources.length > 0) {
html += `<div class="step-sources">Sources: ${step.sources.join(', ')}</div>`;
}
// Recursively render children
if (step.children && step.children.length > 0) {
html += `<div class="step-children">`;
for (const childId of step.children) {
const childStep = allSteps.find(s => s.id === childId);
if (childStep) {
html += this.renderStepTree(childStep, allSteps);
}
}
html += `</div>`;
}
html += `</div>`;
return html;
}
}
export default ContextualThinkingTool;

View File

@ -0,0 +1,130 @@
/**
* Agent Tools Index
*
* This file exports all available agent tools for use by the LLM.
* Tools are prioritized in order of importance/impact.
*/
import { VectorSearchTool } from './vector_search_tool.js';
import { NoteNavigatorTool } from './note_navigator_tool.js';
import { QueryDecompositionTool } from './query_decomposition_tool.js';
import { ContextualThinkingTool } from './contextual_thinking_tool.js';
// Import services needed for initialization
import SemanticContextService from '../semantic_context_service.js';
import aiServiceManager from '../ai_service_manager.js';
import log from '../../log.js';
/**
* Manages all agent tools and provides a unified interface for the LLM agent
*/
export class AgentToolsManager {
private vectorSearchTool: VectorSearchTool | null = null;
private noteNavigatorTool: NoteNavigatorTool | null = null;
private queryDecompositionTool: QueryDecompositionTool | null = null;
private contextualThinkingTool: ContextualThinkingTool | null = null;
private initialized = false;
constructor() {
// Initialize tools only when requested to avoid circular dependencies
}
async initialize(aiServiceManager: any): Promise<void> {
try {
if (this.initialized) {
return;
}
log.info("Initializing LLM agent tools...");
// Create tools
this.vectorSearchTool = new VectorSearchTool();
this.noteNavigatorTool = new NoteNavigatorTool();
this.queryDecompositionTool = new QueryDecompositionTool();
this.contextualThinkingTool = new ContextualThinkingTool();
// Get semantic context service and set it in the vector search tool
const semanticContext = aiServiceManager.getSemanticContextService();
this.vectorSearchTool.setSemanticContext(semanticContext);
this.initialized = true;
log.info("LLM agent tools initialized successfully");
} catch (error: any) {
log.error(`Failed to initialize LLM agent tools: ${error.message}`);
throw new Error(`Agent tools initialization failed: ${error.message}`);
}
}
isInitialized(): boolean {
return this.initialized;
}
/**
* Get all available agent tools
* @returns Object containing all initialized tools
*/
getAllTools() {
if (!this.initialized) {
throw new Error("Agent tools not initialized. Call initialize() first.");
}
return {
vectorSearch: this.vectorSearchTool,
noteNavigator: this.noteNavigatorTool,
queryDecomposition: this.queryDecompositionTool,
contextualThinking: this.contextualThinkingTool
};
}
/**
* Get the vector search tool
*/
getVectorSearchTool(): VectorSearchTool {
if (!this.initialized || !this.vectorSearchTool) {
throw new Error("Vector search tool not initialized");
}
return this.vectorSearchTool;
}
/**
* Get the note structure navigator tool
*/
getNoteNavigatorTool(): NoteNavigatorTool {
if (!this.initialized || !this.noteNavigatorTool) {
throw new Error("Note navigator tool not initialized");
}
return this.noteNavigatorTool;
}
/**
* Get the query decomposition tool
*/
getQueryDecompositionTool(): QueryDecompositionTool {
if (!this.initialized || !this.queryDecompositionTool) {
throw new Error("Query decomposition tool not initialized");
}
return this.queryDecompositionTool;
}
/**
* Get the contextual thinking tool
*/
getContextualThinkingTool(): ContextualThinkingTool {
if (!this.initialized || !this.contextualThinkingTool) {
throw new Error("Contextual thinking tool not initialized");
}
return this.contextualThinkingTool;
}
}
// Export a singleton instance
const agentTools = new AgentToolsManager();
export default agentTools;
// Also export individual tool classes for direct use if needed
export {
VectorSearchTool,
NoteNavigatorTool,
QueryDecompositionTool,
ContextualThinkingTool
};

View 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;

View File

@ -0,0 +1,390 @@
/**
* Query Decomposition Tool
*
* This tool helps the LLM agent break down complex user queries into
* sub-questions that can be answered individually and then synthesized
* into a comprehensive response.
*
* Features:
* - Analyze query complexity
* - Extract multiple intents from a single question
* - Create a multi-stage research plan
* - Track progress through complex information gathering
*/
import log from '../../log.js';
export interface SubQuery {
id: string;
text: string;
reason: string;
isAnswered: boolean;
answer?: string;
}
export interface DecomposedQuery {
originalQuery: string;
subQueries: SubQuery[];
status: 'pending' | 'in_progress' | 'completed';
complexity: number;
}
export class QueryDecompositionTool {
private static queryCounter: number = 0;
/**
* Break down a complex query into smaller, more manageable sub-queries
*
* @param query The original user query
* @param context Optional context about the current note being viewed
* @returns A decomposed query object with sub-queries
*/
decomposeQuery(query: string, context?: string): DecomposedQuery {
try {
// Assess query complexity to determine if decomposition is needed
const complexity = this.assessQueryComplexity(query);
// For simple queries, just return the original as a single sub-query
if (complexity < 3) {
return {
originalQuery: query,
subQueries: [{
id: this.generateSubQueryId(),
text: query,
reason: 'Direct question that can be answered without decomposition',
isAnswered: false
}],
status: 'pending',
complexity
};
}
// For complex queries, perform decomposition
const subQueries = this.createSubQueries(query, context);
return {
originalQuery: query,
subQueries,
status: 'pending',
complexity
};
} catch (error: any) {
log.error(`Error decomposing query: ${error.message}`);
// Fallback to treating it as a simple query
return {
originalQuery: query,
subQueries: [{
id: this.generateSubQueryId(),
text: query,
reason: 'Error in decomposition, treating as simple query',
isAnswered: false
}],
status: 'pending',
complexity: 1
};
}
}
/**
* Update a sub-query with its answer
*
* @param decomposedQuery The decomposed query object
* @param subQueryId The ID of the sub-query to update
* @param answer The answer to the sub-query
* @returns The updated decomposed query
*/
updateSubQueryAnswer(
decomposedQuery: DecomposedQuery,
subQueryId: string,
answer: string
): DecomposedQuery {
const updatedSubQueries = decomposedQuery.subQueries.map(sq => {
if (sq.id === subQueryId) {
return {
...sq,
answer,
isAnswered: true
};
}
return sq;
});
// Check if all sub-queries are answered
const allAnswered = updatedSubQueries.every(sq => sq.isAnswered);
return {
...decomposedQuery,
subQueries: updatedSubQueries,
status: allAnswered ? 'completed' : 'in_progress'
};
}
/**
* Synthesize all sub-query answers into a comprehensive response
*
* @param decomposedQuery The decomposed query with all sub-queries answered
* @returns A synthesized answer to the original query
*/
synthesizeAnswer(decomposedQuery: DecomposedQuery): string {
try {
// Ensure all sub-queries are answered
if (!decomposedQuery.subQueries.every(sq => sq.isAnswered)) {
return "Cannot synthesize answer - not all sub-queries have been answered.";
}
// For simple queries with just one sub-query, return the answer directly
if (decomposedQuery.subQueries.length === 1) {
return decomposedQuery.subQueries[0].answer || "";
}
// For complex queries, build a structured response that references each sub-answer
let synthesized = `Answer to: "${decomposedQuery.originalQuery}"\n\n`;
// Group by themes if there are many sub-queries
if (decomposedQuery.subQueries.length > 3) {
// Here we would ideally group related sub-queries, but for now we'll just present them in order
synthesized += "Based on the information gathered:\n\n";
for (const sq of decomposedQuery.subQueries) {
synthesized += `${sq.answer}\n\n`;
}
} else {
// For fewer sub-queries, present each one with its question
for (const sq of decomposedQuery.subQueries) {
synthesized += `${sq.answer}\n\n`;
}
}
return synthesized.trim();
} catch (error: any) {
log.error(`Error synthesizing answer: ${error.message}`);
return "Error synthesizing the final answer.";
}
}
/**
* Generate a status report on the progress of answering a complex query
*
* @param decomposedQuery The decomposed query
* @returns A status report string
*/
getQueryStatus(decomposedQuery: DecomposedQuery): string {
const answeredCount = decomposedQuery.subQueries.filter(sq => sq.isAnswered).length;
const totalCount = decomposedQuery.subQueries.length;
let status = `Progress: ${answeredCount}/${totalCount} sub-queries answered\n\n`;
for (const sq of decomposedQuery.subQueries) {
status += `${sq.isAnswered ? '✓' : '○'} ${sq.text}\n`;
if (sq.isAnswered) {
status += ` Answer: ${this.truncateText(sq.answer || "", 100)}\n`;
}
}
return status;
}
/**
* Assess the complexity of a query on a scale of 1-10
* This helps determine how many sub-queries are needed
*
* @param query The query to assess
* @returns A complexity score from 1-10
*/
assessQueryComplexity(query: string): number {
// Count the number of question marks as a basic indicator
const questionMarkCount = (query.match(/\?/g) || []).length;
// Count potential sub-questions based on question words
const questionWords = ['what', 'how', 'why', 'where', 'when', 'who', 'which'];
const questionWordMatches = questionWords.map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'gi');
return (query.match(regex) || []).length;
});
const questionWordCount = questionWordMatches.reduce((sum, count) => sum + count, 0);
// Look for conjunctions which might join multiple questions
const conjunctionCount = (query.match(/\b(and|or|but|as well as)\b/gi) || []).length;
// Look for complex requirements
const comparisonCount = (query.match(/\b(compare|versus|vs|difference|similarities?)\b/gi) || []).length;
const analysisCount = (query.match(/\b(analyze|examine|investigate|explore|explain|discuss)\b/gi) || []).length;
// Calculate base complexity
let complexity = 1;
// Add for multiple questions
complexity += Math.min(2, questionMarkCount);
// Add for question words beyond the first one
complexity += Math.min(2, Math.max(0, questionWordCount - 1));
// Add for conjunctions that might join questions
complexity += Math.min(2, conjunctionCount);
// Add for comparative/analytical requirements
complexity += Math.min(2, comparisonCount + analysisCount);
// Add for overall length/complexity
if (query.length > 100) complexity += 1;
if (query.length > 200) complexity += 1;
// Ensure we stay in the 1-10 range
return Math.max(1, Math.min(10, complexity));
}
/**
* Generate a unique ID for a sub-query
*/
private generateSubQueryId(): string {
return `sq_${Date.now()}_${QueryDecompositionTool.queryCounter++}`;
}
/**
* Create sub-queries based on the original query and optional context
*/
private createSubQueries(query: string, context?: string): SubQuery[] {
const subQueries: SubQuery[] = [];
// Simple heuristics for breaking down the query
// In a real implementation, this would be much more sophisticated,
// using natural language understanding to identify different intents
// 1. Look for multiple question marks
const questionSplit = query.split(/\?/).filter(q => q.trim().length > 0);
if (questionSplit.length > 1) {
// Multiple distinct questions detected
for (let i = 0; i < questionSplit.length; i++) {
const text = questionSplit[i].trim() + '?';
subQueries.push({
id: this.generateSubQueryId(),
text,
reason: `Separate question ${i+1} detected in the original query`,
isAnswered: false
});
}
return subQueries;
}
// 2. Look for "and", "or", etc. connecting potentially separate questions
const conjunctions = [
{ regex: /\b(compare|versus|vs\.?|difference between|similarities between)\b/i, label: 'comparison' },
{ regex: /\b(list|enumerate)\b/i, label: 'listing' },
{ regex: /\b(analyze|examine|investigate|explore)\b/i, label: 'analysis' },
{ regex: /\b(explain|why)\b/i, label: 'explanation' },
{ regex: /\b(how to|steps to|process of)\b/i, label: 'procedure' }
];
// Check for comparison queries - these often need multiple sub-queries
for (const conj of conjunctions) {
if (conj.regex.test(query)) {
if (conj.label === 'comparison') {
// For comparisons, we need to research each item, then compare them
const comparisonMatch = query.match(/\b(compare|versus|vs\.?|difference between|similarities between)\s+(.+?)\s+(and|with|to)\s+(.+?)(\?|$)/i);
if (comparisonMatch) {
const item1 = comparisonMatch[2].trim();
const item2 = comparisonMatch[4].trim();
subQueries.push({
id: this.generateSubQueryId(),
text: `What are the key characteristics of ${item1}?`,
reason: `Need to understand ${item1} for the comparison`,
isAnswered: false
});
subQueries.push({
id: this.generateSubQueryId(),
text: `What are the key characteristics of ${item2}?`,
reason: `Need to understand ${item2} for the comparison`,
isAnswered: false
});
subQueries.push({
id: this.generateSubQueryId(),
text: `What are the main differences and similarities between ${item1} and ${item2}?`,
reason: 'Direct comparison after understanding each item',
isAnswered: false
});
return subQueries;
}
}
}
}
// 3. For complex questions without clear separation, create topic-based sub-queries
if (query.length > 100) {
// Extract potential key topics from the query
const words = query.toLowerCase().split(/\W+/).filter(w =>
w.length > 3 &&
!['what', 'when', 'where', 'which', 'with', 'would', 'could', 'should', 'have', 'this', 'that', 'there', 'their'].includes(w)
);
// Count word frequencies
const wordFrequency: Record<string, number> = {};
for (const word of words) {
wordFrequency[word] = (wordFrequency[word] || 0) + 1;
}
// Get top frequent words
const topWords = Object.entries(wordFrequency)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(entry => entry[0]);
if (topWords.length > 0) {
// Create factual sub-query
subQueries.push({
id: this.generateSubQueryId(),
text: `What are the key facts about ${topWords.join(' and ')} relevant to this question?`,
reason: 'Gathering basic information about main topics',
isAnswered: false
});
// Create relationship sub-query if multiple top words
if (topWords.length > 1) {
subQueries.push({
id: this.generateSubQueryId(),
text: `How do ${topWords.join(' and ')} relate to each other?`,
reason: 'Understanding relationships between key topics',
isAnswered: false
});
}
// Add the original query as the final synthesizing question
subQueries.push({
id: this.generateSubQueryId(),
text: query,
reason: 'Original question to be answered after gathering information',
isAnswered: false
});
return subQueries;
}
}
// Fallback: If we can't meaningfully decompose, just use the original query
subQueries.push({
id: this.generateSubQueryId(),
text: query,
reason: 'Question treated as a single unit',
isAnswered: false
});
return subQueries;
}
/**
* Truncate text to a maximum length with ellipsis
*/
private truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
}
export default QueryDecompositionTool;

View File

@ -0,0 +1,185 @@
/**
* Vector Search Tool
*
* This tool enables the LLM agent to perform semantic vector-based searches
* over the content in the notes database. It handles:
* - Finding semantically related notes to a query
* - Extracting relevant sections from notes
* - Providing relevant context for LLM to generate accurate responses
*
* The tool uses embeddings to find notes with similar semantic meaning,
* allowing the LLM to find relevant information even when exact keywords
* are not present.
*/
import log from '../../log.js';
// Define interface for semantic context service to avoid circular imports
interface ISemanticContextService {
semanticSearch(query: string, options: any): Promise<any[]>;
semanticSearchChunks(query: string, options: any): Promise<any[]>;
}
export interface VectorSearchResult {
noteId: string;
title: string;
contentPreview: string;
similarity: number;
parentId?: string;
dateCreated?: string;
dateModified?: string;
}
export interface SearchResultItem {
noteId: string;
noteTitle: string;
contentPreview: string;
similarity: number;
parentId?: string;
dateCreated?: string;
dateModified?: string;
}
export interface ChunkSearchResultItem {
noteId: string;
noteTitle: string;
chunk: string;
similarity: number;
parentId?: string;
}
export class VectorSearchTool {
private semanticContext: ISemanticContextService | null = null;
private maxResults: number = 5;
constructor() {
// The semantic context will be set later via setSemanticContext
}
/**
* Set the semantic context service instance
*/
setSemanticContext(semanticContext: ISemanticContextService): void {
this.semanticContext = semanticContext;
}
/**
* Search for notes semantically related to a query
*/
async searchNotes(query: string, options: {
parentNoteId?: string,
maxResults?: number,
similarityThreshold?: number
} = {}): Promise<VectorSearchResult[]> {
try {
if (!this.semanticContext) {
throw new Error("Semantic context service not set. Call setSemanticContext() first.");
}
if (!query || query.trim().length === 0) {
return [];
}
const maxResults = options.maxResults || this.maxResults;
const similarityThreshold = options.similarityThreshold || 0.65; // Default threshold
const parentNoteId = options.parentNoteId; // Optional filtering by parent
// Search notes using the semantic context service
const results = await this.semanticContext.semanticSearch(query, {
maxResults,
similarityThreshold,
ancestorNoteId: parentNoteId
});
if (!results || results.length === 0) {
return [];
}
// Transform results to the tool's format
return results.map((result: SearchResultItem) => ({
noteId: result.noteId,
title: result.noteTitle,
contentPreview: result.contentPreview,
similarity: result.similarity,
parentId: result.parentId,
dateCreated: result.dateCreated,
dateModified: result.dateModified
}));
} catch (error: any) {
log.error(`Error in vector search: ${error.message}`);
return [];
}
}
/**
* Search for content chunks within notes that are semantically related to a query
*/
async searchContentChunks(query: string, options: {
noteId?: string,
maxResults?: number,
similarityThreshold?: number
} = {}): Promise<VectorSearchResult[]> {
try {
if (!this.semanticContext) {
throw new Error("Semantic context service not set. Call setSemanticContext() first.");
}
if (!query || query.trim().length === 0) {
return [];
}
const maxResults = options.maxResults || this.maxResults;
const similarityThreshold = options.similarityThreshold || 0.70; // Higher threshold for chunks
const noteId = options.noteId; // Optional filtering by specific note
// Search content chunks using the semantic context service
const results = await this.semanticContext.semanticSearchChunks(query, {
maxResults,
similarityThreshold,
noteId
});
if (!results || results.length === 0) {
return [];
}
// Transform results to the tool's format
return results.map((result: ChunkSearchResultItem) => ({
noteId: result.noteId,
title: result.noteTitle,
contentPreview: result.chunk, // Use the chunk content as preview
similarity: result.similarity,
parentId: result.parentId
}));
} catch (error: any) {
log.error(`Error in content chunk search: ${error.message}`);
return [];
}
}
/**
* Elaborate on why certain results were returned for a query
*/
explainResults(query: string, results: VectorSearchResult[]): string {
if (!query || !results || results.length === 0) {
return "No results to explain.";
}
let explanation = `For query "${query}", I found these semantically related notes:\n\n`;
results.forEach((result, index) => {
explanation += `${index + 1}. "${result.title}" (similarity: ${(result.similarity * 100).toFixed(1)}%)\n`;
explanation += ` Preview: ${result.contentPreview.substring(0, 150)}...\n`;
if (index < results.length - 1) {
explanation += "\n";
}
});
explanation += "\nThese results were found based on semantic similarity rather than just keyword matching.";
return explanation;
}
}
export default VectorSearchTool;

View File

@ -8,6 +8,7 @@ import { ContextExtractor } from './context/index.js';
import semanticContextService from './semantic_context_service.js'; import semanticContextService from './semantic_context_service.js';
import indexService from './index_service.js'; import indexService from './index_service.js';
import { getEmbeddingProvider, getEnabledEmbeddingProviders } from './embeddings/providers.js'; import { getEmbeddingProvider, getEnabledEmbeddingProviders } from './embeddings/providers.js';
import agentTools from './agent_tools/index.js';
type ServiceProviders = 'openai' | 'anthropic' | 'ollama'; type ServiceProviders = 'openai' | 'anthropic' | 'ollama';
@ -281,6 +282,128 @@ export class AIServiceManager {
getIndexService() { getIndexService() {
return indexService; return indexService;
} }
/**
* Initialize agent tools for enhanced LLM features
*/
async initializeAgentTools(): Promise<void> {
try {
await agentTools.initialize(this);
log.info("Agent tools initialized successfully");
} catch (error: any) {
log.error(`Error initializing agent tools: ${error.message}`);
}
}
/**
* Get the agent tools manager
* This provides access to all agent tools
*/
getAgentTools() {
return agentTools;
}
/**
* Get the vector search tool for semantic similarity search
*/
getVectorSearchTool() {
return agentTools.getVectorSearchTool();
}
/**
* Get the note navigator tool for hierarchical exploration
*/
getNoteNavigatorTool() {
return agentTools.getNoteNavigatorTool();
}
/**
* Get the query decomposition tool for complex queries
*/
getQueryDecompositionTool() {
return agentTools.getQueryDecompositionTool();
}
/**
* Get the contextual thinking tool for transparent reasoning
*/
getContextualThinkingTool() {
return agentTools.getContextualThinkingTool();
}
/**
* Get whether AI features are enabled from options
*/
getAIEnabled(): boolean {
return options.getOptionBool('aiEnabled');
}
/**
* Set up embeddings provider for AI features
*/
async setupEmbeddingsProvider(): Promise<void> {
try {
if (!this.getAIEnabled()) {
log.info('AI features are disabled');
return;
}
const preferredProvider = options.getOption('embeddingsDefaultProvider') || 'openai';
// Check if we have enabled providers
const enabledProviders = await getEnabledEmbeddingProviders();
if (enabledProviders.length === 0) {
log.info('No embedding providers are enabled');
return;
}
// Validate that preferred provider is enabled
const isPreferredEnabled = enabledProviders.some(p => p.name === preferredProvider);
if (!isPreferredEnabled) {
log.info(`Preferred provider "${preferredProvider}" is not enabled. Using first available.`);
}
// Initialize embedding providers
log.info('Embedding providers initialized successfully');
} catch (error: any) {
log.error(`Error setting up embedding providers: ${error.message}`);
throw error;
}
}
/**
* Initialize the AI Service
*/
async initialize(): Promise<void> {
try {
log.info("Initializing AI service...");
// Check if AI is enabled in options
const isAIEnabled = this.getAIEnabled();
if (!isAIEnabled) {
log.info("AI features are disabled in options");
return;
}
// Set up embeddings provider if AI is enabled
await this.setupEmbeddingsProvider();
// Initialize index service
await this.getIndexService().initialize();
// Initialize agent tools with this service manager instance
await agentTools.initialize(this);
this.initialized = true;
log.info("AI service initialized successfully");
} catch (error: any) {
log.error(`Error initializing AI service: ${error.message}`);
throw error;
}
}
} }
// Don't create singleton immediately, use a lazy-loading pattern // Don't create singleton immediately, use a lazy-loading pattern
@ -321,6 +444,26 @@ export default {
}, },
getIndexService() { getIndexService() {
return getInstance().getIndexService(); return getInstance().getIndexService();
},
// Agent tools related methods
async initializeAgentTools(): Promise<void> {
const manager = getInstance();
return manager.initializeAgentTools();
},
getAgentTools() {
return getInstance().getAgentTools();
},
getVectorSearchTool() {
return getInstance().getVectorSearchTool();
},
getNoteNavigatorTool() {
return getInstance().getNoteNavigatorTool();
},
getQueryDecompositionTool() {
return getInstance().getQueryDecompositionTool();
},
getContextualThinkingTool() {
return getInstance().getContextualThinkingTool();
} }
}; };

View File

@ -6,6 +6,7 @@ import log from "../log.js";
import type { Message } from "./ai_interface.js"; import type { Message } from "./ai_interface.js";
import { cosineSimilarity } from "./embeddings/index.js"; import { cosineSimilarity } from "./embeddings/index.js";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import aiServiceManager from "./ai_service_manager.js";
/** /**
* TriliumContextService provides intelligent context management for working with large knowledge bases * TriliumContextService provides intelligent context management for working with large knowledge bases
@ -617,6 +618,140 @@ Example: ["exact topic mentioned", "related concept 1", "related concept 2"]`;
}; };
} }
} }
/**
* Enhance LLM context with agent tools
*
* This adds context from agent tools such as:
* 1. Vector search results relevant to the query
* 2. Note hierarchy information
* 3. Query decomposition planning
* 4. Contextual thinking visualization
*
* @param noteId The current note being viewed
* @param query The user's query
* @param showThinking Whether to include the agent's thinking process
* @returns Enhanced context string
*/
async getAgentToolsContext(noteId: string, query: string, showThinking: boolean = false): Promise<string> {
try {
const agentTools = aiServiceManager.getAgentTools();
let context = "";
// 1. Get vector search results related to the query
try {
const vectorSearchTool = agentTools.getVectorSearchTool();
const searchResults = await vectorSearchTool.searchNotes(query, {
parentNoteId: noteId,
maxResults: 5
});
if (searchResults.length > 0) {
context += "## Related Information\n\n";
for (const result of searchResults) {
context += `### ${result.title}\n`;
context += `${result.contentPreview}\n\n`;
}
context += "\n";
}
} catch (error: any) {
log.error(`Error getting vector search context: ${error.message}`);
}
// 2. Get note structure context
try {
const navigatorTool = agentTools.getNoteNavigatorTool();
const noteContext = navigatorTool.getNoteContextDescription(noteId);
if (noteContext) {
context += "## Current Note Context\n\n";
context += noteContext + "\n\n";
}
} catch (error: any) {
log.error(`Error getting note structure context: ${error.message}`);
}
// 3. Use query decomposition if it's a complex query
try {
const decompositionTool = agentTools.getQueryDecompositionTool();
const complexity = decompositionTool.assessQueryComplexity(query);
if (complexity > 5) { // Only for fairly complex queries
const decomposed = decompositionTool.decomposeQuery(query);
if (decomposed.subQueries.length > 1) {
context += "## Query Analysis\n\n";
context += `This is a complex query (complexity: ${complexity}/10). It can be broken down into:\n\n`;
for (const sq of decomposed.subQueries) {
context += `- ${sq.text}\n Reason: ${sq.reason}\n\n`;
}
}
}
} catch (error: any) {
log.error(`Error decomposing query: ${error.message}`);
}
// 4. Show thinking process if enabled
if (showThinking) {
try {
const thinkingTool = agentTools.getContextualThinkingTool();
const thinkingId = thinkingTool.startThinking(query);
// Add a thinking step to demonstrate the feature
// In a real implementation, the LLM would add these steps
thinkingTool.addThinkingStep(
"Analyzing the query to understand what information is needed",
"observation",
{ confidence: 1.0 }
);
// Add sample thinking for the context
const parentId = thinkingTool.addThinkingStep(
"Looking for related notes in the knowledge base",
"hypothesis",
{ confidence: 0.9 }
);
if (parentId) {
// Use the VectorSearchTool to find relevant notes
const vectorSearchTool = aiServiceManager.getVectorSearchTool();
const searchResults = await vectorSearchTool.searchNotes(query, {
parentNoteId: parentId,
maxResults: 5
});
if (searchResults.length > 0) {
context += "## Related Information\n\n";
for (const result of searchResults) {
context += `### ${result.title}\n`;
context += `${result.contentPreview}\n\n`;
}
context += "\n";
}
}
thinkingTool.addThinkingStep(
"The most relevant information appears to be in the current note and its semantic neighborhood",
"conclusion",
{ confidence: 0.85 }
);
// Complete the thinking and add it to context
thinkingTool.completeThinking(thinkingId);
context += "## Thinking Process\n\n";
context += thinkingTool.getThinkingSummary(thinkingId) + "\n\n";
} catch (error: any) {
log.error(`Error generating thinking process: ${error.message}`);
}
}
return context;
} catch (error: any) {
log.error(`Error getting agent tools context: ${error.message}`);
return "";
}
}
} }
export default new TriliumContextService(); export default new TriliumContextService();