@oxygen-agent/cli 1.377.3 → 1.591.1
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/column-run-notices.d.ts +11 -0
- package/dist/column-run-notices.js +37 -0
- package/dist/command-manifest.js +13 -8
- package/dist/help.js +79 -16
- package/dist/index.js +3812 -460
- package/dist/skills.js +106 -1
- package/node_modules/@oxygen/formula/dist/coerce.d.ts +8 -0
- package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
- package/node_modules/@oxygen/formula/dist/evaluate.d.ts +31 -0
- package/node_modules/@oxygen/formula/dist/evaluate.js +248 -0
- package/node_modules/@oxygen/formula/dist/expression.d.ts +64 -0
- package/node_modules/@oxygen/formula/dist/expression.js +428 -0
- package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +71 -0
- package/node_modules/@oxygen/formula/dist/formula-functions.js +1100 -0
- package/node_modules/@oxygen/formula/dist/index.d.ts +17 -0
- package/node_modules/@oxygen/formula/dist/index.js +17 -0
- package/node_modules/@oxygen/formula/dist/value-normalizers.d.ts +30 -0
- package/node_modules/@oxygen/formula/dist/value-normalizers.js +80 -0
- package/node_modules/@oxygen/formula/package.json +26 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +30 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.js +2 -2
- package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/billing-anchors.js +135 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +99 -5
- package/node_modules/@oxygen/shared/dist/billing.js +185 -8
- package/node_modules/@oxygen/shared/dist/call-outcomes.d.ts +59 -0
- package/node_modules/@oxygen/shared/dist/call-outcomes.js +73 -0
- package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
- package/node_modules/@oxygen/shared/dist/credit-guidance.js +3 -1
- package/node_modules/@oxygen/shared/dist/crm-reply-events.d.ts +35 -0
- package/node_modules/@oxygen/shared/dist/crm-reply-events.js +31 -0
- package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.js +65 -0
- package/node_modules/@oxygen/shared/dist/directory.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/directory.js +1 -0
- package/node_modules/@oxygen/shared/dist/file-import.js +58 -11
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +19 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/index.js +9 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +56 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +5 -4
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +5 -4
- package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +22 -0
- package/node_modules/@oxygen/shared/dist/linkedin-url.js +7 -4
- package/node_modules/@oxygen/shared/dist/log.js +41 -2
- package/node_modules/@oxygen/shared/dist/microsoft-consent-url.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/microsoft-consent-url.js +29 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +61 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +636 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.js +199 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +89 -23
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +88 -24
- package/node_modules/@oxygen/shared/dist/sequence-crm-events.d.ts +291 -0
- package/node_modules/@oxygen/shared/dist/sequence-crm-events.js +224 -0
- package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +42 -1
- package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +287 -24
- package/node_modules/@oxygen/shared/dist/sequences.js +940 -60
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +70 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.js +106 -0
- package/node_modules/@oxygen/shared/dist/tags.d.ts +90 -1
- package/node_modules/@oxygen/shared/dist/tags.js +122 -6
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +4 -0
- package/node_modules/@oxygen/shared/package.json +95 -0
- package/node_modules/@oxygen/workflows/dist/event-dispatch.d.ts +126 -0
- package/node_modules/@oxygen/workflows/dist/event-dispatch.js +173 -0
- package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +78 -0
- package/node_modules/@oxygen/workflows/dist/graph/expression.js +700 -0
- package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +20 -0
- package/node_modules/@oxygen/workflows/dist/graph/index.js +20 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.d.ts +4 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +812 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +501 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +200 -0
- package/node_modules/@oxygen/workflows/dist/graph/params.d.ts +86 -0
- package/node_modules/@oxygen/workflows/dist/graph/params.js +173 -0
- package/node_modules/@oxygen/workflows/dist/graph/remap.d.ts +48 -0
- package/node_modules/@oxygen/workflows/dist/graph/remap.js +213 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +46 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.js +280 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +270 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.js +93 -0
- package/node_modules/@oxygen/workflows/dist/index.d.ts +113 -1
- package/node_modules/@oxygen/workflows/dist/index.js +179 -13
- package/node_modules/@oxygen/workflows/dist/tool-effects.d.ts +1 -0
- package/node_modules/@oxygen/workflows/dist/tool-effects.js +19 -0
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +135 -4
- package/node_modules/@oxygen/workflows/package.json +4 -0
- package/package.json +7 -5
|
@@ -30,6 +30,108 @@ export function creditTopupUsdCents(credits) {
|
|
|
30
30
|
// from the same credit pool at a flat capacity-guard rate — the old separate
|
|
31
31
|
// monthly action allowance + USD overage meter economy is retired (2026-07-14).
|
|
32
32
|
export const AUTOMATION_ACTION_CREDITS = 0.01;
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// FIXED (committed) credit commitments — recurring per-resource monthly charges
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
//
|
|
37
|
+
// Credit consumption splits in two (founder decision 2026-07-25):
|
|
38
|
+
// FIXED — recurring per-resource monthly charges known in advance. These are
|
|
39
|
+
// BLOCKED out of the balance so flexible spend cannot eat them, and
|
|
40
|
+
// each resource rolls on its OWN monthly anchor (see billing-anchors.ts).
|
|
41
|
+
// FLEXIBLE — everything drawn ad hoc: enrichment, AI columns, automation
|
|
42
|
+
// actions, copilot inference.
|
|
43
|
+
//
|
|
44
|
+
// The ledger/enforcement side lives in @oxygen/integrations (credit-commitments.ts);
|
|
45
|
+
// this module owns only the ratified prices and the lifecycle constants, because
|
|
46
|
+
// control-db and the worker both need them and neither may import the other.
|
|
47
|
+
//
|
|
48
|
+
// PRICE SOURCES DIFFER BY KIND ON PURPOSE:
|
|
49
|
+
// - sending_mailbox and managed_mailbox are ratified HERE (flat, code-signed).
|
|
50
|
+
// - mailbox_warmup and deliverability_unit are ratified in @oxygen/control-db
|
|
51
|
+
// and have live readers + billers in @oxygen/integrations. They stay null
|
|
52
|
+
// HERE because importing control-db into shared would invert the package
|
|
53
|
+
// graph; this is ownership routing, not an unsigned-price signal.
|
|
54
|
+
/** Kinds of resource that carry a fixed monthly credit commitment. */
|
|
55
|
+
export const CREDIT_COMMITMENT_KINDS = [
|
|
56
|
+
"sending_mailbox",
|
|
57
|
+
"managed_mailbox",
|
|
58
|
+
"mailbox_warmup",
|
|
59
|
+
"deliverability_unit",
|
|
60
|
+
"linkedin_account",
|
|
61
|
+
"whatsapp_account",
|
|
62
|
+
];
|
|
63
|
+
/**
|
|
64
|
+
* Sequencer platform fee per CONNECTED SENDING MAILBOX per month, in credits
|
|
65
|
+
* ($1.00). Charged on EVERY mailbox wired to the sequencer — BYOK/self-connected
|
|
66
|
+
* Gmail and Microsoft inboxes included — and it STACKS on Oxygen-sold mailboxes,
|
|
67
|
+
* which additionally pay their own mailbox/warmup/placement lines.
|
|
68
|
+
*/
|
|
69
|
+
export const SENDING_MAILBOX_MONTHLY_CREDITS = 1_000;
|
|
70
|
+
/**
|
|
71
|
+
* Oxygen-sold managed mailbox per month, in credits ($3.00). Flat, superseding
|
|
72
|
+
* the dynamic vendor-COGS x 1.25 quote for the recurring mailbox line.
|
|
73
|
+
*/
|
|
74
|
+
export const MANAGED_MAILBOX_MONTHLY_CREDITS = 3_000;
|
|
75
|
+
/**
|
|
76
|
+
* Grace window after a commitment goes past_due before the owning subsystem may
|
|
77
|
+
* suspend the resource. Notify -> pause -> lapse; never auto-cancel. Aligned with
|
|
78
|
+
* the subscription entitlement grace so a customer never hits two different
|
|
79
|
+
* clocks for the same missed payment.
|
|
80
|
+
*/
|
|
81
|
+
export const COMMITMENT_PAST_DUE_GRACE_DAYS = 14;
|
|
82
|
+
/**
|
|
83
|
+
* Reconnect window in which an ENDED commitment resumes instead of starting a new
|
|
84
|
+
* one. The single most important anti-double-charge rule: a Unipile re-auth, a
|
|
85
|
+
* mailbox re-import, or a reconciler blip must not re-charge a full month. Past
|
|
86
|
+
* this window a reconnect is treated as genuinely new and gets a fresh anchor,
|
|
87
|
+
* so a long-dead row can never resurrect and bill a stale period.
|
|
88
|
+
*/
|
|
89
|
+
export const COMMITMENT_RESUME_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
90
|
+
/**
|
|
91
|
+
* Maximum periods one commitment may catch up in a single sweep tick. A worker
|
|
92
|
+
* outage (or a resurrected row) must never drain a wallet in one lump: past this
|
|
93
|
+
* many overdue periods the biller skips forward, stamps metadata.skipped_periods,
|
|
94
|
+
* and logs. Fail closed TOWARD the customer.
|
|
95
|
+
*/
|
|
96
|
+
export const COMMITMENT_MAX_CATCHUP_PERIODS = 3;
|
|
97
|
+
/**
|
|
98
|
+
* Free month granted to resources that already existed when commitments went
|
|
99
|
+
* live. Combined with CREDIT_COMMITMENT_EPOCH_AT (which pins their anchor to
|
|
100
|
+
* go-live rather than their original created_at), this guarantees no existing
|
|
101
|
+
* customer is charged for infrastructure that was free when they connected it.
|
|
102
|
+
*/
|
|
103
|
+
export const COMMITMENT_GRACE_DAYS = 30;
|
|
104
|
+
/**
|
|
105
|
+
* Flat ratified monthly price for a commitment kind, in credits — or null when
|
|
106
|
+
* the price is not owned HERE (warmup and deliverability are ratified in
|
|
107
|
+
* @oxygen/control-db; their callers use the integrations readers).
|
|
108
|
+
*
|
|
109
|
+
* Returning null is the fail-closed signal: an unpriced kind creates NO
|
|
110
|
+
* commitment row rather than a zero-credit one, matching the
|
|
111
|
+
* InboxPricingUnsignedError / WarmupPricingUnsignedError doctrine.
|
|
112
|
+
*/
|
|
113
|
+
export function ratifiedCommitmentCredits(kind) {
|
|
114
|
+
switch (kind) {
|
|
115
|
+
case "sending_mailbox":
|
|
116
|
+
return SENDING_MAILBOX_MONTHLY_CREDITS;
|
|
117
|
+
case "managed_mailbox":
|
|
118
|
+
return MANAGED_MAILBOX_MONTHLY_CREDITS;
|
|
119
|
+
// Ratified in @oxygen/control-db (MAILBOX_WARMUP_MONTHLY_CREDITS /
|
|
120
|
+
// DELIVERABILITY_UNIT_MONTHLY_CREDITS) since 2026-07-29, alongside the seat
|
|
121
|
+
// prices below. @oxygen/shared cannot import control-db, so these still
|
|
122
|
+
// resolve through their own readers in @oxygen/integrations — null here
|
|
123
|
+
// means "not mine to answer", not "unpriced".
|
|
124
|
+
case "mailbox_warmup":
|
|
125
|
+
case "deliverability_unit":
|
|
126
|
+
return null;
|
|
127
|
+
// Signed in control-db (LINKEDIN_ACCOUNT_MONTHLY_CREDITS /
|
|
128
|
+
// WHATSAPP_ACCOUNT_MONTHLY_CREDITS) — control-db cannot import @oxygen/shared,
|
|
129
|
+
// so the constants live there and the caller passes them.
|
|
130
|
+
case "linkedin_account":
|
|
131
|
+
case "whatsapp_account":
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
33
135
|
export const CREDIT_TOPUP_PACKS = [
|
|
34
136
|
{ id: "10", usdCents: 1_000, credits: 8_000 },
|
|
35
137
|
{ id: "25", usdCents: 2_500, credits: 20_000 },
|
|
@@ -55,24 +157,27 @@ export const TRIAL_CREDIT_GRANT = 20_000;
|
|
|
55
157
|
export const WALKTHROUGH_COMPLETION_BONUS_CREDITS = 1_000;
|
|
56
158
|
export const WALKTHROUGH_SCRAPE_MAX_CREDITS = 2_000;
|
|
57
159
|
export const BASE_PRICING_PLANS = {
|
|
160
|
+
// Fully retired as a distribution surface (2026-07-22; entry is the
|
|
161
|
+
// card-required Starter trial). The tier survives only as the unentitled
|
|
162
|
+
// fallback for churned / never-subscribed orgs: zero monthly credits means
|
|
163
|
+
// ensureCurrentPlanCreditGrant short-circuits — no monthly minting AND no
|
|
164
|
+
// rollover-cap claw-back of leftover balances, which stay spendable.
|
|
58
165
|
free: {
|
|
59
166
|
tier: "free",
|
|
60
167
|
name: "Free",
|
|
61
168
|
monthlyPriceCents: 0,
|
|
62
|
-
monthlyCredits:
|
|
169
|
+
monthlyCredits: 0,
|
|
63
170
|
weeklyCreditsLimit: null,
|
|
64
|
-
rolloverCap:
|
|
171
|
+
rolloverCap: null,
|
|
65
172
|
monthlyAutomationActions: null,
|
|
66
173
|
automationOverageCentsPerMillion: null,
|
|
67
174
|
automationOverageEnabledDefault: false,
|
|
68
175
|
byokEnabled: false,
|
|
69
|
-
description: "
|
|
70
|
-
ctaLabel: "Start
|
|
176
|
+
description: "No active plan. Start a 7-day Starter trial to use OXYGEN — existing credit balances remain spendable.",
|
|
177
|
+
ctaLabel: "Start trial",
|
|
71
178
|
features: [
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"Workflows",
|
|
75
|
-
"No card required",
|
|
179
|
+
"Existing credits stay spendable",
|
|
180
|
+
"Read access to your workspace",
|
|
76
181
|
],
|
|
77
182
|
},
|
|
78
183
|
starter: {
|
|
@@ -428,6 +533,78 @@ export function getCurrentBillingCycleKey(date = new Date()) {
|
|
|
428
533
|
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
429
534
|
return `${year}-${month}`;
|
|
430
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* A monthly Stripe period runs 28-31 days; 35 leaves slack for a proration or a
|
|
538
|
+
* billing-anchor shift without admitting a quarterly or annual period.
|
|
539
|
+
*/
|
|
540
|
+
export const CREDIT_CYCLE_MAX_PERIOD_DAYS = 35;
|
|
541
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
542
|
+
function startOfUtcMonth(date) {
|
|
543
|
+
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
|
|
544
|
+
}
|
|
545
|
+
function startOfFollowingUtcMonth(date) {
|
|
546
|
+
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1));
|
|
547
|
+
}
|
|
548
|
+
function formatUtcMonth(date) {
|
|
549
|
+
return date.toLocaleDateString("en-US", {
|
|
550
|
+
month: "long",
|
|
551
|
+
year: "numeric",
|
|
552
|
+
timeZone: "UTC",
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
function formatUtcDay(date) {
|
|
556
|
+
return date.toLocaleDateString("en-US", {
|
|
557
|
+
month: "short",
|
|
558
|
+
day: "numeric",
|
|
559
|
+
timeZone: "UTC",
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* The window the credit-allowance bar measures: "this month's credits", resolved
|
|
564
|
+
* to the boundary at which the plan's grant actually renews.
|
|
565
|
+
*
|
|
566
|
+
* A Stripe-managed monthly subscription renews on its own period boundary, so
|
|
567
|
+
* that is the honest cycle for it. Everything else — off-Stripe custom plans,
|
|
568
|
+
* the free tier, and any non-monthly Stripe period — renews by CALENDAR month,
|
|
569
|
+
* which is the same boundary already used by getCurrentBillingCycleKey(),
|
|
570
|
+
* getCurrentFreeTierCycleKey(), the shared-billing workspace cap, and the
|
|
571
|
+
* automation-actions meter. The fallback is therefore consistent with every
|
|
572
|
+
* other monthly concept in the product rather than an invention.
|
|
573
|
+
*
|
|
574
|
+
* The length guard cuts BOTH ways deliberately. `now - start <= 35d` rejects a
|
|
575
|
+
* stale period a webhook never advanced; `end - start <= 35d` rejects a genuine
|
|
576
|
+
* annual subscription, which would otherwise pass the first check on day 3 and
|
|
577
|
+
* render a 365-day "month".
|
|
578
|
+
*/
|
|
579
|
+
export function resolveCreditCycleWindow(input) {
|
|
580
|
+
const now = input.now ?? new Date();
|
|
581
|
+
const subscription = input.subscription;
|
|
582
|
+
const start = subscription?.currentPeriodStart ?? null;
|
|
583
|
+
const end = subscription?.currentPeriodEnd ?? null;
|
|
584
|
+
const usesPeriod = subscription != null
|
|
585
|
+
&& !subscription.offStripe
|
|
586
|
+
&& start != null
|
|
587
|
+
&& start.getTime() <= now.getTime()
|
|
588
|
+
&& now.getTime() - start.getTime() <= CREDIT_CYCLE_MAX_PERIOD_DAYS * MS_PER_DAY
|
|
589
|
+
&& (end == null
|
|
590
|
+
|| end.getTime() - start.getTime() <= CREDIT_CYCLE_MAX_PERIOD_DAYS * MS_PER_DAY);
|
|
591
|
+
if (usesPeriod && start) {
|
|
592
|
+
const periodEnd = end ?? new Date(start.getTime() + 31 * MS_PER_DAY);
|
|
593
|
+
return {
|
|
594
|
+
start,
|
|
595
|
+
end: periodEnd,
|
|
596
|
+
source: "subscription_period",
|
|
597
|
+
label: `${formatUtcDay(start)} – ${formatUtcDay(periodEnd)}`,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
const monthStart = startOfUtcMonth(now);
|
|
601
|
+
return {
|
|
602
|
+
start: monthStart,
|
|
603
|
+
end: startOfFollowingUtcMonth(now),
|
|
604
|
+
source: "calendar_month",
|
|
605
|
+
label: formatUtcMonth(monthStart),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
431
608
|
export function evaluateWeeklyQuota(usedCredits, requestedCredits, weeklyCreditsLimit) {
|
|
432
609
|
const projectedCredits = usedCredits + requestedCredits;
|
|
433
610
|
return {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifying how a dial ended, for the power dialer.
|
|
3
|
+
*
|
|
4
|
+
* This exists because the BROWSER CANNOT TELL. The dial path bridges the rep's
|
|
5
|
+
* leg to Twilio immediately and then `<Dial>` rings the lead, so the softphone
|
|
6
|
+
* reports "on call" while the phone is still ringing. A 25-second call that was
|
|
7
|
+
* never answered looks exactly like a 25-second conversation from the client's
|
|
8
|
+
* side. Only the carrier knows, via `DialCallStatus` and answering-machine
|
|
9
|
+
* detection, and both arrive server-side on the status webhook.
|
|
10
|
+
*
|
|
11
|
+
* So the rule is: the power dialer advances on what the SERVER says happened,
|
|
12
|
+
* never on a client-side timer.
|
|
13
|
+
*/
|
|
14
|
+
/** Mirrors tenant-db CALL_STATUSES. Kept as strings so shared stays dependency-free. */
|
|
15
|
+
export type CallOutcomeStatus = "queued" | "ringing" | "in_progress" | "completed" | "failed" | "no_answer" | "busy" | "canceled" | "refused";
|
|
16
|
+
export type CallOutcomeAnsweredBy = "human" | "machine_start" | "machine_end_beep" | "machine_end_silence" | "machine_end_other" | "fax" | "unknown";
|
|
17
|
+
export type CallOutcome = {
|
|
18
|
+
status: CallOutcomeStatus | null;
|
|
19
|
+
answeredBy: CallOutcomeAnsweredBy | null;
|
|
20
|
+
/** Set when a guardrail refused the dial. */
|
|
21
|
+
refusedReason: string | null;
|
|
22
|
+
};
|
|
23
|
+
export type DialVerdict =
|
|
24
|
+
/** A person picked up. Stop the queue — the rep is in a conversation. */
|
|
25
|
+
{
|
|
26
|
+
kind: "human";
|
|
27
|
+
}
|
|
28
|
+
/** Nobody, or a machine. Safe to auto-disposition and move on. */
|
|
29
|
+
| {
|
|
30
|
+
kind: "auto_advance";
|
|
31
|
+
disposition: "no_answer" | "voicemail" | "busy" | "failed";
|
|
32
|
+
}
|
|
33
|
+
/** A guardrail said no. Advancing would burn the queue against a standing block. */
|
|
34
|
+
| {
|
|
35
|
+
kind: "refused";
|
|
36
|
+
reason: string;
|
|
37
|
+
}
|
|
38
|
+
/** Not settled yet — poll again, and default to stopping if it never settles. */
|
|
39
|
+
| {
|
|
40
|
+
kind: "pending";
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* What should the dialer do next?
|
|
44
|
+
*
|
|
45
|
+
* Biased toward STOPPING. Auto-advancing past a live human is the worst failure
|
|
46
|
+
* this component has — it hangs up on a prospect who answered — so anything
|
|
47
|
+
* ambiguous returns `pending`, and a caller that runs out of patience must stop
|
|
48
|
+
* rather than advance.
|
|
49
|
+
*/
|
|
50
|
+
export declare function classifyDialOutcome(outcome: CallOutcome): DialVerdict;
|
|
51
|
+
/**
|
|
52
|
+
* Should a refusal stop the whole session rather than skip one lead?
|
|
53
|
+
*
|
|
54
|
+
* A suppressed contact or a bad calling window is specific to that lead — skip
|
|
55
|
+
* it. An exhausted daily cap, an unhealthy pool, or an empty credit balance
|
|
56
|
+
* refuses everyone, so continuing would march through the entire queue marking
|
|
57
|
+
* good leads as failed.
|
|
58
|
+
*/
|
|
59
|
+
export declare function isStandingRefusal(reason: string): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifying how a dial ended, for the power dialer.
|
|
3
|
+
*
|
|
4
|
+
* This exists because the BROWSER CANNOT TELL. The dial path bridges the rep's
|
|
5
|
+
* leg to Twilio immediately and then `<Dial>` rings the lead, so the softphone
|
|
6
|
+
* reports "on call" while the phone is still ringing. A 25-second call that was
|
|
7
|
+
* never answered looks exactly like a 25-second conversation from the client's
|
|
8
|
+
* side. Only the carrier knows, via `DialCallStatus` and answering-machine
|
|
9
|
+
* detection, and both arrive server-side on the status webhook.
|
|
10
|
+
*
|
|
11
|
+
* So the rule is: the power dialer advances on what the SERVER says happened,
|
|
12
|
+
* never on a client-side timer.
|
|
13
|
+
*/
|
|
14
|
+
const MACHINE = new Set([
|
|
15
|
+
"machine_start",
|
|
16
|
+
"machine_end_beep",
|
|
17
|
+
"machine_end_silence",
|
|
18
|
+
"machine_end_other",
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* What should the dialer do next?
|
|
22
|
+
*
|
|
23
|
+
* Biased toward STOPPING. Auto-advancing past a live human is the worst failure
|
|
24
|
+
* this component has — it hangs up on a prospect who answered — so anything
|
|
25
|
+
* ambiguous returns `pending`, and a caller that runs out of patience must stop
|
|
26
|
+
* rather than advance.
|
|
27
|
+
*/
|
|
28
|
+
export function classifyDialOutcome(outcome) {
|
|
29
|
+
if (outcome.refusedReason)
|
|
30
|
+
return { kind: "refused", reason: outcome.refusedReason };
|
|
31
|
+
// AMD is the strongest signal and it can land BEFORE the call ends, so it is
|
|
32
|
+
// checked before status.
|
|
33
|
+
if (outcome.answeredBy === "human")
|
|
34
|
+
return { kind: "human" };
|
|
35
|
+
if (outcome.answeredBy && MACHINE.has(outcome.answeredBy)) {
|
|
36
|
+
return { kind: "auto_advance", disposition: "voicemail" };
|
|
37
|
+
}
|
|
38
|
+
if (outcome.answeredBy === "fax") {
|
|
39
|
+
return { kind: "auto_advance", disposition: "failed" };
|
|
40
|
+
}
|
|
41
|
+
switch (outcome.status) {
|
|
42
|
+
case "no_answer":
|
|
43
|
+
return { kind: "auto_advance", disposition: "no_answer" };
|
|
44
|
+
case "busy":
|
|
45
|
+
return { kind: "auto_advance", disposition: "busy" };
|
|
46
|
+
case "failed":
|
|
47
|
+
case "canceled":
|
|
48
|
+
return { kind: "auto_advance", disposition: "failed" };
|
|
49
|
+
case "completed":
|
|
50
|
+
// Completed with no AMD verdict means someone or something picked up and
|
|
51
|
+
// the call ran its course. Treated as a human: a real conversation that
|
|
52
|
+
// gets auto-dispositioned and skipped past is unrecoverable, while a
|
|
53
|
+
// machine that stops the queue costs one click.
|
|
54
|
+
return { kind: "human" };
|
|
55
|
+
case "refused":
|
|
56
|
+
return { kind: "refused", reason: outcome.refusedReason ?? "A guardrail refused the call." };
|
|
57
|
+
default:
|
|
58
|
+
return { kind: "pending" };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Guardrail refusals that will refuse EVERY subsequent dial, not just this one. */
|
|
62
|
+
const STANDING_REFUSALS = new Set(["daily_cap", "number_health", "credits"]);
|
|
63
|
+
/**
|
|
64
|
+
* Should a refusal stop the whole session rather than skip one lead?
|
|
65
|
+
*
|
|
66
|
+
* A suppressed contact or a bad calling window is specific to that lead — skip
|
|
67
|
+
* it. An exhausted daily cap, an unhealthy pool, or an empty credit balance
|
|
68
|
+
* refuses everyone, so continuing would march through the entire queue marking
|
|
69
|
+
* good leads as failed.
|
|
70
|
+
*/
|
|
71
|
+
export function isStandingRefusal(reason) {
|
|
72
|
+
return STANDING_REFUSALS.has(reason);
|
|
73
|
+
}
|
|
@@ -27,7 +27,9 @@ export function buildCreditGuidance(input) {
|
|
|
27
27
|
recommended_max_credits: recommendedMaxCredits,
|
|
28
28
|
available_credits: availableCredits,
|
|
29
29
|
credit_posture: "sufficient",
|
|
30
|
-
credit_guidance: "Credits are sufficient; use recommended_max_credits as a safety ceiling
|
|
30
|
+
credit_guidance: "Credits are sufficient; use recommended_max_credits as a safety ceiling "
|
|
31
|
+
+ `(the estimate plus ${Math.round((DEFAULT_HEADROOM_MULTIPLIER - 1) * 100)}% headroom, so a row that costs more than estimated still completes). `
|
|
32
|
+
+ "Unused credits are not spent.",
|
|
31
33
|
};
|
|
32
34
|
}
|
|
33
35
|
return {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inbound-reply CRM signal contract, shared by apps/web and apps/worker.
|
|
3
|
+
*
|
|
4
|
+
* The same logical event — "a lead's reply settled on a status" — is dispatched
|
|
5
|
+
* from three places: the on-demand `inbox analyze` route, the raw provider
|
|
6
|
+
* webhooks, and the worker's background reply bridge. They must agree on the
|
|
7
|
+
* event name AND on the external event id, because that id is the dedupe key:
|
|
8
|
+
* `recordWorkflowEvent` collapses repeats on it, and the run idempotency key is
|
|
9
|
+
* derived from it. If the worker and the web route built the id differently,
|
|
10
|
+
* the same conversation would assert the same person twice.
|
|
11
|
+
*/
|
|
12
|
+
/** Workflow event source for every inbound GTM signal that advances a lead. */
|
|
13
|
+
export declare const CRM_WORKFLOW_EVENT_SOURCE = "crm";
|
|
14
|
+
/**
|
|
15
|
+
* The single channel-neutral "inbound reply → lead stage" event. A raw reply
|
|
16
|
+
* and a classified reply both dispatch this one event; per-channel raw reply
|
|
17
|
+
* events are deliberately not modelled, because an event no standing template
|
|
18
|
+
* consumes is a dead signal.
|
|
19
|
+
*/
|
|
20
|
+
export declare const CRM_EMAIL_REPLY_CLASSIFIED_EVENT = "email_reply_classified";
|
|
21
|
+
/**
|
|
22
|
+
* Channel an inbound CRM signal arrived on. Selects which person identity the
|
|
23
|
+
* standing lead-stage router resolves on (email → email, linkedin →
|
|
24
|
+
* linkedin_url, whatsapp → external_id "whatsapp:<phone>").
|
|
25
|
+
*/
|
|
26
|
+
export type CrmSignalChannel = "email" | "linkedin" | "whatsapp";
|
|
27
|
+
/**
|
|
28
|
+
* Deterministic external event id for one conversation settling on one status.
|
|
29
|
+
*
|
|
30
|
+
* Keyed by (channel, conversation, status) rather than by conversation alone so
|
|
31
|
+
* a RE-classification to a different status is a genuinely new event that can
|
|
32
|
+
* move the lead's stage again, while a re-analysis confirming the same status
|
|
33
|
+
* dedupes to the original event and never re-asserts.
|
|
34
|
+
*/
|
|
35
|
+
export declare function crmReplyExternalEventId(channel: CrmSignalChannel | string, conversationId: string, status: string): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inbound-reply CRM signal contract, shared by apps/web and apps/worker.
|
|
3
|
+
*
|
|
4
|
+
* The same logical event — "a lead's reply settled on a status" — is dispatched
|
|
5
|
+
* from three places: the on-demand `inbox analyze` route, the raw provider
|
|
6
|
+
* webhooks, and the worker's background reply bridge. They must agree on the
|
|
7
|
+
* event name AND on the external event id, because that id is the dedupe key:
|
|
8
|
+
* `recordWorkflowEvent` collapses repeats on it, and the run idempotency key is
|
|
9
|
+
* derived from it. If the worker and the web route built the id differently,
|
|
10
|
+
* the same conversation would assert the same person twice.
|
|
11
|
+
*/
|
|
12
|
+
/** Workflow event source for every inbound GTM signal that advances a lead. */
|
|
13
|
+
export const CRM_WORKFLOW_EVENT_SOURCE = "crm";
|
|
14
|
+
/**
|
|
15
|
+
* The single channel-neutral "inbound reply → lead stage" event. A raw reply
|
|
16
|
+
* and a classified reply both dispatch this one event; per-channel raw reply
|
|
17
|
+
* events are deliberately not modelled, because an event no standing template
|
|
18
|
+
* consumes is a dead signal.
|
|
19
|
+
*/
|
|
20
|
+
export const CRM_EMAIL_REPLY_CLASSIFIED_EVENT = "email_reply_classified";
|
|
21
|
+
/**
|
|
22
|
+
* Deterministic external event id for one conversation settling on one status.
|
|
23
|
+
*
|
|
24
|
+
* Keyed by (channel, conversation, status) rather than by conversation alone so
|
|
25
|
+
* a RE-classification to a different status is a genuinely new event that can
|
|
26
|
+
* move the lead's stage again, while a re-analysis confirming the same status
|
|
27
|
+
* dedupes to the original event and never re-asserts.
|
|
28
|
+
*/
|
|
29
|
+
export function crmReplyExternalEventId(channel, conversationId, status) {
|
|
30
|
+
return `crm:${channel}:${conversationId}:${status}`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The closed vocabulary of per-lead guardrail waivers.
|
|
3
|
+
*
|
|
4
|
+
* Six guardrails stand between a rep pressing dial and a stranger's phone
|
|
5
|
+
* ringing. Three of them are POLICY defaults that a human can reasonably know
|
|
6
|
+
* better than the default for one specific lead — a prospect who says "call me
|
|
7
|
+
* at 7am", a lead whose timezone we never captured but whose rep knows where
|
|
8
|
+
* they sit, a single follow-up worth spending one more dial from a number that
|
|
9
|
+
* has hit today's ramp. The rest are not: do-not-call carries statutory
|
|
10
|
+
* damages, credits are arithmetic, a released number cannot originate a call,
|
|
11
|
+
* and a destination the carrier will not accept does not become reachable
|
|
12
|
+
* because someone ticked a box.
|
|
13
|
+
*
|
|
14
|
+
* So this list is deliberately SHORT and deliberately CLOSED. It lives in
|
|
15
|
+
* @oxygen/shared because it is the only package both @oxygen/tenant-db (which
|
|
16
|
+
* stores the waivers) and @oxygen/integrations (which honours them) can import,
|
|
17
|
+
* and because the tenant migration's CHECK constraint has to agree with it.
|
|
18
|
+
*
|
|
19
|
+
* NOTE that this is NOT a subset of DialGuardrail, and must never be turned into
|
|
20
|
+
* one. "unknown_timezone" is the null-hour BRANCH of the calling_window
|
|
21
|
+
* guardrail, not a guardrail of its own. Keeping them separate is the whole
|
|
22
|
+
* point: "I accept that we do not know their local time" is a different, smaller
|
|
23
|
+
* statement than "call this person at any hour", and collapsing the two would
|
|
24
|
+
* silently sell the second to anyone who meant the first.
|
|
25
|
+
*/
|
|
26
|
+
export declare const DIAL_GUARDRAIL_OVERRIDES: readonly ["calling_window", "unknown_timezone", "daily_cap"];
|
|
27
|
+
export type DialGuardrailOverride = (typeof DIAL_GUARDRAIL_OVERRIDES)[number];
|
|
28
|
+
export declare function isDialGuardrailOverride(value: unknown): value is DialGuardrailOverride;
|
|
29
|
+
/**
|
|
30
|
+
* What each waiver actually buys, in the words a rep or an agent reads.
|
|
31
|
+
*
|
|
32
|
+
* Phrased as the CONSEQUENCE rather than the mechanism ("may be called outside
|
|
33
|
+
* …" rather than "skips the calling_window check") because the person choosing
|
|
34
|
+
* it is deciding whether to accept that consequence, not which branch to skip.
|
|
35
|
+
*/
|
|
36
|
+
export declare const DIAL_GUARDRAIL_OVERRIDE_LABELS: Record<DialGuardrailOverride, string>;
|
|
37
|
+
/**
|
|
38
|
+
* Normalize whatever came out of the database into the canonical shape.
|
|
39
|
+
*
|
|
40
|
+
* Fails SOFT, unlike almost everything else on the dial path, and that is
|
|
41
|
+
* deliberate: this reads a text[] column, and the failure modes are a NULL from
|
|
42
|
+
* a row written before the column existed, or a value some future migration
|
|
43
|
+
* retired. Neither should stop a rep from dialing a lead — dropping an
|
|
44
|
+
* unrecognized waiver is strictly the SAFER outcome, because the guardrail it
|
|
45
|
+
* would have waived simply runs. Writes are validated separately and loudly.
|
|
46
|
+
*
|
|
47
|
+
* Deduped and returned in DIAL_GUARDRAIL_OVERRIDES order so the audit column and
|
|
48
|
+
* every API payload read the same regardless of insertion order.
|
|
49
|
+
*/
|
|
50
|
+
export declare function normalizeDialGuardrailOverrides(value: unknown): DialGuardrailOverride[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The closed vocabulary of per-lead guardrail waivers.
|
|
3
|
+
*
|
|
4
|
+
* Six guardrails stand between a rep pressing dial and a stranger's phone
|
|
5
|
+
* ringing. Three of them are POLICY defaults that a human can reasonably know
|
|
6
|
+
* better than the default for one specific lead — a prospect who says "call me
|
|
7
|
+
* at 7am", a lead whose timezone we never captured but whose rep knows where
|
|
8
|
+
* they sit, a single follow-up worth spending one more dial from a number that
|
|
9
|
+
* has hit today's ramp. The rest are not: do-not-call carries statutory
|
|
10
|
+
* damages, credits are arithmetic, a released number cannot originate a call,
|
|
11
|
+
* and a destination the carrier will not accept does not become reachable
|
|
12
|
+
* because someone ticked a box.
|
|
13
|
+
*
|
|
14
|
+
* So this list is deliberately SHORT and deliberately CLOSED. It lives in
|
|
15
|
+
* @oxygen/shared because it is the only package both @oxygen/tenant-db (which
|
|
16
|
+
* stores the waivers) and @oxygen/integrations (which honours them) can import,
|
|
17
|
+
* and because the tenant migration's CHECK constraint has to agree with it.
|
|
18
|
+
*
|
|
19
|
+
* NOTE that this is NOT a subset of DialGuardrail, and must never be turned into
|
|
20
|
+
* one. "unknown_timezone" is the null-hour BRANCH of the calling_window
|
|
21
|
+
* guardrail, not a guardrail of its own. Keeping them separate is the whole
|
|
22
|
+
* point: "I accept that we do not know their local time" is a different, smaller
|
|
23
|
+
* statement than "call this person at any hour", and collapsing the two would
|
|
24
|
+
* silently sell the second to anyone who meant the first.
|
|
25
|
+
*/
|
|
26
|
+
export const DIAL_GUARDRAIL_OVERRIDES = [
|
|
27
|
+
"calling_window",
|
|
28
|
+
"unknown_timezone",
|
|
29
|
+
"daily_cap",
|
|
30
|
+
];
|
|
31
|
+
const OVERRIDE_SET = new Set(DIAL_GUARDRAIL_OVERRIDES);
|
|
32
|
+
export function isDialGuardrailOverride(value) {
|
|
33
|
+
return typeof value === "string" && OVERRIDE_SET.has(value);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* What each waiver actually buys, in the words a rep or an agent reads.
|
|
37
|
+
*
|
|
38
|
+
* Phrased as the CONSEQUENCE rather than the mechanism ("may be called outside
|
|
39
|
+
* …" rather than "skips the calling_window check") because the person choosing
|
|
40
|
+
* it is deciding whether to accept that consequence, not which branch to skip.
|
|
41
|
+
*/
|
|
42
|
+
export const DIAL_GUARDRAIL_OVERRIDE_LABELS = {
|
|
43
|
+
calling_window: "May be called outside the 9:00-20:00 local calling window.",
|
|
44
|
+
unknown_timezone: "May be called even though we cannot resolve their local time.",
|
|
45
|
+
daily_cap: "May be dialed even when the chosen number has hit today's cap.",
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Normalize whatever came out of the database into the canonical shape.
|
|
49
|
+
*
|
|
50
|
+
* Fails SOFT, unlike almost everything else on the dial path, and that is
|
|
51
|
+
* deliberate: this reads a text[] column, and the failure modes are a NULL from
|
|
52
|
+
* a row written before the column existed, or a value some future migration
|
|
53
|
+
* retired. Neither should stop a rep from dialing a lead — dropping an
|
|
54
|
+
* unrecognized waiver is strictly the SAFER outcome, because the guardrail it
|
|
55
|
+
* would have waived simply runs. Writes are validated separately and loudly.
|
|
56
|
+
*
|
|
57
|
+
* Deduped and returned in DIAL_GUARDRAIL_OVERRIDES order so the audit column and
|
|
58
|
+
* every API payload read the same regardless of insertion order.
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeDialGuardrailOverrides(value) {
|
|
61
|
+
if (!Array.isArray(value))
|
|
62
|
+
return [];
|
|
63
|
+
const present = new Set(value.filter(isDialGuardrailOverride));
|
|
64
|
+
return DIAL_GUARDRAIL_OVERRIDES.filter((override) => present.has(override));
|
|
65
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
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"];
|
|
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", "Education"];
|
|
2
2
|
export type AgencyDirectoryService = (typeof AGENCY_DIRECTORY_SERVICES)[number];
|
|
3
3
|
export declare const AGENCY_DIRECTORY_REGIONS: readonly ["North America", "LATAM", "UK & Ireland", "DACH", "Nordics", "Europe", "APAC", "Middle East & Africa", "Global"];
|
|
4
4
|
export type AgencyDirectoryRegion = (typeof AGENCY_DIRECTORY_REGIONS)[number];
|