fullstackgtm 0.50.1 → 0.52.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 (45) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/cli/auth.js +42 -11
  3. package/dist/cli/enrich.js +122 -56
  4. package/dist/cli/help.js +10 -7
  5. package/dist/cli/icp.js +155 -1
  6. package/dist/cli/init.js +3 -3
  7. package/dist/cli/tam.d.ts +1 -1
  8. package/dist/cli/tam.js +4 -1
  9. package/dist/connectors/clay.d.ts +33 -0
  10. package/dist/connectors/clay.js +123 -0
  11. package/dist/connectors/prospectSources.d.ts +12 -0
  12. package/dist/connectors/prospectSources.js +1 -1
  13. package/dist/contactProviders.d.ts +44 -0
  14. package/dist/contactProviders.js +100 -0
  15. package/dist/enrich.d.ts +7 -1
  16. package/dist/enrich.js +46 -1
  17. package/dist/icp.d.ts +2 -0
  18. package/dist/icp.js +53 -0
  19. package/dist/icpDerive.d.ts +51 -0
  20. package/dist/icpDerive.js +146 -0
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.js +3 -0
  23. package/dist/init.d.ts +1 -1
  24. package/dist/init.js +1 -1
  25. package/dist/llm.d.ts +4 -0
  26. package/dist/llm.js +77 -6
  27. package/dist/publicHttp.js +6 -0
  28. package/docs/api.md +18 -1
  29. package/package.json +1 -1
  30. package/src/cli/auth.ts +37 -10
  31. package/src/cli/enrich.ts +130 -55
  32. package/src/cli/help.ts +10 -7
  33. package/src/cli/icp.ts +144 -3
  34. package/src/cli/init.ts +3 -3
  35. package/src/cli/tam.ts +4 -2
  36. package/src/connectors/clay.ts +155 -0
  37. package/src/connectors/prospectSources.ts +7 -1
  38. package/src/contactProviders.ts +141 -0
  39. package/src/enrich.ts +51 -2
  40. package/src/icp.ts +55 -0
  41. package/src/icpDerive.ts +158 -0
  42. package/src/index.ts +38 -0
  43. package/src/init.ts +2 -2
  44. package/src/llm.ts +71 -6
  45. package/src/publicHttp.ts +7 -1
package/dist/cli/icp.js CHANGED
@@ -1,13 +1,18 @@
1
1
  // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
2
  import { readFileSync, writeFileSync } from "node:fs";
3
+ import { emitKeypressEvents } from "node:readline";
4
+ import { createInterface } from "node:readline/promises";
3
5
  import { resolve } from "node:path";
4
6
  import { resolveLlmCredential } from "../llm.js";
5
7
  import { fitThreshold, icpFromAnswers, icpToCrustdataFilters, icpToExploriumFilters, INTERVIEW_SPEC } from "../icp.js";
6
8
  import { createFileSignalStore, DEFAULT_SIGNALS_CONFIG } from "../signals.js";
7
9
  import { createFileJudgeStore, DEFAULT_JUDGE_PROMPT, judgeRunId, judgeSignals } from "../judge.js";
8
10
  import { DEFAULT_GOLDEN_NOW_ISO, DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet } from "../judgeEval.js";
9
- import { loadIcp, numericOption, option, readSnapshot, resolveLlmBaseUrls, saveRequested } from "./shared.js";
11
+ import { loadIcp, numericOption, option, readSecret, readSnapshot, resolveLlmBaseUrls, saveRequested } from "./shared.js";
10
12
  import { createStatusLine } from "./ui.js";
13
+ import { box, colorEnabled, paint } from "./ui.js";
14
+ import { getCredential, storeCredential } from "../credentials.js";
15
+ import { deriveWebsiteIcp, icpReviewSegments, OPENROUTER_API_BASE } from "../icpDerive.js";
11
16
  import { unknownSubcommandError } from "./suggest.js";
12
17
  function renderJudgeDecisions(decisions) {
13
18
  if (decisions.length === 0)
@@ -23,6 +28,120 @@ function renderJudgeDecisions(decisions) {
23
28
  }
24
29
  return lines.join("\n");
25
30
  }
