@convertcom/mcp-server 0.0.1-beta.10
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/.assets/logo-dark.svg +20 -0
- package/.assets/logo-light.svg +20 -0
- package/README.md +277 -0
- package/build/api-config.d.ts +4 -0
- package/build/api-config.js +139 -0
- package/build/api-config.js.map +1 -0
- package/build/debug-log.d.ts +6 -0
- package/build/debug-log.js +46 -0
- package/build/debug-log.js.map +1 -0
- package/build/executor.d.ts +13 -0
- package/build/executor.js +783 -0
- package/build/executor.js.map +1 -0
- package/build/index.d.ts +10 -0
- package/build/index.js +453 -0
- package/build/index.js.map +1 -0
- package/build/kb-hybrid.d.ts +20 -0
- package/build/kb-hybrid.js +70 -0
- package/build/kb-hybrid.js.map +1 -0
- package/build/kb-search.d.ts +18 -0
- package/build/kb-search.js +77 -0
- package/build/kb-search.js.map +1 -0
- package/build/kb-semantic.d.ts +20 -0
- package/build/kb-semantic.js +135 -0
- package/build/kb-semantic.js.map +1 -0
- package/build/namespaced.d.ts +10 -0
- package/build/namespaced.js +927 -0
- package/build/namespaced.js.map +1 -0
- package/build/operator.d.ts +17 -0
- package/build/operator.js +1320 -0
- package/build/operator.js.map +1 -0
- package/build/prompts.d.ts +13 -0
- package/build/prompts.js +201 -0
- package/build/prompts.js.map +1 -0
- package/build/shared.d.ts +6 -0
- package/build/shared.js +14 -0
- package/build/shared.js.map +1 -0
- package/build/tools.d.ts +19 -0
- package/build/tools.js +1293 -0
- package/build/tools.js.map +1 -0
- package/data/convert-kb.json +28882 -0
- package/data/kb-vectors/0/embedding.index.3.proxima +0 -0
- package/data/kb-vectors/0/scalar.0.ipc +0 -0
- package/data/kb-vectors/LOCK +0 -0
- package/data/kb-vectors/del.0 +0 -0
- package/data/kb-vectors/idmap.0/000008.sst +0 -0
- package/data/kb-vectors/idmap.0/CURRENT +1 -0
- package/data/kb-vectors/idmap.0/IDENTITY +1 -0
- package/data/kb-vectors/idmap.0/LOCK +0 -0
- package/data/kb-vectors/idmap.0/LOG +0 -0
- package/data/kb-vectors/idmap.0/MANIFEST-000005 +0 -0
- package/data/kb-vectors/idmap.0/OPTIONS-000007 +199 -0
- package/data/kb-vectors/manifest.3 +9 -0
- package/package.json +71 -0
- package/server.json +17 -0
|
@@ -0,0 +1,1320 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { executeNamespacedTool } from './namespaced.js';
|
|
5
|
+
import { JSON_SCHEMA_DRAFT_2020_12 } from './shared.js';
|
|
6
|
+
const READ_ACTIONS = [
|
|
7
|
+
{
|
|
8
|
+
action: 'project_audit',
|
|
9
|
+
mode: 'read',
|
|
10
|
+
description: 'Fetch project, domains, experiments, goals, audiences, locations, tags, and features where supported, then return a compact project audit.',
|
|
11
|
+
path_parameters: ['account_id', 'project_id'],
|
|
12
|
+
accepts_body: false,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
action: 'experiment_portfolio',
|
|
16
|
+
mode: 'read',
|
|
17
|
+
description: 'Summarize experiments by status, type, duration, goals, and observed outcome fields.',
|
|
18
|
+
path_parameters: ['account_id', 'project_id'],
|
|
19
|
+
accepts_body: true,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
action: 'experiment_velocity',
|
|
23
|
+
mode: 'read',
|
|
24
|
+
description: 'Compute created, active, completed, draft backlog, and cycle-time signals from project experiments.',
|
|
25
|
+
path_parameters: ['account_id', 'project_id'],
|
|
26
|
+
accepts_body: true,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
action: 'draft_qa',
|
|
30
|
+
mode: 'read',
|
|
31
|
+
description: 'Check draft experiences for missing goals, targeting, variations, code, URLs, and alerts.',
|
|
32
|
+
path_parameters: ['account_id', 'project_id'],
|
|
33
|
+
accepts_body: false,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
action: 'active_health',
|
|
37
|
+
mode: 'read',
|
|
38
|
+
description: 'Check active experiments for long runtime, alerts, setup gaps, and traffic-allocation concerns.',
|
|
39
|
+
path_parameters: ['account_id', 'project_id'],
|
|
40
|
+
accepts_body: true,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
action: 'implementation_health',
|
|
44
|
+
mode: 'read',
|
|
45
|
+
description: 'Inspect Convert-side implementation evidence for web/fullstack projects: project type, snippet/SDK config, domains, live data, optional experience live data, and next browser/local checks.',
|
|
46
|
+
path_parameters: ['account_id', 'project_id'],
|
|
47
|
+
accepts_body: true,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
action: 'failed_test_analysis',
|
|
51
|
+
mode: 'read',
|
|
52
|
+
description: 'Analyze paused/completed/inconclusive experiments and identify likely loss, underpowering, or retest candidates.',
|
|
53
|
+
path_parameters: ['account_id', 'project_id'],
|
|
54
|
+
accepts_body: true,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
action: 'hypothesis_quality_audit',
|
|
58
|
+
mode: 'read',
|
|
59
|
+
description: 'Score hypothesis wording quality and correlate hypotheses with linked experiment outcomes when available.',
|
|
60
|
+
path_parameters: ['account_id', 'project_id'],
|
|
61
|
+
accepts_body: true,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
action: 'audience_segment_comparison',
|
|
65
|
+
mode: 'read',
|
|
66
|
+
description: 'Run aggregated report comparisons across supported segments for one experience.',
|
|
67
|
+
path_parameters: ['account_id', 'project_id', 'experience_id'],
|
|
68
|
+
accepts_body: true,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
action: 'cleanup_candidates',
|
|
72
|
+
mode: 'read',
|
|
73
|
+
description: 'Find stale drafts and unused goals, audiences, locations, tags, and fullstack features where supported.',
|
|
74
|
+
path_parameters: ['account_id', 'project_id'],
|
|
75
|
+
accepts_body: true,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
action: 'get_defaults',
|
|
79
|
+
mode: 'read',
|
|
80
|
+
description: 'Infer and cache project defaults such as preferred goal, audience, location, URL, and traffic split.',
|
|
81
|
+
path_parameters: ['account_id', 'project_id'],
|
|
82
|
+
accepts_body: true,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
const WRITE_ACTIONS = [
|
|
86
|
+
{
|
|
87
|
+
action: 'create_custom_code_ab_test',
|
|
88
|
+
mode: 'write',
|
|
89
|
+
description: 'Create a draft custom-code A/B experience, then read it back for verification.',
|
|
90
|
+
path_parameters: ['account_id', 'project_id'],
|
|
91
|
+
accepts_body: true,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
action: 'create_split_url_test',
|
|
95
|
+
mode: 'write',
|
|
96
|
+
description: 'Create a draft split URL experience with defaultRedirect change data, then read it back for verification.',
|
|
97
|
+
path_parameters: ['account_id', 'project_id'],
|
|
98
|
+
accepts_body: true,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
action: 'enable_split_url_regex',
|
|
102
|
+
mode: 'write',
|
|
103
|
+
description: 'Enable split URL regex support with experiences.update, then read the experience back.',
|
|
104
|
+
path_parameters: ['account_id', 'project_id', 'experience_id'],
|
|
105
|
+
accepts_body: true,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
action: 'safe_update_variation_code',
|
|
109
|
+
mode: 'write',
|
|
110
|
+
description: 'Update a variation customCode change with experiences.update_variation, then read the experience back.',
|
|
111
|
+
path_parameters: ['account_id', 'project_id', 'experience_id', 'variation_id'],
|
|
112
|
+
accepts_body: true,
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
const ALL_ACTIONS = [...READ_ACTIONS, ...WRITE_ACTIONS];
|
|
116
|
+
const DEFAULT_RESULTS_PER_PAGE = 50;
|
|
117
|
+
const DEFAULT_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
118
|
+
export function getOperatorActions(toolSet) {
|
|
119
|
+
return toolSet === 'all' ? ALL_ACTIONS : READ_ACTIONS;
|
|
120
|
+
}
|
|
121
|
+
export function getOperatorTool(toolSet) {
|
|
122
|
+
const actions = getOperatorActions(toolSet);
|
|
123
|
+
const hasWrite = actions.some((action) => action.mode === 'write');
|
|
124
|
+
const annotations = {
|
|
125
|
+
title: 'Operator',
|
|
126
|
+
readOnlyHint: !hasWrite,
|
|
127
|
+
destructiveHint: false,
|
|
128
|
+
idempotentHint: !hasWrite,
|
|
129
|
+
openWorldHint: false,
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
name: 'operator',
|
|
133
|
+
title: 'Operator',
|
|
134
|
+
description: `High-level Convert experimentation workflows. Available actions: ${actions.map((item) => item.action).join(', ')}.`,
|
|
135
|
+
inputSchema: {
|
|
136
|
+
$schema: JSON_SCHEMA_DRAFT_2020_12,
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
action: {
|
|
140
|
+
type: 'string',
|
|
141
|
+
enum: actions.map((item) => item.action),
|
|
142
|
+
description: 'Operator workflow action to perform. See _meta.available_actions for per-action details.',
|
|
143
|
+
},
|
|
144
|
+
path: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
additionalProperties: true,
|
|
147
|
+
description: 'Path identifiers such as account_id, project_id, experience_id, and variation_id. For compatibility, these IDs may also be supplied at the top level.',
|
|
148
|
+
},
|
|
149
|
+
query: {
|
|
150
|
+
type: 'object',
|
|
151
|
+
additionalProperties: true,
|
|
152
|
+
description: 'Optional filters or reporting parameters.',
|
|
153
|
+
},
|
|
154
|
+
body: {
|
|
155
|
+
type: 'object',
|
|
156
|
+
additionalProperties: true,
|
|
157
|
+
description: 'Workflow input. Mutating operator actions always create draft/update-only changes and perform readback verification.',
|
|
158
|
+
},
|
|
159
|
+
confirmation: {
|
|
160
|
+
type: 'object',
|
|
161
|
+
additionalProperties: true,
|
|
162
|
+
description: 'Explicit user confirmation metadata. Passed through to underlying namespace calls when needed.',
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
required: ['action'],
|
|
166
|
+
additionalProperties: true,
|
|
167
|
+
_meta: {
|
|
168
|
+
namespace: 'operator',
|
|
169
|
+
available_actions: actions,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
outputSchema: {
|
|
173
|
+
$schema: JSON_SCHEMA_DRAFT_2020_12,
|
|
174
|
+
type: 'object',
|
|
175
|
+
description: 'Standardized operator result envelope.',
|
|
176
|
+
properties: {
|
|
177
|
+
ok: { type: 'boolean' },
|
|
178
|
+
namespace: { type: 'string' },
|
|
179
|
+
action: { type: 'string' },
|
|
180
|
+
delegate: { type: 'string' },
|
|
181
|
+
status: { type: 'number' },
|
|
182
|
+
data: {},
|
|
183
|
+
error: {},
|
|
184
|
+
issues: { type: 'array', items: { type: 'object' } },
|
|
185
|
+
verified: { type: 'boolean' },
|
|
186
|
+
},
|
|
187
|
+
additionalProperties: true,
|
|
188
|
+
},
|
|
189
|
+
annotations,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export async function executeOperatorTool(rawArgs, toolSet = process.env.TOOLS_FOR_CLIENT ?? 'reporting', context = {}) {
|
|
193
|
+
const parsed = normalizeArgs(rawArgs);
|
|
194
|
+
if (!parsed) {
|
|
195
|
+
return errorResult('unknown', 'InvalidArguments', 'Expected object with fields: action, path, query, body, confirmation.');
|
|
196
|
+
}
|
|
197
|
+
const actionDef = getOperatorActions(toolSet).find((candidate) => candidate.action === parsed.action);
|
|
198
|
+
if (!actionDef) {
|
|
199
|
+
return errorResult(parsed.action || 'unknown', 'UnknownAction', `Unknown operator action "${parsed.action}". Available: ${getOperatorActions(toolSet).map((item) => item.action).join(', ')}`);
|
|
200
|
+
}
|
|
201
|
+
const missing = actionDef.path_parameters.filter((name) => typeof parsed.path[name] === 'undefined' || parsed.path[name] === null);
|
|
202
|
+
if (missing.length > 0) {
|
|
203
|
+
return errorResult(parsed.action, 'MissingPathParameters', `Missing required path parameters: ${missing.map((name) => `path.${name}`).join(', ')}`, { missing_parameters: missing });
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
await report(context, `Starting operator.${parsed.action}`, 0, 1);
|
|
207
|
+
switch (parsed.action) {
|
|
208
|
+
case 'project_audit':
|
|
209
|
+
return okResult(parsed.action, await projectAudit(parsed, context));
|
|
210
|
+
case 'experiment_portfolio':
|
|
211
|
+
return okResult(parsed.action, await experimentPortfolio(parsed, context));
|
|
212
|
+
case 'experiment_velocity':
|
|
213
|
+
return okResult(parsed.action, await experimentVelocity(parsed, context));
|
|
214
|
+
case 'draft_qa':
|
|
215
|
+
return okResult(parsed.action, await draftQa(parsed, context));
|
|
216
|
+
case 'active_health':
|
|
217
|
+
return okResult(parsed.action, await activeHealth(parsed, context));
|
|
218
|
+
case 'implementation_health':
|
|
219
|
+
return okResult(parsed.action, await implementationHealth(parsed, context));
|
|
220
|
+
case 'failed_test_analysis':
|
|
221
|
+
return okResult(parsed.action, await failedTestAnalysis(parsed, context));
|
|
222
|
+
case 'hypothesis_quality_audit':
|
|
223
|
+
return okResult(parsed.action, await hypothesisQualityAudit(parsed, context));
|
|
224
|
+
case 'audience_segment_comparison':
|
|
225
|
+
return okResult(parsed.action, await audienceSegmentComparison(parsed, context));
|
|
226
|
+
case 'cleanup_candidates':
|
|
227
|
+
return okResult(parsed.action, await cleanupCandidates(parsed, context));
|
|
228
|
+
case 'get_defaults':
|
|
229
|
+
return okResult(parsed.action, await getDefaults(parsed, context));
|
|
230
|
+
case 'create_custom_code_ab_test':
|
|
231
|
+
return okResult(parsed.action, await createCustomCodeAbTest(parsed, context));
|
|
232
|
+
case 'create_split_url_test':
|
|
233
|
+
return okResult(parsed.action, await createSplitUrlTest(parsed, context));
|
|
234
|
+
case 'enable_split_url_regex':
|
|
235
|
+
return okResult(parsed.action, await enableSplitUrlRegex(parsed, context));
|
|
236
|
+
case 'safe_update_variation_code':
|
|
237
|
+
return okResult(parsed.action, await safeUpdateVariationCode(parsed, context));
|
|
238
|
+
default:
|
|
239
|
+
return errorResult(parsed.action, 'UnknownAction', `Unhandled operator action "${parsed.action}".`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
244
|
+
return errorResult(parsed.action, 'OperatorExecutionFailed', message);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function normalizeArgs(rawArgs) {
|
|
248
|
+
if (!isObject(rawArgs))
|
|
249
|
+
return null;
|
|
250
|
+
const path = isObject(rawArgs.path) ? { ...rawArgs.path } : {};
|
|
251
|
+
const query = isObject(rawArgs.query) ? { ...rawArgs.query } : {};
|
|
252
|
+
const body = isObject(rawArgs.body) ? { ...rawArgs.body } : {};
|
|
253
|
+
for (const key of ['account_id', 'project_id', 'experience_id', 'variation_id']) {
|
|
254
|
+
if (typeof path[key] === 'undefined' && typeof rawArgs[key] !== 'undefined') {
|
|
255
|
+
path[key] = rawArgs[key];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
action: typeof rawArgs.action === 'string' ? rawArgs.action : String(rawArgs.action ?? ''),
|
|
260
|
+
path,
|
|
261
|
+
query,
|
|
262
|
+
body,
|
|
263
|
+
confirmation: rawArgs.confirmation,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
async function projectAudit(args, context) {
|
|
267
|
+
await report(context, 'Fetching project audit resources', 1, 4);
|
|
268
|
+
const path = basePath(args);
|
|
269
|
+
const [project, domains, experiences, goals, audiences, locations, tags, features] = await Promise.all([
|
|
270
|
+
safeNamespaceCall('projects', 'get', path, undefined, { include: ['domains', 'default_goals', 'default_audiences', 'default_locations'] }),
|
|
271
|
+
safeNamespaceCall('domains', 'list', path),
|
|
272
|
+
listExperiences(path),
|
|
273
|
+
listResource('goals', path, { include: ['stats'] }),
|
|
274
|
+
listResource('audiences', path, { include: ['rules', 'stats.experiences_usage'] }),
|
|
275
|
+
listResource('locations', path, { include: ['rules', 'trigger', 'stats.experiences_usage'] }),
|
|
276
|
+
listResource('tags', path),
|
|
277
|
+
listResource('features', path, { include: ['stats'] }),
|
|
278
|
+
]);
|
|
279
|
+
await report(context, 'Summarizing project audit resources', 3, 4);
|
|
280
|
+
const experienceItems = collectionFrom(experiences.data);
|
|
281
|
+
const goalItems = collectionFrom(goals.data);
|
|
282
|
+
const audienceItems = collectionFrom(audiences.data);
|
|
283
|
+
const locationItems = collectionFrom(locations.data);
|
|
284
|
+
const domainItems = collectionFrom(domains.data);
|
|
285
|
+
const tagItems = collectionFrom(tags.data);
|
|
286
|
+
const featureItems = features.ok ? collectionFrom(features.data) : [];
|
|
287
|
+
return {
|
|
288
|
+
project: compactEntity(objectFrom(project.data)),
|
|
289
|
+
counts: {
|
|
290
|
+
experiences: experienceItems.length,
|
|
291
|
+
experiences_by_status: countBy(experienceItems, 'status'),
|
|
292
|
+
experiences_by_type: countBy(experienceItems, 'type'),
|
|
293
|
+
goals: goalItems.length,
|
|
294
|
+
audiences: audienceItems.length,
|
|
295
|
+
locations: locationItems.length,
|
|
296
|
+
domains: domainItems.length,
|
|
297
|
+
tags: tagItems.length,
|
|
298
|
+
features: featureItems.length,
|
|
299
|
+
},
|
|
300
|
+
active_experiments: summarizeExperiences(experienceItems.filter((item) => item.status === 'active')).slice(0, 20),
|
|
301
|
+
draft_experiments: summarizeExperiences(experienceItems.filter((item) => item.status === 'draft')).slice(0, 20),
|
|
302
|
+
setup_flags: [
|
|
303
|
+
...(domainItems.length === 0 ? ['No domains returned for this project.'] : []),
|
|
304
|
+
...(goalItems.length === 0 ? ['No goals returned for this project.'] : []),
|
|
305
|
+
...(locationItems.length === 0 ? ['No locations returned for this project.'] : []),
|
|
306
|
+
...(audienceItems.length === 0 ? ['No audiences returned for this project.'] : []),
|
|
307
|
+
...warningsFromCalls([project, domains, experiences, goals, audiences, locations, tags, features]),
|
|
308
|
+
],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
async function experimentPortfolio(args, context) {
|
|
312
|
+
await report(context, 'Fetching experiment portfolio', 1, 3);
|
|
313
|
+
const experiences = collectionFrom((await listExperiences(basePath(args), portfolioFilters(args))).data);
|
|
314
|
+
await report(context, 'Computing experiment portfolio', 2, 3);
|
|
315
|
+
const summaries = summarizeExperiences(experiences);
|
|
316
|
+
const outcomes = summaries.map((item) => item.outcome);
|
|
317
|
+
return {
|
|
318
|
+
total: summaries.length,
|
|
319
|
+
by_status: countBy(experiences, 'status'),
|
|
320
|
+
by_type: countBy(experiences, 'type'),
|
|
321
|
+
outcome_counts: countValues(outcomes),
|
|
322
|
+
completed: summaries.filter((item) => item.status === 'completed').length,
|
|
323
|
+
active: summaries.filter((item) => item.status === 'active').length,
|
|
324
|
+
draft: summaries.filter((item) => item.status === 'draft').length,
|
|
325
|
+
experiments: summaries,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
async function experimentVelocity(args, context) {
|
|
329
|
+
await report(context, 'Fetching experiments for velocity report', 1, 3);
|
|
330
|
+
const experiences = collectionFrom((await listExperiences(basePath(args), portfolioFilters(args))).data);
|
|
331
|
+
const summaries = summarizeExperiences(experiences);
|
|
332
|
+
const launched = summaries.filter((item) => typeof item.start_time === 'number' && item.start_time > 0);
|
|
333
|
+
const completed = summaries.filter((item) => typeof item.end_time === 'number' && item.end_time > 0);
|
|
334
|
+
return {
|
|
335
|
+
total: summaries.length,
|
|
336
|
+
created_or_returned: summaries.length,
|
|
337
|
+
active: summaries.filter((item) => item.status === 'active').length,
|
|
338
|
+
completed: summaries.filter((item) => item.status === 'completed').length,
|
|
339
|
+
draft_backlog: summaries.filter((item) => item.status === 'draft').length,
|
|
340
|
+
average_launch_to_end_days: average(completed.map((item) => daysBetween(item.start_time, item.end_time)).filter(isFiniteNumber)),
|
|
341
|
+
launched_with_start_time: launched.length,
|
|
342
|
+
by_status: countBy(experiences, 'status'),
|
|
343
|
+
newest: summaries.slice(0, 20),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
async function draftQa(args, context) {
|
|
347
|
+
await report(context, 'Fetching draft experiences for QA', 1, 3);
|
|
348
|
+
const experiences = collectionFrom((await listExperiences(basePath(args), { status: ['draft'] })).data);
|
|
349
|
+
const checks = experiences.map((experience) => ({
|
|
350
|
+
...summarizeExperience(experience),
|
|
351
|
+
issues: draftIssues(experience),
|
|
352
|
+
}));
|
|
353
|
+
return {
|
|
354
|
+
total_drafts: checks.length,
|
|
355
|
+
needs_attention: checks.filter((item) => item.issues.length > 0),
|
|
356
|
+
passed_basic_qa: checks.filter((item) => item.issues.length === 0),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
async function activeHealth(args, context) {
|
|
360
|
+
const thresholdDays = numberFrom(args.body.threshold_days ?? args.query.threshold_days, 30);
|
|
361
|
+
await report(context, 'Fetching active experiments for health check', 1, 3);
|
|
362
|
+
const experiences = collectionFrom((await listExperiences(basePath(args), { status: ['active'] })).data);
|
|
363
|
+
const checks = experiences.map((experience) => ({
|
|
364
|
+
...summarizeExperience(experience),
|
|
365
|
+
issues: activeIssues(experience, thresholdDays),
|
|
366
|
+
}));
|
|
367
|
+
return {
|
|
368
|
+
threshold_days: thresholdDays,
|
|
369
|
+
total_active: checks.length,
|
|
370
|
+
needs_attention: checks.filter((item) => item.issues.length > 0),
|
|
371
|
+
healthy_or_waiting: checks.filter((item) => item.issues.length === 0),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
async function implementationHealth(args, context) {
|
|
375
|
+
await report(context, 'Fetching project implementation evidence', 1, 4);
|
|
376
|
+
const path = basePath(args);
|
|
377
|
+
const requestedExperienceId = args.path.experience_id ?? args.body.experience_id ?? args.query.experience_id;
|
|
378
|
+
const liveDataBody = {
|
|
379
|
+
event_types: normalizeEventTypes(args.body.event_types ?? args.query.event_types),
|
|
380
|
+
expand: ['experiences', 'experiences.variation', 'conversion.goals', 'project'],
|
|
381
|
+
};
|
|
382
|
+
const [project, domains, projectLiveData] = await Promise.all([
|
|
383
|
+
safeNamespaceCall('projects', 'get', path, undefined, { include: ['domains', 'default_goals', 'default_audiences', 'default_locations'] }),
|
|
384
|
+
safeNamespaceCall('domains', 'list', path),
|
|
385
|
+
safeNamespaceCall('projects', 'get_live_data', path, liveDataBody),
|
|
386
|
+
]);
|
|
387
|
+
const projectData = objectFrom(project.data);
|
|
388
|
+
const projectType = inferProjectType(projectData);
|
|
389
|
+
const shouldFetchFullstackResources = projectType === 'fullstack';
|
|
390
|
+
await report(context, 'Fetching type-specific implementation evidence', 2, 4);
|
|
391
|
+
const [sdkKeys, features] = shouldFetchFullstackResources
|
|
392
|
+
? await Promise.all([
|
|
393
|
+
safeNamespaceCall('accounts', 'list_sdk_keys', path),
|
|
394
|
+
listResource('features', path, { include: ['stats'] }),
|
|
395
|
+
])
|
|
396
|
+
: [null, null];
|
|
397
|
+
const experiencePath = requestedExperienceId
|
|
398
|
+
? basePath(args, [], { experience_id: coerceNumberIfNumeric(requestedExperienceId) })
|
|
399
|
+
: null;
|
|
400
|
+
const [experience, experienceLiveData] = experiencePath
|
|
401
|
+
? await Promise.all([
|
|
402
|
+
safeNamespaceCall('experiences', 'get', experiencePath, undefined, {
|
|
403
|
+
include: ['goals', 'stats', 'variations', 'variations.changes', 'audiences', 'locations', 'alerts'],
|
|
404
|
+
expand: ['goals', 'variations', 'variations.changes', 'audiences', 'locations'],
|
|
405
|
+
}),
|
|
406
|
+
safeNamespaceCall('experiences', 'get_live_data', experiencePath, liveDataBody),
|
|
407
|
+
])
|
|
408
|
+
: [null, null];
|
|
409
|
+
await report(context, 'Summarizing implementation health', 3, 4);
|
|
410
|
+
const domainItems = collectionFrom(domains.data);
|
|
411
|
+
const projectEvents = collectionFrom(projectLiveData.data);
|
|
412
|
+
const experienceEvents = experienceLiveData ? collectionFrom(experienceLiveData.data) : [];
|
|
413
|
+
const issueList = implementationIssues({
|
|
414
|
+
project,
|
|
415
|
+
domains,
|
|
416
|
+
projectLiveData,
|
|
417
|
+
projectData,
|
|
418
|
+
projectType,
|
|
419
|
+
domainItems,
|
|
420
|
+
projectEvents,
|
|
421
|
+
experience,
|
|
422
|
+
experienceEvents,
|
|
423
|
+
sdkKeys,
|
|
424
|
+
});
|
|
425
|
+
return {
|
|
426
|
+
project: compactObject({
|
|
427
|
+
id: projectData.id,
|
|
428
|
+
name: projectData.name,
|
|
429
|
+
project_type: projectType,
|
|
430
|
+
status: projectData.status,
|
|
431
|
+
snippet: summarizeTrackingSnippet(projectData),
|
|
432
|
+
}),
|
|
433
|
+
convert_side_evidence: {
|
|
434
|
+
domains: domainItems.map((domain) => compactObject({
|
|
435
|
+
id: domain.id,
|
|
436
|
+
url: domain.url,
|
|
437
|
+
status: domain.status,
|
|
438
|
+
code_installed: domain.code_installed,
|
|
439
|
+
})),
|
|
440
|
+
project_live_data: summarizeLiveData(projectEvents),
|
|
441
|
+
experience_live_data: experienceLiveData ? summarizeLiveData(experienceEvents) : null,
|
|
442
|
+
sdk_keys: sdkKeys ? summarizeCollectionCall(sdkKeys) : null,
|
|
443
|
+
features: features ? summarizeCollectionCall(features) : null,
|
|
444
|
+
requested_experience: experience ? summarizeExperience(objectFrom(experience.data)) : null,
|
|
445
|
+
},
|
|
446
|
+
required_external_checks: implementationExternalChecks(projectType, stringFrom(args.body.url ?? args.query.url, '')),
|
|
447
|
+
issues: issueList,
|
|
448
|
+
next_steps: implementationNextSteps(projectType, issueList.length > 0),
|
|
449
|
+
limitations: [
|
|
450
|
+
'Convert MCP can verify Convert-side configuration and live-data evidence, but it does not inspect the user browser DOM or local source tree by itself.',
|
|
451
|
+
'Use browser tooling for snippet/window/network checks and filesystem tooling for SDK dependency/startup checks.',
|
|
452
|
+
'A deterministic bucketing trace requires a Convert rule-engine trace endpoint; do not fake exact exclusion reasons from static config alone.',
|
|
453
|
+
],
|
|
454
|
+
warnings: warningsFromCalls([
|
|
455
|
+
project,
|
|
456
|
+
domains,
|
|
457
|
+
projectLiveData,
|
|
458
|
+
...(experience ? [experience] : []),
|
|
459
|
+
...(experienceLiveData ? [experienceLiveData] : []),
|
|
460
|
+
...(sdkKeys ? [sdkKeys] : []),
|
|
461
|
+
...(features ? [features] : []),
|
|
462
|
+
]),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
async function failedTestAnalysis(args, context) {
|
|
466
|
+
await report(context, 'Fetching completed and paused experiments', 1, 3);
|
|
467
|
+
const experiences = collectionFrom((await listExperiences(basePath(args), { status: ['completed', 'paused'] })).data);
|
|
468
|
+
const summaries = summarizeExperiences(experiences);
|
|
469
|
+
const losses = summaries.filter((item) => item.outcome === 'negative' || item.outcome === 'inconclusive_or_unknown');
|
|
470
|
+
return {
|
|
471
|
+
total_reviewed: summaries.length,
|
|
472
|
+
outcome_counts: countValues(summaries.map((item) => item.outcome)),
|
|
473
|
+
possible_failures: losses,
|
|
474
|
+
retest_candidates: losses.filter((item) => item.duration_days === null || item.duration_days < 14),
|
|
475
|
+
guidance: 'Treat unknown outcomes as candidates for report review, not confirmed losses. Use aggregated_report for final statistical decisions.',
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
async function hypothesisQualityAudit(args, context) {
|
|
479
|
+
await report(context, 'Fetching hypotheses and experiments', 1, 3);
|
|
480
|
+
const path = basePath(args);
|
|
481
|
+
const [hypotheses, experiences] = await Promise.all([
|
|
482
|
+
listResource('hypotheses', path),
|
|
483
|
+
listExperiences(path, portfolioFilters(args)),
|
|
484
|
+
]);
|
|
485
|
+
const experienceItems = collectionFrom(experiences.data);
|
|
486
|
+
const hypothesisItems = collectionFrom(hypotheses.data);
|
|
487
|
+
const audited = hypothesisItems.map((hypothesis) => {
|
|
488
|
+
const text = [hypothesis.name, hypothesis.objective, hypothesis.description, hypothesis.summary].filter(Boolean).join(' ');
|
|
489
|
+
return {
|
|
490
|
+
id: hypothesis.id,
|
|
491
|
+
name: hypothesis.name,
|
|
492
|
+
status: hypothesis.status,
|
|
493
|
+
score: hypothesisQualityScore(text),
|
|
494
|
+
linked_experiences: Array.isArray(hypothesis.experiences) ? hypothesis.experiences : [],
|
|
495
|
+
notes: hypothesisQualityNotes(text),
|
|
496
|
+
};
|
|
497
|
+
});
|
|
498
|
+
return {
|
|
499
|
+
total_hypotheses: audited.length,
|
|
500
|
+
average_quality_score: average(audited.map((item) => item.score)),
|
|
501
|
+
quality_distribution: countValues(audited.map((item) => item.score >= 4 ? 'strong' : item.score >= 2 ? 'usable' : 'weak')),
|
|
502
|
+
hypotheses: audited,
|
|
503
|
+
experience_outcomes_by_status: countBy(experienceItems, 'status'),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
async function audienceSegmentComparison(args, context) {
|
|
507
|
+
await report(context, 'Fetching base experience', 1, 4);
|
|
508
|
+
const path = basePath(args, ['experience_id']);
|
|
509
|
+
const experience = await namespaceCall('experiences', 'get', path, undefined, {
|
|
510
|
+
include: ['goals', 'stats', 'variations', 'audiences', 'locations'],
|
|
511
|
+
expand: ['goals', 'variations', 'audiences', 'locations'],
|
|
512
|
+
});
|
|
513
|
+
const segmentSets = normalizeSegmentSets(args.body.segment_sets ?? args.body.segments);
|
|
514
|
+
await report(context, 'Fetching segment reports', 2, 4);
|
|
515
|
+
const reports = [];
|
|
516
|
+
const warnings = [];
|
|
517
|
+
for (const segmentSet of segmentSets) {
|
|
518
|
+
const report = await safeNamespaceCall('experiences', 'aggregated_report', path, {
|
|
519
|
+
...reportBodyFrom(args),
|
|
520
|
+
segments: segmentSet.segments,
|
|
521
|
+
});
|
|
522
|
+
if (report.ok) {
|
|
523
|
+
reports.push({ label: segmentSet.label, segments: segmentSet.segments, report: report.data });
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
warnings.push(`${segmentSet.label}: ${String(report.error ?? report.text ?? 'report failed')}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
experience: summarizeExperience(objectFrom(experience.data)),
|
|
531
|
+
segment_reports: reports,
|
|
532
|
+
warnings,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
async function cleanupCandidates(args, context) {
|
|
536
|
+
await report(context, 'Fetching cleanup candidates', 1, 4);
|
|
537
|
+
const path = basePath(args);
|
|
538
|
+
const staleDraftDays = numberFrom(args.body.stale_draft_days ?? args.query.stale_draft_days, 30);
|
|
539
|
+
const [drafts, goals, audiences, locations, features] = await Promise.all([
|
|
540
|
+
listExperiences(path, { status: ['draft'] }),
|
|
541
|
+
listResource('goals', path, { usage: 'not_used', status: ['active'] }),
|
|
542
|
+
listResource('audiences', path, { usage: 'not_used', status: ['active'] }),
|
|
543
|
+
listResource('locations', path, { usage: 'not_used', status: ['active'] }),
|
|
544
|
+
listResource('features', path),
|
|
545
|
+
]);
|
|
546
|
+
const draftItems = collectionFrom(drafts.data);
|
|
547
|
+
const staleCutoff = Date.now() / 1000 - staleDraftDays * 24 * 60 * 60;
|
|
548
|
+
const staleDrafts = draftItems
|
|
549
|
+
.filter((item) => typeof item.created_at !== 'number' || item.created_at < staleCutoff)
|
|
550
|
+
.map(summarizeExperience);
|
|
551
|
+
return {
|
|
552
|
+
stale_draft_days: staleDraftDays,
|
|
553
|
+
stale_drafts: staleDrafts,
|
|
554
|
+
unused_goals: collectionFrom(goals.data).map(compactEntity),
|
|
555
|
+
unused_audiences: collectionFrom(audiences.data).map(compactEntity),
|
|
556
|
+
unused_locations: collectionFrom(locations.data).map(compactEntity),
|
|
557
|
+
feature_cleanup_guidance: features.ok
|
|
558
|
+
? collectionFrom(features.data).map((feature) => {
|
|
559
|
+
const compact = compactEntity(feature);
|
|
560
|
+
return {
|
|
561
|
+
...(isObject(compact) ? compact : { value: compact }),
|
|
562
|
+
sdk_search_patterns: feature.key ? [`${feature.key}`, `isFeatureEnabled("${feature.key}")`, `getFeature("${feature.key}")`] : [],
|
|
563
|
+
};
|
|
564
|
+
})
|
|
565
|
+
: [],
|
|
566
|
+
warnings: warningsFromCalls([drafts, goals, audiences, locations, features]),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
async function getDefaults(args, context) {
|
|
570
|
+
const refresh = args.body.refresh === true || args.query.refresh === true;
|
|
571
|
+
const cacheKey = `${String(args.path.account_id)}-${String(args.path.project_id)}`;
|
|
572
|
+
if (!refresh) {
|
|
573
|
+
const cached = await readDefaultsCache(cacheKey);
|
|
574
|
+
if (cached) {
|
|
575
|
+
return { ...cached, cached: true };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
await report(context, 'Inferring project defaults', 1, 4);
|
|
579
|
+
const path = basePath(args);
|
|
580
|
+
const [project, experiences, goals, audiences, locations] = await Promise.all([
|
|
581
|
+
safeNamespaceCall('projects', 'get', path, undefined, { include: ['domains', 'default_goals', 'default_audiences', 'default_locations'] }),
|
|
582
|
+
listExperiences(path),
|
|
583
|
+
listResource('goals', path, { status: ['active'] }),
|
|
584
|
+
listResource('audiences', path, { status: ['active'] }),
|
|
585
|
+
listResource('locations', path, { status: ['active'] }),
|
|
586
|
+
]);
|
|
587
|
+
const projectData = objectFrom(project.data);
|
|
588
|
+
const experienceItems = collectionFrom(experiences.data);
|
|
589
|
+
const inferred = {
|
|
590
|
+
account_id: args.path.account_id,
|
|
591
|
+
project_id: args.path.project_id,
|
|
592
|
+
inferred_at: new Date().toISOString(),
|
|
593
|
+
expires_at: new Date(Date.now() + DEFAULT_CACHE_TTL_MS).toISOString(),
|
|
594
|
+
default_goal: firstEntity(projectData.default_goals) ?? mostFrequentEntity(experienceItems, 'primary_goal') ?? firstEntity(collectionFrom(goals.data)),
|
|
595
|
+
default_audience: firstEntity(projectData.default_audiences) ?? firstEntity(collectionFrom(audiences.data)),
|
|
596
|
+
default_location: firstEntity(projectData.default_locations) ?? firstEntity(collectionFrom(locations.data)),
|
|
597
|
+
default_url: mostFrequentString(experienceItems.map((item) => item.url)) ?? firstDomainUrl(projectData) ?? 'https://example.com',
|
|
598
|
+
traffic_split: { control: 50, treatment: 50 },
|
|
599
|
+
recent_name_patterns: experienceItems.map((item) => String(item.name ?? '')).filter(Boolean).slice(0, 10),
|
|
600
|
+
source_counts: {
|
|
601
|
+
experiences: experienceItems.length,
|
|
602
|
+
goals: collectionFrom(goals.data).length,
|
|
603
|
+
audiences: collectionFrom(audiences.data).length,
|
|
604
|
+
locations: collectionFrom(locations.data).length,
|
|
605
|
+
},
|
|
606
|
+
warnings: warningsFromCalls([project, experiences, goals, audiences, locations]),
|
|
607
|
+
};
|
|
608
|
+
await writeDefaultsCache(cacheKey, inferred);
|
|
609
|
+
return { ...inferred, cached: false };
|
|
610
|
+
}
|
|
611
|
+
async function createCustomCodeAbTest(args, context) {
|
|
612
|
+
await report(context, 'Creating draft custom-code A/B test', 1, 3);
|
|
613
|
+
const body = buildCustomCodeExperienceBody(args.body);
|
|
614
|
+
const create = await namespaceCall('experiences', 'create', basePath(args), body, {
|
|
615
|
+
include: ['variations', 'variations.changes', 'goals', 'audiences', 'locations'],
|
|
616
|
+
expand: ['variations', 'variations.changes', 'goals', 'audiences', 'locations'],
|
|
617
|
+
}, args.confirmation);
|
|
618
|
+
const experienceId = extractId(create.data);
|
|
619
|
+
const readback = experienceId
|
|
620
|
+
? await safeNamespaceCall('experiences', 'get', basePath(args, [], { experience_id: experienceId }), undefined, {
|
|
621
|
+
include: ['variations', 'variations.changes', 'goals', 'audiences', 'locations'],
|
|
622
|
+
expand: ['variations', 'variations.changes', 'goals', 'audiences', 'locations'],
|
|
623
|
+
})
|
|
624
|
+
: null;
|
|
625
|
+
return {
|
|
626
|
+
created: create.data,
|
|
627
|
+
readback: readback?.data ?? null,
|
|
628
|
+
verified: Boolean(readback?.ok),
|
|
629
|
+
verification_detail: readback?.ok ? 'Created experience was read back successfully.' : 'Create call succeeded but no readback ID was available.',
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
async function createSplitUrlTest(args, context) {
|
|
633
|
+
await report(context, 'Creating draft split URL test', 1, 3);
|
|
634
|
+
const body = buildSplitUrlExperienceBody(args.body);
|
|
635
|
+
const create = await namespaceCall('experiences', 'create', basePath(args), body, {
|
|
636
|
+
include: ['variations', 'variations.changes', 'goals', 'audiences', 'locations'],
|
|
637
|
+
expand: ['variations', 'variations.changes', 'goals', 'audiences', 'locations'],
|
|
638
|
+
}, args.confirmation);
|
|
639
|
+
const experienceId = extractId(create.data);
|
|
640
|
+
const readback = experienceId
|
|
641
|
+
? await safeNamespaceCall('experiences', 'get', basePath(args, [], { experience_id: experienceId }), undefined, {
|
|
642
|
+
include: ['variations', 'variations.changes', 'goals', 'audiences', 'locations'],
|
|
643
|
+
expand: ['variations', 'variations.changes', 'goals', 'audiences', 'locations'],
|
|
644
|
+
})
|
|
645
|
+
: null;
|
|
646
|
+
return {
|
|
647
|
+
created: create.data,
|
|
648
|
+
readback: readback?.data ?? null,
|
|
649
|
+
verified: Boolean(readback?.ok),
|
|
650
|
+
verification_detail: readback?.ok ? 'Created split URL experience was read back successfully.' : 'Create call succeeded but no readback ID was available.',
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
async function enableSplitUrlRegex(args, context) {
|
|
654
|
+
await report(context, 'Enabling split URL regex support', 1, 3);
|
|
655
|
+
const path = basePath(args, ['experience_id']);
|
|
656
|
+
const update = await namespaceCall('experiences', 'update', path, {
|
|
657
|
+
settings: {
|
|
658
|
+
split_url_settings: {
|
|
659
|
+
split_regex_support: args.body.split_regex_support ?? true,
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
}, undefined, args.confirmation);
|
|
663
|
+
const readback = await safeNamespaceCall('experiences', 'get', path, undefined, {
|
|
664
|
+
include: ['variations', 'variations.changes'],
|
|
665
|
+
expand: ['variations', 'variations.changes'],
|
|
666
|
+
});
|
|
667
|
+
const readbackData = objectFrom(readback.data);
|
|
668
|
+
const readbackValue = readbackData?.settings?.split_url_settings?.split_regex_support;
|
|
669
|
+
return {
|
|
670
|
+
updated: update.data,
|
|
671
|
+
readback: readback.data,
|
|
672
|
+
verified: readback.ok && (readbackValue === true || typeof readbackValue === 'undefined'),
|
|
673
|
+
verification_detail: readbackValue === true
|
|
674
|
+
? 'Readback confirmed settings.split_url_settings.split_regex_support=true.'
|
|
675
|
+
: 'Readback succeeded, but the API response did not expose split_regex_support.',
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
async function safeUpdateVariationCode(args, context) {
|
|
679
|
+
await report(context, 'Updating variation custom code', 1, 3);
|
|
680
|
+
const path = basePath(args, ['experience_id', 'variation_id']);
|
|
681
|
+
const js = stringFrom(args.body.js, '');
|
|
682
|
+
const css = stringFrom(args.body.css, '');
|
|
683
|
+
const update = await namespaceCall('experiences', 'update_variation', path, {
|
|
684
|
+
changes: [
|
|
685
|
+
{
|
|
686
|
+
type: 'customCode',
|
|
687
|
+
data: { js, css },
|
|
688
|
+
},
|
|
689
|
+
],
|
|
690
|
+
}, undefined, args.confirmation);
|
|
691
|
+
const readback = await safeNamespaceCall('experiences', 'get', basePath(args, ['experience_id']), undefined, {
|
|
692
|
+
include: ['variations', 'variations.changes'],
|
|
693
|
+
expand: ['variations', 'variations.changes'],
|
|
694
|
+
});
|
|
695
|
+
return {
|
|
696
|
+
updated: update.data,
|
|
697
|
+
readback: readback.data,
|
|
698
|
+
verified: readback.ok && containsCustomCode(readback.data, args.path.variation_id, js, css),
|
|
699
|
+
verification_detail: readback.ok
|
|
700
|
+
? 'Experience readback completed after variation update.'
|
|
701
|
+
: 'Variation update succeeded but readback failed.',
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
function buildCustomCodeExperienceBody(input) {
|
|
705
|
+
const name = stringFrom(input.name, 'Draft Custom Code A/B Test');
|
|
706
|
+
const url = stringFrom(input.url, 'https://example.com');
|
|
707
|
+
const js = stringFrom(input.js, '');
|
|
708
|
+
const css = stringFrom(input.css, '');
|
|
709
|
+
const controlName = stringFrom(input.control_name, 'control');
|
|
710
|
+
const variationName = stringFrom(input.variation_name, 'Treatment');
|
|
711
|
+
const rawVariations = Array.isArray(input.variations) ? input.variations : null;
|
|
712
|
+
return compactObject({
|
|
713
|
+
name,
|
|
714
|
+
type: 'a/b',
|
|
715
|
+
status: 'draft',
|
|
716
|
+
url,
|
|
717
|
+
description: input.description,
|
|
718
|
+
objective: input.objective,
|
|
719
|
+
primary_goal: input.primary_goal,
|
|
720
|
+
goals: input.goals,
|
|
721
|
+
audiences: input.audiences,
|
|
722
|
+
locations: input.locations,
|
|
723
|
+
settings: input.settings,
|
|
724
|
+
variations: rawVariations ?? [
|
|
725
|
+
{ name: controlName, changes: [{ type: 'customCode', data: { js: '', css: '' } }] },
|
|
726
|
+
{ name: variationName, changes: [{ type: 'customCode', data: { js, css } }] },
|
|
727
|
+
],
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
function buildSplitUrlExperienceBody(input) {
|
|
731
|
+
const name = stringFrom(input.name, 'Draft Split URL Test');
|
|
732
|
+
const originalPattern = stringFrom(input.original_pattern ?? input.url, 'https://example.com');
|
|
733
|
+
const variationPattern = stringFrom(input.variation_pattern ?? input.redirect_url, 'https://example.com/variant');
|
|
734
|
+
const url = stringFrom(input.url, originalPattern);
|
|
735
|
+
const splitRegexSupport = input.split_regex_support === true;
|
|
736
|
+
return compactObject({
|
|
737
|
+
name,
|
|
738
|
+
type: 'split_url',
|
|
739
|
+
status: 'draft',
|
|
740
|
+
url,
|
|
741
|
+
description: input.description,
|
|
742
|
+
objective: input.objective,
|
|
743
|
+
primary_goal: input.primary_goal,
|
|
744
|
+
goals: input.goals,
|
|
745
|
+
audiences: input.audiences,
|
|
746
|
+
locations: input.locations,
|
|
747
|
+
settings: splitRegexSupport ? { split_url_settings: { split_regex_support: true } } : input.settings,
|
|
748
|
+
variations: Array.isArray(input.variations) ? input.variations : [
|
|
749
|
+
{
|
|
750
|
+
name: stringFrom(input.variation_name, 'Variation URL'),
|
|
751
|
+
changes: [
|
|
752
|
+
{
|
|
753
|
+
type: 'defaultRedirect',
|
|
754
|
+
data: {
|
|
755
|
+
original_pattern: originalPattern,
|
|
756
|
+
variation_pattern: variationPattern,
|
|
757
|
+
case_sensitive: input.case_sensitive === true,
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
],
|
|
761
|
+
},
|
|
762
|
+
],
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
async function listExperiences(path, filters = {}) {
|
|
766
|
+
return safeNamespaceCall('experiences', 'list', path, {
|
|
767
|
+
results_per_page: DEFAULT_RESULTS_PER_PAGE,
|
|
768
|
+
sort_by: 'id',
|
|
769
|
+
sort_direction: 'desc',
|
|
770
|
+
include: ['goals', 'stats', 'variations', 'variations.changes', 'audiences', 'locations', 'alerts'],
|
|
771
|
+
expand: ['goals', 'variations', 'variations.changes', 'audiences', 'locations', 'tags'],
|
|
772
|
+
...filters,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
async function listResource(namespace, path, body = {}) {
|
|
776
|
+
return safeNamespaceCall(namespace, 'list', path, {
|
|
777
|
+
results_per_page: DEFAULT_RESULTS_PER_PAGE,
|
|
778
|
+
sort_by: body.sort_by ?? 'id',
|
|
779
|
+
sort_direction: body.sort_direction ?? 'desc',
|
|
780
|
+
...body,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
async function namespaceCall(namespace, action, path, body, query, confirmation) {
|
|
784
|
+
const result = await executeNamespacedTool(namespace, {
|
|
785
|
+
action,
|
|
786
|
+
path,
|
|
787
|
+
...(query ? { query } : {}),
|
|
788
|
+
...(typeof body !== 'undefined' ? { body } : {}),
|
|
789
|
+
...(typeof confirmation !== 'undefined' ? { confirmation } : {}),
|
|
790
|
+
});
|
|
791
|
+
const structured = isObject(result.structuredContent) ? result.structuredContent : {};
|
|
792
|
+
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : undefined;
|
|
793
|
+
if (result.isError || structured.ok === false) {
|
|
794
|
+
return {
|
|
795
|
+
ok: false,
|
|
796
|
+
namespace,
|
|
797
|
+
action,
|
|
798
|
+
status: typeof structured.status === 'number' ? structured.status : undefined,
|
|
799
|
+
error: structured.error ?? text ?? 'Unknown error',
|
|
800
|
+
text,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
ok: true,
|
|
805
|
+
namespace,
|
|
806
|
+
action,
|
|
807
|
+
status: typeof structured.status === 'number' ? structured.status : undefined,
|
|
808
|
+
data: structured.data,
|
|
809
|
+
text,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
async function safeNamespaceCall(namespace, action, path, body, query) {
|
|
813
|
+
try {
|
|
814
|
+
return await namespaceCall(namespace, action, path, body, query);
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
return {
|
|
818
|
+
ok: false,
|
|
819
|
+
namespace,
|
|
820
|
+
action,
|
|
821
|
+
error: error instanceof Error ? error.message : String(error),
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
function portfolioFilters(args) {
|
|
826
|
+
const out = {};
|
|
827
|
+
for (const key of ['status', 'type', 'tags', 'audiences', 'goals', 'locations', 'features', 'hypotheses']) {
|
|
828
|
+
const value = args.body[key] ?? args.query[key];
|
|
829
|
+
if (typeof value !== 'undefined')
|
|
830
|
+
out[key] = value;
|
|
831
|
+
}
|
|
832
|
+
return out;
|
|
833
|
+
}
|
|
834
|
+
function basePath(args, extra = [], override = {}) {
|
|
835
|
+
const path = {
|
|
836
|
+
account_id: coerceNumberIfNumeric(args.path.account_id),
|
|
837
|
+
project_id: coerceNumberIfNumeric(args.path.project_id),
|
|
838
|
+
...override,
|
|
839
|
+
};
|
|
840
|
+
for (const key of extra) {
|
|
841
|
+
path[key] = coerceNumberIfNumeric(args.path[key]);
|
|
842
|
+
}
|
|
843
|
+
return path;
|
|
844
|
+
}
|
|
845
|
+
function summarizeExperiences(experiences) {
|
|
846
|
+
return experiences.map(summarizeExperience);
|
|
847
|
+
}
|
|
848
|
+
function summarizeExperience(experience) {
|
|
849
|
+
const start = numberOrNull(experience.start_time ?? experience.min_running_timestamp);
|
|
850
|
+
const end = numberOrNull(experience.end_time ?? experience.max_running_timestamp);
|
|
851
|
+
const durationDays = daysBetween(start, end ?? Math.floor(Date.now() / 1000));
|
|
852
|
+
return {
|
|
853
|
+
id: experience.id,
|
|
854
|
+
key: experience.key,
|
|
855
|
+
name: experience.name,
|
|
856
|
+
type: experience.type,
|
|
857
|
+
status: experience.status,
|
|
858
|
+
url: experience.url,
|
|
859
|
+
primary_goal: compactEntity(experience.primary_goal),
|
|
860
|
+
goals: collectionFrom(experience.goals).map(compactEntity),
|
|
861
|
+
audiences: collectionFrom(experience.audiences).map(compactEntity),
|
|
862
|
+
locations: collectionFrom(experience.locations).map(compactEntity),
|
|
863
|
+
variations: collectionFrom(experience.variations).map((variation) => ({
|
|
864
|
+
id: variation.id,
|
|
865
|
+
name: variation.name,
|
|
866
|
+
status: variation.status,
|
|
867
|
+
traffic_distribution: variation.traffic_distribution ?? variation.traffic_percentage,
|
|
868
|
+
changes_count: collectionFrom(variation.changes).length,
|
|
869
|
+
})),
|
|
870
|
+
start_time: start,
|
|
871
|
+
end_time: end,
|
|
872
|
+
duration_days: isFiniteNumber(durationDays) ? durationDays : null,
|
|
873
|
+
outcome: inferOutcome(experience),
|
|
874
|
+
alerts: collectionFrom(experience.alerts),
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
function draftIssues(experience) {
|
|
878
|
+
const issues = [];
|
|
879
|
+
if (!experience.url)
|
|
880
|
+
issues.push('Missing URL.');
|
|
881
|
+
if (collectionFrom(experience.variations).length < 2)
|
|
882
|
+
issues.push('Fewer than two variations.');
|
|
883
|
+
if (collectionFrom(experience.goals).length === 0 && !experience.primary_goal)
|
|
884
|
+
issues.push('No goals or primary goal returned.');
|
|
885
|
+
if (collectionFrom(experience.locations).length === 0)
|
|
886
|
+
issues.push('No locations returned.');
|
|
887
|
+
if (collectionFrom(experience.audiences).length === 0)
|
|
888
|
+
issues.push('No audiences returned.');
|
|
889
|
+
if (collectionFrom(experience.alerts).length > 0)
|
|
890
|
+
issues.push('Experience has alerts.');
|
|
891
|
+
return issues;
|
|
892
|
+
}
|
|
893
|
+
function activeIssues(experience, thresholdDays) {
|
|
894
|
+
const summary = summarizeExperience(experience);
|
|
895
|
+
const issues = [];
|
|
896
|
+
if (typeof summary.duration_days === 'number' && summary.duration_days > thresholdDays) {
|
|
897
|
+
issues.push(`Running longer than ${thresholdDays} days.`);
|
|
898
|
+
}
|
|
899
|
+
issues.push(...draftIssues(experience).filter((issue) => issue !== 'No audiences returned.' && issue !== 'No locations returned.'));
|
|
900
|
+
const traffic = collectionFrom(experience.variations)
|
|
901
|
+
.map((variation) => numberOrNull(variation.traffic_distribution ?? variation.traffic_percentage))
|
|
902
|
+
.filter(isFiniteNumber);
|
|
903
|
+
if (traffic.length > 1) {
|
|
904
|
+
const expected = 100 / traffic.length;
|
|
905
|
+
if (traffic.some((value) => Math.abs(value - expected) > 5)) {
|
|
906
|
+
issues.push('Variation traffic allocation appears uneven.');
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return issues;
|
|
910
|
+
}
|
|
911
|
+
function normalizeEventTypes(input) {
|
|
912
|
+
if (Array.isArray(input)) {
|
|
913
|
+
const values = input.filter((value) => typeof value === 'string' && value.trim().length > 0);
|
|
914
|
+
return values.length > 0 ? values : ['view_experience', 'conversion', 'transaction'];
|
|
915
|
+
}
|
|
916
|
+
return ['view_experience', 'conversion', 'transaction'];
|
|
917
|
+
}
|
|
918
|
+
function inferProjectType(project) {
|
|
919
|
+
const explicit = String(project.project_type ?? '').toLowerCase();
|
|
920
|
+
if (explicit.includes('full'))
|
|
921
|
+
return 'fullstack';
|
|
922
|
+
if (explicit.includes('web'))
|
|
923
|
+
return 'web';
|
|
924
|
+
return typeof project.sdk_key === 'string' && project.sdk_key ? 'fullstack' : 'web';
|
|
925
|
+
}
|
|
926
|
+
function summarizeTrackingSnippet(project) {
|
|
927
|
+
const snippet = typeof project.tracking_snippet === 'string' ? project.tracking_snippet : '';
|
|
928
|
+
const srcs = [...snippet.matchAll(/src=["']([^"']+)["']/gi)].map((match) => match[1]).filter(Boolean);
|
|
929
|
+
return {
|
|
930
|
+
configured: snippet.trim().length > 0,
|
|
931
|
+
contains_convert_source: /convert\.com|cdn\.convert/i.test(snippet),
|
|
932
|
+
script_sources: srcs,
|
|
933
|
+
length: snippet.length,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
function summarizeLiveData(events) {
|
|
937
|
+
const timestamps = events.map(liveEventTimestamp).filter(isFiniteNumber);
|
|
938
|
+
const newest = timestamps.length > 0 ? Math.max(...timestamps) : null;
|
|
939
|
+
return {
|
|
940
|
+
events_returned: events.length,
|
|
941
|
+
events_by_type: countValues(events.map(liveEventType)),
|
|
942
|
+
latest_timestamp: newest,
|
|
943
|
+
latest_iso: newest ? unixToIso(newest) : null,
|
|
944
|
+
sample: events.slice(0, 5).map((event) => compactObject({
|
|
945
|
+
event_type: liveEventType(event),
|
|
946
|
+
timestamp: liveEventTimestamp(event),
|
|
947
|
+
experience: compactEntity(event.experience),
|
|
948
|
+
variation: compactEntity(event.variation ?? event.experiences?.variation),
|
|
949
|
+
goal: compactEntity(event.goal ?? event.conversion?.goal),
|
|
950
|
+
project: compactEntity(event.project),
|
|
951
|
+
})),
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
function summarizeCollectionCall(call) {
|
|
955
|
+
const items = collectionFrom(call.data);
|
|
956
|
+
return {
|
|
957
|
+
ok: call.ok,
|
|
958
|
+
count: items.length,
|
|
959
|
+
sample: items.slice(0, 10).map(compactEntity),
|
|
960
|
+
...(call.ok ? {} : { error: call.error ?? call.text ?? 'failed' }),
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
function implementationExternalChecks(projectType, url) {
|
|
964
|
+
if (projectType === 'fullstack') {
|
|
965
|
+
return {
|
|
966
|
+
local_code_checks: [
|
|
967
|
+
'Inspect package dependencies for the relevant Convert Full Stack SDK.',
|
|
968
|
+
'Verify SDK initialization runs once during startup and waits for configuration readiness before bucketing.',
|
|
969
|
+
'Verify bucketing calls pass the intended visitor_id and audience attributes.',
|
|
970
|
+
'Verify conversion tracking calls use the intended goal keys or IDs after the business event completes.',
|
|
971
|
+
],
|
|
972
|
+
browser_checks: [],
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
return {
|
|
976
|
+
browser_checks: [
|
|
977
|
+
`Load ${url || 'the target page'} with browser automation and confirm the Convert script source from project.tracking_snippet appears in the document head.`,
|
|
978
|
+
'Check window.convert readiness after load and inspect console/network errors for blocked or failed Convert resources.',
|
|
979
|
+
'For a specific experience, force or preview the variation and verify the intended DOM/CSS changes apply after dynamic page content has loaded.',
|
|
980
|
+
'Trigger the relevant goal action and re-check Convert live data for view_experience/conversion events.',
|
|
981
|
+
],
|
|
982
|
+
local_code_checks: [],
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
function implementationNextSteps(projectType, hasIssues) {
|
|
986
|
+
const shared = [
|
|
987
|
+
'Use search/fetch for the latest Convert implementation docs before generating code.',
|
|
988
|
+
'After changing setup, re-run operator.implementation_health and compare live-data evidence.',
|
|
989
|
+
];
|
|
990
|
+
if (projectType === 'fullstack') {
|
|
991
|
+
return [
|
|
992
|
+
...(hasIssues ? ['Fix missing SDK/live-data evidence before debugging experiment logic.'] : []),
|
|
993
|
+
'Use filesystem tooling to inspect SDK dependency, initialization, visitor attributes, and track calls.',
|
|
994
|
+
'If the user needs exact "why not bucketed" output, request or use a backend rule-engine trace endpoint.',
|
|
995
|
+
...shared,
|
|
996
|
+
];
|
|
997
|
+
}
|
|
998
|
+
return [
|
|
999
|
+
...(hasIssues ? ['Fix snippet/domain/live-data evidence before deeper visual QA.'] : []),
|
|
1000
|
+
'Use browser tooling for snippet-in-head, window.convert, network, console, preview/forced variation, and DOM assertions.',
|
|
1001
|
+
'Prefer programmatic/callback triggers when dynamic content loads after the standard page trigger.',
|
|
1002
|
+
...shared,
|
|
1003
|
+
];
|
|
1004
|
+
}
|
|
1005
|
+
function implementationIssues(input) {
|
|
1006
|
+
const issues = [];
|
|
1007
|
+
if (!input.project.ok)
|
|
1008
|
+
issues.push('Could not fetch project configuration.');
|
|
1009
|
+
if (!input.domains.ok)
|
|
1010
|
+
issues.push('Could not fetch project domains.');
|
|
1011
|
+
if (!input.projectLiveData.ok)
|
|
1012
|
+
issues.push('Could not fetch project live data.');
|
|
1013
|
+
if (input.projectLiveData.ok && input.projectEvents.length === 0) {
|
|
1014
|
+
issues.push('No project live-data events returned; verify traffic, environment, snippet/SDK installation, and ad/script blockers.');
|
|
1015
|
+
}
|
|
1016
|
+
if (input.projectType === 'web') {
|
|
1017
|
+
const snippet = summarizeTrackingSnippet(input.projectData);
|
|
1018
|
+
if (snippet.configured !== true) {
|
|
1019
|
+
issues.push('Project response did not expose a tracking_snippet; browser snippet checks need the project script source from Convert.');
|
|
1020
|
+
}
|
|
1021
|
+
if (input.domainItems.length > 0 && !input.domainItems.some((domain) => domain.code_installed === true)) {
|
|
1022
|
+
issues.push('No returned domain has code_installed=true.');
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
else if (input.sdkKeys?.ok && collectionFrom(input.sdkKeys.data).length === 0) {
|
|
1026
|
+
issues.push('No SDK keys returned for this fullstack project.');
|
|
1027
|
+
}
|
|
1028
|
+
if (input.experience && !input.experience.ok) {
|
|
1029
|
+
issues.push('Could not fetch requested experience configuration.');
|
|
1030
|
+
}
|
|
1031
|
+
if (input.experience?.ok && input.experienceEvents.length === 0) {
|
|
1032
|
+
issues.push('No live-data events returned for the requested experience.');
|
|
1033
|
+
}
|
|
1034
|
+
return issues;
|
|
1035
|
+
}
|
|
1036
|
+
function liveEventType(event) {
|
|
1037
|
+
return String(event.event_type ?? event.eventType ?? event.event ?? event.type ?? 'unknown');
|
|
1038
|
+
}
|
|
1039
|
+
function liveEventTimestamp(event) {
|
|
1040
|
+
return numberOrNull(event.timestamp ?? event.created_at ?? event.time ?? event.received_at);
|
|
1041
|
+
}
|
|
1042
|
+
function unixToIso(timestamp) {
|
|
1043
|
+
const millis = timestamp > 1000000000000 ? timestamp : timestamp * 1000;
|
|
1044
|
+
return new Date(millis).toISOString();
|
|
1045
|
+
}
|
|
1046
|
+
function inferOutcome(experience) {
|
|
1047
|
+
const candidates = [
|
|
1048
|
+
experience.outcome,
|
|
1049
|
+
experience.result,
|
|
1050
|
+
experience.winner,
|
|
1051
|
+
experience.stats?.outcome,
|
|
1052
|
+
experience.stats?.result,
|
|
1053
|
+
].filter(Boolean).map(String).join(' ').toLowerCase();
|
|
1054
|
+
if (/(winner|won|positive|significant|lift)/.test(candidates) && !/(loss|negative)/.test(candidates))
|
|
1055
|
+
return 'positive';
|
|
1056
|
+
if (/(loser|lost|negative|harm)/.test(candidates))
|
|
1057
|
+
return 'negative';
|
|
1058
|
+
if (String(experience.status ?? '').toLowerCase() === 'completed')
|
|
1059
|
+
return 'completed_unknown';
|
|
1060
|
+
return 'inconclusive_or_unknown';
|
|
1061
|
+
}
|
|
1062
|
+
function normalizeSegmentSets(input) {
|
|
1063
|
+
if (Array.isArray(input)) {
|
|
1064
|
+
return input
|
|
1065
|
+
.filter(isObject)
|
|
1066
|
+
.map((item, index) => ({
|
|
1067
|
+
label: stringFrom(item.label, `segment_${index + 1}`),
|
|
1068
|
+
segments: isObject(item.segments) ? item.segments : item,
|
|
1069
|
+
}));
|
|
1070
|
+
}
|
|
1071
|
+
if (isObject(input)) {
|
|
1072
|
+
return [{ label: 'custom', segments: input }];
|
|
1073
|
+
}
|
|
1074
|
+
return [
|
|
1075
|
+
{ label: 'desktop', segments: { devices: ['desktop'] } },
|
|
1076
|
+
{ label: 'phones', segments: { devices: ['all_phones'] } },
|
|
1077
|
+
{ label: 'tablets', segments: { devices: ['all_tablets'] } },
|
|
1078
|
+
];
|
|
1079
|
+
}
|
|
1080
|
+
function reportBodyFrom(args) {
|
|
1081
|
+
const body = {};
|
|
1082
|
+
for (const key of ['goals', 'metrics', 'start_time', 'end_time', 'utc_offset', 'stats_engine_processing']) {
|
|
1083
|
+
if (typeof args.body[key] !== 'undefined')
|
|
1084
|
+
body[key] = args.body[key];
|
|
1085
|
+
}
|
|
1086
|
+
return body;
|
|
1087
|
+
}
|
|
1088
|
+
function hypothesisQualityScore(text) {
|
|
1089
|
+
let score = 0;
|
|
1090
|
+
if (text.length > 80)
|
|
1091
|
+
score += 1;
|
|
1092
|
+
if (/\b(because|due to|data|observed|evidence|research)\b/i.test(text))
|
|
1093
|
+
score += 1;
|
|
1094
|
+
if (/\b(increase|decrease|improve|reduce|lift|conversion|revenue|click)\b/i.test(text))
|
|
1095
|
+
score += 1;
|
|
1096
|
+
if (/\b(by|from|to|percent|%|rate|rpv|aov)\b/i.test(text))
|
|
1097
|
+
score += 1;
|
|
1098
|
+
if (/\b(if|then|expect|hypothesis)\b/i.test(text))
|
|
1099
|
+
score += 1;
|
|
1100
|
+
return score;
|
|
1101
|
+
}
|
|
1102
|
+
function hypothesisQualityNotes(text) {
|
|
1103
|
+
const notes = [];
|
|
1104
|
+
if (text.length <= 80)
|
|
1105
|
+
notes.push('Hypothesis is short; may not capture problem, change, and expected impact.');
|
|
1106
|
+
if (!/\b(because|due to|data|observed|evidence|research)\b/i.test(text))
|
|
1107
|
+
notes.push('No clear evidence or rationale language found.');
|
|
1108
|
+
if (!/\b(increase|decrease|improve|reduce|lift|conversion|revenue|click)\b/i.test(text))
|
|
1109
|
+
notes.push('No explicit metric or behavior impact language found.');
|
|
1110
|
+
if (!/\b(if|then|expect|hypothesis)\b/i.test(text))
|
|
1111
|
+
notes.push('No explicit if/then or expectation structure found.');
|
|
1112
|
+
return notes;
|
|
1113
|
+
}
|
|
1114
|
+
function objectFrom(value) {
|
|
1115
|
+
if (Array.isArray(value))
|
|
1116
|
+
return isObject(value[0]) ? value[0] : {};
|
|
1117
|
+
if (!isObject(value))
|
|
1118
|
+
return {};
|
|
1119
|
+
if (isObject(value.data) && !Array.isArray(value.data))
|
|
1120
|
+
return value.data;
|
|
1121
|
+
return value;
|
|
1122
|
+
}
|
|
1123
|
+
function collectionFrom(value) {
|
|
1124
|
+
if (Array.isArray(value))
|
|
1125
|
+
return value.filter(isObject);
|
|
1126
|
+
if (!isObject(value))
|
|
1127
|
+
return [];
|
|
1128
|
+
if (Array.isArray(value.data))
|
|
1129
|
+
return value.data.filter(isObject);
|
|
1130
|
+
if (Array.isArray(value.items))
|
|
1131
|
+
return value.items.filter(isObject);
|
|
1132
|
+
if (Array.isArray(value.results))
|
|
1133
|
+
return value.results.filter(isObject);
|
|
1134
|
+
return [];
|
|
1135
|
+
}
|
|
1136
|
+
function compactEntity(value) {
|
|
1137
|
+
if (typeof value === 'number' || typeof value === 'string')
|
|
1138
|
+
return value;
|
|
1139
|
+
if (!isObject(value))
|
|
1140
|
+
return null;
|
|
1141
|
+
return compactObject({
|
|
1142
|
+
id: value.id,
|
|
1143
|
+
key: value.key,
|
|
1144
|
+
name: value.name,
|
|
1145
|
+
status: value.status,
|
|
1146
|
+
type: value.type ?? value.goal_type,
|
|
1147
|
+
url: value.url,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
function compactObject(value) {
|
|
1151
|
+
const out = {};
|
|
1152
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1153
|
+
if (typeof item !== 'undefined' && item !== null)
|
|
1154
|
+
out[key] = item;
|
|
1155
|
+
}
|
|
1156
|
+
return out;
|
|
1157
|
+
}
|
|
1158
|
+
function countBy(items, key) {
|
|
1159
|
+
return countValues(items.map((item) => item[key]));
|
|
1160
|
+
}
|
|
1161
|
+
function countValues(values) {
|
|
1162
|
+
const out = {};
|
|
1163
|
+
for (const value of values) {
|
|
1164
|
+
const key = String(value ?? 'unknown');
|
|
1165
|
+
out[key] = (out[key] ?? 0) + 1;
|
|
1166
|
+
}
|
|
1167
|
+
return out;
|
|
1168
|
+
}
|
|
1169
|
+
function warningsFromCalls(calls) {
|
|
1170
|
+
return calls
|
|
1171
|
+
.filter((call) => !call.ok)
|
|
1172
|
+
.map((call) => `${call.namespace}.${call.action}: ${String(call.error ?? call.text ?? 'failed')}`);
|
|
1173
|
+
}
|
|
1174
|
+
function daysBetween(start, end) {
|
|
1175
|
+
const startNum = numberOrNull(start);
|
|
1176
|
+
const endNum = numberOrNull(end);
|
|
1177
|
+
if (!isFiniteNumber(startNum) || !isFiniteNumber(endNum) || endNum < startNum)
|
|
1178
|
+
return null;
|
|
1179
|
+
return Math.round(((endNum - startNum) / 86400) * 10) / 10;
|
|
1180
|
+
}
|
|
1181
|
+
function average(values) {
|
|
1182
|
+
const finite = values.filter(isFiniteNumber);
|
|
1183
|
+
if (finite.length === 0)
|
|
1184
|
+
return null;
|
|
1185
|
+
return Math.round((finite.reduce((sum, value) => sum + value, 0) / finite.length) * 100) / 100;
|
|
1186
|
+
}
|
|
1187
|
+
function isFiniteNumber(value) {
|
|
1188
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
1189
|
+
}
|
|
1190
|
+
function numberOrNull(value) {
|
|
1191
|
+
const parsed = Number(value);
|
|
1192
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1193
|
+
}
|
|
1194
|
+
function numberFrom(value, fallback) {
|
|
1195
|
+
const parsed = Number(value);
|
|
1196
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
1197
|
+
}
|
|
1198
|
+
function stringFrom(value, fallback) {
|
|
1199
|
+
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
|
1200
|
+
}
|
|
1201
|
+
function coerceNumberIfNumeric(value) {
|
|
1202
|
+
if (typeof value !== 'string')
|
|
1203
|
+
return value;
|
|
1204
|
+
if (!/^\d+$/.test(value))
|
|
1205
|
+
return value;
|
|
1206
|
+
const parsed = Number(value);
|
|
1207
|
+
return Number.isSafeInteger(parsed) ? parsed : value;
|
|
1208
|
+
}
|
|
1209
|
+
function firstEntity(value) {
|
|
1210
|
+
const items = collectionFrom(value);
|
|
1211
|
+
if (items.length > 0)
|
|
1212
|
+
return compactEntity(items[0]);
|
|
1213
|
+
if (Array.isArray(value) && value.length > 0)
|
|
1214
|
+
return compactEntity(value[0]);
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
function mostFrequentEntity(items, key) {
|
|
1218
|
+
const counts = new Map();
|
|
1219
|
+
for (const item of items) {
|
|
1220
|
+
const value = item[key];
|
|
1221
|
+
if (typeof value === 'undefined' || value === null)
|
|
1222
|
+
continue;
|
|
1223
|
+
const id = JSON.stringify(compactEntity(value));
|
|
1224
|
+
const current = counts.get(id);
|
|
1225
|
+
counts.set(id, { count: (current?.count ?? 0) + 1, value });
|
|
1226
|
+
}
|
|
1227
|
+
return [...counts.values()].sort((a, b) => b.count - a.count)[0]?.value ?? null;
|
|
1228
|
+
}
|
|
1229
|
+
function mostFrequentString(values) {
|
|
1230
|
+
const counts = new Map();
|
|
1231
|
+
for (const value of values) {
|
|
1232
|
+
if (typeof value !== 'string' || !value.trim())
|
|
1233
|
+
continue;
|
|
1234
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
1235
|
+
}
|
|
1236
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
1237
|
+
}
|
|
1238
|
+
function firstDomainUrl(project) {
|
|
1239
|
+
const domains = collectionFrom(project.domains);
|
|
1240
|
+
const domain = domains.find((item) => typeof item.url === 'string' && item.url);
|
|
1241
|
+
return typeof domain?.url === 'string' ? domain.url : null;
|
|
1242
|
+
}
|
|
1243
|
+
function extractId(data) {
|
|
1244
|
+
const obj = objectFrom(data);
|
|
1245
|
+
return obj.id ?? obj.experience_id ?? obj.data?.id;
|
|
1246
|
+
}
|
|
1247
|
+
function containsCustomCode(data, variationId, js, css) {
|
|
1248
|
+
const experience = objectFrom(data);
|
|
1249
|
+
const variations = collectionFrom(experience.variations);
|
|
1250
|
+
const target = variations.find((variation) => String(variation.id) === String(variationId));
|
|
1251
|
+
if (!target)
|
|
1252
|
+
return false;
|
|
1253
|
+
return collectionFrom(target.changes).some((change) => {
|
|
1254
|
+
if (change.type !== 'customCode' || !isObject(change.data))
|
|
1255
|
+
return false;
|
|
1256
|
+
return String(change.data.js ?? '') === js && String(change.data.css ?? '') === css;
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
async function readDefaultsCache(cacheKey) {
|
|
1260
|
+
try {
|
|
1261
|
+
const raw = await readFile(defaultsCachePath(cacheKey), 'utf8');
|
|
1262
|
+
const parsed = JSON.parse(raw);
|
|
1263
|
+
const expiresAt = typeof parsed.expires_at === 'string' ? Date.parse(parsed.expires_at) : 0;
|
|
1264
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now())
|
|
1265
|
+
return null;
|
|
1266
|
+
return parsed;
|
|
1267
|
+
}
|
|
1268
|
+
catch {
|
|
1269
|
+
return null;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
async function writeDefaultsCache(cacheKey, data) {
|
|
1273
|
+
const dir = join(homedir(), '.convert', 'convert-mcp-defaults');
|
|
1274
|
+
await mkdir(dir, { recursive: true });
|
|
1275
|
+
await writeFile(defaultsCachePath(cacheKey), JSON.stringify(data, null, 2));
|
|
1276
|
+
}
|
|
1277
|
+
function defaultsCachePath(cacheKey) {
|
|
1278
|
+
return join(homedir(), '.convert', 'convert-mcp-defaults', `${cacheKey.replace(/[^a-zA-Z0-9_.-]/g, '_')}.json`);
|
|
1279
|
+
}
|
|
1280
|
+
async function report(context, message, progress, total) {
|
|
1281
|
+
if (!context.progress)
|
|
1282
|
+
return;
|
|
1283
|
+
await context.progress(message, progress, total);
|
|
1284
|
+
}
|
|
1285
|
+
function okResult(action, data) {
|
|
1286
|
+
const payload = {
|
|
1287
|
+
ok: true,
|
|
1288
|
+
namespace: 'operator',
|
|
1289
|
+
action,
|
|
1290
|
+
delegate: 'operator',
|
|
1291
|
+
status: 200,
|
|
1292
|
+
data,
|
|
1293
|
+
...(typeof data.verified === 'boolean' ? { verified: data.verified } : {}),
|
|
1294
|
+
};
|
|
1295
|
+
return {
|
|
1296
|
+
content: [{ type: 'text', text: JSON.stringify(payload) }],
|
|
1297
|
+
structuredContent: payload,
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
function errorResult(action, code, message, extra = {}) {
|
|
1301
|
+
const payload = {
|
|
1302
|
+
ok: false,
|
|
1303
|
+
namespace: 'operator',
|
|
1304
|
+
action,
|
|
1305
|
+
delegate: 'operator',
|
|
1306
|
+
status: 400,
|
|
1307
|
+
error: code,
|
|
1308
|
+
message,
|
|
1309
|
+
...extra,
|
|
1310
|
+
};
|
|
1311
|
+
return {
|
|
1312
|
+
isError: true,
|
|
1313
|
+
content: [{ type: 'text', text: JSON.stringify(payload) }],
|
|
1314
|
+
structuredContent: payload,
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
function isObject(value) {
|
|
1318
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1319
|
+
}
|
|
1320
|
+
//# sourceMappingURL=operator.js.map
|