@oxygen-agent/cli 1.1014.1600 → 1.1014.1763

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 (127) hide show
  1. package/README.md +1 -1
  2. package/dist/command-families/program.js +4 -0
  3. package/dist/command-manifest.js +18 -3
  4. package/dist/command-support.js +1 -1
  5. package/dist/commands/admin.js +82 -1
  6. package/dist/commands/billing.js +1 -1
  7. package/dist/commands/copilot.js +29 -7
  8. package/dist/commands/custom-integrations.js +13 -0
  9. package/dist/commands/domains.js +7 -0
  10. package/dist/commands/email-assets.d.ts +3 -0
  11. package/dist/commands/email-assets.js +78 -0
  12. package/dist/commands/email-templates.d.ts +3 -0
  13. package/dist/commands/email-templates.js +101 -0
  14. package/dist/commands/inbox.js +1 -1
  15. package/dist/commands/integrations.js +5 -1
  16. package/dist/commands/mailboxes.js +26 -6
  17. package/dist/commands/managed-inboxes.js +2 -2
  18. package/dist/commands/senders.js +4 -4
  19. package/dist/commands/sequences.js +47 -12
  20. package/dist/commands/subscriptions.d.ts +2 -4
  21. package/dist/commands/subscriptions.js +297 -51
  22. package/dist/credential-storage/store.d.ts +10 -0
  23. package/dist/credential-storage/store.js +282 -0
  24. package/dist/credentials.d.ts +1 -3
  25. package/dist/credentials.js +3 -275
  26. package/dist/mailbox-fleet-output.js +11 -2
  27. package/dist/sequence-start-at.d.ts +7 -0
  28. package/dist/sequence-start-at.js +21 -0
  29. package/dist/sequences-brand-kit-command.d.ts +28 -0
  30. package/dist/sequences-brand-kit-command.js +83 -0
  31. package/dist/sequences-content-commands.d.ts +28 -0
  32. package/dist/sequences-content-commands.js +176 -0
  33. package/dist/sequences-newsletter-commands.d.ts +6 -0
  34. package/dist/sequences-newsletter-commands.js +108 -0
  35. package/dist/sequences-test-send-command.d.ts +3 -0
  36. package/dist/sequences-test-send-command.js +47 -0
  37. package/dist/workspace-placement-commands.js +3 -2
  38. package/node_modules/@oxygen/formula/dist/evaluate.js +2 -4
  39. package/node_modules/@oxygen/formula/dist/expression.d.ts +7 -5
  40. package/node_modules/@oxygen/formula/dist/expression.js +26 -4
  41. package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +6 -0
  42. package/node_modules/@oxygen/formula/dist/formula-functions.js +55 -0
  43. package/node_modules/@oxygen/shared/dist/billing.d.ts +2 -2
  44. package/node_modules/@oxygen/shared/dist/billing.js +5 -7
  45. package/node_modules/@oxygen/shared/dist/bulk-email-pacing.d.ts +105 -0
  46. package/node_modules/@oxygen/shared/dist/bulk-email-pacing.js +125 -0
  47. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +29 -0
  48. package/node_modules/@oxygen/shared/dist/capability-discovery.js +236 -18
  49. package/node_modules/@oxygen/shared/dist/clay-migration-auth-state.d.ts +7 -1
  50. package/node_modules/@oxygen/shared/dist/clay-migration-auth-state.js +6 -5
  51. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -0
  52. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.d.ts +40 -1
  53. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.js +60 -6
  54. package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.d.ts +24 -0
  55. package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.js +7 -0
  56. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +2 -1
  57. package/node_modules/@oxygen/shared/dist/contracts/teammates.js +6 -1
  58. package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.js +1 -1
  59. package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.d.ts +1 -1
  60. package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.js +2 -2
  61. package/node_modules/@oxygen/shared/dist/email-brand-kit.d.ts +108 -0
  62. package/node_modules/@oxygen/shared/dist/email-brand-kit.js +214 -0
  63. package/node_modules/@oxygen/shared/dist/email-classification-scope.d.ts +35 -0
  64. package/node_modules/@oxygen/shared/dist/email-classification-scope.js +40 -0
  65. package/node_modules/@oxygen/shared/dist/email-delivery.d.ts +10 -0
  66. package/node_modules/@oxygen/shared/dist/email-delivery.js +14 -0
  67. package/node_modules/@oxygen/shared/dist/email-documents.d.ts +61 -0
  68. package/node_modules/@oxygen/shared/dist/email-documents.js +143 -0
  69. package/node_modules/@oxygen/shared/dist/email-dsn.js +52 -10
  70. package/node_modules/@oxygen/shared/dist/email-html-sanitize.d.ts +23 -0
  71. package/node_modules/@oxygen/shared/dist/email-html-sanitize.js +96 -0
  72. package/node_modules/@oxygen/shared/dist/email-subscriptions.d.ts +63 -1
  73. package/node_modules/@oxygen/shared/dist/email-subscriptions.js +149 -1
  74. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +41 -2
  75. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +50 -1
  76. package/node_modules/@oxygen/shared/dist/native-chat.d.ts +9 -0
  77. package/node_modules/@oxygen/shared/dist/native-chat.js +12 -1
  78. package/node_modules/@oxygen/shared/dist/newsletter-analytics.d.ts +90 -0
  79. package/node_modules/@oxygen/shared/dist/newsletter-analytics.js +28 -0
  80. package/node_modules/@oxygen/shared/dist/newsletter-test-send.d.ts +20 -0
  81. package/node_modules/@oxygen/shared/dist/newsletter-test-send.js +46 -0
  82. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +11 -0
  83. package/node_modules/@oxygen/shared/dist/object-storage.js +12 -0
  84. package/node_modules/@oxygen/shared/dist/onboarding-research.d.ts +9 -2
  85. package/node_modules/@oxygen/shared/dist/onboarding-research.js +9 -2
  86. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +9 -0
  87. package/node_modules/@oxygen/shared/dist/plan-band.js +17 -0
  88. package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +4 -2
  89. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +12 -1
  90. package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +2 -0
  91. package/node_modules/@oxygen/shared/dist/plan-limits-view.js +3 -0
  92. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +5 -0
  93. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +5 -0
  94. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +29 -0
  95. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +29 -0
  96. package/node_modules/@oxygen/shared/dist/recipes.d.ts +2 -0
  97. package/node_modules/@oxygen/shared/dist/recipes.js +13 -0
  98. package/node_modules/@oxygen/shared/dist/sending-domain-faults.d.ts +21 -0
  99. package/node_modules/@oxygen/shared/dist/sending-domain-faults.js +53 -0
  100. package/node_modules/@oxygen/shared/dist/sending-domain-rollout.d.ts +30 -0
  101. package/node_modules/@oxygen/shared/dist/sending-domain-rollout.js +75 -0
  102. package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +10 -1
  103. package/node_modules/@oxygen/shared/dist/sequence-failures.js +68 -6
  104. package/node_modules/@oxygen/shared/dist/sequence-lifecycle.d.ts +34 -3
  105. package/node_modules/@oxygen/shared/dist/sequence-lifecycle.js +57 -4
  106. package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +17 -0
  107. package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
  108. package/node_modules/@oxygen/shared/dist/sequences.d.ts +41 -0
  109. package/node_modules/@oxygen/shared/dist/sequences.js +86 -4
  110. package/node_modules/@oxygen/shared/dist/ses-identity-policy.d.ts +19 -0
  111. package/node_modules/@oxygen/shared/dist/ses-identity-policy.js +54 -0
  112. package/node_modules/@oxygen/shared/dist/skill-play-routing.d.ts +22 -0
  113. package/node_modules/@oxygen/shared/dist/skill-play-routing.js +75 -0
  114. package/node_modules/@oxygen/shared/dist/skill-plays.generated.d.ts +5 -0
  115. package/node_modules/@oxygen/shared/dist/skill-plays.generated.js +269 -0
  116. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +7 -0
  117. package/node_modules/@oxygen/shared/dist/spend-safety.js +7 -0
  118. package/node_modules/@oxygen/shared/dist/teammates.js +2 -1
  119. package/node_modules/@oxygen/shared/dist/tool-call-arguments.d.ts +24 -0
  120. package/node_modules/@oxygen/shared/dist/tool-call-arguments.js +68 -0
  121. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +15 -1
  122. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  123. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  124. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +2 -2
  125. package/node_modules/@oxygen/shared/dist/workspace-placement.d.ts +3 -0
  126. package/node_modules/@oxygen/shared/dist/workspace-placement.js +11 -0
  127. package/package.json +2 -2
