sync: Workflows P2 features from private repo
- 5 bundled workflows (prep, research, connect, integrate, survey) - quickLink tool for fast edge creation - queryDimensionNodes tool for dimension queries - Workflow orchestration tools (list, get, edit) - Dimension awareness with active dimension tracking - Unified Workflows tab in Agent Panel - MCP server updates for workflow execution Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
3951d9daf4
commit
f9271aeeb4
@@ -0,0 +1,74 @@
|
||||
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 queryDimensionNodesTool = tool({
|
||||
description: 'Query all nodes within a specific dimension. Returns nodes sorted by edge count (most connected first).',
|
||||
inputSchema: z.object({
|
||||
dimension: z.string().describe('The dimension name to query nodes from'),
|
||||
limit: z.number().optional().default(20).describe('Maximum number of nodes to return (default: 20)'),
|
||||
offset: z.number().optional().default(0).describe('Number of nodes to skip for pagination'),
|
||||
includeContent: z.boolean().optional().default(false).describe('Include truncated content preview (default: false)'),
|
||||
}),
|
||||
execute: async ({ dimension, limit = 20, offset = 0, includeContent = false }) => {
|
||||
try {
|
||||
// Query nodes with this dimension
|
||||
const nodes = await nodeService.getNodes({
|
||||
dimensions: [dimension],
|
||||
limit,
|
||||
offset,
|
||||
sortBy: 'edges',
|
||||
});
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
dimension,
|
||||
nodes: [],
|
||||
total: 0,
|
||||
message: `No nodes found in dimension "${dimension}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const formattedNodes = nodes.map((node: Node) => {
|
||||
const formatted: Record<string, unknown> = {
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
label: formatNodeForChat({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || [],
|
||||
}),
|
||||
edgeCount: node.edge_count || 0,
|
||||
dimensions: node.dimensions || [],
|
||||
};
|
||||
|
||||
if (includeContent && node.content) {
|
||||
// Truncate to ~100 chars
|
||||
formatted.contentPreview = node.content.length > 100
|
||||
? node.content.substring(0, 100) + '...'
|
||||
: node.content;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
dimension,
|
||||
nodes: formattedNodes,
|
||||
total: nodes.length,
|
||||
hasMore: nodes.length >= limit,
|
||||
message: `Found ${nodes.length} nodes in dimension "${dimension}"`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('queryDimensionNodes error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to query dimension nodes',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { edgeService } from '@/services/database/edges';
|
||||
|
||||
/**
|
||||
* Quick Link Tool - Creates edges to related nodes in ONE operation
|
||||
* No agent loop, no LLM reasoning - just fast database operations
|
||||
*/
|
||||
export const quickLinkTool = tool({
|
||||
description: 'Instantly find and link related nodes based on title/content matches. Fast - no AI reasoning, just database search and edge creation.',
|
||||
inputSchema: z.object({
|
||||
node_id: z.number().describe('The node ID to find connections for'),
|
||||
max_edges: z.number().optional().default(3).describe('Maximum edges to create (default 3)'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const { node_id, max_edges = 3 } = params;
|
||||
try {
|
||||
// 1. Get the source node
|
||||
const sourceNode = await nodeService.getNodeById(node_id);
|
||||
if (!sourceNode) {
|
||||
return { success: false, error: `Node ${node_id} not found`, edgesCreated: 0 };
|
||||
}
|
||||
|
||||
// 2. Extract search terms from title (split on common separators)
|
||||
const title = sourceNode.title || '';
|
||||
const searchTerms = extractSearchTerms(title);
|
||||
|
||||
if (searchTerms.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'No searchable terms found in title',
|
||||
edgesCreated: 0,
|
||||
searchTerms: []
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Search for related nodes using each term
|
||||
const foundNodeIds = new Set<number>();
|
||||
const matchDetails: Array<{ nodeId: number; title: string; matchedTerm: string }> = [];
|
||||
|
||||
for (const term of searchTerms.slice(0, 3)) { // Max 3 search terms
|
||||
const results = await nodeService.getNodes({
|
||||
search: term,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
for (const node of results) {
|
||||
// Skip self and already found
|
||||
if (node.id === node_id || foundNodeIds.has(node.id)) continue;
|
||||
|
||||
foundNodeIds.add(node.id);
|
||||
matchDetails.push({
|
||||
nodeId: node.id,
|
||||
title: node.title || `Node ${node.id}`,
|
||||
matchedTerm: term
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Get existing edges to avoid duplicates
|
||||
const existingConnections = await edgeService.getNodeConnections(node_id);
|
||||
const existingTargets = new Set(existingConnections.map(c => c.connected_node.id));
|
||||
|
||||
// 5. Create edges for top matches (excluding existing)
|
||||
const edgesToCreate = matchDetails
|
||||
.filter(m => !existingTargets.has(m.nodeId))
|
||||
.slice(0, max_edges);
|
||||
|
||||
const createdEdges: Array<{ toNodeId: number; toTitle: string }> = [];
|
||||
|
||||
for (const match of edgesToCreate) {
|
||||
try {
|
||||
await edgeService.createEdge({
|
||||
from_node_id: node_id,
|
||||
to_node_id: match.nodeId,
|
||||
source: 'helper_name',
|
||||
context: { quickLink: true, matchedTerm: match.matchedTerm }
|
||||
});
|
||||
createdEdges.push({ toNodeId: match.nodeId, toTitle: match.title });
|
||||
} catch (err) {
|
||||
// Edge might already exist, continue
|
||||
console.warn(`[quickLink] Failed to create edge to ${match.nodeId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Return summary
|
||||
const skippedCount = matchDetails.filter(m => existingTargets.has(m.nodeId)).length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sourceNode: { id: node_id, title: sourceNode.title },
|
||||
searchTerms,
|
||||
edgesCreated: createdEdges.length,
|
||||
edges: createdEdges,
|
||||
skippedExisting: skippedCount,
|
||||
totalMatches: matchDetails.length,
|
||||
message: createdEdges.length > 0
|
||||
? `Linked to ${createdEdges.length} nodes: ${createdEdges.map(e => e.toTitle).join(', ')}`
|
||||
: skippedCount > 0
|
||||
? `Found ${skippedCount} matches but edges already exist`
|
||||
: `No related nodes found for: ${searchTerms.join(', ')}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Quick link failed',
|
||||
edgesCreated: 0
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract meaningful search terms from a title
|
||||
* Looks for: names, quoted phrases, capitalized words, etc.
|
||||
*/
|
||||
function extractSearchTerms(title: string): string[] {
|
||||
const terms: string[] = [];
|
||||
|
||||
// 1. Extract quoted phrases
|
||||
const quotedMatches = title.match(/["']([^"']+)["']/g);
|
||||
if (quotedMatches) {
|
||||
terms.push(...quotedMatches.map(m => m.replace(/["']/g, '').trim()));
|
||||
}
|
||||
|
||||
// 2. Extract potential names (2-3 capitalized words in sequence)
|
||||
const namePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2})\b/g;
|
||||
let match;
|
||||
while ((match = namePattern.exec(title)) !== null) {
|
||||
const name = match[1];
|
||||
// Skip common non-name phrases
|
||||
if (!isCommonPhrase(name)) {
|
||||
terms.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Extract standalone capitalized words (potential company/project names)
|
||||
const words = title.split(/\s+/);
|
||||
for (const word of words) {
|
||||
const cleaned = word.replace(/[^a-zA-Z0-9]/g, '');
|
||||
if (cleaned.length > 2 && /^[A-Z]/.test(cleaned) && !terms.includes(cleaned)) {
|
||||
terms.push(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
// Dedupe and limit
|
||||
const unique = [...new Set(terms)].filter(t => t.length > 2);
|
||||
return unique.slice(0, 5);
|
||||
}
|
||||
|
||||
function isCommonPhrase(phrase: string): boolean {
|
||||
const common = [
|
||||
'The', 'How', 'What', 'Why', 'When', 'Where', 'Which',
|
||||
'New', 'First', 'Last', 'Next', 'This', 'That',
|
||||
'Part', 'Chapter', 'Section', 'Episode'
|
||||
];
|
||||
return common.some(c => phrase.startsWith(c + ' ') || phrase === c);
|
||||
}
|
||||
@@ -37,6 +37,9 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
||||
queryNodes: 'core',
|
||||
getNodesById: 'core',
|
||||
queryEdge: 'core',
|
||||
queryDimensions: 'core',
|
||||
getDimension: 'core',
|
||||
queryDimensionNodes: 'core',
|
||||
searchContentEmbeddings: 'core',
|
||||
|
||||
// Orchestration: Delegation and reasoning (orchestrator only)
|
||||
@@ -47,12 +50,16 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
||||
delegateNodeComparison: 'orchestration',
|
||||
delegateToWiseRAH: 'orchestration',
|
||||
executeWorkflow: 'orchestration',
|
||||
listWorkflows: 'orchestration',
|
||||
getWorkflow: 'orchestration',
|
||||
editWorkflow: 'orchestration',
|
||||
|
||||
// Execution: Write operations and extraction (workers only)
|
||||
createNode: 'execution',
|
||||
updateNode: 'execution',
|
||||
createEdge: 'execution',
|
||||
updateEdge: 'execution',
|
||||
quickLink: 'execution',
|
||||
embedContent: 'execution',
|
||||
youtubeExtract: 'execution',
|
||||
websiteExtract: 'execution',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { updateNodeTool } from '../database/updateNode';
|
||||
import { createEdgeTool } from '../database/createEdge';
|
||||
import { queryEdgeTool } from '../database/queryEdge';
|
||||
import { updateEdgeTool } from '../database/updateEdge';
|
||||
import { quickLinkTool } from '../database/quickLink';
|
||||
import { createDimensionTool } from '../database/createDimension';
|
||||
import { updateDimensionTool } from '../database/updateDimension';
|
||||
import { lockDimensionTool } from '../database/lockDimension';
|
||||
@@ -13,6 +14,7 @@ import { unlockDimensionTool } from '../database/unlockDimension';
|
||||
import { deleteDimensionTool } from '../database/deleteDimension';
|
||||
import { queryDimensionsTool } from '../database/queryDimensions';
|
||||
import { getDimensionTool } from '../database/getDimension';
|
||||
import { queryDimensionNodesTool } from '../database/queryDimensionNodes';
|
||||
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
|
||||
import { webSearchTool } from '../other/webSearch';
|
||||
import { thinkTool } from '../other/think';
|
||||
@@ -20,6 +22,9 @@ import { delegateToMiniRAHTool } from '../orchestration/delegateToMiniRAH';
|
||||
import { delegateNodeQuotesTool, delegateNodeComparisonTool } from '../orchestration/delegationHelpers';
|
||||
import { delegateToWiseRAHTool } from '../orchestration/delegateToWiseRAH';
|
||||
import { executeWorkflowTool } from '../orchestration/executeWorkflow';
|
||||
import { listWorkflowsTool } from '../orchestration/listWorkflows';
|
||||
import { getWorkflowTool } from '../orchestration/getWorkflow';
|
||||
import { editWorkflowTool } from '../orchestration/editWorkflow';
|
||||
import { youtubeExtractTool } from '../other/youtubeExtract';
|
||||
import { websiteExtractTool } from '../other/websiteExtract';
|
||||
import { paperExtractTool } from '../other/paperExtract';
|
||||
@@ -32,6 +37,7 @@ const CORE_TOOLS: Record<string, any> = {
|
||||
queryEdge: queryEdgeTool,
|
||||
queryDimensions: queryDimensionsTool,
|
||||
getDimension: getDimensionTool,
|
||||
queryDimensionNodes: queryDimensionNodesTool,
|
||||
searchContentEmbeddings: searchContentEmbeddingsTool,
|
||||
};
|
||||
|
||||
@@ -43,6 +49,9 @@ const ORCHESTRATION_TOOLS: Record<string, any> = {
|
||||
delegateNodeComparison: delegateNodeComparisonTool,
|
||||
delegateToWiseRAH: delegateToWiseRAHTool,
|
||||
executeWorkflow: executeWorkflowTool,
|
||||
listWorkflows: listWorkflowsTool,
|
||||
getWorkflow: getWorkflowTool,
|
||||
editWorkflow: editWorkflowTool,
|
||||
};
|
||||
|
||||
// Execution tools for worker agents (includes write operations)
|
||||
@@ -51,6 +60,7 @@ const EXECUTION_TOOLS: Record<string, any> = {
|
||||
updateNode: updateNodeTool,
|
||||
createEdge: createEdgeTool,
|
||||
updateEdge: updateEdgeTool,
|
||||
quickLink: quickLinkTool,
|
||||
createDimension: createDimensionTool,
|
||||
updateDimension: updateDimensionTool,
|
||||
lockDimension: lockDimensionTool,
|
||||
@@ -78,10 +88,14 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
|
||||
'webSearch',
|
||||
'think',
|
||||
'executeWorkflow',
|
||||
'listWorkflows',
|
||||
'getWorkflow',
|
||||
'editWorkflow',
|
||||
'createNode',
|
||||
'updateNode',
|
||||
'createEdge',
|
||||
'updateEdge',
|
||||
'quickLink',
|
||||
'createDimension',
|
||||
'updateDimension',
|
||||
'lockDimension',
|
||||
@@ -110,7 +124,9 @@ const PLANNER_TOOL_NAMES = [
|
||||
'think',
|
||||
'delegateToMiniRAH',
|
||||
'updateNode', // For workflow execution (integrate workflow needs direct write access)
|
||||
'createEdge', // For Quick Link workflow
|
||||
'createEdge', // For edge creation in workflows
|
||||
'quickLink', // Fast edge creation via text search
|
||||
'updateDimension', // For survey workflow (dimension analysis)
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { WorkflowRegistry } from '@/services/workflows/registry';
|
||||
import { saveWorkflow, loadUserWorkflow } from '@/services/workflows/workflowFileService';
|
||||
|
||||
export const editWorkflowTool = tool({
|
||||
description: 'Update a workflow definition. Use to refine instructions, update description, or toggle enabled state.',
|
||||
inputSchema: z.object({
|
||||
workflowKey: z.string().describe('The key of the workflow to edit'),
|
||||
updates: z.object({
|
||||
displayName: z.string().optional().describe('New display name'),
|
||||
description: z.string().optional().describe('New description'),
|
||||
instructions: z.string().optional().describe('New instructions (full replacement)'),
|
||||
enabled: z.boolean().optional().describe('Enable or disable the workflow'),
|
||||
requiresFocusedNode: z.boolean().optional().describe('Whether workflow requires a focused node'),
|
||||
}).describe('Fields to update'),
|
||||
}),
|
||||
execute: async ({ workflowKey, updates }) => {
|
||||
try {
|
||||
// Get existing workflow (from user file or bundled default)
|
||||
const existing = await WorkflowRegistry.getWorkflowByKey(workflowKey);
|
||||
|
||||
if (!existing) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Workflow '${workflowKey}' not found`,
|
||||
};
|
||||
}
|
||||
|
||||
// Merge updates with existing values
|
||||
const updated = {
|
||||
key: workflowKey,
|
||||
displayName: updates.displayName ?? existing.displayName,
|
||||
description: updates.description ?? existing.description,
|
||||
instructions: updates.instructions ?? existing.instructions,
|
||||
enabled: updates.enabled ?? existing.enabled,
|
||||
requiresFocusedNode: updates.requiresFocusedNode ?? existing.requiresFocusedNode,
|
||||
};
|
||||
|
||||
// Save to user workflow file (this will override bundled if same key)
|
||||
saveWorkflow(updated);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Workflow '${workflowKey}' updated`,
|
||||
workflow: {
|
||||
key: updated.key,
|
||||
displayName: updated.displayName,
|
||||
description: updated.description,
|
||||
enabled: updated.enabled,
|
||||
requiresFocusedNode: updated.requiresFocusedNode,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('editWorkflow error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to edit workflow',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { WorkflowRegistry, BUNDLED_WORKFLOW_KEYS } from '@/services/workflows/registry';
|
||||
import { userWorkflowExists } from '@/services/workflows/workflowFileService';
|
||||
|
||||
export const getWorkflowTool = tool({
|
||||
description: 'Retrieve a workflow definition including its full instructions',
|
||||
inputSchema: z.object({
|
||||
workflowKey: z.string().describe('The key of the workflow to retrieve (e.g., "integrate", "prep")'),
|
||||
}),
|
||||
execute: async ({ workflowKey }) => {
|
||||
try {
|
||||
const workflow = await WorkflowRegistry.getWorkflowByKey(workflowKey);
|
||||
|
||||
if (!workflow) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Workflow '${workflowKey}' not found`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflow: {
|
||||
key: workflow.key,
|
||||
displayName: workflow.displayName,
|
||||
description: workflow.description,
|
||||
instructions: workflow.instructions,
|
||||
enabled: workflow.enabled,
|
||||
requiresFocusedNode: workflow.requiresFocusedNode,
|
||||
isBundled: BUNDLED_WORKFLOW_KEYS.has(workflow.key),
|
||||
hasUserOverride: userWorkflowExists(workflow.key),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getWorkflow error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get workflow',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { WorkflowRegistry, BUNDLED_WORKFLOW_KEYS } from '@/services/workflows/registry';
|
||||
|
||||
export const listWorkflowsTool = tool({
|
||||
description: 'List all available workflows',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
try {
|
||||
const workflows = await WorkflowRegistry.getAllWorkflows();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflows: workflows.map(w => ({
|
||||
key: w.key,
|
||||
displayName: w.displayName,
|
||||
description: w.description,
|
||||
enabled: w.enabled,
|
||||
requiresFocusedNode: w.requiresFocusedNode,
|
||||
isBundled: BUNDLED_WORKFLOW_KEYS.has(w.key),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('listWorkflows error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list workflows',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user