31
+ function updateReviewSegment(icp, id, raw) {
32
+ const values = raw.split(",").map((value) => value.trim()).filter(Boolean);
33
+ if (id === "threshold")
34
+ return { ...icp, scoring: { ...icp.scoring, threshold: Number(raw) } };
35
+ if (["industries", "employeeBands", "geos", "technologies"].includes(id)) {
36
+ return { ...icp, firmographics: { ...icp.firmographics, [id]: values } };
37
+ }
38
+ if (["titleKeywords", "jobLevels", "departments"].includes(id)) {
39
+ return { ...icp, persona: { ...icp.persona, [id]: values } };
40
+ }
41
+ return { ...icp, signals: { ...icp.signals, intentTopics: values } };
42
+ }
43
+ function renderDerivedIcp(result, selected = -1) {
44
+ const p = paint(colorEnabled());
45
+ const segments = icpReviewSegments(result.icp);
46
+ const lines = [
47
+ `${result.company.name} ${result.company.domain}`,
48
+ `${Math.round(result.confidence * 100)}% confidence · ${result.derivation.model}`,
49
+ "",
50
+ ...segments.map((segment, index) => `${index === selected ? "›" : " "} ${segment.label.padEnd(15)} ${segment.value}`),
51
+ "",
52
+ selected >= 0 ? "↑/↓ select · enter edit · s save · q cancel" : `${result.evidence.length} website-verifiable evidence quote(s)`,
53
+ ];
54
+ return box(lines, p, "ICP preview").join("\n");
55
+ }
56
+ async function resolveIcpDeriveLlm(args) {
57
+ const requested = option(args, "--provider")?.toLowerCase();
58
+ if (requested && !["openrouter", "openai", "anthropic"].includes(requested)) {
59
+ throw new Error(`icp derive --provider must be openrouter, openai, or anthropic; got "${requested}".`);
60
+ }
61
+ const openRouterKey = process.env.OPENROUTER_API_KEY ?? getCredential("openrouter")?.accessToken;
62
+ const normal = resolveLlmCredential();
63
+ if ((!requested || requested === "openrouter") && openRouterKey) {
64
+ return { provider: "openai", apiKey: openRouterKey, openaiBaseUrl: OPENROUTER_API_BASE };
65
+ }
66
+ if (requested === "openai" || requested === "anthropic") {
67
+ const envKey = requested === "openai" ? process.env.OPENAI_API_KEY : process.env.ANTHROPIC_API_KEY;
68
+ const key = envKey ?? getCredential(requested)?.accessToken;
69
+ if (key)
70
+ return { provider: requested, apiKey: key, ...resolveLlmBaseUrls() };
71
+ }
72
+ if (normal && (!requested || requested === normal.provider))
73
+ return { ...normal, ...resolveLlmBaseUrls() };
74
+ const provider = requested;
75
+ if (!process.stdin.isTTY || process.env.CI) {
76
+ throw new Error("ICP derivation needs an LLM key. Run one of:\n" +
77
+ " fullstackgtm login openrouter\n fullstackgtm login openai\n fullstackgtm login anthropic\n" +
78
+ "Then rerun the same icp derive command.");
79
+ }
80
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
81
+ const answer = provider ?? ((await rl.question("LLM provider [openrouter/openai/anthropic] (openrouter): ")).trim().toLowerCase() || "openrouter");
82
+ rl.close();
83
+ if (!['openrouter', 'openai', 'anthropic'].includes(answer))
84
+ throw new Error(`Unknown LLM provider "${answer}".`);
85
+ const selectedProvider = answer;
86
+ const key = await readSecret(`${selectedProvider} API key`);
87
+ if (!key)
88
+ throw new Error(`No ${answer} API key provided.`);
89
+ const now = new Date().toISOString();
90
+ storeCredential(selectedProvider, { kind: "api_key", accessToken: key, createdAt: now, updatedAt: now });
91
+ console.error(`Stored ${selectedProvider} key in the active FullStackGTM profile.`);
92
+ return selectedProvider === "openrouter"
93
+ ? { provider: "openai", apiKey: key, openaiBaseUrl: OPENROUTER_API_BASE }
94
+ : { provider: selectedProvider, apiKey: key };
95
+ }
96
+ async function reviewDerivedIcp(result) {
97
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
98
+ return result;
99
+ let current = result;
100
+ let selected = 0;
101
+ const render = () => process.stdout.write(`\u001b[2J\u001b[H${renderDerivedIcp(current, selected)}\n`);
102
+ emitKeypressEvents(process.stdin);
103
+ process.stdin.setRawMode?.(true);
104
+ process.stdin.resume();
105
+ render();
106
+ return new Promise((resolveReview) => {
107
+ const finish = (value) => {
108
+ process.stdin.off("keypress", onKey);
109
+ process.stdin.setRawMode?.(false);
110
+ process.stdin.pause();
111
+ resolveReview(value);
112
+ };
113
+ const onKey = async (_input, key) => {
114
+ const segments = icpReviewSegments(current.icp);
115
+ if (key.ctrl && key.name === "c" || key.name === "q")
116
+ return finish(null);
117
+ if (key.name === "up") {
118
+ selected = (selected - 1 + segments.length) % segments.length;
119
+ return render();
120
+ }
121
+ if (key.name === "down") {
122
+ selected = (selected + 1) % segments.length;
123
+ return render();
124
+ }
125
+ if (key.name === "s")
126
+ return finish(current);
127
+ if (key.name !== "return")
128
+ return;
129
+ process.stdin.off("keypress", onKey);
130
+ process.stdin.setRawMode?.(false);
131
+ const segment = segments[selected];
132
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
133
+ const answer = await rl.question(`\n${segment.label} [${segment.value}]: `);
134
+ rl.close();
135
+ if (answer.trim())
136
+ current = { ...current, icp: updateReviewSegment(current.icp, segment.id, answer) };
137
+ emitKeypressEvents(process.stdin);
138
+ process.stdin.setRawMode?.(true);
139
+ process.stdin.on("keypress", onKey);
140
+ render();
141
+ };
142
+ process.stdin.on("keypress", onKey);
143
+ });
144
+ }
26
145
  /**
27
146
  * `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
28
147
  * The CLI can't run AskUserQuestion itself; `icp interview` emits the question
@@ -36,6 +155,8 @@ export async function icpCommand(args) {
36
155
  if (!sub || args.includes("--help") || args.includes("-h")) {
37
156
  console.log(`Usage:
38
157
  fullstackgtm icp interview emit the interview spec (an agent drives it with AskUserQuestion)
158
+ fullstackgtm icp derive --domain <site> [--model <id>] [--out icp.json] [--json]
159
+ derive an evidence-backed ICP from a public website (OpenRouter/OpenAI/Anthropic)
39
160
  fullstackgtm icp set <answers.json> [--name <n>] [--out <path>] write icp.json from interview answers
40
161
  fullstackgtm icp show [--icp <path>] render the ICP + the discovery filters it produces
41
162
  fullstackgtm icp judge [--signals-from latest|<label>] [--with-history] [--prompt <path>] [--min-score 0] [--save] rank unjudged signals into send/nurture/skip decisions (timing x fit x memory)
@@ -56,6 +177,39 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
56
177
  }, null, 2));
57
178
  return;
58
179
  }
180
+ if (sub === "derive") {
181
+ const domain = option(rest, "--domain");
182
+ if (!domain)
183
+ throw new Error("Usage: fullstackgtm icp derive --domain <company.com> [--out icp.json] [--json]");
184
+ const llm = await resolveIcpDeriveLlm(rest);
185
+ const status = createStatusLine();
186
+ let derived;
187
+ try {
188
+ derived = await deriveWebsiteIcp({
189
+ domain,
190
+ llm,
191
+ model: option(rest, "--model") ?? undefined,
192
+ onProgress: (event) => status.set(event.message),
193
+ });
194
+ }
195
+ finally {
196
+ status.done();
197
+ }
198
+ if (!rest.includes("--json") && !rest.includes("--no-interactive")) {
199
+ const reviewed = await reviewDerivedIcp(derived);
200
+ if (!reviewed)
201
+ throw new Error("ICP review cancelled; no file was written.");
202
+ derived = reviewed;
203
+ }
204
+ const out = option(rest, "--out");
205
+ if (out) {
206
+ const path = resolve(process.cwd(), out);
207
+ writeFileSync(path, `${JSON.stringify(derived.icp, null, 2)}\n`);
208
+ console.error(`Wrote reviewed ICP to ${path}. Next: fullstackgtm enrich acquire --source clay --icp ${path}`);
209
+ }
210
+ console.log(rest.includes("--json") ? JSON.stringify(derived, null, 2) : renderDerivedIcp(derived));
211
+ return;
212
+ }
59
213
  if (sub === "set") {
60
214
  const file = rest.find((a) => !a.startsWith("--"));
61
215
  if (!file)
package/dist/cli/init.js CHANGED
@@ -13,7 +13,7 @@ import { option } from "./shared.js";
13
13
  export function initCommand(args) {
14
14
  if (args.includes("--help") || args.includes("-h")) {
15
15
  console.log(`Usage:
16
- fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
16
+ fullstackgtm init [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
17
17
 
18
18
  Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
19
19
  icp.json starter ICP (edit, or rebuild with \`icp interview\`)
@@ -25,8 +25,8 @@ docs/recipes.md for the full play set. Existing files are kept unless --force.`)
25
25
  return;
26
26
  }
27
27
  const source = (option(args, "--source") ?? "pipe0");
28
- if (!["pipe0", "explorium", "linkedin"].includes(source)) {
29
- throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
28
+ if (!["pipe0", "explorium", "clay", "linkedin"].includes(source)) {
29
+ throw new Error(`init: --source must be pipe0|explorium|clay|linkedin (got "${source}")`);
30
30
  }
31
31
  const provider = (option(args, "--provider") ?? "hubspot");
32
32
  if (!["hubspot", "salesforce"].includes(provider)) {
package/dist/cli/tam.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
2
2
  /** Provider API key: env override first, then the credential store (`login`). */
