fullstackgtm 0.52.3 → 0.53.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 +24 -0
- package/dist/cli/enrich.js +38 -13
- package/dist/cli/icp.js +5 -1
- package/dist/icp.d.ts +8 -0
- package/dist/icp.js +44 -2
- package/package.json +1 -1
- package/src/cli/enrich.ts +39 -13
- package/src/cli/icp.ts +3 -1
- package/src/icp.ts +46 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](https://github.com/fullst
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.53.0] — 2026-07-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Clay acquisition progressively relaxes over-constrained searches through
|
|
15
|
+
exact, account-first, persona-first, title-first, and function-first routes;
|
|
16
|
+
every broader candidate still passes through local ICP fit scoring.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Clay geography translation expands broad regions such as North America,
|
|
21
|
+
Europe, and APAC into valid country filters and treats global/worldwide as
|
|
22
|
+
an unconstrained geography rather than a fake country.
|
|
23
|
+
- Phrase-style model-derived titles can qualify equivalent buyer functions,
|
|
24
|
+
allowing roles such as `VP, Global Brand Strategy` to match `Head of Brand`
|
|
25
|
+
without weakening the configured fit threshold.
|
|
26
|
+
|
|
27
|
+
## [0.52.4] — 2026-07-11
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Saving an interactively reviewed website-derived ICP no longer prints a
|
|
32
|
+
duplicate copy of the card below the file-written confirmation.
|
|
33
|
+
|
|
10
34
|
## [0.52.3] — 2026-07-11
|
|
11
35
|
|
|
12
36
|
### Changed
|
package/dist/cli/enrich.js
CHANGED
|
@@ -17,7 +17,7 @@ import { uploadHostedPatchPlan } from "../hostedPatchPlan.js";
|
|
|
17
17
|
import { progressReporter, reportCounts, reportEvent } from "../runReport.js";
|
|
18
18
|
import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
|
|
19
19
|
import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.js";
|
|
20
|
-
import { fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
|
|
20
|
+
import { clayPeopleFilterRoutes, fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
|
|
21
21
|
import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords } from "../enrichApollo.js";
|
|
22
22
|
import { isSpoolPath, readSpoolPath } from "../spoolFiles.js";
|
|
23
23
|
import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
|
|
@@ -508,7 +508,7 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
|
|
|
508
508
|
if ((disc.provider === "linkedin" || disc.provider === "heyreach") && !listId) {
|
|
509
509
|
throw new Error("enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.");
|
|
510
510
|
}
|
|
511
|
-
|
|
511
|
+
let filters = disc.provider === "explorium"
|
|
512
512
|
? (icp ? icpToExploriumFilters(icp) : (disc.filters ?? {}))
|
|
513
513
|
: disc.provider === "pipe0"
|
|
514
514
|
? (icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {}))
|
|
@@ -559,6 +559,9 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
|
|
|
559
559
|
const prospects = [];
|
|
560
560
|
const crmKeys = crmContactKeys(snapshot);
|
|
561
561
|
const runSeen = new Set(seen);
|
|
562
|
+
const clayRoutes = disc.provider === "clay" && icp ? clayPeopleFilterRoutes(icp) : [];
|
|
563
|
+
let clayRouteIndex = 0;
|
|
564
|
+
const allowClayFallback = !resume;
|
|
562
565
|
// Pull successive pages until we have enough fresh candidates or hit the
|
|
563
566
|
// bounded scan ceiling. Page size follows the remaining target, so advancing
|
|
564
567
|
// a cursor never silently discards unused candidates from the final page.
|
|
@@ -578,18 +581,28 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
|
|
|
578
581
|
exhausted = result.nextCursor === null;
|
|
579
582
|
}
|
|
580
583
|
else if (disc.provider === "clay") {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
584
|
+
while (true) {
|
|
585
|
+
if (!cursor) {
|
|
586
|
+
const route = clayRoutes[clayRouteIndex];
|
|
587
|
+
if (route)
|
|
588
|
+
filters = route.filters;
|
|
589
|
+
progress.note(`creating Clay people-search iterator · ${route?.id ?? "configured"}`);
|
|
590
|
+
cursor = await createClaySearch({ apiKey: providerKey("clay"), sourceType: "people", filters });
|
|
591
|
+
}
|
|
592
|
+
const result = await runClayPeopleSearchPage({ apiKey: providerKey("clay"), searchId: cursor, limit: requestSize });
|
|
593
|
+
page = result.prospects;
|
|
594
|
+
offset += page.length;
|
|
595
|
+
exhausted = !result.hasMore;
|
|
596
|
+
if (page.length > 0 || !allowClayFallback || clayRouteIndex >= clayRoutes.length - 1)
|
|
597
|
+
break;
|
|
598
|
+
const failed = clayRoutes[clayRouteIndex]?.id ?? "configured";
|
|
599
|
+
clayRouteIndex += 1;
|
|
600
|
+
const next = clayRoutes[clayRouteIndex];
|
|
601
|
+
progress.note(`Clay ${failed} route returned 0 · trying ${next.id}`);
|
|
602
|
+
cursor = null;
|
|
603
|
+
offset = 0;
|
|
604
|
+
exhausted = false;
|
|
586
605
|
}
|
|
587
|
-
const result = await runClayPeopleSearchPage({
|
|
588
|
-
apiKey: providerKey("clay"), searchId: cursor, limit: requestSize,
|
|
589
|
-
});
|
|
590
|
-
page = result.prospects;
|
|
591
|
-
offset += page.length;
|
|
592
|
-
exhausted = !result.hasMore;
|
|
593
606
|
}
|
|
594
607
|
else if (disc.provider === "explorium") {
|
|
595
608
|
const pageNumber = offset + 1;
|
|
@@ -639,12 +652,24 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
|
|
|
639
652
|
runSeen.add(key);
|
|
640
653
|
}
|
|
641
654
|
progress.note(`${discovered} scanned · ${qualified} qualified · ${prospects.length}/${targetFresh} fresh`);
|
|
655
|
+
if (disc.provider === "clay" && allowClayFallback && exhausted &&
|
|
656
|
+
prospects.length < targetFresh && clayRouteIndex < clayRoutes.length - 1 && discovered < scanLimit) {
|
|
657
|
+
const failed = clayRoutes[clayRouteIndex]?.id ?? "configured";
|
|
658
|
+
clayRouteIndex += 1;
|
|
659
|
+
progress.note(`Clay ${failed} route did not fill the target · trying ${clayRoutes[clayRouteIndex].id}`);
|
|
660
|
+
cursor = null;
|
|
661
|
+
offset = 0;
|
|
662
|
+
exhausted = false;
|
|
663
|
+
}
|
|
642
664
|
// Explorium is page-number based. Only advance after consuming every fresh
|
|
643
665
|
// candidate on that page; otherwise the next run safely re-reads the page
|
|
644
666
|
// and CRM/seen dedupe exposes the remainder instead of losing it.
|
|
645
667
|
if (exploriumPage !== null && partition.fresh.length <= remainingTarget)
|
|
646
668
|
offset = exploriumPage;
|
|
647
669
|
}
|
|
670
|
+
if (disc.provider === "clay" && clayRouteIndex > 0 && discovered > 0) {
|
|
671
|
+
console.error(`Clay discovery relaxed to ${clayRoutes[clayRouteIndex].id} after narrower routes returned 0; all candidates still passed through local ICP fit scoring.`);
|
|
672
|
+
}
|
|
648
673
|
if (discovered === 0 && !resume) {
|
|
649
674
|
console.error(`enrich acquire: ${disc.provider} discovered 0 prospects for this ICP — the plan will be empty. ` +
|
|
650
675
|
"This is usually an over-narrow filter, not an empty market: check the ICP's industry and seniority " +
|
package/dist/cli/icp.js
CHANGED
|
@@ -225,6 +225,7 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
|
|
|
225
225
|
finally {
|
|
226
226
|
status.done();
|
|
227
227
|
}
|
|
228
|
+
const reviewedInteractively = !rest.includes("--json") && !rest.includes("--no-interactive") && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
228
229
|
if (!rest.includes("--json") && !rest.includes("--no-interactive")) {
|
|
229
230
|
const reviewed = await reviewDerivedIcp(derived);
|
|
230
231
|
if (!reviewed)
|
|
@@ -237,7 +238,10 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
|
|
|
237
238
|
writeFileSync(path, `${JSON.stringify(derived.icp, null, 2)}\n`);
|
|
238
239
|
console.error(`Wrote reviewed ICP to ${path}. Next: fullstackgtm enrich acquire --source clay --icp ${path}`);
|
|
239
240
|
}
|
|
240
|
-
|
|
241
|
+
if (rest.includes("--json"))
|
|
242
|
+
console.log(JSON.stringify(derived, null, 2));
|
|
243
|
+
else if (!reviewedInteractively)
|
|
244
|
+
console.log(renderDerivedIcp(derived));
|
|
241
245
|
return;
|
|
242
246
|
}
|
|
243
247
|
if (sub === "set") {
|
package/dist/icp.d.ts
CHANGED
|
@@ -106,6 +106,14 @@ export declare function icpToTheirStackFilters(icp: Icp): {
|
|
|
106
106
|
export declare function icpToCrustdataFilters(icp: Icp): Record<string, unknown>;
|
|
107
107
|
/** Clay people-search filters using only fields confirmed in the live catalog. */
|
|
108
108
|
export declare function icpToClayPeopleFilters(icp: Icp): Record<string, unknown>;
|
|
109
|
+
export type ClayPeopleFilterRoute = {
|
|
110
|
+
id: "exact" | "account-first" | "persona-first" | "title-first" | "function-first";
|
|
111
|
+
filters: Record<string, unknown>;
|
|
112
|
+
};
|
|
113
|
+
/** Progressive Clay searches for an ICP. Each fallback removes only
|
|
114
|
+
* provider-side AND constraints; the canonical ICP still scores every person
|
|
115
|
+
* locally, so broader discovery does not become broader qualification. */
|
|
116
|
+
export declare function clayPeopleFilterRoutes(icp: Icp): ClayPeopleFilterRoute[];
|
|
109
117
|
export type IcpFit = {
|
|
110
118
|
score: number;
|
|
111
119
|
reasons: string[];
|
package/dist/icp.js
CHANGED
|
@@ -235,6 +235,12 @@ const CLAY_SENIORITY = {
|
|
|
235
235
|
founder: "founder",
|
|
236
236
|
senior: "senior",
|
|
237
237
|
};
|
|
238
|
+
const CLAY_REGION_COUNTRIES = {
|
|
239
|
+
"north america": ["United States", "Canada"],
|
|
240
|
+
europe: ["United Kingdom", "Germany", "France", "Netherlands", "Spain", "Italy", "Ireland", "Sweden", "Switzerland"],
|
|
241
|
+
"asia pacific": ["Australia", "New Zealand", "Singapore", "Japan", "India"],
|
|
242
|
+
apac: ["Australia", "New Zealand", "Singapore", "Japan", "India"],
|
|
243
|
+
};
|
|
238
244
|
/** Clay people-search filters using only fields confirmed in the live catalog. */
|
|
239
245
|
export function icpToClayPeopleFilters(icp) {
|
|
240
246
|
const filters = {};
|
|
@@ -247,7 +253,14 @@ export function icpToClayPeopleFilters(icp) {
|
|
|
247
253
|
if (sizes.length)
|
|
248
254
|
filters.company_sizes = sizes;
|
|
249
255
|
if (icp.firmographics.geos?.length) {
|
|
250
|
-
|
|
256
|
+
const countries = [...new Set(icp.firmographics.geos.flatMap((geo) => {
|
|
257
|
+
const normalized = geo.toLowerCase().trim();
|
|
258
|
+
if (normalized === "global" || normalized === "worldwide")
|
|
259
|
+
return [];
|
|
260
|
+
return CLAY_REGION_COUNTRIES[normalized] ?? [COUNTRY_NAMES[normalized] ?? geo];
|
|
261
|
+
}))];
|
|
262
|
+
if (countries.length)
|
|
263
|
+
filters.location_countries_include = countries;
|
|
251
264
|
}
|
|
252
265
|
// Clay accepts a controlled industry catalog. Never invent a title-cased
|
|
253
266
|
// enum for model-authored labels: unsupported values make the entire search
|
|
@@ -258,6 +271,35 @@ export function icpToClayPeopleFilters(icp) {
|
|
|
258
271
|
filters.company_industries_include = industries;
|
|
259
272
|
return filters;
|
|
260
273
|
}
|
|
274
|
+
const ROLE_STOPWORDS = new Set(["chief", "head", "director", "manager", "senior", "vice", "president", "vp", "cmo", "officer", "lead", "of", "and", "the"]);
|
|
275
|
+
function roleKeywords(icp) {
|
|
276
|
+
return [...new Set([
|
|
277
|
+
...(icp.persona.departments ?? []),
|
|
278
|
+
...(icp.persona.titleKeywords ?? []).flatMap((title) => title.split(/[^a-z0-9]+/i)),
|
|
279
|
+
].map((word) => word.trim().toLowerCase()).filter((word) => word.length >= 4 && !ROLE_STOPWORDS.has(word)))];
|
|
280
|
+
}
|
|
281
|
+
/** Progressive Clay searches for an ICP. Each fallback removes only
|
|
282
|
+
* provider-side AND constraints; the canonical ICP still scores every person
|
|
283
|
+
* locally, so broader discovery does not become broader qualification. */
|
|
284
|
+
export function clayPeopleFilterRoutes(icp) {
|
|
285
|
+
const exact = icpToClayPeopleFilters(icp);
|
|
286
|
+
const without = (...keys) => Object.fromEntries(Object.entries(exact).filter(([key]) => !keys.includes(key)));
|
|
287
|
+
const candidates = [
|
|
288
|
+
{ id: "exact", filters: exact },
|
|
289
|
+
{ id: "account-first", filters: without("job_title_seniority_levels_v2") },
|
|
290
|
+
{ id: "persona-first", filters: without("company_industries_include", "company_sizes") },
|
|
291
|
+
{ id: "title-first", filters: without("company_industries_include", "company_sizes", "job_title_seniority_levels_v2", "location_countries_include") },
|
|
292
|
+
{ id: "function-first", filters: { job_title_keywords: roleKeywords(icp) } },
|
|
293
|
+
];
|
|
294
|
+
const seen = new Set();
|
|
295
|
+
return candidates.filter((route) => {
|
|
296
|
+
const fingerprint = JSON.stringify(route.filters);
|
|
297
|
+
if (seen.has(fingerprint))
|
|
298
|
+
return false;
|
|
299
|
+
seen.add(fingerprint);
|
|
300
|
+
return true;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
261
303
|
const ACRONYMS = new Set(["cro", "coo", "ceo", "cfo", "vp", "crm", "gtm", "saas"]);
|
|
262
304
|
function titleCase(value) {
|
|
263
305
|
return value
|
|
@@ -284,7 +326,7 @@ export function scoreProspectAgainstIcp(prospect, icp) {
|
|
|
284
326
|
let weightSum = 0;
|
|
285
327
|
if (keywords.length) {
|
|
286
328
|
weightSum += 0.6;
|
|
287
|
-
const hit = keywords.find((k) => title.includes(k));
|
|
329
|
+
const hit = keywords.find((k) => title.includes(k)) ?? roleKeywords(icp).find((keyword) => title.includes(keyword));
|
|
288
330
|
if (hit) {
|
|
289
331
|
score += 0.6;
|
|
290
332
|
reasons.push(`title matches ICP keyword "${hit}"`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
|
package/src/cli/enrich.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { uploadHostedPatchPlan } from "../hostedPatchPlan.ts";
|
|
|
18
18
|
import { progressReporter, reportCounts, reportEvent } from "../runReport.ts";
|
|
19
19
|
import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
|
|
20
20
|
import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.ts";
|
|
21
|
-
import { fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp, type Icp } from "../icp.ts";
|
|
21
|
+
import { clayPeopleFilterRoutes, fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp, type Icp } from "../icp.ts";
|
|
22
22
|
import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloPullKey } from "../enrichApollo.ts";
|
|
23
23
|
import type { CanonicalGtmSnapshot } from "../types.ts";
|
|
24
24
|
import { isSpoolPath, readSpoolPath } from "../spoolFiles.ts";
|
|
@@ -589,7 +589,7 @@ async function acquireFromApi(
|
|
|
589
589
|
"enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.",
|
|
590
590
|
);
|
|
591
591
|
}
|
|
592
|
-
|
|
592
|
+
let filters = disc.provider === "explorium"
|
|
593
593
|
? (icp ? icpToExploriumFilters(icp) : (disc.filters ?? {}))
|
|
594
594
|
: disc.provider === "pipe0"
|
|
595
595
|
? (icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {}))
|
|
@@ -648,6 +648,9 @@ async function acquireFromApi(
|
|
|
648
648
|
const prospects: Prospect[] = [];
|
|
649
649
|
const crmKeys = crmContactKeys(snapshot);
|
|
650
650
|
const runSeen = new Set(seen);
|
|
651
|
+
const clayRoutes = disc.provider === "clay" && icp ? clayPeopleFilterRoutes(icp) : [];
|
|
652
|
+
let clayRouteIndex = 0;
|
|
653
|
+
const allowClayFallback = !resume;
|
|
651
654
|
|
|
652
655
|
// Pull successive pages until we have enough fresh candidates or hit the
|
|
653
656
|
// bounded scan ceiling. Page size follows the remaining target, so advancing
|
|
@@ -671,18 +674,26 @@ async function acquireFromApi(
|
|
|
671
674
|
cursor = result.nextCursor;
|
|
672
675
|
exhausted = result.nextCursor === null;
|
|
673
676
|
} else if (disc.provider === "clay") {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
677
|
+
while (true) {
|
|
678
|
+
if (!cursor) {
|
|
679
|
+
const route = clayRoutes[clayRouteIndex];
|
|
680
|
+
if (route) filters = route.filters;
|
|
681
|
+
progress.note(`creating Clay people-search iterator · ${route?.id ?? "configured"}`);
|
|
682
|
+
cursor = await createClaySearch({ apiKey: providerKey("clay"), sourceType: "people", filters });
|
|
683
|
+
}
|
|
684
|
+
const result = await runClayPeopleSearchPage({ apiKey: providerKey("clay"), searchId: cursor, limit: requestSize });
|
|
685
|
+
page = result.prospects;
|
|
686
|
+
offset += page.length;
|
|
687
|
+
exhausted = !result.hasMore;
|
|
688
|
+
if (page.length > 0 || !allowClayFallback || clayRouteIndex >= clayRoutes.length - 1) break;
|
|
689
|
+
const failed = clayRoutes[clayRouteIndex]?.id ?? "configured";
|
|
690
|
+
clayRouteIndex += 1;
|
|
691
|
+
const next = clayRoutes[clayRouteIndex];
|
|
692
|
+
progress.note(`Clay ${failed} route returned 0 · trying ${next.id}`);
|
|
693
|
+
cursor = null;
|
|
694
|
+
offset = 0;
|
|
695
|
+
exhausted = false;
|
|
679
696
|
}
|
|
680
|
-
const result = await runClayPeopleSearchPage({
|
|
681
|
-
apiKey: providerKey("clay"), searchId: cursor, limit: requestSize,
|
|
682
|
-
});
|
|
683
|
-
page = result.prospects;
|
|
684
|
-
offset += page.length;
|
|
685
|
-
exhausted = !result.hasMore;
|
|
686
697
|
} else if (disc.provider === "explorium") {
|
|
687
698
|
const pageNumber = offset + 1;
|
|
688
699
|
exploriumPage = pageNumber;
|
|
@@ -729,12 +740,27 @@ async function acquireFromApi(
|
|
|
729
740
|
progress.note(
|
|
730
741
|
`${discovered} scanned · ${qualified} qualified · ${prospects.length}/${targetFresh} fresh`,
|
|
731
742
|
);
|
|
743
|
+
if (
|
|
744
|
+
disc.provider === "clay" && allowClayFallback && exhausted &&
|
|
745
|
+
prospects.length < targetFresh && clayRouteIndex < clayRoutes.length - 1 && discovered < scanLimit
|
|
746
|
+
) {
|
|
747
|
+
const failed = clayRoutes[clayRouteIndex]?.id ?? "configured";
|
|
748
|
+
clayRouteIndex += 1;
|
|
749
|
+
progress.note(`Clay ${failed} route did not fill the target · trying ${clayRoutes[clayRouteIndex].id}`);
|
|
750
|
+
cursor = null;
|
|
751
|
+
offset = 0;
|
|
752
|
+
exhausted = false;
|
|
753
|
+
}
|
|
732
754
|
// Explorium is page-number based. Only advance after consuming every fresh
|
|
733
755
|
// candidate on that page; otherwise the next run safely re-reads the page
|
|
734
756
|
// and CRM/seen dedupe exposes the remainder instead of losing it.
|
|
735
757
|
if (exploriumPage !== null && partition.fresh.length <= remainingTarget) offset = exploriumPage;
|
|
736
758
|
}
|
|
737
759
|
|
|
760
|
+
if (disc.provider === "clay" && clayRouteIndex > 0 && discovered > 0) {
|
|
761
|
+
console.error(`Clay discovery relaxed to ${clayRoutes[clayRouteIndex].id} after narrower routes returned 0; all candidates still passed through local ICP fit scoring.`);
|
|
762
|
+
}
|
|
763
|
+
|
|
738
764
|
if (discovered === 0 && !resume) {
|
|
739
765
|
console.error(
|
|
740
766
|
`enrich acquire: ${disc.provider} discovered 0 prospects for this ICP — the plan will be empty. ` +
|
package/src/cli/icp.ts
CHANGED
|
@@ -216,6 +216,7 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
|
|
|
216
216
|
} finally {
|
|
217
217
|
status.done();
|
|
218
218
|
}
|
|
219
|
+
const reviewedInteractively = !rest.includes("--json") && !rest.includes("--no-interactive") && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
219
220
|
if (!rest.includes("--json") && !rest.includes("--no-interactive")) {
|
|
220
221
|
const reviewed = await reviewDerivedIcp(derived);
|
|
221
222
|
if (!reviewed) throw new Error("ICP review cancelled; no file was written.");
|
|
@@ -227,7 +228,8 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
|
|
|
227
228
|
writeFileSync(path, `${JSON.stringify(derived.icp, null, 2)}\n`);
|
|
228
229
|
console.error(`Wrote reviewed ICP to ${path}. Next: fullstackgtm enrich acquire --source clay --icp ${path}`);
|
|
229
230
|
}
|
|
230
|
-
|
|
231
|
+
if (rest.includes("--json")) console.log(JSON.stringify(derived, null, 2));
|
|
232
|
+
else if (!reviewedInteractively) console.log(renderDerivedIcp(derived));
|
|
231
233
|
return;
|
|
232
234
|
}
|
|
233
235
|
if (sub === "set") {
|
package/src/icp.ts
CHANGED
|
@@ -273,6 +273,13 @@ const CLAY_SENIORITY: Record<string, string> = {
|
|
|
273
273
|
senior: "senior",
|
|
274
274
|
};
|
|
275
275
|
|
|
276
|
+
const CLAY_REGION_COUNTRIES: Record<string, string[]> = {
|
|
277
|
+
"north america": ["United States", "Canada"],
|
|
278
|
+
europe: ["United Kingdom", "Germany", "France", "Netherlands", "Spain", "Italy", "Ireland", "Sweden", "Switzerland"],
|
|
279
|
+
"asia pacific": ["Australia", "New Zealand", "Singapore", "Japan", "India"],
|
|
280
|
+
apac: ["Australia", "New Zealand", "Singapore", "Japan", "India"],
|
|
281
|
+
};
|
|
282
|
+
|
|
276
283
|
/** Clay people-search filters using only fields confirmed in the live catalog. */
|
|
277
284
|
export function icpToClayPeopleFilters(icp: Icp): Record<string, unknown> {
|
|
278
285
|
const filters: Record<string, unknown> = {};
|
|
@@ -282,7 +289,12 @@ export function icpToClayPeopleFilters(icp: Icp): Record<string, unknown> {
|
|
|
282
289
|
const sizes = [...new Set((icp.firmographics.employeeBands ?? []).map((band) => CLAY_EMPLOYEE_BAND[band.replace(/,/g, "")]).filter(Boolean))];
|
|
283
290
|
if (sizes.length) filters.company_sizes = sizes;
|
|
284
291
|
if (icp.firmographics.geos?.length) {
|
|
285
|
-
|
|
292
|
+
const countries = [...new Set(icp.firmographics.geos.flatMap((geo) => {
|
|
293
|
+
const normalized = geo.toLowerCase().trim();
|
|
294
|
+
if (normalized === "global" || normalized === "worldwide") return [];
|
|
295
|
+
return CLAY_REGION_COUNTRIES[normalized] ?? [COUNTRY_NAMES[normalized] ?? geo];
|
|
296
|
+
}))];
|
|
297
|
+
if (countries.length) filters.location_countries_include = countries;
|
|
286
298
|
}
|
|
287
299
|
// Clay accepts a controlled industry catalog. Never invent a title-cased
|
|
288
300
|
// enum for model-authored labels: unsupported values make the entire search
|
|
@@ -295,6 +307,38 @@ export function icpToClayPeopleFilters(icp: Icp): Record<string, unknown> {
|
|
|
295
307
|
return filters;
|
|
296
308
|
}
|
|
297
309
|
|
|
310
|
+
export type ClayPeopleFilterRoute = { id: "exact" | "account-first" | "persona-first" | "title-first" | "function-first"; filters: Record<string, unknown> };
|
|
311
|
+
|
|
312
|
+
const ROLE_STOPWORDS = new Set(["chief", "head", "director", "manager", "senior", "vice", "president", "vp", "cmo", "officer", "lead", "of", "and", "the"]);
|
|
313
|
+
function roleKeywords(icp: Icp): string[] {
|
|
314
|
+
return [...new Set([
|
|
315
|
+
...(icp.persona.departments ?? []),
|
|
316
|
+
...(icp.persona.titleKeywords ?? []).flatMap((title) => title.split(/[^a-z0-9]+/i)),
|
|
317
|
+
].map((word) => word.trim().toLowerCase()).filter((word) => word.length >= 4 && !ROLE_STOPWORDS.has(word)))];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Progressive Clay searches for an ICP. Each fallback removes only
|
|
321
|
+
* provider-side AND constraints; the canonical ICP still scores every person
|
|
322
|
+
* locally, so broader discovery does not become broader qualification. */
|
|
323
|
+
export function clayPeopleFilterRoutes(icp: Icp): ClayPeopleFilterRoute[] {
|
|
324
|
+
const exact = icpToClayPeopleFilters(icp);
|
|
325
|
+
const without = (...keys: string[]) => Object.fromEntries(Object.entries(exact).filter(([key]) => !keys.includes(key)));
|
|
326
|
+
const candidates: ClayPeopleFilterRoute[] = [
|
|
327
|
+
{ id: "exact", filters: exact },
|
|
328
|
+
{ id: "account-first", filters: without("job_title_seniority_levels_v2") },
|
|
329
|
+
{ id: "persona-first", filters: without("company_industries_include", "company_sizes") },
|
|
330
|
+
{ id: "title-first", filters: without("company_industries_include", "company_sizes", "job_title_seniority_levels_v2", "location_countries_include") },
|
|
331
|
+
{ id: "function-first", filters: { job_title_keywords: roleKeywords(icp) } },
|
|
332
|
+
];
|
|
333
|
+
const seen = new Set<string>();
|
|
334
|
+
return candidates.filter((route) => {
|
|
335
|
+
const fingerprint = JSON.stringify(route.filters);
|
|
336
|
+
if (seen.has(fingerprint)) return false;
|
|
337
|
+
seen.add(fingerprint);
|
|
338
|
+
return true;
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
298
342
|
const ACRONYMS = new Set(["cro", "coo", "ceo", "cfo", "vp", "crm", "gtm", "saas"]);
|
|
299
343
|
function titleCase(value: string): string {
|
|
300
344
|
return value
|
|
@@ -332,7 +376,7 @@ export function scoreProspectAgainstIcp(
|
|
|
332
376
|
|
|
333
377
|
if (keywords.length) {
|
|
334
378
|
weightSum += 0.6;
|
|
335
|
-
const hit = keywords.find((k) => title.includes(k));
|
|
379
|
+
const hit = keywords.find((k) => title.includes(k)) ?? roleKeywords(icp).find((keyword) => title.includes(keyword));
|
|
336
380
|
if (hit) {
|
|
337
381
|
score += 0.6;
|
|
338
382
|
reasons.push(`title matches ICP keyword "${hit}"`);
|