@ema.co/mcp-toolkit 0.2.3 → 0.3.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.
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Persona Version Tracking - Core Types and Logic
3
+ *
4
+ * Provides versioning infrastructure for AI Employee configurations.
5
+ * Designed to work locally (git-backed) and map cleanly to backend tables.
6
+ */
7
+ import crypto from "node:crypto";
8
+ import { fingerprintPersona } from "../sync.js";
9
+ // ─────────────────────────────────────────────────────────────────────────────
10
+ // Utility Functions
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ /**
13
+ * Generate a deterministic content hash for a snapshot.
14
+ * Used for deduplication and fast equality checks.
15
+ */
16
+ export function hashSnapshot(snapshot) {
17
+ // Sort keys for deterministic serialization
18
+ // Sort data_sources by file_id for deterministic ordering
19
+ const sortedDataSources = [...(snapshot.data_sources ?? [])].sort((a, b) => a.file_id.localeCompare(b.file_id));
20
+ const canonical = sortObjectKeys({
21
+ workflow_definition: snapshot.workflow_definition,
22
+ workflow_id: snapshot.workflow_id,
23
+ workflow_interface: snapshot.workflow_interface,
24
+ proto_config: snapshot.proto_config,
25
+ display_name: snapshot.display_name,
26
+ description: snapshot.description,
27
+ welcome_messages: snapshot.welcome_messages,
28
+ trigger_type: snapshot.trigger_type,
29
+ embedding_enabled: snapshot.embedding_enabled,
30
+ template_id: snapshot.template_id,
31
+ data_sources: sortedDataSources,
32
+ });
33
+ const bytes = Buffer.from(JSON.stringify(canonical), "utf8");
34
+ return crypto.createHash("sha256").update(bytes).digest("hex");
35
+ }
36
+ /**
37
+ * Deep sort object keys for deterministic JSON serialization.
38
+ */
39
+ function sortObjectKeys(obj) {
40
+ if (obj === null || obj === undefined)
41
+ return obj;
42
+ if (Array.isArray(obj))
43
+ return obj.map(sortObjectKeys);
44
+ if (typeof obj !== "object")
45
+ return obj;
46
+ const sorted = {};
47
+ for (const key of Object.keys(obj).sort()) {
48
+ sorted[key] = sortObjectKeys(obj[key]);
49
+ }
50
+ return sorted;
51
+ }
52
+ /**
53
+ * Generate a new version ID (UUID v4).
54
+ */
55
+ export function generateVersionId() {
56
+ return crypto.randomUUID();
57
+ }
58
+ /**
59
+ * Generate a version name from version number.
60
+ */
61
+ export function generateVersionName(versionNumber) {
62
+ return `v${versionNumber}`;
63
+ }
64
+ /**
65
+ * Get current ISO timestamp.
66
+ */
67
+ export function nowIso() {
68
+ return new Date().toISOString();
69
+ }
70
+ // ─────────────────────────────────────────────────────────────────────────────
71
+ // Snapshot Creation
72
+ // ─────────────────────────────────────────────────────────────────────────────
73
+ /**
74
+ * Extract data source references from persona's status_log.
75
+ * status_log is a Record<widgetName, FileStatus[]> where FileStatus has
76
+ * fields like: id, filename, status, tags, etc.
77
+ */
78
+ export function extractDataSources(persona) {
79
+ const statusLog = persona.status_log;
80
+ if (!statusLog) {
81
+ return [];
82
+ }
83
+ const dataSources = [];
84
+ for (const [widgetName, files] of Object.entries(statusLog)) {
85
+ // Skip internal/system entries (prefixed with underscore)
86
+ if (widgetName.startsWith("_")) {
87
+ continue;
88
+ }
89
+ if (!Array.isArray(files)) {
90
+ continue;
91
+ }
92
+ for (const file of files) {
93
+ if (typeof file !== "object" || file === null) {
94
+ continue;
95
+ }
96
+ const f = file;
97
+ // Use id if available, fallback to filename for legacy files
98
+ const fileId = f.id ?? f.filename;
99
+ if (!fileId) {
100
+ continue;
101
+ }
102
+ dataSources.push({
103
+ file_id: fileId,
104
+ filename: f.filename ?? fileId,
105
+ widget_name: widgetName,
106
+ tags: Array.isArray(f.tags) ? f.tags : [],
107
+ status: f.status ?? "unknown",
108
+ });
109
+ }
110
+ }
111
+ return dataSources;
112
+ }
113
+ /**
114
+ * Create a PersonaSnapshot from a PersonaDTO.
115
+ */
116
+ export function createSnapshotFromPersona(persona) {
117
+ return {
118
+ workflow_definition: persona.workflow_def ?? null,
119
+ workflow_id: persona.workflow_id ?? null,
120
+ workflow_interface: persona.workflow_interface ?? null,
121
+ proto_config: persona.proto_config ?? null,
122
+ display_name: persona.name ?? "",
123
+ description: persona.description ?? "",
124
+ welcome_messages: persona.welcome_messages ?? null,
125
+ trigger_type: persona.trigger_type ?? null,
126
+ embedding_enabled: persona.embedding_enabled ?? null,
127
+ template_id: persona.template_id ?? persona.templateId ?? null,
128
+ data_sources: extractDataSources(persona),
129
+ };
130
+ }
131
+ /**
132
+ * Create a new version snapshot from a persona.
133
+ */
134
+ export function createVersionSnapshot(options) {
135
+ const snapshot = createSnapshotFromPersona(options.persona);
136
+ const contentHash = hashSnapshot(snapshot);
137
+ const versionNumber = (options.previous_version_number ?? 0) + 1;
138
+ return {
139
+ id: generateVersionId(),
140
+ persona_id: options.persona.id,
141
+ tenant_id: options.tenant_id,
142
+ environment: options.environment,
143
+ version_number: versionNumber,
144
+ version_name: generateVersionName(versionNumber),
145
+ content_hash: contentHash,
146
+ snapshot,
147
+ created_at: nowIso(),
148
+ created_by: options.created_by ?? "mcp-toolkit",
149
+ trigger: options.trigger,
150
+ message: options.message ?? "",
151
+ parent_version_id: options.parent_version_id,
152
+ };
153
+ }
154
+ // ─────────────────────────────────────────────────────────────────────────────
155
+ // Version Comparison
156
+ // ─────────────────────────────────────────────────────────────────────────────
157
+ /**
158
+ * Compare two version snapshots and generate a diff.
159
+ */
160
+ export function compareVersions(v1, v2) {
161
+ const diff = {
162
+ v1: { id: v1.id, version_name: v1.version_name, created_at: v1.created_at },
163
+ v2: { id: v2.id, version_name: v2.version_name, created_at: v2.created_at },
164
+ identical: v1.content_hash === v2.content_hash,
165
+ changed_fields: [],
166
+ changes: {},
167
+ };
168
+ if (diff.identical) {
169
+ return diff;
170
+ }
171
+ // Compare snapshot fields (excluding data_sources which is handled separately)
172
+ const fields = [
173
+ "workflow_definition",
174
+ "workflow_id",
175
+ "workflow_interface",
176
+ "proto_config",
177
+ "display_name",
178
+ "description",
179
+ "welcome_messages",
180
+ "trigger_type",
181
+ "embedding_enabled",
182
+ "template_id",
183
+ ];
184
+ for (const field of fields) {
185
+ const val1 = v1.snapshot[field];
186
+ const val2 = v2.snapshot[field];
187
+ if (JSON.stringify(val1) !== JSON.stringify(val2)) {
188
+ diff.changed_fields.push(field);
189
+ diff.changes[field] = { from: val1, to: val2 };
190
+ }
191
+ }
192
+ // Detailed workflow diff if workflow changed
193
+ if (diff.changed_fields.includes("workflow_definition")) {
194
+ diff.workflow_diff = compareWorkflowDefinitions(v1.snapshot.workflow_definition, v2.snapshot.workflow_definition);
195
+ }
196
+ // Data source diff
197
+ const dataSourceDiff = compareDataSources(v1.snapshot.data_sources ?? [], v2.snapshot.data_sources ?? []);
198
+ if (dataSourceDiff.files_added.length > 0 || dataSourceDiff.files_removed.length > 0) {
199
+ diff.changed_fields.push("data_sources");
200
+ diff.changes["data_sources"] = {
201
+ from: v1.snapshot.data_sources,
202
+ to: v2.snapshot.data_sources,
203
+ };
204
+ diff.data_source_diff = dataSourceDiff;
205
+ }
206
+ return diff;
207
+ }
208
+ /**
209
+ * Compare data sources between two snapshots.
210
+ */
211
+ function compareDataSources(ds1, ds2) {
212
+ const ids1 = new Set(ds1.map((d) => d.file_id));
213
+ const ids2 = new Set(ds2.map((d) => d.file_id));
214
+ const files_added = [];
215
+ const files_removed = [];
216
+ // Files in ds2 but not in ds1 = added
217
+ for (const ds of ds2) {
218
+ if (!ids1.has(ds.file_id)) {
219
+ files_added.push(ds.filename);
220
+ }
221
+ }
222
+ // Files in ds1 but not in ds2 = removed
223
+ for (const ds of ds1) {
224
+ if (!ids2.has(ds.file_id)) {
225
+ files_removed.push(ds.filename);
226
+ }
227
+ }
228
+ return { files_added, files_removed };
229
+ }
230
+ /**
231
+ * Compare two workflow definitions and identify node changes.
232
+ */
233
+ function compareWorkflowDefinitions(w1, w2) {
234
+ const result = {
235
+ nodes_added: [],
236
+ nodes_removed: [],
237
+ nodes_modified: [],
238
+ };
239
+ if (!w1 && !w2)
240
+ return result;
241
+ if (!w1) {
242
+ // All nodes in w2 are new
243
+ const actions = w2?.actions ?? [];
244
+ result.nodes_added = actions.map((a) => a.name ?? "unnamed").filter(Boolean);
245
+ return result;
246
+ }
247
+ if (!w2) {
248
+ // All nodes in w1 are removed
249
+ const actions = w1.actions ?? [];
250
+ result.nodes_removed = actions.map((a) => a.name ?? "unnamed").filter(Boolean);
251
+ return result;
252
+ }
253
+ const actions1 = w1.actions ?? [];
254
+ const actions2 = w2.actions ?? [];
255
+ const names1 = new Set(actions1.map((a) => a.name ?? "").filter(Boolean));
256
+ const names2 = new Set(actions2.map((a) => a.name ?? "").filter(Boolean));
257
+ // Find added
258
+ for (const name of names2) {
259
+ if (!names1.has(name)) {
260
+ result.nodes_added.push(name);
261
+ }
262
+ }
263
+ // Find removed
264
+ for (const name of names1) {
265
+ if (!names2.has(name)) {
266
+ result.nodes_removed.push(name);
267
+ }
268
+ }
269
+ // Find modified (present in both but different)
270
+ const map1 = new Map(actions1.map((a) => [a.name ?? "", a]));
271
+ const map2 = new Map(actions2.map((a) => [a.name ?? "", a]));
272
+ for (const name of names1) {
273
+ if (names2.has(name)) {
274
+ const a1 = map1.get(name);
275
+ const a2 = map2.get(name);
276
+ if (JSON.stringify(a1) !== JSON.stringify(a2)) {
277
+ result.nodes_modified.push(name);
278
+ }
279
+ }
280
+ }
281
+ return result;
282
+ }
283
+ // ─────────────────────────────────────────────────────────────────────────────
284
+ // Change Summary Generation
285
+ // ─────────────────────────────────────────────────────────────────────────────
286
+ /**
287
+ * Generate a human-readable summary of changes between versions.
288
+ */
289
+ export function generateChangesSummary(diff) {
290
+ if (diff.identical) {
291
+ return ["No changes"];
292
+ }
293
+ const summary = [];
294
+ // Field-level changes
295
+ for (const field of diff.changed_fields) {
296
+ if (field === "workflow_definition" && diff.workflow_diff) {
297
+ // Detailed workflow changes
298
+ const wd = diff.workflow_diff;
299
+ if (wd.nodes_added.length > 0) {
300
+ summary.push(`Added nodes: ${wd.nodes_added.join(", ")}`);
301
+ }
302
+ if (wd.nodes_removed.length > 0) {
303
+ summary.push(`Removed nodes: ${wd.nodes_removed.join(", ")}`);
304
+ }
305
+ if (wd.nodes_modified.length > 0) {
306
+ summary.push(`Modified nodes: ${wd.nodes_modified.join(", ")}`);
307
+ }
308
+ }
309
+ else if (field === "data_sources" && diff.data_source_diff) {
310
+ // Detailed data source changes
311
+ const dsd = diff.data_source_diff;
312
+ if (dsd.files_added.length > 0) {
313
+ summary.push(`Added files: ${dsd.files_added.join(", ")}`);
314
+ }
315
+ if (dsd.files_removed.length > 0) {
316
+ summary.push(`Removed files: ${dsd.files_removed.join(", ")}`);
317
+ }
318
+ }
319
+ else if (field === "display_name") {
320
+ const from = diff.changes[field]?.from;
321
+ const to = diff.changes[field]?.to;
322
+ summary.push(`Renamed: "${from}" → "${to}"`);
323
+ }
324
+ else if (field === "description") {
325
+ summary.push("Description updated");
326
+ }
327
+ else if (field === "proto_config") {
328
+ summary.push("Configuration updated");
329
+ }
330
+ else if (field === "welcome_messages") {
331
+ summary.push("Welcome messages updated");
332
+ }
333
+ else if (field === "embedding_enabled") {
334
+ const enabled = diff.changes[field]?.to;
335
+ summary.push(`Embedding ${enabled ? "enabled" : "disabled"}`);
336
+ }
337
+ else {
338
+ summary.push(`${field} changed`);
339
+ }
340
+ }
341
+ return summary.length > 0 ? summary : ["Configuration changed"];
342
+ }
343
+ // ─────────────────────────────────────────────────────────────────────────────
344
+ // Exports
345
+ // ─────────────────────────────────────────────────────────────────────────────
346
+ export { fingerprintPersona };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ema.co/mcp-toolkit",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Ema AI Employee toolkit - MCP server, CLI, and SDK for managing AI Employees across environments",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,175 +0,0 @@
1
- # Advisor Communications Assistant - Workflow Fixes
2
-
3
- **Persona ID**: `d5aa0b7a-792d-4e3d-8a74-ac87457ffca2`
4
- **Environment**: demo
5
- **Analysis Date**: 2026-01-10
6
-
7
- ## Summary
8
-
9
- | Severity | Count | Status |
10
- |----------|-------|--------|
11
- | Critical | 3 | Needs fix |
12
- | Warning | 0 | - |
13
- | Info | 4 | Acceptable |
14
-
15
- ## Critical Issues & Fixes
16
-
17
- ### Issue 1: Document → Email Attachment Type Mismatch
18
-
19
- **Location**: `generate_documentnr7ttm` → `send_email_agentq7zdvj`
20
-
21
- **Problem**:
22
- - `document_link` output is type `WELL_KNOWN_TYPE_DOCUMENT`
23
- - `attachment_links` input expects `WELL_KNOWN_TYPE_TEXT_WITH_SOURCES`
24
-
25
- **Fix Options**:
26
-
27
- **Option A (Recommended)**: Use `named_inputs` instead
28
- ```yaml
29
- # Change from:
30
- send_email_agentq7zdvj.inputs.attachment_links:
31
- actionOutput:
32
- actionName: generate_documentnr7ttm
33
- output: document_link
34
-
35
- # To:
36
- send_email_agentq7zdvj.inputs.named_inputs:
37
- multiBinding:
38
- elements:
39
- - namedBinding:
40
- name: document_attachment
41
- value:
42
- actionOutput:
43
- actionName: generate_documentnr7ttm
44
- output: document_link
45
- ```
46
-
47
- **Option B**: Use a fixed_response node to convert document_link to text
48
-
49
- ---
50
-
51
- ### Issue 2: Combined Results → Content Generator Type Mismatch
52
-
53
- **Location**: `combine_search_resultssmoo58` → `personalized_content_generatorfosf62`
54
-
55
- **Problem**:
56
- - `combined_results` output is type `WELL_KNOWN_TYPE_TEXT_WITH_SOURCES`
57
- - `search_results` input expects `WELL_KNOWN_TYPE_SEARCH_RESULT`
58
-
59
- **Fix**:
60
- Route original search results directly instead of combined:
61
-
62
- ```yaml
63
- # Change from:
64
- personalized_content_generatorfosf62.inputs.search_results:
65
- actionOutput:
66
- actionName: combine_search_resultssmoo58
67
- output: combined_results
68
-
69
- # To:
70
- personalized_content_generatorfosf62.inputs.search_results:
71
- actionOutput:
72
- actionName: searchfleqzf
73
- output: search_results
74
- ```
75
-
76
- **Alternative**: Use `named_inputs` for combined results:
77
- ```yaml
78
- personalized_content_generatorfosf62.inputs.named_inputs:
79
- multiBinding:
80
- elements:
81
- - namedBinding:
82
- name: combined_context
83
- value:
84
- actionOutput:
85
- actionName: combine_search_resultssmoo58
86
- output: combined_results
87
- ```
88
-
89
- ---
90
-
91
- ### Issue 3: Summarized Conversation → Custom Agent Conversation Mismatch
92
-
93
- **Location**: `conversation_summarizer` → `custom_agentrjrvw6`
94
-
95
- **Problem**:
96
- - `summarized_conversation` is type `WELL_KNOWN_TYPE_TEXT_WITH_SOURCES`
97
- - `conversation` input expects `WELL_KNOWN_TYPE_CHAT_CONVERSATION`
98
-
99
- **Fix**:
100
- Use the trigger's conversation output instead:
101
-
102
- ```yaml
103
- # Change from:
104
- custom_agentrjrvw6.inputs.conversation:
105
- actionOutput:
106
- actionName: conversation_summarizer
107
- output: summarized_conversation
108
-
109
- # To:
110
- custom_agentrjrvw6.inputs.conversation:
111
- actionOutput:
112
- actionName: trigger
113
- output: chat_conversation
114
- ```
115
-
116
- And pass the summarized query via `named_inputs`:
117
- ```yaml
118
- custom_agentrjrvw6.inputs.named_inputs:
119
- multiBinding:
120
- elements:
121
- - namedBinding:
122
- name: context
123
- value:
124
- actionOutput:
125
- actionName: conversation_summarizer
126
- output: summarized_conversation
127
- ```
128
-
129
- ---
130
-
131
- ## Info Issues (Acceptable)
132
-
133
- ### Sequential Searches
134
- The workflow has sequential dependencies between searches:
135
- - `conversation_summarizer` → `live_web_search7kmyak`
136
- - `searchfleqzf` → `combine_search_resultssmoo58`
137
-
138
- **Assessment**: Acceptable - the searches depend on each other's outputs.
139
-
140
- ### Multiple LLM Nodes
141
- 5 LLM nodes process results from `searchfleqzf`:
142
- - `respond_client_update`
143
- - `respond_market_impact`
144
- - `respond_exposure`
145
- - `respond_compliance`
146
- - `respond_health`
147
-
148
- **Assessment**: Expected behavior - these are conditional branches from the categorizer.
149
-
150
- ---
151
-
152
- ## Application Instructions
153
-
154
- ### Via Ema UI (Recommended)
155
- 1. Open persona in Auto Builder
156
- 2. For each critical issue:
157
- - Click on the target node
158
- - Modify the input binding as described
159
- 3. Save and test
160
-
161
- ### Via API
162
- Use the `/api/personas/update_persona` endpoint with the corrected `workflow` field.
163
-
164
- ---
165
-
166
- ## Type Compatibility Quick Reference
167
-
168
- | From Type | Can Connect To |
169
- |-----------|---------------|
170
- | CHAT_CONVERSATION | conversation inputs, named_inputs |
171
- | TEXT_WITH_SOURCES | query, named_inputs, instructions |
172
- | SEARCH_RESULT | search_results inputs, named_inputs |
173
- | DOCUMENT | named_inputs (NOT text inputs) |
174
-
175
- **Rule**: When in doubt, use `named_inputs` - it accepts ANY type.