@ema.co/mcp-toolkit 1.4.0 → 1.4.2

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.
package/dist/cli/index.js CHANGED
File without changes
@@ -122,17 +122,32 @@ export async function handlePersona(args, client, getTemplateId, createClientFor
122
122
  if (!persona) {
123
123
  return { error: `Persona not found: ${idOrName}` };
124
124
  }
125
+ // Merge new proto_config with existing, or use existing if not provided
126
+ const existingProtoConfig = persona.proto_config ?? {};
127
+ const newProtoConfig = args.proto_config;
128
+ const mergedProtoConfig = newProtoConfig
129
+ ? { ...existingProtoConfig, ...newProtoConfig } // Merge: new values override existing
130
+ : existingProtoConfig;
131
+ // IMPORTANT: The Ema API requires workflow to be sent along with proto_config
132
+ // for proto_config changes to persist. This matches what the UI does.
133
+ // Get full persona details to include workflow
134
+ const fullPersona = await client.getPersonaById(persona.id);
135
+ const existingWorkflow = fullPersona
136
+ ? (fullPersona.workflow_def ?? fullPersona.workflow)
137
+ : undefined;
125
138
  await client.updateAiEmployee({
126
139
  persona_id: persona.id,
127
140
  name: args.name,
128
141
  description: args.description,
129
- proto_config: persona.proto_config ?? {},
142
+ proto_config: mergedProtoConfig,
143
+ workflow: existingWorkflow, // Include workflow for proto_config to persist
130
144
  enabled_by_user: typeof args.enabled === "boolean" ? args.enabled : undefined,
131
145
  });
132
146
  return {
133
147
  success: true,
134
148
  persona_id: persona.id,
135
149
  updated_fields: Object.keys(args).filter(k => !["id", "identifier", "mode", "env"].includes(k)),
150
+ proto_config_updated: !!newProtoConfig,
136
151
  };
137
152
  }
