@ema.co/mcp-toolkit 2026.2.5 → 2026.2.13

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.

Files changed (44) hide show
  1. package/.context/public/guides/dashboard-operations.md +63 -0
  2. package/.context/public/guides/workflow-builder-patterns.md +708 -0
  3. package/LICENSE +29 -21
  4. package/README.md +58 -35
  5. package/dist/mcp/domain/proto-constraints.js +284 -0
  6. package/dist/mcp/domain/structural-rules.js +8 -0
  7. package/dist/mcp/domain/validation-rules.js +102 -15
  8. package/dist/mcp/domain/workflow-graph-optimizer.js +235 -0
  9. package/dist/mcp/domain/workflow-graph-transforms.js +808 -0
  10. package/dist/mcp/domain/workflow-graph.js +376 -0
  11. package/dist/mcp/domain/workflow-optimizer.js +10 -4
  12. package/dist/mcp/guidance.js +45 -2
  13. package/dist/mcp/handlers/feedback/index.js +139 -0
  14. package/dist/mcp/handlers/feedback/store.js +262 -0
  15. package/dist/mcp/handlers/workflow/index.js +12 -11
  16. package/dist/mcp/handlers/workflow/optimize.js +73 -33
  17. package/dist/mcp/knowledge.js +87 -36
  18. package/dist/mcp/resources.js +393 -17
  19. package/dist/mcp/server.js +38 -4
  20. package/dist/mcp/tools.js +89 -2
  21. package/dist/sdk/generated/deprecated-actions.js +182 -96
  22. package/dist/sdk/generated/proto-fields.js +2 -1
  23. package/dist/sdk/generated/protos/service/agent_qa/v1/agent_qa_pb.js +460 -21
  24. package/dist/sdk/generated/protos/service/auth/v1/auth_pb.js +11 -1
  25. package/dist/sdk/generated/protos/service/dataingest/v1/dataingest_pb.js +173 -66
  26. package/dist/sdk/generated/protos/service/feedback/v1/feedback_pb.js +43 -1
  27. package/dist/sdk/generated/protos/service/llmservice/v1/llmservice_pb.js +26 -21
  28. package/dist/sdk/generated/protos/service/persona/v1/persona_config_pb.js +100 -89
  29. package/dist/sdk/generated/protos/service/persona/v1/persona_pb.js +126 -116
  30. package/dist/sdk/generated/protos/service/persona/v1/shared_widgets/widget_types_pb.js +33 -1
  31. package/dist/sdk/generated/protos/service/persona/v1/voicebot_widgets/widget_types_pb.js +60 -11
  32. package/dist/sdk/generated/protos/service/tenant/v1/tenant_pb.js +1 -1
  33. package/dist/sdk/generated/protos/service/user/v1/user_pb.js +1 -1
  34. package/dist/sdk/generated/protos/service/utils/v1/agent_qa_pb.js +35 -0
  35. package/dist/sdk/generated/protos/service/workflows/v1/action_registry_pb.js +1 -1
  36. package/dist/sdk/generated/protos/service/workflows/v1/action_type_pb.js +6 -1
  37. package/dist/sdk/generated/protos/service/workflows/v1/chatbot_pb.js +106 -11
  38. package/dist/sdk/generated/protos/service/workflows/v1/common_forms_pb.js +1 -1
  39. package/dist/sdk/generated/protos/service/workflows/v1/coordinator_pb.js +1 -1
  40. package/dist/sdk/generated/protos/service/workflows/v1/external_actions_pb.js +31 -1
  41. package/dist/sdk/generated/protos/service/workflows/v1/well_known_pb.js +5 -1
  42. package/dist/sdk/generated/protos/service/workflows/v1/workflow_pb.js +1 -1
  43. package/dist/sdk/generated/protos/util/tracking_metadata_pb.js +1 -1
  44. package/package.json +2 -2