3
- export declare function providerKey(provider: "explorium" | "pipe0" | "heyreach" | "theirstack"): string;
3
+ export declare function providerKey(provider: "explorium" | "pipe0" | "clay" | "heyreach" | "theirstack"): string;
4
4
  /**
5
5
  * `tam` — Total Addressable Market mapping. Estimate a defensible universe from
6
6
  * the ICP, then iteratively populate it via scheduled governed acquire runs and
package/dist/cli/tam.js CHANGED
@@ -5,6 +5,7 @@ import { getCredential } from "../credentials.js";
5
5
  import { appendCoverage, computeCoverage, coverageCountsFromSnapshot, classifyCoverage, coverageToText, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamReportToMarkdown } from "../tam.js";
6
6
  import { probeExploriumBusinessCount } from "../connectors/prospectSources.js";
7
7
  import { theirStackCountCompanies, theirStackPullCost, theirStackSearchCompanies } from "../connectors/theirstack.js";
8
+ import { clayApiKey } from "../connectors/clay.js";
8
9
  import { icpToExploriumBusinessFilters, icpToTheirStackFilters } from "../icp.js";
9
10
  import { scheduleCommand } from "./schedule.js";
10
11
  import { loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
@@ -12,6 +13,8 @@ import { unknownSubcommandError } from "./suggest.js";
12
13
  /** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
13
14
  /** Provider API key: env override first, then the credential store (`login`). */
