@oxygen-agent/cli 1.1014.1696 → 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.
- package/README.md +1 -1
- package/dist/command-families/program.js +4 -0
- package/dist/command-manifest.js +14 -2
- package/dist/command-support.js +1 -1
- package/dist/commands/admin.js +82 -1
- package/dist/commands/billing.js +1 -1
- package/dist/commands/copilot.js +29 -7
- package/dist/commands/domains.js +7 -0
- package/dist/commands/email-assets.d.ts +3 -0
- package/dist/commands/email-assets.js +78 -0
- package/dist/commands/email-templates.d.ts +3 -0
- package/dist/commands/email-templates.js +101 -0
- package/dist/commands/sequences.js +47 -12
- package/dist/commands/subscriptions.d.ts +2 -4
- package/dist/commands/subscriptions.js +297 -51
- package/dist/sequence-start-at.d.ts +7 -0
- package/dist/sequence-start-at.js +21 -0
- package/dist/sequences-brand-kit-command.d.ts +28 -0
- package/dist/sequences-brand-kit-command.js +83 -0
- package/dist/sequences-content-commands.d.ts +28 -0
- package/dist/sequences-content-commands.js +176 -0
- package/dist/sequences-newsletter-commands.d.ts +6 -0
- package/dist/sequences-newsletter-commands.js +108 -0
- package/dist/sequences-test-send-command.d.ts +3 -0
- package/dist/sequences-test-send-command.js +47 -0
- package/node_modules/@oxygen/formula/dist/evaluate.js +2 -4
- package/node_modules/@oxygen/formula/dist/expression.d.ts +7 -5
- package/node_modules/@oxygen/formula/dist/expression.js +26 -4
- package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +6 -0
- package/node_modules/@oxygen/formula/dist/formula-functions.js +55 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/billing.js +5 -7
- package/node_modules/@oxygen/shared/dist/bulk-email-pacing.d.ts +105 -0
- package/node_modules/@oxygen/shared/dist/bulk-email-pacing.js +125 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +174 -10
- package/node_modules/@oxygen/shared/dist/contracts/clay-migration.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/contracts/clay-migration.js +28 -2
- package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.js +7 -0
- package/node_modules/@oxygen/shared/dist/contracts/teammates.js +6 -1
- package/node_modules/@oxygen/shared/dist/email-brand-kit.d.ts +108 -0
- package/node_modules/@oxygen/shared/dist/email-brand-kit.js +214 -0
- package/node_modules/@oxygen/shared/dist/email-delivery.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/email-delivery.js +14 -0
- package/node_modules/@oxygen/shared/dist/email-documents.d.ts +61 -0
- package/node_modules/@oxygen/shared/dist/email-documents.js +143 -0
- package/node_modules/@oxygen/shared/dist/email-dsn.js +52 -10
- package/node_modules/@oxygen/shared/dist/email-html-sanitize.d.ts +23 -0
- package/node_modules/@oxygen/shared/dist/email-html-sanitize.js +96 -0
- package/node_modules/@oxygen/shared/dist/email-subscriptions.d.ts +63 -1
- package/node_modules/@oxygen/shared/dist/email-subscriptions.js +149 -1
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +41 -2
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +50 -1
- package/node_modules/@oxygen/shared/dist/newsletter-analytics.d.ts +90 -0
- package/node_modules/@oxygen/shared/dist/newsletter-analytics.js +28 -0
- package/node_modules/@oxygen/shared/dist/newsletter-test-send.d.ts +20 -0
- package/node_modules/@oxygen/shared/dist/newsletter-test-send.js +46 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +12 -0
- package/node_modules/@oxygen/shared/dist/plan-band.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/plan-band.js +17 -0
- package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +4 -2
- package/node_modules/@oxygen/shared/dist/plan-capabilities.js +12 -1
- package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/plan-limits-view.js +3 -0
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +5 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +29 -0
- package/node_modules/@oxygen/shared/dist/sending-domain-faults.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/sending-domain-faults.js +53 -0
- package/node_modules/@oxygen/shared/dist/sending-domain-rollout.d.ts +24 -1
- package/node_modules/@oxygen/shared/dist/sending-domain-rollout.js +51 -4
- package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +10 -1
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +68 -6
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.d.ts +34 -3
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.js +57 -4
- package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +17 -0
- package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +86 -4
- package/node_modules/@oxygen/shared/dist/ses-identity-policy.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/ses-identity-policy.js +54 -0
- package/node_modules/@oxygen/shared/dist/skill-play-routing.d.ts +22 -0
- package/node_modules/@oxygen/shared/dist/skill-play-routing.js +75 -0
- package/node_modules/@oxygen/shared/dist/skill-plays.generated.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/skill-plays.generated.js +269 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.js +7 -0
- package/node_modules/@oxygen/shared/dist/teammates.js +2 -1
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +15 -1
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +2 -2
- package/package.json +2 -2
|
@@ -1,6 +1,32 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Every lifecycle a person reads: the stored statuses plus the derived
|
|
3
|
+
* `scheduled` and `completed`.
|
|
4
|
+
*/
|
|
5
|
+
export declare const SEQUENCE_LIFECYCLES: readonly ["draft", "scheduled", "active", "completed", "paused", "archived"];
|
|
3
6
|
export type SequenceLifecycle = (typeof SEQUENCE_LIFECYCLES)[number];
|
|
7
|
+
/** How far ahead a scheduled start may be set. */
|
|
8
|
+
export declare const SEQUENCE_START_AT_MAX_DAYS = 30;
|
|
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 declare function sequenceScheduledStartAt(settings: {
|
|
15
|
+
start_at?: unknown;
|
|
16
|
+
} | null | undefined, now?: Date): Date | null;
|
|
17
|
+
/**
|
|
18
|
+
* Read a requested scheduled start: an ISO 8601 timestamp with a timezone
|
|
19
|
+
* (`Z` or `±hh:mm`), in the future and at most {@link SEQUENCE_START_AT_MAX_DAYS}
|
|
20
|
+
* days ahead. Returns the canonical UTC form (`toISOString`) that is stored, so
|
|
21
|
+
* every stored value is one Postgres can cast.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseSequenceStartAt(value: unknown, now?: Date): {
|
|
24
|
+
ok: true;
|
|
25
|
+
startAt: string;
|
|
26
|
+
} | {
|
|
27
|
+
ok: false;
|
|
28
|
+
message: string;
|
|
29
|
+
};
|
|
4
30
|
/**
|
|
5
31
|
* The lifecycle word a person reads for a Sequence.
|
|
6
32
|
*
|
|
@@ -10,10 +36,15 @@ export type SequenceLifecycle = (typeof SEQUENCE_LIFECYCLES)[number];
|
|
|
10
36
|
* is the derived operational state; `drained` means every enrolled lead has
|
|
11
37
|
* reached an end. An active Sequence in that state reads as completed.
|
|
12
38
|
*
|
|
39
|
+
* An active Sequence whose `settings.start_at` is still ahead reads as
|
|
40
|
+
* scheduled: it was started, but the planner contacts nobody before then.
|
|
41
|
+
*
|
|
13
42
|
* Derived on read and never stored: operational health is one derived
|
|
14
43
|
* contract, not another lifecycle (docs/primitives/sequences.md, 2026-08-26).
|
|
15
44
|
* Enrolling new leads makes the same Sequence read as active again with no
|
|
16
45
|
* transition to undo. A paused Sequence stays paused, because someone decided
|
|
17
46
|
* that and the decision still holds.
|
|
18
47
|
*/
|
|
19
|
-
export declare function sequenceDisplayStatus<Status extends string>(status: Status, operationalState: string | null | undefined
|
|
48
|
+
export declare function sequenceDisplayStatus<Status extends string>(status: Status, operationalState: string | null | undefined, settings?: {
|
|
49
|
+
start_at?: unknown;
|
|
50
|
+
} | null, now?: Date): Status | "completed" | "scheduled";
|
|
@@ -1,5 +1,51 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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
|
-
|
|
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
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|