@@ -1,5 +1,51 @@
1
- /** Every lifecycle a person reads: the stored statuses plus the derived `completed`. */
2
- export const SEQUENCE_LIFECYCLES = ["draft", "active", "completed", "paused", "archived"];
1
+ /**
2
+ * Every lifecycle a person reads: the stored statuses plus the derived
3
+ * `scheduled` and `completed`.
4
+ */
5
+ export const SEQUENCE_LIFECYCLES = ["draft", "scheduled", "active", "completed", "paused", "archived"];
6
+ /** How far ahead a scheduled start may be set. */
7
+ export const SEQUENCE_START_AT_MAX_DAYS = 30;
8
+ const DAY_MS = 86_400_000;
9
+ /**
10
+ * The stored scheduled start (`settings.start_at`) when it is still ahead of
11
+ * `now`, else null. A past, absent or unreadable value means the Sequence runs
12
+ * as soon as it is active.
13
+ */
14
+ export function sequenceScheduledStartAt(settings, now = new Date()) {
15
+ const raw = settings?.start_at;
16
+ if (typeof raw !== "string")
17
+ return null;
18
+ const ms = Date.parse(raw);
19
+ return Number.isFinite(ms) && ms > now.getTime() ? new Date(ms) : null;
20
+ }
21
+ const ISO_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-](\d{2}):(\d{2}))$/;
22
+ /**
23
+ * Read a requested scheduled start: an ISO 8601 timestamp with a timezone
24
+ * (`Z` or `±hh:mm`), in the future and at most {@link SEQUENCE_START_AT_MAX_DAYS}
25
+ * days ahead. Returns the canonical UTC form (`toISOString`) that is stored, so
26
+ * every stored value is one Postgres can cast.
27
+ */
28
+ export function parseSequenceStartAt(value, now = new Date()) {
29
+ const shape = typeof value === "string" ? ISO_TIMESTAMP.exec(value.trim()) : null;
30
+ if (!shape) {
31
+ return { ok: false, message: "start_at must be an ISO 8601 timestamp with a timezone, e.g. 2026-11-02T09:00:00Z." };
32
+ }
33
+ const [, year, month, day, hour, minute, second, , offsetHour, offsetMinute] = shape;
34
+ const daysInMonth = new Date(Date.UTC(Number(year), Number(month), 0)).getUTCDate();
35
+ const ms = Date.parse(value);
36
+ if (Number(month) < 1 || Number(month) > 12 || Number(day) < 1 || Number(day) > daysInMonth
37
+ || Number(hour) > 23 || Number(minute) > 59 || Number(second ?? 0) > 59
38
+ || Number(offsetHour ?? 0) > 23 || Number(offsetMinute ?? 0) > 59
39
+ || !Number.isFinite(ms)) {
40
+ return { ok: false, message: "start_at is not a valid date and time." };
41
+ }
42
+ if (ms <= now.getTime())
43
+ return { ok: false, message: "start_at must be in the future." };
44
+ if (ms > now.getTime() + SEQUENCE_START_AT_MAX_DAYS * DAY_MS) {
45
+ return { ok: false, message: `start_at must be at most ${SEQUENCE_START_AT_MAX_DAYS} days ahead.` };
46
+ }
47
+ return { ok: true, startAt: new Date(ms).toISOString() };
48
+ }
3
49
  /**
4
50
  * The lifecycle word a person reads for a Sequence.
5
51
  *
@@ -9,12 +55,19 @@ export const SEQUENCE_LIFECYCLES = ["draft", "active", "completed", "paused", "a
9
55
  * is the derived operational state; `drained` means every enrolled lead has
10
56
  * reached an end. An active Sequence in that state reads as completed.
11
57
  *
58
+ * An active Sequence whose `settings.start_at` is still ahead reads as
59
+ * scheduled: it was started, but the planner contacts nobody before then.
60
+ *
12
61
  * Derived on read and never stored: operational health is one derived
13
62
  * contract, not another lifecycle (docs/primitives/sequences.md, 2026-08-26).
14
63
  * Enrolling new leads makes the same Sequence read as active again with no
15
64
  * transition to undo. A paused Sequence stays paused, because someone decided
16
65
  * that and the decision still holds.
17
66
  */
