@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.
- package/README.md +1 -1
- package/dist/command-manifest.js +2 -0
- package/dist/index.js +315 -40
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +5 -5
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +16 -1
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +35 -5
- package/node_modules/@oxygen/shared/dist/company-reply-pause.d.ts +55 -0
- package/node_modules/@oxygen/shared/dist/company-reply-pause.js +146 -0
- package/node_modules/@oxygen/shared/dist/freemail-domains.d.ts +3 -3
- package/node_modules/@oxygen/shared/dist/freemail-domains.js +3 -3
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +5 -0
- package/node_modules/@oxygen/shared/dist/plan-band.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/plan-band.js +24 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +54 -8
- package/node_modules/@oxygen/shared/dist/sequences.js +101 -9
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +3 -0
- package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +2 -3
- package/node_modules/@oxygen/shared/dist/table-capacity.js +10 -16
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +9 -1
- package/node_modules/@oxygen/shared/package.json +5 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +1 -1
- 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: "
|
|
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: "
|
|
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(
|
|
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.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|