@oxygen-agent/cli 1.246.0 → 1.263.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +1076 -32
- package/node_modules/@oxygen/shared/dist/directory.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/directory.js +34 -0
- package/node_modules/@oxygen/shared/dist/email-tracking-token.d.ts +82 -0
- package/node_modules/@oxygen/shared/dist/email-tracking-token.js +130 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/index.js +3 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +55 -8
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +25 -15
- package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +234 -12
- package/node_modules/@oxygen/shared/dist/sequences.js +423 -28
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { OxygenError } from "./cli-result.js";
|
|
2
|
-
import {
|
|
2
|
+
import { hashVariantKey, renderTemplate, templateColumnKeys, } from "./sequence-template.js";
|
|
3
3
|
/**
|
|
4
4
|
* Multichannel sequence DSL — the shared contract validated identically by CLI,
|
|
5
5
|
* MCP, API, and web. A sequence is an ordered list of steps applied to each
|
|
@@ -105,6 +105,100 @@ export function recipientEmailFromRow(rowValues) {
|
|
|
105
105
|
}
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* ESP (email service provider) MATCHING mode for a sequence (settings.esp_matching).
|
|
110
|
+
* Deliverability lore: a send lands better when the SENDING mailbox and the
|
|
111
|
+
* RECIPIENT sit on the same provider (Google→Google, Microsoft→Microsoft), so at
|
|
112
|
+
* mailbox-pick the dispatcher can bias rotation toward a same-ESP mailbox.
|
|
113
|
+
* - "off" — no ESP bias (default); rotation is pure LRU / domain-spread.
|
|
114
|
+
* - "prefer" — pick a same-ESP mailbox when one is available, else fall back to
|
|
115
|
+
* any sendable mailbox (never blocks a send).
|
|
116
|
+
* - "strict" — require a same-ESP mailbox; when none is available the action is
|
|
117
|
+
* DEFERRED with a visible skipped_reason rather than sent cross-provider.
|
|
118
|
+
*/
|
|
119
|
+
export const ESP_MATCHING_MODES = ["off", "prefer", "strict"];
|
|
120
|
+
export const DEFAULT_ESP_MATCHING_MODE = "off";
|
|
121
|
+
export function isEspMatchingMode(value) {
|
|
122
|
+
return typeof value === "string" && ESP_MATCHING_MODES.includes(value);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Validate settings.esp_matching before persistence. Absent/null is valid (means
|
|
126
|
+
* DEFAULT_ESP_MATCHING_MODE "off"); any other value must be one of the three
|
|
127
|
+
* modes. Pure; throws OxygenError("invalid_sequence_settings") on a bad value so
|
|
128
|
+
* the CLI / MCP / API report an identical error. Called from the tenant-db
|
|
129
|
+
* settings validator alongside the budget/prioritization checks.
|
|
130
|
+
*/
|
|
131
|
+
export function validateEspMatchingSetting(value) {
|
|
132
|
+
if (value === undefined || value === null)
|
|
133
|
+
return;
|
|
134
|
+
if (!isEspMatchingMode(value)) {
|
|
135
|
+
throw new OxygenError("invalid_sequence_settings", `settings.esp_matching must be one of: ${ESP_MATCHING_MODES.join(", ")}.`, { details: { field: "esp_matching", value }, exitCode: 1 });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The email-provider FAMILY a recipient (or sending mailbox) routes through. Kept
|
|
140
|
+
* intentionally aligned with EMAIL_MAILBOX_PROVIDERS in tenant-db so a mailbox's
|
|
141
|
+
* `provider` and an inferred recipient ESP compare directly.
|
|
142
|
+
*/
|
|
143
|
+
export const RECIPIENT_ESPS = ["google", "microsoft"];
|
|
144
|
+
/** Consumer domains that deterministically route to Google / Microsoft mail. */
|
|
145
|
+
const GOOGLE_CONSUMER_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
|
|
146
|
+
const MICROSOFT_CONSUMER_DOMAINS = new Set(["outlook.com", "hotmail.com", "live.com", "msn.com"]);
|
|
147
|
+
/**
|
|
148
|
+
* Normalize a recipient domain: lowercase, trim, drop a trailing FQDN dot, and if
|
|
149
|
+
* a full address is passed take the part after the last '@'. Returns "" for junk.
|
|
150
|
+
*/
|
|
151
|
+
function normalizeRecipientDomain(domain) {
|
|
152
|
+
if (typeof domain !== "string")
|
|
153
|
+
return "";
|
|
154
|
+
let d = domain.trim().toLowerCase();
|
|
155
|
+
const at = d.lastIndexOf("@");
|
|
156
|
+
if (at >= 0)
|
|
157
|
+
d = d.slice(at + 1);
|
|
158
|
+
return d.replace(/\.+$/, "");
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Infer a recipient's ESP from its email domain, DETERMINISTICALLY, for the
|
|
162
|
+
* well-known consumer domains (gmail/googlemail → google; outlook/hotmail/live/
|
|
163
|
+
* msn → microsoft). Returns null for any other domain — the caller then resolves
|
|
164
|
+
* the ESP via a cached MX lookup (custom domains fronted by Google Workspace /
|
|
165
|
+
* Microsoft 365 aren't decidable from the domain string alone). Pure and
|
|
166
|
+
* side-effect-free so it's the single source of truth for the deterministic map.
|
|
167
|
+
*/
|
|
168
|
+
export function inferRecipientEsp(domain) {
|
|
169
|
+
const d = normalizeRecipientDomain(domain);
|
|
170
|
+
if (!d)
|
|
171
|
+
return null;
|
|
172
|
+
if (GOOGLE_CONSUMER_DOMAINS.has(d))
|
|
173
|
+
return "google";
|
|
174
|
+
if (MICROSOFT_CONSUMER_DOMAINS.has(d))
|
|
175
|
+
return "microsoft";
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Map a domain's resolved MX exchange hostnames to an ESP family, for the MX-cache
|
|
180
|
+
* path (custom domains on Google Workspace / Microsoft 365). Google MX hosts end
|
|
181
|
+
* in `google.com` / `googlemail.com` (e.g. aspmx.l.google.com); Microsoft 365 MX
|
|
182
|
+
* hosts end in `outlook.com` / `protection.outlook.com` (e.g.
|
|
183
|
+
* acme-com.mail.protection.outlook.com). Returns null when no host matches either
|
|
184
|
+
* family. Pure so both the resolver and its tests share one mapping.
|
|
185
|
+
*/
|
|
186
|
+
export function espFromMxHosts(hosts) {
|
|
187
|
+
if (!Array.isArray(hosts))
|
|
188
|
+
return null;
|
|
189
|
+
for (const raw of hosts) {
|
|
190
|
+
if (typeof raw !== "string")
|
|
191
|
+
continue;
|
|
192
|
+
const host = raw.trim().toLowerCase().replace(/\.+$/, "");
|
|
193
|
+
if (!host)
|
|
194
|
+
continue;
|
|
195
|
+
if (host.endsWith("google.com") || host.endsWith("googlemail.com"))
|
|
196
|
+
return "google";
|
|
197
|
+
if (host.endsWith("outlook.com") || host.endsWith("protection.outlook.com"))
|
|
198
|
+
return "microsoft";
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
108
202
|
/**
|
|
109
203
|
* row_values keys an enrollment's phone number may live under, in
|
|
110
204
|
* send-precedence order, for WhatsApp sends. The enroll path resolves the FIRST
|
|
@@ -151,6 +245,16 @@ export function whatsAppAttendeeIdFromRow(rowValues, phoneColumnKey) {
|
|
|
151
245
|
}
|
|
152
246
|
/** Base content + up to this many alternates per A/B step (base counts as variant "a"). */
|
|
153
247
|
export const MAX_STEP_VARIANTS = 5;
|
|
248
|
+
/**
|
|
249
|
+
* Metrics a step's opt-in auto-winner (A/B auto-optimize) can decide on. `reply`
|
|
250
|
+
* is the only metric today — open/click require tracking domains (a gated,
|
|
251
|
+
* separate capability), so the reversible auto-winner ships on reply first.
|
|
252
|
+
*/
|
|
253
|
+
export const SEQUENCE_AUTO_OPTIMIZE_METRICS = ["reply"];
|
|
254
|
+
/** Default send floor per variant before the auto-winner may decide. */
|
|
255
|
+
export const DEFAULT_AUTO_OPTIMIZE_MIN_SENDS = 100;
|
|
256
|
+
/** Default conversion (reply) floor per variant before the auto-winner may decide. */
|
|
257
|
+
export const DEFAULT_AUTO_OPTIMIZE_MIN_CONVERSIONS = 5;
|
|
154
258
|
export const SEQUENCE_STEP_KINDS = [
|
|
155
259
|
// linkedin channel (native dispatch)
|
|
156
260
|
"visit_profile",
|
|
@@ -158,6 +262,10 @@ export const SEQUENCE_STEP_KINDS = [
|
|
|
158
262
|
"wait_for_connection",
|
|
159
263
|
"message",
|
|
160
264
|
"inmail",
|
|
265
|
+
"follow",
|
|
266
|
+
"like_post",
|
|
267
|
+
"comment_post",
|
|
268
|
+
"withdraw_invite",
|
|
161
269
|
// email channel
|
|
162
270
|
"email_send",
|
|
163
271
|
"email_reply",
|
|
@@ -180,10 +288,12 @@ export const SEQUENCE_STEP_KINDS = [
|
|
|
180
288
|
* - already_connected: resolve the lead's current 1st-degree state at entry and
|
|
181
289
|
* route connected → then_id, not → else_id (no waiting).
|
|
182
290
|
*/
|
|
183
|
-
export const SEQUENCE_CONNECTION_BRANCH_CONDITIONS = ["connection_accepted", "already_connected"];
|
|
291
|
+
export const SEQUENCE_CONNECTION_BRANCH_CONDITIONS = ["connection_accepted", "already_connected", "open_profile"];
|
|
184
292
|
const MAX_SEQUENCE_STEPS = 50;
|
|
185
293
|
const MAX_TEMPLATE_LENGTH = 8_000;
|
|
186
294
|
const MAX_NOTE_LENGTH = 300;
|
|
295
|
+
/** Cap on step Cc/Bcc recipients — a handful of fixed addresses (e.g. an AE, a CRM drop), not a list. */
|
|
296
|
+
const MAX_STEP_EMAIL_RECIPIENTS = 10;
|
|
187
297
|
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
188
298
|
/** Which channel a step executes on, or null for channel-agnostic control steps. */
|
|
189
299
|
export function sequenceStepChannel(step) {
|
|
@@ -247,8 +357,13 @@ export function validateSequenceDefinition(input, options = {}) {
|
|
|
247
357
|
// (email_send/email_reply) and is NOT delegated to an Instantly campaign (no
|
|
248
358
|
// email_enroll/email_move/email_stop steps) — that gates a wait_for_signal or
|
|
249
359
|
// signal-branch on those signals would silently always time out / always take
|
|
250
|
-
// the else arm. Reject it at write time so create/update report the dead gate
|
|
251
|
-
|
|
360
|
+
// the else arm. Reject it at write time so create/update report the dead gate --
|
|
361
|
+
// UNLESS the sequence has native open/click tracking enabled, in which case a
|
|
362
|
+
// native send DOES accumulate email_opened/email_clicked (from the /api/t/*
|
|
363
|
+
// pixel + link routes), so those gates are legitimate and permitted.
|
|
364
|
+
if (!options.nativeTrackingEnabled) {
|
|
365
|
+
reportNativeEngagementGates(normalized, issues);
|
|
366
|
+
}
|
|
252
367
|
if (issues.length > 0) {
|
|
253
368
|
throw new OxygenError("invalid_sequence", `Sequence definition is invalid: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`, { details: { issues }, exitCode: 1 });
|
|
254
369
|
}
|
|
@@ -372,18 +487,24 @@ raw, index, options, issues) {
|
|
|
372
487
|
case "message": {
|
|
373
488
|
const template = requiredTemplate(raw.template, `${path}.template`, issues);
|
|
374
489
|
const variants = normalizeVariants(raw.variants, `${path}.variants`, ["template"], issues);
|
|
490
|
+
const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, issues);
|
|
491
|
+
const attachments = normalizeLinkedInAttachments(raw.attachments, `${path}.attachments`, issues);
|
|
375
492
|
return {
|
|
376
493
|
id, channel: "linkedin", kind: "message", template: template ?? "",
|
|
377
494
|
...(variants ? { variants: variants } : {}),
|
|
495
|
+
...(autoOptimize ? { auto_optimize: autoOptimize } : {}),
|
|
496
|
+
...(attachments ? { attachments } : {}),
|
|
378
497
|
};
|
|
379
498
|
}
|
|
380
499
|
case "inmail": {
|
|
381
500
|
const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues);
|
|
382
501
|
const template = requiredTemplate(raw.template, `${path}.template`, issues);
|
|
383
502
|
const variants = normalizeVariants(raw.variants, `${path}.variants`, ["subject_template", "template"], issues);
|
|
503
|
+
const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, issues);
|
|
384
504
|
return {
|
|
385
505
|
id, channel: "linkedin", kind: "inmail", subject_template: subject ?? "", template: template ?? "",
|
|
386
506
|
...(variants ? { variants: variants } : {}),
|
|
507
|
+
...(autoOptimize ? { auto_optimize: autoOptimize } : {}),
|
|
387
508
|
};
|
|
388
509
|
}
|
|
389
510
|
case "email_enroll": {
|
|
@@ -402,21 +523,37 @@ raw, index, options, issues) {
|
|
|
402
523
|
case "email_send": {
|
|
403
524
|
const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues);
|
|
404
525
|
const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues);
|
|
405
|
-
const
|
|
526
|
+
const bodyHtml = optionalTemplate(raw.body_html_template, `${path}.body_html_template`, MAX_TEMPLATE_LENGTH, issues);
|
|
527
|
+
const variants = normalizeVariants(raw.variants, `${path}.variants`, ["subject_template", "body_template", "body_html_template"], issues);
|
|
528
|
+
const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, issues);
|
|
529
|
+
const cc = optionalEmailList(raw.cc, `${path}.cc`, issues);
|
|
530
|
+
const bcc = optionalEmailList(raw.bcc, `${path}.bcc`, issues);
|
|
406
531
|
const sendWindow = normalizeSendWindow(raw.send_window, `${path}.send_window`, issues);
|
|
407
532
|
return {
|
|
408
533
|
id, channel: "email", kind: "email_send", subject_template: subject ?? "", body_template: body ?? "",
|
|
534
|
+
...(bodyHtml !== undefined ? { body_html_template: bodyHtml } : {}),
|
|
535
|
+
...(cc ? { cc } : {}),
|
|
536
|
+
...(bcc ? { bcc } : {}),
|
|
409
537
|
...(variants ? { variants: variants } : {}),
|
|
538
|
+
...(autoOptimize ? { auto_optimize: autoOptimize } : {}),
|
|
410
539
|
...(sendWindow ? { send_window: sendWindow } : {}),
|
|
411
540
|
};
|
|
412
541
|
}
|
|
413
542
|
case "email_reply": {
|
|
414
543
|
const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues);
|
|
415
|
-
const
|
|
544
|
+
const bodyHtml = optionalTemplate(raw.body_html_template, `${path}.body_html_template`, MAX_TEMPLATE_LENGTH, issues);
|
|
545
|
+
const variants = normalizeVariants(raw.variants, `${path}.variants`, ["body_template", "body_html_template"], issues);
|
|
546
|
+
const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, issues);
|
|
547
|
+
const cc = optionalEmailList(raw.cc, `${path}.cc`, issues);
|
|
548
|
+
const bcc = optionalEmailList(raw.bcc, `${path}.bcc`, issues);
|
|
416
549
|
const sendWindow = normalizeSendWindow(raw.send_window, `${path}.send_window`, issues);
|
|
417
550
|
return {
|
|
418
551
|
id, channel: "email", kind: "email_reply", body_template: body ?? "",
|
|
552
|
+
...(bodyHtml !== undefined ? { body_html_template: bodyHtml } : {}),
|
|
553
|
+
...(cc ? { cc } : {}),
|
|
554
|
+
...(bcc ? { bcc } : {}),
|
|
419
555
|
...(variants ? { variants: variants } : {}),
|
|
556
|
+
...(autoOptimize ? { auto_optimize: autoOptimize } : {}),
|
|
420
557
|
...(sendWindow ? { send_window: sendWindow } : {}),
|
|
421
558
|
};
|
|
422
559
|
}
|
|
@@ -425,9 +562,11 @@ raw, index, options, issues) {
|
|
|
425
562
|
case "whatsapp_message": {
|
|
426
563
|
const template = requiredTemplate(raw.template, `${path}.template`, issues);
|
|
427
564
|
const variants = normalizeVariants(raw.variants, `${path}.variants`, ["template"], issues);
|
|
565
|
+
const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, issues);
|
|
428
566
|
return {
|
|
429
567
|
id, channel: "whatsapp", kind: "whatsapp_message", template: template ?? "",
|
|
430
568
|
...(variants ? { variants: variants } : {}),
|
|
569
|
+
...(autoOptimize ? { auto_optimize: autoOptimize } : {}),
|
|
431
570
|
};
|
|
432
571
|
}
|
|
433
572
|
case "wait": {
|
|
@@ -436,11 +575,13 @@ raw, index, options, issues) {
|
|
|
436
575
|
if ((days ?? 0) + (hours ?? 0) <= 0) {
|
|
437
576
|
issues.push({ path, message: "A wait step needs days and/or hours totaling at least 1 hour." });
|
|
438
577
|
}
|
|
578
|
+
const jitterHours = optionalWaitJitterHours(raw.jitter_hours, `${path}.jitter_hours`, issues);
|
|
439
579
|
return {
|
|
440
580
|
id,
|
|
441
581
|
kind: "wait",
|
|
442
582
|
...(days !== undefined ? { days } : {}),
|
|
443
583
|
...(hours !== undefined ? { hours } : {}),
|
|
584
|
+
...(jitterHours !== undefined ? { jitter_hours: jitterHours } : {}),
|
|
444
585
|
};
|
|
445
586
|
}
|
|
446
587
|
case "wait_for_signal": {
|
|
@@ -494,12 +635,80 @@ raw, index, options, issues) {
|
|
|
494
635
|
else: normalizeTargetRef(raw.else ?? raw.else_id, `${path}.else`, issues),
|
|
495
636
|
};
|
|
496
637
|
}
|
|
638
|
+
case "follow":
|
|
639
|
+
return { id, channel: "linkedin", kind: "follow" };
|
|
640
|
+
case "like_post": {
|
|
641
|
+
const reaction = typeof raw.reaction === "string" && LINKEDIN_POST_REACTION_SET.has(raw.reaction)
|
|
642
|
+
? raw.reaction
|
|
643
|
+
: undefined;
|
|
644
|
+
if (raw.reaction !== undefined && reaction === undefined) {
|
|
645
|
+
issues.push({ path: `${path}.reaction`, message: `reaction must be one of: ${[...LINKEDIN_POST_REACTION_SET].join(", ")}.` });
|
|
646
|
+
}
|
|
647
|
+
const recency = optionalPostRecencyDays(raw.post_recency_days, `${path}.post_recency_days`, issues);
|
|
648
|
+
return {
|
|
649
|
+
id, channel: "linkedin", kind: "like_post",
|
|
650
|
+
...(reaction ? { reaction: reaction } : {}),
|
|
651
|
+
...(recency !== undefined ? { post_recency_days: recency } : {}),
|
|
652
|
+
...(raw.on_no_post === "stop" ? { on_no_post: "stop" } : {}),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
case "comment_post": {
|
|
656
|
+
// ai_prompt (AI-generated comments) is a planned follow-up; today a
|
|
657
|
+
// comment_post step needs a text_template (with {{column}} interpolation).
|
|
658
|
+
const textTemplate = requiredTemplate(raw.text_template, `${path}.text_template`, issues);
|
|
659
|
+
const recency = optionalPostRecencyDays(raw.post_recency_days, `${path}.post_recency_days`, issues);
|
|
660
|
+
return {
|
|
661
|
+
id, channel: "linkedin", kind: "comment_post",
|
|
662
|
+
text_template: textTemplate ?? "",
|
|
663
|
+
...(recency !== undefined ? { post_recency_days: recency } : {}),
|
|
664
|
+
...(raw.on_no_post === "stop" ? { on_no_post: "stop" } : {}),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
case "withdraw_invite":
|
|
668
|
+
return { id, channel: "linkedin", kind: "withdraw_invite" };
|
|
497
669
|
case "stop":
|
|
498
670
|
return { id, kind: "stop" };
|
|
499
671
|
default:
|
|
500
672
|
return null;
|
|
501
673
|
}
|
|
502
674
|
}
|
|
675
|
+
const LINKEDIN_POST_REACTION_SET = new Set(["like", "celebrate", "support", "funny", "love", "insightful"]);
|
|
676
|
+
/** Max attachments per LinkedIn message (kept small — LinkedIn is not a file host). */
|
|
677
|
+
const MAX_LINKEDIN_ATTACHMENTS = 5;
|
|
678
|
+
/**
|
|
679
|
+
* Normalize the optional `attachments` on a message step into `{url, name?}[]`.
|
|
680
|
+
* Each needs an http(s) url; a missing/blank url drops the entry. Returns
|
|
681
|
+
* undefined when there are no valid attachments (field omitted).
|
|
682
|
+
*/
|
|
683
|
+
function normalizeLinkedInAttachments(raw, path, issues) {
|
|
684
|
+
if (raw === undefined || raw === null)
|
|
685
|
+
return undefined;
|
|
686
|
+
if (!Array.isArray(raw)) {
|
|
687
|
+
issues.push({ path, message: "attachments must be an array of { url, name? }." });
|
|
688
|
+
return undefined;
|
|
689
|
+
}
|
|
690
|
+
const out = [];
|
|
691
|
+
for (const entry of raw.slice(0, MAX_LINKEDIN_ATTACHMENTS)) {
|
|
692
|
+
const record = entry && typeof entry === "object" ? entry : {};
|
|
693
|
+
const url = typeof record.url === "string" ? record.url.trim() : "";
|
|
694
|
+
// Lenient: silently drop an entry without a valid http(s) url rather than
|
|
695
|
+
// rejecting the whole sequence for one bad attachment.
|
|
696
|
+
if (!/^https?:\/\//i.test(url))
|
|
697
|
+
continue;
|
|
698
|
+
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : undefined;
|
|
699
|
+
out.push(name ? { url, name } : { url });
|
|
700
|
+
}
|
|
701
|
+
return out.length > 0 ? out : undefined;
|
|
702
|
+
}
|
|
703
|
+
/** Optional post-recency window (1..365 days) for like_post / comment_post steps. */
|
|
704
|
+
function optionalPostRecencyDays(raw, path, issues) {
|
|
705
|
+
if (raw === undefined || raw === null)
|
|
706
|
+
return undefined;
|
|
707
|
+
const value = positiveInt(raw, path, issues);
|
|
708
|
+
if (value === undefined)
|
|
709
|
+
return undefined;
|
|
710
|
+
return Math.min(value, 365);
|
|
711
|
+
}
|
|
503
712
|
function channelForKind(kind) {
|
|
504
713
|
if (kind === "wait" || kind === "wait_for_signal" || kind === "branch" || kind === "stop")
|
|
505
714
|
return null;
|
|
@@ -599,6 +808,36 @@ function optionalTemplate(value, path, maxLength, issues) {
|
|
|
599
808
|
}
|
|
600
809
|
return value;
|
|
601
810
|
}
|
|
811
|
+
/**
|
|
812
|
+
* Validate an optional Cc/Bcc list on an email step: a single address or an array of
|
|
813
|
+
* addresses, each a single-line address-shaped string (has '@', no whitespace, no
|
|
814
|
+
* CR/LF — header-injection-safe, mirroring the ad-hoc send route). Lowercased and
|
|
815
|
+
* de-duplicated; capped at MAX_STEP_EMAIL_RECIPIENTS. Returns undefined when absent or
|
|
816
|
+
* once every entry is dropped (so an empty list never rides the payload).
|
|
817
|
+
*/
|
|
818
|
+
function optionalEmailList(value, path, issues) {
|
|
819
|
+
if (value === undefined || value === null)
|
|
820
|
+
return undefined;
|
|
821
|
+
const raw = Array.isArray(value) ? value : [value];
|
|
822
|
+
if (raw.length > MAX_STEP_EMAIL_RECIPIENTS) {
|
|
823
|
+
issues.push({ path, message: `at most ${MAX_STEP_EMAIL_RECIPIENTS} addresses.` });
|
|
824
|
+
}
|
|
825
|
+
const out = [];
|
|
826
|
+
raw.slice(0, MAX_STEP_EMAIL_RECIPIENTS).forEach((entry, i) => {
|
|
827
|
+
if (typeof entry !== "string" || !entry.trim()) {
|
|
828
|
+
issues.push({ path: `${path}[${i}]`, message: "each address must be a non-empty string." });
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const address = entry.trim().toLowerCase();
|
|
832
|
+
if (/[\r\n]/.test(address) || /\s/.test(address) || !address.includes("@")) {
|
|
833
|
+
issues.push({ path: `${path}[${i}]`, message: "must be a single email address (no spaces or line breaks)." });
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (!out.includes(address))
|
|
837
|
+
out.push(address);
|
|
838
|
+
});
|
|
839
|
+
return out.length > 0 ? out : undefined;
|
|
840
|
+
}
|
|
602
841
|
function optionalString(value, path, issues) {
|
|
603
842
|
if (value === undefined || value === null)
|
|
604
843
|
return undefined;
|
|
@@ -626,26 +865,76 @@ function optionalNonNegativeInt(value, path, issues) {
|
|
|
626
865
|
}
|
|
627
866
|
return num;
|
|
628
867
|
}
|
|
629
|
-
/**
|
|
868
|
+
/**
|
|
869
|
+
* A wait step's optional jitter window in whole hours: a non-negative integer,
|
|
870
|
+
* capped at MAX_WAIT_JITTER_HOURS. 0 is treated as "no jitter" (field omitted).
|
|
871
|
+
*/
|
|
872
|
+
function optionalWaitJitterHours(value, path, issues) {
|
|
873
|
+
const hours = optionalNonNegativeInt(value, path, issues);
|
|
874
|
+
if (hours === undefined || hours === 0)
|
|
875
|
+
return undefined;
|
|
876
|
+
if (hours > MAX_WAIT_JITTER_HOURS) {
|
|
877
|
+
issues.push({ path, message: `must be at most ${MAX_WAIT_JITTER_HOURS} hours.` });
|
|
878
|
+
return undefined;
|
|
879
|
+
}
|
|
880
|
+
return hours;
|
|
881
|
+
}
|
|
882
|
+
/** Upper bound on a wait step's optional jitter window (a week — keep it a nudge, not a reschedule). */
|
|
883
|
+
const MAX_WAIT_JITTER_HOURS = 168;
|
|
884
|
+
/** Total base delay in milliseconds a wait step introduces (no jitter). */
|
|
630
885
|
export function sequenceWaitStepDelayMs(step) {
|
|
631
886
|
const days = step.days ?? 0;
|
|
632
887
|
const hours = step.hours ?? 0;
|
|
633
888
|
return (days * 24 + hours) * 60 * 60 * 1000;
|
|
634
889
|
}
|
|
635
|
-
/**
|
|
636
|
-
|
|
637
|
-
|
|
890
|
+
/**
|
|
891
|
+
* Deterministic jitter (ms) a wait step adds for one enrollment, in
|
|
892
|
+
* [0, jitter_hours) hours. Seeded by the enrollment id + step id through the
|
|
893
|
+
* shared FNV hash (hashVariantKey) — the SAME (enrollment, step) always resolves
|
|
894
|
+
* to the same offset, so it's replayable across retries/crash-replays and never
|
|
895
|
+
* uses Math.random. 0/absent jitter_hours → 0. Resolution is minute-grained so a
|
|
896
|
+
* batch spreads smoothly across the window rather than landing on the hour.
|
|
897
|
+
*/
|
|
898
|
+
export function sequenceWaitStepJitterMs(step, enrollmentId) {
|
|
899
|
+
const jitterHours = step.jitter_hours ?? 0;
|
|
900
|
+
if (jitterHours <= 0)
|
|
901
|
+
return 0;
|
|
902
|
+
const windowMinutes = Math.floor(jitterHours * 60);
|
|
903
|
+
if (windowMinutes <= 0)
|
|
904
|
+
return 0;
|
|
905
|
+
const offsetMinutes = hashVariantKey(`${enrollmentId}:${step.id}:wait-jitter`) % windowMinutes;
|
|
906
|
+
return offsetMinutes * 60 * 1000;
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Full delay (base + deterministic jitter) a wait step introduces for one
|
|
910
|
+
* enrollment. The dispatch planner uses this to compute a resume time that
|
|
911
|
+
* spreads a same-instant batch across the jitter window.
|
|
912
|
+
*/
|
|
913
|
+
export function sequenceWaitStepDelayWithJitterMs(step, enrollmentId) {
|
|
914
|
+
return sequenceWaitStepDelayMs(step) + sequenceWaitStepJitterMs(step, enrollmentId);
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Render a sequence-copy template against a row's values. Delegates to the shared
|
|
918
|
+
* deterministic engine (sequence-template.ts): `{{column}}` substitution plus
|
|
919
|
+
* `{{column|fallback}}`, `{{RANDOM|…}}` spintax, and `{% if … %}` conditionals.
|
|
920
|
+
* Pass `{ seed }` to make spintax choices replayable across retries/crash-replays.
|
|
921
|
+
*/
|
|
922
|
+
export function renderSequenceTemplate(template, values, options) {
|
|
923
|
+
return renderTemplate(template, values, options);
|
|
638
924
|
}
|
|
639
|
-
/**
|
|
925
|
+
/**
|
|
926
|
+
* Column keys referenced across every step's copy — including keys nested inside
|
|
927
|
+
* spintax options, inline fallbacks, and conditional conditions/branches (via the
|
|
928
|
+
* shared engine's scanner), so the start preview's variable-resolution check
|
|
929
|
+
* catches them wherever they appear.
|
|
930
|
+
*/
|
|
640
931
|
export function sequenceTemplateVariables(definition) {
|
|
641
932
|
const vars = new Set();
|
|
642
933
|
const scan = (template) => {
|
|
643
934
|
if (!template)
|
|
644
935
|
return;
|
|
645
|
-
for (const
|
|
646
|
-
|
|
647
|
-
vars.add(match[1]);
|
|
648
|
-
}
|
|
936
|
+
for (const key of templateColumnKeys(template))
|
|
937
|
+
vars.add(key);
|
|
649
938
|
};
|
|
650
939
|
for (const step of definition.steps) {
|
|
651
940
|
if (step.kind === "invite")
|
|
@@ -681,19 +970,19 @@ const VARIANT_ALPHABET = "abcdefghijklmnopqrstuvwxyz";
|
|
|
681
970
|
export function sequenceVariantLabel(index) {
|
|
682
971
|
return VARIANT_ALPHABET[index % VARIANT_ALPHABET.length] ?? "a";
|
|
683
972
|
}
|
|
684
|
-
/** Deterministic, replayable FNV-1a hash → uint32. Stable across processes. */
|
|
685
|
-
function hashVariantKey(value) {
|
|
686
|
-
let hash = 0x811c9dc5;
|
|
687
|
-
for (let i = 0; i < value.length; i += 1) {
|
|
688
|
-
hash ^= value.charCodeAt(i);
|
|
689
|
-
hash = Math.imul(hash, 0x01000193);
|
|
690
|
-
}
|
|
691
|
-
return hash >>> 0;
|
|
692
|
-
}
|
|
693
973
|
function baseVariantContent(step) {
|
|
694
974
|
switch (step.kind) {
|
|
695
|
-
case "email_send": return {
|
|
696
|
-
|
|
975
|
+
case "email_send": return {
|
|
976
|
+
subject_template: step.subject_template,
|
|
977
|
+
body_template: step.body_template,
|
|
978
|
+
// Include the HTML body so its {{column}} refs are scanned by
|
|
979
|
+
// sequenceTemplateVariables (the start preview's variable-resolution check).
|
|
980
|
+
...(step.body_html_template ? { body_html_template: step.body_html_template } : {}),
|
|
981
|
+
};
|
|
982
|
+
case "email_reply": return {
|
|
983
|
+
body_template: step.body_template,
|
|
984
|
+
...(step.body_html_template ? { body_html_template: step.body_html_template } : {}),
|
|
985
|
+
};
|
|
697
986
|
case "message": return { template: step.template };
|
|
698
987
|
case "whatsapp_message": return { template: step.template };
|
|
699
988
|
case "inmail": return { subject_template: step.subject_template, template: step.template };
|
|
@@ -724,15 +1013,78 @@ export function sequenceStepVariantCount(step) {
|
|
|
724
1013
|
* (key, step) always resolves to the same variant — replayable, evenly
|
|
725
1014
|
* distributed, and previewable in a dry run before any send. `key` is the
|
|
726
1015
|
* enrollment id.
|
|
1016
|
+
*
|
|
1017
|
+
* When `pausedVariantIds` is supplied (the auto-winner's paused set for this
|
|
1018
|
+
* step), those variants are skipped and the deterministic split is RE-NORMALIZED
|
|
1019
|
+
* over the remaining active variants — so pausing a losing variant shifts its
|
|
1020
|
+
* share onto the survivors, still deterministically. If every variant would be
|
|
1021
|
+
* paused (never expected — the winner is never paused), it fails safe by ignoring
|
|
1022
|
+
* the paused set rather than stranding the step with nothing to send.
|
|
727
1023
|
*/
|
|
728
|
-
export function selectStepVariant(step, key) {
|
|
1024
|
+
export function selectStepVariant(step, key, options) {
|
|
729
1025
|
const contents = sequenceStepVariantContents(step);
|
|
730
1026
|
if (contents.length === 0)
|
|
731
1027
|
return { variantId: "a", index: 0, content: {} };
|
|
732
|
-
const
|
|
1028
|
+
const paused = options?.pausedVariantIds ? new Set(options.pausedVariantIds) : null;
|
|
1029
|
+
let activeIndices = contents.map((_, i) => i);
|
|
1030
|
+
if (paused && paused.size > 0) {
|
|
1031
|
+
const survivors = activeIndices.filter((i) => !paused.has(sequenceVariantLabel(i)));
|
|
1032
|
+
if (survivors.length > 0)
|
|
1033
|
+
activeIndices = survivors;
|
|
1034
|
+
}
|
|
1035
|
+
const pick = activeIndices.length > 1
|
|
1036
|
+
? activeIndices[hashVariantKey(`${key}:${step.id}`) % activeIndices.length]
|
|
1037
|
+
: activeIndices[0];
|
|
1038
|
+
const index = pick ?? 0;
|
|
733
1039
|
const content = contents[index] ?? contents[0] ?? {};
|
|
734
1040
|
return { variantId: sequenceVariantLabel(index), index, content };
|
|
735
1041
|
}
|
|
1042
|
+
/** The step's opt-in auto-winner config, or null when it carries none. */
|
|
1043
|
+
export function stepAutoOptimizeConfig(step) {
|
|
1044
|
+
return step.auto_optimize ?? null;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Decide the A/B auto-winner for one step from per-variant conversion counts.
|
|
1048
|
+
* Pure. Returns null (no decision yet) unless: at least two variants are still
|
|
1049
|
+
* active (not already paused), EVERY active variant has cleared BOTH thresholds
|
|
1050
|
+
* (min_sends_per_variant AND min_conversions — the min-conversion floor), and the
|
|
1051
|
+
* best conversion rate is strictly ahead of at least one other variant (a tie
|
|
1052
|
+
* pauses nothing). When it decides, it pauses every active variant below the best
|
|
1053
|
+
* rate and stamps evidence. Reversible: a manual reset reactivates a paused
|
|
1054
|
+
* variant, and this re-decides from the survivors on the next pass.
|
|
1055
|
+
*/
|
|
1056
|
+
export function decideVariantAutoWinner(stats, config, decidedAt, alreadyPaused = []) {
|
|
1057
|
+
const paused = new Set(alreadyPaused);
|
|
1058
|
+
const active = stats.filter((s) => !paused.has(s.variantId));
|
|
1059
|
+
if (active.length < 2)
|
|
1060
|
+
return null;
|
|
1061
|
+
const ready = active.every((s) => s.sends >= config.min_sends_per_variant && s.conversions >= config.min_conversions);
|
|
1062
|
+
if (!ready)
|
|
1063
|
+
return null;
|
|
1064
|
+
const withRate = active.map((s) => ({ ...s, rate: s.sends > 0 ? s.conversions / s.sends : 0 }));
|
|
1065
|
+
const bestRate = Math.max(...withRate.map((s) => s.rate));
|
|
1066
|
+
const winner = withRate.find((s) => s.rate === bestRate);
|
|
1067
|
+
if (!winner)
|
|
1068
|
+
return null;
|
|
1069
|
+
const pause = withRate.filter((s) => s.rate < bestRate).map((s) => s.variantId);
|
|
1070
|
+
if (pause.length === 0)
|
|
1071
|
+
return null; // a tie at the top — nothing to pause yet
|
|
1072
|
+
return {
|
|
1073
|
+
pause,
|
|
1074
|
+
winnerId: winner.variantId,
|
|
1075
|
+
evidence: {
|
|
1076
|
+
metric: config.metric,
|
|
1077
|
+
variants: withRate.map((s) => ({
|
|
1078
|
+
variant_id: s.variantId,
|
|
1079
|
+
sends: s.sends,
|
|
1080
|
+
conversions: s.conversions,
|
|
1081
|
+
rate: Number(s.rate.toFixed(4)),
|
|
1082
|
+
})),
|
|
1083
|
+
winner_id: winner.variantId,
|
|
1084
|
+
decided_at: decidedAt,
|
|
1085
|
+
},
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
736
1088
|
// ===== Send windows =====
|
|
737
1089
|
function hhmmToMinutes(value) {
|
|
738
1090
|
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
|
@@ -841,6 +1193,49 @@ function normalizeVariants(raw, path, fields, issues) {
|
|
|
841
1193
|
});
|
|
842
1194
|
return out.length > 0 ? out : undefined;
|
|
843
1195
|
}
|
|
1196
|
+
/** Upper bounds on the auto-optimize thresholds (guard against fat-finger values). */
|
|
1197
|
+
const MAX_AUTO_OPTIMIZE_MIN_SENDS = 100_000;
|
|
1198
|
+
const MAX_AUTO_OPTIMIZE_MIN_CONVERSIONS = 100_000;
|
|
1199
|
+
/**
|
|
1200
|
+
* Validate + normalize a step's optional `auto_optimize` config. Only meaningful
|
|
1201
|
+
* on a step that actually has A/B alternates (variants.length >= 1, i.e. ≥ 2 total
|
|
1202
|
+
* variants) — configuring it on a single-variant step is a dead knob, so reject
|
|
1203
|
+
* it. `metric` must be a supported metric (reply today); the thresholds default to
|
|
1204
|
+
* DEFAULT_AUTO_OPTIMIZE_* and must be positive integers within sane bounds.
|
|
1205
|
+
* Returns undefined when absent (field omitted).
|
|
1206
|
+
*/
|
|
1207
|
+
function normalizeAutoOptimize(raw, path, variants, issues) {
|
|
1208
|
+
if (raw === undefined || raw === null)
|
|
1209
|
+
return undefined;
|
|
1210
|
+
if (!isRecord(raw)) {
|
|
1211
|
+
issues.push({ path, message: "auto_optimize must be an object." });
|
|
1212
|
+
return undefined;
|
|
1213
|
+
}
|
|
1214
|
+
if (!variants || variants.length === 0) {
|
|
1215
|
+
issues.push({ path, message: "auto_optimize needs at least one A/B alternate (2+ variants) to pick a winner from." });
|
|
1216
|
+
return undefined;
|
|
1217
|
+
}
|
|
1218
|
+
const metric = raw.metric;
|
|
1219
|
+
if (typeof metric !== "string" || !SEQUENCE_AUTO_OPTIMIZE_METRICS.includes(metric)) {
|
|
1220
|
+
issues.push({ path: `${path}.metric`, message: `metric must be one of: ${SEQUENCE_AUTO_OPTIMIZE_METRICS.join(", ")}.` });
|
|
1221
|
+
return undefined;
|
|
1222
|
+
}
|
|
1223
|
+
const minSends = raw.min_sends_per_variant === undefined || raw.min_sends_per_variant === null
|
|
1224
|
+
? DEFAULT_AUTO_OPTIMIZE_MIN_SENDS
|
|
1225
|
+
: positiveInt(raw.min_sends_per_variant, `${path}.min_sends_per_variant`, issues) ?? DEFAULT_AUTO_OPTIMIZE_MIN_SENDS;
|
|
1226
|
+
const minConversions = raw.min_conversions === undefined || raw.min_conversions === null
|
|
1227
|
+
? DEFAULT_AUTO_OPTIMIZE_MIN_CONVERSIONS
|
|
1228
|
+
: positiveInt(raw.min_conversions, `${path}.min_conversions`, issues) ?? DEFAULT_AUTO_OPTIMIZE_MIN_CONVERSIONS;
|
|
1229
|
+
if (minSends > MAX_AUTO_OPTIMIZE_MIN_SENDS) {
|
|
1230
|
+
issues.push({ path: `${path}.min_sends_per_variant`, message: `must be at most ${MAX_AUTO_OPTIMIZE_MIN_SENDS}.` });
|
|
1231
|
+
return undefined;
|
|
1232
|
+
}
|
|
1233
|
+
if (minConversions > MAX_AUTO_OPTIMIZE_MIN_CONVERSIONS) {
|
|
1234
|
+
issues.push({ path: `${path}.min_conversions`, message: `must be at most ${MAX_AUTO_OPTIMIZE_MIN_CONVERSIONS}.` });
|
|
1235
|
+
return undefined;
|
|
1236
|
+
}
|
|
1237
|
+
return { metric: metric, min_sends_per_variant: minSends, min_conversions: minConversions };
|
|
1238
|
+
}
|
|
844
1239
|
function normalizeSendWindowConfiguredTimezone(raw, path, issues) {
|
|
845
1240
|
const timezone = typeof raw.timezone === "string" && raw.timezone.trim() ? raw.timezone.trim() : undefined;
|
|
846
1241
|
if (!timezone)
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const OXYGEN_VERSION = "1.
|
|
1
|
+
export declare const OXYGEN_VERSION = "1.263.0";
|
|
2
2
|
export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const OXYGEN_VERSION = "1.
|
|
1
|
+
export const OXYGEN_VERSION = "1.263.0";
|
|
2
2
|
// Bump this only when deployed CLI/API contracts require a newer CLI.
|
|
3
3
|
// 1.181.0: paid table action runs and background columns run require
|
|
4
4
|
// approved=true in addition to max_credits; older CLIs cannot send the flag.
|