18
- export function sequenceDisplayStatus(status, operationalState) {
19
- return status === "active" && operationalState === "drained" ? "completed" : status;
67
+ export function sequenceDisplayStatus(status, operationalState, settings, now = new Date()) {
68
+ if (status !== "active")
69
+ return status;
70
+ if (sequenceScheduledStartAt(settings, now))
71
+ return "scheduled";
72
+ return operationalState === "drained" ? "completed" : status;
20
73
  }
@@ -47,7 +47,24 @@ export type RenderTemplateOptions = {
47
47
  * derived from the template + values (still deterministic, not enrollment-scoped).
48
48
  */
49
49
  seed?: string;
50
+ /**
51
+ * `"html"` escapes each SUBSTITUTED value (`&`, `<`, `>`, `"`, `'`) for an HTML
52
+ * body; the template itself, its fallbacks and spintax options are authored
53
+ * markup and render untouched. Omitted: values substitute verbatim (text parts,
54
+ * subjects and the legacy body_html_template path).
55
+ */
56
+ escape?: "html";
50
57
  };
58
+ /**
59
+ * Placeholders the email builder fills, never the renderer: the recipient's
60
+ * signed one-click unsubscribe link and their preference page. The renderer
61
+ * leaves `{{unsubscribe_url}}` / `{{preferences_url}}` exactly as written, and
62
+ * they are not row columns (templateColumnKeys skips them).
63
+ */
64
+ export declare const RESERVED_EMAIL_PLACEHOLDERS: readonly ["unsubscribe_url", "preferences_url"];
65
+ export type ReservedEmailPlaceholder = typeof RESERVED_EMAIL_PLACEHOLDERS[number];
66
+ /** Escape text for an HTML body or a quoted attribute. */
67
+ export declare function escapeHtmlText(value: string): string;
51
68
  /**
52
69
  * Deterministic, replayable FNV-1a hash → uint32. Stable across processes — the
53
70
  * single hash used by both spintax selection here and A/B variant assignment in
@@ -385,6 +385,14 @@ export type SequenceEmailSendStep = {
385
385
  body_template: string;
386
386
  /** Optional HTML body (B-W2): renders the send as multipart/alternative. Supports {{column}}. */
387
387
  body_html_template?: string;
388
+ /**
389
+ * A designed body stored as an email document (sending-domain Sequences):
390
+ * its revision supplies the HTML and text parts, rendered per recipient at
391
+ * delivery with HTML-escaped merge values. Without `revision` the step follows
392
+ * the document's current revision until the Sequence goes live, which pins it.
393
+ * Not combined with body_html_template; body_template may then stay empty.
394
+ */
395
+ body_document?: SequenceEmailBodyDocument;
388
396
  /** Static Cc / Bcc recipients (fixed addresses, not templated). */
389
397
  cc?: string[];
390
398
  bcc?: string[];
@@ -748,6 +756,39 @@ export type SequenceStep = SequenceLinkedInVisitProfileStep | SequenceLinkedInIn
748
756
  export type SequenceDefinition = {
749
757
  steps: SequenceStep[];
750
758
  };
759
+ /** `body_document` on an email_send step: an email document id and, once pinned, its revision. */
760
+ export type SequenceEmailBodyDocument = {
761
+ id: string;
762
+ revision?: number;
763
+ };
764
+ /** The email_send steps whose body is an email document, in flat step order. */
765
+ export declare function sequenceBodyDocumentSteps(definition: {
766
+ steps?: readonly unknown[];
767
+ } | null | undefined): Array<{
768
+ index: number;
769
+ stepId: string;
770
+ ref: SequenceEmailBodyDocument;
771
+ }>;
772
+ /**
773
+ * Where a step's content lives, for `sequences content get|set` on every surface:
774
+ * the email_send step by id, its document reference, and its plain bodies.
775
+ * Throws a fixed-copy OxygenError naming the step when it is not an email_send.
776
+ */
777
+ export declare function sequenceContentStep(definition: {
778
+ steps?: readonly unknown[];
779
+ } | null | undefined, stepId: string): {
780
+ index: number;
781
+ step: Record<string, unknown>;
782
+ bodyDocument: SequenceEmailBodyDocument | null;
783
+ };
784
+ /**
785
+ * The definition with one email_send step bodied by `documentId` (following its
786
+ * current revision), or null when the step already references exactly that.
787
+ * A document replaces the step's body_html_template.
788
+ */
789
+ export declare function withSequenceStepBodyDocument<T extends {
790
+ steps: readonly unknown[];
791
+ }>(definition: T, stepId: string, documentId: string): T | null;
751
792
  /** Which channel a step executes on, or null for channel-agnostic control steps. */
752
793
  export declare function sequenceStepChannel(step: SequenceStep): SequenceChannel | null;
753
794
  /** Distinct channels a sequence touches (drives the sequence's channels[] tag). */
@@ -472,6 +472,77 @@ export function sequenceLeadContacts(input) {
472
472
  phone: callPhoneFromRow(input.rowValues, phoneKey) !== null || whatsAppAttendeeIdFromRow(input.rowValues, phoneKey) !== null,
473
473
  };
474
474
  }
