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.
Files changed (50) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/DATA-FLOWS.md +1 -0
  3. package/README.md +41 -0
  4. package/dist/acquireCheckpoint.d.ts +33 -0
  5. package/dist/acquireCheckpoint.js +149 -0
  6. package/dist/acquireLinkedIn.d.ts +2 -0
  7. package/dist/acquireLinkedIn.js +1 -1
  8. package/dist/cli/enrich.js +283 -79
  9. package/dist/cli/help.js +13 -9
  10. package/dist/cli/plans.js +100 -6
  11. package/dist/cli/shared.d.ts +3 -1
  12. package/dist/cli/shared.js +2 -2
  13. package/dist/cli/ui.d.ts +10 -5
  14. package/dist/cli/ui.js +16 -5
  15. package/dist/connectors/linkedin.d.ts +2 -0
  16. package/dist/connectors/linkedin.js +5 -0
  17. package/dist/connectors/prospectSources.d.ts +23 -0
  18. package/dist/connectors/prospectSources.js +21 -3
  19. package/dist/enrich.d.ts +44 -1
  20. package/dist/hostedAcquireCheckpoint.d.ts +43 -0
  21. package/dist/hostedAcquireCheckpoint.js +129 -0
  22. package/dist/hostedPatchPlan.d.ts +87 -0
  23. package/dist/hostedPatchPlan.js +270 -0
  24. package/dist/icp.js +13 -2
  25. package/dist/index.d.ts +4 -1
  26. package/dist/index.js +3 -0
  27. package/dist/planStore.d.ts +14 -0
  28. package/dist/planStore.js +73 -0
  29. package/dist/progress.d.ts +2 -2
  30. package/dist/progress.js +2 -2
  31. package/docs/api.md +24 -0
  32. package/docs/architecture.md +9 -0
  33. package/package.json +1 -1
  34. package/skills/fullstackgtm/SKILL.md +1 -1
  35. package/src/acquireCheckpoint.ts +186 -0
  36. package/src/acquireLinkedIn.ts +3 -1
  37. package/src/cli/enrich.ts +322 -78
  38. package/src/cli/help.ts +14 -9
  39. package/src/cli/plans.ts +95 -6
  40. package/src/cli/shared.ts +2 -1
  41. package/src/cli/ui.ts +18 -9
  42. package/src/connectors/linkedin.ts +6 -0
  43. package/src/connectors/prospectSources.ts +46 -4
  44. package/src/enrich.ts +47 -1
  45. package/src/hostedAcquireCheckpoint.ts +156 -0
  46. package/src/hostedPatchPlan.ts +286 -0
  47. package/src/icp.ts +14 -2
  48. package/src/index.ts +20 -0
  49. package/src/planStore.ts +87 -0
  50. 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, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, type Prospect } from "../connectors/prospectSources.ts";
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 <n>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
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 api = await acquireFromApi(config, source, rest, icp, snapshot, seen);
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
- const headroom = remaining(loadMeter(now), config.acquire.budget, costPerRecord, now);
196
- const explicitMax = numericOption(rest, "--max");
197
- let cap = headroom.maxRecords;
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[0], 0, ACQUIRE_STAGES.length);
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, "append", option(rest, "--run-label"), today);
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
- console.log(meterLine);
313
- if (gaugeLine) console.log(gaugeLine);
314
- if (planIds.length > 0) {
315
- console.log(
316
- `Saved plan ${result.plan.id} — ${result.counts.created} net-new lead(s), est. $${result.estCostUsd.toFixed(2)}. ` +
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
- ): Promise<{ records: EnrichSourceRecord[]; skippedCrm: number; skippedSeen: number; processedKeys: string[] }> {
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 maxOverride = numericOption(rest, "--max");
521
- const size = maxOverride !== undefined ? Math.min(maxOverride, disc.size ?? 25) : disc.size ?? 25;
522
-
523
- // 1. Discover. Filters come from the ICP when one is loaded (the whole point —
524
- // targeted, not random); otherwise from the hand-written disc.filters.
525
- let prospects: Prospect[];
526
- if (disc.provider === "explorium") {
527
- const filters = icp
528
- ? icpToExploriumFilters(icp)
529
- : ((disc.filters ?? {}) as Record<string, { values?: string[]; value?: boolean }>);
530
- prospects = await fetchExploriumProspects({ apiKey: providerKey("explorium"), filters, size });
531
- } else if (disc.provider === "pipe0") {
532
- const filters = icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {});
533
- prospects = await fetchPipe0CrustdataProspects({ apiKey: providerKey("pipe0"), filters, limit: size });
534
- } else if (disc.provider === "linkedin" || disc.provider === "heyreach") {
535
- // LinkedIn reads a pre-populated lead list (not an ICP-driven query); the ICP
536
- // scores the pulled list below. List id: disc.listId or --list <id>.
537
- const listId = disc.listId ?? option(rest, "--list") ?? undefined;
538
- if (!listId) {
539
- throw new Error(
540
- "enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.",
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
- const provider = createLinkedInProvider(disc.provider, providerKey("heyreach"));
544
- prospects = await discoverLinkedInProspects(provider, { sourceId: listId, max: size });
545
- } else {
546
- throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
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
- // Surface a zero-discovery result LOUDLY rather than emit a silent empty plan.
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 ${discoveredCount} discovered prospect(s) scored below the ICP fit threshold ` +
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
- // 2.5 Pre-email dedup — drop prospects already in the CRM (name+domain match
578
- // vs the snapshot) or already processed in a prior run (the seen cache),
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
- prospects = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
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
- prospects = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
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
- return { records, skippedCrm, skippedSeen, processedKeys };
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
  }