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