fullstackgtm 0.34.0 → 0.38.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/INSTALL_FOR_AGENTS.md +8 -0
  3. package/README.md +39 -0
  4. package/dist/acquireLinkedIn.d.ts +41 -0
  5. package/dist/acquireLinkedIn.js +57 -0
  6. package/dist/acquireMeter.d.ts +67 -0
  7. package/dist/acquireMeter.js +145 -0
  8. package/dist/acquireSeen.d.ts +5 -0
  9. package/dist/acquireSeen.js +54 -0
  10. package/dist/assign.d.ts +83 -0
  11. package/dist/assign.js +146 -0
  12. package/dist/bin.js +14 -2
  13. package/dist/cli.js +817 -26
  14. package/dist/connectors/hubspot.js +140 -0
  15. package/dist/connectors/linkedin.d.ts +78 -0
  16. package/dist/connectors/linkedin.js +199 -0
  17. package/dist/connectors/prospectSources.d.ts +91 -0
  18. package/dist/connectors/prospectSources.js +227 -0
  19. package/dist/enrich.d.ts +107 -0
  20. package/dist/enrich.js +315 -5
  21. package/dist/format.d.ts +3 -1
  22. package/dist/format.js +14 -2
  23. package/dist/health.d.ts +71 -0
  24. package/dist/health.js +172 -0
  25. package/dist/icp.d.ts +96 -0
  26. package/dist/icp.js +256 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +2 -0
  29. package/dist/mappings.js +3 -0
  30. package/dist/reassign.d.ts +11 -2
  31. package/dist/reassign.js +13 -6
  32. package/dist/runReport.d.ts +9 -0
  33. package/dist/runReport.js +66 -0
  34. package/dist/types.d.ts +25 -1
  35. package/docs/api.md +53 -3
  36. package/docs/architecture.md +11 -1
  37. package/docs/dx-punch-list.md +87 -0
  38. package/docs/linkedin-connector-spec.md +87 -0
  39. package/llms.txt +38 -1
  40. package/package.json +1 -1
  41. package/skills/fullstackgtm/SKILL.md +5 -3
  42. package/src/acquireLinkedIn.ts +83 -0
  43. package/src/acquireMeter.ts +186 -0
  44. package/src/acquireSeen.ts +57 -0
  45. package/src/assign.ts +193 -0
  46. package/src/bin.ts +17 -4
  47. package/src/cli.ts +965 -25
  48. package/src/connectors/hubspot.ts +145 -0
  49. package/src/connectors/linkedin.ts +272 -0
  50. package/src/connectors/prospectSources.ts +324 -0
  51. package/src/enrich.ts +411 -5
  52. package/src/format.ts +20 -2
  53. package/src/health.ts +238 -0
  54. package/src/icp.ts +313 -0
  55. package/src/index.ts +18 -0
  56. package/src/mappings.ts +3 -0
  57. package/src/reassign.ts +24 -8
  58. package/src/runReport.ts +76 -0
  59. package/src/types.ts +32 -0
