@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.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/dist/column-run-notices.d.ts +11 -0
  3. package/dist/column-run-notices.js +37 -0
  4. package/dist/command-manifest.js +13 -8
  5. package/dist/help.js +79 -16
  6. package/dist/index.js +3812 -460
  7. package/dist/skills.js +106 -1
  8. package/node_modules/@oxygen/formula/dist/coerce.d.ts +8 -0
  9. package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
  10. package/node_modules/@oxygen/formula/dist/evaluate.d.ts +31 -0
  11. package/node_modules/@oxygen/formula/dist/evaluate.js +248 -0
  12. package/node_modules/@oxygen/formula/dist/expression.d.ts +64 -0
  13. package/node_modules/@oxygen/formula/dist/expression.js +428 -0
  14. package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +71 -0
  15. package/node_modules/@oxygen/formula/dist/formula-functions.js +1100 -0
  16. package/node_modules/@oxygen/formula/dist/index.d.ts +17 -0
  17. package/node_modules/@oxygen/formula/dist/index.js +17 -0
  18. package/node_modules/@oxygen/formula/dist/value-normalizers.d.ts +30 -0
  19. package/node_modules/@oxygen/formula/dist/value-normalizers.js +80 -0
  20. package/node_modules/@oxygen/formula/package.json +26 -0
  21. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +30 -0
  22. package/node_modules/@oxygen/recipe-sdk/dist/index.js +2 -2
  23. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +60 -0
  24. package/node_modules/@oxygen/shared/dist/billing-anchors.js +135 -0
  25. package/node_modules/@oxygen/shared/dist/billing.d.ts +99 -5
  26. package/node_modules/@oxygen/shared/dist/billing.js +185 -8
  27. package/node_modules/@oxygen/shared/dist/call-outcomes.d.ts +59 -0
  28. package/node_modules/@oxygen/shared/dist/call-outcomes.js +73 -0
  29. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  30. package/node_modules/@oxygen/shared/dist/credit-guidance.js +3 -1
  31. package/node_modules/@oxygen/shared/dist/crm-reply-events.d.ts +35 -0
  32. package/node_modules/@oxygen/shared/dist/crm-reply-events.js +31 -0
  33. package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.d.ts +50 -0
  34. package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.js +65 -0
  35. package/node_modules/@oxygen/shared/dist/directory.d.ts +1 -1
  36. package/node_modules/@oxygen/shared/dist/directory.js +1 -0
  37. package/node_modules/@oxygen/shared/dist/file-import.js +58 -11
  38. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +15 -0
  39. package/node_modules/@oxygen/shared/dist/hosted-ai.js +19 -0
  40. package/node_modules/@oxygen/shared/dist/index.d.ts +9 -0
  41. package/node_modules/@oxygen/shared/dist/index.js +9 -0
  42. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +31 -0
  43. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +56 -0
  44. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +5 -4
  45. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +5 -4
  46. package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +22 -0
  47. package/node_modules/@oxygen/shared/dist/linkedin-url.js +7 -4
  48. package/node_modules/@oxygen/shared/dist/log.js +41 -2
  49. package/node_modules/@oxygen/shared/dist/microsoft-consent-url.d.ts +7 -0
  50. package/node_modules/@oxygen/shared/dist/microsoft-consent-url.js +29 -0
  51. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +31 -0
  52. package/node_modules/@oxygen/shared/dist/object-storage.js +61 -0
  53. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +636 -0
  54. package/node_modules/@oxygen/shared/dist/plan-limits.js +199 -0
  55. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +89 -23
  56. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +88 -24
  57. package/node_modules/@oxygen/shared/dist/sequence-crm-events.d.ts +291 -0
  58. package/node_modules/@oxygen/shared/dist/sequence-crm-events.js +224 -0
  59. package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +42 -1
  60. package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
  61. package/node_modules/@oxygen/shared/dist/sequences.d.ts +287 -24
  62. package/node_modules/@oxygen/shared/dist/sequences.js +940 -60
  63. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +70 -0
  64. package/node_modules/@oxygen/shared/dist/spend-safety.js +106 -0
  65. package/node_modules/@oxygen/shared/dist/tags.d.ts +90 -1
  66. package/node_modules/@oxygen/shared/dist/tags.js +122 -6
  67. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  68. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  69. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +1 -1
  70. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +4 -0
  71. package/node_modules/@oxygen/shared/package.json +95 -0
  72. package/node_modules/@oxygen/workflows/dist/event-dispatch.d.ts +126 -0
  73. package/node_modules/@oxygen/workflows/dist/event-dispatch.js +173 -0
  74. package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +78 -0
  75. package/node_modules/@oxygen/workflows/dist/graph/expression.js +700 -0
  76. package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +20 -0
  77. package/node_modules/@oxygen/workflows/dist/graph/index.js +20 -0
  78. package/node_modules/@oxygen/workflows/dist/graph/lint.d.ts +4 -0
  79. package/node_modules/@oxygen/workflows/dist/graph/lint.js +812 -0
  80. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +501 -0
  81. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +200 -0
  82. package/node_modules/@oxygen/workflows/dist/graph/params.d.ts +86 -0
  83. package/node_modules/@oxygen/workflows/dist/graph/params.js +173 -0
  84. package/node_modules/@oxygen/workflows/dist/graph/remap.d.ts +48 -0
  85. package/node_modules/@oxygen/workflows/dist/graph/remap.js +213 -0
  86. package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +46 -0
  87. package/node_modules/@oxygen/workflows/dist/graph/topology.js +280 -0
  88. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +270 -0
  89. package/node_modules/@oxygen/workflows/dist/graph/types.js +93 -0
  90. package/node_modules/@oxygen/workflows/dist/index.d.ts +113 -1
  91. package/node_modules/@oxygen/workflows/dist/index.js +179 -13
  92. package/node_modules/@oxygen/workflows/dist/tool-effects.d.ts +1 -0
  93. package/node_modules/@oxygen/workflows/dist/tool-effects.js +19 -0
  94. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +135 -4
  95. package/node_modules/@oxygen/workflows/package.json +4 -0
  96. 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: 10_000,
169
+ monthlyCredits: 0,
63
170
  weeklyCreditsLimit: null,
64
- rolloverCap: 10_000,
171
+ rolloverCap: null,
65
172
  monthlyAutomationActions: null,
66
173
  automationOverageCentsPerMillion: null,
67
174
  automationOverageEnabledDefault: false,
68
175
  byokEnabled: false,
69
- description: "Get $10 in credits on us every month — try OXYGEN with managed email, phone enrichment, and AI credits.",
70
- ctaLabel: "Start free",
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
- "$10 in credits every month",
73
- "All integrations",
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
+ }
@@ -50,6 +50,7 @@ export function exitCodeForErrorCode(code) {
50
50
  return 6;
51
51
  case "max_credits_required":
52
52
  case "approval_required":
53
+ case "effect_unknown_approval_required":
53
54
  case "spend_cap_required":
54
55
  case "spend_cap_too_low":
55
56
  case "insufficient_credits":
@@ -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. Unused credits are not spent.",
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];
@@ -13,6 +13,7 @@ export const AGENCY_DIRECTORY_SERVICES = [
13
13
  "RevOps & CRM",
14
14
  "Campaign Management",
15
15
  "Training & Coaching",
16
+ "Education",
16
17
  ];
17
18
  export const AGENCY_DIRECTORY_REGIONS = [
18
19
  "North America",