@oxygen-agent/cli 1.246.0 → 1.263.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ export declare const AGENCY_DIRECTORY_SERVICES: readonly ["Cold Email", "LinkedIn Outbound", "Lead Sourcing & Enrichment", "GTM Engineering", "Outreach Strategy & Consulting", "Copywriting & Messaging", "Deliverability & Infrastructure", "RevOps & CRM", "Campaign Management", "Training & Coaching"];
2
+ export type AgencyDirectoryService = (typeof AGENCY_DIRECTORY_SERVICES)[number];
3
+ export declare const AGENCY_DIRECTORY_REGIONS: readonly ["North America", "LATAM", "UK & Ireland", "DACH", "Nordics", "Europe", "APAC", "Middle East & Africa", "Global"];
4
+ export type AgencyDirectoryRegion = (typeof AGENCY_DIRECTORY_REGIONS)[number];
5
+ /** Case-insensitive lookup of a facet value's canonical casing. */
6
+ export declare function canonicalDirectoryFacetValue(options: readonly string[], value: string): string | null;
@@ -0,0 +1,34 @@
1
+ // Curated facet options for the public agency directory. Listings multi-select
2
+ // from these fixed sets (validated server-side) so the public /agencies
3
+ // filters never fragment into free-text variants. Modeled on Instantly's and
4
+ // SmartLead's fixed service taxonomies, tuned to OXYGEN's GTM-engineering ICP.
5
+ export const AGENCY_DIRECTORY_SERVICES = [
6
+ "Cold Email",
7
+ "LinkedIn Outbound",
8
+ "Lead Sourcing & Enrichment",
9
+ "GTM Engineering",
10
+ "Outreach Strategy & Consulting",
11
+ "Copywriting & Messaging",
12
+ "Deliverability & Infrastructure",
13
+ "RevOps & CRM",
14
+ "Campaign Management",
15
+ "Training & Coaching",
16
+ ];
17
+ export const AGENCY_DIRECTORY_REGIONS = [
18
+ "North America",
19
+ "LATAM",
20
+ "UK & Ireland",
21
+ "DACH",
22
+ "Nordics",
23
+ "Europe",
24
+ "APAC",
25
+ "Middle East & Africa",
26
+ "Global",
27
+ ];
28
+ /** Case-insensitive lookup of a facet value's canonical casing. */
29
+ export function canonicalDirectoryFacetValue(options, value) {
30
+ const normalized = value.trim().toLowerCase();
31
+ if (!normalized)
32
+ return null;
33
+ return options.find((option) => option.toLowerCase() === normalized) ?? null;
34
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Signed token for native open/click tracking on cold email. The dispatcher MINTS
3
+ * the token at send time (one per tracked action) and bakes it into a tracking
4
+ * pixel URL (open) or a rewritten link (click); the public /api/t/* routes VERIFY
5
+ * it. The token is the ONLY credential those anonymous endpoints trust, so it
6
+ * binds org + action_id + kind — and, for a click, the SHA-256 hash of the exact
7
+ * destination URL, so a validly-signed click token can never be turned into an
8
+ * open-redirect to an arbitrary host: the route recomputes the hash of the `u`
9
+ * query param and refuses a mismatch.
10
+ *
11
+ * Kill switch / fail-closed: the signing key (EMAIL_TRACKING_SECRET, Doppler
12
+ * oxygen-shared) is read by both the dispatcher (mint) and web (verify). When it
13
+ * is UNSET, injection is DISABLED (no pixel, no link rewrite) and the public
14
+ * routes serve a benign 1x1 gif / plain redirect WITHOUT recording — never a
15
+ * crash, never a silent paid call.
16
+ *
17
+ * Layering mirrors email-unsubscribe-token.ts: pure crypto in @oxygen/shared so
18
+ * the worker/integrations mint path and the web verify route share one
19
+ * implementation, with no env policy baked in (the caller decides what null means).
20
+ */
21
+ export type EmailTrackingKind = "open" | "click";
22
+ export type EmailTrackingTokenPayload = {
23
+ /** Schema version; only v1 is accepted. */
24
+ v: 1;
25
+ /** Organization id (also the tenant the public route resolves + records into). */
26
+ org: string;
27
+ /** The action id the send belongs to — provenance for the recorded event. */
28
+ act: string;
29
+ /** open (pixel) or click (rewritten link). */
30
+ kind: EmailTrackingKind;
31
+ /** Click only: SHA-256 hex of the destination URL the token authorizes. */
32
+ uh?: string;
33
+ /** Sequence id, for provenance / signal advance. */
34
+ seq?: string;
35
+ /** Enrollment id, so open/click advances the exact journey. */
36
+ enr?: string;
37
+ /** Issued-at (epoch ms), recorded as provenance. */
38
+ iat: number;
39
+ };
40
+ type EnvLike = Record<string, string | undefined>;
41
+ /** SHA-256 hex of a destination URL — the value bound into a click token's `uh`. */
42
+ export declare function hashTrackingUrl(url: string): string;
43
+ /**
44
+ * Sign a payload into `<base64url(json)>.<base64url(hmac-sha256)>`. The HMAC is
45
+ * computed over the encoded body, so any tamper invalidates the signature.
46
+ */
47
+ export declare function signEmailTrackingToken(payload: EmailTrackingTokenPayload, secret: string): string;
48
+ /**
49
+ * Verify a token's signature (constant-time) and shape. Returns the payload only
50
+ * when the signature matches AND it is a well-formed v1 payload; null on ANY
51
+ * problem (bad shape, blank/wrong secret, tampered body). Pure: the route maps
52
+ * null -> benign gif / plain redirect with no recording.
53
+ */
54
+ export declare function verifyEmailTrackingToken(token: string, secret: string): EmailTrackingTokenPayload | null;
55
+ /**
56
+ * Verify a click token AND that the supplied destination URL is the one it
57
+ * authorizes (constant-time hash compare). Returns the payload only when both the
58
+ * signature and the url-hash match; null otherwise. This is what stops the click
59
+ * route from being an open redirect — only the exact signed URL is honored.
60
+ */
61
+ export declare function verifyEmailClickToken(token: string, destinationUrl: string, secret: string): EmailTrackingTokenPayload | null;
62
+ /**
63
+ * The HMAC signing secret for tracking tokens, or null when unset. This is the
64
+ * kill switch: null means "tracking disabled" everywhere — the dispatcher skips
65
+ * injection and the public routes serve a benign response without recording.
66
+ */
67
+ export declare function emailTrackingSigningSecret(env?: EnvLike): string | null;
68
+ /**
69
+ * The public app base URL tracking links point at, trailing slash stripped.
70
+ * Prefers NEXT_PUBLIC_APP_URL, then OXYGEN_APP_URL, then the prod host. (A
71
+ * verified per-org tracking domain is used at send time when present; this is the
72
+ * fallback the token routes are always reachable at.)
73
+ */
74
+ export declare function emailTrackingAppBaseUrl(env?: EnvLike): string;
75
+ /** The open-pixel URL for a signed token: `<base>/api/t/o/<token>`. */
76
+ export declare function buildOpenPixelUrl(token: string, baseUrl: string): string;
77
+ /**
78
+ * The click-tracking URL for a signed token + destination: the destination rides
79
+ * in the `u` query param (the route recomputes its hash against the token's `uh`).
80
+ */
81
+ export declare function buildClickTrackingUrl(token: string, destinationUrl: string, baseUrl: string): string;
82
+ export {};
@@ -0,0 +1,130 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ function base64urlEncode(value) {
3
+ return Buffer.from(value, "utf8").toString("base64url");
4
+ }
5
+ /** SHA-256 hex of a destination URL — the value bound into a click token's `uh`. */
6
+ export function hashTrackingUrl(url) {
7
+ return createHash("sha256").update(url, "utf8").digest("hex");
8
+ }
9
+ /**
10
+ * Sign a payload into `<base64url(json)>.<base64url(hmac-sha256)>`. The HMAC is
11
+ * computed over the encoded body, so any tamper invalidates the signature.
12
+ */
13
+ export function signEmailTrackingToken(payload, secret) {
14
+ const body = base64urlEncode(JSON.stringify(payload));
15
+ const signature = createHmac("sha256", secret).update(body).digest("base64url");
16
+ return `${body}.${signature}`;
17
+ }
18
+ /** Constant-time HMAC-SHA256 check; false on any error (bad base64, length mismatch). */
19
+ function verifySignature(body, providedSig, secret) {
20
+ const expected = createHmac("sha256", secret).update(body).digest();
21
+ let provided;
22
+ try {
23
+ provided = Buffer.from(providedSig, "base64url");
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ return provided.length === expected.length && timingSafeEqual(provided, expected);
29
+ }
30
+ /** Decode + validate the base64url body; typed payload or null on any problem. */
31
+ function parseTokenPayload(body) {
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ if (!parsed || typeof parsed !== "object")
40
+ return null;
41
+ const candidate = parsed;
42
+ if (candidate.v !== 1)
43
+ return null;
44
+ const org = typeof candidate.org === "string" ? candidate.org.trim() : "";
45
+ const act = typeof candidate.act === "string" ? candidate.act.trim() : "";
46
+ const kind = candidate.kind === "open" || candidate.kind === "click" ? candidate.kind : null;
47
+ if (!org || !act || !kind)
48
+ return null;
49
+ // A click token MUST bind a url hash; an open token must NOT (so a click token
50
+ // can't be replayed against the open route, or vice versa, to skip the check).
51
+ const uh = typeof candidate.uh === "string" && candidate.uh ? candidate.uh : undefined;
52
+ if (kind === "click" && !uh)
53
+ return null;
54
+ if (kind === "open" && uh)
55
+ return null;
56
+ return {
57
+ v: 1,
58
+ org,
59
+ act,
60
+ kind,
61
+ ...(uh ? { uh } : {}),
62
+ ...(typeof candidate.seq === "string" && candidate.seq ? { seq: candidate.seq } : {}),
63
+ ...(typeof candidate.enr === "string" && candidate.enr ? { enr: candidate.enr } : {}),
64
+ iat: typeof candidate.iat === "number" && Number.isFinite(candidate.iat) ? candidate.iat : 0,
65
+ };
66
+ }
67
+ /**
68
+ * Verify a token's signature (constant-time) and shape. Returns the payload only
69
+ * when the signature matches AND it is a well-formed v1 payload; null on ANY
70
+ * problem (bad shape, blank/wrong secret, tampered body). Pure: the route maps
71
+ * null -> benign gif / plain redirect with no recording.
72
+ */
73
+ export function verifyEmailTrackingToken(token, secret) {
74
+ if (typeof token !== "string" || typeof secret !== "string" || !secret)
75
+ return null;
76
+ const dot = token.indexOf(".");
77
+ if (dot <= 0 || dot === token.length - 1)
78
+ return null;
79
+ const body = token.slice(0, dot);
80
+ const providedSig = token.slice(dot + 1);
81
+ if (!verifySignature(body, providedSig, secret))
82
+ return null;
83
+ return parseTokenPayload(body);
84
+ }
85
+ /**
86
+ * Verify a click token AND that the supplied destination URL is the one it
87
+ * authorizes (constant-time hash compare). Returns the payload only when both the
88
+ * signature and the url-hash match; null otherwise. This is what stops the click
89
+ * route from being an open redirect — only the exact signed URL is honored.
90
+ */
91
+ export function verifyEmailClickToken(token, destinationUrl, secret) {
92
+ const payload = verifyEmailTrackingToken(token, secret);
93
+ if (!payload || payload.kind !== "click" || !payload.uh)
94
+ return null;
95
+ const expected = Buffer.from(payload.uh, "utf8");
96
+ const actual = Buffer.from(hashTrackingUrl(destinationUrl), "utf8");
97
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
98
+ return null;
99
+ return payload;
100
+ }
101
+ /**
102
+ * The HMAC signing secret for tracking tokens, or null when unset. This is the
103
+ * kill switch: null means "tracking disabled" everywhere — the dispatcher skips
104
+ * injection and the public routes serve a benign response without recording.
105
+ */
106
+ export function emailTrackingSigningSecret(env = process.env) {
107
+ const secret = env.EMAIL_TRACKING_SECRET?.trim();
108
+ return secret ? secret : null;
109
+ }
110
+ /**
111
+ * The public app base URL tracking links point at, trailing slash stripped.
112
+ * Prefers NEXT_PUBLIC_APP_URL, then OXYGEN_APP_URL, then the prod host. (A
113
+ * verified per-org tracking domain is used at send time when present; this is the
114
+ * fallback the token routes are always reachable at.)
115
+ */
116
+ export function emailTrackingAppBaseUrl(env = process.env) {
117
+ const raw = (env.NEXT_PUBLIC_APP_URL || env.OXYGEN_APP_URL || "https://oxygen-agent.com").trim();
118
+ return raw.replace(/\/+$/, "");
119
+ }
120
+ /** The open-pixel URL for a signed token: `<base>/api/t/o/<token>`. */
121
+ export function buildOpenPixelUrl(token, baseUrl) {
122
+ return `${baseUrl.replace(/\/+$/, "")}/api/t/o/${encodeURIComponent(token)}`;
123
+ }
124
+ /**
125
+ * The click-tracking URL for a signed token + destination: the destination rides
126
+ * in the `u` query param (the route recomputes its hash against the token's `uh`).
127
+ */
128
+ export function buildClickTrackingUrl(token, destinationUrl, baseUrl) {
129
+ return `${baseUrl.replace(/\/+$/, "")}/api/t/c/${encodeURIComponent(token)}?u=${encodeURIComponent(destinationUrl)}`;
130
+ }
@@ -7,10 +7,13 @@ export * from "./cli-envelope.js";
7
7
  export * from "./cli-result.js";
8
8
  export * from "./column-types.js";
9
9
  export * from "./credit-guidance.js";
10
+ export * from "./directory.js";
11
+ export * from "./email-tracking-token.js";
10
12
  export * from "./email-unsubscribe-token.js";
11
13
  export * from "./linkedin-post-url.js";
12
14
  export * from "./linkedin-sequences.js";
13
15
  export * from "./networks.js";
16
+ export * from "./sequence-template.js";
14
17
  export * from "./sequences.js";
15
18
  export * from "./log.js";
16
19
  export * from "./provider-request-outcomes.js";
@@ -7,10 +7,13 @@ export * from "./cli-envelope.js";
7
7
  export * from "./cli-result.js";
8
8
  export * from "./column-types.js";
9
9
  export * from "./credit-guidance.js";
10
+ export * from "./directory.js";
11
+ export * from "./email-tracking-token.js";
10
12
  export * from "./email-unsubscribe-token.js";
11
13
  export * from "./linkedin-post-url.js";
12
14
  export * from "./linkedin-sequences.js";
13
15
  export * from "./networks.js";
16
+ export * from "./sequence-template.js";
14
17
  export * from "./sequences.js";
15
18
  export * from "./log.js";
16
19
  export * from "./provider-request-outcomes.js";
@@ -16,10 +16,19 @@
16
16
  * - message — send a message; template supports {{column}} interp
17
17
  * - inmail — send an InMail (works on non-connections)
18
18
  */
19
- export declare const LINKEDIN_SEQUENCE_STEP_KINDS: readonly ["visit_profile", "invite", "wait_for_connection", "wait", "message", "inmail", "branch", "stop"];
19
+ import { type RenderTemplateOptions } from "./sequence-template.js";
20
+ export declare const LINKEDIN_SEQUENCE_STEP_KINDS: readonly ["visit_profile", "invite", "wait_for_connection", "wait", "message", "inmail", "follow", "like_post", "comment_post", "withdraw_invite", "branch", "stop"];
20
21
  export type LinkedInSequenceStepKind = typeof LINKEDIN_SEQUENCE_STEP_KINDS[number];
21
- /** Conditions a `branch` step routes on. Both are LinkedIn-signal driven. */
22
- export declare const LINKEDIN_BRANCH_CONDITIONS: readonly ["connection_accepted", "already_connected"];
22
+ /** LinkedIn reaction types for a `like_post` step. */
23
+ export declare const LINKEDIN_POST_REACTIONS: readonly ["like", "celebrate", "support", "funny", "love", "insightful"];
24
+ export type LinkedInPostReaction = typeof LINKEDIN_POST_REACTIONS[number];
25
+ /**
26
+ * Conditions a `branch` step routes on. All are LinkedIn-signal driven.
27
+ * - connection_accepted / already_connected: connection-degree gates.
28
+ * - open_profile: is the lead an Open Profile (free InMail target)? Lets a
29
+ * sequence route `branch open_profile → inmail : invite` (the Expandi play).
30
+ */
31
+ export declare const LINKEDIN_BRANCH_CONDITIONS: readonly ["connection_accepted", "already_connected", "open_profile"];
23
32
  export type LinkedInBranchCondition = typeof LINKEDIN_BRANCH_CONDITIONS[number];
24
33
  /** Every step carries a stable id so branch edges can target it by reference. */
25
34
  export type LinkedInStepBase = {
@@ -50,6 +59,11 @@ export type LinkedInMessageStep = LinkedInStepBase & {
50
59
  kind: "message";
51
60
  /** Message body. Supports {{column}} interpolation from the source-table row. */
52
61
  template: string;
62
+ /** Files attached to the message (image / document), fetched + uploaded at send. */
63
+ attachments?: {
64
+ url: string;
65
+ name?: string;
66
+ }[];
53
67
  };
54
68
  export type LinkedInInMailStep = LinkedInStepBase & {
55
69
  kind: "inmail";
@@ -86,7 +100,34 @@ export type LinkedInBranchStep = LinkedInStepBase & {
86
100
  export type LinkedInStopStep = LinkedInStepBase & {
87
101
  kind: "stop";
88
102
  };
89
- export type LinkedInSequenceStep = LinkedInVisitProfileStep | LinkedInInviteStep | LinkedInWaitForConnectionStep | LinkedInWaitStep | LinkedInMessageStep | LinkedInInMailStep | LinkedInBranchStep | LinkedInStopStep;
103
+ /** Follow the lead — no connection required. A light warm-up touch. */
104
+ export type LinkedInFollowStep = LinkedInStepBase & {
105
+ kind: "follow";
106
+ };
107
+ /** Post-targeting steps share how they pick which of the lead's posts to act on. */
108
+ type LinkedInPostTargetingBase = {
109
+ /** Only act on a post published within this many days. Defaults to 30. */
110
+ post_recency_days?: number;
111
+ /** What to do when the lead has no recent post to act on. Defaults to "skip". */
112
+ on_no_post?: "skip" | "stop";
113
+ };
114
+ export type LinkedInLikePostStep = LinkedInStepBase & LinkedInPostTargetingBase & {
115
+ kind: "like_post";
116
+ /** Reaction to leave. Defaults to "like". */
117
+ reaction?: LinkedInPostReaction;
118
+ };
119
+ export type LinkedInCommentPostStep = LinkedInStepBase & LinkedInPostTargetingBase & {
120
+ kind: "comment_post";
121
+ /** Comment text with {{column}} interpolation. Provide exactly one of this or ai_prompt. */
122
+ text_template?: string;
123
+ /** Prompt for an AI-generated comment (rendered against the row + post). Exactly one of this or text_template. */
124
+ ai_prompt?: string;
125
+ };
126
+ /** Withdraw the still-pending connection invite to this lead (a no-op if none is pending). */
127
+ export type LinkedInWithdrawInviteStep = LinkedInStepBase & {
128
+ kind: "withdraw_invite";
129
+ };
130
+ export type LinkedInSequenceStep = LinkedInVisitProfileStep | LinkedInInviteStep | LinkedInWaitForConnectionStep | LinkedInWaitStep | LinkedInMessageStep | LinkedInInMailStep | LinkedInFollowStep | LinkedInLikePostStep | LinkedInCommentPostStep | LinkedInWithdrawInviteStep | LinkedInBranchStep | LinkedInStopStep;
90
131
  export type LinkedInSequenceDefinition = {
91
132
  steps: LinkedInSequenceStep[];
92
133
  };
@@ -96,11 +137,17 @@ export type LinkedInSequenceDefinition = {
96
137
  * that dispatch nothing. NOTE: this is the *action* kind, not the *quota* kind
97
138
  * — visit_profile maps to the quota kind profile_view downstream.
98
139
  */
99
- export declare const LINKEDIN_STEP_ACTION_KIND: Record<LinkedInSequenceStepKind, "visit_profile" | "invite" | "message" | "inmail" | null>;
140
+ export type LinkedInStepActionKind = "visit_profile" | "invite" | "message" | "inmail" | "follow" | "like_post" | "comment_post" | "withdraw_invite";
141
+ export declare const LINKEDIN_STEP_ACTION_KIND: Record<LinkedInSequenceStepKind, LinkedInStepActionKind | null>;
100
142
  /** Total delay in milliseconds a `wait` step introduces. */
101
143
  export declare function waitStepDelayMs(step: LinkedInWaitStep): number;
102
144
  /**
103
- * Render a {{column}} template against a row's values. Unknown placeholders
104
- * render empty. Used by the dispatch engine to produce the final message text.
145
+ * Render a sequence-copy template against a row's values. Delegates to the shared
146
+ * deterministic engine, so beyond `{{column}}` substitution it also handles
147
+ * `{{column|fallback}}`, `{{RANDOM|…}}` spintax, and `{% if … %}` conditionals.
148
+ * Unknown `{{column}}` placeholders render empty. Used by the dispatch engine to
149
+ * produce the final message text. Pass `{ seed }` to make spintax choices
150
+ * replayable (crash-safe); with no seed a stable template+values seed is derived.
105
151
  */
106
- export declare function renderLinkedInTemplate(template: string, values: Record<string, unknown>): string;
152
+ export declare function renderLinkedInTemplate(template: string, values: Record<string, unknown>, options?: RenderTemplateOptions): string;
153
+ export {};
@@ -16,6 +16,7 @@
16
16
  * - message — send a message; template supports {{column}} interp
17
17
  * - inmail — send an InMail (works on non-connections)
18
18
  */
19
+ import { renderTemplate } from "./sequence-template.js";
19
20
  export const LINKEDIN_SEQUENCE_STEP_KINDS = [
20
21
  "visit_profile",
21
22
  "invite",
@@ -23,17 +24,23 @@ export const LINKEDIN_SEQUENCE_STEP_KINDS = [
23
24
  "wait",
24
25
  "message",
25
26
  "inmail",
27
+ // Social-graph steps (the "act like a human warming up a lead" palette):
28
+ "follow", // follow the lead (no connection needed) — a light touch before an invite
29
+ "like_post", // react to the lead's most recent post
30
+ "comment_post", // comment on the lead's most recent post (template or AI-generated)
31
+ "withdraw_invite", // withdraw a still-pending connection invite to this lead
26
32
  "branch",
27
33
  "stop",
28
34
  ];
29
- /** Conditions a `branch` step routes on. Both are LinkedIn-signal driven. */
30
- export const LINKEDIN_BRANCH_CONDITIONS = ["connection_accepted", "already_connected"];
35
+ /** LinkedIn reaction types for a `like_post` step. */
36
+ export const LINKEDIN_POST_REACTIONS = ["like", "celebrate", "support", "funny", "love", "insightful"];
31
37
  /**
32
- * The dispatch-queue action kind a step produces (matches the
33
- * ox_sequencer.sequence_actions.action_kind enum), or null for gate/wait steps
34
- * that dispatch nothing. NOTE: this is the *action* kind, not the *quota* kind
35
- * — visit_profile maps to the quota kind profile_view downstream.
38
+ * Conditions a `branch` step routes on. All are LinkedIn-signal driven.
39
+ * - connection_accepted / already_connected: connection-degree gates.
40
+ * - open_profile: is the lead an Open Profile (free InMail target)? Lets a
41
+ * sequence route `branch open_profile → inmail : invite` (the Expandi play).
36
42
  */
43
+ export const LINKEDIN_BRANCH_CONDITIONS = ["connection_accepted", "already_connected", "open_profile"];
37
44
  export const LINKEDIN_STEP_ACTION_KIND = {
38
45
  visit_profile: "visit_profile",
39
46
  invite: "invite",
@@ -41,6 +48,10 @@ export const LINKEDIN_STEP_ACTION_KIND = {
41
48
  wait: null,
42
49
  message: "message",
43
50
  inmail: "inmail",
51
+ follow: "follow",
52
+ like_post: "like_post",
53
+ comment_post: "comment_post",
54
+ withdraw_invite: "withdraw_invite",
44
55
  branch: null,
45
56
  stop: null,
46
57
  };
@@ -51,14 +62,13 @@ export function waitStepDelayMs(step) {
51
62
  return (days * 24 + hours) * 60 * 60 * 1000;
52
63
  }
53
64
  /**
54
- * Render a {{column}} template against a row's values. Unknown placeholders
55
- * render empty. Used by the dispatch engine to produce the final message text.
65
+ * Render a sequence-copy template against a row's values. Delegates to the shared
66
+ * deterministic engine, so beyond `{{column}}` substitution it also handles
67
+ * `{{column|fallback}}`, `{{RANDOM|…}}` spintax, and `{% if … %}` conditionals.
68
+ * Unknown `{{column}}` placeholders render empty. Used by the dispatch engine to
69
+ * produce the final message text. Pass `{ seed }` to make spintax choices
70
+ * replayable (crash-safe); with no seed a stable template+values seed is derived.
56
71
  */
57
- export function renderLinkedInTemplate(template, values) {
58
- return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_match, key) => {
59
- const value = values[key];
60
- if (value === null || value === undefined)
61
- return "";
62
- return typeof value === "string" ? value : String(value);
63
- });
72
+ export function renderLinkedInTemplate(template, values, options) {
73
+ return renderTemplate(template, values, options);
64
74
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Deterministic sequence-copy template engine. Renders the copy of a sequence
3
+ * step (LinkedIn message / InMail / invite note, native email subject/body,
4
+ * WhatsApp message, Instantly custom fields) against a lead's row values in a
5
+ * SINGLE deterministic pass. Beyond the original `{{column}}` substitution it
6
+ * supports three composable, nestable constructs:
7
+ *
8
+ * 1. Inline fallback — `{{column|fallback text}}` renders the column's value, or
9
+ * the literal fallback text when the column is missing/empty. `{{column}}`
10
+ * with no `|` keeps the original behavior (empty when the column is missing).
11
+ * 2. Spintax — `{{RANDOM|option a|option b|option c}}` renders ONE option chosen
12
+ * deterministically from the seed (never Math.random — retries/replays never
13
+ * drift). Options may themselves contain `{{column}}`/fallback/spintax/
14
+ * conditionals, which resolve AFTER selection.
15
+ * 3. Conditional blocks — `{% if column %}A{% endif %}` (truthy),
16
+ * `{% if column == "x" %}A{% else %}B{% endif %}` (== / !=), and
17
+ * `{% if column contains "x" %}A{% endif %}` (case-insensitive substring).
18
+ * The `{% else %}` arm and the operator/quoted value are optional; either
19
+ * branch may nest any construct.
20
+ *
21
+ * Determinism contract: the SAME (template, values, seed) always renders
22
+ * byte-identically. The sequencer dispatch passes an enrollment-scoped seed so a
23
+ * crash-replay of a step re-renders identically; with no seed the engine derives
24
+ * a stable seed from the template + values (still deterministic, just not
25
+ * enrollment-scoped). Pure — no I/O, no clock, no randomness.
26
+ *
27
+ * This is the single home for the render engine; `renderLinkedInTemplate`
28
+ * (linkedin-sequences.ts) and `renderSequenceTemplate` (sequences.ts) both
29
+ * delegate here so every surface renders copy identically.
30
+ */
31
+ export type RenderTemplateOptions = {
32
+ /**
33
+ * Seed for spintax selection. When provided, ALL spintax choices in the
34
+ * template are derived from it, so the same (template, values, seed) renders
35
+ * byte-identically across retries/replays. When omitted, a stable seed is
36
+ * derived from the template + values (still deterministic, not enrollment-scoped).
37
+ */
38
+ seed?: string;
39
+ };
40
+ /**
41
+ * Deterministic, replayable FNV-1a hash → uint32. Stable across processes — the
42
+ * single hash used by both spintax selection here and A/B variant assignment in
43
+ * sequences.ts (which imports it from this module).
44
+ */
45
+ export declare function hashVariantKey(value: string): number;
46
+ /**
47
+ * Render a sequence-copy template against a lead's row values. Supports
48
+ * `{{column}}` substitution, `{{column|fallback}}`, `{{RANDOM|…}}` spintax, and
49
+ * `{% if … %}…{% endif %}` conditionals, in a single deterministic pass. Unknown
50
+ * `{{column}}` placeholders render empty; a malformed placeholder is left literal.
51
+ */
52
+ export declare function renderTemplate(template: string, values: Record<string, unknown>, options?: RenderTemplateOptions): string;
53
+ /**
54
+ * Every column key a template references — across bare `{{column}}` substitutions,
55
+ * `{{column|fallback}}` fallbacks, `{{RANDOM|…}}` spintax options, and both the
56
+ * condition and branches of `{% if … %}` blocks. Pure scan (no rendering), used by
57
+ * the start-preview variable-resolution check so a column referenced only inside a
58
+ * nested construct still surfaces. Deduplicated.
59
+ */
60
+ export declare function templateColumnKeys(template: string): string[];