@ema.co/mcp-toolkit 2026.1.30-1 → 2026.2.5

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.

package/README.md CHANGED
@@ -225,13 +225,17 @@ persona(input="IT helpdesk with KB search", type="chat", name="IT Support", prev
225
225
  ```typescript
226
226
  // Step 1: Get current workflow
227
227
  workflow(mode="get", persona_id="abc-123")
228
- // Returns: workflow_def, schema, guidance
228
+ // Returns: workflow_def, schema, guidance, fingerprint
229
229
 
230
230
  // Step 2: LLM modifies the workflow_def JSON
231
231
  // (add nodes, remove nodes, rewire connections)
232
232
 
233
233
  // Step 3: Deploy modified workflow
234
- workflow(mode="deploy", persona_id="abc-123", workflow_def={...}, preview=true)
234
+ // IMPORTANT: pass base_fingerprint from the get() response to prevent overwriting out-of-band changes
235
+ workflow(mode="deploy", persona_id="abc-123", base_fingerprint="<fingerprint>", workflow_def={...})
236
+
237
+ // If the workflow_def is large (near transport limits), deploy from a file instead:
238
+ // workflow(mode="deploy", persona_id="abc-123", base_fingerprint="<fingerprint>", workflow_def_path="/path/to/wf.json")
235
239
  ```
236
240
 
237
241
  **Get reference info:**
@@ -274,7 +274,7 @@ export function generateAutobuilderPrompt(description, personaType) {
274
274
  prompt += "- Use external_action_caller or send_email_agent as needed\n";
275
275
  }
276
276
  if (hasHITL) {
277
- prompt += "- Include general_hitl with success/failure paths\n";
277
+ prompt += "- Enable HITL flag on agents that need approval (do NOT add standalone general_hitl nodes)\n";
278
278
  }
279
279
  prompt += `- ${config.outputNote}\n`;
280
280
  }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Dashboard Domain Logic