475
+ /** The email_send steps whose body is an email document, in flat step order. */
476
+ export function sequenceBodyDocumentSteps(definition) {
477
+ const out = [];
478
+ (definition?.steps ?? []).forEach((step, index) => {
479
+ if (!isRecord(step) || step.kind !== "email_send" || !isRecord(step.body_document))
480
+ return;
481
+ const id = step.body_document.id;
482
+ const revision = step.body_document.revision;
483
+ if (typeof id !== "string")
484
+ return;
485
+ out.push({ index, stepId: typeof step.id === "string" ? step.id : "", ref: { id, ...(typeof revision === "number" ? { revision } : {}) } });
486
+ });
487
+ return out;
488
+ }
489
+ /**
490
+ * Where a step's content lives, for `sequences content get|set` on every surface:
491
+ * the email_send step by id, its document reference, and its plain bodies.
492
+ * Throws a fixed-copy OxygenError naming the step when it is not an email_send.
493
+ */
494
+ export function sequenceContentStep(definition, stepId) {
495
+ const steps = definition?.steps ?? [];
496
+ const index = steps.findIndex((step) => isRecord(step) && step.id === stepId);
497
+ const step = index === -1 ? null : steps[index];
498
+ if (!isRecord(step)) {
499
+ throw new OxygenError("sequence_step_not_found", "No step with that id in this sequence.", {
500
+ details: { step_id: stepId, step_ids: steps.filter(isRecord).map((entry) => entry.id) }, exitCode: 2,
501
+ });
502
+ }
503
+ if (step.kind !== "email_send") {
504
+ throw new OxygenError("sequence_step_not_email", "Content applies to an email_send step.", { details: { step_id: stepId, kind: step.kind }, exitCode: 2 });
505
+ }
506
+ const ref = sequenceBodyDocumentSteps({ steps: [step] })[0]?.ref ?? null;
507
+ return { index, step, bodyDocument: ref };
508
+ }
509
+ /**
510
+ * The definition with one email_send step bodied by `documentId` (following its
511
+ * current revision), or null when the step already references exactly that.
512
+ * A document replaces the step's body_html_template.
513
+ */
514
+ export function withSequenceStepBodyDocument(definition, stepId, documentId) {
515
+ const { index, step, bodyDocument } = sequenceContentStep(definition, stepId);
516
+ if (bodyDocument && bodyDocument.id === documentId && bodyDocument.revision === undefined && step.body_html_template === undefined)
517
+ return null;
518
+ const { body_html_template: _html, ...rest } = step;
519
+ const steps = [...definition.steps];
520
+ steps[index] = { ...rest, body_document: { id: documentId } };
521
+ return { ...definition, steps };
522
+ }
523
+ const BODY_DOCUMENT_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
524
+ function normalizeBodyDocument(raw, path, issues) {
525
+ if (raw === undefined || raw === null)
526
+ return undefined;
527
+ if (!isRecord(raw)) {
528
+ issues.push({ path, message: "must be an object { id, revision? }." });
529
+ return undefined;
530
+ }
531
+ const unknownKeys = Object.keys(raw).filter((key) => key !== "id" && key !== "revision");
532
+ if (unknownKeys.length > 0)
533
+ issues.push({ path, message: `unknown field(s): ${unknownKeys.join(", ")}; only id and revision.` });
534
+ if (typeof raw.id !== "string" || !BODY_DOCUMENT_UUID_RE.test(raw.id)) {
535
+ issues.push({ path: `${path}.id`, message: "must be an email document id." });
536
+ return undefined;
537
+ }
538
+ if (raw.revision === undefined || raw.revision === null)
539
+ return { id: raw.id.toLowerCase() };
540
+ if (typeof raw.revision !== "number" || !Number.isInteger(raw.revision) || raw.revision < 1) {
541
+ issues.push({ path: `${path}.revision`, message: "must be a positive integer." });
542
+ return undefined;
543
+ }
544
+ return { id: raw.id.toLowerCase(), revision: raw.revision };
545
+ }
475
546
  const MAX_SEQUENCE_STEPS = 50;