@@ -0,0 +1,808 @@
1
+ /**
2
+ * Workflow Graph Transforms
3
+ *
4
+ * Registry of pure transform functions that operate on workflow_def JSON.
5
+ * Each transform: (workflowDef, graph) -> TransformResult | null
6
+ *
7
+ * - Auto-fix transforms modify the workflow to fix issues
8
+ * - Advisory transforms report issues without fixing
9
+ * - NEVER mutates the input workflowDef
10
+ * - Returns null when not applicable
11
+ */
12
+ import { getReachableNodes, getNodesByType, getOutputConsumers, } from "./workflow-graph.js";
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ // Helpers
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ function deepClone(obj) {
17
+ return JSON.parse(JSON.stringify(obj));
18
+ }
19
+ function emptyResult(workflowDef) {
20
+ return {
21
+ applied: true,
22
+ workflowDef,
23
+ description: "",
24
+ nodesRemoved: [],
25
+ nodesAdded: [],
26
+ nodesModified: [],
27
+ edgesRewired: [],
28
+ };
29
+ }
30
+ /** Extract the action type name from a raw action object. */
31
+ function extractActionType(action) {
32
+ const actionDef = action.action;
33
+ const actionName = actionDef?.name;
34
+ if (typeof actionName === "string")
35
+ return actionName;
36
+ if (actionName && typeof actionName === "object") {
37
+ return actionName.name ?? "unknown";
38
+ }
39
+ return "unknown";
40
+ }
41
+ /** Build a signature for a node to detect identical nodes. */
42
+ function nodeSignature(action) {
43
+ const actionType = extractActionType(action);
44
+ const actionDef = action.action;
45
+ const version = actionDef?.version ?? "";
46
+ const inputs = action.inputs;
47
+ // Serialize input wiring deterministically (sorted keys, only actionOutput refs)
48
+ const inputWiring = {};
49
+ if (inputs) {
50
+ const sortedKeys = Object.keys(inputs).sort();
51
+ for (const key of sortedKeys) {
52
+ const val = inputs[key];
53
+ if (val?.actionOutput) {
54
+ inputWiring[key] = val.actionOutput;
55
+ }
56
+ else if (val?.widgetConfig) {
57
+ inputWiring[key] = { widget: val.widgetConfig };
58
+ }
59
+ else if (val?.inline) {
60
+ inputWiring[key] = { inline: val.inline };
61
+ }
62
+ else if (val?.multiBinding) {
63
+ inputWiring[key] = { multi: val.multiBinding };
64
+ }
65
+ }
66
+ }
67
+ return JSON.stringify({ actionType, version, inputWiring });
68
+ }
69
+ /** Check if an action has side effects based on its type. */
70
+ function hasSideEffects(action) {
71
+ const actionType = extractActionType(action);
72
+ const SIDE_EFFECT_FREE = new Set([
73
+ "search", "call_llm", "entity_extraction", "respond_with_sources",
74
+ "respond_for_external_actions", "conversation_to_search_query",
75
+ "combine_search_results", "text_categorizer", "chat_categorizer",
76
+ "response_validator", "fixed_response", "live_web_search",
77
+ "ai_web_search", "json_mapper", "custom_agent",
78
+ ]);
79
+ const TRIGGER_TYPES = new Set(["chat_trigger", "voice_trigger", "document_trigger"]);
80
+ if (TRIGGER_TYPES.has(actionType))
81
+ return false;
82
+ if (SIDE_EFFECT_FREE.has(actionType))
83
+ return false;
84
+ return true;
85
+ }
86
+ /** Check if an action is a trigger node. */
87
+ function isTriggerAction(action) {
88
+ const actionType = extractActionType(action);
89
+ return actionType === "chat_trigger" || actionType === "voice_trigger" || actionType === "document_trigger";
90
+ }
91
+ /** Get all node names from actions array. */
92
+ function getActionNames(actions) {
93
+ return new Set(actions.map((a) => a.name).filter(Boolean));
94
+ }
95
+ /**
96
+ * Rewire all references from oldName to newName in the actions array.
97
+ * This updates actionOutput bindings, multiBinding refs, and runIf refs.
98
+ */
99
+ function rewireReferences(actions, oldName, newName) {
100
+ for (const action of actions) {
101
+ const inputs = action.inputs;
102
+ if (inputs) {
103
+ for (const val of Object.values(inputs)) {
104
+ const binding = val;
105
+ if (binding?.actionOutput) {
106
+ const ao = binding.actionOutput;
107
+ if (ao.actionName === oldName) {
108
+ ao.actionName = newName;
109
+ }
110
+ }
111
+ if (binding?.multiBinding) {
112
+ const mb = binding.multiBinding;
113
+ const elements = mb.elements ?? [];
114
+ for (const elem of elements) {
115
+ if (elem.namedBinding) {
116
+ const nb = elem.namedBinding;
117
+ const value = nb.value;
118
+ if (value?.actionOutput) {
119
+ const ao = value.actionOutput;
120
+ if (ao.actionName === oldName) {
121
+ ao.actionName = newName;
122
+ }
123
+ }
124
+ }
125
+ else if (elem.actionOutput) {
126
+ const ao = elem.actionOutput;
127
+ if (ao.actionName === oldName) {
128
+ ao.actionName = newName;
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ // Rewire runIf
136
+ const runIf = action.runIf;
137
+ if (runIf) {
138
+ const lhs = runIf.lhs;
139
+ if (lhs?.actionOutput) {
140
+ const ao = lhs.actionOutput;
141
+ if (ao.actionName === oldName) {
142
+ ao.actionName = newName;
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+ /**
149
+ * Rewire result mappings from oldName to newName.
150
+ */
151
+ function rewireResults(results, oldName, newName) {
152
+ for (const resultDef of Object.values(results)) {
153
+ if (resultDef && resultDef.actionName === oldName) {
154
+ resultDef.actionName = newName;
155
+ }
156
+ }
157
+ }
158
+ // ─────────────────────────────────────────────────────────────────────────────
159
+ // Auto-fix Transforms
160
+ // ─────────────────────────────────────────────────────────────────────────────
161
+ /**
162
+ * 1. Remove dead nodes unreachable from trigger.
163
+ */
164
+ const removeDeadNodes = {
165
+ id: "remove-dead-nodes",
166
+ name: "Remove Dead Nodes",
167
+ description: "Remove nodes unreachable from the trigger via BFS",
168
+ apply(workflowDef, graph) {
169
+ try {
170
+ if (!graph.trigger || graph.nodes.size === 0)
171
+ return null;
172
+ const reachable = getReachableNodes(graph);
173
+ const unreachable = [];
174
+ for (const nodeId of graph.nodes.keys()) {
175
+ if (!reachable.has(nodeId)) {
176
+ unreachable.push(nodeId);
177
+ }
178
+ }
179
+ if (unreachable.length === 0)
180
+ return null;
181
+ const cloned = deepClone(workflowDef);
182
+ const actions = cloned.actions;
183
+ const results = (cloned.results ?? {});
184
+ const removedNodes = [];
185
+ // Attempt to wire intentional nodes, remove truly orphaned ones
186
+ for (const nodeId of unreachable) {
187
+ const node = graph.nodes.get(nodeId);
188
+ const hasDisplayName = node.displayName && node.displayName !== nodeId;
189
+ const isReferencedInResults = Object.values(results).some((r) => r.actionName === nodeId);
190
+ const hasActionOutputInputs = [...node.inputs.values()].some((ref) => ref.type === "action_output");
191
+ if (hasDisplayName || isReferencedInResults || hasActionOutputInputs) {
192
+ // Intentional node: skip removal (could attempt wiring, but
193
+ // wiring heuristics are risky so we just keep it)
194
+ continue;
195
+ }
196
+ // Truly orphaned: remove
197
+ const idx = actions.findIndex((a) => a.name === nodeId);
198
+ if (idx !== -1) {
199
+ actions.splice(idx, 1);
200
+ removedNodes.push(nodeId);
201
+ }
202
+ // Remove associated result mappings
203
+ for (const [key, resultDef] of Object.entries(results)) {
204
+ if (resultDef.actionName === nodeId) {
205
+ delete results[key];
206
+ }
207
+ }
208
+ }
209
+ if (removedNodes.length === 0)
210
+ return null;
211
+ const result = emptyResult(cloned);
212
+ result.nodesRemoved = removedNodes;
213
+ result.description = `Removed ${removedNodes.length} unreachable node(s): ${removedNodes.join(", ")}`;
214
+ return result;
215
+ }
216
+ catch {
217
+ return null;
218
+ }
219
+ },
220
+ };
221
+ /**
222
+ * 2. Break cycles by removing back-edges.
223
+ */
224
+ const breakCycles = {
225
+ id: "break-cycles",
226
+ name: "Break Cycles",
227
+ description: "Remove back-edges that create cycles in the workflow graph",
228
+ apply(workflowDef, graph) {
229
+ try {
230
+ if (!graph.trigger || graph.nodes.size === 0)
231
+ return null;
232
+ // DFS to find back-edges
233
+ const visited = new Set();
234
+ const inStack = new Set();
235
+ const backEdges = [];
236
+ function dfs(nodeId) {
237
+ visited.add(nodeId);
238
+ inStack.add(nodeId);
239
+ for (const child of graph.forward.get(nodeId) ?? []) {
240
+ if (inStack.has(child)) {
241
+ // Back-edge: nodeId -> child where child is ancestor in DFS stack.
242
+ // forward.get(nodeId) has nodes consuming nodeId's output, so child
243
+ // has an input binding referencing nodeId. Remove it to break cycle.
244
+ backEdges.push({ from: nodeId, to: child, inputName: "" });
245
+ }
246
+ else if (!visited.has(child)) {
247
+ dfs(child);
248
+ }
249
+ }
250
+ inStack.delete(nodeId);
251
+ }
252
+ dfs(graph.trigger);
253
+ if (backEdges.length === 0)
254
+ return null;
255
+ const cloned = deepClone(workflowDef);
256
+ const actions = cloned.actions;
257
+ const edgesRewired = [];
258
+ const nodesModified = [];
259
+ for (const backEdge of backEdges) {
260
+ // Find the target node (child) and remove inputs referencing backEdge.from
261
+ const targetAction = actions.find((a) => a.name === backEdge.to);
262
+ if (!targetAction)
263
+ continue;
264
+ const inputs = targetAction.inputs;
265
+ if (!inputs)
266
+ continue;
267
+ for (const [inputName, inputVal] of Object.entries(inputs)) {
268
+ const binding = inputVal;
269
+ if (binding?.actionOutput) {
270
+ const ao = binding.actionOutput;
271
+ if (ao.actionName === backEdge.from) {
272
+ delete inputs[inputName];
273
+ edgesRewired.push({
274
+ from: backEdge.from,
275
+ to: backEdge.to,
276
+ reason: `Removed back-edge input "${inputName}" to break cycle`,
277
+ });
278
+ if (!nodesModified.includes(backEdge.to)) {
279
+ nodesModified.push(backEdge.to);
280
+ }
281
+ }
282
+ }
283
+ }
284
+ }
285
+ if (edgesRewired.length === 0)
286
+ return null;
287
+ const result = emptyResult(cloned);
288
+ result.nodesModified = nodesModified;
289
+ result.edgesRewired = edgesRewired;
290
+ result.description = `Broke ${edgesRewired.length} cycle(s) by removing back-edge input bindings`;
291
+ return result;
292
+ }
293
+ catch {
294
+ return null;
295
+ }
296
+ },
297
+ };
298
+ /**
299
+ * 3. Merge identical nodes (same action type, version, and input wiring).
300
+ */
301
+ const mergeIdenticalNodes = {
302
+ id: "merge-identical-nodes",
303
+ name: "Merge Identical Nodes",
304
+ description: "Merge nodes with identical action type, version, and input wiring",
305
+ apply(workflowDef, graph) {
306
+ try {
307
+ if (graph.nodes.size === 0)
308
+ return null;
309
+ const actions = workflowDef.actions ?? [];
310
+ // Group by signature, skipping triggers and side-effect nodes
311
+ const groups = new Map();
312
+ for (const action of actions) {
313
+ if (isTriggerAction(action))
314
+ continue;
315
+ if (hasSideEffects(action))
316
+ continue;
317
+ const sig = nodeSignature(action);
318
+ if (!groups.has(sig))
319
+ groups.set(sig, []);
320
+ groups.get(sig).push(action);
321
+ }
322
+ // Find groups with duplicates
323
+ const mergeGroups = [...groups.values()].filter((g) => g.length >= 2);
324
+ if (mergeGroups.length === 0)
325
+ return null;
326
+ const cloned = deepClone(workflowDef);
327
+ const clonedActions = cloned.actions;
328
+ const clonedResults = (cloned.results ?? {});
329
+ const removedNodes = [];
330
+ const edgesRewired = [];
331
+ for (const group of mergeGroups) {
332
+ const survivorName = group[0].name;
333
+ for (let i = 1; i < group.length; i++) {
334
+ const duplicateName = group[i].name;
335
+ // Rewire all references from duplicate to survivor
336
+ rewireReferences(clonedActions, duplicateName, survivorName);
337
+ rewireResults(clonedResults, duplicateName, survivorName);
338
+ // Remove the duplicate
339
+ const idx = clonedActions.findIndex((a) => a.name === duplicateName);
340
+ if (idx !== -1) {
341
+ clonedActions.splice(idx, 1);
342
+ removedNodes.push(duplicateName);
343
+ edgesRewired.push({
344
+ from: duplicateName,
345
+ to: survivorName,
346
+ reason: `Merged identical node "${duplicateName}" into "${survivorName}"`,
347
+ });
348
+ }
349
+ }
350
+ }
351
+ if (removedNodes.length === 0)
352
+ return null;
353
+ const result = emptyResult(cloned);
354
+ result.nodesRemoved = removedNodes;
355
+ result.edgesRewired = edgesRewired;
356
+ result.description = `Merged ${removedNodes.length} duplicate node(s)`;
357
+ return result;
358
+ }
359
+ catch {
360
+ return null;
361
+ }
362
+ },
363
+ };
364
+ /**
365
+ * 4. Remove unnecessary categorizers where all handlers are identical.
366
+ */
367
+ const removeUnnecessaryCategorizers = {
368
+ id: "remove-unnecessary-categorizers",
369
+ name: "Remove Unnecessary Categorizers",
370
+ description: "Remove categorizers where all handler branches do the same thing",
371
+ apply(workflowDef, graph) {
372
+ try {
373
+ if (graph.nodes.size === 0)
374
+ return null;
375
+ const categorizerNodes = getNodesByType(graph, "chat_categorizer", "text_categorizer");
376
+ if (categorizerNodes.length === 0)
377
+ return null;
378
+ const cloned = deepClone(workflowDef);
379
+ const actions = cloned.actions;
380
+ const removedNodes = [];
381
+ const nodesModified = [];
382
+ for (const catNode of categorizerNodes) {
383
+ // Find all handler nodes (nodes with runIf referencing this categorizer)
384
+ const handlerNodes = [];
385
+ for (const node of graph.nodes.values()) {
386
+ if (node.runIf?.sourceNode === catNode.id) {
387
+ handlerNodes.push(node);
388
+ }
389
+ }
390
+ if (handlerNodes.length < 2)
391
+ continue;
392
+ // Check if all handlers have identical action type + input wiring (ignoring runIf)
393
+ const originalActions = workflowDef.actions ?? [];
394
+ const handlerSignatures = handlerNodes.map((h) => {
395
+ const rawAction = originalActions.find((a) => a.name === h.id);
396
+ if (!rawAction)
397
+ return "";
398
+ // Build signature without runIf
399
+ const withoutRunIf = { ...rawAction };
400
+ delete withoutRunIf.runIf;
401
+ return nodeSignature(withoutRunIf);
402
+ });
403
+ const allSame = handlerSignatures.every((s) => s === handlerSignatures[0]);
404
+ if (!allSame)
405
+ continue;
406
+ // Remove the categorizer
407
+ const catIdx = actions.findIndex((a) => a.name === catNode.id);
408
+ if (catIdx !== -1) {
409
+ actions.splice(catIdx, 1);
410
+ removedNodes.push(catNode.id);
411
+ }
412
+ // Keep only the first handler, remove runIf from it, remove others
413
+ const keepHandler = handlerNodes[0].id;
414
+ for (const handler of handlerNodes) {
415
+ const handlerAction = actions.find((a) => a.name === handler.id);
416
+ if (!handlerAction)
417
+ continue;
418
+ if (handler.id === keepHandler) {
419
+ // Remove runIf from kept handler
420
+ delete handlerAction.runIf;
421
+ nodesModified.push(handler.id);
422
+ }
423
+ else {
424
+ // Remove duplicate handlers
425
+ const idx = actions.findIndex((a) => a.name === handler.id);
426
+ if (idx !== -1) {
427
+ // Rewire references from this handler to the kept handler
428
+ rewireReferences(actions, handler.id, keepHandler);
429
+ const results = (cloned.results ?? {});
430
+ rewireResults(results, handler.id, keepHandler);
431
+ actions.splice(idx, 1);
432
+ removedNodes.push(handler.id);
433
+ }
434
+ }
435
+ }
436
+ }
437
+ if (removedNodes.length === 0)
438
+ return null;
439
+ const result = emptyResult(cloned);
440
+ result.nodesRemoved = removedNodes;
441
+ result.nodesModified = nodesModified;
442
+ result.description = `Removed ${removedNodes.filter((n) => categorizerNodes.some((c) => c.id === n)).length} unnecessary categorizer(s) with identical handlers`;
443
+ return result;
444
+ }
445
+ catch {
446
+ return null;
447
+ }
448
+ },
449
+ };
450
+ /**
451
+ * 5. Prune dead branches (categories with no handlers).
452
+ */
453
+ const pruneDeadBranches = {
454
+ id: "prune-dead-branches",
455
+ name: "Prune Dead Branches",
456
+ description: "Remove categorizer categories that have no handler nodes",
457
+ apply(workflowDef, graph) {
458
+ try {
459
+ if (graph.nodes.size === 0)
460
+ return null;
461
+ const categorizerNodes = getNodesByType(graph, "chat_categorizer", "text_categorizer");
462
+ if (categorizerNodes.length === 0)
463
+ return null;
464
+ const cloned = deepClone(workflowDef);
465
+ const actions = cloned.actions;
466
+ const enumTypes = (cloned.enumTypes ?? []);
467
+ const deadCategories = [];
468
+ for (const catNode of categorizerNodes) {
469
+ const categories = catNode.categories ?? [];
470
+ if (categories.length === 0)
471
+ continue;
472
+ // Find which categories have handlers
473
+ const handledCategories = new Set();
474
+ for (const node of graph.nodes.values()) {
475
+ if (node.runIf?.sourceNode === catNode.id && node.runIf?.value) {
476
+ handledCategories.add(node.runIf.value);
477
+ }
478
+ }
479
+ const unhandled = categories.filter((c) => !handledCategories.has(c));
480
+ if (unhandled.length === 0)
481
+ continue;
482
+ deadCategories.push(...unhandled);
483
+ // Remove unhandled categories from the categorizer action
484
+ const catAction = actions.find((a) => a.name === catNode.id);
485
+ if (catAction) {
486
+ const cats = catAction.categories;
487
+ if (Array.isArray(cats)) {
488
+ catAction.categories = cats.filter((c) => !unhandled.includes(c));
489
+ }
490
+ }
491
+ // Remove from enumTypes
492
+ for (const et of enumTypes) {
493
+ const options = et.options;
494
+ if (Array.isArray(options)) {
495
+ et.options = options.filter((o) => !unhandled.includes(o.name));
496
+ }
497
+ }
498
+ }
499
+ if (deadCategories.length === 0)
500
+ return null;
501
+ const result = emptyResult(cloned);
502
+ result.nodesModified = categorizerNodes
503
+ .filter((c) => (c.categories ?? []).some((cat) => deadCategories.includes(cat)))
504
+ .map((c) => c.id);
505
+ result.description = `Pruned ${deadCategories.length} dead branch(es): ${deadCategories.join(", ")}`;
506
+ return result;
507
+ }
508
+ catch {
509
+ return null;
510
+ }
511
+ },
512
+ };
513
+ /**
514
+ * 6. Remove trailing nodes downstream of result mappings that don't contribute to outputs.
515
+ */
516
+ const removeTrailingNodes = {
517
+ id: "remove-trailing-nodes",
518
+ name: "Remove Trailing Nodes",
519
+ description: "Remove side-effect-free nodes downstream of result mappings that don't contribute to any output",
520
+ apply(workflowDef, graph) {
521
+ try {
522
+ if (graph.nodes.size === 0)
523
+ return null;
524
+ // Build set of nodes that contribute to output mappings
525
+ const resultNodes = new Set(graph.outputMappings.map((m) => m.nodeId));
526
+ // Find nodes that are downstream of result nodes but don't contribute to results
527
+ // and are side-effect-free
528
+ const trailingNodes = [];
529
+ for (const [nodeId, node] of graph.nodes) {
530
+ if (node.isTrigger)
531
+ continue;
532
+ if (resultNodes.has(nodeId))
533
+ continue;
534
+ if (node.hasSideEffects)
535
+ continue;
536
+ // Check if this node has no downstream consumers, or none of its
537
+ // downstream path reaches a result node
538
+ const consumers = getOutputConsumers(graph, nodeId);
539
+ // If no one consumes this node's output and it's not a result node, it's trailing
540
+ // Also check: is this node only reachable through result nodes?
541
+ // More precisely: a node is trailing if it doesn't contribute to any result mapping
542
+ const contributesToResult = doesContributeToResult(nodeId, graph, resultNodes);
543
+ if (!contributesToResult) {
544
+ trailingNodes.push(nodeId);
545
+ }
546
+ }
547
+ if (trailingNodes.length === 0)
548
+ return null;
549
+ const cloned = deepClone(workflowDef);
550
+ const actions = cloned.actions;
551
+ for (const nodeId of trailingNodes) {
552
+ const idx = actions.findIndex((a) => a.name === nodeId);
553
+ if (idx !== -1) {
554
+ actions.splice(idx, 1);
555
+ }
556
+ }
557
+ const result = emptyResult(cloned);
558
+ result.nodesRemoved = trailingNodes;
559
+ result.description = `Removed ${trailingNodes.length} trailing node(s): ${trailingNodes.join(", ")}`;
560
+ return result;
561
+ }
562
+ catch {
563
+ return null;
564
+ }
565
+ },
566
+ };
567
+ /**
568
+ * Check if a node contributes to any result mapping via forward traversal.
569
+ */
570
+ function doesContributeToResult(nodeId, graph, resultNodes) {
571
+ if (resultNodes.has(nodeId))
572
+ return true;
573
+ // BFS forward from this node to see if any path leads to a result node
574
+ const visited = new Set();
575
+ const queue = [nodeId];
576
+ while (queue.length > 0) {
577
+ const current = queue.shift();
578
+ if (visited.has(current))
579
+ continue;
580
+ visited.add(current);
581
+ const consumers = graph.forward.get(current);
582
+ if (!consumers)
583
+ continue;
584
+ for (const consumer of consumers) {
585
+ if (resultNodes.has(consumer))
586
+ return true;
587
+ if (!visited.has(consumer)) {
588
+ queue.push(consumer);
589
+ }
590
+ }
591
+ }
592
+ return false;
593
+ }
594
+ /**
595
+ * 7. Prune stale result mappings referencing non-existent nodes.
596
+ */
597
+ const pruneStaleResultMappings = {
598
+ id: "prune-stale-results",
599
+ name: "Prune Stale Result Mappings",
600
+ description: "Remove result entries that reference non-existent action nodes",
601
+ apply(workflowDef, graph) {
602
+ try {
603
+ const results = workflowDef.results;
604
+ if (!results || Object.keys(results).length === 0)
605
+ return null;
606
+ const actions = workflowDef.actions ?? [];
607
+ const actionNames = getActionNames(actions);
608
+ const staleKeys = [];
609
+ for (const [key, resultDef] of Object.entries(results)) {
610
+ if (!resultDef || !resultDef.actionName)
611
+ continue;
612
+ if (!actionNames.has(resultDef.actionName)) {
613
+ staleKeys.push(key);
614
+ }
615
+ }
616
+ if (staleKeys.length === 0)
617
+ return null;
618
+ const cloned = deepClone(workflowDef);
619
+ const clonedResults = cloned.results;
620
+ for (const key of staleKeys) {
621
+ delete clonedResults[key];
622
+ }
623
+ const result = emptyResult(cloned);
624
+ result.description = `Removed ${staleKeys.length} stale result mapping(s): ${staleKeys.join(", ")}`;
625
+ return result;
626
+ }
627
+ catch {
628
+ return null;
629
+ }
630
+ },
631
+ };
632
+ /**
633
+ * 8. Deduplicate enumTypes by name.
634
+ */
635
+ const deduplicateEnumTypes = {
636
+ id: "deduplicate-enums",
637
+ name: "Deduplicate Enum Types",
638
+ description: "Remove duplicate enumType entries with the same name",
639
+ apply(workflowDef, graph) {
640
+ try {
641
+ const enumTypes = workflowDef.enumTypes;
642
+ if (!enumTypes || !Array.isArray(enumTypes) || enumTypes.length === 0)
643
+ return null;
644
+ const seen = new Set();
645
+ const duplicateIndices = [];
646
+ for (let i = 0; i < enumTypes.length; i++) {
647
+ const name = extractEnumName(enumTypes[i]);
648
+ if (!name)
649
+ continue;
650
+ if (seen.has(name)) {
651
+ duplicateIndices.push(i);
652
+ }
653
+ else {
654
+ seen.add(name);
655
+ }
656
+ }
657
+ if (duplicateIndices.length === 0)
658
+ return null;
659
+ const cloned = deepClone(workflowDef);
660
+ const clonedEnums = cloned.enumTypes;
661
+ // Remove in reverse order to preserve indices
662
+ for (let i = duplicateIndices.length - 1; i >= 0; i--) {
663
+ clonedEnums.splice(duplicateIndices[i], 1);
664
+ }
665
+ const result = emptyResult(cloned);
666
+ result.description = `Removed ${duplicateIndices.length} duplicate enumType(s)`;
667
+ return result;
668
+ }
669
+ catch {
670
+ return null;
671
+ }
672
+ },
673
+ };
674
+ /** Extract enum type name, handling nested name objects. */
675
+ function extractEnumName(et) {
676
+ const rawName = et.name;
677
+ if (typeof rawName === "string")
678
+ return rawName;
679
+ if (rawName && typeof rawName === "object") {
680
+ const nameObj = rawName;
681
+ if (typeof nameObj.name === "string")
682
+ return nameObj.name;
683
+ if (nameObj.name && typeof nameObj.name === "object") {
684
+ const nested = nameObj.name;
685
+ if (typeof nested.name === "string")
686
+ return nested.name;
687
+ }
688
+ }
689
+ return undefined;
690
+ }
691
+ // ─────────────────────────────────────────────────────────────────────────────
692
+ // Advisory Transforms
693
+ // ─────────────────────────────────────────────────────────────────────────────
694
+ /**
695
+ * 9. Detect similar nodes (same action type, different inputs).
696
+ */
697
+ const detectSimilarNodes = {
698
+ id: "similar-nodes",
699
+ name: "Detect Similar Nodes",
700
+ description: "Find nodes with the same action type but different inputs",
701
+ apply(workflowDef, graph) {
702
+ try {
703
+ if (graph.nodes.size === 0)
704
+ return null;
705
+ // Group non-trigger nodes by action type
706
+ const byType = new Map();
707
+ for (const node of graph.nodes.values()) {
708
+ if (node.isTrigger)
709
+ continue;
710
+ if (!byType.has(node.actionType))
711
+ byType.set(node.actionType, []);
712
+ byType.get(node.actionType).push(node);
713
+ }
714
+ const actions = workflowDef.actions ?? [];
715
+ const similarPairs = [];
716
+ for (const [actionType, nodes] of byType) {
717
+ if (nodes.length < 2)
718
+ continue;
719
+ // Check if any pair has different signatures (similar but not identical)
720
+ for (let i = 0; i < nodes.length; i++) {
721
+ for (let j = i + 1; j < nodes.length; j++) {
722
+ const rawA = actions.find((a) => a.name === nodes[i].id);
723
+ const rawB = actions.find((a) => a.name === nodes[j].id);
724
+ if (!rawA || !rawB)
725
+ continue;
726
+ const sigA = nodeSignature(rawA);
727
+ const sigB = nodeSignature(rawB);
728
+ if (sigA !== sigB) {
729
+ similarPairs.push({ a: nodes[i].id, b: nodes[j].id, actionType });
730
+ }
731
+ }
732
+ }
733
+ }
734
+ if (similarPairs.length === 0)
735
+ return null;
736
+ const result = emptyResult(workflowDef);
737
+ result.applied = false;
738
+ result.description = `Found ${similarPairs.length} pair(s) of similar nodes: ${similarPairs.map((p) => `${p.a} <-> ${p.b} (${p.actionType})`).join("; ")}`;
739
+ return result;
740
+ }
741
+ catch {
742
+ return null;
743
+ }
744
+ },
745
+ };
746
+ /**
747
+ * 10. Detect suboptimal branching patterns.
748
+ */
749
+ const detectSuboptimalBranching = {
750
+ id: "suboptimal-branching",
751
+ name: "Detect Suboptimal Branching",
752
+ description: "Detect single-category categorizers and deeply nested categorizer chains",
753
+ apply(workflowDef, graph) {
754
+ try {
755
+ if (graph.nodes.size === 0)
756
+ return null;
757
+ const categorizerNodes = getNodesByType(graph, "chat_categorizer", "text_categorizer");
758
+ if (categorizerNodes.length === 0)
759
+ return null;
760
+ const issues = [];
761
+ for (const catNode of categorizerNodes) {
762
+ const categories = catNode.categories ?? [];
763
+ // Single-category (plus Fallback) detection
764
+ const nonFallback = categories.filter((c) => c !== "Fallback");
765
+ if (nonFallback.length <= 1) {
766
+ issues.push(`Categorizer "${catNode.id}" has only ${nonFallback.length} non-Fallback category (${nonFallback.join(", ") || "none"}) - branching adds no value`);
767
+ }
768
+ }
769
+ // Detect deeply nested categorizer chains (categorizer -> handler with runIf -> another categorizer)
770
+ for (const catNode of categorizerNodes) {
771
+ // Check if any downstream node is also a categorizer
772
+ const consumers = getOutputConsumers(graph, catNode.id);
773
+ for (const consumerId of consumers) {
774
+ const consumer = graph.nodes.get(consumerId);
775
+ if (consumer && (consumer.actionType === "chat_categorizer" || consumer.actionType === "text_categorizer")) {
776
+ issues.push(`Nested categorizer chain: "${catNode.id}" -> "${consumerId}" - consider flattening`);
777
+ }
778
+ }
779
+ }
780
+ if (issues.length === 0)
781
+ return null;
782
+ const result = emptyResult(workflowDef);
783
+ result.applied = false;
784
+ result.description = issues.join("; ");
785
+ return result;
786
+ }
787
+ catch {
788
+ return null;
789
+ }
790
+ },
791
+ };
792
+ // ─────────────────────────────────────────────────────────────────────────────
793
+ // Exports
794
+ // ─────────────────────────────────────────────────────────────────────────────
795
+ export const AUTO_TRANSFORMS = [
796
+ removeDeadNodes,
797
+ breakCycles,
798
+ mergeIdenticalNodes,
799
+ removeUnnecessaryCategorizers,
800
+ pruneDeadBranches,
801
+ removeTrailingNodes,
802
+ pruneStaleResultMappings,
803
+ deduplicateEnumTypes,
804
+ ];
805
+ export const ADVISORY_TRANSFORMS = [
806
+ detectSimilarNodes,
807
+ detectSuboptimalBranching,
808
+ ];