@ema.co/mcp-toolkit 0.2.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +321 -0
  3. package/config.example.yaml +32 -0
  4. package/dist/cli/index.js +333 -0
  5. package/dist/config.js +136 -0
  6. package/dist/emaClient.js +398 -0
  7. package/dist/index.js +109 -0
  8. package/dist/mcp/handlers-consolidated.js +851 -0
  9. package/dist/mcp/index.js +15 -0
  10. package/dist/mcp/prompts.js +1753 -0
  11. package/dist/mcp/resources.js +624 -0
  12. package/dist/mcp/server.js +4723 -0
  13. package/dist/mcp/tools-consolidated.js +590 -0
  14. package/dist/mcp/tools-legacy.js +736 -0
  15. package/dist/models.js +8 -0
  16. package/dist/scheduler.js +21 -0
  17. package/dist/sdk/client.js +788 -0
  18. package/dist/sdk/config.js +136 -0
  19. package/dist/sdk/contracts.js +429 -0
  20. package/dist/sdk/generation-schema.js +189 -0
  21. package/dist/sdk/index.js +39 -0
  22. package/dist/sdk/knowledge.js +2780 -0
  23. package/dist/sdk/models.js +8 -0
  24. package/dist/sdk/state.js +88 -0
  25. package/dist/sdk/sync-options.js +216 -0
  26. package/dist/sdk/sync.js +220 -0
  27. package/dist/sdk/validation-rules.js +355 -0
  28. package/dist/sdk/workflow-generator.js +291 -0
  29. package/dist/sdk/workflow-intent.js +1585 -0
  30. package/dist/state.js +88 -0
  31. package/dist/sync.js +416 -0
  32. package/dist/syncOptions.js +216 -0
  33. package/dist/ui.js +334 -0
  34. package/docs/advisor-comms-assistant-fixes.md +175 -0
  35. package/docs/api-contracts.md +216 -0
  36. package/docs/auto-builder-analysis.md +271 -0
  37. package/docs/data-architecture.md +166 -0
  38. package/docs/ema-auto-builder-guide.html +394 -0
  39. package/docs/ema-user-guide.md +1121 -0
  40. package/docs/mcp-tools-guide.md +149 -0
  41. package/docs/naming-conventions.md +218 -0
  42. package/docs/tool-consolidation-proposal.md +427 -0
  43. package/package.json +98 -0
  44. package/resources/templates/chat-ai/README.md +119 -0
  45. package/resources/templates/chat-ai/persona-config.json +111 -0
  46. package/resources/templates/dashboard-ai/README.md +156 -0
  47. package/resources/templates/dashboard-ai/persona-config.json +180 -0
  48. package/resources/templates/voice-ai/README.md +123 -0
  49. package/resources/templates/voice-ai/persona-config.json +74 -0
  50. package/resources/templates/voice-ai/workflow-prompt.md +120 -0
