Initial commit: RA-H Open Source Edition

Local-first knowledge management system with BYO API keys.

Features:
- 3-panel UI (Nodes | Focus | Helpers)
- SQLite + sqlite-vec for vector search
- Agent system (Easy/Hard mode orchestrators)
- Content extraction (YouTube, PDF, web)
- Integrate workflow for connection discovery
- Dimension system with auto-assignment

Tech stack:
- Next.js 15 + TypeScript + Tailwind CSS
- Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK

Setup:
  npm install && npm rebuild better-sqlite3
  scripts/dev/bootstrap-local.sh
  npm run dev

MIT License
This commit is contained in:
“BeeRad”
2025-12-15 16:14:28 +11:00
commit 733d1c3407
226 changed files with 46231 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
import { tool } from 'ai';
import { z } from 'zod';
export const createDimensionTool = tool({
description: 'Create a new dimension or update existing dimension',
inputSchema: z.object({
name: z.string().describe('Dimension name'),
description: z.string().max(500).optional().describe('Dimension description (max 500 characters)'),
isPriority: z.boolean().optional().describe('Whether to lock this dimension for auto-assignment (default: false)')
}),
execute: async (params) => {
console.log('📁 CreateDimension tool called with params:', JSON.stringify(params, null, 2));
try {
const trimmedName = params.name.trim();
if (!trimmedName) {
return {
success: false,
error: 'Dimension name is required',
data: null
};
}
// Call POST /api/dimensions
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: trimmedName,
description: params.description?.trim() || null,
isPriority: params.isPriority || false
})
});
if (!response.ok) {
let errorMessage = 'Failed to create dimension';
try {
const errorResult = await response.json();
errorMessage = errorResult.error || errorMessage;
} catch {
// If response is not JSON (e.g., HTML error page), use status text
errorMessage = `Failed to create dimension: ${response.status} ${response.statusText}`;
}
return {
success: false,
error: errorMessage,
data: null
};
}
const result = await response.json();
return {
success: true,
data: result.data,
message: `Created dimension "${trimmedName}"${params.isPriority ? ' (locked)' : ''}${params.description ? ' with description' : ''}`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create dimension',
data: null
};
}
}
});
+121
View File
@@ -0,0 +1,121 @@
import { tool } from 'ai';
import { z } from 'zod';
import { edgeService } from '@/services/database/edges';
import { nodeService } from '@/services/database/nodes';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
export const createEdgeTool = tool({
description: 'Create directed relationship between nodes',
inputSchema: z.object({
from_node_id: z.number().describe('The ID of the source node (where the connection originates)'),
to_node_id: z.number().describe('The ID of the target node (where the connection points to)'),
context: z.record(z.any()).optional().describe('Additional context about this connection - can include explanation, relationship type, strength, notes, etc.'),
source: z.enum(['user', 'ai', 'ai_similarity', 'helper_name']).default('ai').describe('Source of this edge - use "ai" for AI-created connections, "user" for manual connections, "ai_similarity" for similarity-based connections')
}),
execute: async (params) => {
console.log('🔗 CreateEdge tool called with params:', JSON.stringify(params, null, 2));
try {
// Validate basic IDs
if (!Number.isFinite(params.from_node_id) || params.from_node_id <= 0) {
return {
success: false,
error: 'from_node_id must be a positive integer. Use queryNodes to confirm the source node ID before creating the edge.',
data: null,
};
}
if (!Number.isFinite(params.to_node_id) || params.to_node_id <= 0) {
return {
success: false,
error: 'to_node_id must be a positive integer. Run queryNodes to fetch the target node ID before creating the edge.',
data: null,
};
}
if (params.from_node_id === params.to_node_id) {
return {
success: false,
error: 'Cannot create edge from a node to itself',
data: null
};
}
const [fromNode, toNode] = await Promise.all([
nodeService.getNodeById(params.from_node_id),
nodeService.getNodeById(params.to_node_id)
]);
if (!fromNode) {
return {
success: false,
error: `Source node ${params.from_node_id} not found. Use queryNodes to confirm the ID before creating the edge.`,
data: null
};
}
if (!toNode) {
return {
success: false,
error: `Target node ${params.to_node_id} not found. Run queryNodes to fetch the correct ID before creating the edge.`,
data: null
};
}
// Check if edge already exists
const exists = await edgeService.edgeExists(params.from_node_id, params.to_node_id);
if (exists) {
return {
success: false,
error: `Edge already exists between node ${params.from_node_id} and node ${params.to_node_id}`,
data: null
};
}
// Normalize and create the edge
const source = (() => {
if (params.source === 'ai') return 'helper_name';
if (params.source === 'helper_name') return 'helper_name';
if (params.source === 'ai_similarity') return 'ai_similarity';
if (params.source === 'user') return 'user';
return 'helper_name';
})();
const newEdge = await edgeService.createEdge({
from_node_id: params.from_node_id,
to_node_id: params.to_node_id,
context: params.context || {},
source
});
const fromLabel = formatNodeForChat({
id: fromNode.id,
title: fromNode.title,
dimensions: fromNode.dimensions || []
});
const toLabel = formatNodeForChat({
id: toNode.id,
title: toNode.title,
dimensions: toNode.dimensions || []
});
return {
success: true,
data: newEdge,
message: `Created edge connection from ${fromLabel} to ${toLabel}`,
formatted_labels: {
from: fromLabel,
to: toLabel
}
};
} catch (error) {
console.error('CreateEdge tool error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create edge',
data: null
};
}
}
});
+71
View File
@@ -0,0 +1,71 @@
import { tool } from 'ai';
import { z } from 'zod';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
export const createNodeTool = tool({
description: 'Create node with title/content/link and optional dimensions (locked dimensions auto-assigned)',
inputSchema: z.object({
title: z.string().describe('The title of the node'),
content: z.string().optional().describe('The main content, description, or notes for this node'),
link: z.string().optional().describe('A URL link to the source'),
dimensions: z
.array(z.string())
.max(5)
.optional()
.describe('Optional dimension tags to apply to this node (0-5 items). Locked dimensions will be auto-assigned.'),
chunk: z.string().optional().describe('Raw content for later processing - CRITICAL for extracted content from URLs'),
metadata: z.record(z.any()).optional().describe('Additional metadata like source info, extraction details, etc.')
}),
execute: async (params) => {
console.log('🎯 CreateNode tool called with params:', JSON.stringify(params, null, 2));
try {
const rawDimensions = params.dimensions || [];
const trimmedDimensions = rawDimensions
.map(d => typeof d === 'string' ? d.trim() : '')
.filter(Boolean)
.slice(0, 5); // Limit to 5 dimensions max
// Call the nodes API endpoint
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...params, dimensions: trimmedDimensions })
});
const result = await response.json();
if (!response.ok) {
return {
success: false,
error: result.error || 'Failed to create node',
data: null
};
}
// Format the created node for chat display
const formattedDisplay = formatNodeForChat({
id: result.data.id,
title: result.data.title,
dimensions: result.data.dimensions || trimmedDimensions
});
return {
success: true,
data: {
...result.data,
formatted_display: formattedDisplay
},
message: `Created node ${formattedDisplay} with dimensions: ${result.data.dimensions ? result.data.dimensions.join(', ') : 'auto-assigned'}`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create node',
data: null
};
}
}
});
// Legacy export for backwards compatibility
export const createItemTool = createNodeTool;
+58
View File
@@ -0,0 +1,58 @@
import { tool } from 'ai';
import { z } from 'zod';
export const deleteDimensionTool = tool({
description: 'Delete a dimension and remove all node associations',
inputSchema: z.object({
name: z.string().describe('Dimension name to delete')
}),
execute: async (params) => {
console.log('🗑️ DeleteDimension tool called with params:', JSON.stringify(params, null, 2));
try {
const trimmedName = params.name.trim();
if (!trimmedName) {
return {
success: false,
error: 'Dimension name is required',
data: null
};
}
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions?name=${encodeURIComponent(trimmedName)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
let errorMessage = 'Failed to delete dimension';
try {
const errorResult = await response.json();
errorMessage = errorResult.error || errorMessage;
} catch {
// If response is not JSON (e.g., HTML error page), use status text
errorMessage = `Failed to delete dimension: ${response.status} ${response.statusText}`;
}
return {
success: false,
error: errorMessage,
data: null
};
}
const result = await response.json();
return {
success: true,
data: result.data,
message: `Deleted dimension "${trimmedName}" and removed all node associations`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete dimension',
data: null
};
}
}
});
+63
View File
@@ -0,0 +1,63 @@
import { tool } from 'ai';
import { z } from 'zod';
import { nodeService } from '@/services/database/nodes';
export const getNodesByIdTool = tool({
description: 'Load full node records by IDs',
inputSchema: z.object({
nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('List of node IDs to load'),
includeContentPreview: z.boolean().default(true).describe('Whether to return a trimmed content preview for each node'),
}),
execute: async ({ nodeIds, includeContentPreview }) => {
const uniqueIds = Array.from(new Set(nodeIds.filter(id => Number.isFinite(id) && id > 0)));
if (uniqueIds.length === 0) {
return {
success: false,
error: 'No valid node IDs provided',
data: { nodes: [] },
};
}
const nodes = await Promise.all(
uniqueIds.map(async id => {
try {
const node = await nodeService.getNodeById(id);
if (!node) return null;
const preview = includeContentPreview
? (node.content || node.description || '')
.split(/\s+/)
.slice(0, 80)
.join(' ')
.trim()
: undefined;
return {
id: node.id,
title: node.title,
link: node.link,
dimensions: node.dimensions || [],
chunk_status: node.chunk_status || 'unknown',
updated_at: node.updated_at,
content_preview: preview || null,
metadata: node.metadata ?? null,
};
} catch (error) {
console.warn(`getNodesByIdTool: failed to load node ${id}`, error);
return null;
}
})
);
const foundNodes = nodes.filter(Boolean);
return {
success: true,
data: {
nodes: foundNodes,
requested: uniqueIds,
missing: uniqueIds.filter(id => !foundNodes.find(node => node && node.id === id)),
},
message: `Loaded ${foundNodes.length} of ${uniqueIds.length} requested nodes.`,
};
},
});
+62
View File
@@ -0,0 +1,62 @@
import { tool } from 'ai';
import { z } from 'zod';
export const lockDimensionTool = tool({
description: 'Lock a dimension to enable auto-assignment to new nodes',
inputSchema: z.object({
name: z.string().describe('Dimension name to lock')
}),
execute: async (params) => {
console.log('🔒 LockDimension tool called with params:', JSON.stringify(params, null, 2));
try {
const trimmedName = params.name.trim();
if (!trimmedName) {
return {
success: false,
error: 'Dimension name is required',
data: null
};
}
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: trimmedName,
isPriority: true
})
});
if (!response.ok) {
let errorMessage = 'Failed to lock dimension';
try {
const errorResult = await response.json();
errorMessage = errorResult.error || errorMessage;
} catch {
// If response is not JSON (e.g., HTML error page), use status text
errorMessage = `Failed to lock dimension: ${response.status} ${response.statusText}`;
}
return {
success: false,
error: errorMessage,
data: null
};
}
const result = await response.json();
return {
success: true,
data: result.data,
message: `Locked dimension "${trimmedName}" - it will now be auto-assigned to new nodes`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to lock dimension',
data: null
};
}
}
});
+130
View File
@@ -0,0 +1,130 @@
import { tool } from 'ai';
import { z } from 'zod';
import { edgeService } from '@/services/database/edges';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
export const queryEdgeTool = tool({
description: 'Find edges by node/direction/source/ID',
inputSchema: z.object({
filters: z.object({
node_id: z.number().optional().describe('Get all edges connected to this specific node (both incoming and outgoing)'),
from_node_id: z.number().optional().describe('Get edges originating from this specific node'),
to_node_id: z.number().optional().describe('Get edges pointing to this specific node'),
source: z.enum(['user', 'ai_similarity', 'helper_name']).optional().describe('Filter edges by their source type'),
edge_id: z.number().optional().describe('Get a specific edge by its ID'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results to return')
}).optional().describe('Filters to apply when querying edges')
}),
execute: async ({ filters = {} }) => {
console.log('🔍 QueryEdge tool called with filters:', JSON.stringify(filters, null, 2));
try {
let result;
let message = '';
// Handle specific edge ID lookup
if (filters.edge_id) {
const edge = await edgeService.getEdgeById(filters.edge_id);
return {
success: true,
data: {
edges: edge ? [edge] : [],
count: edge ? 1 : 0,
filters_applied: filters
},
message: edge ? `Found edge ${filters.edge_id}` : `Edge ${filters.edge_id} not found`
};
}
// Handle node connections (most common use case)
if (filters.node_id) {
const connections = await edgeService.getNodeConnections(filters.node_id);
const edges = connections.map(conn => conn.edge);
// Apply additional filters if specified
let filteredEdges = edges;
if (filters.source) {
filteredEdges = edges.filter(edge => edge.source === filters.source);
}
// Apply limit and format connected nodes
const limitedConnections = connections.slice(0, filters.limit || 20);
const formattedConnections = limitedConnections.map(connection => {
const formattedNode = formatNodeForChat({
id: connection.connected_node.id,
title: connection.connected_node.title,
dimensions: connection.connected_node.dimensions || []
});
return {
...connection,
connected_node: {
...connection.connected_node,
formatted_display: formattedNode
}
};
});
// Create message with formatted connected nodes
const connectedNodeLabels = formattedConnections.map(conn => conn.connected_node.formatted_display).join(', ');
const message = `Found ${filteredEdges.length} edges for node ${filters.node_id}${connectedNodeLabels ? `. Connected nodes: ${connectedNodeLabels}` : ''}`;
return {
success: true,
data: {
edges: filteredEdges.slice(0, filters.limit || 20),
connections: formattedConnections,
count: filteredEdges.length,
filters_applied: filters
},
message: message
};
}
// Handle directional queries or get all edges
const allEdges = await edgeService.getEdges();
let filteredEdges = allEdges;
// Apply filters
if (filters.from_node_id) {
filteredEdges = filteredEdges.filter(edge => edge.from_node_id === filters.from_node_id);
message += `from node ${filters.from_node_id} `;
}
if (filters.to_node_id) {
filteredEdges = filteredEdges.filter(edge => edge.to_node_id === filters.to_node_id);
message += `to node ${filters.to_node_id} `;
}
if (filters.source) {
filteredEdges = filteredEdges.filter(edge => edge.source === filters.source);
message += `with source ${filters.source} `;
}
// Apply limit
const limitedEdges = filteredEdges.slice(0, filters.limit || 20);
return {
success: true,
data: {
edges: limitedEdges,
count: filteredEdges.length,
total_available: allEdges.length,
filters_applied: filters
},
message: `Found ${filteredEdges.length} edges ${message}`.trim()
};
} catch (error) {
console.error('QueryEdge tool error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to query edges',
data: {
edges: [],
count: 0,
filters_applied: filters
}
};
}
}
});
+121
View File
@@ -0,0 +1,121 @@
import { tool } from 'ai';
import { z } from 'zod';
import { nodeService } from '@/services/database/nodes';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import type { Node } from '@/types/database';
export const queryNodesTool = tool({
description: 'Search nodes by title/content/dimensions',
inputSchema: z.object({
filters: z.object({
dimensions: z.array(z.string()).describe('Filter by dimensions (e.g., ["research", "ai", "technology"]). Replaces old type/stage filtering.').optional(),
search: z.string().describe('Search term to match against title or content').optional(),
limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return')
}).optional()
}),
execute: async ({ filters = {} }) => {
console.log('🔍 QueryNodes tool called with filters:', JSON.stringify(filters, null, 2));
try {
const limit = filters.limit || 10;
const searchTerm = filters.search?.trim();
if (searchTerm && /^\d+$/.test(searchTerm)) {
const nodeId = Number(searchTerm);
const node = await nodeService.getNodeById(nodeId);
if (!node) {
return {
success: true,
data: {
nodes: [],
count: 0,
filters_applied: filters,
},
message: `Found 0 nodes matching id ${nodeId}`,
};
}
const formatted = formatNodeForChat({
id: node.id,
title: node.title,
dimensions: node.dimensions || [],
});
return {
success: true,
data: {
nodes: [{
id: node.id,
title: node.title,
dimensions: node.dimensions || [],
formatted_display: formatted,
}],
count: 1,
filters_applied: filters,
},
message: `Found 1 node matching id ${nodeId}:\n${formatted}`,
};
}
// Add timeout to prevent hanging
const timeoutPromise: Promise<Node[] | undefined> = new Promise((_, reject) => {
setTimeout(() => reject(new Error('QueryNodes timeout after 10 seconds')), 10000);
});
// Use new nodeService with dimension-based filtering
const nodesPromise: Promise<Node[] | undefined> = nodeService.getNodes({
limit,
dimensions: filters.dimensions,
search: filters.search
});
const nodes = await Promise.race<Node[] | undefined>([nodesPromise, timeoutPromise]);
// Handle the case where nodes might be undefined/null
const safeNodes: Node[] = Array.isArray(nodes) ? nodes : [];
const limitedNodes = safeNodes.slice(0, limit);
// Format nodes for chat display
const formattedNodes = limitedNodes.map(node => {
const formatted = formatNodeForChat({
id: node.id,
title: node.title,
dimensions: node.dimensions || []
});
return {
id: node.id,
title: node.title,
dimensions: node.dimensions || [],
formatted_display: formatted
};
});
// Create message with formatted node labels only (no full node payload)
const formattedLabels = formattedNodes.map(node => node.formatted_display).join(', ');
const message = `Found ${safeNodes.length} nodes${filters.dimensions ? ` with dimensions: ${filters.dimensions.join(', ')}` : ''}${filters.search ? ` matching: "${filters.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`;
return {
success: true,
data: {
nodes: formattedNodes,
count: safeNodes.length,
filters_applied: filters
},
message: message
};
} catch (error) {
console.error('QueryNodes tool error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to query nodes',
data: {
nodes: [],
count: 0,
filters_applied: filters
}
};
}
}
});
// Legacy export for backwards compatibility
export const queryItemsTool = queryNodesTool;
+62
View File
@@ -0,0 +1,62 @@
import { tool } from 'ai';
import { z } from 'zod';
export const unlockDimensionTool = tool({
description: 'Unlock a dimension to disable auto-assignment to new nodes',
inputSchema: z.object({
name: z.string().describe('Dimension name to unlock')
}),
execute: async (params) => {
console.log('🔓 UnlockDimension tool called with params:', JSON.stringify(params, null, 2));
try {
const trimmedName = params.name.trim();
if (!trimmedName) {
return {
success: false,
error: 'Dimension name is required',
data: null
};
}
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: trimmedName,
isPriority: false
})
});
if (!response.ok) {
let errorMessage = 'Failed to unlock dimension';
try {
const errorResult = await response.json();
errorMessage = errorResult.error || errorMessage;
} catch {
// If response is not JSON (e.g., HTML error page), use status text
errorMessage = `Failed to unlock dimension: ${response.status} ${response.statusText}`;
}
return {
success: false,
error: errorMessage,
data: null
};
}
const result = await response.json();
return {
success: true,
data: result.data,
message: `Unlocked dimension "${trimmedName}" - it will no longer be auto-assigned to new nodes`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to unlock dimension',
data: null
};
}
}
});
+81
View File
@@ -0,0 +1,81 @@
import { tool } from 'ai';
import { z } from 'zod';
export const updateDimensionTool = tool({
description: 'Update dimension name, description, or lock status',
inputSchema: z.object({
currentName: z.string().describe('Current dimension name'),
newName: z.string().optional().describe('New dimension name (if renaming)'),
description: z.string().max(500).optional().describe('New description (max 500 characters)'),
isPriority: z.boolean().optional().describe('Lock/unlock status (true = locked, false = unlocked)')
}),
execute: async (params) => {
console.log('📝 UpdateDimension tool called with params:', JSON.stringify(params, null, 2));
try {
// Validate at least one update field
if (!params.newName && params.description === undefined && params.isPriority === undefined) {
return {
success: false,
error: 'At least one update field (newName, description, or isPriority) must be provided',
data: null
};
}
// Handle rename + other updates
const body: any = {
currentName: params.currentName.trim(),
description: params.description?.trim() || ''
};
if (params.newName) {
body.newName = params.newName.trim();
}
if (params.isPriority !== undefined) {
body.isPriority = params.isPriority;
}
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
let errorMessage = 'Failed to update dimension';
try {
const errorResult = await response.json();
errorMessage = errorResult.error || errorMessage;
} catch {
// If response is not JSON (e.g., HTML error page), use status text
errorMessage = `Failed to update dimension: ${response.status} ${response.statusText}`;
}
return {
success: false,
error: errorMessage,
data: null
};
}
const result = await response.json();
const updates = [];
if (params.newName) updates.push(`renamed to "${params.newName}"`);
if (params.description !== undefined) updates.push('description updated');
if (params.isPriority !== undefined) updates.push(params.isPriority ? 'locked' : 'unlocked');
return {
success: true,
data: result.data,
message: `Updated dimension "${params.currentName}": ${updates.join(', ')}`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update dimension',
data: null
};
}
}
});
+67
View File
@@ -0,0 +1,67 @@
import { tool } from 'ai';
import { z } from 'zod';
import { edgeService } from '@/services/database/edges';
export const updateEdgeTool = tool({
description: 'Update edge context/source/feedback',
inputSchema: z.object({
edge_id: z.number().describe('The ID of the edge to update'),
updates: z.object({
context: z.record(z.any()).optional().describe('Updated context information for this edge - can include explanation, relationship type, strength, notes, etc.'),
source: z.enum(['user', 'ai_similarity', 'helper_name']).optional().describe('Updated source classification for this edge'),
user_feedback: z.boolean().optional().describe('User feedback on this edge connection (true = positive, false = negative)')
}).describe('Fields to update on the edge')
}),
execute: async (params) => {
console.log('📝 UpdateEdge tool called with params:', JSON.stringify(params, null, 2));
try {
// Validate that edge exists before updating
const existingEdge = await edgeService.getEdgeById(params.edge_id);
if (!existingEdge) {
return {
success: false,
error: `Edge with ID ${params.edge_id} not found`,
data: null
};
}
// Filter out undefined values from updates
const cleanUpdates = Object.fromEntries(
Object.entries(params.updates).filter(([_, value]) => value !== undefined)
);
if (Object.keys(cleanUpdates).length === 0) {
return {
success: false,
error: 'No valid updates provided',
data: existingEdge
};
}
// Update the edge
const updatedEdge = await edgeService.updateEdge(params.edge_id, cleanUpdates);
// Build descriptive message
const updateDescriptions = [];
if (cleanUpdates.context) updateDescriptions.push('context');
if (cleanUpdates.source) updateDescriptions.push(`source to ${cleanUpdates.source}`);
if (cleanUpdates.user_feedback !== undefined) {
updateDescriptions.push(`user feedback to ${cleanUpdates.user_feedback ? 'positive' : 'negative'}`);
}
return {
success: true,
data: updatedEdge,
message: `Updated edge ${params.edge_id}: ${updateDescriptions.join(', ')}`
};
} catch (error) {
console.error('UpdateEdge tool error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update edge',
data: null
};
}
}
});
+112
View File
@@ -0,0 +1,112 @@
import { tool } from 'ai';
import { z } from 'zod';
export const updateNodeTool = tool({
description: 'Update node fields',
inputSchema: z.object({
id: z.number().describe('The ID of the node to update'),
updates: z.object({
title: z.string().optional().describe('New title'),
content: z.string().optional().describe('New content/description/notes'),
link: z.string().optional().describe('New link'),
dimensions: z.array(z.string()).optional().describe('New dimension tags - completely replaces existing dimensions'),
chunk: z.string().optional().describe('New chunk content'),
metadata: z.record(z.any()).optional().describe('New metadata - completely replaces existing metadata')
}).describe('Object containing the fields to update')
}),
execute: async ({ id, updates }) => {
try {
if (!updates || Object.keys(updates).length === 0) {
return {
success: false,
error: 'updateNode requires at least one field in the updates object.',
data: null
};
}
// FORCE APPEND for content field - fetch existing and append new content
if (updates.content) {
const fetchResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes/${id}`);
if (fetchResponse.ok) {
const { node } = await fetchResponse.json();
const existingContent = (node?.content || '').trim();
const newContent = updates.content.trim();
// Skip if new content is identical to existing (model sent duplicate)
if (existingContent === newContent) {
console.log(`[updateNode] ERROR - new content identical to existing (${existingContent.length} chars). Model should NOT call updateNode again.`);
return {
success: false,
error: 'Content already up to date - do not call updateNode again. Move to next step.',
data: null
};
}
// Detect if adding a section that already exists (e.g., ## Integration Analysis)
const newSectionMatch = newContent.match(/^##\s+(.+)$/m);
if (newSectionMatch && existingContent) {
const sectionHeader = newSectionMatch[0]; // e.g., "## Integration Analysis"
if (existingContent.includes(sectionHeader)) {
console.log(`[updateNode] ERROR - Section "${sectionHeader}" already exists in node`);
return {
success: false,
error: `Section "${sectionHeader}" already exists in this node. Cannot append duplicate section.`,
data: null
};
}
}
// Detect if model included existing content + new content
if (existingContent && newContent.startsWith(existingContent)) {
// Extract only the new part
const actualNewContent = newContent.substring(existingContent.length).trim();
console.log(`[updateNode] Model included existing content - extracting new part only (${actualNewContent.length} chars)`);
const separator = existingContent.endsWith('\n\n') ? '' : '\n\n';
updates.content = `${existingContent}${separator}${actualNewContent}`;
} else if (existingContent) {
// Normal append
const separator = existingContent.endsWith('\n\n') ? '' : '\n\n';
updates.content = `${existingContent}${separator}${newContent}`;
console.log(`[updateNode] Appended content: ${existingContent.length} + ${newContent.length} = ${updates.content.length} chars`);
} else {
console.log(`[updateNode] No existing content, using new content as-is (${newContent.length} chars)`);
}
}
}
// No dimension validation - user has full control over dimensions
// Call the nodes API endpoint
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
const result = await response.json();
if (!response.ok) {
return {
success: false,
error: result.error || 'Failed to update node',
data: null
};
}
return {
success: true,
data: result.node,
message: `Updated node ID ${id}${updates.dimensions ? ` with dimensions: ${updates.dimensions.join(', ')}` : ''}`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update node',
data: null
};
}
}
});
// Legacy export for backwards compatibility
export const updateItemTool = updateNodeTool;
+2
View File
@@ -0,0 +1,2 @@
// Re-export everything from infrastructure
export * from './infrastructure';
+92
View File
@@ -0,0 +1,92 @@
// Tool group definitions and utilities
export interface ToolGroup {
id: string;
name: string;
description: string;
icon: string;
color: string;
}
export const TOOL_GROUPS: Record<string, ToolGroup> = {
core: {
id: 'core',
name: 'Core Graph',
description: 'Read-only knowledge graph queries and search (all agents)',
icon: '●',
color: '#3b82f6'
},
orchestration: {
id: 'orchestration',
name: 'Orchestration',
description: 'Workflows, web search, and reasoning (orchestrator only)',
icon: '●',
color: '#8b5cf6'
},
execution: {
id: 'execution',
name: 'Execution',
description: 'Write operations, extraction, and embeddings (workers only)',
icon: '●',
color: '#10b981'
}
};
// Tool group assignments
export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
// Core: Read-only graph operations (all agents)
queryNodes: 'core',
getNodesById: 'core',
queryEdge: 'core',
searchContentEmbeddings: 'core',
// Orchestration: Delegation and reasoning (orchestrator only)
webSearch: 'orchestration',
think: 'orchestration',
delegateToMiniRAH: 'orchestration',
delegateNodeQuotes: 'orchestration',
delegateNodeComparison: 'orchestration',
delegateToWiseRAH: 'orchestration',
executeWorkflow: 'orchestration',
// Execution: Write operations and extraction (workers only)
createNode: 'execution',
updateNode: 'execution',
createEdge: 'execution',
updateEdge: 'execution',
embedContent: 'execution',
youtubeExtract: 'execution',
websiteExtract: 'execution',
paperExtract: 'execution'
};
/**
* Get tool group by tool ID
*/
export function getToolGroup(toolId: string): ToolGroup | null {
const groupId = TOOL_GROUP_ASSIGNMENTS[toolId];
return groupId ? TOOL_GROUPS[groupId] : null;
}
/**
* Group tools by their assigned groups
*/
export function groupTools(toolIds: string[]): Record<string, string[]> {
const grouped: Record<string, string[]> = {};
toolIds.forEach(toolId => {
const groupId = TOOL_GROUP_ASSIGNMENTS[toolId] || 'other';
if (!grouped[groupId]) {
grouped[groupId] = [];
}
grouped[groupId].push(toolId);
});
return grouped;
}
/**
* Get all available tools organized by groups
*/
export function getAllToolsByGroup(): Record<string, string[]> {
return groupTools(Object.keys(TOOL_GROUP_ASSIGNMENTS));
}
+7
View File
@@ -0,0 +1,7 @@
// Tool registry exports - main interface for the tool system
export { TOOLS, getTool, getTools, getAllTools, getToolSchemas, executeTool, getHelperTools, getToolGroup, groupTools, getAllToolsByGroup } from './registry';
// Tool types
export type { Tool, ToolContext, ToolSchema } from './types';
export type { ToolGroup } from './groups';
export { TOOL_GROUPS } from './groups';
+53
View File
@@ -0,0 +1,53 @@
/**
* Node Formatter Utility
* Wraps node data in special markers for UI rendering as labels
* Format: [NODE:id:"title"]
*/
export interface NodeData {
id: number;
title: string;
dimensions?: string[];
}
/**
* Formats a node for display in chat streams
* @param node - Node data to format
* @returns Formatted string with node markers
*/
export function formatNodeForChat(node: NodeData): string {
return `[NODE:${node.id}:"${node.title}"]`;
}
/**
* Formats multiple nodes for display
* @param nodes - Array of nodes to format
* @returns Formatted string with each node on a new line
*/
export function formatNodesForChat(nodes: NodeData[]): string {
return nodes.map(formatNodeForChat).join('\n');
}
/**
* Extracts node data from a formatted string
* Used by UI components to parse node markers
* @param text - Text potentially containing node markers
* @returns Array of parsed node data
*/
export function parseNodeMarkers(text: string): Array<NodeData & { raw: string }> {
const regex = /\[NODE:\s*(\d+)\s*:\s*["“”']([^"“”']+)["“”']\s*\]/g;
const nodes: Array<NodeData & { raw: string }> = [];
let match;
while ((match = regex.exec(text)) !== null) {
const [raw, id, title] = match;
nodes.push({
raw,
id: parseInt(id, 10),
title,
dimensions: []
});
}
return nodes;
}
+203
View File
@@ -0,0 +1,203 @@
import { getToolGroup, groupTools, getAllToolsByGroup } from './groups';
import { queryNodesTool } from '../database/queryNodes';
import { getNodesByIdTool } from '../database/getNodesById';
import { createNodeTool } from '../database/createNode';
import { updateNodeTool } from '../database/updateNode';
import { createEdgeTool } from '../database/createEdge';
import { queryEdgeTool } from '../database/queryEdge';
import { updateEdgeTool } from '../database/updateEdge';
import { createDimensionTool } from '../database/createDimension';
import { updateDimensionTool } from '../database/updateDimension';
import { lockDimensionTool } from '../database/lockDimension';
import { unlockDimensionTool } from '../database/unlockDimension';
import { deleteDimensionTool } from '../database/deleteDimension';
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
import { webSearchTool } from '../other/webSearch';
import { thinkTool } from '../other/think';
import { delegateToMiniRAHTool } from '../orchestration/delegateToMiniRAH';
import { delegateNodeQuotesTool, delegateNodeComparisonTool } from '../orchestration/delegationHelpers';
import { delegateToWiseRAHTool } from '../orchestration/delegateToWiseRAH';
import { executeWorkflowTool } from '../orchestration/executeWorkflow';
import { youtubeExtractTool } from '../other/youtubeExtract';
import { websiteExtractTool } from '../other/websiteExtract';
import { paperExtractTool } from '../other/paperExtract';
// Core tools available to all agents (read-only graph operations)
const CORE_TOOLS: Record<string, any> = {
queryNodes: queryNodesTool,
getNodesById: getNodesByIdTool,
queryEdge: queryEdgeTool,
searchContentEmbeddings: searchContentEmbeddingsTool,
};
const ORCHESTRATION_TOOLS: Record<string, any> = {
webSearch: webSearchTool,
think: thinkTool,
delegateToMiniRAH: delegateToMiniRAHTool,
delegateNodeQuotes: delegateNodeQuotesTool,
delegateNodeComparison: delegateNodeComparisonTool,
delegateToWiseRAH: delegateToWiseRAHTool,
executeWorkflow: executeWorkflowTool,
};
// Execution tools for worker agents (includes write operations)
const EXECUTION_TOOLS: Record<string, any> = {
createNode: createNodeTool,
updateNode: updateNodeTool,
createEdge: createEdgeTool,
updateEdge: updateEdgeTool,
createDimension: createDimensionTool,
updateDimension: updateDimensionTool,
lockDimension: lockDimensionTool,
unlockDimension: unlockDimensionTool,
deleteDimension: deleteDimensionTool,
youtubeExtract: youtubeExtractTool,
websiteExtract: websiteExtractTool,
paperExtract: paperExtractTool,
};
export const TOOL_SETS = {
core: CORE_TOOLS,
orchestration: ORCHESTRATION_TOOLS,
execution: EXECUTION_TOOLS,
};
export const TOOLS: Record<string, any> = {
...CORE_TOOLS,
...ORCHESTRATION_TOOLS,
...EXECUTION_TOOLS,
};
const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
...Object.keys(CORE_TOOLS),
'webSearch',
'think',
'executeWorkflow',
'createNode',
'updateNode',
'createEdge',
'updateEdge',
'createDimension',
'updateDimension',
'lockDimension',
'unlockDimension',
'deleteDimension',
'youtubeExtract',
'websiteExtract',
'paperExtract',
]));
const EXECUTOR_TOOL_NAMES = [
...Object.keys(CORE_TOOLS),
...Object.keys(ORCHESTRATION_TOOLS).filter(name =>
name !== 'delegateToMiniRAH' &&
name !== 'delegateToWiseRAH' &&
name !== 'executeWorkflow' &&
name !== 'delegateNodeQuotes' &&
name !== 'delegateNodeComparison'
),
...Object.keys(EXECUTION_TOOLS),
];
const PLANNER_TOOL_NAMES = [
...Object.keys(CORE_TOOLS),
'webSearch',
'think',
'delegateToMiniRAH',
'updateNode', // For workflow execution (integrate workflow needs direct write access)
];
/**
* Get tool by ID
*/
export function getTool(toolId: string): any | null {
return TOOLS[toolId] || null;
}
/**
* Get tools by IDs (for helper's available_tools)
*/
export function getTools(toolIds: string[]): any[] {
if (!Array.isArray(toolIds)) {
console.error('getTools received non-array:', toolIds);
return [];
}
return toolIds.map(id => TOOLS[id]).filter(Boolean);
}
/**
* Get all available tools
*/
export function getAllTools(): any[] {
return Object.values(TOOLS);
}
/**
* Get tool schemas for OpenAI function calling
*/
export function getToolSchemas(toolIds: string[]) {
return getTools(toolIds).map(tool => tool.schema);
}
/**
* Get tools for a specific helper by tool names
* This is the main function used by helper APIs to get their assigned tools
*/
export function getHelperTools(availableToolNames: string[]): Record<string, any> {
if (!Array.isArray(availableToolNames)) {
console.error('getHelperTools received non-array:', availableToolNames);
return {};
}
return availableToolNames.reduce((tools, name) => {
if (TOOLS[name]) {
tools[name] = TOOLS[name];
} else {
console.warn(`Tool '${name}' not found in registry`);
}
return tools;
}, {} as Record<string, any>);
}
export function getDefaultToolNamesForRole(role: 'orchestrator' | 'executor' | 'planner'): string[] {
if (role === 'orchestrator') {
return [...ORCHESTRATOR_TOOL_NAMES];
}
if (role === 'planner') {
return [...PLANNER_TOOL_NAMES];
}
return [...EXECUTOR_TOOL_NAMES];
}
export function getToolsForRole(role: 'orchestrator' | 'executor' | 'planner'): Record<string, any> {
const names = getDefaultToolNamesForRole(role);
return getHelperTools(names);
}
/**
* Execute a tool with given parameters and context
*/
export async function executeTool(toolId: string, params: any, context: any) {
const tool = getTool(toolId);
if (!tool) {
return {
success: false,
error: `Tool '${toolId}' not found`,
data: null
};
}
try {
return await tool.execute(params, context);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : `Tool '${toolId}' execution failed`,
data: null
};
}
}
// Export group utilities
export { getToolGroup, groupTools, getAllToolsByGroup };
+28
View File
@@ -0,0 +1,28 @@
import { Node } from '@/types/database';
import { SQLiteClient } from '@/services/database/sqlite-client';
export interface ToolContext {
selectedNodes: Node[];
database: SQLiteClient;
}
export interface ToolSchema {
type: 'function';
function: {
name: string;
description: string;
parameters: {
type: 'object';
properties: Record<string, any>;
required: string[];
};
};
}
export interface Tool {
id: string;
name: string;
description: string;
schema: ToolSchema;
execute: (params: any, context: ToolContext) => Promise<any>;
}
@@ -0,0 +1,221 @@
import { tool } from 'ai';
import { z } from 'zod';
import { AgentDelegationService } from '@/services/agents/delegation';
import { MiniRAHExecutor } from '@/services/agents/executor';
import { RequestContext } from '@/services/context/requestContext';
import type { Node } from '@/types/database';
import { nodeService } from '@/services/database/nodes';
type CapsuleNodeRole = 'primary' | 'secondary' | 'referenced';
interface CapsuleNodeSnapshot {
id: number;
title: string | null;
link: string | null;
dimensions: string[];
chunkStatus: string;
hasChunks: boolean;
excerpt: string | null;
role: CapsuleNodeRole;
}
interface DelegationCapsule {
version: number;
generatedAt: string;
primary: CapsuleNodeSnapshot | null;
secondary: CapsuleNodeSnapshot[];
referenced: CapsuleNodeSnapshot[];
focusCount: number;
}
function truncateWords(text: string, limit: number): string {
if (!text) return '';
const words = text.trim().split(/\s+/);
if (words.length <= limit) return text.trim();
return `${words.slice(0, limit).join(' ')}`;
}
function describeChunk(node: Node): { status: string; hasChunks: boolean } {
const status = node.chunk_status || 'unknown';
const hasChunks = status === 'chunked' || (typeof node.chunk === 'string' && node.chunk.length > 0);
return { status, hasChunks };
}
function buildSnapshot(node: Node, role: CapsuleNodeRole): CapsuleNodeSnapshot {
const primaryText = (node.content || node.description || '').trim();
const linkFallback = node.link || '';
const excerptSource = primaryText.length > 0 ? primaryText : linkFallback;
const { status, hasChunks } = describeChunk(node);
return {
id: node.id,
title: node.title || null,
link: node.link || null,
dimensions: node.dimensions || [],
chunkStatus: status,
hasChunks,
excerpt: excerptSource ? truncateWords(excerptSource, 80) : null,
role,
};
}
function formatCapsuleForLLM(capsule: DelegationCapsule): string {
const lines: string[] = ['=== DELEGATION CAPSULE ==='];
if (capsule.primary) {
lines.push('Primary focus:', formatSnapshotLine(capsule.primary));
} else {
lines.push('Primary focus: None');
}
if (capsule.secondary.length > 0) {
lines.push('Secondary focus nodes:');
capsule.secondary.forEach(snapshot => {
lines.push(`- ${formatSnapshotLine(snapshot)}`);
});
} else {
lines.push('Secondary focus nodes: None');
}
if (capsule.referenced.length > 0) {
lines.push('Referenced nodes provided in this task:');
capsule.referenced.forEach(snapshot => {
lines.push(`- ${formatSnapshotLine(snapshot)}`);
});
}
lines.push('Instructions:',
'- Use the capsule snapshots as ground truth for node IDs and titles.',
'- Call getNodesById if you need the full record beyond the provided excerpt.',
'- List the node IDs you used in the "Context sources used" line of your final summary.',
'=== END CAPSULE ===');
return lines.join('\n');
}
function formatSnapshotLine(snapshot: CapsuleNodeSnapshot): string {
const dimensionLabel = snapshot.dimensions.length > 0 ? snapshot.dimensions.join(', ') : 'none';
const excerpt = snapshot.excerpt ? `Excerpt: ${snapshot.excerpt}` : 'Excerpt: (no stored content; hydrate via getNodesById if required)';
return `[NODE:${snapshot.id}:"${snapshot.title || 'Untitled'}"] | role=${snapshot.role} | dimensions=${dimensionLabel} | chunk_status=${snapshot.chunkStatus} | ${excerpt}`;
}
function buildSourceBlock(snapshot: CapsuleNodeSnapshot): string {
return [
`=== SOURCE: NODE ${snapshot.id} ===`,
`Title: "${snapshot.title || 'Untitled'}"`,
`Role: ${snapshot.role}`,
`Chunk status: ${snapshot.chunkStatus} (has_chunks=${snapshot.hasChunks})`,
snapshot.link ? `Link: ${snapshot.link}` : 'Link: None',
snapshot.dimensions.length > 0 ? `Dimensions: ${snapshot.dimensions.join(', ')}` : 'Dimensions: None',
snapshot.excerpt ? `Excerpt: ${snapshot.excerpt}` : 'Excerpt: (not available) — call getNodesById if you need more context.',
'====================='
].join('\n');
}
async function hydrateReferencedNodes(nodeIds: number[]): Promise<Node[]> {
const nodes: Node[] = [];
for (const id of nodeIds) {
try {
const node = await nodeService.getNodeById(id);
if (node) {
nodes.push(node);
}
} catch (error) {
console.warn(`delegateToMiniRAH: failed to load node ${id}`, error);
}
}
return nodes;
}
export const delegateToMiniRAHTool = tool({
description: 'Delegate task to mini worker',
inputSchema: z.object({
task: z.string().describe('Clear, actionable description of what the mini ra-h should do'),
context: z.array(z.string()).max(16).default([]).describe('Optional context: URLs, node IDs, or key information the worker needs'),
expectedOutcome: z.string().optional().describe('Optional: what format or structure you expect in the summary'),
}),
execute: async ({ task, context = [], expectedOutcome }) => {
const requestContext = RequestContext.get();
const openTabs = (requestContext.openTabs ?? []) as Node[];
const activeTabId = requestContext.activeTabId ?? null;
const providedEntries = Array.isArray(context) ? context.filter(entry => typeof entry === 'string') as string[] : [];
const referencedNodeIds = new Set<number>();
const passthroughEntries: string[] = [];
providedEntries.forEach(entry => {
const trimmed = entry.trim();
const numericMatch = trimmed.match(/^(?:node_id:)?(\d+)$/i);
if (numericMatch) {
const id = Number(numericMatch[1]);
if (Number.isFinite(id) && id > 0) {
referencedNodeIds.add(id);
}
return;
}
passthroughEntries.push(entry);
});
const focusMap = new Map<number, Node>();
openTabs.forEach(node => {
if (node && typeof node.id === 'number') {
focusMap.set(node.id, node);
}
});
const additionalIds = Array.from(referencedNodeIds).filter(id => !focusMap.has(id));
const referencedNodes = await hydrateReferencedNodes(additionalIds);
referencedNodes.forEach(node => focusMap.set(node.id, node));
const focusNodes = Array.from(focusMap.values());
const primaryNode = focusNodes.find(node => node.id === activeTabId) || focusNodes[0] || null;
const secondaryNodes = focusNodes.filter(node => primaryNode && node.id !== primaryNode.id && openTabs.some(tab => tab.id === node.id));
const referencedOnlyNodes = focusNodes.filter(node => !openTabs.some(tab => tab.id === node.id));
const capsule: DelegationCapsule = {
version: 1,
generatedAt: new Date().toISOString(),
primary: primaryNode ? buildSnapshot(primaryNode, 'primary') : null,
secondary: secondaryNodes.map(node => buildSnapshot(node, 'secondary')),
referenced: referencedOnlyNodes.map(node => buildSnapshot(node, 'referenced')),
focusCount: openTabs.length,
};
const sourceBlocks: string[] = [];
const snapshots: CapsuleNodeSnapshot[] = [];
if (capsule.primary) snapshots.push(capsule.primary);
snapshots.push(...capsule.secondary, ...capsule.referenced);
snapshots.forEach(snapshot => {
sourceBlocks.push(buildSourceBlock(snapshot));
});
const enrichedContext: string[] = [
`CAPSULE_JSON::${JSON.stringify(capsule)}`,
formatCapsuleForLLM(capsule),
...sourceBlocks,
...passthroughEntries,
].slice(0, 16);
const delegation = AgentDelegationService.createDelegation({
task,
context: enrichedContext,
expectedOutcome,
supabaseToken: null,
});
const execution = await MiniRAHExecutor.execute({
sessionId: delegation.sessionId,
task,
context: enrichedContext,
expectedOutcome,
traceId: requestContext.traceId,
parentChatId: requestContext.parentChatId,
workflowKey: requestContext.workflowKey,
workflowNodeId: requestContext.workflowNodeId,
});
const summary = execution?.summary || 'Task delegated but no summary returned.';
const status = execution?.status || 'completed';
const sessionSuffix = delegation.sessionId.split('_').pop();
const statusLabel = status === 'completed' ? 'completed the task' : 'flagged an issue';
return `Mini ra-h (session ${sessionSuffix}) ${statusLabel}:\n\n${summary}`;
},
});
@@ -0,0 +1,43 @@
import { tool } from 'ai';
import { z } from 'zod';
import { AgentDelegationService } from '@/services/agents/delegation';
import { WiseRAHExecutor } from '@/services/agents/wiseRAHExecutor';
import { RequestContext } from '@/services/context/requestContext';
export const delegateToWiseRAHTool = tool({
description: 'Delegate complex workflows to wise ra-h (GPT-5)',
inputSchema: z.object({
task: z.string().describe('Complex workflow description: what needs to be planned and executed'),
context: z.array(z.string()).max(8).default([]).describe('Optional context: node IDs, URLs, or key information the planner needs'),
expectedOutcome: z.string().optional().describe('Optional: what final result or format you expect in the summary'),
workflowKey: z.string().optional().describe('Optional: workflow key if invoked via executeWorkflow'),
workflowNodeId: z.number().optional().describe('Optional: target node ID for workflow'),
}),
execute: async ({ task, context = [], expectedOutcome, workflowKey, workflowNodeId }) => {
const requestContext = RequestContext.get();
console.log('[delegateToWiseRAH] Current traceId:', requestContext.traceId);
const delegation = AgentDelegationService.createDelegation({
task,
context,
expectedOutcome,
agentType: 'wise-rah',
supabaseToken: null,
});
const execution = await WiseRAHExecutor.execute({
sessionId: delegation.sessionId,
task,
context,
expectedOutcome,
traceId: requestContext.traceId,
parentChatId: requestContext.parentChatId,
workflowKey,
workflowNodeId,
});
// Return a simple string that Claude can directly use in conversation
const summary = execution?.summary || 'Wise ra-h delegated but no summary returned.';
return `Wise ra-h (session ${delegation.sessionId.split('_').pop()}) completed the workflow:\n\n${summary}`;
},
});
@@ -0,0 +1,60 @@
import { tool } from 'ai';
import { z } from 'zod';
import { delegateToMiniRAHTool } from './delegateToMiniRAH';
export const delegateNodeQuotesTool = tool({
description: 'Extract quotes from single node',
inputSchema: z.object({
nodeId: z.number().int().positive().describe('The node to read from'),
question: z.string().min(3).describe('The question or prompt the quotes should answer'),
maxQuotes: z.number().int().min(1).max(6).default(3).describe('Maximum number of quotes to return'),
requireSummary: z.boolean().default(false).describe('Whether to include a short synthesis after the quotes'),
}),
execute: async ({ nodeId, question, maxQuotes, requireSummary }) => {
const task = `Extract up to ${maxQuotes} ready-to-use verbatim quotes (include speaker/source if present) from [NODE:${nodeId}] that answer: "${question}". Provide each quote with a one-line takeaway.`;
const context = [String(nodeId), `REQUIRE_SYNTHESIS:${requireSummary ? 'yes' : 'no'}`];
const expectedOutcome = requireSummary
? 'Use the required Task/Actions/Result/Node/Context sources used/Follow-up template. In the Result line, list the quotes (prefixed with takeaways) first, then add a 2-3 sentence comparison summary. Always finish with "Context sources used" listing every NODE ID referenced.'
: 'Use the required Task/Actions/Result/Node/Context sources used/Follow-up template. In the Result line, list only the quotes (each prefixed with a takeaway) and end the summary with "Context sources used" covering every NODE ID referenced.';
if (typeof delegateToMiniRAHTool.execute !== 'function') {
throw new Error('delegateToMiniRAH tool is unavailable.');
}
return delegateToMiniRAHTool.execute({
task,
context,
expectedOutcome,
}, undefined as any);
}
});
export const delegateNodeComparisonTool = tool({
description: 'Create synthesis from gathered evidence',
inputSchema: z.object({
title: z.string().min(3).describe('Title for the synthesis node'),
comparisonPrompt: z.string().min(5).describe('Short description of what to compare or conclude'),
sourceNodeIds: z.array(z.number().int().positive()).min(1).max(8).describe('Source nodes that must be cited'),
includeOutline: z.boolean().default(false).describe('If true, worker should draft with numbered sections matching current outline in context'),
}),
execute: async ({ title, comparisonPrompt, sourceNodeIds, includeOutline }) => {
const task = `Using the gathered evidence, draft the final synthesis titled "${title}". Compare or conclude on: ${comparisonPrompt}.`;
const outlineHint = includeOutline
? 'Follow the outline provided in the context exactly (use the same headings).'
: 'Structure the answer with clear sections (Intro, Comparison, Takeaways).';
const contextEntries = sourceNodeIds.map(id => String(id));
contextEntries.push(outlineHint);
const expectedOutcome = 'Use the required Task/Actions/Result/Node/Context sources used/Follow-up template. In the Result line, deliver polished prose ready for createNode/updateNode, cite specific quotes inline where relevant, and finish with "Context sources used" listing every NODE ID you relied on.';
if (typeof delegateToMiniRAHTool.execute !== 'function') {
throw new Error('delegateToMiniRAH tool is unavailable.');
}
return delegateToMiniRAHTool.execute({
task,
context: contextEntries,
expectedOutcome,
}, undefined as any);
}
});
+130
View File
@@ -0,0 +1,130 @@
import { tool } from 'ai';
import { z } from 'zod';
import { WorkflowRegistry } from '@/services/workflows/registry';
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { AgentDelegationService } from '@/services/agents/delegation';
import { WiseRAHExecutor } from '@/services/agents/wiseRAHExecutor';
import { RequestContext } from '@/services/context/requestContext';
import { getAutoContextSummaries } from '@/services/context/autoContext';
export const executeWorkflowTool = tool({
description: 'Execute predefined workflow via wise ra-h',
inputSchema: z.object({
workflowKey: z.string().describe('Key of workflow to execute (e.g., "integrate")'),
nodeId: z.number().describe('ID of node to run workflow on (usually focused node)'),
userContext: z.string().optional().describe('Optional: Additional context or instructions from user'),
}),
execute: async ({ workflowKey, nodeId, userContext }) => {
// 1. Fetch workflow definition
const workflow = await WorkflowRegistry.getWorkflowByKey(workflowKey);
if (!workflow) {
return { success: false, error: `Workflow '${workflowKey}' not found` };
}
if (!workflow.enabled) {
return { success: false, error: `Workflow '${workflowKey}' is disabled` };
}
// 2. Validate node requirement
if (workflow.requiresFocusedNode && !nodeId) {
return { success: false, error: `Workflow '${workflowKey}' requires a focused node` };
}
// 2.5. Prevent re-running same workflow on same node within 1 hour
if (nodeId) {
const db = getSQLiteClient();
const recentRuns = db.query<{ id: number }>(
`SELECT id FROM chats
WHERE json_extract(metadata, '$.workflow_key') = ?
AND json_extract(metadata, '$.workflow_node_id') = ?
AND datetime(created_at) > datetime('now', '-1 hour')
LIMIT 1`,
[workflowKey, nodeId]
).rows;
if (recentRuns.length > 0) {
return {
success: false,
error: `Workflow '${workflowKey}' already ran on node ${nodeId} within the last hour. Check the node content - the Integration Analysis section should already be there.`
};
}
}
// 3. Validate node exists and fetch full context (if node provided)
let contextLines: string[] = [];
if (nodeId) {
const db = getSQLiteClient();
const stmt = db.prepare('SELECT * FROM nodes WHERE id = ?');
const node = stmt.get(nodeId) as any;
if (!node) {
return { success: false, error: `Node ${nodeId} not found` };
}
contextLines = [
`Focused Node: [NODE:${node.id}:"${node.title}"]`,
node.description ? `Description: ${node.description}` : null,
node.content ? `Content: ${node.content}` : null,
node.link ? `Link: ${node.link}` : null,
].filter(Boolean) as string[];
}
// Removed conflicting guardrail - wise-rah has updateNode access and should use it
if (userContext) {
contextLines.push(`User Context: ${userContext}`);
}
// 4. Build task with workflow instructions
RequestContext.set({ workflowKey, workflowNodeId: nodeId });
const autoContextSummaries = getAutoContextSummaries(6);
if (autoContextSummaries.length > 0) {
contextLines.push('Background Context (Top Hubs):');
autoContextSummaries.forEach((summary) => {
contextLines.push(
`[NODE:${summary.id}:"${summary.title}"] (${summary.edgeCount} edges)`
);
});
}
const task = `Execute workflow: ${workflow.displayName}
${workflow.instructions}
${nodeId ? `Target Node ID: ${nodeId}` : 'No specific node targeted (general workflow)'}`;
// 5. Delegate to wise ra-h oracle with workflow metadata
const requestContext = RequestContext.get();
console.log('[executeWorkflowTool] Current traceId:', requestContext.traceId);
const delegation = AgentDelegationService.createDelegation({
task,
context: contextLines,
expectedOutcome: workflow.expectedOutcome,
agentType: 'wise-rah',
supabaseToken: null,
});
// Fire-and-forget execution so main ra-h stays responsive while workflow runs
void WiseRAHExecutor.execute({
sessionId: delegation.sessionId,
task,
context: contextLines,
expectedOutcome: workflow.expectedOutcome,
traceId: requestContext.traceId,
parentChatId: requestContext.parentChatId,
workflowKey,
workflowNodeId: nodeId,
}).catch((error) => {
console.error('[executeWorkflowTool] Wise ra-h delegation failed', error);
});
RequestContext.set({ workflowKey: undefined, workflowNodeId: undefined });
const shortSessionId = delegation.sessionId.split('_').pop();
const workflowLabel = workflow.displayName || workflowKey;
return [
`Delegated **${workflowLabel}** to wise ra-h (session ${shortSessionId}).`,
'Keep working here—wise ra-h will stream every step inside its tab and post the final summary when complete.',
].join('\n');
},
});
+41
View File
@@ -0,0 +1,41 @@
import { tool } from 'ai';
import { z } from 'zod';
export const embedContentTool = tool({
description: 'Chunk and embed a nodes content so semantic search stays current',
inputSchema: z.object({
nodeId: z.number().describe('The ID of the node to process')
}),
execute: async ({ nodeId }) => {
try {
// Call the same API endpoint that the manual embed button uses
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/ingestion`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nodeId })
});
const result = await response.json();
if (!response.ok) {
return {
success: false,
error: result.error || 'Failed to embed content',
data: null
};
}
return {
success: true,
message: result.message || `Successfully chunked and embedded content for node ${nodeId}`,
data: { nodeId }
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to embed content',
data: null
};
}
}
});
+200
View File
@@ -0,0 +1,200 @@
import { tool } from 'ai';
import { z } from 'zod';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { extractPaper } from '@/services/typescript/extractors/paper';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
// AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) {
try {
const prompt = `Analyze this ${contentType} content and provide classification:
Title: "${title}"
Description: "${description}"
Respond with ONLY valid JSON (no markdown, no code blocks):
{
"enhancedDescription": "Improved 2-3 sentence description explaining what this content is about",
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
"reasoning": "Brief explanation of why you chose these categories"
}
Guidelines:
- Include 3-8 relevant semantic tags (not just generic ones)
- Make description informative and contextual
- For academic papers, include tags like: research, academic, paper, plus domain-specific tags
- For AI/ML papers, include: ai, machine-learning, artificial-intelligence, deep-learning
- For economics papers, include: economics, finance, markets, policy
- Be specific and insightful
- Return ONLY the JSON object, no other text`;
const response = await generateText({
model: openai('gpt-5-mini'),
prompt,
maxOutputTokens: 500
});
let content = response.text || '{}';
// Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(content);
return {
enhancedDescription: result.enhancedDescription || description,
tags: Array.isArray(result.tags) ? result.tags : [],
reasoning: result.reasoning || 'AI analysis completed'
};
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
console.warn('Paper analysis fallback (using default description):', message);
return {
enhancedDescription: description,
tags: [],
reasoning: 'Fallback description used'
};
}
}
export const paperExtractTool = tool({
description: 'Extract a PDF or research paper into a node with summary, metadata, and full-text chunk',
inputSchema: z.object({
url: z.string().describe('The PDF URL to add to inbox'),
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node (locked dimensions first)')
}),
execute: async ({ url, title, dimensions }) => {
try {
// Validate PDF URL
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return {
success: false,
error: 'Invalid URL format - must start with http:// or https://',
data: null
};
}
// Check if URL likely points to a PDF
if (!url.toLowerCase().includes('.pdf') && !url.includes('arxiv.org')) {
return {
success: false,
error: 'URL does not appear to point to a PDF file',
data: null
};
}
let result: { success: boolean; content?: string; chunk?: string; metadata?: any; error?: string };
try {
const extractionResult = await extractPaper(url);
result = {
success: true,
content: extractionResult.content,
chunk: extractionResult.chunk,
metadata: {
title: extractionResult.metadata.title,
pages: extractionResult.metadata.pages,
info: extractionResult.metadata.info,
text_length: extractionResult.metadata.text_length,
filename: extractionResult.metadata.filename,
extraction_method: 'typescript'
}
};
} catch (error: any) {
result = {
success: false,
error: error.message || 'TypeScript extraction failed'
};
}
if (!result.success || (!result.content && !result.chunk)) {
return {
success: false,
error: result.error || 'Failed to extract PDF content',
data: null
};
}
console.log('🎯 PDF extraction successful, analyzing with AI...');
// Step 2: AI Analysis for enhanced metadata
const aiAnalysis = await analyzeContentWithAI(
result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`,
result.content?.substring(0, 1000) || 'PDF document content',
'pdf'
);
// Step 3: Create node with extracted content and AI analysis
const nodeTitle = title || result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`;
const enhancedDescription = aiAnalysis?.enhancedDescription || `PDF document from ${new URL(url).hostname}`;
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
let trimmedDimensions = suppliedDimensions
.map(dim => (typeof dim === 'string' ? dim.trim() : ''))
.filter(Boolean);
trimmedDimensions = trimmedDimensions.slice(0, 5);
const createResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: nodeTitle,
content: enhancedDescription,
link: url,
dimensions: trimmedDimensions,
chunk: result.chunk || result.content,
metadata: {
source: 'pdf',
hostname: new URL(url).hostname,
author: result.metadata?.author || result.metadata?.info?.Author,
pages: result.metadata?.pages,
file_size: result.metadata?.file_size,
content_length: (result.chunk || result.content)?.length,
extraction_method: result.metadata?.extraction_method || 'python_pdfplumber',
ai_analysis: aiAnalysis?.reasoning,
enhanced_description: enhancedDescription,
refined_at: new Date().toISOString()
}
})
});
const createResult = await createResponse.json();
if (!createResponse.ok) {
return {
success: false,
error: createResult.error || 'Failed to create node',
data: null
};
}
console.log('🎯 PaperExtract completed successfully');
const formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: trimmedDimensions || [] })
: nodeTitle;
const dimsDisplay = trimmedDimensions.length > 0 ? trimmedDimensions.join(', ') : 'none';
return {
success: true,
message: `Added ${formattedNode} with dimensions: ${dimsDisplay}`,
data: {
nodeId: createResult.data?.id,
title: nodeTitle,
contentLength: (result.chunk || result.content || '').length,
url: url,
dimensions: trimmedDimensions
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to extract PDF content',
data: null
};
}
}
});
+126
View File
@@ -0,0 +1,126 @@
import { tool } from 'ai';
import { z } from 'zod';
import { chunkService } from '@/services/database/chunks';
import { EmbeddingService } from '@/services/embeddings';
export const searchContentEmbeddingsTool = tool({
description: 'Semantic search over node chunks with text fallback for reliability',
inputSchema: z.object({
query: z.string().describe('The search query to find semantically similar content'),
limit: z.number().min(1).max(20).default(5).describe('Maximum number of results to return (default: 5)'),
node_id: z.number().optional().describe('Optional: search within a specific node only'),
similarity_threshold: z.number().min(0.1).max(1.0).default(0.5).describe('Minimum similarity score (0.1-1.0, default: 0.5)')
}),
execute: async ({ query, limit = 5, node_id, similarity_threshold = 0.5 }) => {
const startTime = Date.now();
try {
console.log(`🔍 Searching embeddings for: "${query}"${node_id ? ` in node ${node_id}` : ' across all nodes'} (threshold: ${similarity_threshold})`);
// Generate embedding for the search query
let queryEmbedding: number[];
try {
queryEmbedding = await EmbeddingService.generateQueryEmbedding(query);
} catch (embeddingError) {
console.error('Failed to generate embedding, falling back to text search:', embeddingError);
// Fallback to text search immediately if embedding fails
const chunks = await chunkService.textSearchFallback(
query,
limit,
node_id ? [node_id] : undefined
);
return {
success: true,
data: {
chunks: chunks.map(chunk => ({
id: chunk.id,
node_id: chunk.node_id,
chunk_idx: chunk.chunk_idx,
preview: chunk.text?.length ? `${chunk.text.slice(0, 180)}${chunk.text.length > 180 ? '…' : ''}` : '',
text: chunk.text ?? '',
similarity: chunk.similarity,
})),
query: query,
searched_nodes: node_id ? [node_id] : 'all',
count: chunks.length,
similarity_threshold,
search_method: 'text_fallback',
search_time_ms: Date.now() - startTime
}
};
}
if (!EmbeddingService.validateEmbedding(queryEmbedding)) {
return {
success: false,
error: 'Invalid embedding generated for query',
data: null
};
}
// Determine search scope
let searchNodeIds: number[] | undefined;
if (node_id) {
searchNodeIds = [node_id];
}
// Perform vector similarity search with improved parameters
const chunks = await chunkService.searchChunks(
queryEmbedding,
similarity_threshold,
limit,
searchNodeIds,
query // provide fallback query
);
const searchTime = Date.now() - startTime;
const hasResults = chunks.length > 0;
console.log(`📊 Found ${chunks.length} relevant chunks with similarity >= ${similarity_threshold} (${searchTime}ms)`);
if (hasResults) {
console.log(`🎯 Top result: chunk ${chunks[0].id} (similarity: ${chunks[0].similarity.toFixed(3)})`);
console.log(`📝 Preview: ${chunks[0].text?.slice(0, 100)}...`);
}
// If no results and threshold is high, suggest retry with lower threshold
let suggestions: string[] = [];
if (!hasResults && similarity_threshold > 0.3) {
suggestions.push(`No results found with similarity >= ${similarity_threshold}. Try lowering the threshold to 0.3 for broader results.`);
}
if (!hasResults && searchNodeIds) {
suggestions.push('No results in specified node. Try searching across all nodes.');
}
return {
success: true,
data: {
chunks: chunks.map(chunk => ({
id: chunk.id,
node_id: chunk.node_id,
chunk_idx: chunk.chunk_idx,
preview: chunk.text?.length ? `${chunk.text.slice(0, 180)}${chunk.text.length > 180 ? '…' : ''}` : '',
text: chunk.text ?? '',
similarity: chunk.similarity,
})),
query: query,
searched_nodes: searchNodeIds || 'all',
count: chunks.length,
similarity_threshold,
search_method: 'vector_search',
search_time_ms: searchTime,
suggestions: suggestions.length > 0 ? suggestions : undefined
}
};
} catch (error) {
const searchTime = Date.now() - startTime;
console.error('Embedding search failed completely:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to search content embeddings',
data: null,
search_time_ms: searchTime
};
}
}
});
+37
View File
@@ -0,0 +1,37 @@
import { tool } from 'ai';
import { z } from 'zod';
export const thinkTool = tool({
description: 'Log reasoning without side effects',
inputSchema: z.object({
purpose: z.string().describe('What you are working toward (problem/goal)'),
thoughts: z.string().describe('Current reasoning step or reflection'),
next_action: z
.string()
.optional()
.describe('Planned next action or investigation step'),
step: z
.number()
.int()
.min(1)
.optional()
.describe('Optional step index in the sequence'),
done: z
.boolean()
.optional()
.describe('Signal completion if the plan is finished or stalled'),
}),
execute: async (params) => {
return {
success: true,
data: {
logged: true,
continue: params.done ? false : true,
trace: {
...params,
timestamp: new Date().toISOString(),
},
},
};
},
});
+82
View File
@@ -0,0 +1,82 @@
import { tool } from 'ai';
import { z } from 'zod';
export const webSearchTool = tool({
description: 'Search web via Tavily',
inputSchema: z.object({
query: z.string().describe('The search query for finding information on the web'),
limit: z.number().min(1).max(10).default(5).describe('Maximum number of results to return (default: 5)')
}),
execute: async ({ query, limit = 5 }) => {
try {
const apiKey = process.env.TAVILY_API_KEY;
if (!apiKey) {
console.error('Tavily API key not found in environment variables');
throw new Error('Tavily API key not configured - check environment variables');
}
console.log('WebSearch: Starting Tavily search for query:', query);
const response = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: apiKey,
query: query,
search_depth: 'basic',
include_answer: true,
include_images: false,
include_raw_content: false,
max_results: limit,
include_domains: [],
exclude_domains: []
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error('Tavily API error response:', response.status, errorText);
throw new Error(`Tavily API failed with status: ${response.status} - ${errorText}`);
}
const data = await response.json();
console.log('WebSearch: Tavily response received with', data.results?.length || 0, 'results');
// Extract results from Tavily response
const results = (data.results || []).map((result: any) => ({
title: result.title || 'No title',
snippet: result.content || 'No description available',
url: result.url || '',
score: result.score || 0,
published_date: result.published_date || null,
}));
return {
success: true,
data: {
results,
query: query,
count: results.length,
answer: data.answer || null,
source: 'Tavily AI Search'
}
};
} catch (error) {
console.error('WebSearch tool error:', error);
// Fallback response when web search fails
return {
success: false,
error: error instanceof Error ? error.message : 'Web search failed',
data: {
results: [],
query: query,
count: 0,
note: 'Web search is currently unavailable. Consider searching your knowledge base instead.'
}
};
}
}
});
+190
View File
@@ -0,0 +1,190 @@
import { tool } from 'ai';
import { z } from 'zod';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { extractWebsite } from '@/services/typescript/extractors/website';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
// AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) {
try {
const prompt = `Analyze this ${contentType} content and provide classification:
Title: "${title}"
Description: "${description}"
Respond with ONLY valid JSON (no markdown, no code blocks):
{
"enhancedDescription": "Improved 2-3 sentence description explaining what this content is about",
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
"reasoning": "Brief explanation of why you chose these categories"
}
Guidelines:
- Include 3-8 relevant semantic tags (not just generic ones)
- Make description informative and contextual
- For AI/ML content, include tags like: ai, machine-learning, artificial-intelligence, deep-learning
- For economics content, include: economics, finance, markets, policy
- Be specific and insightful
- Return ONLY the JSON object, no other text`;
const response = await generateText({
model: openai('gpt-5-mini'),
prompt,
maxOutputTokens: 500
});
let content = response.text || '{}';
// Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(content);
return {
enhancedDescription: result.enhancedDescription || description,
tags: Array.isArray(result.tags) ? result.tags : [],
reasoning: result.reasoning || 'AI analysis completed'
};
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
console.warn('Website analysis fallback (using default description):', message);
return {
enhancedDescription: description,
tags: [],
reasoning: 'Fallback description used'
};
}
}
export const websiteExtractTool = tool({
description: 'Extract website content and metadata into a node with summary, tags, and raw chunk',
inputSchema: z.object({
url: z.string().describe('The website URL to add to knowledge base'),
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node (locked dimensions first)')
}),
execute: async ({ url, title, dimensions }) => {
try {
// Validate URL format
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return {
success: false,
error: 'Invalid URL format - must start with http:// or https://',
data: null
};
}
let result: { success: boolean; content?: string; chunk?: string; metadata?: any; error?: string };
try {
const extractionResult = await extractWebsite(url);
result = {
success: true,
content: extractionResult.content,
chunk: extractionResult.chunk,
metadata: {
title: extractionResult.metadata.title,
author: extractionResult.metadata.author,
date: extractionResult.metadata.date,
description: extractionResult.metadata.description,
og_image: extractionResult.metadata.og_image,
site_name: extractionResult.metadata.site_name,
extraction_method: 'typescript'
}
};
} catch (error: any) {
result = {
success: false,
error: error.message || 'TypeScript extraction failed'
};
}
if (!result.success || (!result.content && !result.chunk)) {
return {
success: false,
error: result.error || 'Failed to extract website content',
data: null
};
}
console.log('🎯 Website extraction successful, analyzing with AI...');
// Step 2: AI Analysis for enhanced metadata
const aiAnalysis = await analyzeContentWithAI(
result.metadata?.title || `Website: ${new URL(url).hostname}`,
result.content?.substring(0, 500) || 'Website content',
'website'
);
// Step 3: Create node with extracted content and AI analysis
const nodeTitle = title || result.metadata?.title || `Website: ${new URL(url).hostname}`;
const enhancedDescription = aiAnalysis?.enhancedDescription || `Website content from ${new URL(url).hostname}`;
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
let trimmedDimensions = suppliedDimensions
.map(dim => (typeof dim === 'string' ? dim.trim() : ''))
.filter(Boolean);
trimmedDimensions = trimmedDimensions.slice(0, 5);
const createResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: nodeTitle,
content: enhancedDescription,
link: url,
dimensions: trimmedDimensions,
chunk: result.chunk || result.content,
metadata: {
source: 'website',
hostname: new URL(url).hostname,
author: result.metadata?.author,
published_date: result.metadata?.published_date || result.metadata?.date,
content_length: (result.chunk || result.content)?.length,
extraction_method: result.metadata?.extraction_method || 'python_beautifulsoup',
ai_analysis: aiAnalysis?.reasoning,
enhanced_description: enhancedDescription,
refined_at: new Date().toISOString()
}
})
});
const createResult = await createResponse.json();
if (!createResponse.ok) {
return {
success: false,
error: createResult.error || 'Failed to create node',
data: null
};
}
console.log('🎯 WebsiteExtract completed successfully');
const formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: trimmedDimensions || [] })
: nodeTitle;
const dimsDisplay = trimmedDimensions.length > 0 ? trimmedDimensions.join(', ') : 'none';
return {
success: true,
message: `Added ${formattedNode} with dimensions: ${dimsDisplay}`,
data: {
nodeId: createResult.data?.id,
title: nodeTitle,
contentLength: (result.chunk || result.content || '').length,
url: url,
dimensions: trimmedDimensions
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to extract website content',
data: null
};
}
}
});
+252
View File
@@ -0,0 +1,252 @@
import { tool } from 'ai';
import { z } from 'zod';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { extractYouTube } from '@/services/typescript/extractors/youtube';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
// Available segments for categorization - match database constraint
const AVAILABLE_SEGMENTS = [
'inbox', 'parking', 'in progress', 'archive'
];
// AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) {
try {
const prompt = `Analyze this ${contentType} content and provide classification:
Title: "${title}"
Description: "${description}"
Available types: ${AVAILABLE_SEGMENTS.join(', ')}
Respond with ONLY valid JSON (no markdown, no code blocks):
{
"enhancedDescription": "Improved 2-3 sentence description explaining what this content is about",
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
"segments": ["research"],
"reasoning": "Brief explanation of why you chose these categories"
}
Guidelines:
- Choose 1 segment that best fits the content type
- Include 3-8 relevant semantic tags (not just generic ones)
- Make description informative and contextual
- For AI/ML content, include tags like: ai, machine-learning, artificial-intelligence, deep-learning
- For economics content, include: economics, finance, markets, policy
- Be specific and insightful
- Return ONLY the JSON object, no other text`;
const response = await generateText({
model: openai('gpt-5-mini'),
prompt,
maxOutputTokens: 500
});
let content = response.text || '{}';
// Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(content);
// Validate segments are from available list
const validSegments = result.segments?.filter((seg: string) =>
AVAILABLE_SEGMENTS.includes(seg)
) || [];
return {
enhancedDescription: result.enhancedDescription || description,
tags: Array.isArray(result.tags) ? result.tags : [],
segments: validSegments,
reasoning: result.reasoning || 'AI analysis completed'
};
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
console.warn('YouTube analysis fallback (using default description):', message);
return {
enhancedDescription: description,
tags: [],
segments: [],
reasoning: 'Fallback description used'
};
}
}
async function summariseTranscript(title: string, transcript: string): Promise<string | null> {
if (!transcript || transcript.trim().length === 0) {
return null;
}
// Limit transcript length to keep token costs manageable (approx ≤4k tokens for gpt-4o-mini)
const MAX_CHARS = 16000;
let excerpt = transcript.trim();
if (excerpt.length > MAX_CHARS) {
const head = excerpt.slice(0, MAX_CHARS / 2);
const tail = excerpt.slice(-MAX_CHARS / 2);
excerpt = `${head}\n[...]\n${tail}`;
}
const prompt = `You are summarising a long-form recording for a knowledge graph entry. Title: "${title}".
Using the transcript excerpt below, write a concise 3-4 sentence summary covering the main themes, notable claims, and outcomes. If specific terms, frameworks, or memorable lines appear, mention them. Keep the tone factual (no marketing language). If the excerpt appears truncated, note that the summary is based on the portion provided.
Transcript excerpt:
"""
${excerpt}
"""
`;
try {
const response = await generateText({
model: openai('gpt-4o-mini'),
prompt,
maxOutputTokens: 400
});
return response.text?.trim() || null;
} catch (error) {
console.warn('Transcript summarisation failed, falling back to AI analysis description:', error);
return null;
}
}
export const youtubeExtractTool = tool({
description: 'Extract a YouTube transcript and metadata, create a node, and return summary details',
inputSchema: z.object({
url: z.string().describe('The YouTube video URL to add to knowledge base'),
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node (locked dimensions first)')
}),
execute: async ({ url, title, dimensions }) => {
console.log('🎯 YouTubeExtract tool called with URL:', url);
try {
// Validate YouTube URL
if (!url.includes('youtube.com') && !url.includes('youtu.be')) {
return {
success: false,
error: 'Invalid YouTube URL format',
data: null
};
}
let result: { success: boolean; content?: string; chunk?: string; metadata?: any; error?: string };
console.log('📝 Using TypeScript yt-dlp extractor');
try {
const extractionResult = await extractYouTube(url);
result = {
success: extractionResult.success,
content: extractionResult.content,
chunk: extractionResult.chunk,
metadata: {
video_title: extractionResult.metadata.video_title,
channel_name: extractionResult.metadata.channel_name,
channel_url: extractionResult.metadata.channel_url,
thumbnail_url: extractionResult.metadata.thumbnail_url,
video_id: extractionResult.metadata.video_id,
transcript_length: extractionResult.metadata.transcript_length,
total_segments: extractionResult.metadata.total_segments,
language: extractionResult.metadata.language,
extraction_method: extractionResult.metadata.extraction_method
},
error: extractionResult.error
};
} catch (error: any) {
result = {
success: false,
error: error.message || 'TypeScript extraction failed'
};
}
if (!result.success || (!result.content && !result.chunk)) {
return {
success: false,
error: result.error || 'Failed to extract YouTube content',
data: null
};
}
console.log('🎯 YouTube extraction successful, analyzing with AI...');
// Step 2: AI Analysis for enhanced metadata
const aiAnalysis = await analyzeContentWithAI(
result.metadata?.video_title || 'YouTube Video',
`Video by ${result.metadata?.channel_name || 'Unknown Channel'}`,
'youtube'
);
// Step 3: Create node with extracted content and AI analysis
const nodeTitle = title || result.metadata?.video_title || `YouTube Video ${url.split('/').pop()?.split('?')[0]}`;
const transcriptSummary = await summariseTranscript(nodeTitle, result.chunk || result.content || '');
const content = transcriptSummary || aiAnalysis?.enhancedDescription || `YouTube video by ${result.metadata?.channel_name || 'Unknown Channel'}`;
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
let trimmedDimensions = suppliedDimensions
.map(dim => (typeof dim === 'string' ? dim.trim() : ''))
.filter(Boolean);
trimmedDimensions = trimmedDimensions.slice(0, 5);
const createResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: nodeTitle,
content,
link: url,
dimensions: trimmedDimensions,
chunk: result.chunk || result.content,
metadata: {
source: 'youtube',
video_id: result.metadata?.video_id,
channel_name: result.metadata?.channel_name,
channel_url: result.metadata?.channel_url,
thumbnail_url: result.metadata?.thumbnail_url,
transcript_length: result.metadata?.transcript_length,
total_segments: result.metadata?.total_segments,
language: result.metadata?.language,
extraction_method: result.metadata?.extraction_method,
ai_analysis: aiAnalysis?.reasoning,
summary_origin: transcriptSummary ? 'transcript_summary' : 'metadata_description',
refined_at: new Date().toISOString()
}
})
});
const createResult = await createResponse.json();
if (!createResponse.ok) {
return {
success: false,
error: createResult.error || 'Failed to create item',
data: null
};
}
console.log('🎯 YouTubeExtract completed successfully');
const formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: trimmedDimensions || [] })
: nodeTitle;
const dimsDisplay = trimmedDimensions.length > 0 ? trimmedDimensions.join(', ') : 'none';
return {
success: true,
message: `Added ${formattedNode} with dimensions: ${dimsDisplay}`,
data: {
nodeId: createResult.data?.id,
title: nodeTitle,
contentLength: (result.chunk || result.content || '').length,
url: url,
dimensions: trimmedDimensions
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to extract YouTube content',
data: null
};
}
}
});