@oxygen-agent/cli 1.310.2 → 1.334.3

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.
Files changed (30) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +26 -10
  3. package/dist/help.js +7 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +2077 -134
  6. package/dist/runtime.js +13 -3
  7. package/dist/skills.js +192 -32
  8. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +2 -0
  9. package/node_modules/@oxygen/shared/dist/billing.d.ts +52 -29
  10. package/node_modules/@oxygen/shared/dist/billing.js +77 -55
  11. package/node_modules/@oxygen/shared/dist/index.d.ts +4 -0
  12. package/node_modules/@oxygen/shared/dist/index.js +4 -0
  13. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +84 -0
  14. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +82 -0
  15. package/node_modules/@oxygen/shared/dist/recipes.d.ts +19 -0
  16. package/node_modules/@oxygen/shared/dist/recipes.js +90 -0
  17. package/node_modules/@oxygen/shared/dist/sequences.d.ts +61 -15
  18. package/node_modules/@oxygen/shared/dist/sequences.js +134 -23
  19. package/node_modules/@oxygen/shared/dist/sql-error.d.ts +25 -0
  20. package/node_modules/@oxygen/shared/dist/sql-error.js +46 -0
  21. package/node_modules/@oxygen/shared/dist/version.d.ts +2 -2
  22. package/node_modules/@oxygen/shared/dist/version.js +13 -11
  23. package/node_modules/@oxygen/shared/dist/workflow-mcp-tools.d.ts +4 -0
  24. package/node_modules/@oxygen/shared/dist/workflow-mcp-tools.js +18 -0
  25. package/node_modules/@oxygen/shared/dist/workflow-status-change.d.ts +61 -0
  26. package/node_modules/@oxygen/shared/dist/workflow-status-change.js +124 -0
  27. package/node_modules/@oxygen/shared/dist/workspace-agents.d.ts +65 -0
  28. package/node_modules/@oxygen/shared/dist/workspace-agents.js +67 -0
  29. package/node_modules/@oxygen/workflows/dist/index.js +86 -2
  30. package/package.json +1 -1
@@ -36,7 +36,7 @@ export type SequenceChannel = (typeof SEQUENCE_CHANNELS)[number];
36
36
  * a signal — it's resolved on demand by the dispatcher via a connection branch,
37
37
  * `condition: "already_connected"`.)
38
38
  */
39
- export declare const SEQUENCE_SIGNALS: readonly ["linkedin_connected", "linkedin_replied", "whatsapp_replied", "email_sent", "email_opened", "email_clicked", "email_replied", "email_bounced", "company_hiring", "company_raised_funds", "job_change", "new_hire", "web_visit", "intent"];
39
+ export declare const SEQUENCE_SIGNALS: readonly ["linkedin_connected", "linkedin_replied", "whatsapp_replied", "email_sent", "email_opened", "email_clicked", "email_replied", "email_bounced", "meeting_booked", "email_unsubscribed", "company_hiring", "company_raised_funds", "job_change", "new_hire", "web_visit", "intent"];
40
40
  export type SequenceSignal = (typeof SEQUENCE_SIGNALS)[number];
41
41
  /**
42
42
  * Email engagement signals that ONLY arrive via the Instantly webhook
@@ -177,11 +177,26 @@ export type SequenceSendWindow = {
177
177
  export declare const MAX_STEP_VARIANTS = 26;
178
178
  /**
179
179
  * Metrics a step's opt-in auto-winner (A/B auto-optimize) can decide on. `reply`
180
- * is the only metric today — open/click require tracking domains (a gated,
181
- * separate capability), so the reversible auto-winner ships on reply first.
182
- */
183
- export declare const SEQUENCE_AUTO_OPTIMIZE_METRICS: readonly ["reply"];
180
+ * is the recommended default — it's the strongest intent signal and is produced
181
+ * natively on every channel. `click` and `open` are also supported, but ONLY on a
182
+ * sequence with native open/click tracking enabled (a verified tracking domain +
183
+ * per-sequence open/click toggles): the auto-winner counts opens/clicks from the
184
+ * native tracking-event stream, so without tracking those metrics have no data and
185
+ * are rejected at validation. `open` in particular is a soft signal — Apple Mail
186
+ * Privacy Protection pre-fetches pixels and inflates opens — so prefer `reply`.
187
+ * The two tracking-dependent metrics are named in SEQUENCE_TRACKING_DEPENDENT_METRICS.
188
+ */
189
+ export declare const SEQUENCE_AUTO_OPTIMIZE_METRICS: readonly ["reply", "click", "open"];
184
190
  export type SequenceAutoOptimizeMetric = (typeof SEQUENCE_AUTO_OPTIMIZE_METRICS)[number];
191
+ /**
192
+ * Auto-winner metrics whose conversion counts come ONLY from native open/click
193
+ * tracking (the /api/t/* pixel + link routes feeding the tracking-event stream).
194
+ * A step configuring one of these needs the sequence to have native tracking
195
+ * enabled, or the winner engine sees zero conversions and never decides — the
196
+ * auto-optimize validator rejects it with an actionable message. Mirrors
197
+ * SEQUENCE_NATIVE_UNTRACKED_SIGNALS on the signal side.
198
+ */
199
+ export declare const SEQUENCE_TRACKING_DEPENDENT_METRICS: readonly ["click", "open"];
185
200
  /** Default send floor per variant before the auto-winner may decide. */