3
+ *
4
+ * Business logic for dashboard operations including:
5
+ * - Sanitization of PII in column values
6
+ * - Nested data transformation with sanitization
7
+ * - Validation helpers
8
+ *
9
+ * Note: Pure format translation (without sanitization) is in SDK's columnValueToInput().
10
+ */
11
+ import { columnValueToInput } from "../../sdk/ema-client.js";
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ // Sanitization
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ /**
16
+ * Sanitization session for PII replacement.
17
+ * Maintains consistent replacements across multiple values.
18
+ */
19
+ export class SanitizationSession {
20
+ replacements = new Map();
21
+ counters = {};
22
+ /**
23
+ * Get or create a replacement for a detected PII value.
24
+ * Returns consistent replacements for the same value.
25
+ */
26
+ getOrCreateReplacement(value, type) {
27
+ const existing = this.replacements.get(value);
28
+ if (existing)
29
+ return existing;
30
+ this.counters[type] = (this.counters[type] ?? 0) + 1;
31
+ const replacement = `[${type.toUpperCase()}_${this.counters[type]}]`;
32
+ this.replacements.set(value, replacement);
33
+ return replacement;
34
+ }
35
+ /** Get all replacements made in this session */
36
+ getReplacements() {
37
+ return new Map(this.replacements);
38
+ }
39
+ }
40
+ /**
41
+ * Detect PII patterns in text using regex.
42
+ */
43
+ export function detectPiiPatterns(text) {
44
+ const detected = [];
45
+ // Email pattern
46
+ const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
47
+ for (const match of text.matchAll(emailRegex)) {
48
+ detected.push({ value: match[0], type: "email" });
49
+ }
50
+ // Phone pattern (various formats)
51
+ const phoneRegex = /\b(?:\+?1[-.\s]?)?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g;
52
+ for (const match of text.matchAll(phoneRegex)) {
53
+ detected.push({ value: match[0], type: "phone" });
54
+ }
55
+ return detected;
56
+ }
57
+ /**
58
+ * Sanitize a string value by replacing detected PII.
59
+ */
60
+ export function sanitizeString(value, session, additionalPatterns) {
61
+ let result = value;
62
+ // Pattern-based sanitization
63
+ const detected = detectPiiPatterns(result);
64
+ for (const entity of detected) {
65
+ const replacement = session.getOrCreateReplacement(entity.value, entity.type);
66
+ result = result.split(entity.value).join(replacement);
67
+ }
68
+ // User-provided patterns
69
+ if (additionalPatterns) {
70
+ for (const pattern of additionalPatterns) {
71
+ if (result.includes(pattern)) {
72
+ const replacement = session.getOrCreateReplacement(pattern, "custom");
73
+ result = result.split(pattern).join(replacement);
74
+ }
75
+ }
76
+ }
77
+ return result;
78
+ }
79
+ // ─────────────────────────────────────────────────────────────────────────────
80
+ // Column Value Conversion with Sanitization
81
+ // ─────────────────────────────────────────────────────────────────────────────
82
+ /**
83
+ * Convert a column value to DashboardInput with optional sanitization.
84
+ *
85
+ * This wraps the SDK's pure conversion function and adds sanitization.
86
+ * Use this in MCP handlers when sanitization is needed.
87
+ *
88
+ * @param name - The column/field name
89
+ * @param value - The column value from API response
90
+ * @param session - Sanitization session (null to skip sanitization)
91
+ * @param additionalPatterns - Additional strings to sanitize
92
+ * @returns DashboardInput for upload, or null if value is empty/unsupported
93
+ */
94
+ export function columnValueToInputWithSanitization(name, value, session, additionalPatterns) {
95
+ if (!value)
96
+ return null;
97
+ // If no sanitization needed, delegate to SDK
98
+ if (!session) {
99
+ return columnValueToInput(name, value);
100
+ }
101
+ // Handle string value with sanitization
102
+ if (value.stringValue !== undefined) {
103
+ const sanitized = sanitizeString(value.stringValue, session, additionalPatterns);
104
+ return { name, string_value: sanitized };
105
+ }
106
+ // Handle number value (no sanitization needed)
107
+ if (value.numberValue !== undefined) {
108
+ return { name, number_value: value.numberValue };
109
+ }
110
+ // Handle boolean value (no sanitization needed)
111
+ if (value.booleanValue !== undefined) {
112
+ return { name, boolean_value: value.booleanValue };
113
+ }
114
+ // Handle array value (recursively with sanitization)
115
+ if (value.arrayValue) {
116
+ const elements = value.arrayValue.arrayValues ?? [];
117
+ if (elements.length === 0)
118
+ return null;
119
+ const arrayInputs = [];
120
+ for (const elem of elements) {
121
+ const converted = columnValueToInputWithSanitization("element", elem, session, additionalPatterns);
122
+ if (converted)
123
+ arrayInputs.push(converted);
124
+ }
125
+ return arrayInputs.length > 0 ? { name, array_value: arrayInputs } : null;
126
+ }
127
+ // Handle object value (recursively with sanitization)
128
+ if (value.objectValue) {
129
+ const objectVals = value.objectValue.objectValues ?? {};
130
+ const keys = Object.keys(objectVals);
131
+ if (keys.length === 0)
132
+ return null;
133
+ const objectInput = {};
134
+ for (const key of keys) {
135
+ const converted = columnValueToInputWithSanitization(key, objectVals[key], session, additionalPatterns);
136
+ if (converted)
137
+ objectInput[key] = converted;
138
+ }
139
+ return Object.keys(objectInput).length > 0 ? { name, object_value: objectInput } : null;
140
+ }
141
+ // Unsupported types - delegate to SDK (will return null)
142
+ return columnValueToInput(name, value);
143
+ }
144
+ // ─────────────────────────────────────────────────────────────────────────────
145
+ // Nested Data Validation
146
+ // ─────────────────────────────────────────────────────────────────────────────
147
+ /** Maximum array elements to prevent oversized payloads */
148
+ export const MAX_ARRAY_ELEMENTS = 1000;
149
+ /** Maximum nesting depth for arrays/objects */
150
+ export const MAX_NESTING_DEPTH = 3;
151
+ /**
152
+ * Validate nested data structure.
153
+ * Returns errors/warnings for issues found.
154
+ */
155
+ export function validateNestedData(value, schemaType, path = "", depth = 0) {
156
+ const errors = [];
157
+ // Check nesting depth
158
+ if (depth > MAX_NESTING_DEPTH) {
159
+ errors.push({
160
+ path,
161
+ message: `Nesting depth exceeds maximum of ${MAX_NESTING_DEPTH}`,
162
+ severity: "error",
163
+ });
164
+ return errors;
165
+ }
166
+ // Validate based on schema type
167
+ if (schemaType === "COLUMN_TYPE_ARRAY" || schemaType === "ARRAY") {
168
+ if (!Array.isArray(value)) {
169
+ errors.push({
170
+ path,
171
+ message: `Expected array for ARRAY column, got ${typeof value}`,
172
+ severity: "error",
173
+ });
174
+ }
175
+ else if (value.length > MAX_ARRAY_ELEMENTS) {
176
+ errors.push({
177
+ path,
178
+ message: `Array has ${value.length} elements, exceeds maximum of ${MAX_ARRAY_ELEMENTS}`,
179
+ severity: "error",
180
+ });
181
+ }
182
+ }
183
+ else if (schemaType === "COLUMN_TYPE_OBJECT" || schemaType === "OBJECT") {
184
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
185
+ errors.push({
186
+ path,
187
+ message: `Expected object for OBJECT column, got ${Array.isArray(value) ? "array" : typeof value}`,
188
+ severity: "error",
189
+ });
190
+ }
191
+ }
192
+ else if (schemaType === "COLUMN_TYPE_STRING" || schemaType === "STRING") {
193
+ if (typeof value !== "string") {
194
+ if (Array.isArray(value)) {
195
+ errors.push({
196
+ path,
197
+ message: `Column expects scalar STRING value, got array. Did you mean to use an ARRAY column?`,
198
+ severity: "error",
199
+ });
200
+ }
201
+ else if (typeof value === "object" && value !== null) {
202
+ errors.push({
203
+ path,
204
+ message: `Column expects scalar STRING value, got object. Did you mean to use an OBJECT column?`,
205
+ severity: "error",
206
+ });
207
+ }
208
+ }
209
+ }
210
+ return errors;
211
+ }
212
+ /**
213
+ * Check if a value contains nested data (array or object).
214
+ */
215
+ export function hasNestedData(value) {
216
+ if (Array.isArray(value))
217
+ return true;
218
+ if (typeof value === "object" && value !== null) {
219
+ // Check if it looks like a nested structure vs a simple object
220
+ const keys = Object.keys(value);
221
+ return keys.some(k => !["name", "string_value", "number_value", "boolean_value", "document_value"].includes(k));
222
+ }
223
+ return false;
224
+ }
@@ -132,12 +132,13 @@ export const ANTI_PATTERNS = [
132
132
  {
133
133
  id: "email-without-validation",
134
134
  name: "Email Without Input Validation",
135
- pattern: "send_email_agent without entity_extraction and HITL confirmation",
135
+ pattern: "send_email_agent without entity_extraction to validate recipient data",
136
136
  problem: "Sending emails without extracting and validating recipient data risks sending to wrong people or with wrong content. Emails are high-impact actions with external side effects.",
137
- solution: "Always: 1) Extract required fields (email_address, subject) via entity_extraction, 2) Validate completeness via categorizer, 3) Ask user if missing, 4) Confirm via HITL before sending.",
137
+ solution: "Always: 1) Extract required fields (email_address, subject) via entity_extraction, 2) Validate completeness via categorizer, 3) Ask user if missing. " +
138
+ "For approval: enable the HITL flag on send_email_agent (do NOT add a standalone general_hitl node).",
138
139
  detection: {
139
- issueType: "incomplete_hitl",
140
- condition: "send_email_agent without preceding entity_extraction AND hitl nodes",
140
+ issueType: "incomplete_email_validation",
141
+ condition: "send_email_agent without preceding entity_extraction node to extract/validate recipient data",
141
142
  },
142
143
  severity: "critical",
143
144
  },
@@ -194,12 +195,15 @@ export const ANTI_PATTERNS = [
194
195
  name: "Incomplete HITL Paths",
195
196
  pattern: "HITL with only success path",
196
197
  problem: "Rejected requests have no handling, leaving users without response.",
197
- solution: "ALWAYS implement both success AND failure paths for general_hitl.",
198
+ solution: "If workflow already has general_hitl node: ALWAYS implement both success AND failure paths. For NEW workflows: use HITL flag on the agent instead of standalone general_hitl nodes (see hitl-patterns guidance).",
198
199
  detection: {
199
200
  issueType: "incomplete_hitl",
200
201
  condition: "HITL node missing 'hitl_status_HITL Success' or 'hitl_status_HITL Failure' edge (note: space, not underscore)",
201
202
  },
202
203
  severity: "critical",
204
+ // TODO: Revisit if general_hitl is re-enabled as a standalone node.
205
+ // Currently HITL is a flag on agents (send_email_agent, external_action_caller).
206
+ // This anti-pattern still fires for existing workflows that use general_hitl.
203
207
  },
204
208
  {
205
209
  id: "orphan-nodes",
@@ -432,6 +436,64 @@ export const ANTI_PATTERNS = [
432
436
  },
433
437
  severity: "critical",
434
438
  },
439
+ {
440
+ id: "chat-conversation-without-chat-response",
441
+ name: "Chat Conversation Not Wired to Chat Response Node",
442
+ pattern: "Chat workflow where chat_conversation is consumed by processing nodes but never wired to a chat-aware response node (respond_with_sources or respond_for_external_actions)",
443
+ problem: "When chat_conversation is processed by nodes like entity_extraction, call_llm, or search, " +
444
+ "but the final response is generated by a generic call_llm (not a chat-aware response node), " +
445
+ "the response node lacks conversation history context. This causes:\n" +
446
+ "1. Duplicate/repetitive responses — the LLM re-asks questions already answered in conversation\n" +
447
+ "2. Lost context — follow-up messages lose prior context\n" +
448
+ "3. Hallucinated greetings — the LLM generates its own greeting instead of continuing the conversation\n\n" +
449
+ "The chat-aware response nodes (respond_with_sources, respond_for_external_actions) are specifically " +
450
+ "designed to handle conversation history and produce contextually appropriate responses.",
451
+ solution: "Wire chat_conversation through to a chat-aware response node:\n" +
452
+ "• For search-based responses: use respond_with_sources (takes query + search_results)\n" +
453
+ "• For tool/action results: use respond_for_external_actions (takes query + external_action_result)\n" +
454
+ "• If using call_llm as responder: wire chat_conversation into named_inputs so the LLM sees full history\n\n" +
455
+ "Correct patterns:\n" +
456
+ " chat_trigger → search → respond_with_sources → WORKFLOW_OUTPUT\n" +
457
+ " chat_trigger → external_action_caller → respond_for_external_actions → WORKFLOW_OUTPUT\n" +
458
+ " chat_trigger → call_llm(named_inputs includes chat_conversation) → WORKFLOW_OUTPUT\n\n" +
459
+ "Anti-patterns:\n" +
460
+ " ❌ chat_trigger → search → call_llm (no conversation context) → WORKFLOW_OUTPUT\n" +
461
+ " ❌ chat_trigger → entity_extraction → call_llm (stateless) → WORKFLOW_OUTPUT",
462
+ detection: {
463
+ issueType: "chat_conversation_not_wired_to_response",
464
+ condition: "Chat workflow (chat_trigger) where terminal response node is call_llm without chat_conversation in named_inputs, " +
465
+ "and respond_with_sources / respond_for_external_actions are not used",
466
+ },
467
+ severity: "critical",
468
+ },
469
+ {
470
+ id: "entity-extraction-direct-to-email",
471
+ name: "Entity Extraction Wired Directly to Email Inputs",
472
+ pattern: "entity_extraction outputs connected directly to send_email_agent.email_to, email_subject, or email_body",
473
+ problem: "entity_extraction outputs are typed as WELL_KNOWN_TYPE_ANY (structured extraction results), " +
474
+ "but send_email_agent inputs (email_to, email_subject, email_body) require WELL_KNOWN_TYPE_TEXT_WITH_SOURCES. " +
475
+ "Direct wiring causes type mismatch errors or corrupted email fields.\n\n" +
476
+ "Additionally, entity_extraction outputs may contain multiple extracted values in a structured format — " +
477
+ "wiring the entire output to email_to would send an object/JSON as the recipient address, not a clean email string.",
478
+ solution: "Use intermediary nodes to extract and format individual fields:\n\n" +
479
+ "Pattern A — json_mapper + fixed_response (recommended for multiple fields):\n" +
480
+ " entity_extraction → json_mapper (extract 'to', 'subject', 'body') → fixed_response({{to}}) → send_email_agent.email_to\n" +
481
+ " Same json_mapper → fixed_response({{subject}}) → send_email_agent.email_subject\n" +
482
+ " Same json_mapper → fixed_response({{body}}) → send_email_agent.email_body\n\n" +
483
+ "Pattern B — custom_agent with output_fields (simpler for LLM-generated content):\n" +
484
+ " custom_agent(output_fields=[To,Subject,Body]) → send_email_agent\n" +
485
+ " (custom_agent with output_fields produces individual TEXT_WITH_SOURCES outputs)\n\n" +
486
+ "Pattern C — fixed_response with template variables (for simple extraction):\n" +
487
+ " entity_extraction → fixed_response(template='{{email_address}}', custom_data=entity_extraction) → send_email_agent.email_to\n\n" +
488
+ "Key principle: send_email_agent inputs need TEXT_WITH_SOURCES — always use fixed_response or " +
489
+ "custom_agent(output_fields) as the adapter between extraction results and email fields.",
490
+ detection: {
491
+ issueType: "entity_extraction_direct_to_email",
492
+ condition: "entity_extraction output connected directly to send_email_agent.email_to, email_subject, or email_body " +
493
+ "without an intermediary json_mapper/fixed_response/custom_agent node",
494
+ },
495
+ severity: "critical",
496
+ },
435
497
  ];