476
547
  const MAX_TEMPLATE_LENGTH = 8_000;
477
548
  // The Premium ceiling, and only an authoring hint: the limit that actually
@@ -670,7 +741,9 @@ export function sequenceStepsMissingCopy(steps) {
670
741
  if (sequenceInheritedEmailSubject(steps, index) === null) {
671
742
  check(step, "subject_template", step.subject_template);
672
743
  }
673
- check(step, "body_template", step.body_template);
744
+ // A document-bodied step's copy is the document.
745
+ if (!step.body_document)
746
+ check(step, "body_template", step.body_template);
674
747
  break;
675
748
  case "email_reply":
676
749
  check(step, "body_template", step.body_template);
@@ -1020,12 +1093,20 @@ raw, index, options, issues) {
1020
1093
  // instead of relying on an empty string being read the right way downstream.
1021
1094
  const subject = optionalTemplate(raw.subject_template, `${path}.subject_template`, MAX_TEMPLATE_LENGTH, issues);
1022
1095
  const threaded = !(subject ?? "").trim();
1023
- const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues, options.draft === true);
1096
+ const bodyDocument = normalizeBodyDocument(raw.body_document, `${path}.body_document`, issues);
1097
+ // A document carries its own plain-text part, so body_template may stay
1098
+ // empty (it remains the text fallback when set).
1099
+ const body = requiredTemplate(raw.body_template ?? (bodyDocument ? "" : undefined), `${path}.body_template`, issues, options.draft === true || bodyDocument !== undefined);
1024
1100
  const bodyHtml = optionalTemplate(raw.body_html_template, `${path}.body_html_template`, MAX_TEMPLATE_LENGTH, issues);
