@ema.co/mcp-toolkit 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @ema.co/mcp-toolkit might be problematic. Click here for more details.

@@ -0,0 +1,665 @@
1
+ /**
2
+ * Workflow Optimizer - Proactive Intelligence Engine
3
+ *
4
+ * Goes beyond issue detection to find optimization opportunities,
5
+ * suggest improvements, and calculate workflow health scores.
6
+ * This is the "brains" that makes workflows better.
7
+ */
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+ // Main Entry Point
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ /**
12
+ * Analyze workflow for optimization opportunities
13
+ */
14
+ export function analyzeOptimizations(workflow) {
15
+ const actions = workflow.actions || [];
16
+ const graph = buildDependencyGraph(actions);
17
+ const metrics = calculateMetrics(actions, graph);
18
+ const optimizations = [];
19
+ // 1. Find consolidation opportunities (duplicate/redundant nodes)
20
+ optimizations.push(...findConsolidationOpportunities(actions, graph));
21
+ // 2. Find parallelization opportunities
22
+ optimizations.push(...findParallelizationOpportunities(actions, graph));
23
+ // 3. Find passthrough nodes to eliminate
24
+ optimizations.push(...findPassthroughNodes(actions, graph));
25
+ // 4. Find error handling gaps
26
+ optimizations.push(...findErrorHandlingGaps(actions, graph));
27
+ // 5. Find LLM call optimizations
28
+ optimizations.push(...findLlmOptimizations(actions, graph));
29
+ // 6. Find search optimizations
30
+ optimizations.push(...findSearchOptimizations(actions, graph));
31
+ // 7. Find naming improvements
32
+ optimizations.push(...findNamingImprovements(actions));
33
+ // 8. Find safety improvements (HITL for side effects)
34
+ optimizations.push(...findSafetyImprovements(actions, graph));
35
+ // Calculate health score
36
+ const healthScore = calculateWorkflowHealth(actions, metrics, optimizations);
37
+ // Categorize optimizations
38
+ const quickWins = optimizations.filter(o => o.auto_applicable && o.risk === "none" && o.severity !== "suggestion");
39
+ const strategic = optimizations.filter(o => !o.auto_applicable || o.risk !== "none");
40
+ // Generate recommendations
41
+ const recommendations = generateRecommendations(optimizations, metrics, healthScore);
42
+ return {
43
+ workflow_health_score: healthScore,
44
+ grade: healthScoreToGrade(healthScore),
45
+ optimizations,
46
+ quick_wins: quickWins,
47
+ strategic,
48
+ metrics,
49
+ recommendations,
50
+ };
51
+ }
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+ // Graph Building
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ function buildDependencyGraph(actions) {
56
+ const nodes = new Map();
57
+ const edges = new Map();
58
+ const reverseEdges = new Map();
59
+ // Initialize nodes
60
+ for (const action of actions) {
61
+ const actionType = action.action?.name?.name || "unknown";
62
+ nodes.set(action.name, {
63
+ id: action.name,
64
+ type: actionType,
65
+ depth: 0,
66
+ inputs: [],
67
+ outputs: [],
68
+ });
69
+ edges.set(action.name, new Set());
70
+ reverseEdges.set(action.name, new Set());
71
+ }
72
+ // Build edges from input bindings
73
+ for (const action of actions) {
74
+ const node = nodes.get(action.name);
75
+ if (action.inputs) {
76
+ for (const [inputName, binding] of Object.entries(action.inputs)) {
77
+ const bindingObj = binding;
78
+ const actionOutput = bindingObj?.actionOutput;
79
+ if (actionOutput?.actionName) {
80
+ const sourceNode = String(actionOutput.actionName);
81
+ node.inputs.push(sourceNode);
82
+ // Source → Target edge
83
+ edges.get(sourceNode)?.add(action.name);
84
+ reverseEdges.get(action.name)?.add(sourceNode);
85
+ // Update source node outputs
86
+ const sourceGraphNode = nodes.get(sourceNode);
87
+ if (sourceGraphNode) {
88
+ sourceGraphNode.outputs.push(action.name);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ // Calculate depths (BFS from trigger)
95
+ const trigger = actions.find(a => a.action?.name?.name?.includes("trigger"));
96
+ if (trigger) {
97
+ const visited = new Set();
98
+ const queue = [{ node: trigger.name, depth: 0 }];
99
+ while (queue.length > 0) {
100
+ const { node, depth } = queue.shift();
101
+ if (visited.has(node))
102
+ continue;
103
+ visited.add(node);
104
+ const graphNode = nodes.get(node);
105
+ if (graphNode) {
106
+ graphNode.depth = depth;
107
+ }
108
+ for (const child of edges.get(node) || []) {
109
+ if (!visited.has(child)) {
110
+ queue.push({ node: child, depth: depth + 1 });
111
+ }
112
+ }
113
+ }
114
+ }
115
+ return { nodes, edges, reverseEdges };
116
+ }
117
+ // ─────────────────────────────────────────────────────────────────────────────
118
+ // Metrics Calculation
119
+ // ─────────────────────────────────────────────────────────────────────────────
120
+ function calculateMetrics(actions, graph) {
121
+ // Count node types
122
+ const nodeTypes = {};
123
+ for (const action of actions) {
124
+ const type = action.action?.name?.name || "unknown";
125
+ nodeTypes[type] = (nodeTypes[type] || 0) + 1;
126
+ }
127
+ // Calculate max depth
128
+ const maxDepth = Math.max(...Array.from(graph.nodes.values()).map(n => n.depth), 0);
129
+ // Count branches (nodes with multiple outputs)
130
+ const branchCount = Array.from(graph.nodes.values()).filter(n => n.outputs.length > 1).length;
131
+ // Count parallel opportunities (nodes at same depth that could run together)
132
+ const nodesByDepth = new Map();
133
+ for (const [id, node] of graph.nodes) {
134
+ const nodes = nodesByDepth.get(node.depth) || [];
135
+ nodes.push(id);
136
+ nodesByDepth.set(node.depth, nodes);
137
+ }
138
+ const parallelOpportunities = Array.from(nodesByDepth.values())
139
+ .filter(nodes => nodes.length > 1).length;
140
+ // Calculate redundancy score
141
+ const redundancyScore = calculateRedundancyScore(actions, graph);
142
+ // Calculate complexity score
143
+ const complexityScore = calculateComplexityScore(actions, graph, maxDepth, branchCount);
144
+ // Count sequential latency factors (LLM + search calls on critical path)
145
+ const latencyFactors = countLatencyFactors(actions, graph);
146
+ return {
147
+ total_nodes: actions.length,
148
+ node_types: nodeTypes,
149
+ max_depth: maxDepth,
150
+ branch_count: branchCount,
151
+ parallel_opportunities: parallelOpportunities,
152
+ redundancy_score: redundancyScore,
153
+ complexity_score: complexityScore,
154
+ estimated_latency_factors: latencyFactors,
155
+ };
156
+ }
157
+ function calculateRedundancyScore(actions, graph) {
158
+ let redundancy = 0;
159
+ // Check for duplicate node types with same inputs
160
+ const nodeSignatures = new Map();
161
+ for (const action of actions) {
162
+ const type = action.action?.name?.name || "unknown";
163
+ const node = graph.nodes.get(action.name);
164
+ const signature = `${type}:${node?.inputs.sort().join(",")}`;
165
+ const existing = nodeSignatures.get(signature) || [];
166
+ existing.push(action.name);
167
+ nodeSignatures.set(signature, existing);
168
+ }
169
+ // Each duplicate adds to redundancy
170
+ for (const [_, nodes] of nodeSignatures) {
171
+ if (nodes.length > 1) {
172
+ redundancy += (nodes.length - 1) * 10;
173
+ }
174
+ }
175
+ return Math.min(redundancy, 100);
176
+ }
177
+ function calculateComplexityScore(actions, graph, maxDepth, branchCount) {
178
+ // Base complexity from node count
179
+ let complexity = Math.min(actions.length * 5, 30);
180
+ // Add complexity for depth
181
+ complexity += Math.min(maxDepth * 5, 20);
182
+ // Add complexity for branching
183
+ complexity += Math.min(branchCount * 10, 30);
184
+ // Add complexity for conditional logic
185
+ const conditionalNodes = actions.filter(a => a.runIf).length;
186
+ complexity += Math.min(conditionalNodes * 5, 20);
187
+ return Math.min(complexity, 100);
188
+ }
189
+ function countLatencyFactors(actions, graph) {
190
+ // Count LLM and search nodes on the longest path
191
+ const latencyNodes = actions.filter(a => {
192
+ const type = a.action?.name?.name || "";
193
+ return type.includes("llm") ||
194
+ type.includes("respond") ||
195
+ type.includes("search") ||
196
+ type.includes("categorizer");
197
+ });
198
+ // Find the longest sequential chain of latency nodes
199
+ let maxSequential = 0;
200
+ for (const node of latencyNodes) {
201
+ let count = 1;
202
+ let current = node.name;
203
+ // Follow the dependency chain
204
+ while (true) {
205
+ const graphNode = graph.nodes.get(current);
206
+ if (!graphNode || graphNode.outputs.length === 0)
207
+ break;
208
+ // Find a latency node in outputs
209
+ const nextLatency = graphNode.outputs.find(out => latencyNodes.some(ln => ln.name === out));
210
+ if (!nextLatency)
211
+ break;
212
+ count++;
213
+ current = nextLatency;
214
+ }
215
+ maxSequential = Math.max(maxSequential, count);
216
+ }
217
+ return maxSequential;
218
+ }
219
+ // ─────────────────────────────────────────────────────────────────────────────
220
+ // Optimization Finders
221
+ // ─────────────────────────────────────────────────────────────────────────────
222
+ function findConsolidationOpportunities(actions, graph) {
223
+ const opportunities = [];
224
+ // Find duplicate search nodes with same query source
225
+ const searchNodes = actions.filter(a => a.action?.name?.name?.includes("search") &&
226
+ !a.action?.name?.name?.includes("combine"));
227
+ const searchByQuery = new Map();
228
+ for (const search of searchNodes) {
229
+ const querySource = getQuerySource(search);
230
+ const existing = searchByQuery.get(querySource) || [];
231
+ existing.push(search);
232
+ searchByQuery.set(querySource, existing);
233
+ }
234
+ for (const [query, duplicates] of searchByQuery) {
235
+ if (duplicates.length > 1) {
236
+ opportunities.push({
237
+ id: `consolidate-search-${hashString(query)}`,
238
+ type: "consolidate_nodes",
239
+ severity: "recommended",
240
+ title: "Consolidate Redundant Searches",
241
+ current_state: `${duplicates.length} search nodes using same query source: "${query}"`,
242
+ proposed_change: "Consolidate into single search node, share results via named_inputs",
243
+ benefit: `Reduce API calls by ${duplicates.length - 1}x, faster execution, consistent results`,
244
+ risk: "low",
245
+ auto_applicable: false, // Requires careful rewiring
246
+ affected_nodes: duplicates.map(d => d.name),
247
+ implementation_hint: "Keep one search, remove others, use named_inputs to pass results to all consumers",
248
+ });
249
+ }
250
+ }
251
+ // Find duplicate LLM calls with similar prompts
252
+ const llmNodes = actions.filter(a => a.action?.name?.name?.includes("llm") ||
253
+ a.action?.name?.name?.includes("respond"));
254
+ if (llmNodes.length > 2) {
255
+ // Check for sequential LLM calls
256
+ for (const node of llmNodes) {
257
+ const graphNode = graph.nodes.get(node.name);
258
+ if (graphNode) {
259
+ const llmChildren = graphNode.outputs.filter(out => llmNodes.some(ln => ln.name === out));
260
+ if (llmChildren.length > 0) {
261
+ opportunities.push({
262
+ id: `merge-llm-${node.name}`,
263
+ type: "merge_llm_calls",
264
+ severity: "suggestion",
265
+ title: "Consider Merging Sequential LLM Calls",
266
+ current_state: `${node.name} feeds into LLM node(s): ${llmChildren.join(", ")}`,
267
+ proposed_change: "Merge into single comprehensive LLM call with structured prompt",
268
+ benefit: "Reduce latency, potentially lower cost, more coherent output",
269
+ risk: "medium",
270
+ auto_applicable: false,
271
+ affected_nodes: [node.name, ...llmChildren],
272
+ implementation_hint: "Combine prompts, use structured output format, single call_llm with all context",
273
+ });
274
+ }
275
+ }
276
+ }
277
+ }
278
+ return opportunities;
279
+ }
280
+ function findParallelizationOpportunities(actions, graph) {
281
+ const opportunities = [];
282
+ // Find nodes at same depth that share a parent but are sequential
283
+ const nodesByParent = new Map();
284
+ for (const [nodeId, graphNode] of graph.nodes) {
285
+ for (const input of graphNode.inputs) {
286
+ const siblings = nodesByParent.get(input) || [];
287
+ siblings.push(nodeId);
288
+ nodesByParent.set(input, siblings);
289
+ }
290
+ }
291
+ // Check for sequential siblings that could be parallel
292
+ for (const [parent, children] of nodesByParent) {
293
+ if (children.length > 1) {
294
+ // Check if they're actually sequential (one depends on another)
295
+ const sequential = children.filter(child => {
296
+ const node = graph.nodes.get(child);
297
+ return node && children.some(other => other !== child && node.inputs.includes(other));
298
+ });
299
+ if (sequential.length > 0 && sequential.length < children.length) {
300
+ opportunities.push({
301
+ id: `parallelize-${parent}`,
302
+ type: "parallelize_paths",
303
+ severity: "suggestion",
304
+ title: "Parallelization Opportunity",
305
+ current_state: `Children of ${parent} could potentially run in parallel`,
306
+ proposed_change: "Restructure so independent operations branch from same source",
307
+ benefit: "Reduced latency through parallel execution",
308
+ risk: "low",
309
+ auto_applicable: false,
310
+ affected_nodes: [parent, ...children],
311
+ });
312
+ }
313
+ }
314
+ }
315
+ return opportunities;
316
+ }
317
+ function findPassthroughNodes(actions, graph) {
318
+ const opportunities = [];
319
+ // Find nodes that just pass data through without transformation
320
+ for (const action of actions) {
321
+ const graphNode = graph.nodes.get(action.name);
322
+ if (!graphNode)
323
+ continue;
324
+ // Skip trigger and output nodes
325
+ const type = action.action?.name?.name || "";
326
+ if (type.includes("trigger") || graphNode.outputs.length === 0)
327
+ continue;
328
+ // Check if this node has single input and single output with no transformation
329
+ if (graphNode.inputs.length === 1 && graphNode.outputs.length === 1) {
330
+ // Check if it's a simple passthrough (no significant configuration)
331
+ const inputCount = Object.keys(action.inputs || {}).length;
332
+ const hasTransformation = type.includes("llm") ||
333
+ type.includes("search") ||
334
+ type.includes("categorizer") ||
335
+ type.includes("extract");
336
+ if (!hasTransformation && inputCount <= 2) {
337
+ opportunities.push({
338
+ id: `eliminate-passthrough-${action.name}`,
339
+ type: "eliminate_passthrough",
340
+ severity: "suggestion",
341
+ title: "Potential Passthrough Node",
342
+ current_state: `${action.name} appears to pass data without significant transformation`,
343
+ proposed_change: "Consider removing and connecting predecessor directly to successor",
344
+ benefit: "Simpler workflow, reduced complexity",
345
+ risk: "low",
346
+ auto_applicable: false,
347
+ affected_nodes: [action.name],
348
+ });
349
+ }
350
+ }
351
+ }
352
+ return opportunities;
353
+ }
354
+ function findErrorHandlingGaps(actions, graph) {
355
+ const opportunities = [];
356
+ // Check for external action callers without error handling
357
+ const externalCallers = actions.filter(a => a.action?.name?.name?.includes("external") ||
358
+ a.action?.name?.name?.includes("email") ||
359
+ a.action?.name?.name?.includes("tool"));
360
+ for (const caller of externalCallers) {
361
+ // Check if there's error handling downstream
362
+ const graphNode = graph.nodes.get(caller.name);
363
+ const hasErrorPath = graphNode?.outputs.some(out => {
364
+ const action = actions.find(a => a.name === out);
365
+ return action?.runIf && JSON.stringify(action.runIf).includes("error");
366
+ });
367
+ if (!hasErrorPath) {
368
+ opportunities.push({
369
+ id: `add-error-handling-${caller.name}`,
370
+ type: "add_error_handling",
371
+ severity: "recommended",
372
+ title: "Add Error Handling",
373
+ current_state: `External action ${caller.name} has no error handling path`,
374
+ proposed_change: "Add conditional path for handling failures gracefully",
375
+ benefit: "Better user experience when external services fail",
376
+ risk: "none",
377
+ auto_applicable: false,
378
+ affected_nodes: [caller.name],
379
+ implementation_hint: "Add runIf condition checking for error status, route to fallback response",
380
+ });
381
+ }
382
+ }
383
+ return opportunities;
384
+ }
385
+ function findLlmOptimizations(actions, graph) {
386
+ const opportunities = [];
387
+ const llmNodes = actions.filter(a => a.action?.name?.name?.includes("llm") ||
388
+ a.action?.name?.name?.includes("respond"));
389
+ // Check for call_llm being used instead of respond_with_sources for search results
390
+ for (const node of llmNodes) {
391
+ const type = node.action?.name?.name || "";
392
+ const graphNode = graph.nodes.get(node.name);
393
+ if (type === "call_llm" && graphNode) {
394
+ // Check if input comes from search
395
+ const hasSearchInput = graphNode.inputs.some(input => {
396
+ const inputAction = actions.find(a => a.name === input);
397
+ return inputAction?.action?.name?.name?.includes("search");
398
+ });
399
+ if (hasSearchInput) {
400
+ opportunities.push({
401
+ id: `use-respond-with-sources-${node.name}`,
402
+ type: "improve_response_quality",
403
+ severity: "recommended",
404
+ title: "Use Purpose-Built Response Node",
405
+ current_state: `${node.name} uses call_llm for search-based responses`,
406
+ proposed_change: "Replace with respond_with_sources for better citation handling",
407
+ benefit: "Better citations, grounded responses, less configuration needed",
408
+ risk: "low",
409
+ auto_applicable: false,
410
+ affected_nodes: [node.name],
411
+ });
412
+ }
413
+ }
414
+ }
415
+ return opportunities;
416
+ }
417
+ function findSearchOptimizations(actions, graph) {
418
+ const opportunities = [];
419
+ // Check for web search without KB search
420
+ const hasKbSearch = actions.some(a => a.action?.name?.name === "search" ||
421
+ a.action?.name?.name?.includes("file_search"));
422
+ const hasWebSearch = actions.some(a => a.action?.name?.name?.includes("web_search") ||
423
+ a.action?.name?.name?.includes("live_web"));
424
+ if (hasWebSearch && !hasKbSearch) {
425
+ opportunities.push({
426
+ id: "add-kb-search",
427
+ type: "optimize_search",
428
+ severity: "recommended",
429
+ title: "Add Knowledge Base Search",
430
+ current_state: "Workflow uses web search but no internal KB search",
431
+ proposed_change: "Add KB search as primary source, use web search as supplement",
432
+ benefit: "Faster responses, more controlled content, reduced external dependencies",
433
+ risk: "none",
434
+ auto_applicable: false,
435
+ affected_nodes: actions.filter(a => a.action?.name?.name?.includes("web")).map(a => a.name),
436
+ });
437
+ }
438
+ return opportunities;
439
+ }
440
+ function findNamingImprovements(actions) {
441
+ const opportunities = [];
442
+ // Check for generic names
443
+ const genericPatterns = [
444
+ /^node_\d+$/i,
445
+ /^action_\d+$/i,
446
+ /^step_\d+$/i,
447
+ /^untitled/i,
448
+ /^new_/i,
449
+ ];
450
+ const poorlyNamed = actions.filter(a => genericPatterns.some(p => p.test(a.name)) ||
451
+ genericPatterns.some(p => p.test(a.actionDisplayName || "")));
452
+ if (poorlyNamed.length > 0) {
453
+ opportunities.push({
454
+ id: "improve-naming",
455
+ type: "improve_naming",
456
+ severity: "suggestion",
457
+ title: "Improve Node Naming",
458
+ current_state: `${poorlyNamed.length} nodes have generic names: ${poorlyNamed.map(n => n.name).join(", ")}`,
459
+ proposed_change: "Use descriptive names that indicate purpose (e.g., 'search_customer_faq', 'respond_billing_inquiry')",
460
+ benefit: "Better maintainability, easier debugging, clearer workflow understanding",
461
+ risk: "none",
462
+ auto_applicable: false,
463
+ affected_nodes: poorlyNamed.map(n => n.name),
464
+ });
465
+ }
466
+ return opportunities;
467
+ }
468
+ function findSafetyImprovements(actions, graph) {
469
+ const opportunities = [];
470
+ // Check for external actions without HITL
471
+ const sideEffectNodes = actions.filter(a => {
472
+ const type = a.action?.name?.name || "";
473
+ return type.includes("email") ||
474
+ type.includes("external") ||
475
+ type.includes("create_") ||
476
+ type.includes("update_") ||
477
+ type.includes("delete_");
478
+ });
479
+ const hitlNodes = actions.filter(a => a.action?.name?.name?.includes("hitl"));
480
+ for (const sideEffect of sideEffectNodes) {
481
+ const graphNode = graph.nodes.get(sideEffect.name);
482
+ if (!graphNode)
483
+ continue;
484
+ // Check if there's a HITL node upstream
485
+ const hasUpstreamHitl = graphNode.inputs.some(input => hitlNodes.some(h => h.name === input));
486
+ if (!hasUpstreamHitl) {
487
+ opportunities.push({
488
+ id: `add-hitl-${sideEffect.name}`,
489
+ type: "add_hitl_for_safety",
490
+ severity: "important",
491
+ title: "Add Human Approval Before Side Effect",
492
+ current_state: `${sideEffect.name} can execute without human confirmation`,
493
+ proposed_change: "Add HITL node before this action to confirm before execution",
494
+ benefit: "Prevent unintended external actions, maintain human oversight",
495
+ risk: "none",
496
+ auto_applicable: false,
497
+ affected_nodes: [sideEffect.name],
498
+ implementation_hint: "Insert general_hitl between the previous node and this action, add both success and failure paths",
499
+ });
500
+ }
501
+ }
502
+ return opportunities;
503
+ }
504
+ // ─────────────────────────────────────────────────────────────────────────────
505
+ // Health Score Calculation
506
+ // ─────────────────────────────────────────────────────────────────────────────
507
+ function calculateWorkflowHealth(actions, metrics, optimizations) {
508
+ let score = 100;
509
+ // Deduct for optimizations based on severity
510
+ for (const opt of optimizations) {
511
+ switch (opt.severity) {
512
+ case "important":
513
+ score -= 15;
514
+ break;
515
+ case "recommended":
516
+ score -= 8;
517
+ break;
518
+ case "suggestion":
519
+ score -= 3;
520
+ break;
521
+ }
522
+ }
523
+ // Deduct for high complexity
524
+ if (metrics.complexity_score > 70) {
525
+ score -= 10;
526
+ }
527
+ else if (metrics.complexity_score > 50) {
528
+ score -= 5;
529
+ }
530
+ // Deduct for high redundancy
531
+ if (metrics.redundancy_score > 30) {
532
+ score -= 10;
533
+ }
534
+ else if (metrics.redundancy_score > 10) {
535
+ score -= 5;
536
+ }
537
+ // Deduct for many sequential latency factors
538
+ if (metrics.estimated_latency_factors > 5) {
539
+ score -= 10;
540
+ }
541
+ else if (metrics.estimated_latency_factors > 3) {
542
+ score -= 5;
543
+ }
544
+ // Bonus for good practices
545
+ const hasTrigger = actions.some(a => a.action?.name?.name?.includes("trigger"));
546
+ const hasResponse = actions.some(a => a.action?.name?.name?.includes("respond") ||
547
+ a.action?.name?.name?.includes("llm"));
548
+ if (hasTrigger && hasResponse) {
549
+ score = Math.min(score + 5, 100);
550
+ }
551
+ return Math.max(score, 0);
552
+ }
553
+ function healthScoreToGrade(score) {
554
+ if (score >= 90)
555
+ return "A";
556
+ if (score >= 80)
557
+ return "B";
558
+ if (score >= 70)
559
+ return "C";
560
+ if (score >= 60)
561
+ return "D";
562
+ return "F";
563
+ }
564
+ // ─────────────────────────────────────────────────────────────────────────────
565
+ // Recommendations Generator
566
+ // ─────────────────────────────────────────────────────────────────────────────
567
+ function generateRecommendations(optimizations, metrics, healthScore) {
568
+ const recommendations = [];
569
+ // Top priority optimizations
570
+ const important = optimizations.filter(o => o.severity === "important");
571
+ if (important.length > 0) {
572
+ recommendations.push(`🔴 Address ${important.length} important issue(s): ${important.map(o => o.title).join(", ")}`);
573
+ }
574
+ // Quick wins
575
+ const quickWins = optimizations.filter(o => o.auto_applicable && o.risk === "none");
576
+ if (quickWins.length > 0) {
577
+ recommendations.push(`✅ ${quickWins.length} quick win(s) available for immediate improvement`);
578
+ }
579
+ // Complexity warning
580
+ if (metrics.complexity_score > 60) {
581
+ recommendations.push(`⚠️ High complexity (${metrics.complexity_score}/100). Consider simplifying or breaking into sub-workflows.`);
582
+ }
583
+ // Latency warning
584
+ if (metrics.estimated_latency_factors > 4) {
585
+ recommendations.push(`⏱️ High latency risk: ${metrics.estimated_latency_factors} sequential LLM/search calls. Consider parallelization.`);
586
+ }
587
+ // Redundancy warning
588
+ if (metrics.redundancy_score > 20) {
589
+ recommendations.push(`🔄 Redundancy detected (${metrics.redundancy_score}/100). Consider consolidating duplicate operations.`);
590
+ }
591
+ // Overall assessment
592
+ if (healthScore >= 90) {
593
+ recommendations.push("🌟 Excellent workflow health! Minor optimizations optional.");
594
+ }
595
+ else if (healthScore >= 70) {
596
+ recommendations.push("👍 Good workflow health. Apply recommended optimizations for best results.");
597
+ }
598
+ else if (healthScore >= 50) {
599
+ recommendations.push("⚠️ Workflow needs attention. Apply important fixes first.");
600
+ }
601
+ else {
602
+ recommendations.push("🚨 Workflow needs significant improvements. Review all recommendations.");
603
+ }
604
+ return recommendations;
605
+ }
606
+ // ─────────────────────────────────────────────────────────────────────────────
607
+ // Utilities
608
+ // ─────────────────────────────────────────────────────────────────────────────
609
+ function getQuerySource(action) {
610
+ const queryInput = action.inputs?.query;
611
+ const actionOutput = queryInput?.actionOutput;
612
+ if (actionOutput) {
613
+ return `${actionOutput.actionName}.${actionOutput.output}`;
614
+ }
615
+ return "unknown";
616
+ }
617
+ function hashString(str) {
618
+ let hash = 0;
619
+ for (let i = 0; i < str.length; i++) {
620
+ const char = str.charCodeAt(i);
621
+ hash = ((hash << 5) - hash) + char;
622
+ hash = hash & hash;
623
+ }
624
+ return Math.abs(hash).toString(36).substring(0, 8);
625
+ }
626
+ /**
627
+ * Get a human-readable summary of the optimization report
628
+ */
629
+ export function summarizeOptimizationReport(report) {
630
+ const lines = [];
631
+ lines.push(`## Workflow Health Report`);
632
+ lines.push("");
633
+ lines.push(`**Health Score**: ${report.workflow_health_score}/100 (Grade: ${report.grade})`);
634
+ lines.push("");
635
+ lines.push("### Metrics");
636
+ lines.push(`- Nodes: ${report.metrics.total_nodes}`);
637
+ lines.push(`- Max Depth: ${report.metrics.max_depth}`);
638
+ lines.push(`- Branches: ${report.metrics.branch_count}`);
639
+ lines.push(`- Complexity: ${report.metrics.complexity_score}/100`);
640
+ lines.push(`- Redundancy: ${report.metrics.redundancy_score}/100`);
641
+ lines.push(`- Latency Factors: ${report.metrics.estimated_latency_factors}`);
642
+ lines.push("");
643
+ if (report.quick_wins.length > 0) {
644
+ lines.push("### Quick Wins");
645
+ for (const opt of report.quick_wins) {
646
+ lines.push(`- **${opt.title}**: ${opt.proposed_change}`);
647
+ }
648
+ lines.push("");
649
+ }
650
+ if (report.strategic.length > 0) {
651
+ lines.push("### Strategic Improvements");
652
+ for (const opt of report.strategic) {
653
+ const severity = opt.severity === "important" ? "🔴" :
654
+ opt.severity === "recommended" ? "🟡" : "🟢";
655
+ lines.push(`${severity} **${opt.title}**: ${opt.current_state}`);
656
+ lines.push(` → ${opt.proposed_change}`);
657
+ }
658
+ lines.push("");
659
+ }
660
+ lines.push("### Recommendations");
661
+ for (const rec of report.recommendations) {
662
+ lines.push(`- ${rec}`);
663
+ }
664
+ return lines.join("\n");
665
+ }