fullstackgtm 0.52.4 → 0.53.1
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 +32 -0
- package/dist/cli/enrich.js +70 -24
- package/dist/cli/ui.js +6 -0
- package/dist/icp.d.ts +8 -0
- package/dist/icp.js +44 -2
- package/package.json +1 -1
- package/src/cli/enrich.ts +69 -25
- package/src/cli/ui.ts +6 -0
- package/src/icp.ts +46 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,38 @@ 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.1] — 2026-07-11
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Human-readable `enrich acquire` output shows compact lead cards by default
|
|
15
|
+
before the governed plan preview; `--verbose` remains the full forensic dump.
|
|
16
|
+
- Acquisition budget counters are labeled as applied-create policy headroom,
|
|
17
|
+
explicitly distinguished from market size, and no longer duplicated by a
|
|
18
|
+
second gauge in compact output.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Multi-line progress renderers reserve their terminal rows before painting,
|
|
23
|
+
preventing partial spinner frames from scrolling into final output.
|
|
24
|
+
|
|
25
|
+
## [0.53.0] — 2026-07-11
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- Clay acquisition progressively relaxes over-constrained searches through
|
|
30
|
+
exact, account-first, persona-first, title-first, and function-first routes;
|
|
31
|
+
every broader candidate still passes through local ICP fit scoring.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Clay geography translation expands broad regions such as North America,
|
|
36
|
+
Europe, and APAC into valid country filters and treats global/worldwide as
|
|
37
|
+
an unconstrained geography rather than a fake country.
|
|
38
|
+
- Phrase-style model-derived titles can qualify equivalent buyer functions,
|
|
39
|
+
allowing roles such as `VP, Global Brand Strategy` to match `Head of Brand`
|
|
40
|
+
without weakening the configured fit threshold.
|
|
41
|
+
|
|
10
42
|
## [0.52.4] — 2026-07-11
|
|
11
43
|
|
|
12
44
|
### Fixed
|
package/dist/cli/enrich.js
CHANGED
|
@@ -17,13 +17,13 @@ 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";
|
|
24
24
|
import { providerKey } from "./tam.js";
|
|
25
25
|
import { unknownSubcommandError } from "./suggest.js";
|
|
26
|
-
import { colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint } from "./ui.js";
|
|
26
|
+
import { box, colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, truncateToWidth } from "./ui.js";
|
|
27
27
|
import { compactPlan, verbosePlanRequested } from "./planOutput.js";
|
|
28
28
|
/**
|
|
29
29
|
* The enrich layer: governed append/refresh of third-party data (Apollo pull,
|
|
@@ -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 " +
|
|
@@ -760,8 +785,8 @@ function acquireGaugeLine(headroom, budget, p) {
|
|
|
760
785
|
const painter = fraction >= 0.9 ? p.red : fraction >= 0.7 ? p.yellow : p.green;
|
|
761
786
|
parts.push(`${p.dim(label)} ${painter(formatBar(fraction, 10))} ${fmt(used)}/${fmt(cap)}`);
|
|
762
787
|
};
|
|
763
|
-
segment("
|
|
764
|
-
segment("
|
|
788
|
+
segment("applied/day", headroom.records.day, budget.records?.perDay, String);
|
|
789
|
+
segment("applied/mo", headroom.records.month, budget.records?.perMonth, String);
|
|
765
790
|
segment("spend/day", headroom.spendUsd.day, budget.spend?.perDay, (value) => `$${value.toFixed(2)}`);
|
|
766
791
|
segment("spend/mo", headroom.spendUsd.month, budget.spend?.perMonth, (value) => `$${value.toFixed(2)}`);
|
|
767
792
|
return parts.length > 0 ? parts.join(" · ") : null;
|
|
@@ -770,10 +795,33 @@ function formatAcquireMeter(headroom, costPerRecord) {
|
|
|
770
795
|
const n = (v) => (v === null ? "∞" : String(v));
|
|
771
796
|
const money = (v) => (v === null ? "∞" : `$${v.toFixed(2)}`);
|
|
772
797
|
const max = headroom.maxRecords === null ? "unlimited" : `${headroom.maxRecords}`;
|
|
773
|
-
return
|
|
774
|
-
|
|
775
|
-
`
|
|
776
|
-
`(≈$${costPerRecord.toFixed(2)}/lead)
|
|
798
|
+
return `Apply budget (not market size) — up to ${max} more create(s) may be applied now; ` +
|
|
799
|
+
`${n(headroom.records.day)} left today, ${n(headroom.records.month)} this month · ` +
|
|
800
|
+
`spend headroom ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
|
|
801
|
+
`(≈$${costPerRecord.toFixed(2)}/lead). Dry runs do not consume this budget.`;
|
|
802
|
+
}
|
|
803
|
+
function renderAcquireLeadCards(result) {
|
|
804
|
+
const p = paint(colorEnabled(process.stdout));
|
|
805
|
+
const evidence = new Map((result.plan.evidence ?? []).map((item) => [item.id, item]));
|
|
806
|
+
return result.plan.operations.map((operation, index) => {
|
|
807
|
+
const payload = operation.afterValue;
|
|
808
|
+
const props = payload.properties ?? {};
|
|
809
|
+
const name = [props.firstname, props.lastname].filter(Boolean).join(" ") || payload.matchValue;
|
|
810
|
+
const title = props.jobtitle || "Title unavailable";
|
|
811
|
+
const company = props.company || payload.associateCompanyName || "Company unavailable";
|
|
812
|
+
let fit;
|
|
813
|
+
const item = operation.evidenceIds?.[0] ? evidence.get(operation.evidenceIds[0]) : undefined;
|
|
814
|
+
try {
|
|
815
|
+
fit = Number(JSON.parse(item?.text ?? "{}").fitScore);
|
|
816
|
+
}
|
|
817
|
+
catch { /* malformed evidence remains display-only */ }
|
|
818
|
+
const lines = [
|
|
819
|
+
truncateToWidth(name, 84),
|
|
820
|
+
truncateToWidth(`${title} · ${company}`, 84),
|
|
821
|
+
truncateToWidth([payload.associateCompanyDomain, props.hs_linkedin_url].filter(Boolean).join(" · "), 84),
|
|
822
|
+
];
|
|
823
|
+
return box(lines, p, `Lead ${index + 1}/${result.plan.operations.length}${Number.isFinite(fit) ? ` · ${Math.round((fit ?? 0) * 100)}% fit` : ""}`).join("\n");
|
|
824
|
+
}).join("\n");
|
|
777
825
|
}
|
|
778
826
|
function printAcquireOutput(options) {
|
|
779
827
|
const { args, result, meter, meterLine, gaugeLine, saved, planSaved, hostedPlanUrl } = options;
|
|
@@ -802,15 +850,13 @@ function printAcquireOutput(options) {
|
|
|
802
850
|
console.log(` Plan ${hostedPlanUrl}`);
|
|
803
851
|
}
|
|
804
852
|
else if (planSaved || !saved) {
|
|
853
|
+
if (result.plan.operations.length > 0)
|
|
854
|
+
console.log(renderAcquireLeadCards(result));
|
|
805
855
|
console.log(compactPlan(result.plan, { saved: planSaved }));
|
|
806
856
|
console.log(meterLine);
|
|
807
|
-
if (gaugeLine)
|
|
808
|
-
console.log(gaugeLine);
|
|
809
857
|
}
|
|
810
858
|
else {
|
|
811
859
|
console.log(meterLine);
|
|
812
|
-
if (gaugeLine)
|
|
813
|
-
console.log(gaugeLine);
|
|
814
860
|
console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
|
|
815
861
|
}
|
|
816
862
|
if (planSaved) {
|
package/dist/cli/ui.js
CHANGED
|
@@ -266,6 +266,12 @@ export function createChecklist(items, stream = process.stderr, env = process.en
|
|
|
266
266
|
// newline on every repaint can scroll the old top row into permanent
|
|
267
267
|
// history when the board sits at the bottom of the terminal, producing
|
|
268
268
|
// apparent duplicate/errored frames in terminal captures.
|
|
269
|
+
// Reserve the board's rows before the first paint. Writing populated rows
|
|
270
|
+
// directly at the terminal bottom can scroll the first rows into history,
|
|
271
|
+
// after which cursor-up repaints target the wrong lines and leave fragments.
|
|
272
|
+
if (painted === 0 && lines.length > 1) {
|
|
273
|
+
stream.write(`${"\n".repeat(lines.length - 1)}\u001b[${lines.length - 1}A`);
|
|
274
|
+
}
|
|
269
275
|
const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
270
276
|
stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}`);
|
|
271
277
|
painted = lines.length;
|
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.1",
|
|
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,14 +18,14 @@ 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
|
-
import type { CanonicalGtmSnapshot } from "../types.ts";
|
|
23
|
+
import type { CanonicalGtmSnapshot, CreateRecordPayload } from "../types.ts";
|
|
24
24
|
import { isSpoolPath, readSpoolPath } from "../spoolFiles.ts";
|
|
25
25
|
import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.ts";
|
|
26
26
|
import { providerKey } from "./tam.ts";
|
|
27
27
|
import { unknownSubcommandError } from "./suggest.ts";
|
|
28
|
-
import { colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, type Paint } from "./ui.ts";
|
|
28
|
+
import { box, colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, truncateToWidth, type Paint } from "./ui.ts";
|
|
29
29
|
import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
|
|
30
30
|
import type { AcquireBudget } from "../acquireMeter.ts";
|
|
31
31
|
|
|
@@ -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. ` +
|
|
@@ -877,8 +903,8 @@ function acquireGaugeLine(headroom: AcquireRemaining, budget: AcquireBudget, p:
|
|
|
877
903
|
const painter = fraction >= 0.9 ? p.red : fraction >= 0.7 ? p.yellow : p.green;
|
|
878
904
|
parts.push(`${p.dim(label)} ${painter(formatBar(fraction, 10))} ${fmt(used)}/${fmt(cap)}`);
|
|
879
905
|
};
|
|
880
|
-
segment("
|
|
881
|
-
segment("
|
|
906
|
+
segment("applied/day", headroom.records.day, budget.records?.perDay, String);
|
|
907
|
+
segment("applied/mo", headroom.records.month, budget.records?.perMonth, String);
|
|
882
908
|
segment("spend/day", headroom.spendUsd.day, budget.spend?.perDay, (value) => `$${value.toFixed(2)}`);
|
|
883
909
|
segment("spend/mo", headroom.spendUsd.month, budget.spend?.perMonth, (value) => `$${value.toFixed(2)}`);
|
|
884
910
|
return parts.length > 0 ? parts.join(" · ") : null;
|
|
@@ -888,12 +914,31 @@ function formatAcquireMeter(headroom: AcquireRemaining, costPerRecord: number):
|
|
|
888
914
|
const n = (v: number | null) => (v === null ? "∞" : String(v));
|
|
889
915
|
const money = (v: number | null) => (v === null ? "∞" : `$${v.toFixed(2)}`);
|
|
890
916
|
const max = headroom.maxRecords === null ? "unlimited" : `${headroom.maxRecords}`;
|
|
891
|
-
return (
|
|
892
|
-
|
|
893
|
-
`
|
|
894
|
-
`
|
|
895
|
-
|
|
896
|
-
|
|
917
|
+
return `Apply budget (not market size) — up to ${max} more create(s) may be applied now; ` +
|
|
918
|
+
`${n(headroom.records.day)} left today, ${n(headroom.records.month)} this month · ` +
|
|
919
|
+
`spend headroom ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
|
|
920
|
+
`(≈$${costPerRecord.toFixed(2)}/lead). Dry runs do not consume this budget.`;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function renderAcquireLeadCards(result: ReturnType<typeof buildAcquirePlan>): string {
|
|
924
|
+
const p = paint(colorEnabled(process.stdout));
|
|
925
|
+
const evidence = new Map((result.plan.evidence ?? []).map((item) => [item.id, item]));
|
|
926
|
+
return result.plan.operations.map((operation, index) => {
|
|
927
|
+
const payload = operation.afterValue as CreateRecordPayload;
|
|
928
|
+
const props = payload.properties ?? {};
|
|
929
|
+
const name = [props.firstname, props.lastname].filter(Boolean).join(" ") || payload.matchValue;
|
|
930
|
+
const title = props.jobtitle || "Title unavailable";
|
|
931
|
+
const company = props.company || payload.associateCompanyName || "Company unavailable";
|
|
932
|
+
let fit: number | undefined;
|
|
933
|
+
const item = operation.evidenceIds?.[0] ? evidence.get(operation.evidenceIds[0]) : undefined;
|
|
934
|
+
try { fit = Number((JSON.parse(item?.text ?? "{}") as { fitScore?: unknown }).fitScore); } catch { /* malformed evidence remains display-only */ }
|
|
935
|
+
const lines = [
|
|
936
|
+
truncateToWidth(name, 84),
|
|
937
|
+
truncateToWidth(`${title} · ${company}`, 84),
|
|
938
|
+
truncateToWidth([payload.associateCompanyDomain, props.hs_linkedin_url].filter(Boolean).join(" · "), 84),
|
|
939
|
+
];
|
|
940
|
+
return box(lines, p, `Lead ${index + 1}/${result.plan.operations.length}${Number.isFinite(fit) ? ` · ${Math.round((fit ?? 0) * 100)}% fit` : ""}`).join("\n");
|
|
941
|
+
}).join("\n");
|
|
897
942
|
}
|
|
898
943
|
|
|
899
944
|
function printAcquireOutput(options: {
|
|
@@ -933,12 +978,11 @@ function printAcquireOutput(options: {
|
|
|
933
978
|
console.log(` Budget ${gaugeLine ?? meterLine}`);
|
|
934
979
|
if (hostedPlanUrl) console.log(` Plan ${hostedPlanUrl}`);
|
|
935
980
|
} else if (planSaved || !saved) {
|
|
981
|
+
if (result.plan.operations.length > 0) console.log(renderAcquireLeadCards(result));
|
|
936
982
|
console.log(compactPlan(result.plan, { saved: planSaved }));
|
|
937
983
|
console.log(meterLine);
|
|
938
|
-
if (gaugeLine) console.log(gaugeLine);
|
|
939
984
|
} else {
|
|
940
985
|
console.log(meterLine);
|
|
941
|
-
if (gaugeLine) console.log(gaugeLine);
|
|
942
986
|
console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
|
|
943
987
|
}
|
|
944
988
|
|
package/src/cli/ui.ts
CHANGED
|
@@ -328,6 +328,12 @@ export function createChecklist(
|
|
|
328
328
|
// newline on every repaint can scroll the old top row into permanent
|
|
329
329
|
// history when the board sits at the bottom of the terminal, producing
|
|
330
330
|
// apparent duplicate/errored frames in terminal captures.
|
|
331
|
+
// Reserve the board's rows before the first paint. Writing populated rows
|
|
332
|
+
// directly at the terminal bottom can scroll the first rows into history,
|
|
333
|
+
// after which cursor-up repaints target the wrong lines and leave fragments.
|
|
334
|
+
if (painted === 0 && lines.length > 1) {
|
|
335
|
+
stream.write(`${"\n".repeat(lines.length - 1)}\u001b[${lines.length - 1}A`);
|
|
336
|
+
}
|
|
331
337
|
const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
332
338
|
stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}`);
|
|
333
339
|
painted = lines.length;
|
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}"`);
|