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