fullstackgtm 0.50.0 → 0.51.0
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/CHANGELOG.md +45 -0
- package/README.md +7 -0
- package/dist/cli/audit.js +6 -1
- package/dist/cli/auth.js +49 -4
- package/dist/cli/backfill.js +4 -0
- package/dist/cli/call.js +28 -6
- package/dist/cli/draft.js +11 -1
- package/dist/cli/enrich.js +124 -57
- package/dist/cli/fix.js +6 -3
- package/dist/cli/help.js +31 -28
- package/dist/cli/icp.js +15 -1
- package/dist/cli/init.js +3 -3
- package/dist/cli/market.js +14 -1
- package/dist/cli/planOutput.d.ts +5 -0
- package/dist/cli/planOutput.js +27 -0
- package/dist/cli/plans.js +184 -33
- package/dist/cli/schedule.js +22 -11
- package/dist/cli/signals.js +22 -3
- package/dist/cli/tam.d.ts +1 -1
- package/dist/cli/tam.js +14 -2
- package/dist/cli/ui.js +14 -6
- package/dist/connectors/clay.d.ts +33 -0
- package/dist/connectors/clay.js +123 -0
- package/dist/connectors/hubspot.d.ts +4 -0
- package/dist/connectors/hubspot.js +36 -18
- package/dist/connectors/prospectSources.d.ts +12 -0
- package/dist/connectors/prospectSources.js +1 -1
- package/dist/contactProviders.d.ts +44 -0
- package/dist/contactProviders.js +100 -0
- package/dist/enrich.d.ts +7 -1
- package/dist/enrich.js +46 -1
- package/dist/hostedPatchPlan.js +6 -1
- package/dist/icp.d.ts +2 -0
- package/dist/icp.js +49 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/init.d.ts +1 -1
- package/dist/init.js +1 -1
- package/docs/api.md +18 -1
- package/package.json +1 -1
- package/src/cli/audit.ts +5 -1
- package/src/cli/auth.ts +46 -4
- package/src/cli/backfill.ts +3 -0
- package/src/cli/call.ts +25 -6
- package/src/cli/draft.ts +9 -1
- package/src/cli/enrich.ts +132 -56
- package/src/cli/fix.ts +5 -3
- package/src/cli/help.ts +31 -28
- package/src/cli/icp.ts +13 -1
- package/src/cli/init.ts +3 -3
- package/src/cli/market.ts +12 -1
- package/src/cli/planOutput.ts +30 -0
- package/src/cli/plans.ts +174 -29
- package/src/cli/schedule.ts +21 -15
- package/src/cli/signals.ts +22 -3
- package/src/cli/tam.ts +12 -3
- package/src/cli/ui.ts +15 -6
- package/src/connectors/clay.ts +155 -0
- package/src/connectors/hubspot.ts +41 -17
- package/src/connectors/prospectSources.ts +7 -1
- package/src/contactProviders.ts +141 -0
- package/src/enrich.ts +51 -2
- package/src/hostedPatchPlan.ts +6 -1
- package/src/icp.ts +51 -0
- package/src/index.ts +25 -0
- package/src/init.ts +2 -2
|
@@ -42,6 +42,10 @@ export type HubspotConnectorOptions = {
|
|
|
42
42
|
* alongside the legacy `onProgress` callback; both are presentation-only.
|
|
43
43
|
*/
|
|
44
44
|
progress?: ProgressEmitter;
|
|
45
|
+
/** Maximum retries for HubSpot 429/5xx responses (default 5). */
|
|
46
|
+
maxRetries?: number;
|
|
47
|
+
/** Injectable delay for deterministic retry tests. */
|
|
48
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
45
49
|
};
|
|
46
50
|
|
|
47
51
|
const OBJECT_PATHS: Partial<Record<GtmObjectType, string>> = {
|
|
@@ -76,6 +80,8 @@ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHO
|
|
|
76
80
|
export function createHubspotConnector(options: HubspotConnectorOptions): HubspotWritableConnector {
|
|
77
81
|
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
78
82
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
83
|
+
const maxRetries = options.maxRetries ?? 5;
|
|
84
|
+
const sleep = options.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
|
79
85
|
const mappings = options.fieldMappings;
|
|
80
86
|
// create:<Name> dedup within one connector lifetime (one apply run): the
|
|
81
87
|
// search API is eventually consistent, so a just-created company is
|
|
@@ -113,27 +119,45 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Hubspo
|
|
|
113
119
|
};
|
|
114
120
|
|
|
115
121
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
116
|
-
|
|
117
|
-
let
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
122
|
+
let response: Response | undefined;
|
|
123
|
+
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
124
|
+
const token = await options.getAccessToken();
|
|
125
|
+
try {
|
|
126
|
+
response = await fetchImpl(`${baseUrl}${path}`, {
|
|
127
|
+
...init,
|
|
128
|
+
headers: {
|
|
129
|
+
Authorization: `Bearer ${token}`,
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
...(init.headers ?? {}),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
136
|
+
throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
|
|
137
|
+
}
|
|
138
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
139
|
+
if (!retryable || attempt === maxRetries) break;
|
|
140
|
+
await response.text().catch(() => undefined);
|
|
141
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
142
|
+
const seconds = retryAfter === null ? NaN : Number(retryAfter);
|
|
143
|
+
const dateDelay = retryAfter && !Number.isFinite(seconds) ? Date.parse(retryAfter) - Date.now() : NaN;
|
|
144
|
+
const delayMs = Math.min(
|
|
145
|
+
30_000,
|
|
146
|
+
Math.max(0, Number.isFinite(seconds) ? seconds * 1_000 : Number.isFinite(dateDelay) ? dateDelay : 1_000 * 2 ** attempt),
|
|
147
|
+
);
|
|
148
|
+
const reason = response.status === 429 ? "rate limited" : `temporarily unavailable (${response.status})`;
|
|
149
|
+
options.progress?.note(`HubSpot ${reason}; retrying in ${Math.ceil(delayMs / 1_000)}s (${attempt + 1}/${maxRetries})`);
|
|
150
|
+
await sleep(delayMs);
|
|
130
151
|
}
|
|
131
|
-
if (!response.ok) {
|
|
152
|
+
if (!response || !response.ok) {
|
|
132
153
|
// Status line only — HubSpot 4xx bodies echo submitted property values
|
|
133
154
|
// (contact emails, company domains) and the request payload, and these
|
|
134
155
|
// errors are persisted into scheduled-run records. Never interpolate it.
|
|
135
|
-
await response
|
|
136
|
-
|
|
156
|
+
await response?.text().catch(() => undefined);
|
|
157
|
+
if (response?.status === 429) {
|
|
158
|
+
throw new Error(`HubSpot rate limit (429) persisted after ${maxRetries} retries. Wait for the portal limit to reset, then retry; no CRM writes were made.`);
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`HubSpot API error ${response?.status ?? "unknown"}. Check the token scopes and request.`);
|
|
137
161
|
}
|
|
138
162
|
// DELETE and some association writes return 204 with an empty body.
|
|
139
163
|
const text = await response.text();
|
|
@@ -34,6 +34,12 @@ export type Prospect = {
|
|
|
34
34
|
linkedin?: string;
|
|
35
35
|
/** real work email once resolved (pipe0); never the hashed value */
|
|
36
36
|
email?: string;
|
|
37
|
+
/** Validated mobile number when a contact provider explicitly returns one. */
|
|
38
|
+
mobile?: string;
|
|
39
|
+
/** Validated business direct dial when distinct from mobile. */
|
|
40
|
+
directDial?: string;
|
|
41
|
+
/** Person geography returned by identity-search providers. */
|
|
42
|
+
location?: { city?: string; state?: string; region?: string; country?: string; countryCode?: string };
|
|
37
43
|
/** ICP fit score 0..1, set by the acquire scorer */
|
|
38
44
|
fitScore?: number;
|
|
39
45
|
/** provider-native id for traceability */
|
|
@@ -471,7 +477,7 @@ function fieldValue(field: Pipe0Field | undefined): string | undefined {
|
|
|
471
477
|
}
|
|
472
478
|
|
|
473
479
|
// ---------------------------------------------------------------------------
|
|
474
|
-
// Pre-
|
|
480
|
+
// Pre-enrichment dedup: identity keys shared between prospects and CRM contacts, so
|
|
475
481
|
// we can drop already-in-CRM / already-seen people BEFORE paying for emails.
|
|
476
482
|
|
|
477
483
|
function normName(value: string | undefined): string {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Prospect } from "./connectors/prospectSources.ts";
|
|
2
|
+
|
|
3
|
+
export type ContactField = "work_email" | "mobile" | "direct_dial";
|
|
4
|
+
export type ContactInputShape = "linkedin" | "name_domain" | "email" | "phone" | "company_domain";
|
|
5
|
+
export type ContactProviderExecution = "sync" | "poll" | "webhook" | "batch";
|
|
6
|
+
export type ContactProviderBilling = "per_attempt" | "per_match" | "per_field" | "subscription" | "unknown";
|
|
7
|
+
|
|
8
|
+
export type ContactProviderCapability = {
|
|
9
|
+
provider: string;
|
|
10
|
+
operation: string;
|
|
11
|
+
inputShapes: ContactInputShape[];
|
|
12
|
+
outputFields: ContactField[];
|
|
13
|
+
execution: ContactProviderExecution;
|
|
14
|
+
billing: ContactProviderBilling;
|
|
15
|
+
chargesOn: Array<"request" | "match" | "valid" | "accept_all" | "unknown">;
|
|
16
|
+
supportsBalance: boolean;
|
|
17
|
+
supportsIdempotency: boolean;
|
|
18
|
+
maxBatchSize?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Only implemented adapters belong here; planned providers stay in the strategy document. */
|
|
22
|
+
export const CONTACT_PROVIDER_CAPABILITIES: Readonly<Record<string, readonly ContactProviderCapability[]>> = {
|
|
23
|
+
pipe0: [{
|
|
24
|
+
provider: "pipe0",
|
|
25
|
+
operation: "person:workemail:waterfall@1",
|
|
26
|
+
inputShapes: ["name_domain"],
|
|
27
|
+
outputFields: ["work_email"],
|
|
28
|
+
execution: "batch",
|
|
29
|
+
billing: "per_attempt",
|
|
30
|
+
chargesOn: ["request"],
|
|
31
|
+
supportsBalance: false,
|
|
32
|
+
supportsIdempotency: false,
|
|
33
|
+
maxBatchSize: 100,
|
|
34
|
+
}],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type ContactWaterfallStep = { provider: string; fields: ContactField[] };
|
|
38
|
+
|
|
39
|
+
export type ContactProviderAdapter = {
|
|
40
|
+
provider: string;
|
|
41
|
+
resolve(prospects: Prospect[], fields: ContactField[]): Promise<Prospect[]>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type ContactWaterfallAttempt = {
|
|
45
|
+
provider: string;
|
|
46
|
+
fields: ContactField[];
|
|
47
|
+
attempted: number;
|
|
48
|
+
added: Partial<Record<ContactField, number>>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type ContactWaterfallResult = { prospects: Prospect[]; attempts: ContactWaterfallAttempt[] };
|
|
52
|
+
|
|
53
|
+
export function validateContactWaterfall(steps: ContactWaterfallStep[]): ContactWaterfallStep[] {
|
|
54
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
55
|
+
throw new Error("contact waterfall must contain at least one provider step");
|
|
56
|
+
}
|
|
57
|
+
return steps.map((step, index) => {
|
|
58
|
+
if (!step || typeof step.provider !== "string" || !step.provider.trim()) {
|
|
59
|
+
throw new Error(`contact waterfall step ${index + 1}: provider must be a non-empty string`);
|
|
60
|
+
}
|
|
61
|
+
const capabilities = CONTACT_PROVIDER_CAPABILITIES[step.provider];
|
|
62
|
+
if (!capabilities) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`contact waterfall step ${index + 1}: unsupported provider "${step.provider}" ` +
|
|
65
|
+
`(implemented: ${Object.keys(CONTACT_PROVIDER_CAPABILITIES).join(", ")})`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(step.fields) || step.fields.length === 0) {
|
|
69
|
+
throw new Error(`contact waterfall step ${index + 1}: fields must be a non-empty array`);
|
|
70
|
+
}
|
|
71
|
+
const fields = [...new Set(step.fields)];
|
|
72
|
+
for (const field of fields) {
|
|
73
|
+
if (field !== "work_email" && field !== "mobile" && field !== "direct_dial") {
|
|
74
|
+
throw new Error(`contact waterfall step ${index + 1}: unsupported field "${String(field)}"`);
|
|
75
|
+
}
|
|
76
|
+
if (!capabilities.some((capability) => capability.outputFields.includes(field))) {
|
|
77
|
+
const available = [...new Set(capabilities.flatMap((capability) => capability.outputFields))];
|
|
78
|
+
throw new Error(
|
|
79
|
+
`contact waterfall step ${index + 1}: ${step.provider} does not implement "${field}" ` +
|
|
80
|
+
`(available: ${available.join(", ")})`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { provider: step.provider, fields };
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Run providers in order. Later steps receive only records still missing a requested field. */
|
|
89
|
+
export async function runContactWaterfall(opts: {
|
|
90
|
+
prospects: Prospect[];
|
|
91
|
+
steps: ContactWaterfallStep[];
|
|
92
|
+
adapters: Readonly<Record<string, ContactProviderAdapter>>;
|
|
93
|
+
}): Promise<ContactWaterfallResult> {
|
|
94
|
+
const steps = validateContactWaterfall(opts.steps);
|
|
95
|
+
const prospects = opts.prospects.map((prospect) => ({ ...prospect }));
|
|
96
|
+
const attempts: ContactWaterfallAttempt[] = [];
|
|
97
|
+
for (const step of steps) {
|
|
98
|
+
const adapter = opts.adapters[step.provider];
|
|
99
|
+
if (!adapter) throw new Error(`contact waterfall: adapter "${step.provider}" is not configured`);
|
|
100
|
+
const indexes = prospects
|
|
101
|
+
.map((prospect, index) => step.fields.some((field) => !fieldValue(prospect, field)) ? index : -1)
|
|
102
|
+
.filter((index) => index >= 0);
|
|
103
|
+
if (indexes.length === 0) {
|
|
104
|
+
attempts.push({ provider: step.provider, fields: step.fields, attempted: 0, added: {} });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const inputs = indexes.map((index) => prospects[index]);
|
|
108
|
+
const outputs = await adapter.resolve(inputs, step.fields);
|
|
109
|
+
if (outputs.length !== inputs.length) {
|
|
110
|
+
throw new Error(`contact waterfall: ${step.provider} returned ${outputs.length} result(s) for ${inputs.length} input(s)`);
|
|
111
|
+
}
|
|
112
|
+
const added: Partial<Record<ContactField, number>> = {};
|
|
113
|
+
outputs.forEach((output, outputIndex) => {
|
|
114
|
+
const prospectIndex = indexes[outputIndex];
|
|
115
|
+
const current = prospects[prospectIndex];
|
|
116
|
+
let merged = current;
|
|
117
|
+
for (const field of step.fields) {
|
|
118
|
+
if (fieldValue(current, field)) continue;
|
|
119
|
+
const value = fieldValue(output, field);
|
|
120
|
+
if (!value) continue;
|
|
121
|
+
merged = setFieldValue(merged, field, value);
|
|
122
|
+
added[field] = (added[field] ?? 0) + 1;
|
|
123
|
+
}
|
|
124
|
+
prospects[prospectIndex] = merged;
|
|
125
|
+
});
|
|
126
|
+
attempts.push({ provider: step.provider, fields: step.fields, attempted: inputs.length, added });
|
|
127
|
+
}
|
|
128
|
+
return { prospects, attempts };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function fieldValue(prospect: Prospect, field: ContactField): string | undefined {
|
|
132
|
+
if (field === "work_email") return prospect.email;
|
|
133
|
+
if (field === "mobile") return prospect.mobile;
|
|
134
|
+
return prospect.directDial;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function setFieldValue(prospect: Prospect, field: ContactField, value: string): Prospect {
|
|
138
|
+
if (field === "work_email") return { ...prospect, email: value };
|
|
139
|
+
if (field === "mobile") return { ...prospect, mobile: value };
|
|
140
|
+
return { ...prospect, directDial: value };
|
|
141
|
+
}
|
package/src/enrich.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { AcquireBudget } from "./acquireMeter.ts";
|
|
|
6
6
|
import type { ProgressEmitter } from "./progress.ts";
|
|
7
7
|
import { parseAssignmentPolicy, resolveAssignment } from "./assign.ts";
|
|
8
8
|
import type { AssignmentContext, AssignmentPolicy } from "./assign.ts";
|
|
9
|
+
import { validateContactWaterfall, type ContactWaterfallStep } from "./contactProviders.ts";
|
|
9
10
|
import type {
|
|
10
11
|
CanonicalGtmSnapshot,
|
|
11
12
|
CreateRecordPayload,
|
|
@@ -108,11 +109,16 @@ export type AcquireCreateMap = {
|
|
|
108
109
|
* real emails (e.g. explorium discovers, pipe0 resolves the work email).
|
|
109
110
|
*/
|
|
110
111
|
export type AcquireDiscoveryConfig = {
|
|
111
|
-
provider: "explorium" | "pipe0" | "linkedin" | "heyreach";
|
|
112
|
+
provider: "explorium" | "pipe0" | "clay" | "linkedin" | "heyreach";
|
|
113
|
+
/** Clay search collection. Phase 1 supports people; companies follows separately. */
|
|
114
|
+
sourceType?: "people" | "companies";
|
|
112
115
|
filters?: Record<string, unknown>;
|
|
113
116
|
size?: number;
|
|
114
117
|
/** Maximum raw provider candidates scanned per run while seeking fresh leads. */
|
|
115
118
|
scanLimit?: number;
|
|
119
|
+
/** Ordered fill-only contact providers. Later steps receive only unresolved fields. */
|
|
120
|
+
contactWaterfall?: ContactWaterfallStep[];
|
|
121
|
+
/** @deprecated Use contactWaterfall. Retained for existing configurations. */
|
|
116
122
|
resolveEmailsWith?: "pipe0";
|
|
117
123
|
/** LinkedIn sources only: the provider-native list id to read (HeyReach lead-list id). */
|
|
118
124
|
listId?: string;
|
|
@@ -148,7 +154,7 @@ const MATCH_KEYS: Record<EnrichObjectType, string[]> = {
|
|
|
148
154
|
};
|
|
149
155
|
|
|
150
156
|
/** API source ids the MVP can pull from. */
|
|
151
|
-
export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0", "linkedin"];
|
|
157
|
+
export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0", "clay", "linkedin"];
|
|
152
158
|
|
|
153
159
|
/**
|
|
154
160
|
* Canonical fields enrich may target, plus the HubSpot property spellings the
|
|
@@ -339,6 +345,21 @@ export function parseEnrichConfig(raw: string): EnrichConfig {
|
|
|
339
345
|
fail(error instanceof Error ? error.message : String(error));
|
|
340
346
|
}
|
|
341
347
|
}
|
|
348
|
+
for (const [sourceId, discovery] of Object.entries(config.acquire?.discovery ?? {})) {
|
|
349
|
+
if (discovery.provider === "clay" && (discovery.sourceType ?? "people") !== "people") {
|
|
350
|
+
fail(`acquire.discovery.${sourceId}.sourceType currently supports only "people" for Clay`);
|
|
351
|
+
}
|
|
352
|
+
if (discovery.contactWaterfall !== undefined) {
|
|
353
|
+
try {
|
|
354
|
+
discovery.contactWaterfall = validateContactWaterfall(discovery.contactWaterfall);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
fail(`acquire.discovery.${sourceId}.${error instanceof Error ? error.message : String(error)}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (discovery.resolveEmailsWith !== undefined && discovery.resolveEmailsWith !== "pipe0") {
|
|
360
|
+
fail(`acquire.discovery.${sourceId}.resolveEmailsWith must be "pipe0"`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
342
363
|
|
|
343
364
|
return config as EnrichConfig;
|
|
344
365
|
}
|
|
@@ -408,6 +429,34 @@ export function builtinAcquirePreset(source: string | undefined): EnrichConfig |
|
|
|
408
429
|
},
|
|
409
430
|
};
|
|
410
431
|
}
|
|
432
|
+
if (provider === "clay") {
|
|
433
|
+
return {
|
|
434
|
+
sources: { clay: { kind: "api" } },
|
|
435
|
+
match: { contact: { keys: ["linkedin", "email"], onAmbiguous: "skip" } },
|
|
436
|
+
fields: {},
|
|
437
|
+
policy: { overwrite: "never" },
|
|
438
|
+
acquire: {
|
|
439
|
+
budget: { records: { perDay: 50, perMonth: 500 } },
|
|
440
|
+
costPerRecord: { clay: 0 },
|
|
441
|
+
discovery: { clay: { provider: "clay", sourceType: "people", size: 25 } },
|
|
442
|
+
create: {
|
|
443
|
+
contact: {
|
|
444
|
+
matchKey: "linkedin",
|
|
445
|
+
properties: {
|
|
446
|
+
hs_linkedin_url: "linkedin",
|
|
447
|
+
firstname: "firstName",
|
|
448
|
+
lastname: "lastName",
|
|
449
|
+
jobtitle: "jobTitle",
|
|
450
|
+
company: "companyName",
|
|
451
|
+
email: "email",
|
|
452
|
+
},
|
|
453
|
+
associateCompanyFrom: "companyName",
|
|
454
|
+
associateCompanyDomainFrom: "companyDomain",
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
}
|
|
411
460
|
if (provider !== "pipe0" && provider !== "explorium") return undefined;
|
|
412
461
|
return {
|
|
413
462
|
sources: { [provider]: { kind: "api" } },
|
package/src/hostedPatchPlan.ts
CHANGED
|
@@ -207,7 +207,12 @@ export async function reportHostedPlanLifecycle(stored: StoredPlan, options: Opt
|
|
|
207
207
|
if (!paired) return { status: "unpaired" };
|
|
208
208
|
const status = stored.status === "applied" ? "applied" : stored.status === "rejected" ? "rejected" : "approved";
|
|
209
209
|
const runRecord = status === "applied" ? stored.runs.at(-1) : undefined;
|
|
210
|
-
|
|
210
|
+
// The hosted terminal transition is authority-bound to the exact approved
|
|
211
|
+
// subset. Local runs also record excluded operations as `skipped` for a
|
|
212
|
+
// complete human audit trail; those are not execution receipts and must not
|
|
213
|
+
// be echoed as though they were authorized provider work.
|
|
214
|
+
const approved = new Set(stored.approvedOperationIds);
|
|
215
|
+
const operationReceipts = runRecord?.results.filter((result) => approved.has(result.operationId)).map((result) => ({
|
|
211
216
|
packageOpId: result.operationId,
|
|
212
217
|
status: result.status,
|
|
213
218
|
...(result.detail ? { error: result.detail.slice(0, 1000) } : {}),
|
package/src/icp.ts
CHANGED
|
@@ -240,6 +240,57 @@ export function icpToCrustdataFilters(icp: Icp): Record<string, unknown> {
|
|
|
240
240
|
return f;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
const CLAY_INDUSTRY: Record<string, string[]> = {
|
|
244
|
+
software: ["Software Development"],
|
|
245
|
+
saas: ["Software Development"],
|
|
246
|
+
internet: ["Technology, Information and Internet"],
|
|
247
|
+
fintech: ["Financial Services"],
|
|
248
|
+
"financial services": ["Financial Services"],
|
|
249
|
+
"information technology & services": ["IT Services and IT Consulting"],
|
|
250
|
+
"information technology and services": ["IT Services and IT Consulting"],
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const CLAY_EMPLOYEE_BAND: Record<string, string> = {
|
|
254
|
+
"1-10": "2-10",
|
|
255
|
+
"11-50": "11-50",
|
|
256
|
+
"51-200": "51-200",
|
|
257
|
+
"201-500": "201-500",
|
|
258
|
+
"501-1000": "501-1,000",
|
|
259
|
+
"1001-5000": "1,001-5,000",
|
|
260
|
+
"5001-10000": "5,001-10,000",
|
|
261
|
+
"10001+": "10,001+",
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const CLAY_SENIORITY: Record<string, string> = {
|
|
265
|
+
cxo: "c-suite",
|
|
266
|
+
"c-suite": "c-suite",
|
|
267
|
+
vp: "vp",
|
|
268
|
+
director: "director",
|
|
269
|
+
head: "head",
|
|
270
|
+
manager: "manager",
|
|
271
|
+
owner: "owner",
|
|
272
|
+
founder: "founder",
|
|
273
|
+
senior: "senior",
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** Clay people-search filters using only fields confirmed in the live catalog. */
|
|
277
|
+
export function icpToClayPeopleFilters(icp: Icp): Record<string, unknown> {
|
|
278
|
+
const filters: Record<string, unknown> = {};
|
|
279
|
+
if (icp.persona.titleKeywords?.length) filters.job_title_keywords = icp.persona.titleKeywords;
|
|
280
|
+
const seniorities = [...new Set((icp.persona.jobLevels ?? []).map((level) => CLAY_SENIORITY[level.toLowerCase()]).filter(Boolean))];
|
|
281
|
+
if (seniorities.length) filters.job_title_seniority_levels_v2 = seniorities;
|
|
282
|
+
const sizes = [...new Set((icp.firmographics.employeeBands ?? []).map((band) => CLAY_EMPLOYEE_BAND[band.replace(/,/g, "")]).filter(Boolean))];
|
|
283
|
+
if (sizes.length) filters.company_sizes = sizes;
|
|
284
|
+
if (icp.firmographics.geos?.length) {
|
|
285
|
+
filters.location_countries_include = icp.firmographics.geos.map((geo) => COUNTRY_NAMES[geo.toLowerCase()] ?? geo);
|
|
286
|
+
}
|
|
287
|
+
const industries = [...new Set((icp.firmographics.industries ?? []).flatMap((industry) =>
|
|
288
|
+
CLAY_INDUSTRY[industry.toLowerCase()] ?? [titleCase(industry)]
|
|
289
|
+
))];
|
|
290
|
+
if (industries.length) filters.company_industries_include = industries;
|
|
291
|
+
return filters;
|
|
292
|
+
}
|
|
293
|
+
|
|
243
294
|
const ACRONYMS = new Set(["cro", "coo", "ceo", "cfo", "vp", "crm", "gtm", "saas"]);
|
|
244
295
|
function titleCase(value: string): string {
|
|
245
296
|
return value
|
package/src/index.ts
CHANGED
|
@@ -55,6 +55,20 @@ export {
|
|
|
55
55
|
type RulePackageTrust,
|
|
56
56
|
} from "./config.ts";
|
|
57
57
|
export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
|
|
58
|
+
export {
|
|
59
|
+
CONTACT_PROVIDER_CAPABILITIES,
|
|
60
|
+
runContactWaterfall,
|
|
61
|
+
validateContactWaterfall,
|
|
62
|
+
type ContactField,
|
|
63
|
+
type ContactInputShape,
|
|
64
|
+
type ContactProviderAdapter,
|
|
65
|
+
type ContactProviderBilling,
|
|
66
|
+
type ContactProviderCapability,
|
|
67
|
+
type ContactProviderExecution,
|
|
68
|
+
type ContactWaterfallAttempt,
|
|
69
|
+
type ContactWaterfallResult,
|
|
70
|
+
type ContactWaterfallStep,
|
|
71
|
+
} from "./contactProviders.ts";
|
|
58
72
|
export {
|
|
59
73
|
APPLY_STAGES,
|
|
60
74
|
BACKFILL_STRIPE_STAGES,
|
|
@@ -70,6 +84,17 @@ export {
|
|
|
70
84
|
type ProgressSnapshot,
|
|
71
85
|
} from "./progress.ts";
|
|
72
86
|
export { createHubspotConnector, type HubspotConnectorOptions } from "./connectors/hubspot.ts";
|
|
87
|
+
export {
|
|
88
|
+
CLAY_PUBLIC_API_BASE,
|
|
89
|
+
clayApiKey,
|
|
90
|
+
createClaySearch,
|
|
91
|
+
normalizeClayPerson,
|
|
92
|
+
runClayPeopleSearchPage,
|
|
93
|
+
validateClayApiKey,
|
|
94
|
+
type ClayPeopleSearchPage,
|
|
95
|
+
type ClaySearchSourceType,
|
|
96
|
+
type ClayKeyValidation,
|
|
97
|
+
} from "./connectors/clay.ts";
|
|
73
98
|
export {
|
|
74
99
|
DEFAULT_LOOPBACK_PORT,
|
|
75
100
|
DEFAULT_OAUTH_SCOPES,
|
package/src/init.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { builtinAcquirePreset, ENRICH_CONFIG_FILE_NAME, type EnrichConfig } from
|
|
|
19
19
|
import { DEFAULT_FIT_THRESHOLD, type Icp } from "./icp.ts";
|
|
20
20
|
|
|
21
21
|
export type InitProvider = "hubspot" | "salesforce";
|
|
22
|
-
export type InitSource = "pipe0" | "explorium" | "linkedin";
|
|
22
|
+
export type InitSource = "pipe0" | "explorium" | "clay" | "linkedin";
|
|
23
23
|
|
|
24
24
|
export type ScaffoldOptions = {
|
|
25
25
|
/** discovery source the acquire preset + playbook are wired for. Default "pipe0". */
|
|
@@ -63,7 +63,7 @@ export function starterIcp(): Icp {
|
|
|
63
63
|
export function starterEnrichConfig(source: InitSource): EnrichConfig {
|
|
64
64
|
const preset = builtinAcquirePreset(source);
|
|
65
65
|
if (!preset?.acquire) {
|
|
66
|
-
// builtinAcquirePreset covers pipe0/explorium/linkedin, so this is unreachable
|
|
66
|
+
// builtinAcquirePreset covers pipe0/explorium/clay/linkedin, so this is unreachable
|
|
67
67
|
// for the typed InitSource set — guard anyway rather than emit a broken file.
|
|
68
68
|
throw new Error(`init: no acquire preset for source "${source}"`);
|
|
69
69
|
}
|