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
@@ -26,6 +26,10 @@ export function createHubspotConnector(options) {
26
26
  // search API is eventually consistent, so a just-created company is
27
27
  // invisible to search — this map is the authoritative same-run record.
28
28
  const createdCompaniesByName = new Map();
29
+ // Same-run dedup for `create_record` contact creates, keyed by lowercased
30
+ // match value (email): HubSpot search is eventually consistent, so a contact
31
+ // created earlier in this apply run is invisible to a later search.
32
+ const createdContactsByMatch = new Map();
29
33
  async function request(path, init = {}) {
30
34
  const token = await options.getAccessToken();
31
35
  let response;
@@ -161,6 +165,7 @@ export function createHubspotConnector(options) {
161
165
  email: stringOrUndefined(readMapped(props, "contacts", "email", "email")),
162
166
  phone: stringOrUndefined(readMapped(props, "contacts", "phone", "phone")),
163
167
  title: stringOrUndefined(readMapped(props, "contacts", "title", "jobtitle")),
168
+ linkedin: stringOrUndefined(readMapped(props, "contacts", "linkedin", "hs_linkedin_url")),
164
169
  ownerId: stringOrUndefined(readMapped(props, "contacts", "ownerId", "hubspot_owner_id")),
165
170
  provenance: provenanceFrom(props),
166
171
  lastSyncAt: stringOrUndefined(contact.updatedAt),
@@ -413,6 +418,139 @@ export function createHubspotConnector(options) {
413
418
  });
414
419
  return (data?.results ?? []).map((row) => String(row.id));
415
420
  }
421
+ /** Exact-value contact lookup (by any searchable property) for resolve-first creates. */
422
+ async function searchContactsBy(property, value) {
423
+ const data = await request(`/crm/v3/objects/contacts/search`, {
424
+ method: "POST",
425
+ body: JSON.stringify({
426
+ filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
427
+ properties: [property],
428
+ limit: 3,
429
+ }),
430
+ });
431
+ return (data?.results ?? []).map((row) => String(row.id));
432
+ }
433
+ /**
434
+ * Resolve a company by exact name, creating it on a confirmed miss. Returns
435
+ * the company id, or null on ambiguity (≥2 existing) so the caller skips the
436
+ * association rather than guessing. Same-run safe via createdCompaniesByName.
437
+ */
438
+ async function resolveOrCreateCompanyByName(name, opId) {
439
+ const nameKey = name.toLowerCase();
440
+ const already = createdCompaniesByName.get(nameKey);
441
+ if (already)
442
+ return already;
443
+ const matches = await searchCompaniesByName(name);
444
+ if (matches.length > 1)
445
+ return null;
446
+ if (matches.length === 1) {
447
+ createdCompaniesByName.set(nameKey, matches[0]);
448
+ return matches[0];
449
+ }
450
+ let created;
451
+ try {
452
+ created = await request(`/crm/v3/objects/companies`, {
453
+ method: "POST",
454
+ body: JSON.stringify({
455
+ properties: { name, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` },
456
+ }),
457
+ });
458
+ }
459
+ catch {
460
+ created = await request(`/crm/v3/objects/companies`, {
461
+ method: "POST",
462
+ body: JSON.stringify({ properties: { name } }),
463
+ });
464
+ }
465
+ const id = String(created.id);
466
+ createdCompaniesByName.set(nameKey, id);
467
+ return id;
468
+ }
469
+ /**
470
+ * Create a NET-NEW record (a sourced lead). Resolve-first: re-checks the
471
+ * dedupe key against the live CRM (the plan-time snapshot can be stale) and
472
+ * creates ONLY on a confirmed miss — a record a concurrent writer already
473
+ * added is returned as `skipped`, never duplicated. Contacts can be linked
474
+ * to a resolved-or-created company in the same step.
475
+ */
476
+ async function createRecord(operation) {
477
+ const payload = operation.afterValue;
478
+ if (!payload || typeof payload !== "object" || !payload.properties) {
479
+ return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
480
+ }
481
+ const objectPath = OBJECT_PATHS[operation.objectType];
482
+ if (operation.objectType !== "contact" && operation.objectType !== "account") {
483
+ return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
484
+ }
485
+ const matchValue = String(payload.matchValue ?? "").trim();
486
+ if (!matchValue) {
487
+ return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
488
+ }
489
+ if (operation.objectType === "account") {
490
+ const id = await resolveOrCreateCompanyByName(matchValue, operation.id);
491
+ if (id === null) {
492
+ return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — multiple companies named "${matchValue}". Not creating.` };
493
+ }
494
+ return { operationId: operation.id, status: "applied", detail: `Resolved/created company "${matchValue}" (${id}).`, providerData: { id } };
495
+ }
496
+ // contact — resolve-first on the dedupe key (email by default)
497
+ const matchKey = payload.matchKey || "email";
498
+ const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
499
+ if (createdContactsByMatch.has(matchKeyLower)) {
500
+ return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: createdContactsByMatch.get(matchKeyLower), existing: true } };
501
+ }
502
+ const existing = await searchContactsBy(matchKey, matchValue);
503
+ if (existing.length > 0) {
504
+ createdContactsByMatch.set(matchKeyLower, existing[0]);
505
+ return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existing.join(", ")}); resolve-first declined to create.`, providerData: { id: existing[0], existing: true } };
506
+ }
507
+ const properties = { ...payload.properties };
508
+ // Stamp ownership at create time so the lead is never born ownerless. The
509
+ // canonical ownerId maps to HubSpot's standard owner property.
510
+ if (payload.ownerId && !properties.hubspot_owner_id) {
511
+ properties.hubspot_owner_id = String(payload.ownerId);
512
+ }
513
+ let created;
514
+ try {
515
+ created = await request(`/crm/v3/objects/${objectPath}`, {
516
+ method: "POST",
517
+ body: JSON.stringify({ properties: { ...properties, hs_object_source_detail_2: `fullstackgtm acquire (${operation.id})` } }),
518
+ });
519
+ }
520
+ catch {
521
+ // Some portals reject writes to source-detail properties — the provenance
522
+ // stamp is best-effort, the create is not.
523
+ created = await request(`/crm/v3/objects/${objectPath}`, {
524
+ method: "POST",
525
+ body: JSON.stringify({ properties }),
526
+ });
527
+ }
528
+ const contactId = String(created.id);
529
+ createdContactsByMatch.set(matchKeyLower, contactId);
530
+ let companyNote = "";
531
+ if (payload.associateCompanyName) {
532
+ try {
533
+ const companyId = await resolveOrCreateCompanyByName(payload.associateCompanyName, operation.id);
534
+ if (companyId) {
535
+ await request(`/crm/v4/objects/contacts/${encodeURIComponent(contactId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
536
+ companyNote = ` Linked to company "${payload.associateCompanyName}" (${companyId}).`;
537
+ }
538
+ else {
539
+ companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
540
+ }
541
+ }
542
+ catch (error) {
543
+ // Association is best-effort — the contact create already succeeded.
544
+ companyNote = ` (company link failed: ${error instanceof Error ? error.message : String(error)})`;
545
+ }
546
+ }
547
+ return {
548
+ operationId: operation.id,
549
+ status: "applied",
550
+ detail: `Created contact ${matchKey}=${matchValue} (${contactId}).${companyNote}`,
551
+ providerData: { id: contactId, created: true },
552
+ };
553
+ }
416
554
  async function createTask(operation) {
417
555
  const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
418
556
  if (associationTypeId === undefined) {
@@ -602,6 +740,8 @@ export function createHubspotConnector(options) {
602
740
  return await linkRecord(operation);
603
741
  case "create_task":
604
742
  return await createTask(operation);
743
+ case "create_record":
744
+ return await createRecord(operation);
605
745
  case "merge_records":
606
746
  return await mergeRecords(operation);
607
747
  case "archive_record":
@@ -0,0 +1,78 @@
1
+ /**
2
+ * LinkedIn connector — Phase 1 discovery (read-only).
3
+ *
4
+ * A provider-agnostic surface for pulling prospects out of LinkedIn via a
5
+ * rented execution layer (default: HeyReach; Unipile is a drop-in alternative).
6
+ * Deliberately decoupled from the acquire spine — the `Icp`→filter and
7
+ * `LinkedInProspect`→`Prospect` bridges live in the `enrich acquire --source
8
+ * linkedin` wiring, not here — so this layer can land and be tested on its own.
9
+ *
10
+ * Phase 2 (future) extends `LinkedInProvider` with send actions
11
+ * (connect/message) routed through the plan/apply gate. Spec:
12
+ * docs/linkedin-connector-spec.md. Nothing here ever writes or sends.
13
+ */
14
+ export type LinkedInProspect = {
15
+ firstName: string;
16
+ lastName: string;
17
+ fullName?: string;
18
+ headline?: string;
19
+ jobTitle?: string;
20
+ company?: string;
21
+ /** Canonical LinkedIn profile URL — the identity key dedup uses downstream. */
22
+ profileUrl: string;
23
+ location?: string;
24
+ email?: string;
25
+ emailStatus?: string;
26
+ /** The raw provider payload, preserved for debugging and later mapping. */
27
+ raw?: unknown;
28
+ };
29
+ export type LinkedInSource = {
30
+ /** Provider-native id of a prospect source (HeyReach: a lead-list id). */
31
+ id: string;
32
+ name: string;
33
+ /** Approximate prospect count, when the provider reports it. */
34
+ count?: number;
35
+ };
36
+ export type FetchProspectsOptions = {
37
+ /** Which source to read (HeyReach lead-list id); provider may have a default. */
38
+ sourceId?: string;
39
+ /** Hard cap on prospects returned; also bounds pagination. */
40
+ max?: number;
41
+ };
42
+ export type ConnectionStatus = {
43
+ ok: boolean;
44
+ detail: string;
45
+ };
46
+ /**
47
+ * Provider-agnostic LinkedIn discovery surface (Phase 1). Read-only: it
48
+ * enumerates prospect sources and pulls normalized prospects, never sends.
49
+ */
50
+ export interface LinkedInProvider {
51
+ readonly name: string;
52
+ /** Cheap auth/connectivity check; never throws — returns ok:false instead. */
53
+ checkConnection(): Promise<ConnectionStatus>;
54
+ /** Enumerate prospect sources (HeyReach lead lists). */
55
+ listSources(): Promise<LinkedInSource[]>;
56
+ /** Pull prospects from a source, normalized + capped. */
57
+ fetchProspects(options?: FetchProspectsOptions): Promise<LinkedInProspect[]>;
58
+ }
59
+ export declare const HEYREACH_BASE = "https://api.heyreach.io/api/public";
60
+ export type HeyReachProviderOptions = {
61
+ apiKey: string;
62
+ fetchImpl?: typeof fetch;
63
+ baseUrl?: string;
64
+ /** Lead-list id used when fetchProspects is called without an explicit sourceId. */
65
+ defaultListId?: string;
66
+ /** Page size for paginated reads (≤ server max). Lower in tests. */
67
+ pageSize?: number;
68
+ };
69
+ export declare function createHeyReachProvider(options: HeyReachProviderOptions): LinkedInProvider;
70
+ /** Map a HeyReach lead (nested `profile` or flat) into the normalized shape. */
71
+ export declare function normalizeHeyReachLead(raw: any): LinkedInProspect;
72
+ export declare const FAKE_LINKEDIN_PROSPECTS: LinkedInProspect[];
73
+ export type FakeLinkedInOptions = {
74
+ prospects?: LinkedInProspect[];
75
+ sources?: LinkedInSource[];
76
+ connection?: ConnectionStatus;
77
+ };
78
+ export declare function createFakeLinkedInProvider(opts?: FakeLinkedInOptions): LinkedInProvider;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * LinkedIn connector — Phase 1 discovery (read-only).
3
+ *
4
+ * A provider-agnostic surface for pulling prospects out of LinkedIn via a
5
+ * rented execution layer (default: HeyReach; Unipile is a drop-in alternative).
6
+ * Deliberately decoupled from the acquire spine — the `Icp`→filter and
7
+ * `LinkedInProspect`→`Prospect` bridges live in the `enrich acquire --source
8
+ * linkedin` wiring, not here — so this layer can land and be tested on its own.
9
+ *
10
+ * Phase 2 (future) extends `LinkedInProvider` with send actions
11
+ * (connect/message) routed through the plan/apply gate. Spec:
12
+ * docs/linkedin-connector-spec.md. Nothing here ever writes or sends.
13
+ */
14
+ // ── HeyReach adapter ────────────────────────────────────────────────────────
15
+ // Base + auth verified 2026-06-20: POST endpoints, `X-API-KEY` header, 300
16
+ // req/min (429). Confirmed live: /auth/CheckApiKey, /list/GetAll. The
17
+ // leads-from-list path has a documented gap in HeyReach's public docs and is
18
+ // marked to reconcile at live validation (step 3, needs a real key).
19
+ export const HEYREACH_BASE = "https://api.heyreach.io/api/public";
20
+ const HEYREACH_MAX_PAGE = 100;
21
+ export function createHeyReachProvider(options) {
22
+ const apiKey = options.apiKey;
23
+ if (!apiKey) {
24
+ throw new Error("HeyReach API key is required (`login heyreach` or HEYREACH_API_KEY).");
25
+ }
26
+ const fetchImpl = options.fetchImpl ?? fetch;
27
+ const baseUrl = (options.baseUrl ?? HEYREACH_BASE).replace(/\/$/, "");
28
+ const pageSize = Math.max(1, Math.min(options.pageSize ?? HEYREACH_MAX_PAGE, HEYREACH_MAX_PAGE));
29
+ async function call(path, method, body) {
30
+ let response;
31
+ try {
32
+ response = await fetchImpl(`${baseUrl}${path}`, {
33
+ method,
34
+ // Secret travels in the header, never the URL (no leak via logs/history).
35
+ headers: {
36
+ "X-API-KEY": apiKey,
37
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
38
+ },
39
+ body: body !== undefined ? JSON.stringify(body) : undefined,
40
+ });
41
+ }
42
+ catch {
43
+ throw new Error(`Cannot reach HeyReach at ${baseUrl}${path}. Check network access.`);
44
+ }
45
+ if (response.status === 429) {
46
+ const retry = response.headers.get("Retry-After");
47
+ throw new Error(`HeyReach rate limit (429)${retry ? `; retry after ${retry}s` : ""}. The cap is 300 requests/min.`);
48
+ }
49
+ if (!response.ok) {
50
+ await response.text().catch(() => undefined);
51
+ throw new Error(`HeyReach API error ${response.status} on ${path}. Check the API key and request.`);
52
+ }
53
+ if (response.status === 204)
54
+ return undefined;
55
+ return response.json().catch(() => undefined);
56
+ }
57
+ return {
58
+ name: "heyreach",
59
+ async checkConnection() {
60
+ try {
61
+ await call("/auth/CheckApiKey", "GET");
62
+ return { ok: true, detail: "HeyReach API key valid." };
63
+ }
64
+ catch (error) {
65
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
66
+ }
67
+ },
68
+ async listSources() {
69
+ const data = await call("/list/GetAll", "POST", { offset: 0, limit: HEYREACH_MAX_PAGE });
70
+ return toArray(data).map((item) => ({
71
+ id: String(item?.id ?? item?.listId ?? ""),
72
+ name: String(item?.name ?? item?.title ?? "(unnamed list)"),
73
+ count: typeof item?.totalItemsCount === "number"
74
+ ? item.totalItemsCount
75
+ : typeof item?.count === "number"
76
+ ? item.count
77
+ : undefined,
78
+ }));
79
+ },
80
+ async fetchProspects(opts = {}) {
81
+ const listId = opts.sourceId ?? options.defaultListId;
82
+ if (!listId) {
83
+ throw new Error("HeyReach fetchProspects needs a sourceId (lead-list id) or a configured defaultListId — list them with listSources().");
84
+ }
85
+ const max = opts.max ?? Number.POSITIVE_INFINITY;
86
+ const out = [];
87
+ let offset = 0;
88
+ while (out.length < max) {
89
+ const data = await call("/list/GetLeadsFromList", "POST", { listId, offset, limit: pageSize });
90
+ const items = toArray(data);
91
+ if (items.length === 0)
92
+ break;
93
+ for (const item of items) {
94
+ out.push(normalizeHeyReachLead(item));
95
+ if (out.length >= max)
96
+ break;
97
+ }
98
+ if (items.length < pageSize)
99
+ break;
100
+ offset += items.length;
101
+ }
102
+ return out;
103
+ },
104
+ };
105
+ }
106
+ /** First value that is a non-empty string, else undefined (treats "" as absent). */
107
+ function firstNonEmpty(...values) {
108
+ for (const value of values) {
109
+ if (typeof value === "string" && value.trim() !== "")
110
+ return value;
111
+ }
112
+ return undefined;
113
+ }
114
+ /** HeyReach wraps list payloads as `{ items: [...] }` on some routes, bare arrays on others. */
115
+ function toArray(data) {
116
+ if (Array.isArray(data))
117
+ return data;
118
+ if (Array.isArray(data?.items))
119
+ return data.items;
120
+ return [];
121
+ }
122
+ /** Map a HeyReach lead (nested `profile` or flat) into the normalized shape. */
123
+ export function normalizeHeyReachLead(raw) {
124
+ const p = (raw && typeof raw === "object" && raw.profile) || raw || {};
125
+ const first = p.firstName ?? raw?.firstName ?? "";
126
+ const last = p.lastName ?? raw?.lastName ?? "";
127
+ const full = (p.fullName ?? raw?.fullName ?? `${first} ${last}`.trim()) || undefined;
128
+ return {
129
+ firstName: String(first ?? ""),
130
+ lastName: String(last ?? ""),
131
+ fullName: full,
132
+ headline: p.headline ?? raw?.headline ?? undefined,
133
+ jobTitle: p.position ?? p.jobTitle ?? raw?.position ?? undefined,
134
+ company: p.companyName ?? p.company ?? raw?.companyName ?? undefined,
135
+ profileUrl: String(p.profileUrl ?? p.linkedInUrl ?? raw?.profileUrl ?? raw?.linkedin_url ?? ""),
136
+ location: p.location ?? raw?.location ?? undefined,
137
+ // HeyReach exposes the raw `emailAddress` plus its own enrichment outputs
138
+ // (`enrichedEmailAddress`, `customEmailAddress`) — verified against the live
139
+ // /list/GetLeadsFromList schema 2026-06-20. Unresolved emails come back as
140
+ // "", so pick the first NON-EMPTY value (?? would keep the empty string).
141
+ email: firstNonEmpty(p.emailAddress, p.enrichedEmailAddress, p.customEmailAddress, p.email, raw?.emailAddress),
142
+ emailStatus: p.emailStatus ?? raw?.emailStatus ?? undefined,
143
+ raw,
144
+ };
145
+ }
146
+ // ── Fake provider (tests / dry-run with no key, no network) ─────────────────
147
+ export const FAKE_LINKEDIN_PROSPECTS = [
148
+ {
149
+ firstName: "Dana",
150
+ lastName: "Okafor",
151
+ fullName: "Dana Okafor",
152
+ headline: "VP RevOps",
153
+ jobTitle: "VP Revenue Operations",
154
+ company: "Northwind Logistics",
155
+ profileUrl: "https://www.linkedin.com/in/dana-okafor",
156
+ location: "Chicago, IL",
157
+ email: "dana@northwind.example",
158
+ emailStatus: "verified",
159
+ },
160
+ {
161
+ firstName: "Priya",
162
+ lastName: "Raman",
163
+ fullName: "Priya Raman",
164
+ headline: "Head of Sales Ops",
165
+ jobTitle: "Head of Sales Operations",
166
+ company: "Cobalt Health",
167
+ profileUrl: "https://www.linkedin.com/in/priya-raman",
168
+ location: "Austin, TX",
169
+ },
170
+ {
171
+ firstName: "Marco",
172
+ lastName: "Bianchi",
173
+ fullName: "Marco Bianchi",
174
+ headline: "RevOps Manager",
175
+ jobTitle: "Revenue Operations Manager",
176
+ company: "Ferro Systems",
177
+ profileUrl: "https://www.linkedin.com/in/marco-bianchi",
178
+ location: "Remote",
179
+ email: "marco@ferro.example",
180
+ emailStatus: "guessed",
181
+ },
182
+ ];
183
+ export function createFakeLinkedInProvider(opts = {}) {
184
+ const prospects = opts.prospects ?? FAKE_LINKEDIN_PROSPECTS;
185
+ const sources = opts.sources ?? [{ id: "list_demo", name: "Demo ICP list", count: prospects.length }];
186
+ return {
187
+ name: "fake",
188
+ async checkConnection() {
189
+ return opts.connection ?? { ok: true, detail: "fake LinkedIn provider" };
190
+ },
191
+ async listSources() {
192
+ return sources.map((s) => ({ ...s }));
193
+ },
194
+ async fetchProspects(options = {}) {
195
+ const capped = options.max != null ? prospects.slice(0, options.max) : prospects;
196
+ return capped.map((p) => ({ ...p }));
197
+ },
198
+ };
199
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * API prospect sources for `enrich acquire` — net-new lead discovery + email
3
+ * resolution, behind one normalized shape the acquire builder consumes.
4
+ *
5
+ * Two confirmed providers (others slot in the same way):
6
+ * - Explorium — net-new discovery (POST /v1/prospects). Returns names, titles,
7
+ * company + website, LinkedIn; the email it returns is HASHED, so it is a
8
+ * discovery source, not an email source.
9
+ * - pipe0 — work-email resolution (POST /v1/pipes/run/sync with the
10
+ * `person:workemail:waterfall@1` block). Turns {name, company_domain} into a
11
+ * real work email. This is the email leg for Explorium-discovered people.
12
+ *
13
+ * Zero runtime deps: global fetch only, injectable for tests. Keys arrive via
14
+ * env/credential store, never argv.
15
+ */
16
+ import type { CanonicalGtmSnapshot } from "../types.ts";
17
+ export type Prospect = {
18
+ firstName?: string;
19
+ lastName?: string;
20
+ fullName?: string;
21
+ jobTitle?: string;
22
+ /** normalized seniority, e.g. "cxo","vp","director" (Explorium job_level_main) */
23
+ jobLevel?: string;
24
+ /** normalized department, e.g. "sales" (Explorium job_department_main) */
25
+ jobDepartment?: string;
26
+ companyName?: string;
27
+ /** bare domain, e.g. "microsoft.com" — feeds pipe0's company_domain */
28
+ companyDomain?: string;
29
+ linkedin?: string;
30
+ /** real work email once resolved (pipe0); never the hashed value */
31
+ email?: string;
32
+ /** ICP fit score 0..1, set by the acquire scorer */
33
+ fitScore?: number;
34
+ /** provider-native id for traceability */
35
+ sourceId?: string;
36
+ };
37
+ type FetchImpl = typeof fetch;
38
+ export type ExploriumFilters = Record<string, {
39
+ values?: string[];
40
+ value?: boolean;
41
+ }>;
42
+ export declare function fetchExploriumProspects(opts: {
43
+ apiKey: string;
44
+ filters: ExploriumFilters;
45
+ size?: number;
46
+ apiBaseUrl?: string;
47
+ fetchImpl?: FetchImpl;
48
+ }): Promise<Prospect[]>;
49
+ export declare function fetchPipe0CrustdataProspects(opts: {
50
+ apiKey: string;
51
+ filters: Record<string, unknown>;
52
+ limit?: number;
53
+ apiBaseUrl?: string;
54
+ fetchImpl?: FetchImpl;
55
+ }): Promise<Prospect[]>;
56
+ /**
57
+ * Resolve real work emails for people via pipe0's waterfall. Input rows need a
58
+ * full name + a company domain (or name). Returns the prospects with `email`
59
+ * filled where the waterfall found one; rows with no hit are returned unchanged.
60
+ */
61
+ export declare function pipe0ResolveWorkEmails(opts: {
62
+ apiKey: string;
63
+ prospects: Prospect[];
64
+ apiBaseUrl?: string;
65
+ fetchImpl?: FetchImpl;
66
+ /** Rows per pipe0 call. Small chunks are resilient to the waterfall's
67
+ * batch rate-limit (a throttled chunk returns work_email status "failed"
68
+ * for every row, so one big batch is all-or-nothing). Default 3. */
69
+ chunkSize?: number;
70
+ }): Promise<Prospect[]>;
71
+ /**
72
+ * Stable identity keys for a prospect (prefixed by kind). A match on ANY key
73
+ * means "same person". LinkedIn is strongest; name+domain is the only key
74
+ * shareable with the canonical CRM snapshot (which has no LinkedIn); email is
75
+ * available only after resolution (so it can't pre-filter, but it strengthens
76
+ * the seen cache).
77
+ */
78
+ export declare function prospectIdentityKeys(p: Prospect): string[];
79
+ /** Identity keys for every contact already in the CRM snapshot (email + name|domain). */
80
+ export declare function crmContactKeys(snapshot: CanonicalGtmSnapshot): Set<string>;
81
+ /**
82
+ * Split prospects into those worth paying to enrich vs. drop. A prospect is
83
+ * dropped if any identity key is already in the CRM (`crmKeys`) or in the
84
+ * cross-run `seen` cache. Pure + deterministic.
85
+ */
86
+ export declare function partitionFreshProspects(prospects: Prospect[], crmKeys: Set<string>, seen: Set<string>): {
87
+ fresh: Prospect[];
88
+ skippedCrm: number;
89
+ skippedSeen: number;
90
+ };
91
+ export {};