436
498
  export const OPTIMIZATION_RULES = [
437
499
  {
@@ -25,36 +25,38 @@ export const GUIDANCE_RULES = [
25
25
  category: "workflow",
26
26
  title: "Get Workflow Before Modifying",
27
27
  applies: "Before any workflow modification",
28
- description: "Always fetch the current workflow_def before making changes.",
29
- do: "Call workflow(mode='get', persona_id='...') to get the current workflow_def and schema.",
30
- dont: "Jump straight to deploy without understanding the current state.",
28
+ description: "Always fetch the current workflow_def before making changes. Use the returned workflow_def as your canonical format reference; do not reverse-engineer from local JSON files.",
29
+ do: "Call workflow(mode='get', persona_id='...') to get the current workflow_def and schema. Use the returned workflow_def as format reference. For extraction_columns see ema://rules/extraction-column-format.",
30
+ dont: "Jump straight to deploy without understanding the current state. Do not compare local JSON files in Python/scripts; use MCP get as format reference.",
31
31
  example: `// Good - get first, then modify, then deploy
32
32
  const data = await workflow({ mode: "get", persona_id: "abc" });
33
33
  const wf = data.workflow_def;
34
34
  // LLM modifies wf...
35
35
  wf.actions.push(newNode);
36
- await workflow({ mode: "deploy", persona_id: "abc", workflow_def: wf });`,
36
+ await workflow({ mode: "deploy", persona_id: "abc", base_fingerprint: data.fingerprint, workflow_def: wf });`,
37
37
  antiExample: `// Bad - deploying without getting current state
38
- await workflow({ mode: "deploy", persona_id: "abc", workflow_def: guessedDef });`,
38
+ await workflow({ mode: "deploy", persona_id: "abc", workflow_def: guessedDef }); // Will be blocked (missing base_fingerprint)`,
39
39
  related: ["preview-before-deploy"],
40
40
  },
41
41
  {
42
42
  id: "preview-before-deploy",
43
43
  level: "critical",
44
44
  category: "safety",
45
- title: "Preview Before Deploying",
45
+ title: "Validate Before Deploying",
46
46
  applies: "Before any deployment",
47
- description: "Always preview changes before deploying to catch errors early.",
48
- do: "Use preview=true to see what will change before committing.",
49
- dont: "Deploy directly without reviewing the changes.",
50
- example: `// Good
51
- const preview = await persona({
52
- id: "abc",
53
- update: { workflow_spec: spec },
54
- preview: true // See changes first
55
- });
56
- // Review preview.changes
57
- await persona({ id: "abc", update: { workflow_spec: spec } }); // Then deploy`,
47
+ description: "Always validate and review changes before deploying to catch errors early.",
48
+ do: "Use workflow(mode='validate') before workflow(mode='deploy'). For persona workflow_spec updates, you can use preview=true to review changes before applying.",
49
+ dont: "Deploy directly without validating/reviewing.",
50
+ example: `// Good (workflow deploy)
51
+ const data = await workflow({ mode: "get", persona_id: "abc" });
52
+ // LLM modifies data.workflow_def...
53
+ await workflow({ mode: "validate", workflow_def: data.workflow_def });
54
+ await workflow({ mode: "deploy", persona_id: "abc", base_fingerprint: data.fingerprint, workflow_def: data.workflow_def });
55
+
56
+ // Good (persona workflow_spec preview)
57
+ const preview = await persona({ method: "update", id: "abc", workflow_spec: spec, preview: true });
58
+ // Review preview.changes, then apply:
59
+ await persona({ method: "update", id: "abc", workflow_spec: spec, preview: false });`,
58
60
  related: ["get-before-modify"],
59
61
  },
60
62
  {
@@ -93,7 +95,8 @@ const stats = await persona({ id: "abc", data: { method: "stats" } });
93
95
  // stats.success should be > 0
94
96
 
95
97
  // Then deploy workflow with search
96
- await workflow({ mode: "deploy", persona_id: "abc", workflow_def: workflowWithSearch });`,
98
+ const wfData = await workflow({ mode: "get", persona_id: "abc" });
99
+ await workflow({ mode: "deploy", persona_id: "abc", base_fingerprint: wfData.fingerprint, workflow_def: workflowWithSearch });`,
97
100
  antiExample: `// BAD: Deploy search workflow with no documents
98
101
  await workflow({ mode: "deploy", persona_id: "abc", workflow_def: workflowWithSearch });
99
102
  // Search will return empty results!`,
@@ -106,11 +109,13 @@ await workflow({ mode: "deploy", persona_id: "abc", workflow_def: workflowWithSe
106
109
  title: "Workflow Modification Flow",
107
110
  applies: "When modifying workflows",
108
111
  description: "All workflow modifications follow a 3-step flow: get → modify → deploy. " +
112
+ "Use the returned workflow_def as your format reference; do not reverse-engineer from local files. " +
109
113
  "The LLM generates the full workflow_def, MCP just deploys it.",
110
- do: "1) Get workflow: workflow(mode='get', persona_id='...') " +
111
- "2) LLM modifies the workflow_def JSON " +
112
- "3) Deploy: workflow(mode='deploy', persona_id='...', workflow_def={...})",
113
- dont: "Try to use incremental update operations - they don't exist. Always deploy complete workflow_def.",
114
+ do: "1) Get workflow: workflow(mode='get', persona_id='...') — use returned workflow_def as format reference; for extraction_columns fetch ema://rules/extraction-column-format " +
115
+ "2) LLM modifies the workflow_def JSON (text/JSON edits) " +
116
+ "3) Deploy: workflow(mode='deploy', persona_id='...', base_fingerprint='<fingerprint>', workflow_def={...}) " +
117
+ "(or workflow_def_path='/path/to/wf.json' for large payloads)",
118
+ dont: "Try to use incremental update operations - they don't exist. Do not compare local JSON files in Python/scripts; use MCP get as format reference. Always deploy complete workflow_def.",
114
119
  example: `// The ONLY working pattern for workflow modifications:
115
120
  const data = await workflow({ mode: "get", persona_id: "abc" });
116
121
  const wf = data.workflow_def;
@@ -120,7 +125,7 @@ wf.actions.push(newNode);
120
125
  wf.actions[2].inputs.instructions.inline.wellKnown.stringValue = "new prompt";
121
126
 
122
127
  // Deploy the complete modified workflow
123
- await workflow({ mode: "deploy", persona_id: "abc", workflow_def: wf });`,
128
+ await workflow({ mode: "deploy", persona_id: "abc", base_fingerprint: data.fingerprint, workflow_def: wf });`,
124
129
  antiExample: `// WRONG: These patterns DO NOT WORK
125
130
  persona({ method: "update", id: "abc", input: "add HITL" }) // input param ignored
126
131
  persona({ method: "update", id: "abc", operations: [...] }) // operations param ignored`,
@@ -136,7 +141,7 @@ persona({ method: "update", id: "abc", operations: [...] }) // operations param
136
141
  title: "workflow_spec via persona update has limitations",
137
142
  applies: "When using persona(method='update', workflow_spec={...})",
138
143
  description: "persona(method='update', workflow_spec={...}) can ONLY remove/rewire nodes, NOT add new ones. " +
139
- "For adding nodes, use workflow(mode='deploy', workflow_def={...}) with full workflow.",
144
+ "For adding nodes, use workflow(mode='deploy', base_fingerprint='<fingerprint>', workflow_def={...}) with full workflow.",
140
145
  do: "Use workflow_spec for simple rewiring. Use workflow(mode='deploy') for adding nodes.",
141
146
  dont: "Try to add new nodes via workflow_spec - it will throw an error.",
142
147
  example: `// workflow_spec: good for rewiring/removing
@@ -152,7 +157,7 @@ await persona({
152
157
  // For adding nodes: use workflow(mode="deploy")
153
158
  const data = await workflow({ mode: "get", persona_id: "abc" });
154
159
  data.workflow_def.actions.push(newNode);
155
- await workflow({ mode: "deploy", persona_id: "abc", workflow_def: data.workflow_def });`,
160
+ await workflow({ mode: "deploy", persona_id: "abc", base_fingerprint: data.fingerprint, workflow_def: data.workflow_def });`,
156
161
  antiExample: `// WRONG: workflow_spec cannot add new nodes
157
162
  await persona({
158
163
  method: "update",
@@ -402,7 +407,7 @@ All workflow modifications follow a 3-step flow:
402
407
 
403
408
  1. Get: \`workflow(mode="get", persona_id="...")\` → returns workflow_def, schema
404
409
  2. LLM modifies the workflow_def JSON
405
- 3. Deploy: \`workflow(mode="deploy", persona_id="...", workflow_def={...})\`
410
+ 3. Deploy: \`workflow(mode="deploy", persona_id="...", base_fingerprint="<fingerprint>", workflow_def={...})\`
406
411
 
407
412
  The LLM generates the full workflow_def. MCP provides data and executes.
408
413
 
@@ -16,40 +16,7 @@
16
16
  * - Slower but allows data transformation
17
17
  */
18
18
  import { errorResult } from "../types.js";
19
- /**
20
- * Sanitization session for PII replacement.
21
- * Simple implementation - can be enhanced with more patterns.
22
- */
23
- class SanitizationSession {
24
- replacements = new Map();
25
- counters = {};
26
- getOrCreateReplacement(value, type) {
27
- const existing = this.replacements.get(value);
28
- if (existing)
29
- return existing;
30
- this.counters[type] = (this.counters[type] ?? 0) + 1;
31
- const replacement = `[${type.toUpperCase()}_${this.counters[type]}]`;
32
- this.replacements.set(value, replacement);
33
- return replacement;
34
- }
35
- }
36
- /**
37
- * Simple pattern-based PII detection.
38
- */
39
- function detectWithPatterns(text) {
40
- const detected = [];
41
- // Email pattern
42
- const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
43
- for (const match of text.matchAll(emailRegex)) {
44
- detected.push({ value: match[0], type: "email" });
45
- }
46
- // Phone pattern (various formats)
47
- const phoneRegex = /\b(?:\+?1[-.\s]?)?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g;
48
- for (const match of text.matchAll(phoneRegex)) {
49
- detected.push({ value: match[0], type: "phone" });
50
- }
51
- return detected;
52
- }
19
+ import { SanitizationSession, columnValueToInputWithSanitization, sanitizeString, } from "../../domain/dashboard.js";
53
20
  /**
54
21
  * Infer MIME type from filename extension.
55
22
  */
@@ -103,26 +70,12 @@ async function processDocumentColumn(client, sourcePersonaId, rowId, inputCol, d
103
70
  }
104
71
  /**
105
72
  * Process a string column with optional sanitization.
73
+ * Uses the domain sanitizeString function for PII removal.
106
74
  */
107
75
  function processStringColumn(inputCol, value, sanitizationSession, sanitizeExamples) {
108
- let processedValue = value;
109
- if (sanitizationSession && processedValue) {
110
- // Pattern-based sanitization
111
- const detected = detectWithPatterns(processedValue);
112
- for (const entity of detected) {
113
- const replacement = sanitizationSession.getOrCreateReplacement(entity.value, entity.type);
114
- processedValue = processedValue.split(entity.value).join(replacement);
115
- }
116
- // User-provided examples
117
- if (sanitizeExamples) {
118
- for (const example of sanitizeExamples) {
119
- if (processedValue.includes(example)) {
120
- const replacement = sanitizationSession.getOrCreateReplacement(example, "unknown");
121
- processedValue = processedValue.split(example).join(replacement);
122
- }
123
- }
124
- }
125
- }
76
+ const processedValue = sanitizationSession
77
+ ? sanitizeString(value, sanitizationSession, sanitizeExamples)
78
+ : value;
126
79
  return {
127
80
  name: inputCol.name,
128
81
  string_value: processedValue,
@@ -281,11 +234,44 @@ export async function handleDashboardClone(args, client) {
281
234
  }
282
235
  }
283
236
  else if (inputCol.columnType === "COLUMN_TYPE_ARRAY") {
237
+ // Handle full array - convert each element to DashboardInput using domain function
284
238
  const arrayVals = colValue.value?.arrayValue?.arrayValues ?? [];
285
239
  if (arrayVals.length > 0) {
286
- const value = arrayVals[0]?.stringValue ?? "";
287
- if (value) {
288
- inputs.push(processStringColumn(inputCol, value, sanitizationSession, sanitizeExamples));
240
+ const arrayInputs = [];
241
+ for (const elem of arrayVals) {
242
+ // Cast to ColumnValueData - types are structurally compatible
243
+ const elemInput = columnValueToInputWithSanitization("element", elem, sanitizationSession, sanitizeExamples);
244
+ if (elemInput) {
245
+ arrayInputs.push(elemInput);
246
+ }
247
+ }
248
+ if (arrayInputs.length > 0) {
249
+ inputs.push({
250
+ name: inputCol.name,
251
+ array_value: arrayInputs,
252
+ });
253
+ }
254
+ }
255
+ }
256
+ else if (inputCol.columnType === "COLUMN_TYPE_OBJECT") {
257
+ // Handle object columns - convert each sub-column using domain function
258
+ const objectVals = colValue.value?.objectValue?.objectValues ?? {};
259
+ const objectKeys = Object.keys(objectVals);
260
+ if (objectKeys.length > 0) {
261
+ const objectInput = {};
262
+ for (const key of objectKeys) {
263
+ const subValue = objectVals[key];
264
+ // Cast to ColumnValueData - types are structurally compatible
265
+ const subInput = columnValueToInputWithSanitization(key, subValue, sanitizationSession, sanitizeExamples);
266
+ if (subInput) {
267
+ objectInput[key] = subInput;
268
+ }
269
+ }
270
+ if (Object.keys(objectInput).length > 0) {
271
+ inputs.push({
272
+ name: inputCol.name,
273
+ object_value: objectInput,
274
+ });
289
275
  }
290
276
  }
291
277
  }