14
15
  export function providerKey(provider) {
16
+ if (provider === "clay")
17
+ return clayApiKey();
15
18
  const envName = provider === "explorium"
16
19
  ? "EXPLORIUM_API_KEY"
17
20
  : provider === "pipe0"
@@ -41,7 +44,7 @@ export async function tamCommand(args) {
41
44
  fullstackgtm tam accounts [--name <n>] [--icp <path>] --source theirstack [--max <n>] [--dry-run | --confirm] [--max-credits <n>] [--usd-per-credit <r>] [--out <file.csv> | --json]
42
45
  fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
43
46
  fullstackgtm tam report [--name <n>] [--out <path>]
44
- fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
47
+ fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--label <l>]
45
48
 
46
49
  Estimate the reachable market FROM your ICP: a real account count × a confirmed
47
50
  ANNUAL ACV (--acv <annual-usd>, or --acv-from-crm --deal-period monthly|quarterly|
@@ -0,0 +1,33 @@
1
+ import type { Prospect } from "./prospectSources.ts";
2
+ export declare const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
3
+ export type ClayKeyValidation = {
4
+ ok: boolean;
5
+ detail: string;
6
+ workspaceId?: string;
7
+ };
8
+ export type ClaySearchSourceType = "people" | "companies";
9
+ export type ClayPeopleSearchPage = {
10
+ prospects: Prospect[];
11
+ hasMore: boolean;
12
+ };
13
+ /** Create a forward-only Clay search iterator. This call does not fetch a page. */
14
+ export declare function createClaySearch(opts: {
15
+ apiKey: string;
16
+ sourceType: ClaySearchSourceType;
17
+ filters: Record<string, unknown>;
18
+ fetchImpl?: typeof fetch;
19
+ apiBaseUrl?: string;
20
+ }): Promise<string>;
21
+ /** Consume the next people page from a Clay search iterator. */
22
+ export declare function runClayPeopleSearchPage(opts: {
23
+ apiKey: string;
24
+ searchId: string;
25
+ limit?: number;
26
+ fetchImpl?: typeof fetch;
27
+ apiBaseUrl?: string;
28
+ }): Promise<ClayPeopleSearchPage>;
29
+ export declare function normalizeClayPerson(value: unknown): Prospect;
30
+ /** Validate a Clay Public API key without creating a search or spending enrichment credits. */
31
+ export declare function validateClayApiKey(apiKey: string, fetchImpl?: typeof fetch, apiBaseUrl?: string): Promise<ClayKeyValidation>;
32
+ /** Resolve Clay credentials using the standard env -> profile-scoped store ladder. */
33
+ export declare function clayApiKey(env?: Record<string, string | undefined>): string;
@@ -0,0 +1,123 @@
1
+ import { getCredential } from "../credentials.js";
2
+ import { ProviderHttpError } from "../providerError.js";
3
+ export const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
4
+ function clayHeaders(apiKey) {
5
+ return { "clay-api-key": apiKey, Accept: "application/json", "Content-Type": "application/json" };
6
+ }
7
+ /** Create a forward-only Clay search iterator. This call does not fetch a page. */
8
+ export async function createClaySearch(opts) {
9
+ const fetchImpl = opts.fetchImpl ?? fetch;
10
+ const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
11
+ const response = await fetchImpl(`${base}/search/filters-mode`, {
12
+ method: "POST",
13
+ headers: clayHeaders(opts.apiKey),
14
+ body: JSON.stringify({ source_type: opts.sourceType, filters: opts.filters }),
15
+ });
16
+ if (!response.ok)
17
+ throw new ProviderHttpError("Clay", "create search", response.status);
18
+ const body = await response.json();
19
+ const searchId = body.search_id ?? body.searchId;
20
+ if (typeof searchId !== "string" || !searchId)
21
+ throw new Error("Clay create search returned no search_id.");
22
+ return searchId;
23
+ }
24
+ /** Consume the next people page from a Clay search iterator. */
25
+ export async function runClayPeopleSearchPage(opts) {
26
+ const fetchImpl = opts.fetchImpl ?? fetch;
27
+ const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
28
+ const limit = Math.max(1, Math.min(opts.limit ?? 25, 100));
29
+ const response = await fetchImpl(`${base}/search/filters-mode/${encodeURIComponent(opts.searchId)}/run`, {
30
+ method: "POST",
31
+ headers: clayHeaders(opts.apiKey),
32
+ body: JSON.stringify({ limit }),
33
+ });
34
+ if (!response.ok)
35
+ throw new ProviderHttpError("Clay", "run people search", response.status);
36
+ const body = await response.json();
37
+ if (!Array.isArray(body.data))
38
+ throw new Error("Clay people search returned an invalid data array.");
39
+ const hasMore = body.has_more ?? body.hasMore;
40
+ if (typeof hasMore !== "boolean")
41
+ throw new Error("Clay people search returned no has_more flag.");
42
+ return { prospects: body.data.map(normalizeClayPerson), hasMore };
43
+ }
44
+ export function normalizeClayPerson(value) {
45
+ const row = value && typeof value === "object" ? value : {};
46
+ const location = row.structured_location && typeof row.structured_location === "object"
47
+ ? row.structured_location
48
+ : {};
49
+ const fullName = stringValue(row.name);
50
+ return {
51
+ firstName: stringValue(row.first_name),
52
+ lastName: stringValue(row.last_name),
53
+ fullName,
54
+ jobTitle: stringValue(row.latest_experience_title),
55
+ companyName: stringValue(row.latest_experience_company),
56
+ companyDomain: bareDomain(stringValue(row.domain)),
57
+ linkedin: normalizeLinkedin(stringValue(row.url)),
58
+ headline: undefined,
59
+ sourceId: normalizeLinkedin(stringValue(row.url)) ?? fullName,
60
+ location: {
61
+ city: stringValue(location.city),
62
+ state: stringValue(location.state),
63
+ region: stringValue(location.region),
64
+ country: stringValue(location.country),
65
+ countryCode: stringValue(location.country_iso),
66
+ },
67
+ };
68
+ }
69
+ function stringValue(value) {
70
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
71
+ }
72
+ function bareDomain(value) {
73
+ return value?.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/.*$/, "").toLowerCase() || undefined;
74
+ }
75
+ function normalizeLinkedin(value) {
76
+ if (!value)
77
+ return undefined;
78
+ const normalized = value.startsWith("http") ? value : `https://${value.replace(/^\/+/, "")}`;
79
+ return normalized.replace(/\/$/, "");
80
+ }
81
+ /** Validate a Clay Public API key without creating a search or spending enrichment credits. */
82
+ export async function validateClayApiKey(apiKey, fetchImpl = fetch, apiBaseUrl = CLAY_PUBLIC_API_BASE) {
83
+ let response;
84
+ try {
85
+ response = await fetchImpl(`${apiBaseUrl.replace(/\/$/, "")}/me`, {
86
+ headers: { "clay-api-key": apiKey, Accept: "application/json" },
87
+ });
88
+ }
89
+ catch (error) {
90
+ const detail = error instanceof Error ? error.message : String(error);
91
+ return { ok: false, detail: `Cannot reach the Clay Public API: ${detail}` };
92
+ }
93
+ if (!response.ok)
94
+ return { ok: false, detail: `Clay rejected the key: HTTP ${response.status}` };
95
+ let body;
96
+ try {
97
+ body = await response.json();
98
+ }
99
+ catch {
100
+ return { ok: false, detail: "Clay accepted the request but returned an invalid JSON response." };
101
+ }
102
+ const record = body && typeof body === "object" ? body : {};
103
+ const workspace = record.workspace && typeof record.workspace === "object"
104
+ ? record.workspace
105
+ : undefined;
106
+ const rawWorkspaceId = record.workspace_id ?? record.workspaceId ?? workspace?.id;
107
+ const workspaceId = typeof rawWorkspaceId === "string" || typeof rawWorkspaceId === "number"
108
+ ? String(rawWorkspaceId)
109
+ : undefined;
110
+ return {
111
+ ok: true,
112
+ detail: workspaceId ? `Key accepted by Clay workspace ${workspaceId}.` : "Key accepted by the Clay Public API.",
113
+ workspaceId,
114
+ };
115
+ }
116
+ /** Resolve Clay credentials using the standard env -> profile-scoped store ladder. */
117
+ export function clayApiKey(env = process.env) {
118
+ const key = env.CLAY_API_KEY ?? getCredential("clay")?.accessToken;
119
+ if (key)
120
+ return key;
121
+ throw new Error('No Clay credentials. Run `clay api-keys create --name "fullstackgtm"`, then pipe the key to ' +
122
+ '`fullstackgtm login clay`, or set CLAY_API_KEY.');
123
+ }
@@ -32,6 +32,18 @@ export type Prospect = {
32
32
  linkedin?: string;
33
33
  /** real work email once resolved (pipe0); never the hashed value */
34
34
  email?: string;
35
+ /** Validated mobile number when a contact provider explicitly returns one. */
36
+ mobile?: string;
37
+ /** Validated business direct dial when distinct from mobile. */
38
+ directDial?: string;
39
+ /** Person geography returned by identity-search providers. */
40
+ location?: {
41
+ city?: string;
42
+ state?: string;
43
+ region?: string;
44
+ country?: string;
45
+ countryCode?: string;
46
+ };
35
47
  /** ICP fit score 0..1, set by the acquire scorer */
36
48
  fitScore?: number;
37
49
  /** provider-native id for traceability */
@@ -313,7 +313,7 @@ function fieldValue(field) {
313
313
  return typeof v === "string" && v.trim() ? v : undefined;
314
314
  }
315
315
  // ---------------------------------------------------------------------------
316
- // Pre-email dedup: identity keys shared between prospects and CRM contacts, so
316
+ // Pre-enrichment dedup: identity keys shared between prospects and CRM contacts, so
317
317
  // we can drop already-in-CRM / already-seen people BEFORE paying for emails.
318
318
  function normName(value) {
319
319
  return (value ?? "").trim().toLowerCase().replace(/\s+/g, " ");
@@ -0,0 +1,44 @@
1
+ import type { Prospect } from "./connectors/prospectSources.ts";
2
+ export type ContactField = "work_email" | "mobile" | "direct_dial";
3
+ export type ContactInputShape = "linkedin" | "name_domain" | "email" | "phone" | "company_domain";
4
+ export type ContactProviderExecution = "sync" | "poll" | "webhook" | "batch";
5
+ export type ContactProviderBilling = "per_attempt" | "per_match" | "per_field" | "subscription" | "unknown";
6
+ export type ContactProviderCapability = {
7
+ provider: string;
8
+ operation: string;
9
+ inputShapes: ContactInputShape[];
10
+ outputFields: ContactField[];
11
+ execution: ContactProviderExecution;
12
+ billing: ContactProviderBilling;
13
+ chargesOn: Array<"request" | "match" | "valid" | "accept_all" | "unknown">;
14
+ supportsBalance: boolean;
15
+ supportsIdempotency: boolean;
16
+ maxBatchSize?: number;
17
+ };
18
+ /** Only implemented adapters belong here; planned providers stay in the strategy document. */
19
+ export declare const CONTACT_PROVIDER_CAPABILITIES: Readonly<Record<string, readonly ContactProviderCapability[]>>;
20
+ export type ContactWaterfallStep = {
21
+ provider: string;
22
+ fields: ContactField[];
23
+ };
24
+ export type ContactProviderAdapter = {
25
+ provider: string;
26
+ resolve(prospects: Prospect[], fields: ContactField[]): Promise<Prospect[]>;
27
+ };
28
+ export type ContactWaterfallAttempt = {
29
+ provider: string;
30
+ fields: ContactField[];
31
+ attempted: number;
32
+ added: Partial<Record<ContactField, number>>;
33
+ };
34
+ export type ContactWaterfallResult = {
35
+ prospects: Prospect[];
36
+ attempts: ContactWaterfallAttempt[];
37
+ };
38
+ export declare function validateContactWaterfall(steps: ContactWaterfallStep[]): ContactWaterfallStep[];
39
+ /** Run providers in order. Later steps receive only records still missing a requested field. */
40
+ export declare function runContactWaterfall(opts: {
41
+ prospects: Prospect[];
42
+ steps: ContactWaterfallStep[];
43
+ adapters: Readonly<Record<string, ContactProviderAdapter>>;
44
+ }): Promise<ContactWaterfallResult>;
@@ -0,0 +1,100 @@
1
+ /** Only implemented adapters belong here; planned providers stay in the strategy document. */
2
+ export const CONTACT_PROVIDER_CAPABILITIES = {
3
+ pipe0: [{
4
+ provider: "pipe0",
5
+ operation: "person:workemail:waterfall@1",
6
+ inputShapes: ["name_domain"],
7
+ outputFields: ["work_email"],
8
+ execution: "batch",
9
+ billing: "per_attempt",
10
+ chargesOn: ["request"],
11
+ supportsBalance: false,
12
+ supportsIdempotency: false,
13
+ maxBatchSize: 100,
14
+ }],
15
+ };
16
+ export function validateContactWaterfall(steps) {
17
+ if (!Array.isArray(steps) || steps.length === 0) {
18
+ throw new Error("contact waterfall must contain at least one provider step");
19
+ }
20
+ return steps.map((step, index) => {
21
+ if (!step || typeof step.provider !== "string" || !step.provider.trim()) {
22
+ throw new Error(`contact waterfall step ${index + 1}: provider must be a non-empty string`);
23
+ }
24
+ const capabilities = CONTACT_PROVIDER_CAPABILITIES[step.provider];
25
+ if (!capabilities) {
26
+ throw new Error(`contact waterfall step ${index + 1}: unsupported provider "${step.provider}" ` +
27
+ `(implemented: ${Object.keys(CONTACT_PROVIDER_CAPABILITIES).join(", ")})`);
28
+ }
29
+ if (!Array.isArray(step.fields) || step.fields.length === 0) {
30
+ throw new Error(`contact waterfall step ${index + 1}: fields must be a non-empty array`);
31
+ }
32
+ const fields = [...new Set(step.fields)];
33
+ for (const field of fields) {
34
+ if (field !== "work_email" && field !== "mobile" && field !== "direct_dial") {
35
+ throw new Error(`contact waterfall step ${index + 1}: unsupported field "${String(field)}"`);
36
+ }
37
+ if (!capabilities.some((capability) => capability.outputFields.includes(field))) {
38
+ const available = [...new Set(capabilities.flatMap((capability) => capability.outputFields))];
39
+ throw new Error(`contact waterfall step ${index + 1}: ${step.provider} does not implement "${field}" ` +
40
+ `(available: ${available.join(", ")})`);
41
+ }
42
+ }
43
+ return { provider: step.provider, fields };
44
+ });
45
+ }
46
+ /** Run providers in order. Later steps receive only records still missing a requested field. */
47
+ export async function runContactWaterfall(opts) {
48
+ const steps = validateContactWaterfall(opts.steps);
49
+ const prospects = opts.prospects.map((prospect) => ({ ...prospect }));
50
+ const attempts = [];
51
+ for (const step of steps) {
52
+ const adapter = opts.adapters[step.provider];
53
+ if (!adapter)
54
+ throw new Error(`contact waterfall: adapter "${step.provider}" is not configured`);
55
+ const indexes = prospects
56
+ .map((prospect, index) => step.fields.some((field) => !fieldValue(prospect, field)) ? index : -1)
57
+ .filter((index) => index >= 0);
58
+ if (indexes.length === 0) {
59
+ attempts.push({ provider: step.provider, fields: step.fields, attempted: 0, added: {} });
60
+ continue;
61
+ }
62
+ const inputs = indexes.map((index) => prospects[index]);
63
+ const outputs = await adapter.resolve(inputs, step.fields);
64
+ if (outputs.length !== inputs.length) {
65
+ throw new Error(`contact waterfall: ${step.provider} returned ${outputs.length} result(s) for ${inputs.length} input(s)`);
66
+ }
67
+ const added = {};
68
+ outputs.forEach((output, outputIndex) => {
69
+ const prospectIndex = indexes[outputIndex];
70
+ const current = prospects[prospectIndex];
71
+ let merged = current;
72
+ for (const field of step.fields) {
73
+ if (fieldValue(current, field))
74
+ continue;
75
+ const value = fieldValue(output, field);
76
+ if (!value)
77
+ continue;
78
+ merged = setFieldValue(merged, field, value);
79
+ added[field] = (added[field] ?? 0) + 1;
80
+ }
81
+ prospects[prospectIndex] = merged;
82
+ });
83
+ attempts.push({ provider: step.provider, fields: step.fields, attempted: inputs.length, added });
84
+ }
85
+ return { prospects, attempts };
86
+ }
87
+ function fieldValue(prospect, field) {
88
+ if (field === "work_email")
89
+ return prospect.email;
90
+ if (field === "mobile")
91
+ return prospect.mobile;
92
+ return prospect.directDial;
93
+ }
94
+ function setFieldValue(prospect, field, value) {
95
+ if (field === "work_email")
96
+ return { ...prospect, email: value };
97
+ if (field === "mobile")
98
+ return { ...prospect, mobile: value };
99
+ return { ...prospect, directDial: value };
100
+ }
package/dist/enrich.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { AcquireBudget } from "./acquireMeter.ts";
2
2
  import type { ProgressEmitter } from "./progress.ts";
3
3
  import type { AssignmentContext, AssignmentPolicy } from "./assign.ts";
4
+ import { type ContactWaterfallStep } from "./contactProviders.ts";
4
5
  import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
5
6
  /**
6
7
  * The enrich layer: governed append/refresh of third-party data into the CRM.
@@ -83,11 +84,16 @@ export type AcquireCreateMap = {
83
84
  * real emails (e.g. explorium discovers, pipe0 resolves the work email).
84
85
  */
85
86
  export type AcquireDiscoveryConfig = {
86
- provider: "explorium" | "pipe0" | "linkedin" | "heyreach";
87
+ provider: "explorium" | "pipe0" | "clay" | "linkedin" | "heyreach";
88
+ /** Clay search collection. Phase 1 supports people; companies follows separately. */
89
+ sourceType?: "people" | "companies";
87
90
  filters?: Record<string, unknown>;
88
91
  size?: number;
89
92
  /** Maximum raw provider candidates scanned per run while seeking fresh leads. */
90
93
  scanLimit?: number;
94
+ /** Ordered fill-only contact providers. Later steps receive only unresolved fields. */
95
+ contactWaterfall?: ContactWaterfallStep[];
96
+ /** @deprecated Use contactWaterfall. Retained for existing configurations. */
91
97
  resolveEmailsWith?: "pipe0";
92
98
  /** LinkedIn sources only: the provider-native list id to read (HeyReach lead-list id). */
93
99
  listId?: string;