1101
+ if (bodyDocument && bodyHtml !== undefined && bodyHtml.trim()) {
1102
+ issues.push({ path: `${path}.body_html_template`, message: "set body_document or body_html_template, not both." });
1103
+ }
1025
1104
  // A threaded step has no subject to override, so its variants may only vary
1026
1105
  // the body — a subject-only variant then fails normalizeVariants' own
1027
1106
  // "must override at least one of" check rather than silently doing nothing.
1028
- const variants = normalizeVariants(raw.variants, `${path}.variants`, threaded ? ["body_template", "body_html_template"] : ["subject_template", "body_template", "body_html_template"], issues, options.draft === true);
1107
+ // A document-bodied step's HTML is the document, so variants vary subject/text only.
1108
+ const variantBodyFields = bodyDocument ? ["body_template"] : ["body_template", "body_html_template"];
1109
+ const variants = normalizeVariants(raw.variants, `${path}.variants`, threaded ? variantBodyFields : ["subject_template", ...variantBodyFields], issues, options.draft === true);
1029
1110
  const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
1030
1111
  const cc = optionalEmailList(raw.cc, `${path}.cc`, issues);
1031
1112
  const bcc = optionalEmailList(raw.bcc, `${path}.bcc`, issues);
@@ -1047,6 +1128,7 @@ raw, index, options, issues) {
1047
1128
  ...(threaded ? {} : { subject_template: subject }),
1048
1129
  body_template: body ?? "",
1049
1130
  ...(bodyHtml !== undefined ? { body_html_template: bodyHtml } : {}),
1131
+ ...(bodyDocument ? { body_document: bodyDocument } : {}),
1050
1132
  ...(cc ? { cc } : {}),
1051
1133
  ...(bcc ? { bcc } : {}),
1052
1134
  ...(variants ? { variants: variants } : {}),
@@ -1289,7 +1371,7 @@ const STEP_KIND_FIELDS = {
1289
1371
  like_post: ["reaction", "post_recency_days", "on_no_post"],
1290
1372
  comment_post: ["text_template", "ai_prompt", "post_recency_days", "on_no_post"],
1291
1373
  withdraw_invite: [],
1292
- email_send: ["subject_template", "body_template", "body_html_template", "cc", "bcc", "send_window", "variants", "auto_optimize"],
1374
+ email_send: ["subject_template", "body_template", "body_html_template", "body_document", "cc", "bcc", "send_window", "variants", "auto_optimize"],
1293
1375
  email_reply: ["body_template", "body_html_template", "cc", "bcc", "send_window", "variants", "auto_optimize"],
1294
1376
  email_enroll: ["subsequence_id"],
1295
1377
  email_move: ["subsequence_id"],
@@ -0,0 +1,19 @@
1
+ /** OXYGEN's own sending and product domains: never claimable by a workspace. */
2
+ export declare const RESERVED_SES_IDENTITY_SUFFIXES: readonly ["oxygen-agent.com"];
3
+ /** An unverified claim lapses after this long, so a typo cannot hold a domain forever. */
4
+ export declare const SES_IDENTITY_CLAIM_TTL_MS: number;
5
+ /** Lower-cased hostname with at least two labels, or null when it is not one. */
6
+ export declare function normalizeSesIdentity(value: unknown): string | null;
7
+ /** True when `identity` is `parent` or one of its subdomains. */
8
+ export declare function isSesIdentityWithin(identity: string, parent: string): boolean;
9
+ /** True when either identity covers the other: the same name, or one is a subdomain of the other. */
10
+ export declare function sesIdentitiesOverlap(a: string, b: string): boolean;
11
+ export declare function isReservedSesIdentity(identity: string): boolean;
12
+ /**
13
+ * The identity's parents down to two labels, nearest first: `a.news.example.com`
14
+ * gives `news.example.com`, `example.com`. With the identity itself these are
15
+ * the registry rows a claim must not collide with on the way up.
16
+ */
17
+ export declare function sesIdentityParents(identity: string): string[];
18
+ /** The SES tenant name for a workspace: `oxy_` plus its UUID without dashes (36 of SES's 64 characters). */
19
+ export declare function sesTenantName(organizationId: string): string;
@@ -0,0 +1,54 @@
1
+ // Who may own an SES identity on OXYGEN's one shared SES account. SES knows an
2
+ // identity once per account and lets a verified domain send for its subdomains,
3
+ // so ownership is decided here and in the control-db registry before any SES
4
+ // call: one workspace per identity, never one under or over another workspace's
5
+ // identity, and never OXYGEN's own domains.
6
+ /** OXYGEN's own sending and product domains: never claimable by a workspace. */
7
+ export const RESERVED_SES_IDENTITY_SUFFIXES = ["oxygen-agent.com"];
8
+ /** An unverified claim lapses after this long, so a typo cannot hold a domain forever. */
9
+ export const SES_IDENTITY_CLAIM_TTL_MS = 72 * 60 * 60 * 1000;
10
+ const LABEL_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
11
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ /** Lower-cased hostname with at least two labels, or null when it is not one. */
13
+ export function normalizeSesIdentity(value) {
14
+ if (typeof value !== "string")
15
+ return null;
16
+ const identity = value.trim().toLowerCase().replace(/\.$/, "");
17
+ if (identity.length === 0 || identity.length > 253)
18
+ return null;
19
+ const labels = identity.split(".");
20
+ if (labels.length < 2 || !labels.every((label) => LABEL_RE.test(label)))
21
+ return null;
22
+ if (/^[0-9]+$/.test(labels[labels.length - 1]))
23
+ return null;
24
+ return identity;
25
+ }
26
+ /** True when `identity` is `parent` or one of its subdomains. */
27
+ export function isSesIdentityWithin(identity, parent) {
28
+ return identity === parent || identity.endsWith(`.${parent}`);
29
+ }
30
+ /** True when either identity covers the other: the same name, or one is a subdomain of the other. */
31
+ export function sesIdentitiesOverlap(a, b) {
32
+ return isSesIdentityWithin(a, b) || isSesIdentityWithin(b, a);
33
+ }
34
+ export function isReservedSesIdentity(identity) {
35
+ return RESERVED_SES_IDENTITY_SUFFIXES.some((suffix) => sesIdentitiesOverlap(identity, suffix));
36
+ }
37
+ /**
38
+ * The identity's parents down to two labels, nearest first: `a.news.example.com`
39
+ * gives `news.example.com`, `example.com`. With the identity itself these are
40
+ * the registry rows a claim must not collide with on the way up.
41
+ */
42
+ export function sesIdentityParents(identity) {
43
+ const labels = identity.split(".");
44
+ const parents = [];
45
+ for (let index = 1; index <= labels.length - 2; index += 1)
46
+ parents.push(labels.slice(index).join("."));
47
+ return parents;
48
+ }
49
+ /** The SES tenant name for a workspace: `oxy_` plus its UUID without dashes (36 of SES's 64 characters). */
50
+ export function sesTenantName(organizationId) {
51
+ if (!UUID_RE.test(organizationId))
52
+ throw new Error("sesTenantName needs an organization UUID");
53
+ return `oxy_${organizationId.toLowerCase().replaceAll("-", "")}`;
54
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Which feature-skill play a GTM ask matches, from each play's own title and
3
+ * trigger phrases (generated from the play catalog, never a second list).
4
+ *
5
+ * A capability route names the skill that owns an ask; without the play file the
6
+ * agent planned from general knowledge and skipped OXYGEN's defaults (Oxygent
7
+ * suite, 2026-10-21: oxygen_skills_get called 0 times in 8 runs). The match is
8
+ * deliberately strict: the ask must hold three quarters (and at least three) of
9
+ * one trigger phrase's content words, an adjacent pair of them included, and a
10
+ * tie between plays names none.
11
+ */
12
+ export type SkillPlayMatch = {
13
+ skill: string;
14
+ slug: string;
15
+ file: string;
16
+ };
17
+ export declare const SKILL_PLAY_TRIGGERS: readonly {
18
+ skill: string;
19
+ slug: string;
20
+ phrases: readonly string[];
21
+ }[];
22
+ export declare function matchSkillPlay(query: string): SkillPlayMatch | null;
@@ -0,0 +1,75 @@
1
+ import { SKILL_PLAY_TRIGGERS_GENERATED } from "./skill-plays.generated.js";
2
+ export const SKILL_PLAY_TRIGGERS = SKILL_PLAY_TRIGGERS_GENERATED;
3
+ const STOPWORDS = new Set([
4
+ "a", "about", "across", "all", "an", "and", "any", "are", "as", "at", "be", "before", "by", "can", "d", "do", "does",
5
+ "every", "for", "from", "get", "give", "going", "how", "i", "i'm", "in", "into", "is", "it", "it's", "its", "just",
6
+ "let", "ll", "me", "my", "of", "on", "one", "or", "our", "s", "should", "so", "some", "that", "the", "their", "them",
7
+ "then", "these", "they", "this", "those", "to", "up", "us", "want", "we", "what", "when", "which", "who", "why",
8
+ "will", "with", "would", "you", "your",
9
+ ]);
10
+ function stem(word) {
11
+ if (word.length > 4 && word.endsWith("ies"))
12
+ return `${word.slice(0, -3)}y`;
13
+ if (word.length > 3 && word.endsWith("s") && !word.endsWith("ss"))
14
+ return word.slice(0, -1);
15
+ return word;
16
+ }
17
+ function contentWords(text) {
18
+ return text
19
+ .toLowerCase()
20
+ .replace(/[’]/g, "'")
21
+ .split(/[^a-z0-9/']+/)
22
+ .map((word) => word.replace(/^[/']+|[/']+$/g, ""))
23
+ .filter((word) => word.length > 0 && !STOPWORDS.has(word))
24
+ .map(stem);
25
+ }
26
+ function wordPairs(words) {
27
+ const pairs = new Set();
28
+ for (let i = 1; i < words.length; i += 1)
29
+ pairs.add(`${words[i - 1]} ${words[i]}`);
30
+ return pairs;
31
+ }
32
+ const PLAY_PHRASES = SKILL_PLAY_TRIGGERS.map((play) => ({
33
+ play,
34
+ phrases: play.phrases.map((phrase) => {
35
+ const words = contentWords(phrase);
36
+ return { words: new Set(words), pairs: wordPairs(words) };
37
+ }),
38
+ }));
39
+ /** An ask must hold at least this many of one phrase's words... */
40
+ const MIN_SHARED_WORDS = 3;
41
+ /** ...and this share of them, one adjacent pair included. */
42
+ const MIN_PHRASE_COVERAGE = 0.75;
43
+ export function matchSkillPlay(query) {
44
+ const askedWords = contentWords(query);
45
+ const asked = new Set(askedWords);
46
+ const askedPairs = wordPairs(askedWords);
47
+ if (askedPairs.size === 0)
48
+ return null;
49
+ let best = null;
50
+ let tied = false;
51
+ for (const { play, phrases } of PLAY_PHRASES) {
52
+ let score = 0;
53
+ for (const { words, pairs } of phrases) {
54
+ let shared = 0;
55
+ for (const word of words)
56
+ if (asked.has(word))
57
+ shared += 1;
58
+ if (shared < MIN_SHARED_WORDS || shared / words.size < MIN_PHRASE_COVERAGE)
59
+ continue;
60
+ if (![...pairs].some((pair) => askedPairs.has(pair)))
61
+ continue;
62
+ score = Math.max(score, shared / words.size + shared / 100);
63
+ }
64
+ if (score === 0)
65
+ continue;
66
+ if (!best || score > best.score) {
67
+ best = { skill: play.skill, slug: play.slug, score };
68
+ tied = false;
69
+ }
70
+ else if (score === best.score) {
71
+ tied = true;
72
+ }
73
+ }
74
+ return best && !tied ? { skill: best.skill, slug: best.slug, file: `plays/${best.slug}.md` } : null;
75
+ }
@@ -0,0 +1,5 @@
1
+ export declare const SKILL_PLAY_TRIGGERS_GENERATED: readonly {
2
+ skill: string;
3
+ slug: string;
4
+ phrases: readonly string[];
5
+ }[];