fullstackgtm 0.49.0 → 0.50.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 +53 -0
- package/DATA-FLOWS.md +1 -0
- package/README.md +41 -0
- package/dist/acquireCheckpoint.d.ts +33 -0
- package/dist/acquireCheckpoint.js +149 -0
- package/dist/acquireLinkedIn.d.ts +2 -0
- package/dist/acquireLinkedIn.js +1 -1
- package/dist/cli/enrich.js +283 -79
- package/dist/cli/help.js +13 -9
- package/dist/cli/plans.js +100 -6
- package/dist/cli/shared.d.ts +3 -1
- package/dist/cli/shared.js +2 -2
- package/dist/cli/ui.d.ts +10 -5
- package/dist/cli/ui.js +16 -5
- package/dist/connectors/linkedin.d.ts +2 -0
- package/dist/connectors/linkedin.js +5 -0
- package/dist/connectors/prospectSources.d.ts +23 -0
- package/dist/connectors/prospectSources.js +21 -3
- package/dist/enrich.d.ts +44 -1
- package/dist/hostedAcquireCheckpoint.d.ts +43 -0
- package/dist/hostedAcquireCheckpoint.js +129 -0
- package/dist/hostedPatchPlan.d.ts +87 -0
- package/dist/hostedPatchPlan.js +270 -0
- package/dist/icp.js +13 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/planStore.d.ts +14 -0
- package/dist/planStore.js +73 -0
- package/dist/progress.d.ts +2 -2
- package/dist/progress.js +2 -2
- package/docs/api.md +24 -0
- package/docs/architecture.md +9 -0
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +1 -1
- package/src/acquireCheckpoint.ts +186 -0
- package/src/acquireLinkedIn.ts +3 -1
- package/src/cli/enrich.ts +322 -78
- package/src/cli/help.ts +14 -9
- package/src/cli/plans.ts +95 -6
- package/src/cli/shared.ts +2 -1
- package/src/cli/ui.ts +18 -9
- package/src/connectors/linkedin.ts +6 -0
- package/src/connectors/prospectSources.ts +46 -4
- package/src/enrich.ts +47 -1
- package/src/hostedAcquireCheckpoint.ts +156 -0
- package/src/hostedPatchPlan.ts +286 -0
- package/src/icp.ts +14 -2
- package/src/index.ts +20 -0
- package/src/planStore.ts +87 -0
- package/src/progress.ts +2 -2
package/src/cli/enrich.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
// Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
|
|
2
2
|
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import { resolve } from "node:path";
|
|
5
6
|
import { getCredential } from "../credentials.ts";
|
|
6
7
|
import { patchPlanToMarkdown } from "../format.ts";
|
|
7
8
|
import { createFilePlanStore } from "../planStore.ts";
|
|
8
9
|
import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, type EnrichConfig, type EnrichCounts, type EnrichObjectType, type EnrichRun, type EnrichRunStore, type EnrichSourceRecord } from "../enrich.ts";
|
|
9
10
|
import { loadMeter, remaining, type AcquireRemaining } from "../acquireMeter.ts";
|
|
10
|
-
import { crmContactKeys, fetchExploriumProspects,
|
|
11
|
+
import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspectPage, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, type Prospect } from "../connectors/prospectSources.ts";
|
|
11
12
|
import { loadSeen, recordSeen } from "../acquireSeen.ts";
|
|
13
|
+
import { acquireCheckpointId, createFileAcquireCheckpointStore, type AcquireCheckpointKey } from "../acquireCheckpoint.ts";
|
|
14
|
+
import { readHostedAcquireCheckpoint, writeHostedAcquireCheckpoint } from "../hostedAcquireCheckpoint.ts";
|
|
15
|
+
import { uploadHostedPatchPlan } from "../hostedPatchPlan.ts";
|
|
12
16
|
import { progressReporter, reportCounts, reportEvent } from "../runReport.ts";
|
|
13
17
|
import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
|
|
14
18
|
import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.ts";
|
|
@@ -43,7 +47,7 @@ enrich append [--source apollo] [--objects companies,contacts] [--save] [--conf
|
|
|
43
47
|
enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
|
|
44
48
|
[source options] [--run-label <label>] [--json]
|
|
45
49
|
enrich ingest <file.csv|payload.json|spool.jsonl|spool-dir> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
|
|
46
|
-
enrich acquire [--source <id>] [--max <
|
|
50
|
+
enrich acquire [--source <id>] [--max <new-leads>] [--scan-limit <candidates>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
|
|
47
51
|
enrich status [--runs] [--source <id>] [--config <path>] [--json]
|
|
48
52
|
|
|
49
53
|
acquire creates NET-NEW leads from a staged prospect list (ingest first):
|
|
@@ -53,6 +57,9 @@ it dedupes each sourced row against the CRM, skips matches and ambiguities
|
|
|
53
57
|
remaining budget (records + spend, per day and per month; whichever is hit
|
|
54
58
|
first). Approval-gated like every write: \`--save\` → plans approve → apply.
|
|
55
59
|
The meter is charged only when a create actually lands at apply.
|
|
60
|
+
\`--max\` controls the desired net-new plan size; \`--scan-limit\` separately
|
|
61
|
+
bounds raw candidates scanned after ICP and dedupe filtering. Continuation is
|
|
62
|
+
persisted per query so scheduled runs advance instead of rereading page one.
|
|
56
63
|
Discovery sources (zero-config presets): explorium | pipe0 (ICP-driven search),
|
|
57
64
|
and linkedin (reads a HeyReach lead list — pass --list <id>, key on the LinkedIn
|
|
58
65
|
URL, $0/record; \`login heyreach\` or HEYREACH_API_KEY).
|
|
@@ -141,7 +148,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
141
148
|
|
|
142
149
|
// Prospects come from an API source (net-new discovery, e.g. Explorium +
|
|
143
150
|
// pipe0 work-email) or a staged ingest list (Clay/CSV/webhook).
|
|
144
|
-
const snapshot = await readSnapshot(rest);
|
|
151
|
+
const snapshot = await readSnapshot(rest, undefined, { persistProgress: true });
|
|
145
152
|
const icp = loadIcp(rest);
|
|
146
153
|
if (sourceConfig.kind === "api" && !icp) {
|
|
147
154
|
console.error(
|
|
@@ -159,16 +166,39 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
159
166
|
);
|
|
160
167
|
}
|
|
161
168
|
const seen = loadSeen();
|
|
169
|
+
const now = new Date();
|
|
170
|
+
const costPerRecord = config.acquire.costPerRecord?.[source] ?? 0;
|
|
171
|
+
const headroom = remaining(loadMeter(now), config.acquire.budget, costPerRecord, now);
|
|
172
|
+
const explicitMax = numericOption(rest, "--max");
|
|
173
|
+
const desiredCreates = explicitMax ?? config.acquire.discovery?.[source]?.size ?? 25;
|
|
174
|
+
const cap = headroom.maxRecords === null ? desiredCreates : Math.min(headroom.maxRecords, desiredCreates);
|
|
162
175
|
let records: EnrichSourceRecord[];
|
|
163
176
|
let apiSkippedCrm = 0;
|
|
164
177
|
let apiSkippedSeen = 0;
|
|
165
178
|
let apiProcessedKeys: string[] = [];
|
|
179
|
+
let acquireTelemetry: NonNullable<EnrichRun["acquireTelemetry"]> | undefined;
|
|
180
|
+
let acquireCheckpointSync: { key: AcquireCheckpointKey; hostedVersion: number | null } | undefined;
|
|
181
|
+
const renderer = createProgressRenderer(ACQUIRE_STAGES);
|
|
182
|
+
const acquireProgress = createProgressEmitter(
|
|
183
|
+
composeListeners(renderer.listener, progressReporter()),
|
|
184
|
+
);
|
|
166
185
|
if (sourceConfig.kind === "api") {
|
|
167
|
-
const
|
|
186
|
+
const priorRun = await store.latest({ source, mode: "acquire" });
|
|
187
|
+
acquireProgress.stage(ACQUIRE_STAGES[0], 0, ACQUIRE_STAGES.length);
|
|
188
|
+
acquireProgress.note("loading saved audience position");
|
|
189
|
+
let api: Awaited<ReturnType<typeof acquireFromApi>>;
|
|
190
|
+
try {
|
|
191
|
+
api = await acquireFromApi(config, source, rest, icp, snapshot, seen, priorRun, cap, save, acquireProgress);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
renderer.done({ persist: true });
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
168
196
|
records = api.records;
|
|
169
197
|
apiSkippedCrm = api.skippedCrm;
|
|
170
198
|
apiSkippedSeen = api.skippedSeen;
|
|
171
199
|
apiProcessedKeys = api.processedKeys;
|
|
200
|
+
acquireTelemetry = api.telemetry;
|
|
201
|
+
acquireCheckpointSync = { key: api.checkpointKey, hostedVersion: api.hostedVersion };
|
|
172
202
|
} else {
|
|
173
203
|
const stagedLabel = option(rest, "--staged-run");
|
|
174
204
|
const stagedRun = stagedLabel
|
|
@@ -184,18 +214,15 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
184
214
|
|
|
185
215
|
// Meter: how many MORE leads may we create right now? --max is an
|
|
186
216
|
// additional per-run ceiling, never a way to exceed the budget.
|
|
187
|
-
const now = new Date();
|
|
188
|
-
const costPerRecord = config.acquire.costPerRecord?.[source] ?? 0;
|
|
189
217
|
if (apiSkippedCrm || apiSkippedSeen) {
|
|
190
218
|
console.error(
|
|
191
219
|
`Pre-email dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} seen before paying for emails ` +
|
|
192
220
|
`(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} enrichment saved).`,
|
|
193
221
|
);
|
|
194
222
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (explicitMax !== undefined) cap = cap === null ? explicitMax : Math.min(cap, explicitMax);
|
|
223
|
+
// `cap` is the desired create count bounded by the persistent meter. Raw
|
|
224
|
+
// discovery scanning has its own --scan-limit and may cross many duplicate-
|
|
225
|
+
// heavy pages to fill this target.
|
|
199
226
|
|
|
200
227
|
// Assignment: never create an ownerless lead. An explicit `acquire.assign`
|
|
201
228
|
// policy wins; a `--assign-owner <id>` flag is a quick fixed override;
|
|
@@ -229,13 +256,10 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
229
256
|
// piped) and, via the composed reporter, heartbeat to a paired hosted app.
|
|
230
257
|
// The meter reading (creates vs headroom + budget burn) rides the same
|
|
231
258
|
// emitter, feeding the dashboard's gauge without printing anything new.
|
|
232
|
-
const renderer = createProgressRenderer(ACQUIRE_STAGES);
|
|
233
|
-
const acquireProgress = createProgressEmitter(
|
|
234
|
-
composeListeners(renderer.listener, progressReporter()),
|
|
235
|
-
);
|
|
236
259
|
let result: ReturnType<typeof buildAcquirePlan>;
|
|
237
260
|
try {
|
|
238
|
-
acquireProgress.stage(ACQUIRE_STAGES[
|
|
261
|
+
acquireProgress.stage(ACQUIRE_STAGES[2], 2, ACQUIRE_STAGES.length);
|
|
262
|
+
acquireProgress.note("building governed create plan");
|
|
239
263
|
if (config.acquire.budget?.records?.perDay && headroom.records.day !== null) {
|
|
240
264
|
acquireProgress.meter(
|
|
241
265
|
config.acquire.budget.records.perDay - headroom.records.day,
|
|
@@ -253,7 +277,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
253
277
|
progress: acquireProgress,
|
|
254
278
|
});
|
|
255
279
|
} finally {
|
|
256
|
-
renderer.done();
|
|
280
|
+
renderer.done({ persist: true });
|
|
257
281
|
}
|
|
258
282
|
|
|
259
283
|
if (result.counts.unassigned > 0 && result.counts.created > 0) {
|
|
@@ -293,31 +317,67 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
293
317
|
return;
|
|
294
318
|
}
|
|
295
319
|
|
|
296
|
-
const run = await openEnrichRun(store, source, "
|
|
320
|
+
const run = await openEnrichRun(store, source, "acquire", option(rest, "--run-label"), today);
|
|
297
321
|
const planIds: string[] = [];
|
|
322
|
+
let hostedPlanUrl: string | null = null;
|
|
298
323
|
if (result.plan.operations.length > 0) {
|
|
299
|
-
await createFilePlanStore().save(result.plan);
|
|
324
|
+
const storedPlan = await createFilePlanStore().save(result.plan);
|
|
300
325
|
planIds.push(result.plan.id);
|
|
301
326
|
reportEvent("plan_saved", result.plan.id);
|
|
327
|
+
const mirror = await uploadHostedPatchPlan(storedPlan);
|
|
328
|
+
if (mirror.status === "saved") {
|
|
329
|
+
hostedPlanUrl = mirror.state.url;
|
|
330
|
+
reportEvent("plan_mirrored", mirror.state.patchPlanId);
|
|
331
|
+
} else if (mirror.status === "unavailable" || mirror.status === "conflict") {
|
|
332
|
+
console.error(`Hosted plan sync pending: ${mirror.reason}. The local signed plan remains authoritative.`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (acquireTelemetry) {
|
|
336
|
+
acquireTelemetry.funnel.proposed = result.counts.created;
|
|
337
|
+
acquireTelemetry.funnel.withheldByMeter = result.counts.withheldByMeter;
|
|
302
338
|
}
|
|
303
339
|
await store.update({
|
|
304
340
|
...run,
|
|
305
341
|
completedAt: new Date().toISOString(),
|
|
306
|
-
cursor: null,
|
|
342
|
+
cursor: acquireTelemetry?.discovery.cursor ?? null,
|
|
343
|
+
counts: acquireTelemetry
|
|
344
|
+
? {
|
|
345
|
+
fetched: acquireTelemetry.funnel.discovered,
|
|
346
|
+
matched: acquireTelemetry.funnel.skippedCrm + acquireTelemetry.funnel.skippedSeen,
|
|
347
|
+
unmatched: acquireTelemetry.funnel.resolved,
|
|
348
|
+
ambiguous: result.counts.ambiguous,
|
|
349
|
+
opsEmitted: acquireTelemetry.funnel.proposed,
|
|
350
|
+
}
|
|
351
|
+
: run.counts,
|
|
352
|
+
acquireTelemetry,
|
|
307
353
|
planIds: [...(run.planIds ?? []), ...planIds],
|
|
308
354
|
});
|
|
309
355
|
// Remember everyone we email-resolved this run so the next run skips them
|
|
310
356
|
// pre-email (cross-run credit saver). Committed (--save) runs only.
|
|
311
357
|
if (apiProcessedKeys.length > 0) recordSeen(apiProcessedKeys, now);
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
`Review \`fullstackgtm plans show ${result.plan.id}\`, approve \`fullstackgtm plans approve ${result.plan.id} --operations all\`, ` +
|
|
318
|
-
`then \`fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot\`. The meter is charged only when a create lands at apply.`,
|
|
358
|
+
if (acquireTelemetry && acquireCheckpointSync) {
|
|
359
|
+
await persistAcquireCheckpoint(
|
|
360
|
+
acquireCheckpointSync.key,
|
|
361
|
+
acquireTelemetry.discovery,
|
|
362
|
+
acquireCheckpointSync.hostedVersion,
|
|
319
363
|
);
|
|
364
|
+
}
|
|
365
|
+
if (planIds.length > 0) {
|
|
366
|
+
const hostedRuns = hostedRunsUrl();
|
|
367
|
+
console.log(`\nSaved plan ${result.plan.id}`);
|
|
368
|
+
console.log(` Leads ${result.counts.created} net-new`);
|
|
369
|
+
console.log(` Est. cost $${result.estCostUsd.toFixed(2)}`);
|
|
370
|
+
console.log(` Budget ${gaugeLine ?? meterLine}`);
|
|
371
|
+
if (hostedPlanUrl) console.log(` Plan ${hostedPlanUrl}`);
|
|
372
|
+
if (hostedRuns) console.log(` Observe ${hostedRuns}`);
|
|
373
|
+
console.log("\nNext steps");
|
|
374
|
+
console.log(` Review fullstackgtm plans show ${result.plan.id}`);
|
|
375
|
+
console.log(` Approve fullstackgtm plans approve ${result.plan.id} --operations all`);
|
|
376
|
+
console.log(` Apply fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot`);
|
|
377
|
+
console.log("\nThe meter is charged only when a create lands at apply.");
|
|
320
378
|
} else {
|
|
379
|
+
console.log(meterLine);
|
|
380
|
+
if (gaugeLine) console.log(gaugeLine);
|
|
321
381
|
console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
|
|
322
382
|
}
|
|
323
383
|
return;
|
|
@@ -510,47 +570,165 @@ async function acquireFromApi(
|
|
|
510
570
|
icp: Icp | undefined,
|
|
511
571
|
snapshot: CanonicalGtmSnapshot,
|
|
512
572
|
seen: Set<string>,
|
|
513
|
-
|
|
573
|
+
priorRun: EnrichRun | null,
|
|
574
|
+
targetFresh: number,
|
|
575
|
+
persistCheckpoint: boolean,
|
|
576
|
+
progress: ReturnType<typeof createProgressEmitter>,
|
|
577
|
+
): Promise<{
|
|
578
|
+
records: EnrichSourceRecord[];
|
|
579
|
+
skippedCrm: number;
|
|
580
|
+
skippedSeen: number;
|
|
581
|
+
processedKeys: string[];
|
|
582
|
+
telemetry: NonNullable<EnrichRun["acquireTelemetry"]>;
|
|
583
|
+
checkpointKey: AcquireCheckpointKey;
|
|
584
|
+
hostedVersion: number | null;
|
|
585
|
+
}> {
|
|
514
586
|
const acquire = config.acquire!;
|
|
515
587
|
const disc = acquire.discovery?.[source];
|
|
516
588
|
if (!disc) {
|
|
517
589
|
throw new Error(`enrich acquire: api source "${source}" needs an "acquire.discovery.${source}" config (provider + size).`);
|
|
518
590
|
}
|
|
519
591
|
const matchKey = acquire.create.contact?.matchKey ?? "email";
|
|
520
|
-
const
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
592
|
+
const explicitScanLimit = numericOption(rest, "--scan-limit");
|
|
593
|
+
const scanLimit = Math.max(targetFresh, Math.min(explicitScanLimit ?? disc.scanLimit ?? Math.max(100, targetFresh * 10), 5_000));
|
|
594
|
+
const listId = disc.listId ?? option(rest, "--list") ?? undefined;
|
|
595
|
+
if ((disc.provider === "linkedin" || disc.provider === "heyreach") && !listId) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
"enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.",
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
const filters = disc.provider === "explorium"
|
|
601
|
+
? (icp ? icpToExploriumFilters(icp) : (disc.filters ?? {}))
|
|
602
|
+
: disc.provider === "pipe0"
|
|
603
|
+
? (icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {}))
|
|
604
|
+
: {};
|
|
605
|
+
const queryFingerprint = createHash("sha256")
|
|
606
|
+
.update(JSON.stringify({ source, provider: disc.provider, listId, filters, icp: icp ?? null }))
|
|
607
|
+
.digest("hex");
|
|
608
|
+
const checkpointKey: AcquireCheckpointKey = {
|
|
609
|
+
provider: disc.provider,
|
|
610
|
+
source,
|
|
611
|
+
listId: listId ?? null,
|
|
612
|
+
queryFingerprint,
|
|
613
|
+
};
|
|
614
|
+
const checkpointStore = createFileAcquireCheckpointStore();
|
|
615
|
+
const localCheckpoint = await checkpointStore.get(checkpointKey);
|
|
616
|
+
const hostedRead = await readHostedAcquireCheckpoint(acquireCheckpointId(checkpointKey));
|
|
617
|
+
const hostedRecord = hostedRead.status === "found" ? hostedRead.record : null;
|
|
618
|
+
const hostedVersion = hostedRecord?.version ?? null;
|
|
619
|
+
// Once paired, hosted state lets another authenticated worker resume this
|
|
620
|
+
// audience. A newer offline local checkpoint is still preserved and pushed
|
|
621
|
+
// with CAS; otherwise hosted wins. Legacy run telemetry is a one-time
|
|
622
|
+
// migration source only when neither dedicated store has this exact key.
|
|
623
|
+
let persisted = localCheckpoint?.continuation;
|
|
624
|
+
if (
|
|
625
|
+
hostedRecord &&
|
|
626
|
+
(!localCheckpoint || hostedRecord.updatedAt >= Date.parse(localCheckpoint.updatedAt))
|
|
627
|
+
) {
|
|
628
|
+
persisted = hostedRecord.checkpoint;
|
|
629
|
+
if (persistCheckpoint) {
|
|
630
|
+
await checkpointStore.put(
|
|
631
|
+
checkpointKey,
|
|
632
|
+
{
|
|
633
|
+
cursor: hostedRecord.checkpoint.cursor,
|
|
634
|
+
offset: hostedRecord.checkpoint.offset,
|
|
635
|
+
exhausted: hostedRecord.checkpoint.exhausted,
|
|
636
|
+
},
|
|
637
|
+
new Date(hostedRecord.updatedAt),
|
|
541
638
|
);
|
|
542
639
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
640
|
+
}
|
|
641
|
+
const prior = priorRun?.acquireTelemetry?.discovery;
|
|
642
|
+
const resume = persisted ?? (
|
|
643
|
+
prior?.queryFingerprint === queryFingerprint
|
|
644
|
+
? { cursor: prior.cursor, offset: prior.offset, exhausted: prior.exhausted }
|
|
645
|
+
: undefined
|
|
646
|
+
);
|
|
647
|
+
let cursor = resume?.cursor ?? null;
|
|
648
|
+
let offset = resume?.offset ?? 0;
|
|
649
|
+
let exhausted = resume?.exhausted ?? false;
|
|
650
|
+
let discovered = 0;
|
|
651
|
+
let qualified = 0;
|
|
652
|
+
let skippedCrm = 0;
|
|
653
|
+
let skippedSeen = 0;
|
|
654
|
+
const prospects: Prospect[] = [];
|
|
655
|
+
const crmKeys = crmContactKeys(snapshot);
|
|
656
|
+
const runSeen = new Set(seen);
|
|
657
|
+
|
|
658
|
+
// Pull successive pages until we have enough fresh candidates or hit the
|
|
659
|
+
// bounded scan ceiling. Page size follows the remaining target, so advancing
|
|
660
|
+
// a cursor never silently discards unused candidates from the final page.
|
|
661
|
+
while (prospects.length < targetFresh && discovered < scanLimit && !exhausted) {
|
|
662
|
+
// Cursor/offset providers can shrink the final request exactly. Explorium
|
|
663
|
+
// uses page numbers, so keep page_size stable or page boundaries would
|
|
664
|
+
// overlap/skip as the number of fresh candidates changes.
|
|
665
|
+
const requestSize = Math.max(1, Math.min(
|
|
666
|
+
100,
|
|
667
|
+
disc.provider === "explorium" ? targetFresh : targetFresh - prospects.length,
|
|
668
|
+
scanLimit - discovered,
|
|
669
|
+
));
|
|
670
|
+
let page: Prospect[];
|
|
671
|
+
let exploriumPage: number | null = null;
|
|
672
|
+
if (disc.provider === "pipe0") {
|
|
673
|
+
const result = await fetchPipe0CrustdataProspectPage({
|
|
674
|
+
apiKey: providerKey("pipe0"), filters, limit: requestSize, cursor: cursor ?? undefined,
|
|
675
|
+
});
|
|
676
|
+
page = result.prospects;
|
|
677
|
+
cursor = result.nextCursor;
|
|
678
|
+
exhausted = result.nextCursor === null;
|
|
679
|
+
} else if (disc.provider === "explorium") {
|
|
680
|
+
const pageNumber = offset + 1;
|
|
681
|
+
exploriumPage = pageNumber;
|
|
682
|
+
page = await fetchExploriumProspects({
|
|
683
|
+
apiKey: providerKey("explorium"),
|
|
684
|
+
filters: filters as Record<string, { values?: string[]; value?: boolean }>,
|
|
685
|
+
size: requestSize,
|
|
686
|
+
page: pageNumber,
|
|
687
|
+
});
|
|
688
|
+
exhausted = page.length < requestSize;
|
|
689
|
+
} else if (disc.provider === "linkedin" || disc.provider === "heyreach") {
|
|
690
|
+
const provider = createLinkedInProvider(disc.provider, providerKey("heyreach"));
|
|
691
|
+
page = await discoverLinkedInProspects(provider, {
|
|
692
|
+
sourceId: listId, max: requestSize, cursor: String(offset),
|
|
693
|
+
});
|
|
694
|
+
offset += page.length;
|
|
695
|
+
exhausted = page.length < requestSize;
|
|
696
|
+
} else {
|
|
697
|
+
throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
|
|
698
|
+
}
|
|
699
|
+
discovered += page.length;
|
|
700
|
+
progress.items(discovered, scanLimit);
|
|
701
|
+
if (page.length === 0) {
|
|
702
|
+
exhausted = true;
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
let fitted = page;
|
|
706
|
+
if (icp) {
|
|
707
|
+
const threshold = fitThreshold(icp);
|
|
708
|
+
fitted = page
|
|
709
|
+
.map((p) => ({ ...p, fitScore: scoreProspectAgainstIcp(p, icp).score }))
|
|
710
|
+
.filter((p) => (p.fitScore ?? 0) >= threshold);
|
|
711
|
+
}
|
|
712
|
+
qualified += fitted.length;
|
|
713
|
+
const partition = partitionFreshProspects(fitted, crmKeys, runSeen);
|
|
714
|
+
skippedCrm += partition.skippedCrm;
|
|
715
|
+
skippedSeen += partition.skippedSeen;
|
|
716
|
+
const remainingTarget = targetFresh - prospects.length;
|
|
717
|
+
for (const prospect of partition.fresh) {
|
|
718
|
+
if (prospects.length >= targetFresh) break;
|
|
719
|
+
prospects.push(prospect);
|
|
720
|
+
for (const key of prospectIdentityKeys(prospect)) runSeen.add(key);
|
|
721
|
+
}
|
|
722
|
+
progress.note(
|
|
723
|
+
`${discovered} scanned · ${qualified} qualified · ${prospects.length}/${targetFresh} fresh`,
|
|
724
|
+
);
|
|
725
|
+
// Explorium is page-number based. Only advance after consuming every fresh
|
|
726
|
+
// candidate on that page; otherwise the next run safely re-reads the page
|
|
727
|
+
// and CRM/seen dedupe exposes the remainder instead of losing it.
|
|
728
|
+
if (exploriumPage !== null && partition.fresh.length <= remainingTarget) offset = exploriumPage;
|
|
547
729
|
}
|
|
548
730
|
|
|
549
|
-
|
|
550
|
-
// A provider that returns 0 with no error usually means an over-narrow ICP
|
|
551
|
-
// filter (most often the industry/seniority vocab) — not "no market exists".
|
|
552
|
-
const discoveredCount = prospects.length;
|
|
553
|
-
if (discoveredCount === 0) {
|
|
731
|
+
if (discovered === 0 && !resume) {
|
|
554
732
|
console.error(
|
|
555
733
|
`enrich acquire: ${disc.provider} discovered 0 prospects for this ICP — the plan will be empty. ` +
|
|
556
734
|
"This is usually an over-narrow filter, not an empty market: check the ICP's industry and seniority " +
|
|
@@ -558,28 +736,15 @@ async function acquireFromApi(
|
|
|
558
736
|
"generated discovery filters. (Distinct from dedup: nothing was returned to filter.)",
|
|
559
737
|
);
|
|
560
738
|
}
|
|
561
|
-
|
|
562
|
-
// 2. ICP fit scoring BEFORE paying for emails — only above-threshold prospects
|
|
563
|
-
// proceed to (credit-spending) email resolution.
|
|
564
|
-
if (icp) {
|
|
565
|
-
const threshold = fitThreshold(icp);
|
|
566
|
-
prospects = prospects
|
|
567
|
-
.map((p) => ({ ...p, fitScore: scoreProspectAgainstIcp(p, icp).score }))
|
|
568
|
-
.filter((p) => (p.fitScore ?? 0) >= threshold);
|
|
569
|
-
if (discoveredCount > 0 && prospects.length === 0) {
|
|
739
|
+
if (icp && discovered > 0 && qualified === 0) {
|
|
570
740
|
console.error(
|
|
571
|
-
`enrich acquire: all ${
|
|
741
|
+
`enrich acquire: all ${discovered} discovered prospect(s) scored below the ICP fit threshold ` +
|
|
572
742
|
`(${fitThreshold(icp)}) — none qualified. Loosen the ICP persona or lower scoring.threshold.`,
|
|
573
743
|
);
|
|
574
|
-
}
|
|
575
744
|
}
|
|
576
745
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
// BEFORE paying pipe0 for emails. The email-level dedup (buildAcquirePlan)
|
|
580
|
-
// and apply-time resolve-first remain the precise backstop.
|
|
581
|
-
const { fresh, skippedCrm, skippedSeen } = partitionFreshProspects(prospects, crmContactKeys(snapshot), seen);
|
|
582
|
-
prospects = fresh;
|
|
746
|
+
progress.stage(ACQUIRE_STAGES[1], 1, ACQUIRE_STAGES.length);
|
|
747
|
+
progress.note("checking work-email requirements");
|
|
583
748
|
|
|
584
749
|
// 3. Resolve real work emails. Triggered either when email IS the dedupe key
|
|
585
750
|
// (Explorium's email is hashed, Crustdata returns none) or when a source
|
|
@@ -591,10 +756,16 @@ async function acquireFromApi(
|
|
|
591
756
|
// names. Resolve domains first (pipe0 company:identity) so resolution can
|
|
592
757
|
// actually land — without it, name-only resolution fails for every lead.
|
|
593
758
|
if (prospects.some((p) => !p.companyDomain && p.companyName)) {
|
|
594
|
-
|
|
759
|
+
progress.note(`resolving company domains for ${prospects.length} candidate(s)`);
|
|
760
|
+
const resolved = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
|
|
761
|
+
prospects.splice(0, prospects.length, ...resolved);
|
|
595
762
|
}
|
|
596
|
-
|
|
763
|
+
progress.note(`resolving work emails for ${prospects.length} candidate(s)`);
|
|
764
|
+
const resolved = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
|
|
765
|
+
prospects.splice(0, prospects.length, ...resolved);
|
|
597
766
|
}
|
|
767
|
+
progress.items(prospects.length, prospects.length);
|
|
768
|
+
progress.note(`${prospects.length} candidate(s) ready for planning`);
|
|
598
769
|
|
|
599
770
|
const processedKeys = prospects.flatMap(prospectIdentityKeys);
|
|
600
771
|
const records = prospects
|
|
@@ -608,7 +779,60 @@ async function acquireFromApi(
|
|
|
608
779
|
};
|
|
609
780
|
})
|
|
610
781
|
.filter((record) => Boolean(record.keys[matchKey]));
|
|
611
|
-
|
|
782
|
+
const discovery = { queryFingerprint, cursor, offset, exhausted };
|
|
783
|
+
return {
|
|
784
|
+
records,
|
|
785
|
+
skippedCrm,
|
|
786
|
+
skippedSeen,
|
|
787
|
+
processedKeys,
|
|
788
|
+
telemetry: {
|
|
789
|
+
funnel: {
|
|
790
|
+
discovered,
|
|
791
|
+
qualified,
|
|
792
|
+
skippedCrm,
|
|
793
|
+
skippedSeen,
|
|
794
|
+
resolved: records.length,
|
|
795
|
+
proposed: 0,
|
|
796
|
+
withheldByMeter: 0,
|
|
797
|
+
},
|
|
798
|
+
discovery,
|
|
799
|
+
},
|
|
800
|
+
checkpointKey,
|
|
801
|
+
hostedVersion,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Commit continuation only after the saved plan/run is durable. */
|
|
806
|
+
async function persistAcquireCheckpoint(
|
|
807
|
+
key: AcquireCheckpointKey,
|
|
808
|
+
discovery: NonNullable<EnrichRun["acquireTelemetry"]>["discovery"],
|
|
809
|
+
hostedVersion: number | null,
|
|
810
|
+
): Promise<void> {
|
|
811
|
+
const store = createFileAcquireCheckpointStore();
|
|
812
|
+
await store.put(key, {
|
|
813
|
+
cursor: discovery.cursor,
|
|
814
|
+
offset: discovery.offset,
|
|
815
|
+
exhausted: discovery.exhausted,
|
|
816
|
+
});
|
|
817
|
+
const hostedWrite = await writeHostedAcquireCheckpoint(
|
|
818
|
+
acquireCheckpointId(key),
|
|
819
|
+
discovery,
|
|
820
|
+
hostedVersion,
|
|
821
|
+
);
|
|
822
|
+
if (hostedWrite.status === "conflict" && hostedWrite.current) {
|
|
823
|
+
const current = hostedWrite.current.checkpoint;
|
|
824
|
+
await store.put(
|
|
825
|
+
key,
|
|
826
|
+
{ cursor: current.cursor, offset: current.offset, exhausted: current.exhausted },
|
|
827
|
+
new Date(hostedWrite.current.updatedAt),
|
|
828
|
+
);
|
|
829
|
+
console.error(
|
|
830
|
+
"Acquisition checkpoint advanced concurrently in the hosted workspace; " +
|
|
831
|
+
"the newer hosted continuation was kept locally for the next run.",
|
|
832
|
+
);
|
|
833
|
+
} else if (hostedWrite.status === "unavailable") {
|
|
834
|
+
console.error(`Hosted checkpoint sync unavailable; local continuation retained (${hostedWrite.reason}).`);
|
|
835
|
+
}
|
|
612
836
|
}
|
|
613
837
|
|
|
614
838
|
/**
|
|
@@ -651,6 +875,11 @@ function formatAcquireMeter(headroom: AcquireRemaining, costPerRecord: number):
|
|
|
651
875
|
);
|
|
652
876
|
}
|
|
653
877
|
|
|
878
|
+
function hostedRunsUrl(): string | null {
|
|
879
|
+
const baseUrl = getCredential("broker")?.baseUrl?.replace(/\/+$/, "");
|
|
880
|
+
return baseUrl ? `${baseUrl}/dashboard/runs` : null;
|
|
881
|
+
}
|
|
882
|
+
|
|
654
883
|
function resolveEnrichSource(config: EnrichConfig, rest: string[]): string {
|
|
655
884
|
const requested = option(rest, "--source");
|
|
656
885
|
const declared = Object.keys(config.sources);
|
|
@@ -702,7 +931,7 @@ function apolloApiKey(): string {
|
|
|
702
931
|
async function openEnrichRun(
|
|
703
932
|
store: EnrichRunStore,
|
|
704
933
|
source: string,
|
|
705
|
-
mode: "append" | "refresh",
|
|
934
|
+
mode: "append" | "refresh" | "acquire",
|
|
706
935
|
requestedLabel: string | null,
|
|
707
936
|
today: string,
|
|
708
937
|
): Promise<EnrichRun> {
|
|
@@ -861,6 +1090,7 @@ async function enrichStatus(store: EnrichRunStore, rest: string[], configFile: s
|
|
|
861
1090
|
startedAt: last.startedAt,
|
|
862
1091
|
completedAt: last.completedAt,
|
|
863
1092
|
counts: last.counts,
|
|
1093
|
+
acquireTelemetry: last.acquireTelemetry,
|
|
864
1094
|
planIds: last.planIds,
|
|
865
1095
|
},
|
|
866
1096
|
interrupted: interrupted.map((run) => ({ runLabel: run.runLabel, cursor: run.cursor })),
|
|
@@ -888,6 +1118,20 @@ async function enrichStatus(store: EnrichRunStore, rest: string[], configFile: s
|
|
|
888
1118
|
` ${last.counts.ambiguous} ambiguous, ${last.counts.opsEmitted} ops` +
|
|
889
1119
|
(last.planIds.length ? ` · plans: ${last.planIds.join(", ")}` : ""),
|
|
890
1120
|
);
|
|
1121
|
+
if (last.acquireTelemetry) {
|
|
1122
|
+
const f = last.acquireTelemetry.funnel;
|
|
1123
|
+
const d = last.acquireTelemetry.discovery;
|
|
1124
|
+
console.log(
|
|
1125
|
+
` funnel: ${f.discovered} discovered → ${f.qualified} qualified → ` +
|
|
1126
|
+
`${f.resolved} resolved → ${f.proposed} proposed` +
|
|
1127
|
+
` · skipped ${f.skippedCrm} CRM + ${f.skippedSeen} seen` +
|
|
1128
|
+
(f.withheldByMeter ? ` · ${f.withheldByMeter} meter-withheld` : ""),
|
|
1129
|
+
);
|
|
1130
|
+
console.log(
|
|
1131
|
+
` audience: ${d.exhausted ? "exhausted" : "continuing"}` +
|
|
1132
|
+
`${d.cursor ? " · cursor saved" : d.offset ? ` · offset ${d.offset}` : ""}`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
891
1135
|
for (const run of entry.interrupted) {
|
|
892
1136
|
console.log(` interrupted: ${run.runLabel} at cursor ${run.cursor ?? "(start)"} — re-run with --save to resume`);
|
|
893
1137
|
}
|