@oxygen-agent/cli 1.1010.954 → 1.1011.95

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 (27) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +2 -0
  3. package/dist/index.js +315 -40
  4. package/node_modules/@oxygen/cli-ugc/dist/commands.js +5 -5
  5. package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +16 -1
  6. package/node_modules/@oxygen/shared/dist/capability-discovery.js +35 -5
  7. package/node_modules/@oxygen/shared/dist/company-reply-pause.d.ts +55 -0
  8. package/node_modules/@oxygen/shared/dist/company-reply-pause.js +146 -0
  9. package/node_modules/@oxygen/shared/dist/freemail-domains.d.ts +3 -3
  10. package/node_modules/@oxygen/shared/dist/freemail-domains.js +3 -3
  11. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +2 -0
  12. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +5 -0
  13. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +7 -0
  14. package/node_modules/@oxygen/shared/dist/plan-band.js +24 -0
  15. package/node_modules/@oxygen/shared/dist/sequences.d.ts +54 -8
  16. package/node_modules/@oxygen/shared/dist/sequences.js +101 -9
  17. package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +1 -0
  18. package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +3 -0
  19. package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +2 -3
  20. package/node_modules/@oxygen/shared/dist/table-capacity.js +10 -16
  21. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  22. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  23. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +6 -0
  24. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +9 -1
  25. package/node_modules/@oxygen/shared/package.json +5 -0
  26. package/node_modules/@oxygen/workflows/dist/graph/lint.js +1 -1
  27. package/package.json +1 -1
@@ -306,13 +306,13 @@ export function registerUgcCommands(program, { request: requestOxygen, handle })
306
306
  {
307
307
  name: "brief",
308
308
  kind: "string",
309
- description: "Short creator brief (up to 1,200 characters): what the product is, who it is for, key facts and the call to action. Drafts are grounded in it.",
309
+ description: "Short creator brief (up to 1,200 characters): what the product is, who it is for, key facts and the call to action. Drafts are grounded in it; share longer guidance as a wiki page with --knowledge-slugs.",
310
310
  required: false,
311
311
  },
312
312
  {
313
313
  name: "description",
314
314
  kind: "string",
315
- description: "Program description.",
315
+ description: "Internal note for your team: creators never see it and drafts do not use it. Put what creators need in the brief.",
316
316
  required: false,
317
317
  },
