gtm-now 0.10.5 → 0.10.7

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/dist/index.js CHANGED
@@ -9481,13 +9481,14 @@ function mapSeniority(values) {
9481
9481
  function isApiClientError(error, statusCode) {
9482
9482
  return error instanceof Error && "statusCode" in error && error.statusCode === statusCode;
9483
9483
  }
9484
- var PROSPEO_PAGE_SIZE, SENIORITY_TO_PROSPEO, ProspeoMcpAdapter;
9484
+ var PROSPEO_PAGE_SIZE, MAX_PAGES, SENIORITY_TO_PROSPEO, ProspeoMcpAdapter;
9485
9485
  var init_prospeo2 = __esm({
9486
9486
  "src/providers/adapters/prospeo.ts"() {
9487
9487
  "use strict";
9488
9488
  init_dist();
9489
9489
  init_dist2();
9490
9490
  PROSPEO_PAGE_SIZE = 25;
9491
+ MAX_PAGES = 40;
9491
9492
  SENIORITY_TO_PROSPEO = {
9492
9493
  c_suite: "C-Suite",
9493
9494
  founder: "Founder/Owner",
@@ -9577,24 +9578,33 @@ var init_prospeo2 = __esm({
9577
9578
  { statusCode: 400 }
9578
9579
  );
9579
9580
  }
9580
- const page = query.offset ? Math.floor(query.offset / PROSPEO_PAGE_SIZE) + 1 : 1;
9581
+ const requestedLimit = query.limit ?? PROSPEO_PAGE_SIZE;
9582
+ const startPage = query.offset ? Math.floor(query.offset / PROSPEO_PAGE_SIZE) + 1 : 1;
9581
9583
  try {
9582
- const response = await this.client.searchPeople({ page, filters });
9583
- if (response.error || !response.results?.length) return [];
9584
- return response.results.map((r) => ({
9585
- first_name: r.person?.first_name ?? "",
9586
- last_name: r.person?.last_name ?? "",
9587
- job_title: r.person?.current_job_title ?? void 0,
9588
- company: r.company?.name ?? void 0,
9589
- company_domain: r.company?.domain ?? void 0,
9590
- email: r.person?.email?.email ?? void 0,
9591
- phone: r.person?.mobile?.mobile ?? void 0,
9592
- linkedin_url: r.person?.linkedin_url ?? void 0,
9593
- seniority: r.person?.job_history?.[0]?.seniority ?? void 0,
9594
- department: r.person?.job_history?.[0]?.departments?.[0] ?? void 0,
9595
- location: r.person?.location ? [r.person.location.city, r.person.location.state, r.person.location.country].filter(Boolean).join(", ") : void 0,
9596
- sources: [this.name]
9597
- }));
9584
+ const allResults = [];
9585
+ let page = startPage;
9586
+ while (allResults.length < requestedLimit && page < startPage + MAX_PAGES) {
9587
+ const response = await this.client.searchPeople({ page, filters });
9588
+ if (response.error || !response.results?.length) break;
9589
+ const mapped = response.results.map((r) => ({
9590
+ first_name: r.person?.first_name ?? "",
9591
+ last_name: r.person?.last_name ?? "",
9592
+ job_title: r.person?.current_job_title ?? void 0,
9593
+ company: r.company?.name ?? void 0,
9594
+ company_domain: r.company?.domain ?? void 0,
9595
+ email: r.person?.email?.email ?? void 0,
9596
+ phone: r.person?.mobile?.mobile ?? void 0,
9597
+ linkedin_url: r.person?.linkedin_url ?? void 0,
9598
+ seniority: r.person?.job_history?.[0]?.seniority ?? void 0,
9599
+ department: r.person?.job_history?.[0]?.departments?.[0] ?? void 0,
9600
+ location: r.person?.location ? [r.person.location.city, r.person.location.state, r.person.location.country].filter(Boolean).join(", ") : void 0,
9601
+ sources: [this.name]
9602
+ }));
9603
+ allResults.push(...mapped);
9604
+ if (response.results.length < PROSPEO_PAGE_SIZE) break;
9605
+ page++;
9606
+ }
9607
+ return allResults.slice(0, requestedLimit);
9598
9608
  } catch (error) {
9599
9609
  if (isApiClientError(error, 400)) {
9600
9610
  const body = error.responseBody;
@@ -10784,7 +10794,7 @@ var init_prospect = __esm({
10784
10794
  prospectTools = [
10785
10795
  {
10786
10796
  name: "gtm_find_companies",
10787
- description: "Discover companies matching ICP criteria via DiscoLike, Apollo, or StoreLeads. Supports lookalike discovery, ICP text, tech stack filtering. Use gtm_tool_help for full parameter details.",
10797
+ description: 'Discover companies matching ICP criteria via DiscoLike, Apollo, StoreLeads, or Exa (semantic/neural search). Use provider: "exa" for meaning-based search that understands intent, not just keywords.',
10788
10798
  inputSchema: {
10789
10799
  type: "object",
10790
10800
  properties: {
@@ -10890,7 +10900,7 @@ var init_prospect = __esm({
10890
10900
  },
10891
10901
  {
10892
10902
  name: "gtm_find_people",
10893
- description: 'Find contacts by job title, company, or domain. Returns name, title, email, LinkedIn URL. Slim by default; pass fields: "all" for full payload.',
10903
+ description: 'Find contacts by job title, company, or domain. Returns name, title, email, LinkedIn URL. Slim by default; pass fields: "all" for full payload. Pass enrich: true to resolve full emails inline (avoids a separate gtm_enrich call).',
10894
10904
  inputSchema: {
10895
10905
  type: "object",
10896
10906
  properties: {
@@ -10979,6 +10989,10 @@ var init_prospect = __esm({
10979
10989
  type: "string",
10980
10990
  description: 'Company revenue range as "min,max" (e.g., "1000000,10000000")'
10981
10991
  },
10992
+ enrich: {
10993
+ type: "boolean",
10994
+ description: "Resolve full email addresses inline. Some providers return masked emails (e.g., j***@company.com) in search results. Pass enrich: true to run waterfall enrichment on those contacts automatically, so you get real emails without a separate gtm_enrich call."
10995
+ },
10982
10996
  fields: {
10983
10997
  description: 'Response fields. Defaults to slim (name, title, email, company, company_domain, linkedin). Pass "all" for full payload including skills, phone, seniority, department, connections. Or pass an array of specific field names.',
10984
10998
  oneOf: [
@@ -11526,7 +11540,15 @@ For DTC/e-commerce brands, use provider: "storeleads" \u2014 it has revenue esti
11526
11540
 
11527
11541
  Tip: DiscoLike lookalike mode is extremely powerful. If you have 2-3 example companies, use the domains parameter to find hundreds of similar ones.
11528
11542
 
11529
- Single domain lookup: passing just domain (no icp_text, domains, category, tech_stack, or country) does a direct company profile fetch with rich data (growth signals, engagement metrics, tech stack from vendors). This is for researching ONE specific company, not discovery.`,
11543
+ Single domain lookup: passing just domain (no icp_text, domains, category, tech_stack, or country) does a direct company profile fetch with rich data (growth signals, engagement metrics, tech stack from vendors). This is for researching ONE specific company, not discovery.
11544
+
11545
+ Exa (provider: "exa") \u2014 semantic/neural search:
11546
+ - Meaning-based ICP matching: "B2B SaaS companies selling to healthcare, not healthtech vendors" actually works
11547
+ - Best for: nuanced ICP descriptions where keyword matching fails
11548
+ - Uses icp_text for semantic search, domains for lookalike discovery
11549
+ - Does NOT return employee count, industry, or tech stack \u2014 use for discovery, then enrich with DiscoLike or Apollo
11550
+ - Must be explicitly selected with provider: "exa" (not auto-selected)
11551
+ - Requires GTM_EXA_API_KEY env var`,
11530
11552
  gtm_find_people: `Find contacts at companies by job title, company, or domain. Returns name, title, email (when available), phone, and LinkedIn URL.
11531
11553
 
11532
11554
  Powered by DiscoLike Contacts, Apollo, or BetterContact depending on configuration.
@@ -11864,171 +11886,451 @@ For any task: always dry_run enrichment batches > 5 to preview cost before execu
11864
11886
  }
11865
11887
  });
11866
11888
 
11867
- // src/providers/types.ts
11868
- function applySlimFields(obj, fields) {
11869
- const result = {};
11870
- for (const field of fields) {
11871
- if (field in obj) {
11872
- result[field] = obj[field];
11873
- }
11874
- }
11875
- return result;
11876
- }
11877
- var SLIM_COMPANY_FIELDS, SLIM_PERSON_FIELDS;
11878
- var init_types3 = __esm({
11879
- "src/providers/types.ts"() {
11880
- "use strict";
11881
- SLIM_COMPANY_FIELDS = [
11882
- "domain",
11883
- "name",
11884
- "industry",
11885
- "employees",
11886
- "country"
11887
- ];
11888
- SLIM_PERSON_FIELDS = [
11889
- "first_name",
11890
- "last_name",
11891
- "job_title",
11892
- "email",
11893
- "company",
11894
- "company_domain",
11895
- "linkedin_url"
11896
- ];
11897
- }
11898
- });
11899
-
11900
- // src/providers/manifest-validation.ts
11901
- function validateParams(provider, tool, args) {
11902
- const manifest = getToolManifest(provider, tool);
11903
- if (!manifest) return [];
11904
- const supported = new Set(manifest.supported_params);
11905
- const warnings = [];
11906
- for (const [key, value] of Object.entries(args)) {
11907
- if (value === void 0 || value === null) continue;
11908
- if (controlSet.has(key)) continue;
11909
- if (!supported.has(key)) {
11910
- const alternatives = Object.entries(MANIFESTS).filter(([_, m]) => m[tool]?.supported_params.includes(key)).map(([name]) => name);
11911
- const suggestion = alternatives.length > 0 ? ` Use provider: '${alternatives[0]}' for ${key} filtering.` : "";
11912
- warnings.push(`${key} is not supported by ${provider}.${suggestion}`);
11913
- }
11914
- }
11915
- return warnings;
11889
+ // src/providers/waterfall.ts
11890
+ function getCacheKey(contact) {
11891
+ if (!contact.linkedin_url) return null;
11892
+ return contact.linkedin_url.toLowerCase().trim();
11916
11893
  }
11917
- function clampLimit(provider, tool, limit) {
11918
- const manifest = getToolManifest(provider, tool);
11919
- if (!manifest) return limit ?? 50;
11920
- if (limit === void 0) return manifest.default_limit;
11921
- return Math.min(limit, manifest.max_limit);
11894
+ function isExhaustedError(error) {
11895
+ const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
11896
+ return EXHAUSTION_PATTERNS.some((p) => msg.includes(p));
11922
11897
  }
11923
- var controlSet;
11924
- var init_manifest_validation = __esm({
11925
- "src/providers/manifest-validation.ts"() {
11926
- "use strict";
11927
- init_manifests();
11928
- init_manifest_types();
11929
- controlSet = new Set(CONTROL_PARAMS);
11898
+ function isRateLimitError(error) {
11899
+ if (error instanceof Error && "status" in error) {
11900
+ return error.status === 429;
11930
11901
  }
11931
- });
11932
-
11933
- // src/utils/result-writer.ts
11934
- import * as fs from "fs";
11935
- import * as path from "path";
11936
- import * as os from "os";
11937
- function shouldWriteToFile(count, output) {
11938
- if (output === "file") return true;
11939
- if (output === "inline") return false;
11940
- if (output === "csv") return false;
11941
- return count > FILE_OUTPUT_THRESHOLD;
11902
+ const msg = error instanceof Error ? error.message : String(error);
11903
+ return msg.includes("429") || msg.toLowerCase().includes("rate limit");
11942
11904
  }
11943
- function writeResultsFile(tool, results, meta, resultsDir = DEFAULT_RESULTS_DIR) {
11944
- fs.mkdirSync(resultsDir, { recursive: true });
11945
- const timestamp = formatTimestamp(/* @__PURE__ */ new Date());
11946
- const filename = `${tool}-${timestamp}.jsonl`;
11947
- const filePath = path.join(resultsDir, filename);
11948
- const metaLine = JSON.stringify({ _meta: { ...meta, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
11949
- const dataLines = results.map((r) => JSON.stringify(r));
11950
- const content = [metaLine, ...dataLines].join("\n") + "\n";
11951
- fs.writeFileSync(filePath, content, "utf-8");
11952
- try {
11953
- cleanOldResults(7, resultsDir);
11954
- } catch {
11955
- }
11956
- return filePath;
11905
+ function withTimeout(promise, ms, label) {
11906
+ return new Promise((resolve, reject) => {
11907
+ const timer = setTimeout(
11908
+ () => reject(new Error(`Provider timeout: ${label} did not respond within ${ms}ms`)),
11909
+ ms
11910
+ );
11911
+ promise.then(
11912
+ (val) => {
11913
+ clearTimeout(timer);
11914
+ resolve(val);
11915
+ },
11916
+ (err) => {
11917
+ clearTimeout(timer);
11918
+ reject(err);
11919
+ }
11920
+ );
11921
+ });
11957
11922
  }
11958
- function writeResultsCsv(tool, results, resultsDir = DEFAULT_RESULTS_DIR) {
11959
- fs.mkdirSync(resultsDir, { recursive: true });
11960
- const timestamp = formatTimestamp(/* @__PURE__ */ new Date());
11961
- const filename = `${tool}-${timestamp}.csv`;
11962
- const filePath = path.join(resultsDir, filename);
11963
- if (results.length === 0) {
11964
- fs.writeFileSync(filePath, "", "utf-8");
11965
- return filePath;
11966
- }
11967
- const headers = Object.keys(results[0]);
11968
- const headerLine = headers.map(escapeCsvField).join(",");
11969
- const dataLines = results.map(
11970
- (row) => headers.map((h) => escapeCsvField(formatCsvValue(row[h]))).join(",")
11971
- );
11972
- const content = [headerLine, ...dataLines].join("\n") + "\n";
11973
- fs.writeFileSync(filePath, content, "utf-8");
11974
- try {
11975
- cleanOldResults(7, resultsDir);
11976
- } catch {
11923
+ async function runWaterfall(contacts, config, registry) {
11924
+ const exhausted = /* @__PURE__ */ new Set();
11925
+ const batchDeadline = Date.now() + (config.batchTimeoutMs ?? DEFAULT_BATCH_TIMEOUT_MS);
11926
+ const concurrency = config.concurrency ?? 1;
11927
+ if (concurrency <= 1) {
11928
+ const results2 = [];
11929
+ for (const contact of contacts) {
11930
+ if (Date.now() > batchDeadline) {
11931
+ results2.push(makeTimeoutResult(contact));
11932
+ continue;
11933
+ }
11934
+ const result = await enrichContact(contact, config, registry, exhausted);
11935
+ results2.push(result);
11936
+ }
11937
+ return results2;
11938
+ }
11939
+ const results = new Array(contacts.length);
11940
+ let nextIndex = 0;
11941
+ async function worker() {
11942
+ while (nextIndex < contacts.length) {
11943
+ const i = nextIndex++;
11944
+ if (Date.now() > batchDeadline) {
11945
+ results[i] = makeTimeoutResult(contacts[i]);
11946
+ continue;
11947
+ }
11948
+ results[i] = await enrichContact(contacts[i], config, registry, exhausted);
11949
+ }
11977
11950
  }
11978
- return filePath;
11979
- }
11980
- function formatCsvValue(value) {
11981
- if (value === null || value === void 0) return "";
11982
- if (Array.isArray(value)) return value.join(";");
11983
- if (typeof value === "object") return JSON.stringify(value);
11984
- return String(value);
11951
+ await Promise.all(Array.from({ length: Math.min(concurrency, contacts.length) }, () => worker()));
11952
+ return results;
11985
11953
  }
11986
- function escapeCsvField(value) {
11987
- if (value.includes(",") || value.includes('"') || value.includes("\n")) {
11988
- return `"${value.replace(/"/g, '""')}"`;
11989
- }
11990
- return value;
11954
+ function makeTimeoutResult(contact) {
11955
+ return {
11956
+ contact,
11957
+ attempts: [
11958
+ {
11959
+ provider: "waterfall",
11960
+ capability: "enrich_email",
11961
+ found: false,
11962
+ charged: false,
11963
+ duration_ms: 0,
11964
+ error: "Batch timeout exceeded \u2014 skipped remaining contacts"
11965
+ }
11966
+ ],
11967
+ charged: false
11968
+ };
11991
11969
  }
11992
- function cleanOldResults(maxAgeDays, resultsDir = DEFAULT_RESULTS_DIR) {
11993
- let entries;
11994
- try {
11995
- entries = fs.readdirSync(resultsDir, { withFileTypes: true });
11996
- } catch {
11997
- return;
11998
- }
11999
- const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1e3;
12000
- for (const entry of entries) {
12001
- if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
12002
- const filePath = path.join(resultsDir, entry.name);
12003
- try {
12004
- const stat = fs.statSync(filePath);
12005
- if (stat.mtimeMs < cutoff) {
12006
- fs.unlinkSync(filePath);
11970
+ async function enrichContact(contact, config, registry, exhausted) {
11971
+ const attempts = [];
11972
+ let email;
11973
+ let emailProvider;
11974
+ let phone;
11975
+ let phoneProvider;
11976
+ let charged = false;
11977
+ const cacheKey = getCacheKey(contact);
11978
+ const cached = cacheKey ? enrichmentCache.get(cacheKey) : null;
11979
+ for (const field of config.find) {
11980
+ const capability = field === "email" ? "enrich_email" : "enrich_phone";
11981
+ const cachedValue = cached?.[field];
11982
+ if (cachedValue) {
11983
+ if (field === "email") {
11984
+ email = cachedValue;
11985
+ emailProvider = cached.provider;
12007
11986
  }
12008
- } catch {
11987
+ if (field === "phone") {
11988
+ phone = cachedValue;
11989
+ phoneProvider = cached.provider;
11990
+ }
11991
+ attempts.push({
11992
+ provider: cached.provider,
11993
+ capability,
11994
+ found: true,
11995
+ charged: false,
11996
+ duration_ms: 0,
11997
+ cached: true
11998
+ });
11999
+ continue;
12000
+ }
12001
+ const fieldResult = await enrichField(contact, field, config, registry, exhausted);
12002
+ attempts.push(...fieldResult.attempts);
12003
+ if (field === "email" && fieldResult.value) {
12004
+ email = fieldResult.value;
12005
+ emailProvider = fieldResult.provider;
12006
+ charged = true;
12007
+ } else if (field === "phone" && fieldResult.value) {
12008
+ phone = fieldResult.value;
12009
+ phoneProvider = fieldResult.provider;
12010
+ charged = true;
12009
12011
  }
12010
12012
  }
12011
- }
12012
- function formatTimestamp(date) {
12013
- const pad = (n) => String(n).padStart(2, "0");
12014
- return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
12015
- }
12016
- var FILE_OUTPUT_THRESHOLD, DEFAULT_RESULTS_DIR;
12017
- var init_result_writer = __esm({
12018
- "src/utils/result-writer.ts"() {
12019
- "use strict";
12020
- FILE_OUTPUT_THRESHOLD = 20;
12021
- DEFAULT_RESULTS_DIR = path.join(os.homedir(), ".gtm", "results");
12013
+ if (cacheKey && (email || phone)) {
12014
+ const existing = enrichmentCache.get(cacheKey);
12015
+ enrichmentCache.set(cacheKey, {
12016
+ email: email ?? existing?.email,
12017
+ phone: phone ?? existing?.phone,
12018
+ provider: attempts.find((a) => a.found && !a.cached)?.provider ?? existing?.provider ?? "unknown",
12019
+ timestamp: Date.now()
12020
+ });
12022
12021
  }
12023
- });
12024
-
12025
- // src/handlers/prospect.ts
12026
- var prospect_exports = {};
12027
- __export(prospect_exports, {
12028
- handleProspect: () => handleProspect
12029
- });
12030
- function deduplicatePeople(people) {
12031
- const seen = /* @__PURE__ */ new Set();
12022
+ return {
12023
+ contact,
12024
+ email,
12025
+ phone,
12026
+ email_provider: emailProvider,
12027
+ phone_provider: phoneProvider,
12028
+ attempts,
12029
+ charged
12030
+ };
12031
+ }
12032
+ async function enrichField(contact, field, config, registry, exhausted) {
12033
+ const capability = field === "email" ? "enrich_email" : "enrich_phone";
12034
+ const attempts = [];
12035
+ for (const providerName of config.providers) {
12036
+ if (exhausted.has(providerName)) {
12037
+ attempts.push({
12038
+ provider: providerName,
12039
+ capability,
12040
+ found: false,
12041
+ charged: false,
12042
+ duration_ms: 0,
12043
+ error: "Skipped \u2014 provider exhausted (no credits remaining this batch)"
12044
+ });
12045
+ continue;
12046
+ }
12047
+ const provider = registry.get(providerName);
12048
+ if (!provider || !provider.capabilities.includes(capability)) continue;
12049
+ for (let retryAttempt = 0; retryAttempt <= WATERFALL_RETRY_DELAYS.length; retryAttempt++) {
12050
+ const start = Date.now();
12051
+ try {
12052
+ let value;
12053
+ const providerTimeoutMs = config.providerTimeoutMs ?? DEFAULT_PROVIDER_TIMEOUT_MS;
12054
+ if (field === "email" && "enrichEmail" in provider) {
12055
+ const result = await withTimeout(
12056
+ provider.enrichEmail(contact),
12057
+ providerTimeoutMs,
12058
+ providerName
12059
+ );
12060
+ value = result?.email;
12061
+ } else if (field === "phone" && "enrichPhone" in provider) {
12062
+ const result = await withTimeout(
12063
+ provider.enrichPhone(contact),
12064
+ providerTimeoutMs,
12065
+ providerName
12066
+ );
12067
+ value = result?.phone;
12068
+ }
12069
+ const found = !!value;
12070
+ attempts.push({
12071
+ provider: providerName,
12072
+ capability,
12073
+ found,
12074
+ charged: found,
12075
+ duration_ms: Date.now() - start
12076
+ });
12077
+ if (found && config.stopOnFirst) {
12078
+ return { value, provider: providerName, attempts };
12079
+ }
12080
+ break;
12081
+ } catch (error) {
12082
+ const errorMsg = error instanceof Error ? error.message : String(error);
12083
+ if (isExhaustedError(error)) {
12084
+ exhausted.add(providerName);
12085
+ attempts.push({
12086
+ provider: providerName,
12087
+ capability,
12088
+ found: false,
12089
+ charged: false,
12090
+ duration_ms: Date.now() - start,
12091
+ error: `Provider exhausted \u2014 skipping for remaining contacts: ${errorMsg}`
12092
+ });
12093
+ break;
12094
+ }
12095
+ if (isRateLimitError(error) && retryAttempt < WATERFALL_RETRY_DELAYS.length) {
12096
+ const delay = WATERFALL_RETRY_DELAYS[retryAttempt];
12097
+ await new Promise((r) => setTimeout(r, delay));
12098
+ continue;
12099
+ }
12100
+ attempts.push({
12101
+ provider: providerName,
12102
+ capability,
12103
+ found: false,
12104
+ charged: false,
12105
+ duration_ms: Date.now() - start,
12106
+ error: errorMsg
12107
+ });
12108
+ logError(
12109
+ `waterfall:${providerName}`,
12110
+ error instanceof Error ? error : new Error(String(error)),
12111
+ { contact: `${contact.first_name} ${contact.last_name}` }
12112
+ );
12113
+ break;
12114
+ }
12115
+ }
12116
+ }
12117
+ return { attempts };
12118
+ }
12119
+ function dryRunWaterfall(contactsCount, config, registry) {
12120
+ const activeProviders = config.providers.filter((name) => registry.get(name));
12121
+ const costs = {};
12122
+ for (const name of activeProviders) {
12123
+ const pricing = DEFAULT_PRICING[name];
12124
+ if (pricing?.estimated_cost_per_hit) {
12125
+ costs[name] = pricing.estimated_cost_per_hit;
12126
+ } else if (pricing?.estimated_cost_per_lookup) {
12127
+ costs[name] = `${pricing.estimated_cost_per_lookup}/lookup`;
12128
+ } else {
12129
+ costs[name] = "unknown";
12130
+ }
12131
+ }
12132
+ return {
12133
+ dry_run: true,
12134
+ contacts_count: contactsCount,
12135
+ waterfall: activeProviders,
12136
+ pricing_model: "pay-on-find",
12137
+ estimated_cost_per_hit: costs,
12138
+ note: "Charged only when contact info is found. No result = no charge.",
12139
+ confirm: "Call gtm_enrich again without dry_run to execute."
12140
+ };
12141
+ }
12142
+ var enrichmentCache, DEFAULT_PRICING, EXHAUSTION_PATTERNS, WATERFALL_RETRY_DELAYS, DEFAULT_PROVIDER_TIMEOUT_MS, DEFAULT_BATCH_TIMEOUT_MS;
12143
+ var init_waterfall = __esm({
12144
+ "src/providers/waterfall.ts"() {
12145
+ "use strict";
12146
+ init_dist();
12147
+ enrichmentCache = /* @__PURE__ */ new Map();
12148
+ DEFAULT_PRICING = {
12149
+ bettercontact: { charged_only_on_find: true, estimated_cost_per_hit: "$0.10" },
12150
+ prospeo: { charged_only_on_find: true, estimated_cost_per_hit: "$0.05" },
12151
+ apollo: { charged_only_on_find: false, estimated_cost_per_lookup: "$0.03" },
12152
+ hunter: { charged_only_on_find: true, estimated_cost_per_hit: "$0.05" }
12153
+ };
12154
+ EXHAUSTION_PATTERNS = [
12155
+ "insufficient credits",
12156
+ "no credits",
12157
+ "0 credits",
12158
+ "plan limit",
12159
+ "quota exceeded",
12160
+ "upgrade your plan",
12161
+ "not accessible with this api_key on a free plan"
12162
+ ];
12163
+ WATERFALL_RETRY_DELAYS = [2e3, 5e3, 1e4];
12164
+ DEFAULT_PROVIDER_TIMEOUT_MS = 15e3;
12165
+ DEFAULT_BATCH_TIMEOUT_MS = 3e5;
12166
+ }
12167
+ });
12168
+
12169
+ // src/providers/types.ts
12170
+ function applySlimFields(obj, fields) {
12171
+ const result = {};
12172
+ for (const field of fields) {
12173
+ if (field in obj) {
12174
+ result[field] = obj[field];
12175
+ }
12176
+ }
12177
+ return result;
12178
+ }
12179
+ var SLIM_COMPANY_FIELDS, SLIM_PERSON_FIELDS;
12180
+ var init_types3 = __esm({
12181
+ "src/providers/types.ts"() {
12182
+ "use strict";
12183
+ SLIM_COMPANY_FIELDS = [
12184
+ "domain",
12185
+ "name",
12186
+ "industry",
12187
+ "employees",
12188
+ "country"
12189
+ ];
12190
+ SLIM_PERSON_FIELDS = [
12191
+ "first_name",
12192
+ "last_name",
12193
+ "job_title",
12194
+ "email",
12195
+ "company",
12196
+ "company_domain",
12197
+ "linkedin_url"
12198
+ ];
12199
+ }
12200
+ });
12201
+
12202
+ // src/providers/manifest-validation.ts
12203
+ function validateParams(provider, tool, args) {
12204
+ const manifest = getToolManifest(provider, tool);
12205
+ if (!manifest) return [];
12206
+ const supported = new Set(manifest.supported_params);
12207
+ const warnings = [];
12208
+ for (const [key, value] of Object.entries(args)) {
12209
+ if (value === void 0 || value === null) continue;
12210
+ if (controlSet.has(key)) continue;
12211
+ if (!supported.has(key)) {
12212
+ const alternatives = Object.entries(MANIFESTS).filter(([_, m]) => m[tool]?.supported_params.includes(key)).map(([name]) => name);
12213
+ const suggestion = alternatives.length > 0 ? ` Use provider: '${alternatives[0]}' for ${key} filtering.` : "";
12214
+ warnings.push(`${key} is not supported by ${provider}.${suggestion}`);
12215
+ }
12216
+ }
12217
+ return warnings;
12218
+ }
12219
+ function clampLimit(provider, tool, limit) {
12220
+ const manifest = getToolManifest(provider, tool);
12221
+ if (!manifest) return limit ?? 50;
12222
+ if (limit === void 0) return manifest.default_limit;
12223
+ return Math.min(limit, manifest.max_limit);
12224
+ }
12225
+ var controlSet;
12226
+ var init_manifest_validation = __esm({
12227
+ "src/providers/manifest-validation.ts"() {
12228
+ "use strict";
12229
+ init_manifests();
12230
+ init_manifest_types();
12231
+ controlSet = new Set(CONTROL_PARAMS);
12232
+ }
12233
+ });
12234
+
12235
+ // src/utils/result-writer.ts
12236
+ import * as fs from "fs";
12237
+ import * as path from "path";
12238
+ import * as os from "os";
12239
+ function shouldWriteToFile(count, output) {
12240
+ if (output === "file") return true;
12241
+ if (output === "inline") return false;
12242
+ if (output === "csv") return false;
12243
+ return count > FILE_OUTPUT_THRESHOLD;
12244
+ }
12245
+ function writeResultsFile(tool, results, meta, resultsDir = DEFAULT_RESULTS_DIR) {
12246
+ fs.mkdirSync(resultsDir, { recursive: true });
12247
+ const timestamp = formatTimestamp(/* @__PURE__ */ new Date());
12248
+ const filename = `${tool}-${timestamp}.jsonl`;
12249
+ const filePath = path.join(resultsDir, filename);
12250
+ const metaLine = JSON.stringify({ _meta: { ...meta, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
12251
+ const dataLines = results.map((r) => JSON.stringify(r));
12252
+ const content = [metaLine, ...dataLines].join("\n") + "\n";
12253
+ fs.writeFileSync(filePath, content, "utf-8");
12254
+ try {
12255
+ cleanOldResults(7, resultsDir);
12256
+ } catch {
12257
+ }
12258
+ return filePath;
12259
+ }
12260
+ function writeResultsCsv(tool, results, resultsDir = DEFAULT_RESULTS_DIR) {
12261
+ fs.mkdirSync(resultsDir, { recursive: true });
12262
+ const timestamp = formatTimestamp(/* @__PURE__ */ new Date());
12263
+ const filename = `${tool}-${timestamp}.csv`;
12264
+ const filePath = path.join(resultsDir, filename);
12265
+ if (results.length === 0) {
12266
+ fs.writeFileSync(filePath, "", "utf-8");
12267
+ return filePath;
12268
+ }
12269
+ const headers = Object.keys(results[0]);
12270
+ const headerLine = headers.map(escapeCsvField).join(",");
12271
+ const dataLines = results.map(
12272
+ (row) => headers.map((h) => escapeCsvField(formatCsvValue(row[h]))).join(",")
12273
+ );
12274
+ const content = [headerLine, ...dataLines].join("\n") + "\n";
12275
+ fs.writeFileSync(filePath, content, "utf-8");
12276
+ try {
12277
+ cleanOldResults(7, resultsDir);
12278
+ } catch {
12279
+ }
12280
+ return filePath;
12281
+ }
12282
+ function formatCsvValue(value) {
12283
+ if (value === null || value === void 0) return "";
12284
+ if (Array.isArray(value)) return value.join(";");
12285
+ if (typeof value === "object") return JSON.stringify(value);
12286
+ return String(value);
12287
+ }
12288
+ function escapeCsvField(value) {
12289
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
12290
+ return `"${value.replace(/"/g, '""')}"`;
12291
+ }
12292
+ return value;
12293
+ }
12294
+ function cleanOldResults(maxAgeDays, resultsDir = DEFAULT_RESULTS_DIR) {
12295
+ let entries;
12296
+ try {
12297
+ entries = fs.readdirSync(resultsDir, { withFileTypes: true });
12298
+ } catch {
12299
+ return;
12300
+ }
12301
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1e3;
12302
+ for (const entry of entries) {
12303
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
12304
+ const filePath = path.join(resultsDir, entry.name);
12305
+ try {
12306
+ const stat = fs.statSync(filePath);
12307
+ if (stat.mtimeMs < cutoff) {
12308
+ fs.unlinkSync(filePath);
12309
+ }
12310
+ } catch {
12311
+ }
12312
+ }
12313
+ }
12314
+ function formatTimestamp(date) {
12315
+ const pad = (n) => String(n).padStart(2, "0");
12316
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
12317
+ }
12318
+ var FILE_OUTPUT_THRESHOLD, DEFAULT_RESULTS_DIR;
12319
+ var init_result_writer = __esm({
12320
+ "src/utils/result-writer.ts"() {
12321
+ "use strict";
12322
+ FILE_OUTPUT_THRESHOLD = 20;
12323
+ DEFAULT_RESULTS_DIR = path.join(os.homedir(), ".gtm", "results");
12324
+ }
12325
+ });
12326
+
12327
+ // src/handlers/prospect.ts
12328
+ var prospect_exports = {};
12329
+ __export(prospect_exports, {
12330
+ handleProspect: () => handleProspect
12331
+ });
12332
+ function deduplicatePeople(people) {
12333
+ const seen = /* @__PURE__ */ new Set();
12032
12334
  return people.filter((p) => {
12033
12335
  const key = p.linkedin_url ?? p.email ?? `${p.first_name}_${p.last_name}_${p.company}`;
12034
12336
  if (seen.has(key)) return false;
@@ -12191,6 +12493,43 @@ async function findPeople(args, ctx) {
12191
12493
  };
12192
12494
  const results = await finder.findPeople(query);
12193
12495
  const dedupedResults = deduplicatePeople(results);
12496
+ const shouldEnrich = args.enrich ?? false;
12497
+ let enrichedCount = 0;
12498
+ if (shouldEnrich) {
12499
+ const needsEnrichment = dedupedResults.filter(
12500
+ (r) => !r.email || r.email.includes("*")
12501
+ );
12502
+ if (needsEnrichment.length > 0) {
12503
+ try {
12504
+ const contacts = needsEnrichment.map((r) => ({
12505
+ first_name: r.first_name,
12506
+ last_name: r.last_name,
12507
+ company: r.company,
12508
+ company_domain: r.company_domain,
12509
+ linkedin_url: r.linkedin_url
12510
+ }));
12511
+ const clientConfig = ctx.session.getActiveContext().config;
12512
+ const waterfallProviders = clientConfig.waterfall?.email ?? ["prospeo"];
12513
+ const waterfallConfig = {
12514
+ providers: waterfallProviders,
12515
+ find: ["email"],
12516
+ verify: false,
12517
+ stopOnFirst: true,
12518
+ concurrency: 5
12519
+ };
12520
+ const enrichResults = await runWaterfall(contacts, waterfallConfig, ctx.registry);
12521
+ for (let i = 0; i < needsEnrichment.length; i++) {
12522
+ const enriched = enrichResults[i];
12523
+ if (enriched?.email) {
12524
+ needsEnrichment[i].email = enriched.email;
12525
+ enrichedCount++;
12526
+ }
12527
+ }
12528
+ } catch (error) {
12529
+ logError("prospect:inlineEnrich", error instanceof Error ? error : new Error(String(error)));
12530
+ }
12531
+ }
12532
+ }
12194
12533
  const resultsWithAlias = dedupedResults.map((r) => ({
12195
12534
  ...r,
12196
12535
  company_domain: r.company_domain ?? r.company
@@ -12257,6 +12596,7 @@ async function findPeople(args, ctx) {
12257
12596
  has_more,
12258
12597
  offset,
12259
12598
  ...Object.keys(applied_filters).length > 0 && { applied_filters },
12599
+ ...shouldEnrich && { enriched: enrichedCount },
12260
12600
  people: slimmedResults,
12261
12601
  ...warnings?.length ? { warnings } : {}
12262
12602
  };
@@ -12264,7 +12604,9 @@ async function findPeople(args, ctx) {
12264
12604
  var init_prospect2 = __esm({
12265
12605
  "src/handlers/prospect.ts"() {
12266
12606
  "use strict";
12607
+ init_dist();
12267
12608
  init_errors();
12609
+ init_waterfall();
12268
12610
  init_types3();
12269
12611
  init_manifest_validation();
12270
12612
  init_result_writer();
@@ -12272,266 +12614,6 @@ var init_prospect2 = __esm({
12272
12614
  }
12273
12615
  });
12274
12616
 
12275
- // src/providers/waterfall.ts
12276
- function getCacheKey(contact) {
12277
- if (!contact.linkedin_url) return null;
12278
- return contact.linkedin_url.toLowerCase().trim();
12279
- }
12280
- function isExhaustedError(error) {
12281
- const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
12282
- return EXHAUSTION_PATTERNS.some((p) => msg.includes(p));
12283
- }
12284
- function isRateLimitError(error) {
12285
- if (error instanceof Error && "status" in error) {
12286
- return error.status === 429;
12287
- }
12288
- const msg = error instanceof Error ? error.message : String(error);
12289
- return msg.includes("429") || msg.toLowerCase().includes("rate limit");
12290
- }
12291
- function withTimeout(promise, ms, label) {
12292
- return new Promise((resolve, reject) => {
12293
- const timer = setTimeout(
12294
- () => reject(new Error(`Provider timeout: ${label} did not respond within ${ms}ms`)),
12295
- ms
12296
- );
12297
- promise.then(
12298
- (val) => {
12299
- clearTimeout(timer);
12300
- resolve(val);
12301
- },
12302
- (err) => {
12303
- clearTimeout(timer);
12304
- reject(err);
12305
- }
12306
- );
12307
- });
12308
- }
12309
- async function runWaterfall(contacts, config, registry) {
12310
- const results = [];
12311
- const exhausted = /* @__PURE__ */ new Set();
12312
- const batchDeadline = Date.now() + (config.batchTimeoutMs ?? DEFAULT_BATCH_TIMEOUT_MS);
12313
- for (const contact of contacts) {
12314
- if (Date.now() > batchDeadline) {
12315
- results.push({
12316
- contact,
12317
- attempts: [
12318
- {
12319
- provider: "waterfall",
12320
- capability: "enrich_email",
12321
- found: false,
12322
- charged: false,
12323
- duration_ms: 0,
12324
- error: "Batch timeout exceeded \u2014 skipped remaining contacts"
12325
- }
12326
- ],
12327
- charged: false
12328
- });
12329
- continue;
12330
- }
12331
- const result = await enrichContact(contact, config, registry, exhausted);
12332
- results.push(result);
12333
- }
12334
- return results;
12335
- }
12336
- async function enrichContact(contact, config, registry, exhausted) {
12337
- const attempts = [];
12338
- let email;
12339
- let emailProvider;
12340
- let phone;
12341
- let phoneProvider;
12342
- let charged = false;
12343
- const cacheKey = getCacheKey(contact);
12344
- const cached = cacheKey ? enrichmentCache.get(cacheKey) : null;
12345
- for (const field of config.find) {
12346
- const capability = field === "email" ? "enrich_email" : "enrich_phone";
12347
- const cachedValue = cached?.[field];
12348
- if (cachedValue) {
12349
- if (field === "email") {
12350
- email = cachedValue;
12351
- emailProvider = cached.provider;
12352
- }
12353
- if (field === "phone") {
12354
- phone = cachedValue;
12355
- phoneProvider = cached.provider;
12356
- }
12357
- attempts.push({
12358
- provider: cached.provider,
12359
- capability,
12360
- found: true,
12361
- charged: false,
12362
- duration_ms: 0,
12363
- cached: true
12364
- });
12365
- continue;
12366
- }
12367
- const fieldResult = await enrichField(contact, field, config, registry, exhausted);
12368
- attempts.push(...fieldResult.attempts);
12369
- if (field === "email" && fieldResult.value) {
12370
- email = fieldResult.value;
12371
- emailProvider = fieldResult.provider;
12372
- charged = true;
12373
- } else if (field === "phone" && fieldResult.value) {
12374
- phone = fieldResult.value;
12375
- phoneProvider = fieldResult.provider;
12376
- charged = true;
12377
- }
12378
- }
12379
- if (cacheKey && (email || phone)) {
12380
- const existing = enrichmentCache.get(cacheKey);
12381
- enrichmentCache.set(cacheKey, {
12382
- email: email ?? existing?.email,
12383
- phone: phone ?? existing?.phone,
12384
- provider: attempts.find((a) => a.found && !a.cached)?.provider ?? existing?.provider ?? "unknown",
12385
- timestamp: Date.now()
12386
- });
12387
- }
12388
- return {
12389
- contact,
12390
- email,
12391
- phone,
12392
- email_provider: emailProvider,
12393
- phone_provider: phoneProvider,
12394
- attempts,
12395
- charged
12396
- };
12397
- }
12398
- async function enrichField(contact, field, config, registry, exhausted) {
12399
- const capability = field === "email" ? "enrich_email" : "enrich_phone";
12400
- const attempts = [];
12401
- for (const providerName of config.providers) {
12402
- if (exhausted.has(providerName)) {
12403
- attempts.push({
12404
- provider: providerName,
12405
- capability,
12406
- found: false,
12407
- charged: false,
12408
- duration_ms: 0,
12409
- error: "Skipped \u2014 provider exhausted (no credits remaining this batch)"
12410
- });
12411
- continue;
12412
- }
12413
- const provider = registry.get(providerName);
12414
- if (!provider || !provider.capabilities.includes(capability)) continue;
12415
- for (let retryAttempt = 0; retryAttempt <= WATERFALL_RETRY_DELAYS.length; retryAttempt++) {
12416
- const start = Date.now();
12417
- try {
12418
- let value;
12419
- const providerTimeoutMs = config.providerTimeoutMs ?? DEFAULT_PROVIDER_TIMEOUT_MS;
12420
- if (field === "email" && "enrichEmail" in provider) {
12421
- const result = await withTimeout(
12422
- provider.enrichEmail(contact),
12423
- providerTimeoutMs,
12424
- providerName
12425
- );
12426
- value = result?.email;
12427
- } else if (field === "phone" && "enrichPhone" in provider) {
12428
- const result = await withTimeout(
12429
- provider.enrichPhone(contact),
12430
- providerTimeoutMs,
12431
- providerName
12432
- );
12433
- value = result?.phone;
12434
- }
12435
- const found = !!value;
12436
- attempts.push({
12437
- provider: providerName,
12438
- capability,
12439
- found,
12440
- charged: found,
12441
- duration_ms: Date.now() - start
12442
- });
12443
- if (found && config.stopOnFirst) {
12444
- return { value, provider: providerName, attempts };
12445
- }
12446
- break;
12447
- } catch (error) {
12448
- const errorMsg = error instanceof Error ? error.message : String(error);
12449
- if (isExhaustedError(error)) {
12450
- exhausted.add(providerName);
12451
- attempts.push({
12452
- provider: providerName,
12453
- capability,
12454
- found: false,
12455
- charged: false,
12456
- duration_ms: Date.now() - start,
12457
- error: `Provider exhausted \u2014 skipping for remaining contacts: ${errorMsg}`
12458
- });
12459
- break;
12460
- }
12461
- if (isRateLimitError(error) && retryAttempt < WATERFALL_RETRY_DELAYS.length) {
12462
- const delay = WATERFALL_RETRY_DELAYS[retryAttempt];
12463
- await new Promise((r) => setTimeout(r, delay));
12464
- continue;
12465
- }
12466
- attempts.push({
12467
- provider: providerName,
12468
- capability,
12469
- found: false,
12470
- charged: false,
12471
- duration_ms: Date.now() - start,
12472
- error: errorMsg
12473
- });
12474
- logError(
12475
- `waterfall:${providerName}`,
12476
- error instanceof Error ? error : new Error(String(error)),
12477
- { contact: `${contact.first_name} ${contact.last_name}` }
12478
- );
12479
- break;
12480
- }
12481
- }
12482
- }
12483
- return { attempts };
12484
- }
12485
- function dryRunWaterfall(contactsCount, config, registry) {
12486
- const activeProviders = config.providers.filter((name) => registry.get(name));
12487
- const costs = {};
12488
- for (const name of activeProviders) {
12489
- const pricing = DEFAULT_PRICING[name];
12490
- if (pricing?.estimated_cost_per_hit) {
12491
- costs[name] = pricing.estimated_cost_per_hit;
12492
- } else if (pricing?.estimated_cost_per_lookup) {
12493
- costs[name] = `${pricing.estimated_cost_per_lookup}/lookup`;
12494
- } else {
12495
- costs[name] = "unknown";
12496
- }
12497
- }
12498
- return {
12499
- dry_run: true,
12500
- contacts_count: contactsCount,
12501
- waterfall: activeProviders,
12502
- pricing_model: "pay-on-find",
12503
- estimated_cost_per_hit: costs,
12504
- note: "Charged only when contact info is found. No result = no charge.",
12505
- confirm: "Call gtm_enrich again without dry_run to execute."
12506
- };
12507
- }
12508
- var enrichmentCache, DEFAULT_PRICING, EXHAUSTION_PATTERNS, WATERFALL_RETRY_DELAYS, DEFAULT_PROVIDER_TIMEOUT_MS, DEFAULT_BATCH_TIMEOUT_MS;
12509
- var init_waterfall = __esm({
12510
- "src/providers/waterfall.ts"() {
12511
- "use strict";
12512
- init_dist();
12513
- enrichmentCache = /* @__PURE__ */ new Map();
12514
- DEFAULT_PRICING = {
12515
- bettercontact: { charged_only_on_find: true, estimated_cost_per_hit: "$0.10" },
12516
- prospeo: { charged_only_on_find: true, estimated_cost_per_hit: "$0.05" },
12517
- apollo: { charged_only_on_find: false, estimated_cost_per_lookup: "$0.03" },
12518
- hunter: { charged_only_on_find: true, estimated_cost_per_hit: "$0.05" }
12519
- };
12520
- EXHAUSTION_PATTERNS = [
12521
- "insufficient credits",
12522
- "no credits",
12523
- "0 credits",
12524
- "plan limit",
12525
- "quota exceeded",
12526
- "upgrade your plan",
12527
- "not accessible with this api_key on a free plan"
12528
- ];
12529
- WATERFALL_RETRY_DELAYS = [2e3, 5e3, 1e4];
12530
- DEFAULT_PROVIDER_TIMEOUT_MS = 15e3;
12531
- DEFAULT_BATCH_TIMEOUT_MS = 3e5;
12532
- }
12533
- });
12534
-
12535
12617
  // src/handlers/enrich.ts
12536
12618
  var enrich_exports = {};
12537
12619
  __export(enrich_exports, {
@@ -13440,7 +13522,8 @@ async function createTable(args) {
13440
13522
  const body = await response.json().catch(() => ({}));
13441
13523
  throw new Error(`Viewer API error ${response.status}: ${JSON.stringify(body)}`);
13442
13524
  }
13443
- return response.json();
13525
+ const result = await response.json();
13526
+ return { ...result, row_ids: rows.map((r) => r._row_id) };
13444
13527
  }
13445
13528
  async function updateTable(args) {
13446
13529
  const tableId = args.table_id;
@@ -13750,6 +13833,7 @@ var init_validation = __esm({
13750
13833
  min_employees: z2.number().int().min(0).optional(),
13751
13834
  max_employees: z2.number().int().min(1).optional(),
13752
13835
  revenue_range: z2.string().optional(),
13836
+ enrich: z2.boolean().optional(),
13753
13837
  fields: z2.union([z2.literal("all"), z2.array(z2.string())]).optional(),
13754
13838
  output: z2.enum(["file", "inline", "csv"]).optional(),
13755
13839
  detail: detailSchema