138
153
  case "compare": {
File without changes
@@ -119,6 +119,10 @@ export function generateConsolidatedTools(envNames, defaultEnv) {
119
119
  clone_from: { type: "string", description: "Clone from persona ID (for create)" },
120
120
  // Update flags
121
121
  enabled: { type: "boolean", description: "Enable/disable (for update)" },
122
+ proto_config: {
123
+ type: "object",
124
+ description: "Voice/chat settings (welcomeMessage, identityAndPurpose, etc.) for update"
125
+ },
122
126
  // Compare flags
123
127
  compare_to: { type: "string", description: "Second persona ID (for compare)" },
124
128
  compare_env: { type: "string", description: "Environment of compare_to persona" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ema.co/mcp-toolkit",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
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",
@@ -41,8 +41,10 @@
41
41
  "restart": "npm run stop && npm run dev",
42
42
  "restart:mcp": "npm run stop && npm run mcp",
43
43
  "cli": "tsx src/cli/index.ts",
44
- "test": "vitest run",
45
- "test:watch": "vitest",
44
+ "test": "vitest run --exclude 'test/integration/**'",
45
+ "test:watch": "vitest --exclude 'test/integration/**'",
46
+ "test:integration": "EMA_TEST_INTEGRATION=true vitest run test/integration/",
47
+ "test:all": "npm run test && npm run test:integration",
46
48
  "typecheck": "tsc --noEmit",
47
49
  "precommit": "npm run typecheck && npm run test",
48
50
  "prepare": "husky",
package/dist/config.js DELETED
@@ -1,136 +0,0 @@
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
- }
package/dist/emaClient.js DELETED
@@ -1,398 +0,0 @@
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/models.js DELETED
@@ -1,8 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────────────────────
2
- // Sync Metadata (stored in description as HTML comment)
3
- // ─────────────────────────────────────────────────────────────────────────────
4
- /**
5
- * The key used to store sync metadata in status_log.
6
- * Prefixed with underscore to indicate internal/system use.
7
- */
8
- export const SYNC_METADATA_KEY = "_ema_sync";
package/dist/state.js DELETED
@@ -1,88 +0,0 @@
1
- import Database from "better-sqlite3";
2
- export class StateStore {
3
- db;
4
- mapTable = "persona_map_v2";
5
- fpTable = "persona_fingerprint_v2";
6
- runsTable = "sync_runs_v2";
7
- constructor(path) {
8
- this.db = new Database(path);
9
- this.db.pragma("journal_mode = WAL");
10
- this.migrate();
11
- }
12
- close() {
13
- this.db.close();
14
- }
15
- migrate() {
16
- this.db.exec(`
17
- CREATE TABLE IF NOT EXISTS ${this.mapTable} (
18
- master_env TEXT NOT NULL,
19
- master_user_id TEXT NOT NULL,
20
- master_persona_id TEXT NOT NULL,
21
- target_env TEXT NOT NULL,
22
- target_user_id TEXT NOT NULL,
23
- target_persona_id TEXT NOT NULL,
24
- PRIMARY KEY (master_env, master_user_id, master_persona_id, target_env, target_user_id)
25
- );
26
-
27
- CREATE TABLE IF NOT EXISTS ${this.fpTable} (
28
- env TEXT NOT NULL,
29
- env_user_id TEXT NOT NULL,
30
- persona_id TEXT NOT NULL,
31
- fingerprint TEXT NOT NULL,
32
- last_seen_at TEXT NOT NULL,
33
- PRIMARY KEY (env, env_user_id, persona_id)
34
- );
35
-
36
- CREATE TABLE IF NOT EXISTS ${this.runsTable} (
37
- id TEXT PRIMARY KEY,
38
- master_env TEXT NOT NULL,
39
- master_env_user_id TEXT NOT NULL,
40
- started_at TEXT NOT NULL,
41
- finished_at TEXT,
42
- status TEXT NOT NULL,
43
- error TEXT
44
- );
45
- `);
46
- }
47
- getMapping(args) {
48
- const row = this.db
49
- .prepare(`SELECT target_persona_id
50
- FROM ${this.mapTable}
51
- WHERE master_env = ? AND master_user_id = ? AND master_persona_id = ? AND target_env = ? AND target_user_id = ?`)
52
- .get(args.masterEnv, args.masterUserId, args.masterPersonaId, args.targetEnv, args.targetUserId);
53
- return row?.target_persona_id ?? null;
54
- }
55
- upsertMapping(row) {
56
- this.db
57
- .prepare(`INSERT INTO ${this.mapTable} (master_env, master_user_id, master_persona_id, target_env, target_user_id, target_persona_id)
58
- VALUES (?, ?, ?, ?, ?, ?)
59
- ON CONFLICT(master_env, master_user_id, master_persona_id, target_env, target_user_id)
60
- DO UPDATE SET target_persona_id = excluded.target_persona_id`)
61
- .run(row.master_env, row.master_user_id, row.master_persona_id, row.target_env, row.target_user_id, row.target_persona_id);
62
- }
63
- getFingerprint(args) {
64
- const row = this.db
65
- .prepare(`SELECT fingerprint FROM ${this.fpTable} WHERE env = ? AND env_user_id = ? AND persona_id = ?`)
66
- .get(args.env, args.envUserId, args.personaId);
67
- return row?.fingerprint ?? null;
68
- }
69
- upsertFingerprint(row) {
70
- this.db
71
- .prepare(`INSERT INTO ${this.fpTable} (env, env_user_id, persona_id, fingerprint, last_seen_at)
72
- VALUES (?, ?, ?, ?, ?)
73
- ON CONFLICT(env, env_user_id, persona_id)
74
- DO UPDATE SET fingerprint = excluded.fingerprint, last_seen_at = excluded.last_seen_at`)
75
- .run(row.env, row.env_user_id, row.persona_id, row.fingerprint, row.last_seen_at);
76
- }
77
- beginRun(row) {
78
- this.db
79
- .prepare(`INSERT INTO ${this.runsTable} (id, master_env, master_env_user_id, started_at, status)
80
- VALUES (?, ?, ?, ?, ?)`)
81
- .run(row.id, row.master_env, row.master_env_user_id, row.started_at, row.status);
82
- }
83
- finishRun(args) {
84
- this.db
85
- .prepare(`UPDATE ${this.runsTable} SET finished_at = ?, status = ?, error = ? WHERE id = ?`)
86
- .run(args.finishedAt, args.status, args.error ?? null, args.id);
87
- }
88
- }
@@ -1,216 +0,0 @@
1
- /**
2
- * Sync Options Configuration
3
- *
4
- * Hierarchical configuration for sync behavior, loaded from:
5
- * 1. .ema.yaml in repo root (shared team defaults)
6
- * 2. ~/.ema.yaml in user home (personal overrides)
7
- * 3. Tool parameters (runtime overrides)
8
- *
9
- * Resolution order (lowest → highest priority):
10
- * Built-in defaults → defaults → targets[env] → personas[name] → tool params
11
- */
12
- import fs from "node:fs";
13
- import path from "node:path";
14
- import os from "node:os";
15
- import yaml from "js-yaml";
16
- import { z } from "zod";
17
- // ─────────────────────────────────────────────────────────────────────────────
18
- // Schema
19
- // ─────────────────────────────────────────────────────────────────────────────
20
- const SyncBehaviorSchema = z.object({
21
- sync_status: z.boolean().optional(),
22
- dry_run: z.boolean().optional(),
23
- }).strict();
24
- const RoutingRuleSchema = z.object({
25
- match: z.object({
26
- names: z.array(z.string()).optional().default([]),
27
- ids: z.array(z.string()).optional().default([]),
28
- }).strict(),
29
- targets: z.array(z.string()).min(1),
30
- }).strict();
31
- const SyncOptionsConfigSchema = z.object({
32
- defaults: SyncBehaviorSchema.optional().default({}),
33
- targets: z.record(SyncBehaviorSchema).optional().default({}),
34
- personas: z.record(SyncBehaviorSchema).optional().default({}),
35
- routing: z.array(RoutingRuleSchema).optional().default([]),
36
- }).strict();
37
- // ─────────────────────────────────────────────────────────────────────────────
38
- // Built-in defaults
39
- // ─────────────────────────────────────────────────────────────────────────────
40
- const BUILTIN_DEFAULTS = {
41
- sync_status: false,
42
- dry_run: false,
43
- };
44
- // ─────────────────────────────────────────────────────────────────────────────
45
- // Config loading
46
- // ─────────────────────────────────────────────────────────────────────────────
47
- let cachedSyncOptions = null;
48
- function loadYamlFile(filePath) {
49
- try {
50
- const content = fs.readFileSync(filePath, "utf8");
51
- return yaml.load(content);
52
- }
53
- catch {
54
- return null;
55
- }
56
- }
57
- function findRepoRoot() {
58
- // Start from current working directory and look for .git or package.json
59
- let dir = process.cwd();
60
- while (dir !== path.dirname(dir)) {
61
- if (fs.existsSync(path.join(dir, ".git")) || fs.existsSync(path.join(dir, "package.json"))) {
62
- return dir;
63
- }
64
- dir = path.dirname(dir);
65
- }
66
- return process.cwd();
67
- }
68
- /**
69
- * Load and merge sync options from all config sources.
70
- * Caches result for performance.
71
- */
72
- export function loadSyncOptions() {
73
- if (cachedSyncOptions)
74
- return cachedSyncOptions;
75
- const repoRoot = findRepoRoot();
76
- const homeDir = os.homedir();
77
- // Load from repo .ema.yaml
78
- const repoConfigPath = path.join(repoRoot, ".ema.yaml");
79
- const repoConfig = loadYamlFile(repoConfigPath);
80
- // Load from ~/.ema.yaml
81
- const userConfigPath = path.join(homeDir, ".ema.yaml");
82
- const userConfig = loadYamlFile(userConfigPath);
83
- // Merge configs (user overrides repo)
84
- const merged = {
85
- defaults: {},
86
- targets: {},
87
- personas: {},
88
- routing: [],
89
- };
90
- // Apply repo config
91
- if (repoConfig && typeof repoConfig === "object") {
92
- const parsed = SyncOptionsConfigSchema.safeParse(repoConfig);
93
- if (parsed.success) {
94
- merged.defaults = { ...merged.defaults, ...parsed.data.defaults };
95
- merged.targets = { ...merged.targets, ...parsed.data.targets };
96
- merged.personas = { ...merged.personas, ...parsed.data.personas };
97
- merged.routing = [...(parsed.data.routing ?? [])];
98
- }
99
- }
100
- // Apply user config (overrides repo)
101
- if (userConfig && typeof userConfig === "object") {
102
- const parsed = SyncOptionsConfigSchema.safeParse(userConfig);
103
- if (parsed.success) {
104
- merged.defaults = { ...merged.defaults, ...parsed.data.defaults };
105
- // Deep merge targets
106
- for (const [env, behavior] of Object.entries(parsed.data.targets ?? {})) {
107
- merged.targets[env] = { ...merged.targets[env], ...behavior };
108
- }
109
- // Deep merge personas
110
- for (const [name, behavior] of Object.entries(parsed.data.personas ?? {})) {
111
- merged.personas[name] = { ...merged.personas[name], ...behavior };
112
- }
113
- // Prepend user routing rules (higher priority)
114
- merged.routing = [...(parsed.data.routing ?? []), ...merged.routing];
115
- }
116
- }
117
- cachedSyncOptions = merged;
118
- return merged;
119
- }
120
- /**
121
- * Clear the cached sync options (for testing or config reload).
122
- */
123
- export function clearSyncOptionsCache() {
124
- cachedSyncOptions = null;
125
- }
126
- /**
127
- * Resolve sync behavior for a specific persona and target environment.
128
- *
129
- * Resolution order (lowest → highest priority):
130
- * 1. Built-in defaults
131
- * 2. Config defaults
132
- * 3. Config targets[targetEnv]
133
- * 4. Config personas[personaName]
134
- * 5. Runtime overrides (passed as parameter)
135
- */
136
- export function resolveSyncBehavior(opts) {
137
- const { personaName, targetEnv, overrides } = opts;
138
- const config = loadSyncOptions();
139
- // Start with built-in defaults
140
- const resolved = { ...BUILTIN_DEFAULTS };
141
- // Apply config defaults
142
- if (config.defaults) {
143
- if (config.defaults.sync_status !== undefined)
144
- resolved.sync_status = config.defaults.sync_status;
145
- if (config.defaults.dry_run !== undefined)
146
- resolved.dry_run = config.defaults.dry_run;
147
- }
148
- // Apply target env overrides
149
- const targetConfig = config.targets?.[targetEnv];
150
- if (targetConfig) {
151
- if (targetConfig.sync_status !== undefined)
152
- resolved.sync_status = targetConfig.sync_status;
153
- if (targetConfig.dry_run !== undefined)
154
- resolved.dry_run = targetConfig.dry_run;
155
- }
156
- // Apply persona overrides
157
- if (personaName) {
158
- const personaConfig = config.personas?.[personaName];
159
- if (personaConfig) {
160
- if (personaConfig.sync_status !== undefined)
161
- resolved.sync_status = personaConfig.sync_status;
162
- if (personaConfig.dry_run !== undefined)
163
- resolved.dry_run = personaConfig.dry_run;
164
- }
165
- }
166
- // Apply runtime overrides
167
- if (overrides) {
168
- if (overrides.sync_status !== undefined)
169
- resolved.sync_status = overrides.sync_status;
170
- if (overrides.dry_run !== undefined)
171
- resolved.dry_run = overrides.dry_run;
172
- }
173
- return resolved;
174
- }
175
- // ─────────────────────────────────────────────────────────────────────────────
176
- // Routing
177
- // ─────────────────────────────────────────────────────────────────────────────
178
- /**
179
- * Check if a string matches a glob pattern (simple implementation).
180
- * Supports * as wildcard.
181
- */
182
- function matchGlob(pattern, value) {
183
- const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
184
- return regex.test(value);
185
- }
186
- /**
187
- * Get target environments for a persona based on routing rules.
188
- */
189
- export function getRoutingTargets(personaName, personaId) {
190
- const config = loadSyncOptions();
191
- const targets = new Set();
192
- for (const rule of config.routing ?? []) {
193
- // Check name patterns
194
- for (const pattern of rule.match.names ?? []) {
195
- if (matchGlob(pattern, personaName)) {
196
- rule.targets.forEach((t) => targets.add(t));
197
- break;
198
- }
199
- }
200
- // Check IDs
201
- if (rule.match.ids?.includes(personaId)) {
202
- rule.targets.forEach((t) => targets.add(t));
203
- }
204
- }
205
- return [...targets];
206
- }
207
- /**
208
- * Validate sync options config.
209
- */
210
- export function validateSyncOptions(input) {
211
- const result = SyncOptionsConfigSchema.safeParse(input);
212
- if (!result.success) {
213
- return { ok: false, errors: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) };
214
- }
215
- return { ok: true, config: result.data };
216
- }