186
201
  export declare const DEFAULT_AUTO_OPTIMIZE_MIN_SENDS = 100;
187
202
  /** Default conversion (reply) floor per variant before the auto-winner may decide. */
@@ -235,7 +250,7 @@ export type SequenceLinkedInMessageStep = {
235
250
  variants?: {
236
251
  template?: string;
237
252
  }[];
238
- /** Opt-in reversible auto-winner over the variants (reply metric). */
253
+ /** Opt-in reversible auto-winner over the variants (metric: reply | click | open; reply recommended). */
239
254
  auto_optimize?: SequenceAutoOptimizeConfig;
240
255
  /** Files attached to the message (≤ a few, fetched at dispatch). */
241
256
  attachments?: SequenceLinkedInAttachment[];
@@ -251,7 +266,7 @@ export type SequenceLinkedInInMailStep = {
251
266
  subject_template?: string;
252
267
  template?: string;
253
268
  }[];
254
- /** Opt-in reversible auto-winner over the variants (reply metric). */
269
+ /** Opt-in reversible auto-winner over the variants (metric: reply | click | open; reply recommended). */
255
270
  auto_optimize?: SequenceAutoOptimizeConfig;
256
271
  };
257
272
  export type SequenceLinkedInFollowStep = {
@@ -323,7 +338,7 @@ export type SequenceEmailSendStep = {
323
338
  body_template?: string;
324
339
  body_html_template?: string;
325
340
  }[];
326
- /** Opt-in reversible auto-winner over the variants (reply metric). */
341
+ /** Opt-in reversible auto-winner over the variants (metric: reply | click | open; reply recommended). */
327
342
  auto_optimize?: SequenceAutoOptimizeConfig;
328
343
  /** Per-step send window (overrides the sequence-level email_send_window). */
329
344
  send_window?: SequenceSendWindow;
@@ -345,7 +360,7 @@ export type SequenceEmailReplyStep = {
345
360
  body_template?: string;
346
361
  body_html_template?: string;
347
362
  }[];
348
- /** Opt-in reversible auto-winner over the variants (reply metric). */
363
+ /** Opt-in reversible auto-winner over the variants (metric: reply | click | open; reply recommended). */
349
364
  auto_optimize?: SequenceAutoOptimizeConfig;
350
365
  /** Per-step send window (overrides the sequence-level email_send_window). */
351
366
  send_window?: SequenceSendWindow;
@@ -366,7 +381,7 @@ export type SequenceWhatsAppMessageStep = {
366
381
  variants?: {
367
382
  template?: string;
368
383
  }[];
369
- /** Opt-in reversible auto-winner over the variants (reply metric). */
384
+ /** Opt-in reversible auto-winner over the variants (metric: reply | click | open; reply recommended). */
370
385
  auto_optimize?: SequenceAutoOptimizeConfig;
371
386
  };
372
387
  export type SequenceWaitStep = {
@@ -447,12 +462,38 @@ export type SequenceStopStep = {
447
462
  kind: "stop";
448
463
  };
449
464
  /**
450
- * A boolean expression over the signals an enrollment has accumulated. A leaf
451
- * tests whether a signal is present (default) or absent (present:false).
465
+ * A boolean expression a signal-branch routes on. Two leaf kinds, plus the
466
+ * all/any/not combinators:
467
+ * - a SIGNAL leaf `{ signal, present? }` tests whether an accumulated enrollment
468
+ * signal is present (default) or absent (present:false).
469
+ * - a DATA leaf `{ has_column, present? }` tests whether the lead's source row
470
+ * carries a non-empty value in the named row_values column — present (default)
471
+ * or absent (present:false). This is the LinkedIn/Lemlist "does the lead have an
472
+ * email / phone / … ?" branch: route by what data you HAVE about the lead,
473
+ * evaluated synchronously against the enrollment's row_values (no signal, no
474
+ * wait). A value counts as present when it is a non-empty trimmed string, or any
475
+ * number / boolean; null / undefined / "" / whitespace count as absent.
476
+ *
477
+ * Lemlist condition-key → Oxygen mapping (13 keys), so a Lemlist journey ports
478
+ * across without a lossy translation table:
479
+ * hasEmailAddress → { has_column: "email" } (or any SEQUENCE_EMAIL_COLUMN_KEYS key)
480
+ * hasLinkedinUrl → { has_column: "<linkedin url column>" }
481
+ * hasPhoneNumber → { has_column: "phone" } (or any SEQUENCE_PHONE_COLUMN_KEYS key)
482
+ * linkedinInviteAccepted → a CONNECTION branch (condition: "connection_accepted")
483
+ * opened → { signal: "email_opened" }
484
+ * clicked → { signal: "email_clicked" }
485
+ * replied → { signal: "email_replied" } (or linkedin_replied / whatsapp_replied)
486
+ * meetingBooked → { signal: "meeting_booked" }
487
+ * emailsUnsubscribed → { signal: "email_unsubscribed" }
488
+ * aircallDone → out of scope (no Aircall integration)
489
+ * Negate any leaf with present:false (e.g. "no email on file" = { has_column: "email", present: false }).
452
490
  */
453
491
  export type SequenceSignalCondition = {
454
492
  signal: SequenceSignal;
455
493
  present?: boolean;
494
+ } | {
495
+ has_column: string;
496
+ present?: boolean;
456
497
  } | {
457
498
  all: SequenceSignalCondition[];
458
499
  } | {
@@ -527,10 +568,15 @@ export declare function renderSequenceTemplate(template: string, values: Record<
527
568
  */
528
569
  export declare function sequenceTemplateVariables(definition: SequenceDefinition): string[];
529
570
  /**
530
- * Evaluate a condition against the set of signals an enrollment has fired.
531
- * Used by the dispatch planner for branch steps. Pure.
571
+ * Evaluate a condition against an enrollment's accumulated signals AND (optionally)
572
+ * its source-row values. Used by the dispatch planner for branch steps. Pure.
573
+ *
574
+ * `rowValues` is optional and backward-compatible: when omitted (undefined), a data
575
+ * leaf ({ has_column }) evaluates as ABSENT — present:false matches, present:true
576
+ * does not — so an existing signal-only caller is unaffected. Signal leaves ignore
577
+ * rowValues entirely.
532
578
  */
533
- export declare function evaluateSequenceCondition(condition: SequenceSignalCondition, firedSignals: Iterable<string>): boolean;
579
+ export declare function evaluateSequenceCondition(condition: SequenceSignalCondition, firedSignals: Iterable<string>, rowValues?: Record<string, unknown> | null): boolean;
534
580
  /** Variant label for an index: 0 → "a" (the base step), 1 → "b", … */
535
581
  export declare function sequenceVariantLabel(index: number): string;
536
582
  type StepVariantContent = Record<string, string>;
@@ -45,6 +45,14 @@ export const SEQUENCE_SIGNALS = [
45
45
  "email_clicked",
46
46
  "email_replied",
47
47
  "email_bounced",
48
+ // Outcome + lifecycle signals that CLOSE the Lemlist parity gap (its meetingBooked
49
+ // / emailsUnsubscribed keys). `meeting_booked` is recorded via the published
50
+ // `sequences signal` callable (a monitor, CRM sync, or human marks the meeting —
51
+ // no provider webhook). `email_unsubscribed` is recorded AUTOMATICALLY by the
52
+ // public one-click List-Unsubscribe webhook, folded onto the enrollment exactly
53
+ // like email_bounced. branch / wait_for_signal consume both like any other signal.
54
+ "meeting_booked",
55
+ "email_unsubscribed",
48
56
  // External GTM signals — recorded onto an enrollment by an Oxygen monitor,
49
57
  // enrichment column, or the published `sequences signal` callable (NOT a
50
58
  // provider webhook). branch / wait_for_signal consume these identically to
@@ -256,10 +264,28 @@ export function whatsAppAttendeeIdFromRow(rowValues, phoneColumnKey) {
256
264
  export const MAX_STEP_VARIANTS = 26;
257
265
  /**
258
266
  * Metrics a step's opt-in auto-winner (A/B auto-optimize) can decide on. `reply`
259
- * is the only metric today — open/click require tracking domains (a gated,
260
- * separate capability), so the reversible auto-winner ships on reply first.
267
+ * is the recommended default — it's the strongest intent signal and is produced
268
+ * natively on every channel. `click` and `open` are also supported, but ONLY on a
269
+ * sequence with native open/click tracking enabled (a verified tracking domain +
270
+ * per-sequence open/click toggles): the auto-winner counts opens/clicks from the
271
+ * native tracking-event stream, so without tracking those metrics have no data and
272
+ * are rejected at validation. `open` in particular is a soft signal — Apple Mail
273
+ * Privacy Protection pre-fetches pixels and inflates opens — so prefer `reply`.
274
+ * The two tracking-dependent metrics are named in SEQUENCE_TRACKING_DEPENDENT_METRICS.
261
275
  */
262
- export const SEQUENCE_AUTO_OPTIMIZE_METRICS = ["reply"];
276
+ export const SEQUENCE_AUTO_OPTIMIZE_METRICS = ["reply", "click", "open"];
277
+ /**
278
+ * Auto-winner metrics whose conversion counts come ONLY from native open/click
279
+ * tracking (the /api/t/* pixel + link routes feeding the tracking-event stream).
280
+ * A step configuring one of these needs the sequence to have native tracking
281
+ * enabled, or the winner engine sees zero conversions and never decides — the
282
+ * auto-optimize validator rejects it with an actionable message. Mirrors
283
+ * SEQUENCE_NATIVE_UNTRACKED_SIGNALS on the signal side.
284
+ */
285
+ export const SEQUENCE_TRACKING_DEPENDENT_METRICS = ["click", "open"];
286
+ function metricNeedsNativeTracking(metric) {
287
+ return SEQUENCE_TRACKING_DEPENDENT_METRICS.includes(metric);
288
+ }
263
289
  /** Default send floor per variant before the auto-winner may decide. */
264
290
  export const DEFAULT_AUTO_OPTIMIZE_MIN_SENDS = 100;
265
291
  /** Default conversion (reply) floor per variant before the auto-winner may decide. */
@@ -432,6 +458,12 @@ function reportNativeEngagementGates(steps, issues) {
432
458
  function collectConditionSignals(condition, out) {
433
459
  if ("signal" in condition)
434
460
  out.add(condition.signal);
461
+ // A data leaf ({ has_column }) references a row_values column, not a signal, so
462
+ // it contributes nothing — and must be handled BEFORE the not-fallthrough below
463
+ // (which would otherwise read condition.not off a data leaf). This is also why
464
+ // the native-untracked engagement-gate walker never trips on a has_column branch.
465
+ else if ("has_column" in condition)
466
+ return;
435
467
  else if ("all" in condition)
436
468
  condition.all.forEach((c) => collectConditionSignals(c, out));
437
469
  else if ("any" in condition)
@@ -496,7 +528,7 @@ raw, index, options, issues) {
496
528
  case "message": {
497
529
  const template = requiredTemplate(raw.template, `${path}.template`, issues);
498
530
  const variants = normalizeVariants(raw.variants, `${path}.variants`, ["template"], issues);
499
- const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, issues);
531
+ const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
500
532
  const attachments = normalizeLinkedInAttachments(raw.attachments, `${path}.attachments`, issues);
501
533
  return {
502
534
  id, channel: "linkedin", kind: "message", template: template ?? "",
@@ -509,7 +541,7 @@ raw, index, options, issues) {
509
541
  const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues);
510
542
  const template = requiredTemplate(raw.template, `${path}.template`, issues);
511
543
  const variants = normalizeVariants(raw.variants, `${path}.variants`, ["subject_template", "template"], issues);
512
- const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, issues);
544
+ const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
513
545
  return {
514
546
  id, channel: "linkedin", kind: "inmail", subject_template: subject ?? "", template: template ?? "",
515
547
  ...(variants ? { variants: variants } : {}),
@@ -534,7 +566,7 @@ raw, index, options, issues) {
534
566
  const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues);
535
567
  const bodyHtml = optionalTemplate(raw.body_html_template, `${path}.body_html_template`, MAX_TEMPLATE_LENGTH, issues);
536
568
  const variants = normalizeVariants(raw.variants, `${path}.variants`, ["subject_template", "body_template", "body_html_template"], issues);
537
- const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, issues);
569
+ const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
538
570
  const cc = optionalEmailList(raw.cc, `${path}.cc`, issues);
539
571
  const bcc = optionalEmailList(raw.bcc, `${path}.bcc`, issues);
540
572
  const sendWindow = normalizeSendWindow(raw.send_window, `${path}.send_window`, issues);
@@ -552,7 +584,7 @@ raw, index, options, issues) {
552
584
  const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues);
553
585
  const bodyHtml = optionalTemplate(raw.body_html_template, `${path}.body_html_template`, MAX_TEMPLATE_LENGTH, issues);
554
586
  const variants = normalizeVariants(raw.variants, `${path}.variants`, ["body_template", "body_html_template"], issues);
555
- const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, issues);
587
+ const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
556
588
  const cc = optionalEmailList(raw.cc, `${path}.cc`, issues);
557
589
  const bcc = optionalEmailList(raw.bcc, `${path}.bcc`, issues);
558
590
  const sendWindow = normalizeSendWindow(raw.send_window, `${path}.send_window`, issues);
@@ -571,7 +603,7 @@ raw, index, options, issues) {
571
603
  case "whatsapp_message": {
572
604
  const template = requiredTemplate(raw.template, `${path}.template`, issues);
573
605
  const variants = normalizeVariants(raw.variants, `${path}.variants`, ["template"], issues);
574
- const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, issues);
606
+ const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
575
607
  return {
576
608
  id, channel: "whatsapp", kind: "whatsapp_message", template: template ?? "",
577
609
  ...(variants ? { variants: variants } : {}),
@@ -662,13 +694,22 @@ raw, index, options, issues) {
662
694
  };
663
695
  }
664
696
  case "comment_post": {
665
- // ai_prompt (AI-generated comments) is a planned follow-up; today a
666
- // comment_post step needs a text_template (with {{column}} interpolation).
667
- const textTemplate = requiredTemplate(raw.text_template, `${path}.text_template`, issues);
697
+ // A comment step needs a comment source: a text_template ({{column}}
698
+ // interpolation), an ai_prompt (a KG-grounded comment generated at send
699
+ // time), or both — in which case ai_prompt generates and text_template is
700
+ // the fallback when generation fails. At least one is required.
701
+ const textTemplate = optionalTemplate(raw.text_template, `${path}.text_template`, MAX_TEMPLATE_LENGTH, issues);
702
+ const aiPrompt = optionalTemplate(raw.ai_prompt, `${path}.ai_prompt`, MAX_TEMPLATE_LENGTH, issues);
703
+ const hasText = typeof textTemplate === "string" && textTemplate.trim().length > 0;
704
+ const hasPrompt = typeof aiPrompt === "string" && aiPrompt.trim().length > 0;
705
+ if (!hasText && !hasPrompt) {
706
+ issues.push({ path, message: "comment_post requires at least one of text_template or ai_prompt." });
707
+ }
668
708
  const recency = optionalPostRecencyDays(raw.post_recency_days, `${path}.post_recency_days`, issues);
669
709
  return {
670
710
  id, channel: "linkedin", kind: "comment_post",
671
- text_template: textTemplate ?? "",
711
+ ...(hasText ? { text_template: textTemplate } : {}),
712
+ ...(hasPrompt ? { ai_prompt: aiPrompt } : {}),
672
713
  ...(recency !== undefined ? { post_recency_days: recency } : {}),
673
714
  ...(raw.on_no_post === "stop" ? { on_no_post: "stop" } : {}),
674
715
  };
@@ -753,6 +794,26 @@ function normalizeTargetRef(value, path, issues) {
753
794
  }
754
795
  return value;
755
796
  }
797
+ /** Max length of a data-leaf column key — bounded like an identifier, not template-length. */
798
+ const MAX_CONDITION_COLUMN_KEY_LENGTH = 128;
799
+ /**
800
+ * Validate + normalize a data leaf's `has_column` row_values key: a non-empty
801
+ * string, trimmed, at most MAX_CONDITION_COLUMN_KEY_LENGTH chars. Unlike a step id
802
+ * it is NOT charset-restricted — a column key can carry spaces/capitals (e.g.
803
+ * "Email", "Work Email"). Returns the trimmed key, or undefined with an issue.
804
+ */
805
+ function normalizeConditionColumnKey(value, path, issues) {
806
+ if (typeof value !== "string" || !value.trim()) {
807
+ issues.push({ path, message: "has_column must be a non-empty string (a row_values column key)." });
808
+ return undefined;
809
+ }
810
+ const key = value.trim();
811
+ if (key.length > MAX_CONDITION_COLUMN_KEY_LENGTH) {
812
+ issues.push({ path, message: `has_column must be at most ${MAX_CONDITION_COLUMN_KEY_LENGTH} characters.` });
813
+ return undefined;
814
+ }
815
+ return key;
816
+ }
756
817
  function normalizeCondition(raw, path, issues, depth = 0) {
757
818
  if (depth > 6) {
758
819
  issues.push({ path, message: "condition nests too deeply." });
@@ -768,6 +829,12 @@ function normalizeCondition(raw, path, issues, depth = 0) {
768
829
  return undefined;
769
830
  return { signal, present: raw.present === false ? false : true };
770
831
  }
832
+ if (raw.has_column !== undefined) {
833
+ const key = normalizeConditionColumnKey(raw.has_column, `${path}.has_column`, issues);
834
+ if (!key)
835
+ return undefined;
836
+ return { has_column: key, present: raw.present === false ? false : true };
837
+ }
771
838
  if (Array.isArray(raw.all)) {
772
839
  return { all: normalizeConditionList(raw.all, `${path}.all`, issues, depth) };
773
840
  }
@@ -778,7 +845,7 @@ function normalizeCondition(raw, path, issues, depth = 0) {
778
845
  const inner = normalizeCondition(raw.not, `${path}.not`, issues, depth + 1);
779
846
  return inner ? { not: inner } : undefined;
780
847
  }
781
- issues.push({ path, message: "condition must be {signal}, {all}, {any}, or {not}." });
848
+ issues.push({ path, message: "condition must be {signal}, {has_column}, {all}, {any}, or {not}." });
782
849
  return undefined;
783
850
  }
784
851
  function normalizeConditionList(raw, path, issues, depth) {
@@ -958,20 +1025,49 @@ export function sequenceTemplateVariables(definition) {
958
1025
  return [...vars];
959
1026
  }
960
1027
  /**
961
- * Evaluate a condition against the set of signals an enrollment has fired.
962
- * Used by the dispatch planner for branch steps. Pure.
1028
+ * Evaluate a condition against an enrollment's accumulated signals AND (optionally)
1029
+ * its source-row values. Used by the dispatch planner for branch steps. Pure.
1030
+ *
1031
+ * `rowValues` is optional and backward-compatible: when omitted (undefined), a data
1032
+ * leaf ({ has_column }) evaluates as ABSENT — present:false matches, present:true
1033
+ * does not — so an existing signal-only caller is unaffected. Signal leaves ignore
1034
+ * rowValues entirely.
963
1035
  */
964
- export function evaluateSequenceCondition(condition, firedSignals) {
1036
+ export function evaluateSequenceCondition(condition, firedSignals, rowValues) {
965
1037
  const fired = firedSignals instanceof Set ? firedSignals : new Set(firedSignals);
966
1038
  if ("signal" in condition) {
967
1039
  const present = fired.has(condition.signal);
968
1040
  return condition.present === false ? !present : present;
969
1041
  }
1042
+ if ("has_column" in condition) {
1043
+ const present = rowValueIsPresent(rowValues?.[condition.has_column]);
1044
+ return condition.present === false ? !present : present;
1045
+ }
970
1046
  if ("all" in condition)
971
- return condition.all.every((c) => evaluateSequenceCondition(c, fired));
1047
+ return condition.all.every((c) => evaluateSequenceCondition(c, fired, rowValues));
972
1048
  if ("any" in condition)
973
- return condition.any.some((c) => evaluateSequenceCondition(c, fired));
974
- return !evaluateSequenceCondition(condition.not, fired);
1049
+ return condition.any.some((c) => evaluateSequenceCondition(c, fired, rowValues));
1050
+ return !evaluateSequenceCondition(condition.not, fired, rowValues);
1051
+ }
1052
+ /**
1053
+ * Whether a lead's row_values value counts as "present" for a data-leaf branch: a
1054
+ * non-empty trimmed string, or any number (incl. 0) / boolean (incl. false), or a
1055
+ * non-empty array / object. null / undefined / "" / whitespace-only → absent. Pure.
1056
+ */
1057
+ function rowValueIsPresent(value) {
1058
+ if (value === null || value === undefined)
1059
+ return false;
1060
+ if (typeof value === "string")
1061
+ return value.trim().length > 0;
1062
+ if (typeof value === "number")
1063
+ return !Number.isNaN(value);
1064
+ if (typeof value === "boolean")
1065
+ return true;
1066
+ if (Array.isArray(value))
1067
+ return value.length > 0;
1068
+ if (typeof value === "object")
1069
+ return Object.keys(value).length > 0;
1070
+ return true;
975
1071
  }
976
1072
  // ===== A/B variants =====
977
1073
  const VARIANT_ALPHABET = "abcdefghijklmnopqrstuvwxyz";
@@ -1209,11 +1305,16 @@ const MAX_AUTO_OPTIMIZE_MIN_CONVERSIONS = 100_000;
1209
1305
  * Validate + normalize a step's optional `auto_optimize` config. Only meaningful
1210
1306
  * on a step that actually has A/B alternates (variants.length >= 1, i.e. ≥ 2 total
1211
1307
  * variants) — configuring it on a single-variant step is a dead knob, so reject
1212
- * it. `metric` must be a supported metric (reply today); the thresholds default to
1213
- * DEFAULT_AUTO_OPTIMIZE_* and must be positive integers within sane bounds.
1214
- * Returns undefined when absent (field omitted).
1308
+ * it. `metric` must be a supported metric (reply | click | open); the thresholds
1309
+ * default to DEFAULT_AUTO_OPTIMIZE_* and must be positive integers within sane
1310
+ * bounds. The tracking-dependent metrics (click/open) are rejected unless the
1311
+ * sequence has native open/click tracking enabled (options.nativeTrackingEnabled)
1312
+ * — the SAME gate the open/click SIGNAL validation uses — because the winner
1313
+ * engine counts those conversions only from the native tracking-event stream, so
1314
+ * without tracking they would always read zero and never decide. Returns undefined
1315
+ * when absent (field omitted).
1215
1316
  */
1216
- function normalizeAutoOptimize(raw, path, variants, issues) {
1317
+ function normalizeAutoOptimize(raw, path, variants, options, issues) {
1217
1318
  if (raw === undefined || raw === null)
1218
1319
  return undefined;
1219
1320
  if (!isRecord(raw)) {
@@ -1229,6 +1330,16 @@ function normalizeAutoOptimize(raw, path, variants, issues) {
1229
1330
  issues.push({ path: `${path}.metric`, message: `metric must be one of: ${SEQUENCE_AUTO_OPTIMIZE_METRICS.join(", ")}.` });
1230
1331
  return undefined;
1231
1332
  }
1333
+ // Tracking gate: click/open conversions come only from native open/click
1334
+ // tracking. Reject them when the sequence has no native tracking, naming the
1335
+ // prerequisite — reply stays available and is the recommended default.
1336
+ if (metricNeedsNativeTracking(metric) && !options.nativeTrackingEnabled) {
1337
+ issues.push({
1338
+ path: `${path}.metric`,
1339
+ message: `metric '${metric}' needs native open/click tracking, which is not enabled for this sequence — turn on a verified tracking domain and per-sequence open/click tracking (see 'oxygen domains tracking'), or use metric 'reply' (the recommended default; opens are soft signals under Apple Mail Privacy Protection).`,
1340
+ });
1341
+ return undefined;
1342
+ }
1232
1343
  const minSends = raw.min_sends_per_variant === undefined || raw.min_sends_per_variant === null
1233
1344
  ? DEFAULT_AUTO_OPTIMIZE_MIN_SENDS
1234
1345
  : positiveInt(raw.min_sends_per_variant, `${path}.min_sends_per_variant`, issues) ?? DEFAULT_AUTO_OPTIMIZE_MIN_SENDS;
@@ -20,6 +20,14 @@ export type SqlErrorAttribution = {
20
20
  constraint: string | null;
21
21
  /** Neon/HTTP transport status code (numeric) when the failure carries one. */
22
22
  statusCode: number | null;
23
+ /**
24
+ * pg's `detail` for deadlocks (40P01) ONLY: the waits-for lock chain
25
+ * ("Process A waits for ...; blocked by process B."), which names processes,
26
+ * transactions, and lock modes — never customer row values — and is the only
27
+ * way to attribute which statements deadlocked. Every other SQLSTATE keeps
28
+ * the blanket OXY-38 detail ban (unique-violation details embed row values).
29
+ */
30
+ deadlockDetail: string | null;
23
31
  };
24
32
  /** Log-field shape (snake_case) for merging into `errorFields(error)` payloads. */
25
33
  export declare function sqlErrorFields(error: unknown): Record<string, unknown>;
@@ -45,5 +53,22 @@ export declare function redactSqlParameters(text: string): string;
45
53
  * 40001/40P01 knowledge lives in one place. Never throws.
46
54
  */
47
55
  export declare function isRetryableConcurrencyError(error?: unknown): boolean;
56
+ /**
57
+ * True when a write lost a concurrency race and is safe to re-read / retry: a
58
+ * serialization (40001) or deadlock (40P01) transaction abort, OR the XX000
59
+ * catalog race Postgres reports as "tuple concurrently updated". The SQLSTATE
60
+ * classifier deliberately maps the latter to `internal` (it is NOT a
61
+ * transaction-abort code), so `isRetryableConcurrencyError` misses it and it is
62
+ * matched by message here — the dual test the tenant-migration bookkeeping write
63
+ * and the role-grant apply both need.
64
+ *
65
+ * Both the SQLSTATE and the message are read through the `Error.cause` chain, so
66
+ * this stays correct when the error is a drizzle `DrizzleQueryError` — which
67
+ * nests the real pg error on `.cause` and replaces the top-level message with
68
+ * `Failed query: <sql>`. A top-level-only read (the pre-consolidation apps/web +
69
+ * tenant-db copies) was structurally blind to every drizzle-wrapped conflict
70
+ * (OXY-4148). Never throws.
71
+ */
72
+ export declare function isConcurrentWriteConflictError(error?: unknown): boolean;
48
73
  /** Telemetry-attribute shape (dotted keys) for span error attribution. */
49
74
  export declare function sqlErrorTelemetryAttributes(error: unknown): Record<string, unknown>;
@@ -84,6 +84,7 @@ function describeSqlError(error) {
84
84
  // or transport status codes — safe to surface. We deliberately do NOT read
85
85
  // pg's `detail`, `hint`, or `where`: those embed customer row values (e.g.
86
86
  // "Key (email)=(a@b.com) already exists") and would leak data (OXY-38).
87
+ // Single carve-out: 40P01 deadlock detail (see readDeadlockDetail).
87
88
  return {
88
89
  pgCode: sqlstate,
89
90
  cause,
@@ -95,6 +96,7 @@ function describeSqlError(error) {
95
96
  column: readIdentifierField(error, "column"),
96
97
  constraint: readIdentifierField(error, "constraint"),
97
98
  statusCode: readStatusCode(error),
99
+ deadlockDetail: sqlstate === "40P01" ? readDeadlockDetail(error) : null,
98
100
  };
99
101
  }
100
102
  catch {
@@ -130,6 +132,7 @@ export function sqlErrorFields(error) {
130
132
  ...(attribution.column ? { sql_error_column: attribution.column } : {}),
131
133
  ...(attribution.constraint ? { sql_error_constraint: attribution.constraint } : {}),
132
134
  ...(attribution.statusCode !== null ? { sql_error_status: attribution.statusCode } : {}),
135
+ ...(attribution.deadlockDetail ? { sql_error_detail: attribution.deadlockDetail } : {}),
133
136
  };
134
137
  }
135
138
  /**
@@ -161,6 +164,32 @@ export function isRetryableConcurrencyError(error) {
161
164
  const cause = describeSqlError(error)?.cause;
162
165
  return cause === "serialization" || cause === "deadlock";
163
166
  }
167
+ /**
168
+ * True when a write lost a concurrency race and is safe to re-read / retry: a
169
+ * serialization (40001) or deadlock (40P01) transaction abort, OR the XX000
170
+ * catalog race Postgres reports as "tuple concurrently updated". The SQLSTATE
171
+ * classifier deliberately maps the latter to `internal` (it is NOT a
172
+ * transaction-abort code), so `isRetryableConcurrencyError` misses it and it is
173
+ * matched by message here — the dual test the tenant-migration bookkeeping write
174
+ * and the role-grant apply both need.
175
+ *
176
+ * Both the SQLSTATE and the message are read through the `Error.cause` chain, so
177
+ * this stays correct when the error is a drizzle `DrizzleQueryError` — which
178
+ * nests the real pg error on `.cause` and replaces the top-level message with
179
+ * `Failed query: <sql>`. A top-level-only read (the pre-consolidation apps/web +
180
+ * tenant-db copies) was structurally blind to every drizzle-wrapped conflict
181
+ * (OXY-4148). Never throws.
182
+ */
183
+ export function isConcurrentWriteConflictError(error) {
184
+ return isRetryableConcurrencyError(error) || causeChainMessageIncludes(error, "tuple concurrently updated");
185
+ }
186
+ // Walk the Error.cause chain and report whether any message in it contains the
187
+ // needle. drizzle buries the driver's message on `.cause.message` (its own
188
+ // top-level message is `Failed query: ...`), so a single-level `.message` read
189
+ // misses "tuple concurrently updated" once the error is wrapped.
190
+ function causeChainMessageIncludes(error, needle) {
191
+ return (readCauseChain(error, (obj) => typeof obj.message === "string" && obj.message.includes(needle) ? true : null) === true);
192
+ }
164
193
  /** Telemetry-attribute shape (dotted keys) for span error attribution. */
165
194
  export function sqlErrorTelemetryAttributes(error) {
166
195
  const attribution = describeSqlError(error);
@@ -264,6 +293,23 @@ function readIdentifierField(error, key) {
264
293
  return typeof value === "string" && isSafeIdentifier(value) ? value : null;
265
294
  });
266
295
  }
296
+ // pg's deadlock (40P01) detail is the waits-for lock chain: "Process 123 waits
297
+ // for ShareLock on transaction 456; blocked by process 789." — process ids,
298
+ // transaction ids, and lock modes only, never customer row values. That chain
299
+ // is the only client-visible attribution of WHICH statements deadlocked, so it
300
+ // is surfaced as the single exception to the OXY-38 detail ban. Defence in
301
+ // depth: only accepted when the text actually matches the waits-for shape (an
302
+ // unexpected driver payload is dropped) and capped in length.
303
+ const DEADLOCK_DETAIL_SHAPE = /^process \d+ waits for /i;
304
+ const DEADLOCK_DETAIL_MAX_LENGTH = 600;
305
+ function readDeadlockDetail(error) {
306
+ return readCauseChain(error, (obj) => {
307
+ const value = obj.detail;
308
+ return typeof value === "string" && DEADLOCK_DETAIL_SHAPE.test(value)
309
+ ? value.slice(0, DEADLOCK_DETAIL_MAX_LENGTH)
310
+ : null;
311
+ });
312
+ }
267
313
  function readSeverity(error) {
268
314
  return readCauseChain(error, (obj) => {
269
315
  const value = obj.severity;
@@ -1,3 +1,3 @@
1
- export declare const OXYGEN_VERSION = "1.310.2";
1
+ export declare const OXYGEN_VERSION = "1.334.3";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
3
- export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.298.0";
3
+ export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.310.2";
1
+ export const OXYGEN_VERSION = "1.334.3";
2
2
  // The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
3
3
  // operational route. Raising it hard-rejects every older CLI from the entire
4
4
  // product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
@@ -20,20 +20,22 @@ export const OXYGEN_VERSION = "1.310.2";
20
20
  // (see MANAGED_INBOX_MINIMUM_CLI_VERSION). A per-route floor fails one command
21
21
  // safely; a global floor fails the whole product. OXY-4091.
22
22
  //
23
- // 1.181.0: paid table action runs and background columns run require
23
+ // 1.328.1: paid table action runs and background columns run require
24
24
  // approved=true in addition to max_credits; older CLIs cannot send the flag.
25
- // 1.154.0: LinkedIn → Sequencer rename moved the CLI/API/MCP surface
25
+ // 1.328.1: LinkedIn → Sequencer rename moved the CLI/API/MCP surface
26
26
  // (oxygen sequences|inbox|senders, /api/cli/{sequences,inbox,senders}) and
27
27
  // removed the old /api/cli/linkedin/* routes — older CLIs would 404.
28
28
  export const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
29
29
  // Per-surface floor for the whitelabel/managed-inbox purchase path, enforced only
30
30
  // by /api/cli/managed-inboxes/subscribe.
31
31
  //
32
- // 1.298.0: whitelabel inbox subscribe moved from Oxygen credits to a USD-billed
33
- // Stripe subscription. Older CLIs send `zapshield` (gone), omit the registrant
34
- // address the registrar now requires, and read `credits_required` from a preview
35
- // that no longer returns it — so they would mis-render the price of a PAID order.
36
- // Refusing them is right; refusing them EVERYWHERE was not. This shipped as a
37
- // 117-minor raise of the global floor inside an unrelated feature commit and armed
38
- // a product-wide lockout that nothing would have caught before prod (OXY-4091).
39
- export const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.298.0";
32
+ // 1.326.2: Pricing Model 2.0 (2026-07-14) moved whitelabel inbox subscribe from the
33
+ // USD Stripe rail back to Oxygen credits: the preview now returns
34
+ // monthly_credits/one_off_credits and the approved execute RESERVES credits instead
35
+ // of creating a Stripe subscription. An older CLI mis-renders the price/shape of a
36
+ // PAID order (and would misread the reserved-not-charged response), so it is refused
37
+ // on THIS route only. The floor lags one release per OXY-4091: the 1.326.2 CLI
38
+ // publishes from the 1.326.x prod deploy, and this floor rides the 1.330.0 release —
39
+ // so it can never exceed npm's newest CLI. (1.298.0 was the prior credits→Stripe
40
+ // contract move.) Do NOT raise the global OXYGEN_MINIMUM_CLI_VERSION for this.
41
+ export const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
@@ -0,0 +1,4 @@
1
+ export declare const WORKFLOW_MCP_TOOL_PREFIX = "oxygen_workflow_";
2
+ export declare const MAX_MCP_TOOL_NAME_LENGTH = 64;
3
+ export declare function workflowMcpToolName(slug: string): string;
4
+ export declare function workflowMcpToolNameExceedsLimit(slug: string): boolean;
@@ -0,0 +1,18 @@
1
+ // The MCP tool a workflow published via `oxygen workflows mcp enable` appears as
2
+ // to MCP clients — the "Clay Functions" dynamic-tool pattern. The slug is used
3
+ // verbatim (hyphens preserved). This is the single canonical name builder,
4
+ // shared by the mcp-server selector, the CLI, and the web routes so the tool
5
+ // name can never drift between the surface that publishes it, the surface that
6
+ // lists it, and the surface that dispatches it.
7
+ export const WORKFLOW_MCP_TOOL_PREFIX = "oxygen_workflow_";
8
+ // MCP tool names are capped at 64 chars. A slug long enough to push
9
+ // `oxygen_workflow_<slug>` past this is SKIPPED from tools/list (never truncated:
10
+ // a truncated name would collide across distinct slugs and dispatch to the wrong
11
+ // workflow).
12
+ export const MAX_MCP_TOOL_NAME_LENGTH = 64;
13
+ export function workflowMcpToolName(slug) {
14
+ return `${WORKFLOW_MCP_TOOL_PREFIX}${slug}`;
15
+ }
16
+ export function workflowMcpToolNameExceedsLimit(slug) {
17
+ return workflowMcpToolName(slug).length > MAX_MCP_TOOL_NAME_LENGTH;
18
+ }