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:
“BeeRad”
2026-01-11 18:54:34 +11:00
co-authored by Claude Opus 4.5
parent 3951d9daf4
commit f9271aeeb4
24 changed files with 2842 additions and 153 deletions
+74
View File
@@ -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',
};
}
},
});
+159
View File
@@ -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);
}
+7
View File
@@ -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',
+17 -1
View File
@@ -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)
];
/**
+62
View File
@@ -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',
};
}
},
});
+43
View File
@@ -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',
};
}
},
});
+31
View File
@@ -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',
};
}
},
});