@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.
@@ -1,5 +1,5 @@
1
1
  import { OxygenError } from "./cli-result.js";
2
- import { renderLinkedInTemplate } from "./linkedin-sequences.js";
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
- reportNativeEngagementGates(normalized, issues);
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 variants = normalizeVariants(raw.variants, `${path}.variants`, ["subject_template", "body_template"], issues);
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 variants = normalizeVariants(raw.variants, `${path}.variants`, ["body_template"], issues);
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
- /** Total delay in milliseconds a wait step introduces. */
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
- /** Render a {{column}} template against a row's values (reuses the LinkedIn impl). */
636
- export function renderSequenceTemplate(template, values) {
637
- return renderLinkedInTemplate(template, values);
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
- /** Column keys referenced by {{...}} placeholders across every step's copy. */
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 match of template.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)) {
646
- if (match[1])
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 { subject_template: step.subject_template, body_template: step.body_template };
696
- case "email_reply": return { body_template: step.body_template };
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 index = contents.length > 1 ? hashVariantKey(`${key}:${step.id}`) % contents.length : 0;
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.246.0";
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.246.0";
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.246.0",
3
+ "version": "1.263.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",