@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
package/dist/config.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Environment Configuration
3
+ *
4
+ * Defines Ema API environments and service settings.
5
+ * Used by both MCP server and CLI/service modes.
6
+ */
7
+ import fs from "node:fs";
8
+ import yaml from "js-yaml";
9
+ import { z } from "zod";
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Schema
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ const EnvironmentSchema = z.object({
14
+ name: z.string().min(1),
15
+ baseUrl: z.string().url(),
16
+ bearerTokenEnv: z.string().min(1),
17
+ isMaster: z.boolean().optional().default(false),
18
+ // Deprecated but accepted for backward compatibility
19
+ userId: z.string().optional(),
20
+ templateIdMap: z.record(z.string()).optional(),
21
+ });
22
+ const SchedulerSchema = z.object({
23
+ intervalSeconds: z.number().int().positive().optional(),
24
+ cron: z.string().min(1).optional(),
25
+ }).strict().refine((v) => v.cron || v.intervalSeconds, { message: "scheduler must set either intervalSeconds or cron" });
26
+ const ServiceSchema = z.object({
27
+ stateDbPath: z.string().min(1).optional().default("./ema-state.sqlite3"),
28
+ scheduler: SchedulerSchema.optional(),
29
+ }).strict();
30
+ // Legacy schema for backward compatibility
31
+ const LegacyRouteRuleSchema = z.object({
32
+ personaIds: z.array(z.string()).default([]),
33
+ targetEnvs: z.array(z.string()).default([]),
34
+ nameGlobs: z.array(z.string()).optional().default([]),
35
+ nameRegexes: z.array(z.string()).optional().default([]),
36
+ namePrefixes: z.array(z.string()).optional().default([]),
37
+ }).passthrough();
38
+ const AppConfigSchema = z.object({
39
+ environments: z.array(EnvironmentSchema).min(1),
40
+ service: ServiceSchema.optional(),
41
+ dryRun: z.boolean().optional().default(false),
42
+ verbose: z.boolean().optional().default(false),
43
+ // Legacy fields (kept for backward compatibility)
44
+ routing: z.array(LegacyRouteRuleSchema).optional(),
45
+ scheduler: SchedulerSchema.optional(),
46
+ stateDbPath: z.string().optional(),
47
+ eventSharedSecretEnv: z.string().optional(),
48
+ }).refine((cfg) => {
49
+ // Validate at most one master
50
+ const masters = cfg.environments.filter((e) => e.isMaster);
51
+ return masters.length <= 1;
52
+ }, { message: "At most one environment can be isMaster: true" });
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+ // Loading functions
55
+ // ─────────────────────────────────────────────────────────────────────────────
56
+ export function loadConfig(path) {
57
+ const raw = fs.readFileSync(path, "utf8");
58
+ const parsed = yaml.load(raw);
59
+ if (!parsed || typeof parsed !== "object") {
60
+ throw new Error("Invalid config YAML");
61
+ }
62
+ const res = AppConfigSchema.safeParse(parsed);
63
+ if (!res.success) {
64
+ const errors = res.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
65
+ throw new Error(`Invalid config: ${errors.join("; ")}`);
66
+ }
67
+ return res.data;
68
+ }
69
+ export function loadConfigOptional(path) {
70
+ try {
71
+ return loadConfig(path);
72
+ }
73
+ catch (e) {
74
+ if (e && typeof e === "object" && "code" in e && e.code === "ENOENT") {
75
+ return null;
76
+ }
77
+ const msg = e instanceof Error ? e.message : String(e);
78
+ if (msg.includes("ENOENT"))
79
+ return null;
80
+ throw e;
81
+ }
82
+ }
83
+ export function loadConfigFromJsonEnv() {
84
+ const raw = process.env.EMA_CONFIG_JSON ?? process.env.EMA_AGENT_SYNC_CONFIG_JSON;
85
+ if (!raw)
86
+ return null;
87
+ let parsed;
88
+ try {
89
+ parsed = JSON.parse(raw);
90
+ }
91
+ catch {
92
+ throw new Error("EMA_CONFIG_JSON is not valid JSON");
93
+ }
94
+ const res = validateConfig(parsed);
95
+ if (!res.ok) {
96
+ throw new Error(`Invalid EMA_CONFIG_JSON: ${res.errors.join("; ")}`);
97
+ }
98
+ return res.config;
99
+ }
100
+ export function validateConfig(input) {
101
+ const res = AppConfigSchema.safeParse(input);
102
+ if (!res.success) {
103
+ return { ok: false, errors: res.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) };
104
+ }
105
+ return { ok: true, config: res.data };
106
+ }
107
+ export function configToYaml(cfg) {
108
+ return yaml.dump(cfg, { noRefs: true, sortKeys: true });
109
+ }
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // Utilities
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+ export function resolveBearerToken(envVarName) {
114
+ const v = process.env[envVarName];
115
+ if (!v)
116
+ throw new Error(`Missing bearer token env var: ${envVarName}`);
117
+ // Strip "Bearer " prefix if user included it
118
+ const trimmed = v.trim();
119
+ if (trimmed.toLowerCase().startsWith("bearer ")) {
120
+ return trimmed.slice(7).trim();
121
+ }
122
+ return trimmed;
123
+ }
124
+ export function assertEnvVarsPresent(cfg) {
125
+ for (const e of cfg.environments) {
126
+ if (!process.env[e.bearerTokenEnv]) {
127
+ throw new Error(`Missing required env var for ${e.name}: ${e.bearerTokenEnv}`);
128
+ }
129
+ }
130
+ }
131
+ export function getMasterEnv(cfg) {
132
+ return cfg.environments.find((e) => e.isMaster);
133
+ }
134
+ export function getEnvByName(cfg, name) {
135
+ return cfg.environments.find((e) => e.name === name);
136
+ }
@@ -0,0 +1,398 @@
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
+ constructor(env, opts) {
18
+ this.env = env;
19
+ this.timeoutMs = opts?.timeoutMs ?? 30_000;
20
+ }
21
+ async requestWithRetries(method, path, opts) {
22
+ const retries = opts?.retries ?? 4;
23
+ const baseDelayMs = opts?.baseDelayMs ?? 500;
24
+ let lastErr;
25
+ for (let attempt = 0; attempt <= retries; attempt++) {
26
+ try {
27
+ const controller = new AbortController();
28
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
29
+ const fullUrl = `${this.env.baseUrl.replace(/\/$/, "")}${path}`;
30
+ const response = await fetch(fullUrl, {
31
+ method,
32
+ headers: {
33
+ Authorization: `Bearer ${this.env.bearerToken}`,
34
+ "Content-Type": "application/json",
35
+ },
36
+ body: opts?.json !== undefined ? JSON.stringify(opts.json) : undefined,
37
+ signal: controller.signal,
38
+ });
39
+ clearTimeout(timeoutId);
40
+ // Retry on transient errors
41
+ if ([429, 500, 502, 503, 504].includes(response.status) && attempt < retries) {
42
+ const delay = baseDelayMs * 2 ** attempt;
43
+ await sleep(delay);
44
+ continue;
45
+ }
46
+ return response;
47
+ }
48
+ catch (e) {
49
+ lastErr = e instanceof Error ? e : new Error(String(e));
50
+ if (attempt >= retries)
51
+ throw lastErr;
52
+ const delay = baseDelayMs * 2 ** attempt;
53
+ await sleep(delay);
54
+ }
55
+ }
56
+ throw lastErr ?? new Error("Unreachable");
57
+ }
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+ // AI Employees (Personas)
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ async getPersonasForTenant() {
62
+ // Try POST first (original API), fall back to GET if 405
63
+ let resp = await this.requestWithRetries("POST", "/api/personas/get_personas_for_tenant", {
64
+ json: {},
65
+ });
66
+ // If POST fails with 405, try GET method
67
+ if (resp.status === 405) {
68
+ resp = await this.requestWithRetries("GET", "/api/personas/get_personas_for_tenant", {});
69
+ }
70
+ if (!resp.ok) {
71
+ throw new EmaApiError({
72
+ statusCode: resp.status,
73
+ body: await resp.text(),
74
+ message: `get_personas_for_tenant failed (${this.env.name})`,
75
+ });
76
+ }
77
+ const data = (await resp.json());
78
+ const personas = data.personas ?? data.configs ?? [];
79
+ return personas;
80
+ }
81
+ /** Alias: in the UI, personas are called "AI Employees" */
82
+ async getAiEmployeesForTenant() {
83
+ return this.getPersonasForTenant();
84
+ }
85
+ /**
86
+ * Get a single persona by ID with full details including workflow_def.
87
+ * Uses /api/personas/{id} endpoint which returns the complete persona including workflow_def.
88
+ */
89
+ async getPersonaById(personaId) {
90
+ // #region agent log
91
+ fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:getPersonaById:start', message: 'Fetching full persona details', data: { env: this.env.name, personaId }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'workflow-fix', hypothesisId: 'D' }) }).catch(() => { });
92
+ // #endregion
93
+ // Primary: /api/personas/{id} - returns full persona with workflow_def
94
+ try {
95
+ const resp = await this.requestWithRetries("GET", `/api/personas/${personaId}`, {});
96
+ if (resp.ok) {
97
+ const persona = (await resp.json());
98
+ // #region agent log
99
+ const wfActions = persona?.workflow_def?.actions;
100
+ fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:getPersonaById:success', message: 'Got full persona from /api/personas/{id}', data: { env: this.env.name, personaId, hasWorkflowDef: !!persona?.workflow_def, workflowActionsCount: Array.isArray(wfActions) ? wfActions.length : 0 }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'workflow-fix', hypothesisId: 'D' }) }).catch(() => { });
101
+ // #endregion
102
+ return persona;
103
+ }
104
+ }
105
+ catch {
106
+ // Fall through to fallback
107
+ }
108
+ // Fallback: /api/ai_employee/get_ai_employee endpoint
109
+ try {
110
+ const resp = await this.requestWithRetries("GET", `/api/ai_employee/get_ai_employee?persona_id=${personaId}`, {});
111
+ if (resp.ok) {
112
+ const data = (await resp.json());
113
+ const persona = data.persona ?? data.ai_employee ?? data.config ?? null;
114
+ // #region agent log
115
+ fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:getPersonaById:fallback', message: 'Got persona from fallback endpoint', data: { env: this.env.name, personaId, hasWorkflowDef: !!persona?.workflow_def }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'workflow-fix', hypothesisId: 'D' }) }).catch(() => { });
116
+ // #endregion
117
+ return persona;
118
+ }
119
+ }
120
+ catch {
121
+ // Fall through to return null
122
+ }
123
+ return null;
124
+ }
125
+ /**
126
+ * Get workflow definition by workflow_id.
127
+ * Tries multiple API endpoints to fetch the workflow.
128
+ */
129
+ async getWorkflowDef(workflowId) {
130
+ // Try gRPC-web style GetWorkflow endpoint
131
+ try {
132
+ const resp = await this.requestWithRetries("POST", "/workflows.v1.WorkflowManager/GetWorkflow", {
133
+ json: { workflow_id: workflowId },
134
+ });
135
+ if (resp.ok) {
136
+ const data = (await resp.json());
137
+ return data.workflow ?? (Object.keys(data).length > 0 ? data : null);
138
+ }
139
+ }
140
+ catch {
141
+ // Fall through
142
+ }
143
+ // Try GetWorkflowDefinition endpoint (alternate name)
144
+ try {
145
+ const resp = await this.requestWithRetries("POST", "/workflows.v1.WorkflowManager/GetWorkflowDefinition", {
146
+ json: { workflow_id: workflowId },
147
+ });
148
+ if (resp.ok) {
149
+ const data = (await resp.json());
150
+ return data;
151
+ }
152
+ }
153
+ catch {
154
+ // Fall through
155
+ }
156
+ // Try listing actions and constructing workflow
157
+ try {
158
+ const actions = await this.listActionsFromWorkflow(workflowId);
159
+ if (actions && actions.length > 0) {
160
+ // Return actions as part of workflow def (partial reconstruction)
161
+ return { workflow_id: workflowId, actions: actions };
162
+ }
163
+ }
164
+ catch {
165
+ // Fall through
166
+ }
167
+ return null;
168
+ }
169
+ async updateAiEmployee(req, opts) {
170
+ // #region agent log
171
+ if (opts?.verbose) {
172
+ fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:updateAiEmployee:fullRequest', message: 'FULL UPDATE REQUEST', data: { env: this.env.name, fullRequest: req }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'verbose-test', hypothesisId: 'full-request' }) }).catch(() => { });
173
+ }
174
+ // #endregion
175
+ const resp = await this.requestWithRetries("POST", "/api/ai_employee/update_ai_employee", {
176
+ json: req,
177
+ });
178
+ if (!resp.ok) {
179
+ const body = await resp.text();
180
+ // #region agent log
181
+ fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:updateAiEmployee:error', message: 'UPDATE FAILED', data: { env: this.env.name, status: resp.status, body }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'verbose-test', hypothesisId: 'api-error' }) }).catch(() => { });
182
+ // #endregion
183
+ throw new EmaApiError({
184
+ statusCode: resp.status,
185
+ body,
186
+ message: `update_ai_employee failed (${this.env.name})`,
187
+ });
188
+ }
189
+ const result = (await resp.json());
190
+ // #region agent log
191
+ if (opts?.verbose) {
192
+ fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:updateAiEmployee:response', message: 'UPDATE RESPONSE', data: { env: this.env.name, responseKeys: Object.keys(result), response: result }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'verbose-test', hypothesisId: 'full-response' }) }).catch(() => { });
193
+ }
194
+ // #endregion
195
+ return result;
196
+ }
197
+ async createAiEmployee(req, opts) {
198
+ // #region agent log
199
+ if (opts?.verbose) {
200
+ fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:createAiEmployee:fullRequest', message: 'FULL CREATE REQUEST', data: { env: this.env.name, fullRequest: req }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'verbose-test', hypothesisId: 'full-request' }) }).catch(() => { });
201
+ }
202
+ // #endregion
203
+ const resp = await this.requestWithRetries("POST", "/api/ai_employee/create_ai_employee", {
204
+ json: req,
205
+ });
206
+ if (!resp.ok) {
207
+ const body = await resp.text();
208
+ // #region agent log
209
+ fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:createAiEmployee:error', message: 'CREATE FAILED', data: { env: this.env.name, status: resp.status, body }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'verbose-test', hypothesisId: 'api-error' }) }).catch(() => { });
210
+ // #endregion
211
+ throw new EmaApiError({
212
+ statusCode: resp.status,
213
+ body,
214
+ message: `create_ai_employee failed (${this.env.name})`,
215
+ });
216
+ }
217
+ const result = (await resp.json());
218
+ // #region agent log
219
+ if (opts?.verbose) {
220
+ fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:createAiEmployee:response', message: 'CREATE RESPONSE', data: { env: this.env.name, responseKeys: Object.keys(result), response: result }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'verbose-test', hypothesisId: 'full-response' }) }).catch(() => { });
221
+ }
222
+ // #endregion
223
+ return result;
224
+ }
225
+ // ─────────────────────────────────────────────────────────────────────────────
226
+ // Actions (displayed as "Agents" in the UI)
227
+ // ─────────────────────────────────────────────────────────────────────────────
228
+ /**
229
+ * List all actions available to the tenant.
230
+ * API endpoint: workflows.v1.ActionManager/ListActions
231
+ * UI label: "Agents"
232
+ */
233
+ async listActions() {
234
+ const resp = await this.requestWithRetries("POST", "/workflows.v1.ActionManager/ListActions", {
235
+ json: {},
236
+ });
237
+ if (!resp.ok) {
238
+ throw new EmaApiError({
239
+ statusCode: resp.status,
240
+ body: await resp.text(),
241
+ message: `listActions failed (${this.env.name})`,
242
+ });
243
+ }
244
+ const data = (await resp.json());
245
+ return data.actions ?? [];
246
+ }
247
+ /** Alias: in the UI, actions are called "Agents" */
248
+ async listAgents() {
249
+ return this.listActions();
250
+ }
251
+ /**
252
+ * List actions associated with a specific workflow.
253
+ * API endpoint: workflows.v1.ActionManager/ListActionsFromWorkflow
254
+ * UI label: "Agents"
255
+ *
256
+ * @param workflowId - The workflow ID to list actions for
257
+ */
258
+ async listActionsFromWorkflow(workflowId) {
259
+ const resp = await this.requestWithRetries("POST", "/workflows.v1.ActionManager/ListActionsFromWorkflow", { json: { workflow_id: workflowId } });
260
+ if (!resp.ok) {
261
+ throw new EmaApiError({
262
+ statusCode: resp.status,
263
+ body: await resp.text(),
264
+ message: `listActionsFromWorkflow failed (${this.env.name})`,
265
+ });
266
+ }
267
+ const data = (await resp.json());
268
+ return data.actions ?? [];
269
+ }
270
+ /** Alias: in the UI, actions are called "Agents" */
271
+ async listAgentsFromWorkflow(workflowId) {
272
+ return this.listActionsFromWorkflow(workflowId);
273
+ }
274
+ // ─────────────────────────────────────────────────────────────────────────────
275
+ // Sync Metadata (tagging synced personas)
276
+ // ─────────────────────────────────────────────────────────────────────────────
277
+ /**
278
+ * Sync tag regex patterns.
279
+ * Current format: <!-- synced_from:env/id -->
280
+ * Legacy formats also supported for backward compatibility.
281
+ */
282
+ static SYNC_TAG_REGEX = /<!-- synced_from:([^/]+)\/([a-f0-9-]+) -->/;
283
+ static LEGACY_JSON_REGEX = /<!-- _ema_sync:(.+?) -->/;
284
+ /**
285
+ * Get sync metadata from a persona's description.
286
+ * Returns null if the persona has never been synced.
287
+ */
288
+ getSyncMetadata(persona) {
289
+ // Check top-level description for sync tag
290
+ const desc = persona.description;
291
+ if (desc) {
292
+ const meta = this.extractSyncMetadataFromString(desc);
293
+ if (meta)
294
+ return meta;
295
+ }
296
+ // Fallback: check proto_config._ema_sync (legacy)
297
+ const protoConfig = persona.proto_config;
298
+ if (protoConfig?._ema_sync) {
299
+ return protoConfig._ema_sync;
300
+ }
301
+ return null;
302
+ }
303
+ /**
304
+ * Extract sync metadata from a string (description field).
305
+ * Supports both new compact format and legacy JSON format.
306
+ */
307
+ extractSyncMetadataFromString(text) {
308
+ // Try new compact format: <!-- synced_from:env/id -->
309
+ const compactMatch = text.match(EmaClient.SYNC_TAG_REGEX);
310
+ if (compactMatch) {
311
+ return {
312
+ master_env: compactMatch[1],
313
+ master_id: compactMatch[2],
314
+ synced_at: new Date().toISOString(),
315
+ };
316
+ }
317
+ // Try legacy JSON format: <!-- _ema_sync:{...} -->
318
+ const legacyMatch = text.match(EmaClient.LEGACY_JSON_REGEX);
319
+ if (legacyMatch) {
320
+ try {
321
+ return JSON.parse(legacyMatch[1]);
322
+ }
323
+ catch {
324
+ return null;
325
+ }
326
+ }
327
+ return null;
328
+ }
329
+ /**
330
+ * Check if a persona was synced from a master environment.
331
+ */
332
+ isSyncedPersona(persona) {
333
+ return this.getSyncMetadata(persona) !== null;
334
+ }
335
+ /**
336
+ * Regex to clean all sync tag formats from description.
337
+ */
338
+ static SYNC_CLEANUP_REGEX = /<!-- (?:synced_from|sync|_ema_sync):[^\n]*-->/g;
339
+ /**
340
+ * Extract the clean description (without sync marker).
341
+ */
342
+ getCleanDescription(description) {
343
+ if (!description)
344
+ return "";
345
+ return description.replace(EmaClient.SYNC_CLEANUP_REGEX, "").trim();
346
+ }
347
+ /**
348
+ * Build description with compact sync tag appended.
349
+ */
350
+ buildDescriptionWithSyncTag(cleanDescription, metadata) {
351
+ const marker = `<!-- synced_from:${metadata.master_env}/${metadata.master_id} -->`;
352
+ return cleanDescription ? `${cleanDescription}\n\n${marker}` : marker;
353
+ }
354
+ /**
355
+ * Tag a persona as synced by appending metadata to description.
356
+ */
357
+ async tagAsSynced(personaId, metadata, currentDescription, existingProtoConfig) {
358
+ const cleanDesc = this.getCleanDescription(currentDescription);
359
+ const newDescription = this.buildDescriptionWithSyncTag(cleanDesc, metadata);
360
+ await this.updateAiEmployee({
361
+ persona_id: personaId,
362
+ proto_config: existingProtoConfig ?? {},
363
+ description: newDescription,
364
+ });
365
+ }
366
+ /**
367
+ * Remove sync metadata from a persona (unlink from master).
368
+ */
369
+ async removeSyncTag(personaId, currentDescription, existingProtoConfig) {
370
+ const cleanDesc = this.getCleanDescription(currentDescription);
371
+ await this.updateAiEmployee({
372
+ persona_id: personaId,
373
+ proto_config: existingProtoConfig ?? {},
374
+ description: cleanDesc,
375
+ });
376
+ }
377
+ /**
378
+ * List all personas that have sync metadata (were synced from master).
379
+ */
380
+ async listSyncedPersonas() {
381
+ const personas = await this.getPersonasForTenant();
382
+ const synced = [];
383
+ for (const p of personas) {
384
+ const meta = this.getSyncMetadata(p);
385
+ if (meta) {
386
+ synced.push({ persona: p, syncMetadata: meta });
387
+ }
388
+ }
389
+ return synced;
390
+ }
391
+ /**
392
+ * Find a synced persona by its master environment and master ID.
393
+ */
394
+ async findSyncedPersona(masterEnv, masterId) {
395
+ const synced = await this.listSyncedPersonas();
396
+ return (synced.find((s) => s.syncMetadata.master_env === masterEnv && s.syncMetadata.master_id === masterId) ?? null);
397
+ }
398
+ }
package/dist/index.js ADDED
@@ -0,0 +1,109 @@
1
+ import process from "node:process";
2
+ import Fastify from "fastify";
3
+ import { assertEnvVarsPresent, configToYaml, getMasterEnv, loadConfigFromJsonEnv, loadConfigOptional, validateConfig, } from "./sdk/config.js";
4
+ import { StateStore } from "./sdk/state.js";
5
+ import { syncOnce } from "./sync.js";
6
+ import crypto from "node:crypto";
7
+ import { startScheduler } from "./scheduler.js";
8
+ import { uiHtml, uiJs } from "./ui.js";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ async function main() {
12
+ const configPath = process.env.EMA_AGENT_SYNC_CONFIG ?? "./config.yaml";
13
+ const cfg = loadConfigFromJsonEnv() ?? loadConfigOptional(configPath);
14
+ const hasConfig = !!cfg;
15
+ const app = Fastify({ logger: true });
16
+ const state = new StateStore((cfg?.stateDbPath ?? "./ema-agent-sync.sqlite3"));
17
+ const sched = cfg ? startScheduler(cfg, state) : null;
18
+ app.get("/health", async () => ({ status: "ok", mode: hasConfig ? "sync" : "config-builder" }));
19
+ // --- Config wizard UI ---
20
+ app.get("/ui", async (_req, reply) => {
21
+ reply.header("Content-Type", "text/html; charset=utf-8");
22
+ return uiHtml();
23
+ });
24
+ app.get("/ui/app.js", async (_req, reply) => {
25
+ reply.header("Content-Type", "text/javascript; charset=utf-8");
26
+ return uiJs();
27
+ });
28
+ // --- Config helper APIs (used by UI) ---
29
+ app.post("/api/config/validate", async (req) => {
30
+ const result = validateConfig(req.body);
31
+ if (result.ok)
32
+ return { ok: true };
33
+ return { ok: false, errors: result.errors };
34
+ });
35
+ app.post("/api/config/yaml", async (req) => {
36
+ const result = validateConfig(req.body);
37
+ if (!result.ok)
38
+ return { ok: false, errors: result.errors };
39
+ return { ok: true, yaml: configToYaml(result.config) };
40
+ });
41
+ app.post("/api/config/save", async (req, reply) => {
42
+ const result = validateConfig(req.body);
43
+ if (!result.ok)
44
+ return { ok: false, errors: result.errors };
45
+ const out = String((req.headers["x-config-path"] ?? "./config.yaml"));
46
+ // Guard: only allow relative paths in cwd, no traversal
47
+ if (path.isAbsolute(out) || out.includes("..")) {
48
+ reply.code(400);
49
+ return { ok: false, errors: ["Invalid x-config-path (must be relative, no '..')"] };
50
+ }
51
+ const ext = path.extname(out);
52
+ if (ext !== ".yaml" && ext !== ".yml") {
53
+ reply.code(400);
54
+ return { ok: false, errors: ["Config path must end with .yaml or .yml"] };
55
+ }
56
+ fs.writeFileSync(out, configToYaml(result.config), "utf8");
57
+ return { ok: true, savedTo: out };
58
+ });
59
+ app.post("/events/persona-changed", async (req, reply) => {
60
+ if (!cfg) {
61
+ reply.code(400);
62
+ return { error: "ConfigMissing", message: "No config loaded. Create one via /ui or set EMA_AGENT_SYNC_CONFIG_JSON." };
63
+ }
64
+ // Useful for running just the UI wizard without having secrets set in the environment yet.
65
+ if (process.env.EMA_AGENT_SYNC_SKIP_ENV_CHECK !== "1") {
66
+ assertEnvVarsPresent(cfg);
67
+ }
68
+ // Optional shared secret auth
69
+ if (cfg.eventSharedSecretEnv) {
70
+ const expected = process.env[cfg.eventSharedSecretEnv];
71
+ const got = String((req.headers["x-ema-sync-secret"] ?? ""));
72
+ if (!expected) {
73
+ reply.code(500);
74
+ return { error: "ServerMisconfigured", message: "Missing event shared secret env var" };
75
+ }
76
+ const a = Buffer.from(expected);
77
+ const b = Buffer.from(got);
78
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
79
+ reply.code(401);
80
+ return { error: "Unauthorized" };
81
+ }
82
+ }
83
+ // Payload shape: { env, persona_id } (we still run full sync for now; targeted comes next)
84
+ const result = await syncOnce(cfg, state);
85
+ reply.code(202);
86
+ return { accepted: true, result };
87
+ });
88
+ const port = Number(process.env.PORT ?? 8080);
89
+ const host = process.env.HOST ?? "0.0.0.0";
90
+ await app.listen({ port, host });
91
+ if (cfg) {
92
+ const master = getMasterEnv(cfg);
93
+ // Ensure bearer tokens exist unless explicitly disabled
94
+ if (process.env.EMA_AGENT_SYNC_SKIP_ENV_CHECK !== "1") {
95
+ assertEnvVarsPresent(cfg);
96
+ }
97
+ app.log.info({ masterEnv: master?.name ?? "none" }, "started");
98
+ if (sched)
99
+ app.log.info("scheduler_started");
100
+ }
101
+ else {
102
+ app.log.info({ mode: "config-builder" }, "started_without_config");
103
+ }
104
+ }
105
+ main().catch((err) => {
106
+ // eslint-disable-next-line no-console
107
+ console.error(err);
108
+ process.exit(1);
109
+ });