@ema.co/mcp-toolkit 2026.2.27 → 2026.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @ema.co/mcp-toolkit might be problematic. Click here for more details.

Files changed (58) hide show
  1. package/.context/public/guides/ema-user-guide.md +7 -6
  2. package/.context/public/guides/mcp-tools-guide.md +46 -23
  3. package/dist/config/index.js +11 -0
  4. package/dist/config/workflow-patterns.js +361 -0
  5. package/dist/mcp/autobuilder.js +2 -2
  6. package/dist/mcp/domain/generation-schema.js +15 -9
  7. package/dist/mcp/domain/structural-rules.js +3 -3
  8. package/dist/mcp/domain/validation-rules.js +20 -27
  9. package/dist/mcp/domain/workflow-generator.js +3 -3
  10. package/dist/mcp/domain/workflow-graph.js +1 -1
  11. package/dist/mcp/guidance.js +60 -1
  12. package/dist/mcp/handlers/conversation/adapter.js +13 -0
  13. package/dist/mcp/handlers/conversation/create.js +19 -0
  14. package/dist/mcp/handlers/conversation/delete.js +18 -0
  15. package/dist/mcp/handlers/conversation/formatters.js +62 -0
  16. package/dist/mcp/handlers/conversation/history.js +15 -0
  17. package/dist/mcp/handlers/conversation/index.js +43 -0
  18. package/dist/mcp/handlers/conversation/list.js +40 -0
  19. package/dist/mcp/handlers/conversation/messages.js +13 -0
  20. package/dist/mcp/handlers/conversation/rename.js +16 -0
  21. package/dist/mcp/handlers/conversation/send.js +90 -0
  22. package/dist/mcp/handlers/data/index.js +169 -3
  23. package/dist/mcp/handlers/feedback/client-id.js +49 -0
  24. package/dist/mcp/handlers/feedback/coalesce.js +167 -0
  25. package/dist/mcp/handlers/feedback/index.js +42 -1
  26. package/dist/mcp/handlers/feedback/outbox.js +301 -0
  27. package/dist/mcp/handlers/feedback/probes.js +127 -0
  28. package/dist/mcp/handlers/feedback/remote-store.js +59 -0
  29. package/dist/mcp/handlers/feedback/store.js +13 -1
  30. package/dist/mcp/handlers/persona/delete.js +7 -28
  31. package/dist/mcp/handlers/persona/update.js +7 -26
  32. package/dist/mcp/handlers/persona/version.js +30 -15
  33. package/dist/mcp/handlers/template/adapter.js +23 -0
  34. package/dist/mcp/handlers/template/crud.js +174 -0
  35. package/dist/mcp/handlers/template/index.js +6 -7
  36. package/dist/mcp/handlers/workflow/adapter.js +30 -46
  37. package/dist/mcp/handlers/workflow/index.js +2 -2
  38. package/dist/mcp/handlers/workflow/validation.js +2 -2
  39. package/dist/mcp/knowledge-guidance-topics.js +90 -53
  40. package/dist/mcp/knowledge.js +7 -357
  41. package/dist/mcp/prompts.js +5 -5
  42. package/dist/mcp/resources-dynamic.js +46 -38
  43. package/dist/mcp/resources-validation.js +5 -5
  44. package/dist/mcp/server.js +38 -5
  45. package/dist/mcp/tools.js +340 -8
  46. package/dist/sdk/client-adapter.js +90 -2
  47. package/dist/sdk/client.js +7 -0
  48. package/dist/sdk/ema-client.js +242 -27
  49. package/dist/sdk/generated/agent-catalog.js +96 -39
  50. package/dist/sdk/generated/deprecated-actions.js +1 -1
  51. package/dist/sdk/grpc-client.js +67 -5
  52. package/dist/sync/central-factory.js +86 -0
  53. package/dist/sync/central-version-storage.js +387 -0
  54. package/dist/sync/dis-port.js +75 -0
  55. package/dist/sync/version-policy.js +29 -31
  56. package/dist/sync/version-storage-interface.js +11 -0
  57. package/dist/sync/version-storage.js +22 -22
  58. package/package.json +2 -1
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Central Version Storage — DIS-backed version snapshot storage.
3
+ *
4
+ * Implements IVersionStorage using the Data Ingest Service (DIS) as the backing
5
+ * store, attaching a system widget (`_ema_versions`) to each persona.
6
+ *
7
+ * Design:
8
+ * - Each version is a JSON file: `v{NNN}__{hash}.json`
9
+ * - A `_manifest.json` file tracks all versions for efficient listing
10
+ * - A `_policy.json` file stores per-persona versioning policy
11
+ * - DIS has no overwrite — re-uploads create duplicates. Cleaned up on read.
12
+ * - Manifest reconciliation rebuilds from DIS scan if counts mismatch.
13
+ *
14
+ * ───────────────────────────────────────────────────────────────────────────────
15
+ * TODO(data-snapshots): Future — Data Source Snapshot Support
16
+ * ───────────────────────────────────────────────────────────────────────────────
17
+ *
18
+ * Currently, persona versions capture config + workflow + data source REFERENCES
19
+ * (file_id, filename, widget_name) but not the actual file contents. This means:
20
+ *
21
+ * - Restore works for config/workflow (re-pushed via updateAiEmployee)
22
+ * - Restore does NOT restore deleted data source files
23
+ * - The `data_sources` array in the snapshot is a point-in-time inventory
24
+ *
25
+ * To support full data restore, consider:
26
+ *
27
+ * 1. **On snapshot**: For each DataSourceRef, download the file content from DIS
28
+ * and upload it into `_ema_versions` with a name like `data_v{N}__{file_id}.bin`.
29
+ * Pro: complete restore. Con: doubles storage, N downloads per snapshot.
30
+ *
31
+ * 2. **On restore**: For each DataSourceRef in the snapshot, check if file_id
32
+ * still exists (via list). If missing, either warn or re-upload from the
33
+ * backed-up copy. Pro: graceful degradation. Con: requires backup from (1).
34
+ *
35
+ * 3. **Deferred download**: Don't back up files on snapshot. On restore, check
36
+ * existence and warn about missing files. Cheapest option — works if files
37
+ * are rarely deleted. Pro: zero overhead. Con: lossy restore.
38
+ *
39
+ * 4. **Content-addressable dedup**: Store file content keyed by hash. Multiple
40
+ * versions sharing the same file reference one copy. Pro: optimal storage.
41
+ * Con: complex, needs hash computation for every file on snapshot.
42
+ *
43
+ * Implementation hooks (where to add this):
44
+ * - `saveVersion()` — optionally download + store data source files
45
+ * - `getRestoreData()` in VersionPolicyEngine — check file existence, include re-upload plan
46
+ * - `version.ts` restore handler — execute re-upload plan if files were backed up
47
+ * - `pruneVersions()` — also clean up backed-up data files for pruned versions
48
+ *
49
+ * See also: DataSourceRef TODO in version-tracking.ts
50
+ * ───────────────────────────────────────────────────────────────────────────────
51
+ */
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+ // Constants
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ const MANIFEST_FILENAME = "_manifest.json";
56
+ const POLICY_FILENAME = "_policy.json";
57
+ /** Pattern: v{NNN}__{hash}.json */
58
+ const VERSION_FILE_PATTERN = /^v(\d+)__([a-f0-9]+)\.json$/;
59
+ const DEFAULT_POLICY = {
60
+ auto_version_on_deploy: true,
61
+ auto_version_on_sync: true,
62
+ max_versions: 50,
63
+ prune_strategy: "oldest",
64
+ };
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+ // CentralVersionStorage
67
+ // ─────────────────────────────────────────────────────────────────────────────
68
+ export class CentralVersionStorage {
69
+ port;
70
+ personaId;
71
+ /**
72
+ * @param port - DIS port for file operations
73
+ * @param personaId - The persona ID this storage is scoped to.
74
+ * CentralVersionStorage is constructed per-persona (unlike local storage
75
+ * which is per-workspace). Call sites create one per operation.
76
+ */
77
+ constructor(port, personaId) {
78
+ this.port = port;
79
+ this.personaId = personaId;
80
+ }
81
+ // ─────────────── Version CRUD ───────────────
82
+ async saveVersion(version) {
83
+ const filename = `v${String(version.version_number).padStart(3, "0")}__${version.content_hash}.json`;
84
+ const content = Buffer.from(JSON.stringify(version, null, 2), "utf-8");
85
+ // Upload the version file
86
+ const fileId = await this.port.upload(this.personaId, filename, content);
87
+ // Update manifest
88
+ const manifest = await this.loadManifest();
89
+ const entry = {
90
+ id: version.id,
91
+ version_number: version.version_number,
92
+ version_name: version.version_name,
93
+ content_hash: version.content_hash,
94
+ created_at: version.created_at,
95
+ created_by: version.created_by,
96
+ trigger: version.trigger,
97
+ message: version.message,
98
+ parent_version_id: version.parent_version_id,
99
+ dis_file_id: fileId,
100
+ };
101
+ // Guard: reconcileManifest may have already added this entry
102
+ const alreadyExists = manifest.versions.some((v) => v.dis_file_id === fileId || (v.version_number === version.version_number && v.content_hash === version.content_hash));
103
+ if (!alreadyExists) {
104
+ manifest.versions.push(entry);
105
+ }
106
+ manifest.latest_version_number = version.version_number;
107
+ manifest.latest_version_id = version.id;
108
+ manifest.updated_at = new Date().toISOString();
109
+ await this.saveManifest(manifest);
110
+ }
111
+ async getVersion(personaId, versionNumber) {
112
+ const manifest = await this.loadManifest();
113
+ const entry = manifest.versions.find((v) => v.version_number === versionNumber);
114
+ if (!entry)
115
+ return null;
116
+ return this.downloadSnapshot(entry.dis_file_id);
117
+ }
118
+ async getVersionById(personaId, versionId) {
119
+ // Check manifest first (avoids downloading all versions)
120
+ const manifest = await this.loadManifest();
121
+ const entry = manifest.versions.find((v) => v.id === versionId);
122
+ if (!entry)
123
+ return null;
124
+ return this.downloadSnapshot(entry.dis_file_id);
125
+ }
126
+ async getVersionByName(personaId, versionName) {
127
+ if (versionName === "latest") {
128
+ return this.getLatestVersion(personaId);
129
+ }
130
+ const match = versionName.match(/^v(\d+)$/);
131
+ if (!match)
132
+ return null;
133
+ return this.getVersion(personaId, parseInt(match[1], 10));
134
+ }
135
+ async getLatestVersion(personaId) {
136
+ const manifest = await this.loadManifest();
137
+ if (manifest.versions.length === 0)
138
+ return null;
139
+ // Sort descending and pick the latest
140
+ const sorted = [...manifest.versions].sort((a, b) => b.version_number - a.version_number);
141
+ return this.downloadSnapshot(sorted[0].dis_file_id);
142
+ }
143
+ // ─────────────── Listing & Queries ───────────────
144
+ async listVersions(_personaId) {
145
+ const manifest = await this.loadManifest();
146
+ // Return without DIS-specific fields
147
+ return manifest.versions.map(({ dis_file_id, ...entry }) => entry);
148
+ }
149
+ async hasContentHash(_personaId, contentHash) {
150
+ const manifest = await this.loadManifest();
151
+ return manifest.versions.some((v) => v.content_hash === contentHash);
152
+ }
153
+ async getNextVersionNumber(_personaId) {
154
+ const manifest = await this.loadManifest();
155
+ if (manifest.versions.length === 0)
156
+ return 1;
157
+ // Compute from actual entries — don't trust latest_version_number field
158
+ const maxVer = Math.max(...manifest.versions.map((v) => v.version_number));
159
+ return maxVer + 1;
160
+ }
161
+ async getManifest(_personaId) {
162
+ const manifest = await this.loadManifest();
163
+ if (manifest.versions.length === 0 && manifest.latest_version_number === 0) {
164
+ return null;
165
+ }
166
+ // Strip DIS-specific fields for the public interface
167
+ // Compute latest from actual entries (not stored field which may be stale)
168
+ const latestEntry = manifest.versions.length > 0
169
+ ? [...manifest.versions].sort((a, b) => b.version_number - a.version_number)[0]
170
+ : null;
171
+ return {
172
+ persona_id: manifest.persona_id,
173
+ environment: manifest.environment,
174
+ latest_version_id: latestEntry?.id ?? null,
175
+ latest_version_number: latestEntry?.version_number ?? 0,
176
+ versions: manifest.versions.map(({ dis_file_id, ...entry }) => entry),
177
+ updated_at: manifest.updated_at,
178
+ };
179
+ }
180
+ // ─────────────── Policy ───────────────
181
+ async getPolicy(personaId) {
182
+ const files = await this.port.list(this.personaId);
183
+ const policyFiles = files.filter((f) => f.filename === POLICY_FILENAME);
184
+ if (policyFiles.length === 0) {
185
+ return { persona_id: personaId, ...DEFAULT_POLICY };
186
+ }
187
+ // Cleanup duplicates (DIS has no overwrite)
188
+ if (policyFiles.length > 1) {
189
+ await this.cleanupDuplicates(policyFiles);
190
+ }
191
+ try {
192
+ // Download the most recent one (last uploaded)
193
+ const latest = policyFiles[policyFiles.length - 1];
194
+ const buf = await this.port.download(latest.file_id);
195
+ const policy = JSON.parse(buf.toString("utf-8"));
196
+ return { ...DEFAULT_POLICY, ...policy, persona_id: personaId };
197
+ }
198
+ catch {
199
+ return { persona_id: personaId, ...DEFAULT_POLICY };
200
+ }
201
+ }
202
+ async savePolicy(policy) {
203
+ const content = Buffer.from(JSON.stringify(policy, null, 2), "utf-8");
204
+ await this.port.upload(this.personaId, POLICY_FILENAME, content);
205
+ // Best-effort cleanup of old policy files
206
+ const files = await this.port.list(this.personaId);
207
+ const policyFiles = files.filter((f) => f.filename === POLICY_FILENAME);
208
+ if (policyFiles.length > 1) {
209
+ await this.cleanupDuplicates(policyFiles);
210
+ }
211
+ }
212
+ // ─────────────── Maintenance ───────────────
213
+ async pruneVersions(_personaId) {
214
+ const policy = await this.getPolicy(this.personaId);
215
+ const maxVersions = policy.max_versions ?? 50;
216
+ const manifest = await this.loadManifest();
217
+ if (manifest.versions.length <= maxVersions)
218
+ return 0;
219
+ // Sort oldest first
220
+ const sorted = [...manifest.versions].sort((a, b) => a.version_number - b.version_number);
221
+ const toRemove = sorted.slice(0, sorted.length - maxVersions);
222
+ // Track which file IDs were actually deleted — only remove those from manifest
223
+ const deletedFileIds = new Set();
224
+ for (const entry of toRemove) {
225
+ try {
226
+ await this.port.delete(this.personaId, entry.dis_file_id);
227
+ deletedFileIds.add(entry.dis_file_id);
228
+ }
229
+ catch {
230
+ // Keep in manifest — DIS file still exists
231
+ }
232
+ }
233
+ // Only remove manifest entries for successfully deleted files
234
+ manifest.versions = manifest.versions.filter((v) => !deletedFileIds.has(v.dis_file_id));
235
+ // Recompute latest_version_number from remaining entries
236
+ if (manifest.versions.length > 0) {
237
+ const maxVer = Math.max(...manifest.versions.map((v) => v.version_number));
238
+ manifest.latest_version_number = maxVer;
239
+ const latestEntry = manifest.versions.find((v) => v.version_number === maxVer);
240
+ manifest.latest_version_id = latestEntry?.id ?? null;
241
+ }
242
+ else {
243
+ manifest.latest_version_number = 0;
244
+ manifest.latest_version_id = null;
245
+ }
246
+ manifest.updated_at = new Date().toISOString();
247
+ await this.saveManifest(manifest);
248
+ return deletedFileIds.size;
249
+ }
250
+ // ─────────────── Private Helpers ───────────────
251
+ /**
252
+ * Load the manifest from DIS, with cleanup-on-read for duplicates
253
+ * and reconciliation if the manifest is missing/corrupt.
254
+ */
255
+ async loadManifest() {
256
+ const files = await this.port.list(this.personaId);
257
+ const manifestFiles = files.filter((f) => f.filename === MANIFEST_FILENAME);
258
+ if (manifestFiles.length === 0) {
259
+ // No manifest — try to reconcile from version files
260
+ return this.reconcileManifest(files);
261
+ }
262
+ // Cleanup duplicate manifests
263
+ if (manifestFiles.length > 1) {
264
+ await this.cleanupDuplicates(manifestFiles);
265
+ }
266
+ try {
267
+ // Download the most recent manifest
268
+ const latest = manifestFiles[manifestFiles.length - 1];
269
+ const buf = await this.port.download(latest.file_id);
270
+ const manifest = JSON.parse(buf.toString("utf-8"));
271
+ // Validate: count version files in DIS vs manifest entries
272
+ const versionFiles = files.filter((f) => VERSION_FILE_PATTERN.test(f.filename));
273
+ if (versionFiles.length !== manifest.versions.length) {
274
+ // Mismatch — reconcile from DIS
275
+ return this.reconcileManifest(files);
276
+ }
277
+ return manifest;
278
+ }
279
+ catch {
280
+ // Corrupt manifest — reconcile from DIS
281
+ return this.reconcileManifest(files);
282
+ }
283
+ }
284
+ /**
285
+ * Rebuild manifest by scanning version files in DIS.
286
+ */
287
+ async reconcileManifest(files) {
288
+ const versionFiles = files.filter((f) => VERSION_FILE_PATTERN.test(f.filename));
289
+ const entries = [];
290
+ let maxVersionNumber = 0;
291
+ for (const file of versionFiles) {
292
+ const match = file.filename.match(VERSION_FILE_PATTERN);
293
+ if (!match)
294
+ continue;
295
+ const versionNumber = parseInt(match[1], 10);
296
+ // Download to get full metadata
297
+ try {
298
+ const buf = await this.port.download(file.file_id);
299
+ const snapshot = JSON.parse(buf.toString("utf-8"));
300
+ entries.push({
301
+ id: snapshot.id,
302
+ version_number: snapshot.version_number,
303
+ version_name: snapshot.version_name,
304
+ content_hash: snapshot.content_hash,
305
+ created_at: snapshot.created_at,
306
+ created_by: snapshot.created_by,
307
+ trigger: snapshot.trigger,
308
+ message: snapshot.message,
309
+ parent_version_id: snapshot.parent_version_id,
310
+ dis_file_id: file.file_id,
311
+ });
312
+ if (versionNumber > maxVersionNumber) {
313
+ maxVersionNumber = versionNumber;
314
+ }
315
+ }
316
+ catch {
317
+ // Skip corrupt version files during reconciliation
318
+ }
319
+ }
320
+ // Sort by version number
321
+ entries.sort((a, b) => a.version_number - b.version_number);
322
+ const latestEntry = entries.length > 0 ? entries[entries.length - 1] : null;
323
+ const manifest = {
324
+ persona_id: this.personaId,
325
+ environment: "central",
326
+ latest_version_id: latestEntry?.id ?? null,
327
+ latest_version_number: maxVersionNumber,
328
+ versions: entries,
329
+ updated_at: new Date().toISOString(),
330
+ };
331
+ // Save the reconciled manifest (if there are any versions)
332
+ if (entries.length > 0) {
333
+ await this.saveManifest(manifest);
334
+ }
335
+ return manifest;
336
+ }
337
+ /**
338
+ * Save manifest to DIS. Uploads a new copy and cleans up old ones.
339
+ */
340
+ async saveManifest(manifest) {
341
+ const content = Buffer.from(JSON.stringify(manifest, null, 2), "utf-8");
342
+ await this.port.upload(this.personaId, MANIFEST_FILENAME, content);
343
+ // Best-effort cleanup of old manifest files
344
+ const files = await this.port.list(this.personaId);
345
+ const manifestFiles = files.filter((f) => f.filename === MANIFEST_FILENAME);
346
+ if (manifestFiles.length > 1) {
347
+ await this.cleanupDuplicates(manifestFiles);
348
+ }
349
+ }
350
+ /**
351
+ * Download and parse a version snapshot by file ID.
352
+ */
353
+ async downloadSnapshot(fileId) {
354
+ try {
355
+ const buf = await this.port.download(fileId);
356
+ return JSON.parse(buf.toString("utf-8"));
357
+ }
358
+ catch {
359
+ return null;
360
+ }
361
+ }
362
+ /**
363
+ * Clean up duplicate files (DIS has no overwrite), keeping the most recent.
364
+ * Deletes all but the last entry (assumed most recently uploaded).
365
+ */
366
+ async cleanupDuplicates(files) {
367
+ // Keep the last one (most recently uploaded), delete the rest
368
+ const toDelete = files.slice(0, files.length - 1);
369
+ for (const file of toDelete) {
370
+ try {
371
+ await this.port.delete(this.personaId, file.file_id);
372
+ }
373
+ catch {
374
+ // Best-effort
375
+ }
376
+ }
377
+ }
378
+ }
379
+ // ─────────────────────────────────────────────────────────────────────────────
380
+ // Factory
381
+ // ─────────────────────────────────────────────────────────────────────────────
382
+ /**
383
+ * Create a CentralVersionStorage instance scoped to a persona.
384
+ */
385
+ export function createCentralVersionStorage(port, personaId) {
386
+ return new CentralVersionStorage(port, personaId);
387
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * DIS Port - Dependency injection interface for Data Ingest Service operations.
3
+ *
4
+ * DISPort abstracts the DIS operations needed by CentralVersionStorage,
5
+ * enabling both production (DISAdapter → SDK) and test (MockDISPort → Map) usage.
6
+ */
7
+ /** System widget name for version storage. Prefixed with `_` to exclude from search/validation. */
8
+ export const SYSTEM_WIDGET_NAME = "_ema_versions";
9
+ /** ContentNodeStatus values for soft-deleted files (still appear in listings during deletion). */
10
+ const DELETED_STATUSES = new Set([5 /* DELETE_IN_PROGRESS */, 7 /* DELETED */]);
11
+ /**
12
+ * Production DIS adapter wrapping the SDK client (EmaClientV2).
13
+ */
14
+ export class DISAdapter {
15
+ client;
16
+ personaIdForAuth;
17
+ constructor(client, personaIdForAuth) {
18
+ this.client = client;
19
+ this.personaIdForAuth = personaIdForAuth;
20
+ }
21
+ async upload(personaId, filename, content) {
22
+ const result = await this.client.uploadDataSource(personaId, content, filename, {
23
+ widgetName: SYSTEM_WIDGET_NAME,
24
+ tags: SYSTEM_WIDGET_NAME,
25
+ mimeType: "application/json",
26
+ });
27
+ return result.fileId;
28
+ }
29
+ async list(personaId) {
30
+ const allFiles = [];
31
+ let page = 1;
32
+ const limit = 100;
33
+ // Paginate through all files
34
+ // eslint-disable-next-line no-constant-condition
35
+ while (true) {
36
+ const resp = await this.client.listDataSourceFiles(personaId, {
37
+ widgetName: SYSTEM_WIDGET_NAME,
38
+ page,
39
+ limit,
40
+ });
41
+ for (const node of resp.data) {
42
+ // Skip soft-deleted files (still appear in listings during DIS deletion)
43
+ if (node.status != null && DELETED_STATUSES.has(node.status))
44
+ continue;
45
+ allFiles.push({
46
+ file_id: node.id,
47
+ filename: node.nodeName,
48
+ created_at: node.createdAt
49
+ ? new Date(Number(node.createdAt.seconds) * 1000).toISOString()
50
+ : undefined,
51
+ });
52
+ }
53
+ // Exit when: no pagination info, at last page, or no data returned
54
+ const pagination = resp.pagination;
55
+ if (!pagination || page >= pagination.totalPages || resp.data.length === 0) {
56
+ break;
57
+ }
58
+ page++;
59
+ }
60
+ return allFiles;
61
+ }
62
+ async download(fileId) {
63
+ return this.client.downloadFile(fileId, this.personaIdForAuth);
64
+ }
65
+ async delete(personaId, fileId) {
66
+ try {
67
+ await this.client.deleteDataSource(personaId, fileId, {
68
+ widgetName: SYSTEM_WIDGET_NAME,
69
+ });
70
+ }
71
+ catch {
72
+ // Best-effort: file may already be gone
73
+ }
74
+ }
75
+ }
@@ -4,6 +4,8 @@
4
4
  * Handles automatic versioning decisions based on policies.
5
5
  * Determines when to create snapshots, manages retention, and
6
6
  * enforces versioning rules.
7
+ *
8
+ * All methods are async to support both local and central (DIS) storage.
7
9
  */
8
10
  import { createVersionSnapshot, hashSnapshot, createSnapshotFromPersona, generateChangesSummary, compareVersions, } from "./version-tracking.js";
9
11
  // ─────────────────────────────────────────────────────────────────────────────
@@ -18,12 +20,12 @@ export class VersionPolicyEngine {
18
20
  /**
19
21
  * Check if a version should be created for a persona.
20
22
  */
21
- checkPolicy(persona, trigger) {
22
- const policy = this.storage.getPolicy(persona.id);
23
+ async checkPolicy(persona, trigger) {
24
+ const policy = await this.storage.getPolicy(persona.id);
23
25
  const snapshot = createSnapshotFromPersona(persona);
24
26
  const contentHash = hashSnapshot(snapshot);
25
27
  // Check if this would be a duplicate
26
- const isDuplicate = this.storage.hasContentHash(persona.id, contentHash);
28
+ const isDuplicate = await this.storage.hasContentHash(persona.id, contentHash);
27
29
  // Determine if we should create based on trigger and policy
28
30
  let shouldCreate = false;
29
31
  let reason = "";
@@ -69,7 +71,7 @@ export class VersionPolicyEngine {
69
71
  case "auto":
70
72
  // Check interval if specified
71
73
  if (policy.min_interval) {
72
- const latest = this.storage.getLatestVersion(persona.id);
74
+ const latest = await this.storage.getLatestVersion(persona.id);
73
75
  if (latest && !this.hasIntervalPassed(latest.created_at, policy.min_interval)) {
74
76
  shouldCreate = false;
75
77
  skipReason = `Minimum interval (${policy.min_interval}) not yet passed`;
@@ -122,6 +124,9 @@ export class VersionPolicyEngine {
122
124
  intervalMs += parseInt(minutes, 10) * 60 * 1000;
123
125
  if (days)
124
126
  intervalMs += parseInt(days, 10) * 24 * 60 * 60 * 1000;
127
+ // Unrecognized format → interval not passed (safe default: don't auto-create)
128
+ if (intervalMs === 0)
129
+ return false;
125
130
  return now.getTime() - last.getTime() >= intervalMs;
126
131
  }
127
132
  // ─────────────── Version Creation ───────────────
@@ -129,9 +134,9 @@ export class VersionPolicyEngine {
129
134
  * Create a version if policy allows.
130
135
  * This is the main entry point for automatic versioning.
131
136
  */
132
- createVersionIfAllowed(persona, trigger, options) {
137
+ async createVersionIfAllowed(persona, trigger, options) {
133
138
  // Check policy (unless forced)
134
- const check = this.checkPolicy(persona, trigger);
139
+ const check = await this.checkPolicy(persona, trigger);
135
140
  if (!options.force && !check.should_create_version) {
136
141
  return {
137
142
  created: false,
@@ -140,7 +145,7 @@ export class VersionPolicyEngine {
140
145
  };
141
146
  }
142
147
  // Get parent version info
143
- const latest = this.storage.getLatestVersion(persona.id);
148
+ const latest = await this.storage.getLatestVersion(persona.id);
144
149
  const previousVersionNumber = latest?.version_number;
145
150
  const parentVersionId = latest?.id;
146
151
  // Create the version
@@ -162,9 +167,9 @@ export class VersionPolicyEngine {
162
167
  version.changes_summary = changesFromParent;
163
168
  }
164
169
  // Save the version
165
- this.storage.saveVersion(version);
170
+ await this.storage.saveVersion(version);
166
171
  // Prune if necessary
167
- const versionsPruned = this.storage.pruneVersions(persona.id);
172
+ const versionsPruned = await this.storage.pruneVersions(persona.id);
168
173
  return {
169
174
  created: true,
170
175
  version,
@@ -176,7 +181,7 @@ export class VersionPolicyEngine {
176
181
  /**
177
182
  * Force create a version regardless of policy.
178
183
  */
179
- forceCreateVersion(persona, options) {
184
+ async forceCreateVersion(persona, options) {
180
185
  return this.createVersionIfAllowed(persona, "manual", {
181
186
  ...options,
182
187
  force: true,
@@ -187,18 +192,18 @@ export class VersionPolicyEngine {
187
192
  * Get the snapshot data needed to restore a version.
188
193
  * Returns the persona configuration that can be applied via updateAiEmployee.
189
194
  */
190
- getRestoreData(personaId, versionIdentifier) {
195
+ async getRestoreData(personaId, versionIdentifier) {
191
196
  // Get the version
192
197
  let version = null;
193
198
  if (typeof versionIdentifier === "number") {
194
- version = this.storage.getVersion(personaId, versionIdentifier);
199
+ version = await this.storage.getVersion(personaId, versionIdentifier);
195
200
  }
196
201
  else if (versionIdentifier.match(/^v\d+$/)) {
197
- version = this.storage.getVersionByName(personaId, versionIdentifier);
202
+ version = await this.storage.getVersionByName(personaId, versionIdentifier);
198
203
  }
199
204
  else {
200
205
  // Assume it's a version ID
201
- version = this.storage.getVersionById(personaId, versionIdentifier);
206
+ version = await this.storage.getVersionById(personaId, versionIdentifier);
202
207
  }
203
208
  if (!version) {
204
209
  return {
@@ -226,28 +231,28 @@ export class VersionPolicyEngine {
226
231
  /**
227
232
  * Get policy for a persona.
228
233
  */
229
- getPolicy(personaId) {
234
+ async getPolicy(personaId) {
230
235
  return this.storage.getPolicy(personaId);
231
236
  }
232
237
  /**
233
238
  * Update policy for a persona.
234
239
  */
235
- updatePolicy(personaId, updates) {
236
- const current = this.storage.getPolicy(personaId);
240
+ async updatePolicy(personaId, updates) {
241
+ const current = await this.storage.getPolicy(personaId);
237
242
  const updated = {
238
243
  ...current,
239
244
  ...updates,
240
245
  persona_id: personaId, // Ensure this is always set
241
246
  };
242
- this.storage.savePolicy(updated);
247
+ await this.storage.savePolicy(updated);
243
248
  return updated;
244
249
  }
245
250
  // ─────────────── Query Methods ───────────────
246
251
  /**
247
252
  * List versions for a persona with optional filtering.
248
253
  */
249
- listVersions(personaId, options) {
250
- let versions = this.storage.listVersions(personaId);
254
+ async listVersions(personaId, options) {
255
+ let versions = await this.storage.listVersions(personaId);
251
256
  // Apply filters
252
257
  if (options?.trigger) {
253
258
  versions = versions.filter((v) => v.trigger === options.trigger);
@@ -275,7 +280,7 @@ export class VersionPolicyEngine {
275
280
  /**
276
281
  * Get a specific version with full details.
277
282
  */
278
- getVersion(personaId, versionIdentifier) {
283
+ async getVersion(personaId, versionIdentifier) {
279
284
  if (versionIdentifier === "latest") {
280
285
  return this.storage.getLatestVersion(personaId);
281
286
  }
@@ -292,9 +297,9 @@ export class VersionPolicyEngine {
292
297
  /**
293
298
  * Compare two versions.
294
299
  */
295
- compareVersions(personaId, v1Identifier, v2Identifier) {
296
- const v1 = this.getVersion(personaId, v1Identifier);
297
- const v2 = this.getVersion(personaId, v2Identifier);
300
+ async compareVersions(personaId, v1Identifier, v2Identifier) {
301
+ const v1 = await this.getVersion(personaId, v1Identifier);
302
+ const v2 = await this.getVersion(personaId, v2Identifier);
298
303
  if (!v1) {
299
304
  return { success: false, error: `Version not found: ${v1Identifier}` };
300
305
  }
@@ -309,13 +314,6 @@ export class VersionPolicyEngine {
309
314
  summary,
310
315
  };
311
316
  }
312
- // ─────────────── Storage Access ───────────────
313
- /**
314
- * Get the underlying storage instance.
315
- */
316
- getStorage() {
317
- return this.storage;
318
- }
319
317
  }
320
318
  // ─────────────────────────────────────────────────────────────────────────────
321
319
  // Factory Function
@@ -0,0 +1,11 @@
1
+ /**
2
+ * IVersionStorage - Async contract for version snapshot storage.
3
+ *
4
+ * Implemented by:
5
+ * - VersionStorage (local file-based, .ema-versions/)
6
+ * - CentralVersionStorage (DIS-backed, _ema_versions widget)
7
+ *
8
+ * All methods return Promise<T> to accommodate async DIS operations.
9
+ * Local storage wraps synchronous fs calls in Promise.resolve().
10
+ */
11
+ export {};