@@ -0,0 +1,788 @@
1
+ export class EmaApiError extends Error {
2
+ statusCode;
3
+ body;
4
+ constructor(opts) {
5
+ super(opts.message);
6
+ this.statusCode = opts.statusCode;
7
+ this.body = opts.body;
8
+ this.name = "EmaApiError";
9
+ }
10
+ }
11
+ function sleep(ms) {
12
+ return new Promise((resolve) => setTimeout(resolve, ms));
13
+ }
14
+ export class EmaClient {
15
+ env;
16
+ timeoutMs;
17
+ tokenRefreshConfig;
18
+ refreshIntervalId;
19
+ isRefreshing = false;
20
+ constructor(env, opts) {
21
+ this.env = env;
22
+ this.timeoutMs = opts?.timeoutMs ?? 30_000;
23
+ this.tokenRefreshConfig = opts?.tokenRefresh;
24
+ // Start background refresh if configured
25
+ if (this.tokenRefreshConfig?.backgroundRefresh !== false && this.tokenRefreshConfig?.refreshCallback) {
26
+ this.startBackgroundRefresh();
27
+ }
28
+ }
29
+ /**
30
+ * Start background token refresh.
31
+ * Refreshes tokens proactively before expiration.
32
+ */
33
+ startBackgroundRefresh() {
34
+ if (this.refreshIntervalId)
35
+ return;
36
+ const intervalMs = this.tokenRefreshConfig?.refreshIntervalMs ?? 50 * 60 * 1000; // 50 minutes default
37
+ this.refreshIntervalId = setInterval(async () => {
38
+ await this.refreshToken();
39
+ }, intervalMs);
40
+ // Don't prevent process exit
41
+ if (this.refreshIntervalId.unref) {
42
+ this.refreshIntervalId.unref();
43
+ }
44
+ }
45
+ /**
46
+ * Stop background token refresh.
47
+ */
48
+ stopBackgroundRefresh() {
49
+ if (this.refreshIntervalId) {
50
+ clearInterval(this.refreshIntervalId);
51
+ this.refreshIntervalId = undefined;
52
+ }
53
+ }
54
+ /**
55
+ * Manually refresh the token.
56
+ * Returns true if refresh succeeded.
57
+ */
58
+ async refreshToken() {
59
+ if (!this.tokenRefreshConfig?.refreshCallback)
60
+ return false;
61
+ if (this.isRefreshing)
62
+ return false;
63
+ this.isRefreshing = true;
64
+ try {
65
+ const newToken = await this.tokenRefreshConfig.refreshCallback();
66
+ if (newToken) {
67
+ this.env = { ...this.env, bearerToken: newToken };
68
+ return true;
69
+ }
70
+ return false;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ finally {
76
+ this.isRefreshing = false;
77
+ }
78
+ }
79
+ /**
80
+ * Update the bearer token directly.
81
+ */
82
+ updateToken(newToken) {
83
+ this.env = { ...this.env, bearerToken: newToken };
84
+ }
85
+ /**
86
+ * Get the current environment (read-only).
87
+ */
88
+ getEnvironment() {
89
+ return this.env;
90
+ }
91
+ async requestWithRetries(method, path, opts) {
92
+ const retries = opts?.retries ?? 4;
93
+ const baseDelayMs = opts?.baseDelayMs ?? 500;
94
+ let hasAttemptedRefresh = false;
95
+ let lastErr;
96
+ for (let attempt = 0; attempt <= retries; attempt++) {
97
+ try {
98
+ const controller = new AbortController();
99
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
100
+ const fullUrl = `${this.env.baseUrl.replace(/\/$/, "")}${path}`;
101
+ const response = await fetch(fullUrl, {
102
+ method,
103
+ headers: {
104
+ Authorization: `Bearer ${this.env.bearerToken}`,
105
+ "Content-Type": "application/json",
106
+ },
107
+ body: opts?.json !== undefined ? JSON.stringify(opts.json) : undefined,
108
+ signal: controller.signal,
109
+ });
110
+ clearTimeout(timeoutId);
111
+ // Handle 401 - attempt token refresh once
112
+ if (response.status === 401 && !hasAttemptedRefresh && this.tokenRefreshConfig?.refreshCallback) {
113
+ hasAttemptedRefresh = true;
114
+ const refreshed = await this.refreshToken();
115
+ if (refreshed) {
116
+ // Retry immediately with new token
117
+ continue;
118
+ }
119
+ }
120
+ // Retry on transient errors
121
+ if ([429, 500, 502, 503, 504].includes(response.status) && attempt < retries) {
122
+ const delay = baseDelayMs * 2 ** attempt;
123
+ await sleep(delay);
124
+ continue;
125
+ }
126
+ return response;
127
+ }
128
+ catch (e) {
129
+ lastErr = e instanceof Error ? e : new Error(String(e));
130
+ if (attempt >= retries)
131
+ throw lastErr;
132
+ const delay = baseDelayMs * 2 ** attempt;
133
+ await sleep(delay);
134
+ }
135
+ }
136
+ throw lastErr ?? new Error("Unreachable");
137
+ }
138
+ // ─────────────────────────────────────────────────────────────────────────────
139
+ // AI Employees (Personas)
140
+ // ─────────────────────────────────────────────────────────────────────────────
141
+ async getPersonasForTenant() {
142
+ // Try POST first (original API), fall back to GET if 405
143
+ let resp = await this.requestWithRetries("POST", "/api/personas/get_personas_for_tenant", {
144
+ json: {},
145
+ });
146
+ // If POST fails with 405, try GET method
147
+ if (resp.status === 405) {
148
+ resp = await this.requestWithRetries("GET", "/api/personas/get_personas_for_tenant", {});
149
+ }
150
+ if (!resp.ok) {
151
+ throw new EmaApiError({
152
+ statusCode: resp.status,
153
+ body: await resp.text(),
154
+ message: `get_personas_for_tenant failed (${this.env.name})`,
155
+ });
156
+ }
157
+ const data = (await resp.json());
158
+ const personas = data.personas ?? data.configs ?? [];
159
+ return personas;
160
+ }
161
+ /** Alias: in the UI, personas are called "AI Employees" */
162
+ async getAiEmployeesForTenant() {
163
+ return this.getPersonasForTenant();
164
+ }
165
+ /**
166
+ * Get a single persona by ID with full details including workflow_def.
167
+ * Uses /api/personas/{id} endpoint which returns the complete persona including workflow_def.
168
+ */
169
+ async getPersonaById(personaId) {
170
+ // Primary: /api/personas/{id} - returns full persona with workflow_def
171
+ // Note: get_minimal_persona=false is REQUIRED to get workflow_def in response
172
+ try {
173
+ const resp = await this.requestWithRetries("GET", `/api/personas/${personaId}?get_minimal_persona=false`, {});
174
+ if (resp.ok) {
175
+ return (await resp.json());
176
+ }
177
+ }
178
+ catch {
179
+ // Fall through to fallback
180
+ }
181
+ // Fallback: /api/ai_employee/get_ai_employee endpoint
182
+ try {
183
+ const resp = await this.requestWithRetries("GET", `/api/ai_employee/get_ai_employee?persona_id=${personaId}`, {});
184
+ if (resp.ok) {
185
+ const data = (await resp.json());
186
+ return data.persona ?? data.ai_employee ?? data.config ?? null;
187
+ }
188
+ }
189
+ catch {
190
+ // Fall through to return null
191
+ }
192
+ return null;
193
+ }
194
+ /**
195
+ * Get workflow definition by workflow_id.
196
+ * Tries multiple API endpoints to fetch the workflow.
197
+ */
198
+ async getWorkflowDef(workflowId) {
199
+ // Try gRPC-web style GetWorkflow endpoint
200
+ try {
201
+ const resp = await this.requestWithRetries("POST", "/workflows.v1.WorkflowManager/GetWorkflow", {
202
+ json: { workflow_id: workflowId },
203
+ });
204
+ if (resp.ok) {
205
+ const data = (await resp.json());
206
+ return data.workflow ?? (Object.keys(data).length > 0 ? data : null);
207
+ }
208
+ }
209
+ catch {
210
+ // Fall through
211
+ }
212
+ // Try GetWorkflowDefinition endpoint (alternate name)
213
+ try {
214
+ const resp = await this.requestWithRetries("POST", "/workflows.v1.WorkflowManager/GetWorkflowDefinition", {
215
+ json: { workflow_id: workflowId },
216
+ });
217
+ if (resp.ok) {
218
+ const data = (await resp.json());
219
+ return data;
220
+ }
221
+ }
222
+ catch {
223
+ // Fall through
224
+ }
225
+ // Try listing actions and constructing workflow
226
+ try {
227
+ const actions = await this.listActionsFromWorkflow(workflowId);
228
+ if (actions && actions.length > 0) {
229
+ // Return actions as part of workflow def (partial reconstruction)
230
+ return { workflow_id: workflowId, actions: actions };
231
+ }
232
+ }
233
+ catch {
234
+ // Fall through
235
+ }
236
+ return null;
237
+ }
238
+ async updateAiEmployee(req, opts) {
239
+ // Use /api/personas/update_persona - same endpoint as Ema frontend
240
+ const resp = await this.requestWithRetries("POST", "/api/personas/update_persona", {
241
+ json: req,
242
+ });
243
+ if (!resp.ok) {
244
+ const body = await resp.text();
245
+ // Try to parse error body for better error messages
246
+ let errorDetail = "";
247
+ try {
248
+ const errorJson = JSON.parse(body);
249
+ // Ema API returns InvalidPersonaResponse with 'detail' field
250
+ errorDetail = errorJson.detail ?? errorJson.message ?? errorJson.error ?? "";
251
+ // Also check for GWE (workflow) issues
252
+ if (errorJson.gwe_issues) {
253
+ const issues = errorJson.gwe_issues;
254
+ if (Array.isArray(issues)) {
255
+ errorDetail += ` Workflow issues: ${issues.map((i) => i.message ?? i.detail ?? JSON.stringify(i)).join("; ")}`;
256
+ }
257
+ }
258
+ }
259
+ catch {
260
+ // Body is not JSON, use as-is
261
+ errorDetail = body;
262
+ }
263
+ throw new EmaApiError({
264
+ statusCode: resp.status,
265
+ body,
266
+ message: errorDetail
267
+ ? `update_ai_employee failed (${this.env.name}): ${errorDetail}`
268
+ : `update_ai_employee failed (${this.env.name}) - status ${resp.status}`,
269
+ });
270
+ }
271
+ return (await resp.json());
272
+ }
273
+ async createAiEmployee(req, opts) {
274
+ const resp = await this.requestWithRetries("POST", "/api/ai_employee/create_ai_employee", {
275
+ json: req,
276
+ });
277
+ if (!resp.ok) {
278
+ const body = await resp.text();
279
+ throw new EmaApiError({
280
+ statusCode: resp.status,
281
+ body,
282
+ message: `create_ai_employee failed (${this.env.name})`,
283
+ });
284
+ }
285
+ return (await resp.json());
286
+ }
287
+ // ─────────────────────────────────────────────────────────────────────────────
288
+ // Data Source Management
289
+ // ─────────────────────────────────────────────────────────────────────────────
290
+ /**
291
+ * Upload a file to an AI Employee's knowledge base.
292
+ * API endpoint: POST /api/v2/upload/files (multipart/form-data)
293
+ */
294
+ async uploadDataSource(personaId, fileContent, filename, opts) {
295
+ const widgetName = opts?.widgetName ?? "fileUpload";
296
+ const tags = opts?.tags ?? widgetName;
297
+ const mimeType = opts?.mimeType ?? this.detectMimeType(filename);
298
+ // Build multipart form data manually
299
+ const boundary = `----EmaUploadBoundary${Date.now()}`;
300
+ const parts = [];
301
+ // persona_id field
302
+ parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="persona_id"\r\n\r\n${personaId}`);
303
+ // tags field
304
+ parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="tags"\r\n\r\n${tags}`);
305
+ // widget_name field
306
+ parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="widget_name"\r\n\r\n${widgetName}`);
307
+ // Encode file content parts separately to handle binary data
308
+ const textEncoder = new TextEncoder();
309
+ const headerPart = textEncoder.encode(parts.join("\r\n") + "\r\n" +
310
+ `--${boundary}\r\nContent-Disposition: form-data; name="files-0"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`);
311
+ const filenamePart = textEncoder.encode(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="files-0"\r\n\r\n${filename}`);
312
+ const pathPart = textEncoder.encode(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="files-0-path"\r\n\r\n`);
313
+ const closingPart = textEncoder.encode(`\r\n--${boundary}--\r\n`);
314
+ // Combine all parts
315
+ const totalLength = headerPart.length + fileContent.length + filenamePart.length + pathPart.length + closingPart.length;
316
+ const body = new Uint8Array(totalLength);
317
+ let offset = 0;
318
+ body.set(headerPart, offset);
319
+ offset += headerPart.length;
320
+ body.set(fileContent, offset);
321
+ offset += fileContent.length;
322
+ body.set(filenamePart, offset);
323
+ offset += filenamePart.length;
324
+ body.set(pathPart, offset);
325
+ offset += pathPart.length;
326
+ body.set(closingPart, offset);
327
+ const controller = new AbortController();
328
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs * 2); // Longer timeout for uploads
329
+ try {
330
+ const resp = await fetch(`${this.env.baseUrl.replace(/\/$/, "")}/api/v2/upload/files`, {
331
+ method: "POST",
332
+ headers: {
333
+ Authorization: `Bearer ${this.env.bearerToken}`,
334
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
335
+ "X-Persona-Id": personaId,
336
+ },
337
+ body: body,
338
+ signal: controller.signal,
339
+ });
340
+ clearTimeout(timeoutId);
341
+ if (!resp.ok) {
342
+ const errorBody = await resp.text();
343
+ throw new EmaApiError({
344
+ statusCode: resp.status,
345
+ body: errorBody,
346
+ message: `uploadDataSource failed (${this.env.name})`,
347
+ });
348
+ }
349
+ const result = await resp.json();
350
+ return {
351
+ fileId: result.file_id ?? result.id ?? "",
352
+ status: result.status ?? "uploaded",
353
+ filename: filename,
354
+ };
355
+ }
356
+ finally {
357
+ clearTimeout(timeoutId);
358
+ }
359
+ }
360
+ /**
361
+ * Delete a data source file from an AI Employee's knowledge base.
362
+ * API endpoint: DELETE /api/v2/upload/files/{file_id} or via persona update
363
+ */
364
+ async deleteDataSource(personaId, fileId) {
365
+ // Try direct delete endpoint first
366
+ const controller = new AbortController();
367
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
368
+ try {
369
+ const resp = await fetch(`${this.env.baseUrl.replace(/\/$/, "")}/api/v2/upload/files/${fileId}?persona_id=${personaId}`, {
370
+ method: "DELETE",
371
+ headers: {
372
+ Authorization: `Bearer ${this.env.bearerToken}`,
373
+ "X-Persona-Id": personaId,
374
+ },
375
+ signal: controller.signal,
376
+ });
377
+ clearTimeout(timeoutId);
378
+ if (!resp.ok && resp.status !== 404) {
379
+ const errorBody = await resp.text();
380
+ throw new EmaApiError({
381
+ statusCode: resp.status,
382
+ body: errorBody,
383
+ message: `deleteDataSource failed (${this.env.name})`,
384
+ });
385
+ }
386
+ return { success: true, fileId };
387
+ }
388
+ finally {
389
+ clearTimeout(timeoutId);
390
+ }
391
+ }
392
+ /**
393
+ * List data source files for an AI Employee.
394
+ * Uses the DataIngestService gRPC endpoint.
395
+ */
396
+ async listDataSourceFiles(personaId) {
397
+ // Try the content aggregates endpoint (may return limited info)
398
+ try {
399
+ const resp = await this.requestWithRetries("POST", "/dataingest.v1.DataIngestService/GetContentNodeAggregates", {
400
+ json: {
401
+ widget_name: "fileUpload",
402
+ include_files: true,
403
+ persona_id: personaId,
404
+ },
405
+ });
406
+ if (resp.ok) {
407
+ const data = await resp.json();
408
+ return (data.files ?? []).map((f) => ({
409
+ id: f.id,
410
+ filename: f.name,
411
+ status: f.status ?? "unknown",
412
+ }));
413
+ }
414
+ }
415
+ catch {
416
+ // Fall through to return empty
417
+ }
418
+ return [];
419
+ }
420
+ /**
421
+ * Detect MIME type from filename extension.
422
+ */
423
+ detectMimeType(filename) {
424
+ const ext = filename.toLowerCase().split(".").pop() ?? "";
425
+ const mimeTypes = {
426
+ pdf: "application/pdf",
427
+ doc: "application/msword",
428
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
429
+ txt: "text/plain",
430
+ md: "text/markdown",
431
+ csv: "text/csv",
432
+ json: "application/json",
433
+ xml: "application/xml",
434
+ html: "text/html",
435
+ htm: "text/html",
436
+ xls: "application/vnd.ms-excel",
437
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
438
+ ppt: "application/vnd.ms-powerpoint",
439
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
440
+ png: "image/png",
441
+ jpg: "image/jpeg",
442
+ jpeg: "image/jpeg",
443
+ gif: "image/gif",
444
+ svg: "image/svg+xml",
445
+ };
446
+ return mimeTypes[ext] ?? "application/octet-stream";
447
+ }
448
+ // ─────────────────────────────────────────────────────────────────────────────
449
+ // Actions (displayed as "Agents" in the UI)
450
+ // ─────────────────────────────────────────────────────────────────────────────
451
+ /**
452
+ * List all actions available to the tenant.
453
+ * API endpoint: workflows.v1.ActionManager/ListActions
454
+ * UI label: "Agents"
455
+ */
456
+ async listActions() {
457
+ const resp = await this.requestWithRetries("POST", "/workflows.v1.ActionManager/ListActions", {
458
+ json: {},
459
+ });
460
+ if (!resp.ok) {
461
+ throw new EmaApiError({
462
+ statusCode: resp.status,
463
+ body: await resp.text(),
464
+ message: `listActions failed (${this.env.name})`,
465
+ });
466
+ }
467
+ const data = (await resp.json());
468
+ return data.actions ?? [];
469
+ }
470
+ /** Alias: in the UI, actions are called "Agents" */
471
+ async listAgents() {
472
+ return this.listActions();
473
+ }
474
+ // ─────────────────────────────────────────────────────────────────────────────
475
+ // Persona Templates
476
+ // ─────────────────────────────────────────────────────────────────────────────
477
+ /**
478
+ * List persona templates available to the current tenant.
479
+ * API endpoint: /api/personas/get_persona_templates
480
+ *
481
+ * Returns system templates (chatbot_starter, voicebot_ai_employee, etc.)
482
+ * that define pre-configured AI Employee configurations.
483
+ */
484
+ async getPersonaTemplates() {
485
+ // Try GET first (newer API), fall back to POST
486
+ let resp = await this.requestWithRetries("GET", "/api/personas/get_persona_templates", {});
487
+ if (resp.status === 405) {
488
+ // Fall back to POST
489
+ resp = await this.requestWithRetries("POST", "/api/personas/get_persona_templates", {
490
+ json: {},
491
+ });
492
+ }
493
+ if (!resp.ok) {
494
+ throw new EmaApiError({
495
+ statusCode: resp.status,
496
+ body: await resp.text(),
497
+ message: `getPersonaTemplates failed (${this.env.name})`,
498
+ });
499
+ }
500
+ const data = (await resp.json());
501
+ return data.templates ?? [];
502
+ }
503
+ /**
504
+ * Get a specific persona template by ID.
505
+ *
506
+ * @param templateId - The template ID to fetch
507
+ */
508
+ async getPersonaTemplateById(templateId) {
509
+ const templates = await this.getPersonaTemplates();
510
+ return templates.find((t) => t.id === templateId) ?? null;
511
+ }
512
+ /**
513
+ * List actions associated with a specific workflow.
514
+ * API endpoint: workflows.v1.ActionManager/ListActionsFromWorkflow
515
+ * UI label: "Agents"
516
+ *
517
+ * @param workflowId - The workflow ID to list actions for
518
+ */
519
+ async listActionsFromWorkflow(workflowId) {
520
+ const resp = await this.requestWithRetries("POST", "/workflows.v1.ActionManager/ListActionsFromWorkflow", { json: { workflow_id: workflowId } });
521
+ if (!resp.ok) {
522
+ throw new EmaApiError({
523
+ statusCode: resp.status,
524
+ body: await resp.text(),
525
+ message: `listActionsFromWorkflow failed (${this.env.name})`,
526
+ });
527
+ }
528
+ const data = (await resp.json());
529
+ return data.actions ?? [];
530
+ }
531
+ /** Alias: in the UI, actions are called "Agents" */
532
+ async listAgentsFromWorkflow(workflowId) {
533
+ return this.listActionsFromWorkflow(workflowId);
534
+ }
535
+ // ─────────────────────────────────────────────────────────────────────────────
536
+ // Sync Metadata (tagging synced personas)
537
+ // ─────────────────────────────────────────────────────────────────────────────
538
+ /**
539
+ * Sync tag regex patterns.
540
+ * Current format: <!-- synced_from:env/id -->
541
+ * Legacy formats also supported for backward compatibility.
542
+ */
543
+ static SYNC_TAG_REGEX = /<!-- synced_from:([^/]+)\/([a-f0-9-]+) -->/;
544
+ static LEGACY_JSON_REGEX = /<!-- _ema_sync:(.+?) -->/;
545
+ /**
546
+ * Get sync metadata from a persona's description.
547
+ * Returns null if the persona has never been synced.
548
+ */
549
+ getSyncMetadata(persona) {
550
+ // Check top-level description for sync tag
551
+ const desc = persona.description;
552
+ if (desc) {
553
+ const meta = this.extractSyncMetadataFromString(desc);
554
+ if (meta)
555
+ return meta;
556
+ }
557
+ // Fallback: check proto_config._ema_sync (legacy)
558
+ const protoConfig = persona.proto_config;
559
+ if (protoConfig?._ema_sync) {
560
+ return protoConfig._ema_sync;
561
+ }
562
+ return null;
563
+ }
564
+ /**
565
+ * Extract sync metadata from a string (description field).
566
+ * Supports both new compact format and legacy JSON format.
567
+ */
568
+ extractSyncMetadataFromString(text) {
569
+ // Try new compact format: <!-- synced_from:env/id -->
570
+ const compactMatch = text.match(EmaClient.SYNC_TAG_REGEX);
571
+ if (compactMatch) {
572
+ return {
573
+ master_env: compactMatch[1],
574
+ master_id: compactMatch[2],
575
+ synced_at: new Date().toISOString(),
576
+ };
577
+ }
578
+ // Try legacy JSON format: <!-- _ema_sync:{...} -->
579
+ const legacyMatch = text.match(EmaClient.LEGACY_JSON_REGEX);
580
+ if (legacyMatch) {
581
+ try {
582
+ return JSON.parse(legacyMatch[1]);
583
+ }
584
+ catch {
585
+ return null;
586
+ }
587
+ }
588
+ return null;
589
+ }
590
+ /**
591
+ * Check if a persona was synced from a master environment.
592
+ */
593
+ isSyncedPersona(persona) {
594
+ return this.getSyncMetadata(persona) !== null;
595
+ }
596
+ /**
597
+ * Regex to clean all sync tag formats from description.
598
+ */
599
+ static SYNC_CLEANUP_REGEX = /<!-- (?:synced_from|sync|_ema_sync):[^\n]*-->/g;
600
+ /**
601
+ * Extract the clean description (without sync marker).
602
+ */
603
+ getCleanDescription(description) {
604
+ if (!description)
605
+ return "";
606
+ return description.replace(EmaClient.SYNC_CLEANUP_REGEX, "").trim();
607
+ }
608
+ /**
609
+ * Build description with compact sync tag appended.
610
+ */
611
+ buildDescriptionWithSyncTag(cleanDescription, metadata) {
612
+ const marker = `<!-- synced_from:${metadata.master_env}/${metadata.master_id} -->`;
613
+ return cleanDescription ? `${cleanDescription}\n\n${marker}` : marker;
614
+ }
615
+ /**
616
+ * Tag a persona as synced by appending metadata to description.
617
+ */
618
+ async tagAsSynced(personaId, metadata, currentDescription, existingProtoConfig) {
619
+ const cleanDesc = this.getCleanDescription(currentDescription);
620
+ const newDescription = this.buildDescriptionWithSyncTag(cleanDesc, metadata);
621
+ await this.updateAiEmployee({
622
+ persona_id: personaId,
623
+ proto_config: existingProtoConfig ?? {},
624
+ description: newDescription,
625
+ });
626
+ }
627
+ /**
628
+ * Remove sync metadata from a persona (unlink from master).
629
+ */
630
+ async removeSyncTag(personaId, currentDescription, existingProtoConfig) {
631
+ const cleanDesc = this.getCleanDescription(currentDescription);
632
+ await this.updateAiEmployee({
633
+ persona_id: personaId,
634
+ proto_config: existingProtoConfig ?? {},
635
+ description: cleanDesc,
636
+ });
637
+ }
638
+ /**
639
+ * List all personas that have sync metadata (were synced from master).
640
+ */
641
+ async listSyncedPersonas() {
642
+ const personas = await this.getPersonasForTenant();
643
+ const synced = [];
644
+ for (const p of personas) {
645
+ const meta = this.getSyncMetadata(p);
646
+ if (meta) {
647
+ synced.push({ persona: p, syncMetadata: meta });
648
+ }
649
+ }
650
+ return synced;
651
+ }
652
+ /**
653
+ * Find a synced persona by its master environment and master ID.
654
+ */
655
+ async findSyncedPersona(masterEnv, masterId) {
656
+ const synced = await this.listSyncedPersonas();
657
+ return (synced.find((s) => s.syncMetadata.master_env === masterEnv && s.syncMetadata.master_id === masterId) ?? null);
658
+ }
659
+ // ─────────────────────────────────────────────────────────────────────────────
660
+ // Autobuilder Chat API
661
+ // ─────────────────────────────────────────────────────────────────────────────
662
+ // Cache for discovered Autobuilder persona
663
+ autobuilderPersonaId = null;
664
+ // Cache for active conversation per target persona
665
+ autobuilderConversations = new Map();
666
+ /**
667
+ * Auto-discover the Autobuilder persona in the tenant.
668
+ * Looks for personas with template_id matching the Autobuilder template.
669
+ */
670
+ async findAutobuilderPersona() {
671
+ if (this.autobuilderPersonaId) {
672
+ return this.autobuilderPersonaId;
673
+ }
674
+ // Known Autobuilder template IDs
675
+ const AUTOBUILDER_TEMPLATE_IDS = [
676
+ "00000000-0000-0000-0000-000000000014", // Ema Auto Builder
677
+ "00000000-0000-0000-0000-00000000000a", // Autobuilder for X
678
+ ];
679
+ const allPersonas = await this.getPersonasForTenant();
680
+ // First, look for the main "Ema Auto Builder" by name and template
681
+ const mainAutobuilder = allPersonas.find(p => p.name === "Ema Auto Builder" &&
682
+ AUTOBUILDER_TEMPLATE_IDS.includes(p.template_id ?? ""));
683
+ if (mainAutobuilder) {
684
+ this.autobuilderPersonaId = mainAutobuilder.id;
685
+ return mainAutobuilder.id;
686
+ }
687
+ // Fallback: any persona with Autobuilder template
688
+ const anyAutobuilder = allPersonas.find(p => AUTOBUILDER_TEMPLATE_IDS.includes(p.template_id ?? ""));
689
+ if (anyAutobuilder) {
690
+ this.autobuilderPersonaId = anyAutobuilder.id;
691
+ return anyAutobuilder.id;
692
+ }
693
+ throw new EmaApiError({
694
+ statusCode: 404,
695
+ message: `No Autobuilder persona found in tenant (${this.env.name}). Please contact your Ema administrator.`,
696
+ });
697
+ }
698
+ /**
699
+ * Create a new conversation with the Autobuilder.
700
+ * API endpoint: POST /api/conversations/create?persona_id=<autobuilder_id>
701
+ */
702
+ async createAutobuilderConversation(autobuilderPersonaId) {
703
+ const resp = await this.requestWithRetries("POST", `/api/conversations/create?persona_id=${autobuilderPersonaId}`, {});
704
+ if (!resp.ok) {
705
+ const body = await resp.text();
706
+ throw new EmaApiError({
707
+ statusCode: resp.status,
708
+ body,
709
+ message: `create_autobuilder_conversation failed (${this.env.name})`,
710
+ });
711
+ }
712
+ const result = await resp.json();
713
+ return { conversation_id: String(result.conversation_id ?? result.id ?? "") };
714
+ }
715
+ /**
716
+ * Send a message to the Autobuilder chat.
717
+ * API endpoint: POST /api/web/
718
+ *
719
+ * @param conversationId - The conversation ID from createAutobuilderConversation
720
+ * @param prompt - The message to send (e.g., "test", "add error handling", etc.)
721
+ * @param workflowDef - Optional workflow definition to iterate on
722
+ */
723
+ async chatWithAutobuilder(conversationId, prompt, workflowDef) {
724
+ const body = {
725
+ prompt,
726
+ conversation_id: conversationId,
727
+ };
728
+ if (workflowDef) {
729
+ body.workflow_def = workflowDef;
730
+ }
731
+ const resp = await this.requestWithRetries("POST", "/api/web/", { json: body });
732
+ if (!resp.ok) {
733
+ const errorBody = await resp.text();
734
+ throw new EmaApiError({
735
+ statusCode: resp.status,
736
+ body: errorBody,
737
+ message: `chat_with_autobuilder failed (${this.env.name})`,
738
+ });
739
+ }
740
+ const result = await resp.json();
741
+ return {
742
+ response: String(result.response ?? result.message ?? ""),
743
+ workflow_def: result.workflow_def,
744
+ conversation_id: conversationId,
745
+ raw: result,
746
+ };
747
+ }
748
+ /**
749
+ * High-level: Iterate on a persona's workflow using the Autobuilder.
750
+ * Abstracts away Autobuilder discovery and conversation management.
751
+ *
752
+ * @param personaId - The target AI Employee whose workflow to iterate on
753
+ * @param instruction - Natural language instruction (e.g., "add error handling", "optimize search")
754
+ * @param opts.newConversation - Force create a new conversation (default: reuse existing)
755
+ */
756
+ async iterateWorkflow(personaId, instruction, opts) {
757
+ // 1. Auto-discover Autobuilder
758
+ const autobuilderId = await this.findAutobuilderPersona();
759
+ // 2. Get or create conversation for this persona
760
+ let conversationId = this.autobuilderConversations.get(personaId);
761
+ if (!conversationId || opts?.newConversation) {
762
+ const conv = await this.createAutobuilderConversation(autobuilderId);
763
+ conversationId = conv.conversation_id;
764
+ this.autobuilderConversations.set(personaId, conversationId);
765
+ }
766
+ // 3. Fetch target persona's current workflow
767
+ const persona = await this.getPersonaById(personaId);
768
+ if (!persona) {
769
+ throw new EmaApiError({
770
+ statusCode: 404,
771
+ message: `AI Employee not found: ${personaId}`,
772
+ });
773
+ }
774
+ const workflowDef = persona.workflow_def;
775
+ // 4. Chat with Autobuilder
776
+ const response = await this.chatWithAutobuilder(conversationId, instruction, workflowDef);
777
+ return {
778
+ persona_id: personaId,
779
+ persona_name: persona.name ?? "Unknown",
780
+ autobuilder_id: autobuilderId,
781
+ conversation_id: conversationId,
782
+ instruction,
783
+ response: response.response,
784
+ updated_workflow: response.workflow_def,
785
+ has_changes: !!response.workflow_def,
786
+ };
787
+ }
788
+ }