318
318
  {
@@ -330,7 +330,7 @@ export function registerUgcCommands(program, { request: requestOxygen, handle })
330
330
  {
331
331
  name: "posting_target",
332
332
  kind: "posting_target",
333
- description: "Posts each creator should publish by default: <n>/month, <n>/week or <n>/total (n 1–100), or none to clear. One creator's own deal: creators set-target.",
333
+ description: "Posts each creator should publish by default: <n>/month, <n>/week or <n>/total (n 1–100), or none to clear (2 every two weeks is 1/week). One creator's own deal: creators set-target.",
334
334
  required: false,
335
335
  },
336
336
  {
@@ -373,7 +373,7 @@ export function registerUgcCommands(program, { request: requestOxygen, handle })
373
373
  {
374
374
  name: "description",
375
375
  kind: "string",
376
- description: "Program description.",
376
+ description: "Internal note for your team: creators never see it and drafts do not use it. Put what creators need in the brief.",
377
377
  required: false,
378
378
  },
379
379
  {
@@ -391,7 +391,7 @@ export function registerUgcCommands(program, { request: requestOxygen, handle })
391
391
  {
392
392
  name: "posting_target",
393
393
  kind: "posting_target",
394
- description: "Posts each creator should publish by default: <n>/month, <n>/week or <n>/total (n 1–100), or none to clear. One creator's own deal: creators set-target.",
394
+ description: "Posts each creator should publish by default: <n>/month, <n>/week or <n>/total (n 1–100), or none to clear (2 every two weeks is 1/week). One creator's own deal: creators set-target.",
395
395
  required: false,
396
396
  },
397
397
  {
@@ -10,9 +10,24 @@ export function parsePostingTarget(value) {
10
10
  const match = /^(\d{1,3})\s*\/\s*(week|month|total)$/.exec(text);
11
11
  const count = match ? Number(match[1]) : 0;
12
12
  if (!match || count < 1 || count > 100)
13
- throw new UgcUsageError("--posting-target must be <n>/month, <n>/total or <n>/week with n from 1 to 100, or none.");
13
+ throw new UgcUsageError(`--posting-target must be <n>/month, <n>/total or <n>/week with n from 1 to 100, or none.${twoWeekRate(text)}`);
14
14
  return { count, period: match[2] };
15
15
  }
16
+ /**
17
+ * A two-week rhythm has no period of its own, so name the rate that matches the
18
+ * count typed. "Every two weeks: 1/week or 2/month" read as two equal choices,
19
+ * and a 2026-09-29 blind run set 2/month for 2/fortnight: half the rate.
20
+ */
21
+ function twoWeekRate(text) {
22
+ const fortnight = /^(\d{1,3})\s*\/\s*(?:fortnight|2\s*weeks?|two\s*weeks?|bi-?weekly)$/.exec(text);
23
+ const n = fortnight ? Number(fortnight[1]) : 0;
24
+ if (n < 1)
25
+ return "";
26
+ const monthly = Math.round((n * 52) / 24);
27
+ return n % 2 === 0
28
+ ? ` ${n} every two weeks is ${n / 2}/week (counted per calendar week) or about ${monthly}/month.`
29
+ : ` ${n} every two weeks has no whole weekly count; about ${monthly}/month is the closest rate.`;
30
+ }
16
31
  /** A JSON array of emails, or emails separated by commas, semicolons or whitespace. */
17
32
  export function parseEmailList(value) {
18
33
  const text = value.trim();
@@ -338,17 +338,17 @@ export const OXYGEN_CAPABILITY_ROUTES = [
338
338
  id: "sequences",
339
339
  layer: "Action",
340
340
  primitive: "sequences",
341
- owns: "Net-new LinkedIn outreach plus versioned multichannel programs, cadence, enrollment, sender limits, suppression, reply-stop, and dispatch.",
341
+ owns: "Net-new LinkedIn outreach plus versioned multichannel programs, cadence, enrollment, sender limits, suppression, reply-stop, pausing a company when someone there replies, and dispatch.",
342
342
  notFor: "A reply in an existing conversation or a direct one-off email; use Messages. Not for general deterministic automation.",
343
343
  // The external-campaign sentence exists because a 2026-09-16 blind user asked
344
344
  // to "push table rows to smartlead campaign", landed here, read the native
345
345
  // email track's "Only 'instantly' is supported" and concluded Smartlead was
346
346
  // unreachable. The governed path for a campaign the customer already runs
347
347
  // elsewhere is `sequences push-external`; it is not a Sequence.
348
- execution: "Use the one-step gateway for new LinkedIn initiation or create/enroll/start a hosted campaign with bounded send authority. Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport. To push table rows into an existing Smartlead, Instantly, lemlist or HeyReach campaign you already run, use `sequences push-external` (dry_run preview, then --approved).",
348
+ execution: "Use the one-step gateway for new LinkedIn initiation or create/enroll/start a hosted campaign with bounded send authority. Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport. To push table rows into an existing Smartlead, Instantly, lemlist or HeyReach campaign you already run, use `sequences push-external` (dry_run preview, then --approved). To stop a company's other leads when one of them replies, set `--company-reply-pause` on the campaign (choose the reply categories); to pause or resume a company on demand, e.g. from a workflow when a meeting is booked, use `sequences pause-company` / `resume-company`. A pause is resumable; a company do-not-contact entry is a permanent block on every campaign.",
349
349
  posture: "external_write",
350
- gatewayTools: ["oxygen_sequences_send", "oxygen_sequences_create", "oxygen_sequences_enroll", "oxygen_sequences_start", "oxygen_sequences_list", "oxygen_sequences_push_external"],
351
- gatewayCommands: ["sequences send", "sequences create", "sequences enroll", "sequences start", "sequences list", "sequences push-external"],
350
+ gatewayTools: ["oxygen_sequences_send", "oxygen_sequences_create", "oxygen_sequences_enroll", "oxygen_sequences_start", "oxygen_sequences_list", "oxygen_sequences_push_external", "oxygen_sequences_company_pause"],
351
+ gatewayCommands: ["sequences send", "sequences create", "sequences enroll", "sequences start", "sequences list", "sequences push-external", "sequences pause-company"],
352
352
  skills: ["oxygen-sequencer"],
353
353
  endpointSections: ["senders", "sequencer", "sequences", "spintax", "suppressions", "voice"],
354
354
  // "sender identity", "sender profile" and "phone number" are here because a
@@ -356,7 +356,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
356
356
  // to workspace-access — "identity" matched the AUTH sense. The nouns a user
357
357
  // reaches for when naming a sending identity or the dialing pool have to land
358
358
  // on the group that actually owns `senders profiles` and `voice numbers`.
359
- intentTerms: ["sequence", "campaign", "cadence", "enroll", "outreach", "linkedin message", "linkedin dm", "nurture", "follow up", "sender rotation", "stop on reply", "sender identity", "sending identity", "sender profile", "phone number", "dialing", "dialer", "call", "cold call", "smartlead", "instantly", "lemlist", "heyreach", "external sequencer", "push to campaign", "push rows to campaign", "add leads to campaign"],
359
+ intentTerms: ["sequence", "campaign", "cadence", "enroll", "outreach", "linkedin message", "linkedin dm", "nurture", "follow up", "sender rotation", "stop on reply", "company replied", "pause company", "pause outreach to a company", "stop outreach to a company", "stop emailing a company", "colleagues", "do not contact a company", "sender identity", "sending identity", "sender profile", "phone number", "dialing", "dialer", "call", "cold call", "smartlead", "instantly", "lemlist", "heyreach", "external sequencer", "push to campaign", "push rows to campaign", "add leads to campaign"],
360
360
  },
361
361
  {
362
362
  id: "publishing",
@@ -654,6 +654,21 @@ function normalizeIntent(query) {
654
654
  * posts, with the AI filter applied, before a table exists.
655
655
  */
656
656
  const LINKEDIN_KEYWORD_POST_COMMAND = 'oxygen signals search preview --family linkedin_posts --keywords "<any of>" --all-keywords "<all of>" --exclude-keywords "<none of>" --ai-filter "<posts to keep>" --json';
657
+ /**
658
+ * Running (or joining) a creator program is UGC, which lives on the Publishing
659
+ * card. Checked before the LinkedIn keyword-post rule: "a creator program where
660
+ * LinkedIn creators post about my product" is not a keyword search (2026-09-29
661
+ * baselines routed it to Signals, and "creator program" to post tools).
662
+ */
663
+ function isUgcProgramIntent(query) {
664
+ return /\b(ugc|user[- ]generated content|creator programs?|ambassador programs?|brand ambassadors?)\b/.test(query)
665
+ || /\b(invite|recruit|onboard|sponsor)\w*\b.{0,30}\bcreators?\b/.test(query)
666
+ || /\bcreators?\b.{0,40}\bpost\w*\b.{0,20}\babout (my|our)\b/.test(query);
667
+ }
668
+ /** The creator's side of a program: find one, apply, or accept an invitation. */
669
+ function isUgcCreatorSideIntent(query) {
670
+ return /\b(join|apply|applying|find|discover|browse|accept)\w*\b.{0,30}\b(creator|ugc|ambassador) programs?\b/.test(query);
671
+ }
657
672
  function isLinkedInKeywordPostIntent(query) {
658
673
  return /\blinkedin\b/.test(query)
659
674
  && /\b(posts?|posting)\b/.test(query)
@@ -720,6 +735,8 @@ function isOpenLinkedInRequestsIntent(query) {
720
735
  function explicitCapabilityIntent(query) {
721
736
  if (/\b(infographic|graphic designer|render html|carousel pages|visual design|gtm flow image)\b/.test(query))
722
737
  return ROUTE_BY_ID.get("visual-rendering") ?? null;
738
+ if (isUgcProgramIntent(query) || isUgcCreatorSideIntent(query))
739
+ return ROUTE_BY_PRIMITIVE.get("publishing") ?? null;
723
740
  if (isOwnLinkedInNetworkIntent(query) || isOpenLinkedInRequestsIntent(query))
724
741
  return ROUTE_BY_ID.get("connected-linkedin") ?? null;
725
742
  if (isCompanyPageFollowersIntent(query))
@@ -955,6 +972,19 @@ function isLocalBusinessIntent(query) {
955
972
  return /\b(local business(?:es)?|google maps|maps listings?|storefronts?|near me|(?:dental|medical|law|veterinary) (?:practices?|offices?|clinics?)|dentists?|restaurants?|cafes?|coffee shops?|gyms?|salons?|plumbers?|electricians?|contractors?|hvac|auto repair|real estate agents?|businesses (?:in|near|around) [a-z])\b/.test(query);
956
973
  }
957
974
  function recommendationsFor(card, query) {
975
+ if (card.primitive === "publishing" && isUgcCreatorSideIntent(query)) {
976
+ return {
977
+ tools: ["oxygen_ugc_get", "oxygen_ugc_application_create", "oxygen_ugc_invite_accept"],
978
+ commands: ["ugc programs discover", "ugc programs apply", "ugc creators accept"],
979
+ };
980
+ }
981
+ if (card.primitive === "publishing" && isUgcProgramIntent(query)) {
982
+ return {
983
+ tools: ["oxygen_ugc_program_create", "oxygen_ugc_invite_create", "oxygen_ugc_get"],
984
+ commands: ["ugc programs create", "ugc creators invite", "ugc programs list"],
985
+ exactCommand: "oxygen ugc programs create --product-url <your product site> --json",
986
+ };
987
+ }
958
988
  if (card.id === "sourcing-and-provider-tools" && isCompanyUrlRecoveryIntent(query)) {
959
989
  return {
960
990
  tools: ["oxygen_companies_search_plan", "oxygen_companies_search_run"],
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Pause a company on reply: when one lead at a company replies, every other lead
3
+ * of that company is HELD (resumable, nothing is sent) until the reply is
4
+ * classified or a human resumes it. Shared by the tenant-db hold ledger, the
5
+ * inbound reply hooks, and the API/CLI/MCP surfaces so a company identity and
6
+ * the campaign setting resolve identically everywhere.
7
+ */
8
+ /** Reply statuses that keep a company paused when the setting is enabled. */
9
+ export declare const COMPANY_REPLY_PAUSE_DEFAULT_STATUSES: readonly string[];
10
+ /**
11
+ * What the retired `stop_on_reply_scope: "company_domain"` meant: every
12
+ * AI-settable status except out_of_office stopped the company.
13
+ */
14
+ export declare const COMPANY_REPLY_PAUSE_LEGACY_STATUSES: readonly string[];
15
+ /**
16
+ * A reply status is an Inbox label key. Keys are minted by `slugifyLabelKey`
17
+ * (tenant-db email-labels): lower snake_case, never a leading underscore. The
18
+ * slug has no length limit of its own, so the bound here is generous.
19
+ */
20
+ export declare const COMPANY_REPLY_PAUSE_STATUS_KEY_PATTERN: RegExp;
21
+ export type CompanyReplyPauseSetting = {
22
+ enabled: boolean;
23
+ statuses: string[];
24
+ };
25
+ export type ResolvedCompanyReplyPause = CompanyReplyPauseSetting & {
26
+ source: "setting" | "legacy_scope" | "off";
27
+ };
28
+ /**
29
+ * Read a Sequence's `settings.company_reply_pause`. An explicit setting (even a
30
+ * disabled one) wins over the deprecated `stop_on_reply_scope` alias; without
31
+ * it, `"company_domain"` reads as enabled with the legacy statuses.
32
+ */
33
+ export declare function resolveCompanyReplyPause(settings: Record<string, unknown> | null | undefined): ResolvedCompanyReplyPause;
34
+ /**
35
+ * Every company identity key an enrollment row carries: explicit company-domain
36
+ * and LinkedIn-company fields plus the recipient email's domain. A consumer mail
37
+ * host (gmail, outlook, …) is never a company, from either source, so strangers
38
+ * who share a mail provider are never grouped. Deduplicated and sorted.
39
+ */
40
+ export declare function companyIdentityKeysFromRowValues(rowValues: Record<string, unknown> | null | undefined, emailColumnKey?: string | null): string[];
41
+ export type CompanyIdentityErrorCode = "company_identity_required" | "company_identity_ambiguous" | "company_domain_invalid" | "company_email_freemail" | "company_email_invalid" | "company_linkedin_invalid";
42
+ /** Resolve the one company a pause/resume action names. */
43
+ export declare function companyIdentityFromActionInput(input: {
44
+ domain?: string | null;
45
+ email?: string | null;
46
+ linkedinCompanyUrl?: string | null;
47
+ }): {
48
+ ok: true;
49
+ keys: string[];
50
+ displayName: string;
51
+ } | {
52
+ ok: false;
53
+ code: CompanyIdentityErrorCode;
54
+ message: string;
55
+ };
@@ -0,0 +1,146 @@
1
+ import { companyDncIdentitiesFromRowValues, companyDncIdentityKey, normalizeCompanyDncDomain, normalizeLinkedinCompanyUrl, } from "./dnc-identities.js";
2
+ import { isFreemailDomain } from "./freemail-domains.js";
3
+ import { recipientEmailFromRow } from "./sequences.js";
4
+ /**
5
+ * Pause a company on reply: when one lead at a company replies, every other lead
6
+ * of that company is HELD (resumable, nothing is sent) until the reply is
7
+ * classified or a human resumes it. Shared by the tenant-db hold ledger, the
8
+ * inbound reply hooks, and the API/CLI/MCP surfaces so a company identity and
9
+ * the campaign setting resolve identically everywhere.
10
+ */
11
+ /** Reply statuses that keep a company paused when the setting is enabled. */
12
+ export const COMPANY_REPLY_PAUSE_DEFAULT_STATUSES = [
13
+ "interested",
14
+ "neutral",
15
+ "not_now",
16
+ "not_interested",
17
+ ];
18
+ /**
19
+ * What the retired `stop_on_reply_scope: "company_domain"` meant: every
20
+ * AI-settable status except out_of_office stopped the company.
21
+ */
22
+ export const COMPANY_REPLY_PAUSE_LEGACY_STATUSES = [
23
+ ...COMPANY_REPLY_PAUSE_DEFAULT_STATUSES,
24
+ "wrong_person",
25
+ ];
26
+ /**
27
+ * A reply status is an Inbox label key. Keys are minted by `slugifyLabelKey`
28
+ * (tenant-db email-labels): lower snake_case, never a leading underscore. The
29
+ * slug has no length limit of its own, so the bound here is generous.
30
+ */
31
+ export const COMPANY_REPLY_PAUSE_STATUS_KEY_PATTERN = /^[a-z0-9][a-z0-9_]{0,127}$/;
32
+ /**
33
+ * Read a Sequence's `settings.company_reply_pause`. An explicit setting (even a
34
+ * disabled one) wins over the deprecated `stop_on_reply_scope` alias; without
35
+ * it, `"company_domain"` reads as enabled with the legacy statuses.
36
+ */
37
+ export function resolveCompanyReplyPause(settings) {
38
+ const raw = settings?.company_reply_pause;
39
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
40
+ const record = raw;
41
+ const statuses = Array.isArray(record.statuses)
42
+ ? [...new Set(record.statuses.filter((status) => typeof status === "string" && COMPANY_REPLY_PAUSE_STATUS_KEY_PATTERN.test(status)))]
43
+ : [];
44
+ if (record.enabled !== true)
45
+ return { enabled: false, statuses, source: "setting" };
46
+ return {
47
+ enabled: true,
48
+ statuses: statuses.length > 0 ? statuses : [...COMPANY_REPLY_PAUSE_DEFAULT_STATUSES],
49
+ source: "setting",
50
+ };
51
+ }
52
+ if (settings?.stop_on_reply_scope === "company_domain") {
53
+ return { enabled: true, statuses: [...COMPANY_REPLY_PAUSE_LEGACY_STATUSES], source: "legacy_scope" };
54
+ }
55
+ return { enabled: false, statuses: [], source: "off" };
56
+ }
57
+ function emailDomain(email) {
58
+ const at = email.lastIndexOf("@");
59
+ if (at <= 0)
60
+ return null;
61
+ return normalizeCompanyDncDomain(email.slice(at + 1));
62
+ }
63
+ /**
64
+ * Every company identity key an enrollment row carries: explicit company-domain
65
+ * and LinkedIn-company fields plus the recipient email's domain. A consumer mail
66
+ * host (gmail, outlook, …) is never a company, from either source, so strangers
67
+ * who share a mail provider are never grouped. Deduplicated and sorted.
68
+ */
69
+ export function companyIdentityKeysFromRowValues(rowValues, emailColumnKey) {
70
+ if (!rowValues)
71
+ return [];
72
+ const keys = new Set();
73
+ for (const identity of companyDncIdentitiesFromRowValues(rowValues)) {
74
+ if (identity.kind === "domain" && isFreemailDomain(identity.value))
75
+ continue;
76
+ keys.add(identity.key);
77
+ }
78
+ const email = recipientEmailFromRow(rowValues, emailColumnKey);
79
+ const domain = email ? emailDomain(email) : null;
80
+ if (domain && !isFreemailDomain(domain))
81
+ keys.add(companyDncIdentityKey("domain", domain));
82
+ return [...keys].sort();
83
+ }
84
+ /** Resolve the one company a pause/resume action names. */
85
+ export function companyIdentityFromActionInput(input) {
86
+ const domain = input.domain?.trim() || null;
87
+ const email = input.email?.trim() || null;
88
+ const linkedinCompanyUrl = input.linkedinCompanyUrl?.trim() || null;
89
+ const provided = [domain, email, linkedinCompanyUrl].filter((value) => value !== null).length;
90
+ if (provided === 0) {
91
+ return {
92
+ ok: false,
93
+ code: "company_identity_required",
94
+ message: "Name the company with a domain, an email address, or a LinkedIn company URL.",
95
+ };
96
+ }
97
+ if (provided > 1) {
98
+ return {
99
+ ok: false,
100
+ code: "company_identity_ambiguous",
101
+ message: "Name the company one way only: a domain, an email address, or a LinkedIn company URL.",
102
+ };
103
+ }
104
+ if (domain) {
105
+ const normalized = normalizeCompanyDncDomain(domain);
106
+ if (!normalized) {
107
+ return { ok: false, code: "company_domain_invalid", message: `"${domain}" is not a company domain.` };
108
+ }
109
+ if (isFreemailDomain(normalized)) {
110
+ return {
111
+ ok: false,
112
+ code: "company_domain_invalid",
113
+ message: `${normalized} is a personal email provider, not a company.`,
114
+ };
115
+ }
116
+ return { ok: true, keys: [companyDncIdentityKey("domain", normalized)], displayName: normalized };
117
+ }
118
+ if (email) {
119
+ const at = email.lastIndexOf("@");
120
+ const normalized = at > 0 && !/\s/.test(email) ? emailDomain(email) : null;
121
+ if (!normalized) {
122
+ return { ok: false, code: "company_email_invalid", message: `"${email}" is not an email address.` };
123
+ }
124
+ if (isFreemailDomain(normalized)) {
125
+ return {
126
+ ok: false,
127
+ code: "company_email_freemail",
128
+ message: `${normalized} is a personal email provider, so the address does not identify a company.`,
129
+ };
130
+ }
131
+ return { ok: true, keys: [companyDncIdentityKey("domain", normalized)], displayName: normalized };
132
+ }
133
+ const url = normalizeLinkedinCompanyUrl(linkedinCompanyUrl);
134
+ if (!url) {
135
+ return {
136
+ ok: false,
137
+ code: "company_linkedin_invalid",
138
+ message: `"${linkedinCompanyUrl}" is not a LinkedIn company page URL.`,
139
+ };
140
+ }
141
+ return {
142
+ ok: true,
143
+ keys: [companyDncIdentityKey("linkedin_company", url)],
144
+ displayName: url.replace(/^https:\/\/www\./, ""),
145
+ };
146
+ }
@@ -17,9 +17,9 @@
17
17
  * Two consumers ride on that answer, and they fail in OPPOSITE directions, which is
18
18
  * why this tier is conservative rather than generous:
19
19
  *
20
- * 1. The company-scope reply stop (`settings.stop_on_reply_scope = "company_domain"`)
21
- * terminalizes an enrollment's colleagues by matching domain. A host wrongly IN
22
- * this tier is harmless here; a host wrongly OUT of it stops strangers who merely
20
+ * 1. Pause a company on reply (`settings.company_reply_pause`, company-reply-pause.ts)
21
+ * holds an enrollment's colleagues by matching domain. A host wrongly IN this
22
+ * tier is harmless here; a host wrongly OUT of it pauses strangers who merely
23
23
  * share a mail host — real, customer-visible damage.
24
24
  * 2. Company derivation from a person's email domain
25
25
  * (`deriveCompanyDomainFromEmail`, `packages/tenant-db/src/table-link-plan.ts`)
@@ -17,9 +17,9 @@
17
17
  * Two consumers ride on that answer, and they fail in OPPOSITE directions, which is
18
18
  * why this tier is conservative rather than generous:
19
19
  *
20
- * 1. The company-scope reply stop (`settings.stop_on_reply_scope = "company_domain"`)
21
- * terminalizes an enrollment's colleagues by matching domain. A host wrongly IN
22
- * this tier is harmless here; a host wrongly OUT of it stops strangers who merely
20
+ * 1. Pause a company on reply (`settings.company_reply_pause`, company-reply-pause.ts)
21
+ * holds an enrollment's colleagues by matching domain. A host wrongly IN this
22
+ * tier is harmless here; a host wrongly OUT of it pauses strangers who merely
23
23
  * share a mail host — real, customer-visible damage.
24
24
  * 2. Company derivation from a person's email domain
25
25
  * (`deriveCompanyDomainFromEmail`, `packages/tenant-db/src/table-link-plan.ts`)
@@ -17,6 +17,8 @@
17
17
  import { OxygenError } from "./cli-result.js";
18
18
  /** Was this thrown error the quota chokepoint refusing the call? */
19
19
  export declare function isLinkedInQuotaDenial(error: unknown): error is OxygenError;
20
+ /** Quota messages include sender display names; log only the typed code. */
21
+ export declare function linkedInQuotaSafeErrorFields(error: unknown): Record<string, unknown>;
20
22
  /**
21
23
  * When a quota-denied caller may try again — the denial's own `resets_at` when it
22
24
  * carries a usable one, else a flat hour. Null for anything that is NOT a quota
@@ -15,6 +15,7 @@
15
15
  * until its budget resets rather than re-denied on every tenant tick.
16
16
  */
17
17
  import { OxygenError } from "./cli-result.js";
18
+ import { errorFields } from "./log.js";
18
19
  import { isRecord } from "./type-guards.js";
19
20
  // The codes a denial is raised with. Each is a "come back later" signal, not a
20
21
  // broken caller: a daily cap / closed active window that reopens on its own clock,
@@ -37,6 +38,10 @@ const QUOTA_FALLBACK_BACKOFF_MS = 60 * 60 * 1000;
37
38
  export function isLinkedInQuotaDenial(error) {
38
39
  return error instanceof OxygenError && QUOTA_DENIED_CODES.has(error.code);
39
40
  }
41
+ /** Quota messages include sender display names; log only the typed code. */
42
+ export function linkedInQuotaSafeErrorFields(error) {
43
+ return isLinkedInQuotaDenial(error) ? { error_code: error.code } : errorFields(error);
44
+ }
40
45
  /**
41
46
  * When a quota-denied caller may try again — the denial's own `resets_at` when it
42
47
  * carries a usable one, else a flat hour. Null for anything that is NOT a quota
@@ -14,6 +14,13 @@ import { type WorkspaceTableCapacityStatus } from "./table-capacity.js";
14
14
  */
15
15
  export type PlanBand = "free" | "49" | "99" | "199" | "499" | "999" | "1999";
16
16
  export declare const PLAN_BAND_ORDER: readonly PlanBand[];
17
+ /** Whether a larger plan actually raises either storage limit in force. */
18
+ export declare function largerPlanStorageRelief(band: PlanBand, options?: RepricingOptions): {
19
+ tableRows: boolean;
20
+ workspace: boolean;
21
+ };
22
+ /** A database refusal carries its enforced cap, but no billing-plan identity. */
23
+ export declare function anyPlanRaisesTableRowLimit(enforcedLimitRows: number, workspaceLimitRows?: number, options?: RepricingOptions): boolean;
17
24
  /** The limits rung each band enforces at today. */
18
25
  export declare const PLAN_BAND_LIMITS_TIER: Readonly<Record<PlanBand, LimitsTier>>;
19
26
  /**
@@ -12,6 +12,30 @@ export const PLAN_BAND_ORDER = [
12
12
  "999",
13
13
  "1999",
14
14
  ];
15
+ /** Whether a larger plan actually raises either storage limit in force. */
16
+ export function largerPlanStorageRelief(band, options = {}) {
17
+ const current = resolveWorkspaceTableCapacity(band, options);
18
+ const larger = PLAN_BAND_ORDER.slice(PLAN_BAND_ORDER.indexOf(band) + 1)
19
+ .map((next) => resolveWorkspaceTableCapacity(next, options));
20
+ return {
21
+ tableRows: larger.some((next) => next.tableRowLimit > current.tableRowLimit),
22
+ workspace: larger.some((next) => next.workspaceRowLimit > current.workspaceRowLimit
23
+ || next.workspaceDatabaseLimitBytes > current.workspaceDatabaseLimitBytes),
24
+ };
25
+ }
26
+ /** A database refusal carries its enforced cap, but no billing-plan identity. */
27
+ export function anyPlanRaisesTableRowLimit(enforcedLimitRows, workspaceLimitRows, options = {}) {
28
+ if (workspaceLimitRows !== undefined) {
29
+ const matchingBand = PLAN_BAND_ORDER.find((band) => {
30
+ const capacity = resolveWorkspaceTableCapacity(band, options);
31
+ return capacity.tableRowLimit === enforcedLimitRows
32
+ && capacity.workspaceRowLimit === workspaceLimitRows;
33
+ });
34
+ // A mismatched pair is drift, not evidence that buying a new plan helps.
35
+ return matchingBand ? largerPlanStorageRelief(matchingBand, options).tableRows : false;
36
+ }
37
+ return PLAN_BAND_ORDER.some((band) => resolveWorkspaceTableCapacity(band, options).tableRowLimit > enforcedLimitRows);
38
+ }
15
39
  /** The limits rung each band enforces at today. */
16
40
  export const PLAN_BAND_LIMITS_TIER = Object.freeze({
17
41
  free: "free",
@@ -451,12 +451,23 @@ export type SequenceAutoOptimizeConfig = {
451
451
  };
452
452
  export declare const SEQUENCE_STEP_KINDS: readonly ["visit_profile", "invite", "wait_for_connection", "message", "inmail", "follow", "like_post", "comment_post", "withdraw_invite", "email_send", "email_reply", "email_enroll", "email_move", "email_stop", "whatsapp_message", "call_task", "crm_task", "wait", "wait_for_signal", "branch", "stop"];
453
453
  export type SequenceStepKind = (typeof SEQUENCE_STEP_KINDS)[number];
454
+ /**
455
+ * Advanced: the sender profile (person) whose LinkedIn account sends this step.
456
+ * Omitted = the lead's assigned person. LinkedIn lets one of the workspace's
457
+ * accounts hold a conversation with a lead, so the relationship steps
458
+ * (invite, message, inmail, withdraw_invite) must all name the same person or
459
+ * none: that person then owns every lead's LinkedIn thread. Light touches
460
+ * (visit_profile, follow, like_post, comment_post) may each name anyone.
461
+ */
462
+ export type SequenceLinkedInStepSender = {
463
+ sender_profile_id?: string;
464
+ };
454
465
  export type SequenceLinkedInVisitProfileStep = {
455
466
  id: string;
456
467
  channel: "linkedin";
457
468
  kind: "visit_profile";
458
- };
459
- export type SequenceLinkedInInviteStep = {
469
+ } & SequenceLinkedInStepSender;
470
+ export type SequenceLinkedInInviteStep = SequenceLinkedInStepSender & {
460
471
  id: string;
461
472
  channel: "linkedin";
462
473
  kind: "invite";
@@ -475,7 +486,7 @@ export type SequenceLinkedInAttachment = {
475
486
  url: string;
476
487
  name?: string;
477
488
  };
478
- export type SequenceLinkedInMessageStep = {
489
+ export type SequenceLinkedInMessageStep = SequenceLinkedInStepSender & {
479
490
  id: string;
480
491
  channel: "linkedin";
481
492
  kind: "message";
@@ -489,7 +500,7 @@ export type SequenceLinkedInMessageStep = {
489
500
  /** Files attached to the message (≤ a few, fetched at dispatch). */
490
501
  attachments?: SequenceLinkedInAttachment[];
491
502
  };
492
- export type SequenceLinkedInInMailStep = {
503
+ export type SequenceLinkedInInMailStep = SequenceLinkedInStepSender & {
493
504
  id: string;
494
505
  channel: "linkedin";
495
506
  kind: "inmail";
@@ -507,8 +518,8 @@ export type SequenceLinkedInFollowStep = {
507
518
  id: string;
508
519
  channel: "linkedin";
509
520
  kind: "follow";
510
- };
511
- export type SequenceLinkedInLikePostStep = {
521
+ } & SequenceLinkedInStepSender;
522
+ export type SequenceLinkedInLikePostStep = SequenceLinkedInStepSender & {
512
523
  id: string;
513
524
  channel: "linkedin";
514
525
  kind: "like_post";
@@ -516,7 +527,7 @@ export type SequenceLinkedInLikePostStep = {
516
527
  post_recency_days?: number;
517
528
  on_no_post?: "stop";
518
529
  };
519
- export type SequenceLinkedInCommentPostStep = {
530
+ export type SequenceLinkedInCommentPostStep = SequenceLinkedInStepSender & {
520
531
  id: string;
521
532
  channel: "linkedin";
522
533
  kind: "comment_post";
@@ -529,7 +540,7 @@ export type SequenceLinkedInWithdrawInviteStep = {
529
540
  id: string;
530
541
  channel: "linkedin";
531
542
  kind: "withdraw_invite";
532
- };
543
+ } & SequenceLinkedInStepSender;
533
544
  export type SequenceEmailEnrollStep = {
534
545
  id: string;
535
546
  channel: "email";
@@ -557,6 +568,8 @@ export type SequenceEmailSendStep = {
557
568
  id: string;
558
569
  channel: "email";
559
570
  kind: "email_send";
571
+ /** Advanced: send this step from this exact inbox instead of the lead's normal assignment. */
572
+ sender_mailbox_id?: string;
560
573
  /**
561
574
  * Subject line. Supports {{column}} interpolation.
562
575
  *
@@ -990,6 +1003,39 @@ export type SequenceMissingCopy = {
990
1003
  * stored under `draft`. Empty means nothing to send, so starting would
991
1004
  * either dispatch a blank message or fail per lead at send time.
992
1005
  */
1006
+ /**
1007
+ * Threaded email steps that would continue another inbox's thread. A provider
1008
+ * thread belongs to the mailbox that opened it, so a step that changes From —
1009
+ * an exact-inbox step, or the return to the lead's assigned inbox after one —
1010
+ * must open a new thread under its own subject. Checked on save and again at
1011
+ * launch, because a draft may be saved half-written.
1012
+ */
1013
+ export declare function sequenceSenderThreadConflicts(steps: readonly SequenceStep[]): {
1014
+ index: number;
1015
+ stepId: string;
1016
+ message: string;
1017
+ }[];
1018
+ /** LinkedIn kinds that open or continue a conversation, so bind the lead to one account. */
1019
+ export declare const LINKEDIN_RELATIONSHIP_STEP_KINDS: Set<string>;
1020
+ /** LinkedIn light touches: they bind nothing, so each may come from any selected person. */
1021
+ export declare const LINKEDIN_LIGHT_TOUCH_STEP_KINDS: Set<string>;
1022
+ /**
1023
+ * The person who owns every lead's LinkedIn conversation in this journey, when
1024
+ * the relationship steps name one (null = each lead's assigned person). Only
1025
+ * meaningful once sequenceLinkedInOwnerConflicts is empty.
1026
+ */
1027
+ export declare function sequenceLinkedInOwnerProfileId(steps: readonly SequenceStep[]): string | null;
1028
+ /**
1029
+ * Relationship steps that disagree about who sends them. LinkedIn lets one of
1030
+ * the workspace's accounts hold a conversation with a lead (and OXYGEN binds
1031
+ * the lead to the first account that invites or messages it), so a connection
1032
+ * request from one person followed by a message from another would be refused.
1033
+ */
1034
+ export declare function sequenceLinkedInOwnerConflicts(steps: readonly SequenceStep[]): {
1035
+ index: number;
1036
+ stepId: string;
1037
+ message: string;
1038
+ }[];
993
1039
  export declare function sequenceStepsMissingCopy(steps: readonly SequenceStep[]): SequenceMissingCopy[];
994
1040
  /**
995
1041
  * Validate + normalize a raw sequence definition. Assigns stable ids to steps