@@ -0,0 +1,227 @@
1
+ function splitName(full) {
2
+ if (!full)
3
+ return {};
4
+ const parts = full.trim().split(/\s+/);
5
+ if (parts.length === 1)
6
+ return { firstName: parts[0] };
7
+ return { firstName: parts[0], lastName: parts.slice(1).join(" ") };
8
+ }
9
+ function bareDomain(value) {
10
+ if (!value)
11
+ return undefined;
12
+ return value
13
+ .trim()
14
+ .replace(/^https?:\/\//i, "")
15
+ .replace(/^www\./i, "")
16
+ .replace(/\/.*$/, "")
17
+ .toLowerCase() || undefined;
18
+ }
19
+ export async function fetchExploriumProspects(opts) {
20
+ const fetchImpl = opts.fetchImpl ?? fetch;
21
+ const base = (opts.apiBaseUrl ?? "https://api.explorium.ai").replace(/\/$/, "");
22
+ const size = Math.min(opts.size ?? 25, 100);
23
+ const response = await fetchImpl(`${base}/v1/prospects`, {
24
+ method: "POST",
25
+ headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
26
+ body: JSON.stringify({ mode: "full", size, page_size: size, page: 1, filters: opts.filters }),
27
+ });
28
+ if (!response.ok) {
29
+ throw new Error(`Explorium /v1/prospects failed: HTTP ${response.status} ${await safeText(response)}`);
30
+ }
31
+ const data = (await response.json());
32
+ return (data.data ?? []).map((row) => ({
33
+ firstName: row.first_name,
34
+ lastName: row.last_name,
35
+ fullName: row.full_name ?? ([row.first_name, row.last_name].filter(Boolean).join(" ") || undefined),
36
+ jobTitle: row.job_title,
37
+ jobLevel: row.job_level_main,
38
+ jobDepartment: row.job_department_main,
39
+ companyName: row.company_name,
40
+ companyDomain: bareDomain(row.company_website),
41
+ linkedin: normalizeLinkedin(row.linkedin),
42
+ sourceId: row.prospect_id,
43
+ }));
44
+ }
45
+ // ---------------------------------------------------------------------------
46
+ // pipe0 / Crustdata — net-new discovery (search). Crustdata is built into pipe0
47
+ // (no separate connection needed). Returns profiles; pair with
48
+ // pipe0ResolveWorkEmails to get real emails.
49
+ export async function fetchPipe0CrustdataProspects(opts) {
50
+ const fetchImpl = opts.fetchImpl ?? fetch;
51
+ const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
52
+ const limit = Math.min(opts.limit ?? 25, 100);
53
+ const response = await fetchImpl(`${base}/v1/searches/run/sync`, {
54
+ method: "POST",
55
+ headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
56
+ body: JSON.stringify({
57
+ searches: [{ search_id: "people:profiles:crustdata@1", config: { limit, filters: opts.filters } }],
58
+ }),
59
+ });
60
+ if (!response.ok) {
61
+ throw new Error(`pipe0 /v1/searches/run/sync failed: HTTP ${response.status} ${await safeText(response)}`);
62
+ }
63
+ const body = (await response.json());
64
+ // Surface upstream provider errors (e.g. CreditBalanceInsufficient) instead of
65
+ // silently returning [] — a throttle/credit failure must not look like "no ICP matches".
66
+ const upstreamErrors = (body.search_statuses ?? []).flatMap((s) => s.errors ?? []);
67
+ if (upstreamErrors.length > 0 && !(body.results ?? []).length) {
68
+ throw new Error(`pipe0/Crustdata search error: ${upstreamErrors.map((e) => `${e.code ?? "error"}: ${e.message ?? ""}`).join("; ")}`);
69
+ }
70
+ return (body.results ?? []).map((row) => {
71
+ const fullName = strField(row.name);
72
+ const { firstName, lastName } = splitName(fullName);
73
+ return {
74
+ firstName,
75
+ lastName,
76
+ fullName,
77
+ jobTitle: strField(row.job_title),
78
+ companyDomain: bareDomain(strField(row.company_website_url)),
79
+ linkedin: normalizeLinkedin(strField(row.profile_url)),
80
+ sourceId: strField(row.profile_url) ?? fullName,
81
+ };
82
+ });
83
+ }
84
+ function strField(field) {
85
+ const v = field?.value;
86
+ return typeof v === "string" && v.trim() ? v : undefined;
87
+ }
88
+ function normalizeLinkedin(value) {
89
+ if (!value)
90
+ return undefined;
91
+ const v = value.trim().replace(/^https?:\/\//i, "").replace(/\/$/, "");
92
+ return v ? `https://${v}` : undefined;
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // pipe0 — work-email resolution (waterfall)
96
+ /**
97
+ * Resolve real work emails for people via pipe0's waterfall. Input rows need a
98
+ * full name + a company domain (or name). Returns the prospects with `email`
99
+ * filled where the waterfall found one; rows with no hit are returned unchanged.
100
+ */
101
+ export async function pipe0ResolveWorkEmails(opts) {
102
+ const fetchImpl = opts.fetchImpl ?? fetch;
103
+ const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
104
+ const chunkSize = Math.max(1, opts.chunkSize ?? 3);
105
+ // Only rows with the waterfall's required inputs (name + company_domain|name).
106
+ const resolvable = opts.prospects.filter((p) => p.fullName && (p.companyDomain || p.companyName));
107
+ if (resolvable.length === 0)
108
+ return opts.prospects;
109
+ const emailByKey = new Map();
110
+ for (let i = 0; i < resolvable.length; i += chunkSize) {
111
+ const chunk = resolvable.slice(i, i + chunkSize);
112
+ const input = chunk.map((p) => ({
113
+ name: p.fullName,
114
+ ...(p.companyDomain ? { company_domain: p.companyDomain } : { company_name: p.companyName }),
115
+ }));
116
+ let body;
117
+ try {
118
+ const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
119
+ method: "POST",
120
+ headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
121
+ body: JSON.stringify({ pipes: [{ pipe_id: "person:workemail:waterfall@1" }], input }),
122
+ });
123
+ if (!response.ok)
124
+ continue; // throttled/5xx chunk — skip, keep the rest
125
+ body = (await response.json());
126
+ }
127
+ catch {
128
+ continue; // network hiccup on one chunk must not sink the whole run
129
+ }
130
+ for (const recordId of body.order ?? []) {
131
+ const fields = body.records?.[recordId]?.fields ?? {};
132
+ const email = fields.work_email?.status === "completed" ? fieldValue(fields.work_email) : undefined;
133
+ if (email) {
134
+ const domain = fieldValue(fields.company_domain) ?? fieldValue(fields.company_name);
135
+ emailByKey.set(personKey(fieldValue(fields.name), domain), email);
136
+ }
137
+ }
138
+ }
139
+ return opts.prospects.map((p) => {
140
+ const email = emailByKey.get(personKey(p.fullName, p.companyDomain ?? p.companyName));
141
+ return email ? { ...p, email } : p;
142
+ });
143
+ }
144
+ function personKey(name, company) {
145
+ return `${(name ?? "").trim().toLowerCase()}|${(company ?? "").trim().toLowerCase()}`;
146
+ }
147
+ function fieldValue(field) {
148
+ const v = field?.value;
149
+ return typeof v === "string" && v.trim() ? v : undefined;
150
+ }
151
+ async function safeText(response) {
152
+ try {
153
+ return (await response.text()).slice(0, 300);
154
+ }
155
+ catch {
156
+ return "";
157
+ }
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Pre-email dedup: identity keys shared between prospects and CRM contacts, so
161
+ // we can drop already-in-CRM / already-seen people BEFORE paying for emails.
162
+ function normName(value) {
163
+ return (value ?? "").trim().toLowerCase().replace(/\s+/g, " ");
164
+ }
165
+ /**
166
+ * Stable identity keys for a prospect (prefixed by kind). A match on ANY key
167
+ * means "same person". LinkedIn is strongest; name+domain is the only key
168
+ * shareable with the canonical CRM snapshot (which has no LinkedIn); email is
169
+ * available only after resolution (so it can't pre-filter, but it strengthens
170
+ * the seen cache).
171
+ */
172
+ export function prospectIdentityKeys(p) {
173
+ const keys = [];
174
+ if (p.linkedin)
175
+ keys.push(`li:${p.linkedin.trim().toLowerCase().replace(/\/+$/, "")}`);
176
+ const name = normName(p.fullName ?? [p.firstName, p.lastName].filter(Boolean).join(" "));
177
+ const domain = bareDomain(p.companyDomain);
178
+ if (name && domain)
179
+ keys.push(`nd:${name}|${domain}`);
180
+ if (p.email)
181
+ keys.push(`em:${p.email.trim().toLowerCase()}`);
182
+ return keys;
183
+ }
184
+ /** Identity keys for every contact already in the CRM snapshot (email + name|domain). */
185
+ export function crmContactKeys(snapshot) {
186
+ const domainByAccount = new Map();
187
+ for (const account of snapshot.accounts ?? []) {
188
+ const d = bareDomain(account.domain);
189
+ if (d)
190
+ domainByAccount.set(account.id, d);
191
+ }
192
+ const set = new Set();
193
+ for (const contact of snapshot.contacts ?? []) {
194
+ if (contact.linkedin)
195
+ set.add(`li:${contact.linkedin.trim().toLowerCase().replace(/\/+$/, "")}`);
196
+ if (contact.email)
197
+ set.add(`em:${contact.email.trim().toLowerCase()}`);
198
+ const name = normName([contact.firstName, contact.lastName].filter(Boolean).join(" "));
199
+ const domain = contact.accountId ? domainByAccount.get(contact.accountId) : undefined;
200
+ if (name && domain)
201
+ set.add(`nd:${name}|${domain}`);
202
+ }
203
+ return set;
204
+ }
205
+ /**
206
+ * Split prospects into those worth paying to enrich vs. drop. A prospect is
207
+ * dropped if any identity key is already in the CRM (`crmKeys`) or in the
208
+ * cross-run `seen` cache. Pure + deterministic.
209
+ */
210
+ export function partitionFreshProspects(prospects, crmKeys, seen) {
211
+ const fresh = [];
212
+ let skippedCrm = 0;
213
+ let skippedSeen = 0;
214
+ for (const p of prospects) {
215
+ const keys = prospectIdentityKeys(p);
216
+ if (keys.some((k) => crmKeys.has(k))) {
217
+ skippedCrm += 1;
218
+ continue;
219
+ }
220
+ if (keys.some((k) => seen.has(k))) {
221
+ skippedSeen += 1;
222
+ continue;
223
+ }
224
+ fresh.push(p);
225
+ }
226
+ return { fresh, skippedCrm, skippedSeen };
227
+ }
package/dist/enrich.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { AcquireBudget } from "./acquireMeter.ts";
2
+ import type { AssignmentContext, AssignmentPolicy } from "./assign.ts";
1
3
  import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
2
4
  /**
3
5
  * The enrich layer: governed append/refresh of third-party data into the CRM.
@@ -55,6 +57,47 @@ export type EnrichConfig = {
55
57
  match: Partial<Record<EnrichObjectType, EnrichMatchConfig>>;
56
58
  fields: Partial<Record<EnrichObjectType, EnrichFieldConfig[]>>;
57
59
  policy: EnrichPolicyConfig;
60
+ /** Net-new lead creation (`enrich acquire`). Optional; absent = acquire off. */
61
+ acquire?: AcquireConfig;
62
+ };
63
+ /** Maps a sourced record to the CRM properties a net-new record is created with. */
64
+ export type AcquireCreateMap = {
65
+ /** CRM property name → source payload path (read via sourceValueAt). */
66
+ properties: Record<string, string>;
67
+ /** Source path whose value becomes the dedupe key (e.g. "email"). */
68
+ matchKey: string;
69
+ /** Optional source path to a company name; the connector resolves-or-creates it. */
70
+ associateCompanyFrom?: string;
71
+ };
72
+ /**
73
+ * Net-new discovery for an API acquire source. `provider` selects the adapter
74
+ * (explorium = prospect search; pipe0 = work-email waterfall). `filters` is the
75
+ * provider-native query; `resolveEmailsWith` chains a second provider to fill
76
+ * real emails (e.g. explorium discovers, pipe0 resolves the work email).
77
+ */
78
+ export type AcquireDiscoveryConfig = {
79
+ provider: "explorium" | "pipe0" | "linkedin" | "heyreach";
80
+ filters?: Record<string, unknown>;
81
+ size?: number;
82
+ resolveEmailsWith?: "pipe0";
83
+ /** LinkedIn sources only: the provider-native list id to read (HeyReach lead-list id). */
84
+ listId?: string;
85
+ };
86
+ export type AcquireConfig = {
87
+ /** Windowed budget enforced by the acquire meter; absent = unmetered. */
88
+ budget?: AcquireBudget;
89
+ /** Estimated provider cost (USD) per created record, by source id. */
90
+ costPerRecord?: Record<string, number>;
91
+ /** How to build a net-new record from a sourced row, per object type. */
92
+ create: Partial<Record<EnrichObjectType, AcquireCreateMap>>;
93
+ /** Net-new discovery params for API sources, by source id. */
94
+ discovery?: Record<string, AcquireDiscoveryConfig>;
95
+ /**
96
+ * Ownership rule stamped onto every created lead so it is never born
97
+ * ownerless. Absent = no auto-assignment (the CLI may still default to the
98
+ * portal's sole active owner). Shared with `reassign --assign-unowned`.
99
+ */
100
+ assign?: AssignmentPolicy;
58
101
  };
59
102
  export declare const ENRICH_CONFIG_FILE_NAME = "enrich.config.json";
60
103
  export declare const DEFAULT_STALE_DAYS = 90;
@@ -68,6 +111,25 @@ export declare function resolveCrmField(objectType: EnrichObjectType, name: stri
68
111
  * the accepted values.
69
112
  */
70
113
  export declare function parseEnrichConfig(raw: string): EnrichConfig;
114
+ /**
115
+ * Built-in zero-config presets for well-known sources, so the common Mode-A
116
+ * loop — "feed a Clay export, get the dedup/routing verdict" — needs no
117
+ * hand-authored config. These are **match-only** (empty `fields`): the value is
118
+ * the matched / unmatched / ambiguous routing and collision detection, not field
119
+ * writes. Drop an `enrich.config.json` next to your run to map fields for actual
120
+ * field enrichment; an explicit config always wins over a preset. Constructed
121
+ * directly (not via `parseEnrichConfig`) so the match-only shape is allowed.
122
+ */
123
+ export declare const BUILTIN_ENRICH_PRESETS: Record<string, EnrichConfig>;
124
+ export declare function builtinEnrichPreset(source: string | undefined): EnrichConfig | undefined;
125
+ /**
126
+ * Zero-config `enrich acquire` preset so every client gets targeted, deduped,
127
+ * metered lead-fill out of the box — no hand-authored enrich.config.json. The
128
+ * ICP (icp.json) supplies the targeting; this supplies sensible plumbing. The
129
+ * create mapping writes the prospect's LinkedIn URL into hs_linkedin_url, so
130
+ * each created contact strengthens future dedup. An explicit config always wins.
131
+ */
132
+ export declare function builtinAcquirePreset(source: string | undefined): EnrichConfig | undefined;
71
133
  export declare function loadEnrichConfig(path: string): EnrichConfig;
72
134
  export declare function parseCsv(text: string): Array<Record<string, string>>;
73
135
  export type EnrichSourceRecord = {
@@ -154,6 +216,51 @@ export type EnrichPlanResult = {
154
216
  * current CRM value → apply-time compare-and-set rejects drifted records).
155
217
  */
156
218
  export declare function buildEnrichPlan(options: BuildEnrichPlanOptions): EnrichPlanResult;
219
+ export type AcquireCounts = {
220
+ fetched: number;
221
+ matched: number;
222
+ ambiguous: number;
223
+ unmatched: number;
224
+ /** create_record ops emitted. */
225
+ created: number;
226
+ /** unmatched rows that would have been created but for the meter ceiling. */
227
+ withheldByMeter: number;
228
+ /** created ops that got an owner stamped (a subset of `created`). */
229
+ assigned: number;
230
+ /** created ops left ownerless (no policy, or policy could not place them). */
231
+ unassigned: number;
232
+ };
233
+ export type AcquirePlanResult = {
234
+ plan: PatchPlan;
235
+ counts: AcquireCounts;
236
+ estCostUsd: number;
237
+ };
238
+ export type BuildAcquirePlanOptions = {
239
+ config: EnrichConfig;
240
+ source: string;
241
+ snapshot: CanonicalGtmSnapshot;
242
+ records: EnrichSourceRecord[];
243
+ runLabel: string;
244
+ /** Meter ceiling: max create ops to emit. null/undefined = unlimited. */
245
+ maxRecords?: number | null;
246
+ now?: () => Date;
247
+ };
248
+ /**
249
+ * Lift a prospect/source payload into the routing attributes a territory rule
250
+ * reads. Tolerant of provider spellings (explorium vs pipe0); absent attributes
251
+ * stay undefined, so a rule that routes on them simply won't match (falling to
252
+ * the policy fallback) instead of mis-routing.
253
+ */
254
+ export declare function acquireAssignmentContext(payload: Record<string, unknown>, companyName: string | undefined, accountOwnerByName: ReadonlyMap<string, string>): AssignmentContext;
255
+ /**
256
+ * Match each sourced record against the snapshot and route it: matched =
257
+ * already in the CRM (skip), ambiguous = a possible duplicate exists (skip —
258
+ * resolve-first never creates over ambiguity), unmatched = a net-new lead.
259
+ * Unmatched rows with a dedupe key and at least one mapped property become
260
+ * `create_record` operations, capped at `maxRecords` (the meter's headroom).
261
+ * Always a dry-run plan; nothing is written until apply.
262
+ */
263
+ export declare function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanResult;
157
264
  /** Latest stamp per (objectType, objectId, field) across a source's runs. */
158
265
  export declare function latestStamps(runs: EnrichRun[], source: string): Map<string, EnrichStamp>;
159
266
  export declare function staleDaysFor(config: EnrichConfig, objectType: EnrichObjectType, field: string): number;