fullstackgtm 0.44.0 → 0.45.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 +185 -1
- package/README.md +19 -7
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +80 -0
- package/dist/cli/auth.js +500 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +373 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +788 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +344 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +609 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +454 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +70 -0
- package/dist/cli/shared.js +331 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +121 -0
- package/dist/cli/ui.js +375 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +100 -5130
- package/dist/connector.d.ts +15 -0
- package/dist/connector.js +35 -0
- package/dist/connectors/hubspot.d.ts +3 -1
- package/dist/connectors/hubspot.js +10 -3
- package/dist/connectors/salesforce.d.ts +3 -1
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/signalSources.js +7 -59
- package/dist/icp.d.ts +15 -11
- package/dist/icp.js +19 -36
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/marketClassify.d.ts +2 -0
- package/dist/marketClassify.js +7 -1
- package/dist/mcp-bin.js +2 -2
- package/dist/mcp.js +259 -166
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +254 -1
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/types.d.ts +11 -0
- package/docs/api.md +73 -7
- package/docs/signal-spool-format.md +13 -0
- package/llms.txt +15 -6
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +1 -1
- package/src/audit.ts +27 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +549 -0
- package/src/cli/call.ts +398 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +885 -0
- package/src/cli/fix.ts +372 -0
- package/src/cli/help.ts +664 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +523 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +375 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +426 -0
- package/src/cli.ts +99 -5829
- package/src/connector.ts +46 -0
- package/src/connectors/hubspot.ts +14 -2
- package/src/connectors/salesforce.ts +18 -2
- package/src/connectors/signalSources.ts +7 -56
- package/src/icp.ts +19 -35
- package/src/judge.ts +7 -0
- package/src/marketClassify.ts +8 -1
- package/src/mcp-bin.ts +2 -2
- package/src/mcp.ts +130 -57
- package/src/schedule.ts +310 -3
- package/src/spoolFiles.ts +116 -0
- package/src/types.ts +12 -0
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
// Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { getCredential } from "../credentials.js";
|
|
5
|
+
import { patchPlanToMarkdown } from "../format.js";
|
|
6
|
+
import { createFilePlanStore } from "../planStore.js";
|
|
7
|
+
import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor } from "../enrich.js";
|
|
8
|
+
import { loadMeter, remaining } from "../acquireMeter.js";
|
|
9
|
+
import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys } from "../connectors/prospectSources.js";
|
|
10
|
+
import { loadSeen, recordSeen } from "../acquireSeen.js";
|
|
11
|
+
import { reportCounts, reportEvent } from "../runReport.js";
|
|
12
|
+
import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.js";
|
|
13
|
+
import { fitThreshold, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
|
|
14
|
+
import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords } from "../enrichApollo.js";
|
|
15
|
+
import { isSpoolPath, readSpoolPath } from "../spoolFiles.js";
|
|
16
|
+
import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
|
|
17
|
+
import { providerKey } from "./tam.js";
|
|
18
|
+
import { unknownSubcommandError } from "./suggest.js";
|
|
19
|
+
import { colorEnabled, createStatusLine, formatBar, formatDuration, paint } from "./ui.js";
|
|
20
|
+
/**
|
|
21
|
+
* The enrich layer: governed append/refresh of third-party data (Apollo pull,
|
|
22
|
+
* Clay ingest) into the CRM through the normal dry-run → approval → apply
|
|
23
|
+
* contract. State lives in the profile-scoped run store (checkpoint,
|
|
24
|
+
* staleness ledger, observability in one); scheduling belongs to the
|
|
25
|
+
* horizontal scheduler — enrich owns no cron logic.
|
|
26
|
+
*/
|
|
27
|
+
export async function enrichCommand(args) {
|
|
28
|
+
const [subcommand, ...rest] = args;
|
|
29
|
+
// Catch --help BEFORE config load, credential resolution, or any network
|
|
30
|
+
// call (the 0.14.1/0.18 bug class — `enrich append --help` executing a
|
|
31
|
+
// paid Apollo pull would be its worst recurrence).
|
|
32
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
|
|
33
|
+
console.log(`Usage:
|
|
34
|
+
enrich append [--source apollo] [--objects companies,contacts] [--save] [--config <path>]
|
|
35
|
+
[source options] [--run-label <label>] [--json]
|
|
36
|
+
enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
|
|
37
|
+
[source options] [--run-label <label>] [--json]
|
|
38
|
+
enrich ingest <file.csv|payload.json|spool.jsonl|spool-dir> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
|
|
39
|
+
enrich acquire [--source <id>] [--max <n>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
|
|
40
|
+
enrich status [--runs] [--source <id>] [--config <path>] [--json]
|
|
41
|
+
|
|
42
|
+
acquire creates NET-NEW leads from a staged prospect list (ingest first):
|
|
43
|
+
it dedupes each sourced row against the CRM, skips matches and ambiguities
|
|
44
|
+
(resolve-first never creates over a possible duplicate), and proposes a
|
|
45
|
+
\`create_record\` op per confirmed net-new row — capped by the acquire meter's
|
|
46
|
+
remaining budget (records + spend, per day and per month; whichever is hit
|
|
47
|
+
first). Approval-gated like every write: \`--save\` → plans approve → apply.
|
|
48
|
+
The meter is charged only when a create actually lands at apply.
|
|
49
|
+
Discovery sources (zero-config presets): explorium | pipe0 (ICP-driven search),
|
|
50
|
+
and linkedin (reads a HeyReach lead list — pass --list <id>, key on the LinkedIn
|
|
51
|
+
URL, $0/record; \`login heyreach\` or HEYREACH_API_KEY).
|
|
52
|
+
|
|
53
|
+
Leads are never born ownerless: set an \`acquire.assign\` policy (fixed /
|
|
54
|
+
round-robin / territory / account-owner) to stamp an owner at create time, or
|
|
55
|
+
pass \`--assign-owner <id>\`. With a single-owner portal and no policy, acquire
|
|
56
|
+
defaults every lead to that owner; with several owners and no policy it warns
|
|
57
|
+
and leaves them unassigned. Backfill existing ownerless records with
|
|
58
|
+
\`reassign --assign-unowned --to <ownerId>\`.
|
|
59
|
+
|
|
60
|
+
append pulls from an api source (Apollo — BYO key via \`login apollo\` or
|
|
61
|
+
APOLLO_API_KEY) or reads data staged by \`enrich ingest\` (Clay CSV exports,
|
|
62
|
+
webhook payload JSON), matches source records to CRM records via the ordered
|
|
63
|
+
match keys in enrich.config.json (unique hit wins; zero hits falls through to
|
|
64
|
+
the next key; multiple hits skip or flow into the suggest chain, per
|
|
65
|
+
onAmbiguous), and emits a fill-blanks-only patch plan. Without --save it
|
|
66
|
+
prints the dry-run diff and writes NOTHING; with --save the plan lands in the
|
|
67
|
+
plan store as needs_approval and the run (counts, per-field enrichedAt stamps,
|
|
68
|
+
resume cursor) lands in the profile's enrich run store. From there the normal
|
|
69
|
+
chain takes over: plans approve → apply.
|
|
70
|
+
|
|
71
|
+
refresh computes its work set from the run-store stamps — fields enrich
|
|
72
|
+
itself wrote, opted in with "refresh": true, older than the staleness window
|
|
73
|
+
(--stale-days overrides per-field staleDays and policy.defaultStaleDays) —
|
|
74
|
+
re-fetches the source, and proposes updates only where the source value
|
|
75
|
+
actually changed. Every operation carries beforeValue, so apply-time
|
|
76
|
+
compare-and-set rejects writes over a CRM that moved underneath the plan.
|
|
77
|
+
|
|
78
|
+
Conflict policy (MVP): "never" — enrich only fills blank fields and only
|
|
79
|
+
re-touches fields its own ledger proves it stamped. system-only and always
|
|
80
|
+
are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!["append", "refresh", "ingest", "status", "acquire"].includes(subcommand)) {
|
|
84
|
+
throw unknownSubcommandError("enrich", subcommand, ["append", "refresh", "ingest", "status", "acquire"]);
|
|
85
|
+
}
|
|
86
|
+
const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? ENRICH_CONFIG_FILE_NAME);
|
|
87
|
+
const store = createFileEnrichRunStore();
|
|
88
|
+
if (subcommand === "status") {
|
|
89
|
+
await enrichStatus(store, rest, configPath());
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Config resolution: an explicit --config or an on-disk default always wins
|
|
93
|
+
// (and validates). Only when neither exists do we fall back to a built-in
|
|
94
|
+
// preset for the source (e.g. `--source clay`), so the common Mode-A loop
|
|
95
|
+
// needs no hand-authored config. A present-but-invalid config still errors.
|
|
96
|
+
const explicitConfig = option(rest, "--config");
|
|
97
|
+
const configFile = configPath();
|
|
98
|
+
// No config file + no --config → fall back to a built-in preset so the common
|
|
99
|
+
// paths need zero hand-authored config: `acquire` gets the zero-config acquire
|
|
100
|
+
// preset (targeted/deduped/metered out the gate); other verbs get the source
|
|
101
|
+
// preset (e.g. clay ingest). An explicit/on-disk config always wins.
|
|
102
|
+
const presetFor = (src) => subcommand === "acquire" ? builtinAcquirePreset(src) : builtinEnrichPreset(src);
|
|
103
|
+
const config = !explicitConfig && !existsSync(configFile)
|
|
104
|
+
? (presetFor(option(rest, "--source") ?? undefined) ?? loadEnrichConfig(configFile))
|
|
105
|
+
: loadEnrichConfig(configFile);
|
|
106
|
+
// `enrich ingest` stages rows. If the same command also names a CRM source
|
|
107
|
+
// (--input/--provider), collapse the two-step into one: stage, then run the
|
|
108
|
+
// append against the just-staged data so `enrich ingest clay.csv --source clay
|
|
109
|
+
// --input snap.json` returns the hygiene verdict end-to-end. Without a CRM
|
|
110
|
+
// source it stays stage-only (the existing two-step is preserved).
|
|
111
|
+
if (subcommand === "ingest") {
|
|
112
|
+
const autoAppend = Boolean(option(rest, "--provider") || option(rest, "--input"));
|
|
113
|
+
await enrichIngest(store, config, rest, autoAppend);
|
|
114
|
+
if (!autoAppend)
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (subcommand === "acquire") {
|
|
118
|
+
if (!config.acquire) {
|
|
119
|
+
throw new Error('enrich acquire: config has no "acquire" section. Add e.g. { "acquire": { "create": { "contact": { "matchKey": "email", "properties": { "email": "email", "firstname": "first_name", "lastname": "last_name" } } }, "budget": { "records": { "perDay": 50, "perMonth": 500 } }, "costPerRecord": { "clay": 0.10 } } } to enrich.config.json.');
|
|
120
|
+
}
|
|
121
|
+
const source = resolveEnrichSource(config, rest);
|
|
122
|
+
const sourceConfig = config.sources[source];
|
|
123
|
+
const save = saveRequested(rest);
|
|
124
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
125
|
+
// Prospects come from an API source (net-new discovery, e.g. Explorium +
|
|
126
|
+
// pipe0 work-email) or a staged ingest list (Clay/CSV/webhook).
|
|
127
|
+
const snapshot = await readSnapshot(rest);
|
|
128
|
+
const icp = loadIcp(rest);
|
|
129
|
+
if (sourceConfig.kind === "api" && !icp) {
|
|
130
|
+
console.error("⚠ No ICP loaded — discovery will use the raw discovery.filters and skip fit scoring. " +
|
|
131
|
+
"Develop one with `fullstackgtm icp interview` (or pass --icp <path>) so leads map to your ICP.");
|
|
132
|
+
}
|
|
133
|
+
// Recommend the strong dedup key when the CRM carries no LinkedIn URLs:
|
|
134
|
+
// without them, pre-email dedup falls back to the weaker name+domain match.
|
|
135
|
+
if (sourceConfig.kind === "api" && !(snapshot.contacts ?? []).some((c) => c.linkedin)) {
|
|
136
|
+
console.error("⚠ No LinkedIn URLs found on your contacts — pre-email dedup falls back to name+domain (weaker). " +
|
|
137
|
+
"Populate the HubSpot \"LinkedIn URL\" (hs_linkedin_url) property for exact dedup; " +
|
|
138
|
+
"acquire writes it on every new contact it creates, so coverage grows automatically.");
|
|
139
|
+
}
|
|
140
|
+
const seen = loadSeen();
|
|
141
|
+
let records;
|
|
142
|
+
let apiSkippedCrm = 0;
|
|
143
|
+
let apiSkippedSeen = 0;
|
|
144
|
+
let apiProcessedKeys = [];
|
|
145
|
+
if (sourceConfig.kind === "api") {
|
|
146
|
+
const api = await acquireFromApi(config, source, rest, icp, snapshot, seen);
|
|
147
|
+
records = api.records;
|
|
148
|
+
apiSkippedCrm = api.skippedCrm;
|
|
149
|
+
apiSkippedSeen = api.skippedSeen;
|
|
150
|
+
apiProcessedKeys = api.processedKeys;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
const stagedLabel = option(rest, "--staged-run");
|
|
154
|
+
const stagedRun = stagedLabel
|
|
155
|
+
? await store.get(stagedLabel)
|
|
156
|
+
: await store.latest({ source, mode: "ingest" });
|
|
157
|
+
if (!stagedRun || stagedRun.mode !== "ingest") {
|
|
158
|
+
throw new Error(`No staged data for source "${source}". Stage prospects first: fullstackgtm enrich ingest <prospects.csv|payload.json> --source ${source}`);
|
|
159
|
+
}
|
|
160
|
+
records = stagedSourceRecords(config, source, stagedRun);
|
|
161
|
+
}
|
|
162
|
+
// Meter: how many MORE leads may we create right now? --max is an
|
|
163
|
+
// additional per-run ceiling, never a way to exceed the budget.
|
|
164
|
+
const now = new Date();
|
|
165
|
+
const costPerRecord = config.acquire.costPerRecord?.[source] ?? 0;
|
|
166
|
+
if (apiSkippedCrm || apiSkippedSeen) {
|
|
167
|
+
console.error(`Pre-email dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} seen before paying for emails ` +
|
|
168
|
+
`(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} enrichment saved).`);
|
|
169
|
+
}
|
|
170
|
+
const headroom = remaining(loadMeter(now), config.acquire.budget, costPerRecord, now);
|
|
171
|
+
const explicitMax = numericOption(rest, "--max");
|
|
172
|
+
let cap = headroom.maxRecords;
|
|
173
|
+
if (explicitMax !== undefined)
|
|
174
|
+
cap = cap === null ? explicitMax : Math.min(cap, explicitMax);
|
|
175
|
+
// Assignment: never create an ownerless lead. An explicit `acquire.assign`
|
|
176
|
+
// policy wins; a `--assign-owner <id>` flag is a quick fixed override;
|
|
177
|
+
// otherwise, when the portal has exactly one active owner, default every
|
|
178
|
+
// lead to them. With multiple owners and no policy we refuse to guess —
|
|
179
|
+
// leads are left unassigned and the operator is told to configure a rule.
|
|
180
|
+
const assignOwnerFlag = option(rest, "--assign-owner");
|
|
181
|
+
if (!config.acquire.assign) {
|
|
182
|
+
if (assignOwnerFlag) {
|
|
183
|
+
config.acquire.assign = { strategy: "fixed", ownerId: assignOwnerFlag };
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const activeOwners = (snapshot.users ?? []).filter((u) => u.active !== false);
|
|
187
|
+
if (activeOwners.length === 1) {
|
|
188
|
+
const sole = activeOwners[0].crmId ?? activeOwners[0].id;
|
|
189
|
+
config.acquire.assign = { strategy: "fixed", ownerId: sole };
|
|
190
|
+
console.error(`Assignment: no policy set — defaulting every lead to the portal's sole owner ` +
|
|
191
|
+
`${activeOwners[0].name} (${sole}). Configure "acquire.assign" to route across reps.`);
|
|
192
|
+
}
|
|
193
|
+
else if (activeOwners.length > 1) {
|
|
194
|
+
console.error(`⚠ Assignment: ${activeOwners.length} active owners and no "acquire.assign" policy — ` +
|
|
195
|
+
`leads will be created OWNERLESS. Add an acquire.assign rule (fixed / round-robin / territory) ` +
|
|
196
|
+
`or pass --assign-owner <id> so every lead lands with an owner.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const result = buildAcquirePlan({
|
|
201
|
+
config,
|
|
202
|
+
source,
|
|
203
|
+
snapshot,
|
|
204
|
+
records,
|
|
205
|
+
runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
|
|
206
|
+
maxRecords: cap,
|
|
207
|
+
});
|
|
208
|
+
if (result.counts.unassigned > 0 && result.counts.created > 0) {
|
|
209
|
+
console.error(`⚠ ${result.counts.unassigned}/${result.counts.created} proposed lead(s) have no owner ` +
|
|
210
|
+
`(policy could not place them). They will be created ownerless.`);
|
|
211
|
+
}
|
|
212
|
+
// Observability: headline metrics for the web run timeline (paired users).
|
|
213
|
+
reportCounts({
|
|
214
|
+
sourced: result.counts.fetched,
|
|
215
|
+
created: result.counts.created,
|
|
216
|
+
withheldByMeter: result.counts.withheldByMeter,
|
|
217
|
+
skippedInCrm: apiSkippedCrm,
|
|
218
|
+
skippedSeen: apiSkippedSeen,
|
|
219
|
+
estCostUsd: result.estCostUsd,
|
|
220
|
+
});
|
|
221
|
+
const meterLine = formatAcquireMeter(headroom, costPerRecord);
|
|
222
|
+
const gaugeLine = acquireGaugeLine(headroom, config.acquire.budget ?? {}, paint(colorEnabled(process.stdout)));
|
|
223
|
+
if (!save) {
|
|
224
|
+
if (rest.includes("--json")) {
|
|
225
|
+
console.log(JSON.stringify({ plan: result.plan, counts: result.counts, estCostUsd: result.estCostUsd, meter: headroom }, null, 2));
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
console.log(patchPlanToMarkdown(result.plan));
|
|
229
|
+
console.log(meterLine);
|
|
230
|
+
if (gaugeLine)
|
|
231
|
+
console.log(gaugeLine);
|
|
232
|
+
console.log("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const run = await openEnrichRun(store, source, "append", option(rest, "--run-label"), today);
|
|
237
|
+
const planIds = [];
|
|
238
|
+
if (result.plan.operations.length > 0) {
|
|
239
|
+
await createFilePlanStore().save(result.plan);
|
|
240
|
+
planIds.push(result.plan.id);
|
|
241
|
+
reportEvent("plan_saved", result.plan.id);
|
|
242
|
+
}
|
|
243
|
+
await store.update({
|
|
244
|
+
...run,
|
|
245
|
+
completedAt: new Date().toISOString(),
|
|
246
|
+
cursor: null,
|
|
247
|
+
planIds: [...(run.planIds ?? []), ...planIds],
|
|
248
|
+
});
|
|
249
|
+
// Remember everyone we email-resolved this run so the next run skips them
|
|
250
|
+
// pre-email (cross-run credit saver). Committed (--save) runs only.
|
|
251
|
+
if (apiProcessedKeys.length > 0)
|
|
252
|
+
recordSeen(apiProcessedKeys, now);
|
|
253
|
+
console.log(meterLine);
|
|
254
|
+
if (gaugeLine)
|
|
255
|
+
console.log(gaugeLine);
|
|
256
|
+
if (planIds.length > 0) {
|
|
257
|
+
console.log(`Saved plan ${result.plan.id} — ${result.counts.created} net-new lead(s), est. $${result.estCostUsd.toFixed(2)}. ` +
|
|
258
|
+
`Review \`fullstackgtm plans show ${result.plan.id}\`, approve \`fullstackgtm plans approve ${result.plan.id} --operations all\`, ` +
|
|
259
|
+
`then \`fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot\`. The meter is charged only when a create lands at apply.`);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const mode = subcommand === "refresh" ? "refresh" : "append";
|
|
267
|
+
const source = resolveEnrichSource(config, rest);
|
|
268
|
+
const sourceConfig = config.sources[source];
|
|
269
|
+
const save = saveRequested(rest);
|
|
270
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
271
|
+
// Refresh work set comes from the staleness ledger, before any fetch.
|
|
272
|
+
const allRuns = await store.list();
|
|
273
|
+
let workSet = [];
|
|
274
|
+
if (mode === "refresh") {
|
|
275
|
+
const staleDaysOverride = numericOption(rest, "--stale-days");
|
|
276
|
+
workSet = selectStaleWork(config, allRuns, source, { staleDaysOverride });
|
|
277
|
+
if (workSet.length === 0) {
|
|
278
|
+
const stamped = latestStamps(allRuns, source).size;
|
|
279
|
+
console.log(stamped === 0
|
|
280
|
+
? `Nothing to refresh: no ${source} enrichment stamps yet. Run \`enrich append --source ${source} --save\` first.`
|
|
281
|
+
: `Nothing to refresh: all ${stamped} stamped field(s) from ${source} are within their staleness window.`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const snapshot = await readSnapshot(rest);
|
|
286
|
+
// Assemble source records: api pull (checkpointed when --save) or staged ingest data.
|
|
287
|
+
let run = null;
|
|
288
|
+
let records;
|
|
289
|
+
let missCount = 0;
|
|
290
|
+
if (sourceConfig.kind === "api") {
|
|
291
|
+
const objectTypes = parseEnrichObjects(rest, config, source);
|
|
292
|
+
const fieldsFor = (objectType) => (config.fields[objectType] ?? []).filter((field) => field.from[source] !== undefined);
|
|
293
|
+
const pullKeys = mode === "append"
|
|
294
|
+
? apolloPullKeysForAppend(snapshot, objectTypes, (objectType, record) => fieldsFor(objectType).some((field) => {
|
|
295
|
+
const value = record[resolveCrmField(objectType, field.crm)];
|
|
296
|
+
return value === undefined || value === null || String(value).trim() === "";
|
|
297
|
+
}))
|
|
298
|
+
: apolloPullKeysForRefresh(snapshot, workSet);
|
|
299
|
+
if (pullKeys.length === 0) {
|
|
300
|
+
console.log(mode === "append"
|
|
301
|
+
? "Nothing to enrich: no records with a blank mapped field and a pull key (companies need a domain, contacts an email)."
|
|
302
|
+
: "Nothing to refresh: no stale records carry a pull key (companies need a domain, contacts an email).");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const client = createApolloClient({
|
|
306
|
+
getApiKey: () => apolloApiKey(),
|
|
307
|
+
apiBaseUrl: process.env.APOLLO_API_BASE_URL,
|
|
308
|
+
});
|
|
309
|
+
if (save) {
|
|
310
|
+
run = await openEnrichRun(store, source, mode, option(rest, "--run-label"), today);
|
|
311
|
+
if (run.cursor) {
|
|
312
|
+
console.error(`Resuming interrupted run ${run.runLabel} from cursor ${run.cursor} (${run.pulled?.length ?? 0} record(s) already pulled).`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Interactive terminals get a stderr progress bar over the pull (rate +
|
|
316
|
+
// ETA); the checkpointing callback below is unchanged. Inert otherwise.
|
|
317
|
+
const pullBar = createStatusLine();
|
|
318
|
+
const pullStarted = Date.now();
|
|
319
|
+
let pullProcessed = 0;
|
|
320
|
+
let result;
|
|
321
|
+
try {
|
|
322
|
+
result = await pullApolloRecords(client, pullKeys, {
|
|
323
|
+
resumeAfter: run?.cursor ?? null,
|
|
324
|
+
onProgress: async (progress) => {
|
|
325
|
+
if (pullBar.active) {
|
|
326
|
+
pullProcessed += 1;
|
|
327
|
+
const elapsed = Date.now() - pullStarted;
|
|
328
|
+
const rate = pullProcessed / Math.max(1, elapsed / 1000);
|
|
329
|
+
const left = Math.max(0, pullKeys.length - pullProcessed);
|
|
330
|
+
pullBar.set(`Enriching via ${source}… ${formatBar(pullProcessed / pullKeys.length, 12)} ${pullProcessed}/${pullKeys.length} · ${rate.toFixed(1)}/s · ETA ${formatDuration((left / Math.max(rate, 0.01)) * 1000)}`);
|
|
331
|
+
}
|
|
332
|
+
if (!run)
|
|
333
|
+
return;
|
|
334
|
+
run.cursor = progress.lastKeyValue;
|
|
335
|
+
if (progress.record)
|
|
336
|
+
run.pulled = [...(run.pulled ?? []), progress.record];
|
|
337
|
+
if (progress.miss)
|
|
338
|
+
run.missedKeys = [...(run.missedKeys ?? []), progress.miss.value];
|
|
339
|
+
await store.update(run);
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
pullBar.done();
|
|
345
|
+
}
|
|
346
|
+
records = run ? [...(run.pulled ?? [])] : result.records;
|
|
347
|
+
missCount = run ? (run.missedKeys?.length ?? 0) : result.misses.length;
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
const stagedLabel = option(rest, "--staged-run");
|
|
351
|
+
const stagedRun = stagedLabel
|
|
352
|
+
? await store.get(stagedLabel)
|
|
353
|
+
: await store.latest({ source, mode: "ingest" });
|
|
354
|
+
if (!stagedRun || stagedRun.mode !== "ingest") {
|
|
355
|
+
throw new Error(`No staged data for source "${source}". Stage it first: fullstackgtm enrich ingest <file.csv|payload.json> --source ${source}`);
|
|
356
|
+
}
|
|
357
|
+
records = stagedSourceRecords(config, source, stagedRun);
|
|
358
|
+
if (save)
|
|
359
|
+
run = await openEnrichRun(store, source, mode, option(rest, "--run-label"), today);
|
|
360
|
+
}
|
|
361
|
+
const result = buildEnrichPlan({
|
|
362
|
+
config,
|
|
363
|
+
source,
|
|
364
|
+
mode,
|
|
365
|
+
snapshot,
|
|
366
|
+
records,
|
|
367
|
+
workSet: mode === "refresh" ? workSet : undefined,
|
|
368
|
+
runLabel: run?.runLabel ?? `${mode}-${source}-${today}`,
|
|
369
|
+
});
|
|
370
|
+
// Pull keys the source had no data for count as fetched-but-unmatched.
|
|
371
|
+
result.counts.fetched += missCount;
|
|
372
|
+
result.counts.unmatched += missCount;
|
|
373
|
+
reportCounts({
|
|
374
|
+
fetched: result.counts.fetched,
|
|
375
|
+
matched: result.counts.matched,
|
|
376
|
+
unmatched: result.counts.unmatched,
|
|
377
|
+
opsEmitted: result.counts.opsEmitted,
|
|
378
|
+
});
|
|
379
|
+
if (!save) {
|
|
380
|
+
if (rest.includes("--json")) {
|
|
381
|
+
console.log(JSON.stringify(result.plan, null, 2));
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
console.log(patchPlanToMarkdown(result.plan));
|
|
385
|
+
console.log(formatEnrichCounts(result.counts, result.ambiguities.length));
|
|
386
|
+
console.log("\nDry run — nothing written. Re-run with --save to persist the plan and the run record.");
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
// --save: persist the plan (when it proposes anything) and finalize the run.
|
|
391
|
+
const planIds = [];
|
|
392
|
+
if (result.plan.operations.length > 0) {
|
|
393
|
+
await createFilePlanStore().save(result.plan);
|
|
394
|
+
planIds.push(result.plan.id);
|
|
395
|
+
}
|
|
396
|
+
const finalized = {
|
|
397
|
+
...run,
|
|
398
|
+
completedAt: new Date().toISOString(),
|
|
399
|
+
cursor: null,
|
|
400
|
+
counts: result.counts,
|
|
401
|
+
planIds: [...(run.planIds ?? []), ...planIds],
|
|
402
|
+
stamps: [...(run.stamps ?? []), ...result.stamps],
|
|
403
|
+
ambiguities: result.ambiguities,
|
|
404
|
+
};
|
|
405
|
+
await store.update(finalized);
|
|
406
|
+
console.log(formatEnrichCounts(result.counts, result.ambiguities.length));
|
|
407
|
+
if (planIds.length > 0) {
|
|
408
|
+
console.log(`Saved plan ${result.plan.id} (run ${finalized.runLabel}). Review with \`fullstackgtm plans show ${result.plan.id}\`, ` +
|
|
409
|
+
`approve with \`fullstackgtm plans approve ${result.plan.id} --operations <ids|all>\`, then ` +
|
|
410
|
+
`\`fullstackgtm apply --plan-id ${result.plan.id} --provider <name>\`.`);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
console.log(`Run ${finalized.runLabel} recorded; no operations to propose.`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function formatEnrichCounts(counts, ambiguities) {
|
|
417
|
+
return (`Source records: ${counts.fetched} fetched · ${counts.matched} matched · ` +
|
|
418
|
+
`${counts.unmatched} unmatched · ${counts.ambiguous} ambiguous (${ambiguities} collision(s) recorded) · ` +
|
|
419
|
+
`${counts.opsEmitted} operation(s) proposed`);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Pull net-new prospects from an API acquire source into source records the
|
|
423
|
+
* acquire builder dedupes + turns into create_record ops. Explorium discovers;
|
|
424
|
+
* pipe0 (when resolveEmailsWith=pipe0) fills real work emails. Only rows that
|
|
425
|
+
* carry the dedupe key (email) survive — you cannot resolve-first without it.
|
|
426
|
+
*/
|
|
427
|
+
async function acquireFromApi(config, source, rest, icp, snapshot, seen) {
|
|
428
|
+
const acquire = config.acquire;
|
|
429
|
+
const disc = acquire.discovery?.[source];
|
|
430
|
+
if (!disc) {
|
|
431
|
+
throw new Error(`enrich acquire: api source "${source}" needs an "acquire.discovery.${source}" config (provider + size).`);
|
|
432
|
+
}
|
|
433
|
+
const matchKey = acquire.create.contact?.matchKey ?? "email";
|
|
434
|
+
const maxOverride = numericOption(rest, "--max");
|
|
435
|
+
const size = maxOverride !== undefined ? Math.min(maxOverride, disc.size ?? 25) : disc.size ?? 25;
|
|
436
|
+
// 1. Discover. Filters come from the ICP when one is loaded (the whole point —
|
|
437
|
+
// targeted, not random); otherwise from the hand-written disc.filters.
|
|
438
|
+
let prospects;
|
|
439
|
+
if (disc.provider === "explorium") {
|
|
440
|
+
const filters = icp
|
|
441
|
+
? icpToExploriumFilters(icp)
|
|
442
|
+
: (disc.filters ?? {});
|
|
443
|
+
prospects = await fetchExploriumProspects({ apiKey: providerKey("explorium"), filters, size });
|
|
444
|
+
}
|
|
445
|
+
else if (disc.provider === "pipe0") {
|
|
446
|
+
const filters = icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {});
|
|
447
|
+
prospects = await fetchPipe0CrustdataProspects({ apiKey: providerKey("pipe0"), filters, limit: size });
|
|
448
|
+
}
|
|
449
|
+
else if (disc.provider === "linkedin" || disc.provider === "heyreach") {
|
|
450
|
+
// LinkedIn reads a pre-populated lead list (not an ICP-driven query); the ICP
|
|
451
|
+
// scores the pulled list below. List id: disc.listId or --list <id>.
|
|
452
|
+
const listId = disc.listId ?? option(rest, "--list") ?? undefined;
|
|
453
|
+
if (!listId) {
|
|
454
|
+
throw new Error("enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.");
|
|
455
|
+
}
|
|
456
|
+
const provider = createLinkedInProvider(disc.provider, providerKey("heyreach"));
|
|
457
|
+
prospects = await discoverLinkedInProspects(provider, { sourceId: listId, max: size });
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
|
|
461
|
+
}
|
|
462
|
+
// Surface a zero-discovery result LOUDLY rather than emit a silent empty plan.
|
|
463
|
+
// A provider that returns 0 with no error usually means an over-narrow ICP
|
|
464
|
+
// filter (most often the industry/seniority vocab) — not "no market exists".
|
|
465
|
+
const discoveredCount = prospects.length;
|
|
466
|
+
if (discoveredCount === 0) {
|
|
467
|
+
console.error(`enrich acquire: ${disc.provider} discovered 0 prospects for this ICP — the plan will be empty. ` +
|
|
468
|
+
"This is usually an over-narrow filter, not an empty market: check the ICP's industry and seniority " +
|
|
469
|
+
"(provider vocab is finicky), widen one constraint, or run `fullstackgtm icp show` to inspect the " +
|
|
470
|
+
"generated discovery filters. (Distinct from dedup: nothing was returned to filter.)");
|
|
471
|
+
}
|
|
472
|
+
// 2. ICP fit scoring BEFORE paying for emails — only above-threshold prospects
|
|
473
|
+
// proceed to (credit-spending) email resolution.
|
|
474
|
+
if (icp) {
|
|
475
|
+
const threshold = fitThreshold(icp);
|
|
476
|
+
prospects = prospects
|
|
477
|
+
.map((p) => ({ ...p, fitScore: scoreProspectAgainstIcp(p, icp).score }))
|
|
478
|
+
.filter((p) => (p.fitScore ?? 0) >= threshold);
|
|
479
|
+
if (discoveredCount > 0 && prospects.length === 0) {
|
|
480
|
+
console.error(`enrich acquire: all ${discoveredCount} discovered prospect(s) scored below the ICP fit threshold ` +
|
|
481
|
+
`(${fitThreshold(icp)}) — none qualified. Loosen the ICP persona or lower scoring.threshold.`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// 2.5 Pre-email dedup — drop prospects already in the CRM (name+domain match
|
|
485
|
+
// vs the snapshot) or already processed in a prior run (the seen cache),
|
|
486
|
+
// BEFORE paying pipe0 for emails. The email-level dedup (buildAcquirePlan)
|
|
487
|
+
// and apply-time resolve-first remain the precise backstop.
|
|
488
|
+
const { fresh, skippedCrm, skippedSeen } = partitionFreshProspects(prospects, crmContactKeys(snapshot), seen);
|
|
489
|
+
prospects = fresh;
|
|
490
|
+
// 3. Resolve real work emails. Triggered either when email IS the dedupe key
|
|
491
|
+
// (Explorium's email is hashed, Crustdata returns none) or when a source
|
|
492
|
+
// that keys on something else opts in via `resolveEmailsWith: "pipe0"`
|
|
493
|
+
// (e.g. LinkedIn keys on the profile URL but still wants outreach emails).
|
|
494
|
+
// pipe0 waterfall, chunked; resolves from name + company domain/name.
|
|
495
|
+
if (matchKey === "email" || disc.resolveEmailsWith === "pipe0") {
|
|
496
|
+
// The waterfall needs a company DOMAIN; LinkedIn/HeyReach lists carry only
|
|
497
|
+
// names. Resolve domains first (pipe0 company:identity) so resolution can
|
|
498
|
+
// actually land — without it, name-only resolution fails for every lead.
|
|
499
|
+
if (prospects.some((p) => !p.companyDomain && p.companyName)) {
|
|
500
|
+
prospects = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
|
|
501
|
+
}
|
|
502
|
+
prospects = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
|
|
503
|
+
}
|
|
504
|
+
const processedKeys = prospects.flatMap(prospectIdentityKeys);
|
|
505
|
+
const records = prospects
|
|
506
|
+
.map((p) => {
|
|
507
|
+
const keyValue = p[matchKey];
|
|
508
|
+
return {
|
|
509
|
+
id: p.sourceId ?? `${source}:${keyValue ?? p.fullName ?? ""}`,
|
|
510
|
+
objectType: "contact",
|
|
511
|
+
keys: { [matchKey]: keyValue },
|
|
512
|
+
payload: p,
|
|
513
|
+
};
|
|
514
|
+
})
|
|
515
|
+
.filter((record) => Boolean(record.keys[matchKey]));
|
|
516
|
+
return { records, skippedCrm, skippedSeen, processedKeys };
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Rich-only fuel gauge under the acquire meter line: one bar per configured
|
|
520
|
+
* budget window, colored by how much is burned (green → yellow ≥70% → red
|
|
521
|
+
* ≥90%). Returns null in plain mode or with no caps configured, keeping
|
|
522
|
+
* piped output byte-identical.
|
|
523
|
+
*/
|
|
524
|
+
function acquireGaugeLine(headroom, budget, p) {
|
|
525
|
+
if (!p.enabled)
|
|
526
|
+
return null;
|
|
527
|
+
const parts = [];
|
|
528
|
+
const segment = (label, left, cap, fmt) => {
|
|
529
|
+
if (left === null || cap === undefined || cap <= 0)
|
|
530
|
+
return;
|
|
531
|
+
const used = Math.max(0, cap - left);
|
|
532
|
+
const fraction = used / cap;
|
|
533
|
+
const painter = fraction >= 0.9 ? p.red : fraction >= 0.7 ? p.yellow : p.green;
|
|
534
|
+
parts.push(`${p.dim(label)} ${painter(formatBar(fraction, 10))} ${fmt(used)}/${fmt(cap)}`);
|
|
535
|
+
};
|
|
536
|
+
segment("records/day", headroom.records.day, budget.records?.perDay, String);
|
|
537
|
+
segment("records/mo", headroom.records.month, budget.records?.perMonth, String);
|
|
538
|
+
segment("spend/day", headroom.spendUsd.day, budget.spend?.perDay, (value) => `$${value.toFixed(2)}`);
|
|
539
|
+
segment("spend/mo", headroom.spendUsd.month, budget.spend?.perMonth, (value) => `$${value.toFixed(2)}`);
|
|
540
|
+
return parts.length > 0 ? parts.join(" · ") : null;
|
|
541
|
+
}
|
|
542
|
+
function formatAcquireMeter(headroom, costPerRecord) {
|
|
543
|
+
const n = (v) => (v === null ? "∞" : String(v));
|
|
544
|
+
const money = (v) => (v === null ? "∞" : `$${v.toFixed(2)}`);
|
|
545
|
+
const max = headroom.maxRecords === null ? "unlimited" : `${headroom.maxRecords}`;
|
|
546
|
+
return (`Acquire meter — creatable now: ${max} lead(s). ` +
|
|
547
|
+
`Records left: ${n(headroom.records.day)}/day, ${n(headroom.records.month)}/month · ` +
|
|
548
|
+
`Spend left: ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
|
|
549
|
+
`(≈$${costPerRecord.toFixed(2)}/lead).`);
|
|
550
|
+
}
|
|
551
|
+
function resolveEnrichSource(config, rest) {
|
|
552
|
+
const requested = option(rest, "--source");
|
|
553
|
+
const declared = Object.keys(config.sources);
|
|
554
|
+
if (requested) {
|
|
555
|
+
if (!config.sources[requested]) {
|
|
556
|
+
throw new Error(`Unknown enrich source "${requested}" (declared: ${declared.join(", ")})`);
|
|
557
|
+
}
|
|
558
|
+
return requested;
|
|
559
|
+
}
|
|
560
|
+
if (declared.length === 1)
|
|
561
|
+
return declared[0];
|
|
562
|
+
if (config.sources.apollo)
|
|
563
|
+
return "apollo";
|
|
564
|
+
throw new Error(`Multiple sources declared (${declared.join(", ")}) — pass --source <id>`);
|
|
565
|
+
}
|
|
566
|
+
function parseEnrichObjects(rest, config, source) {
|
|
567
|
+
const configured = ["company", "contact"].filter((objectType) => (config.fields[objectType] ?? []).some((field) => field.from[source] !== undefined));
|
|
568
|
+
const flag = option(rest, "--objects");
|
|
569
|
+
if (!flag) {
|
|
570
|
+
if (configured.length === 0) {
|
|
571
|
+
throw new Error(`No fields map from source "${source}" — add "from": { "${source}": ... } entries to the config.`);
|
|
572
|
+
}
|
|
573
|
+
return configured;
|
|
574
|
+
}
|
|
575
|
+
const requested = Array.from(new Set(flag.split(",").map((part) => parseSingleObjectType(part))));
|
|
576
|
+
for (const objectType of requested) {
|
|
577
|
+
if (!configured.includes(objectType)) {
|
|
578
|
+
throw new Error(`--objects ${flag}: no ${objectType} fields map from source "${source}" in the config.`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return requested;
|
|
582
|
+
}
|
|
583
|
+
function apolloApiKey() {
|
|
584
|
+
if (process.env.APOLLO_API_KEY)
|
|
585
|
+
return process.env.APOLLO_API_KEY;
|
|
586
|
+
const stored = getCredential("apollo");
|
|
587
|
+
if (stored)
|
|
588
|
+
return stored.accessToken;
|
|
589
|
+
throw new Error('No Apollo credentials. Run `echo "$APOLLO_API_KEY" | fullstackgtm login apollo` once, or set APOLLO_API_KEY.');
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Open (or resume) a saved run. An interrupted run — same label, same source
|
|
593
|
+
* and mode, never completed — is resumed from its cursor; a completed run
|
|
594
|
+
* with the default label gets a -2/-3 suffix (runs are append-only).
|
|
595
|
+
*/
|
|
596
|
+
async function openEnrichRun(store, source, mode, requestedLabel, today) {
|
|
597
|
+
const baseLabel = requestedLabel ?? `${mode}-${source}-${today}`;
|
|
598
|
+
let label = baseLabel;
|
|
599
|
+
for (let suffix = 2;; suffix += 1) {
|
|
600
|
+
const existing = await store.get(label);
|
|
601
|
+
if (!existing)
|
|
602
|
+
break;
|
|
603
|
+
if (existing.source === source && existing.mode === mode && existing.completedAt === null) {
|
|
604
|
+
return existing; // resume the interrupted run
|
|
605
|
+
}
|
|
606
|
+
if (requestedLabel) {
|
|
607
|
+
throw new Error(`Run "${requestedLabel}" already exists and is completed — enrich runs are append-only.`);
|
|
608
|
+
}
|
|
609
|
+
label = `${baseLabel}-${suffix}`;
|
|
610
|
+
}
|
|
611
|
+
return store.append({
|
|
612
|
+
id: enrichRunId(source, label),
|
|
613
|
+
runLabel: label,
|
|
614
|
+
source,
|
|
615
|
+
mode,
|
|
616
|
+
startedAt: new Date().toISOString(),
|
|
617
|
+
completedAt: null,
|
|
618
|
+
cursor: null,
|
|
619
|
+
counts: { fetched: 0, matched: 0, unmatched: 0, ambiguous: 0, opsEmitted: 0 },
|
|
620
|
+
planIds: [],
|
|
621
|
+
stamps: [],
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
async function enrichIngest(store, config, rest, autoAppend = false) {
|
|
625
|
+
const file = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
|
|
626
|
+
if (!file)
|
|
627
|
+
throw new Error("Usage: fullstackgtm enrich ingest <file.csv|payload.json|spool.jsonl|spool-dir> --source <id> [--run-label <label>]");
|
|
628
|
+
const source = option(rest, "--source");
|
|
629
|
+
if (!source)
|
|
630
|
+
throw new Error("enrich ingest requires --source <id> (the ingest source the data belongs to)");
|
|
631
|
+
const sourceConfig = config.sources[source];
|
|
632
|
+
if (!sourceConfig) {
|
|
633
|
+
throw new Error(`Unknown enrich source "${source}" (declared: ${Object.keys(config.sources).join(", ")})`);
|
|
634
|
+
}
|
|
635
|
+
if (sourceConfig.kind !== "ingest") {
|
|
636
|
+
throw new Error(`Source "${source}" is kind "${sourceConfig.kind}" — only ingest sources accept staged data.`);
|
|
637
|
+
}
|
|
638
|
+
const filePath = resolve(process.cwd(), file);
|
|
639
|
+
let rows;
|
|
640
|
+
if (isSpoolPath(filePath)) {
|
|
641
|
+
// Spool convention (docs/signal-spool-format.md): a *.jsonl file (one JSON
|
|
642
|
+
// row per line) or a directory of *.jsonl / *.json spool files — so a
|
|
643
|
+
// webhook landing zone can be staged directly. Additive: .csv / .json
|
|
644
|
+
// files parse exactly as before.
|
|
645
|
+
rows = readSpoolPath(filePath, "enrich ingest").map((entry) => entry.row);
|
|
646
|
+
}
|
|
647
|
+
else if ((file.toLowerCase().endsWith(".csv") || sourceConfig.format === "csv") && !file.toLowerCase().endsWith(".json")) {
|
|
648
|
+
const raw = readFileSync(filePath, "utf8");
|
|
649
|
+
rows = parseCsv(raw);
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
const raw = readFileSync(filePath, "utf8");
|
|
653
|
+
const parsed = JSON.parse(raw);
|
|
654
|
+
if (Array.isArray(parsed))
|
|
655
|
+
rows = parsed;
|
|
656
|
+
else if (parsed && typeof parsed === "object" && Array.isArray(parsed.rows)) {
|
|
657
|
+
rows = parsed.rows;
|
|
658
|
+
}
|
|
659
|
+
else if (parsed && typeof parsed === "object") {
|
|
660
|
+
rows = [parsed];
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
throw new Error(`${file}: expected a JSON array, an object, or { "rows": [...] }`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (rows.length === 0)
|
|
667
|
+
throw new Error(`${file}: no rows to stage`);
|
|
668
|
+
const objectsFlag = option(rest, "--objects");
|
|
669
|
+
const objectType = objectsFlag
|
|
670
|
+
? parseSingleObjectType(objectsFlag)
|
|
671
|
+
: inferIngestObjectType(config, source, rows);
|
|
672
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
673
|
+
const baseLabel = option(rest, "--run-label") ?? `ingest-${source}-${today}`;
|
|
674
|
+
let label = baseLabel;
|
|
675
|
+
for (let suffix = 2; await store.get(label); suffix += 1) {
|
|
676
|
+
if (option(rest, "--run-label")) {
|
|
677
|
+
throw new Error(`Run "${baseLabel}" already exists — enrich runs are append-only; pick a new --run-label.`);
|
|
678
|
+
}
|
|
679
|
+
label = `${baseLabel}-${suffix}`;
|
|
680
|
+
}
|
|
681
|
+
const now = new Date().toISOString();
|
|
682
|
+
await store.append({
|
|
683
|
+
id: enrichRunId(source, label),
|
|
684
|
+
runLabel: label,
|
|
685
|
+
source,
|
|
686
|
+
mode: "ingest",
|
|
687
|
+
startedAt: now,
|
|
688
|
+
completedAt: now,
|
|
689
|
+
cursor: null,
|
|
690
|
+
counts: { fetched: rows.length, matched: 0, unmatched: 0, ambiguous: 0, opsEmitted: 0 },
|
|
691
|
+
planIds: [],
|
|
692
|
+
stamps: [],
|
|
693
|
+
staged: rows,
|
|
694
|
+
stagedObjectType: objectType,
|
|
695
|
+
});
|
|
696
|
+
console.log(autoAppend
|
|
697
|
+
? `Staged ${rows.length} ${objectType} row(s) from ${file} as run ${label}; matching against the CRM…`
|
|
698
|
+
: `Staged ${rows.length} ${objectType} row(s) from ${file} as run ${label}. ` +
|
|
699
|
+
`Next: fullstackgtm enrich append --source ${source} [source options] [--save]`);
|
|
700
|
+
}
|
|
701
|
+
function parseSingleObjectType(value) {
|
|
702
|
+
const normalized = value.trim().toLowerCase();
|
|
703
|
+
if (normalized === "companies" || normalized === "company")
|
|
704
|
+
return "company";
|
|
705
|
+
if (normalized === "contacts" || normalized === "contact")
|
|
706
|
+
return "contact";
|
|
707
|
+
throw new Error(`--objects must be companies or contacts (got "${value}")`);
|
|
708
|
+
}
|
|
709
|
+
async function enrichStatus(store, rest, configFile) {
|
|
710
|
+
const sourceFilter = option(rest, "--source");
|
|
711
|
+
const allRuns = (await store.list()).filter((run) => !sourceFilter || run.source === sourceFilter);
|
|
712
|
+
if (allRuns.length === 0) {
|
|
713
|
+
console.log(sourceFilter
|
|
714
|
+
? `No enrich runs for source "${sourceFilter}".`
|
|
715
|
+
: "No enrich runs yet. Start with `fullstackgtm enrich append --save` or stage data with `enrich ingest`.");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
// Staleness windows come from the config when one is readable; status must
|
|
719
|
+
// not REQUIRE a config (the run store alone is enough to report on).
|
|
720
|
+
let config = null;
|
|
721
|
+
if (existsSync(configFile)) {
|
|
722
|
+
try {
|
|
723
|
+
config = loadEnrichConfig(configFile);
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
config = null;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const now = Date.now();
|
|
730
|
+
const sources = Array.from(new Set(allRuns.map((run) => run.source)));
|
|
731
|
+
const report = sources.map((source) => {
|
|
732
|
+
const runs = allRuns.filter((run) => run.source === source);
|
|
733
|
+
const last = runs[runs.length - 1];
|
|
734
|
+
const interrupted = runs.filter((run) => run.completedAt === null);
|
|
735
|
+
const stamps = Array.from(latestStamps(runs, source).values());
|
|
736
|
+
const ages = stamps.map((stamp) => (now - Date.parse(stamp.enrichedAt)) / 86_400_000);
|
|
737
|
+
const staleness = stamps.map((stamp, index) => {
|
|
738
|
+
const windowDays = config
|
|
739
|
+
? staleDaysFor(config, stamp.objectType, stamp.field)
|
|
740
|
+
: DEFAULT_STALE_DAYS;
|
|
741
|
+
return ages[index] > windowDays;
|
|
742
|
+
});
|
|
743
|
+
return {
|
|
744
|
+
source,
|
|
745
|
+
runs: runs.length,
|
|
746
|
+
lastRun: {
|
|
747
|
+
runLabel: last.runLabel,
|
|
748
|
+
mode: last.mode,
|
|
749
|
+
startedAt: last.startedAt,
|
|
750
|
+
completedAt: last.completedAt,
|
|
751
|
+
counts: last.counts,
|
|
752
|
+
planIds: last.planIds,
|
|
753
|
+
},
|
|
754
|
+
interrupted: interrupted.map((run) => ({ runLabel: run.runLabel, cursor: run.cursor })),
|
|
755
|
+
stamps: {
|
|
756
|
+
total: stamps.length,
|
|
757
|
+
stale: staleness.filter(Boolean).length,
|
|
758
|
+
oldestDays: ages.length ? Math.round(Math.max(...ages)) : null,
|
|
759
|
+
newestDays: ages.length ? Math.round(Math.min(...ages)) : null,
|
|
760
|
+
windowSource: config ? "enrich.config.json" : `default ${DEFAULT_STALE_DAYS}d`,
|
|
761
|
+
},
|
|
762
|
+
};
|
|
763
|
+
});
|
|
764
|
+
if (rest.includes("--json")) {
|
|
765
|
+
console.log(JSON.stringify({ sources: report, runs: rest.includes("--runs") ? allRuns : undefined }, null, 2));
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
for (const entry of report) {
|
|
769
|
+
const last = entry.lastRun;
|
|
770
|
+
console.log(`${entry.source} — ${entry.runs} run(s)`);
|
|
771
|
+
console.log(` last: ${last.runLabel} (${last.mode}) ${last.completedAt ? `completed ${last.completedAt}` : "INTERRUPTED"}` +
|
|
772
|
+
` · ${last.counts.fetched} fetched, ${last.counts.matched} matched, ${last.counts.unmatched} unmatched,` +
|
|
773
|
+
` ${last.counts.ambiguous} ambiguous, ${last.counts.opsEmitted} ops` +
|
|
774
|
+
(last.planIds.length ? ` · plans: ${last.planIds.join(", ")}` : ""));
|
|
775
|
+
for (const run of entry.interrupted) {
|
|
776
|
+
console.log(` interrupted: ${run.runLabel} at cursor ${run.cursor ?? "(start)"} — re-run with --save to resume`);
|
|
777
|
+
}
|
|
778
|
+
console.log(` stamps: ${entry.stamps.total} field(s) enriched · ${entry.stamps.stale} stale (window: ${entry.stamps.windowSource})` +
|
|
779
|
+
(entry.stamps.total ? ` · age ${entry.stamps.newestDays}–${entry.stamps.oldestDays}d` : ""));
|
|
780
|
+
}
|
|
781
|
+
if (rest.includes("--runs")) {
|
|
782
|
+
console.log("");
|
|
783
|
+
for (const run of allRuns) {
|
|
784
|
+
console.log(`${run.runLabel} ${run.source.padEnd(8)} ${run.mode.padEnd(8)} ${run.completedAt ? "done" : "interrupted"}` +
|
|
785
|
+
` ${run.counts.opsEmitted} ops ${run.stamps.length} stamps${run.staged ? ` ${run.staged.length} staged` : ""}`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|