@mackody/quickenrich-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.codex-plugin/plugin.json +39 -0
  2. package/.env.example +17 -0
  3. package/.mcp.json +17 -0
  4. package/README.md +283 -0
  5. package/agents/AGENTS.md +6 -0
  6. package/agents/CLAUDE.md +6 -0
  7. package/package.json +40 -0
  8. package/scripts/agent-docs-smoke.mjs +72 -0
  9. package/scripts/agent-envelope-workflow-smoke.mjs +92 -0
  10. package/scripts/agent-surface-smoke.mjs +102 -0
  11. package/scripts/benchmark-contract-smoke.mjs +46 -0
  12. package/scripts/envelope-budget-smoke.mjs +37 -0
  13. package/scripts/killer-contract-smoke.mjs +206 -0
  14. package/scripts/lib/benchmark.mjs +92 -0
  15. package/scripts/lib/core.mjs +3303 -0
  16. package/scripts/live-benchmark.mjs +518 -0
  17. package/scripts/live-smoke.mjs +61 -0
  18. package/scripts/local-floor-benchmark.mjs +366 -0
  19. package/scripts/mcp-config-smoke.mjs +110 -0
  20. package/scripts/mcp-live-smoke.mjs +115 -0
  21. package/scripts/mock-api-smoke.mjs +112 -0
  22. package/scripts/native-capabilities-smoke.mjs +273 -0
  23. package/scripts/native-live-smoke.mjs +43 -0
  24. package/scripts/offline-contract-smoke.mjs +80 -0
  25. package/scripts/ops-contract-smoke.mjs +270 -0
  26. package/scripts/paged-finder-floor-smoke.mjs +93 -0
  27. package/scripts/perf-contract-smoke.mjs +111 -0
  28. package/scripts/quickenrich-agent-docs.mjs +109 -0
  29. package/scripts/quickenrich-mcp.mjs +201 -0
  30. package/scripts/quickenrich-token.mjs +175 -0
  31. package/scripts/rate-limit-contract-smoke.mjs +70 -0
  32. package/scripts/reverse-concurrency-floor-smoke.mjs +82 -0
  33. package/scripts/serialization-floor-smoke.mjs +42 -0
  34. package/scripts/token-bucket-contract-smoke.mjs +121 -0
  35. package/scripts/token-cli-smoke.mjs +59 -0
  36. package/skills/quickenrich/SKILL.md +20 -0
@@ -0,0 +1,3303 @@
1
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { Agent as HttpAgent, request as httpRequest } from "node:http";
4
+ import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
5
+ import { homedir } from "node:os";
6
+ import { dirname, extname, join } from "node:path";
7
+
8
+ export const SERVER_VERSION = "0.1.0";
9
+ export const DEFAULT_API_BASE_URL = "https://app.quickenrich.io";
10
+ export const DOCS_URL = "https://app.quickenrich.io/docs";
11
+ export const DEFAULT_CONFIG_PATH = join(homedir(), ".config", "quickenrich-mcp", "config.json");
12
+ export const DEFAULT_DATA_DIR = join(homedir(), ".cache", "quickenrich-mcp");
13
+ export const MAX_OUTPUT_CHARS = positiveInteger(process.env.QUICKENRICH_MCP_MAX_OUTPUT_CHARS, 12_000);
14
+
15
+ const DEFAULT_TIMEOUT_MS = positiveInteger(process.env.QUICKENRICH_API_TIMEOUT_MS, 30_000);
16
+ const DEFAULT_RETRY_COUNT = positiveInteger(process.env.QUICKENRICH_API_RETRY_COUNT, 2);
17
+ const DEFAULT_RETRY_BASE_MS = positiveInteger(process.env.QUICKENRICH_API_RETRY_BASE_MS, 250);
18
+ const DEFAULT_RESPONSE_MODE = process.env.QUICKENRICH_MCP_RESPONSE_MODE || "compact";
19
+ const DEFAULT_DOMAIN_SEARCH_RPM = positiveInteger(process.env.QUICKENRICH_DOMAIN_SEARCH_REQUESTS_PER_MINUTE, 300);
20
+ const DEFAULT_DOMAIN_SEARCH_BURST = positiveInteger(process.env.QUICKENRICH_DOMAIN_SEARCH_BURST_SIZE, 8);
21
+ const RESPONSE_MODES = new Set(["compact", "full", "meta"]);
22
+ const PROJECTION_MODES = new Set(["full", "people", "companies", "emails", "phones", "ids"]);
23
+ const CACHE_MODES = new Set(["off", "read", "write", "readwrite", "refresh", "cache_only"]);
24
+ const SERIALIZED_TOOL_PAYLOAD = Symbol("quickenrich.serializedToolPayload");
25
+
26
+ export const RATE_LIMITS = {
27
+ employee_search: 1000,
28
+ phone_search: 1000,
29
+ reverse_email: 1000,
30
+ domain_search: 300,
31
+ contact_finder: 120,
32
+ company_finder: 120,
33
+ lookup_values: 0,
34
+ };
35
+
36
+ const DEFAULT_BURSTS = {
37
+ employee_search: 16,
38
+ phone_search: 16,
39
+ reverse_email: 16,
40
+ domain_search: DEFAULT_DOMAIN_SEARCH_BURST,
41
+ contact_finder: 4,
42
+ company_finder: 4,
43
+ };
44
+ const MAX_BATCH_CONCURRENCY = 32;
45
+ const MAX_EMAIL_BATCH_CONCURRENCY = 128;
46
+ const USE_FETCH_TRANSPORT = /^(1|true|yes|fetch)$/i.test(String(process.env.QUICKENRICH_API_USE_FETCH || ""));
47
+ const SHARED_HTTP_AGENT = new HttpAgent({ keepAlive: true, maxSockets: 256, maxFreeSockets: 64 });
48
+ const SHARED_HTTPS_AGENT = new HttpsAgent({ keepAlive: true, maxSockets: 256, maxFreeSockets: 64 });
49
+ let artifactWriteCounter = 0;
50
+
51
+ const LOOKUP_PATHS = {
52
+ country_codes: "/api/lookups/country-codes",
53
+ industries: "/api/lookups/industries",
54
+ employee_ranges: "/api/lookups/employee-ranges",
55
+ revenue_ranges: "/api/lookups/revenue-ranges",
56
+ company_services: "/api/lookups/company-services",
57
+ };
58
+
59
+ const RESERVED_EMAIL_TLDS = new Set(["example", "invalid", "localhost", "test"]);
60
+ const FOUNDER_TITLE = "Founder, CEO, Owner, President, Co-Founder, Managing Director";
61
+ const AGENT_TOOL_NAMES = new Set([
62
+ "quickenrich_auth_status",
63
+ "quickenrich_agent_graph",
64
+ "quickenrich_natural_language",
65
+ "quickenrich_pipeline",
66
+ "quickenrich_batch_pull_leads",
67
+ "quickenrich_batch_reverse_email",
68
+ "quickenrich_batch_employee_search",
69
+ "quickenrich_reverse_email",
70
+ "quickenrich_lookup_values",
71
+ ]);
72
+
73
+ const AGENT_TOOL_BRIEFS = {
74
+ quickenrich_auth_status: "Check auth/config without exposing the API key.",
75
+ quickenrich_agent_graph: "Compact map of QE tools, routes, limits, credits, and envelope policy.",
76
+ quickenrich_natural_language: "Plan a QE tool call from plain English; use execute=false for no-spend routing.",
77
+ quickenrich_pipeline: "Native enrichment workflows with file IO, resume/output artifacts, projections, cache, and budget guards.",
78
+ quickenrich_batch_pull_leads: "Primary batch lead/company pull: domain_search, contact_finder, company_finder; supports CSV/JSONL, max_credits, resume_state, export.",
79
+ quickenrich_batch_reverse_email: "Batch reverse-email lookup from emails/CSV/JSONL with dedupe, max_credits, resume_state, export.",
80
+ quickenrich_batch_employee_search: "Batch exact-person email lookup from persons/CSV/JSONL; optional phone lane; max_credits, resume_state, export.",
81
+ quickenrich_employee_search: "Single exact person lookup using linkedin_url or company_url + first_name + last_name.",
82
+ quickenrich_reverse_email: "Single reverse-email lookup.",
83
+ quickenrich_lookup_values: "Discover valid finder filter values: countries, industries, employee/revenue ranges, services.",
84
+ };
85
+
86
+ export const TOOL_DEFINITIONS = [
87
+ {
88
+ name: "quickenrich_auth_status",
89
+ description: "Check Quick Enrich MCP auth readiness without returning the API key. Env vars override local config.",
90
+ inputSchema: { type: "object", properties: {}, required: [] },
91
+ },
92
+ {
93
+ name: "quickenrich_agent_graph",
94
+ description: "Return a compact capability map for the Quick Enrich MCP server, including endpoint rate limits and credit notes.",
95
+ inputSchema: { type: "object", properties: { response: responseSchema() }, required: [] },
96
+ },
97
+ {
98
+ name: "quickenrich_natural_language",
99
+ description: "Plan, and optionally execute, a Quick Enrich action from natural language. Use execute=false to inspect the plan before spending requests.",
100
+ inputSchema: {
101
+ type: "object",
102
+ properties: {
103
+ request: { type: "string", description: "Plain-English request, e.g. 'pull founder leads for example.com and sample.com'." },
104
+ execute: { type: "boolean", description: "If true, execute the planned MCP tool. Default false.", default: false },
105
+ response: responseSchema(),
106
+ },
107
+ required: ["request"],
108
+ },
109
+ },
110
+ {
111
+ name: "quickenrich_employee_search",
112
+ description: "Find an exact person record and verified email using linkedin_url or company_url + first_name + last_name. Quick Enrich docs: GET /api/employees/search.",
113
+ inputSchema: {
114
+ type: "object",
115
+ properties: {
116
+ linkedin_url: { type: "string" },
117
+ company_url: { type: "string" },
118
+ first_name: { type: "string" },
119
+ last_name: { type: "string" },
120
+ include_raw: { type: "boolean", default: false },
121
+ response: responseSchema(),
122
+ },
123
+ required: [],
124
+ },
125
+ },
126
+ {
127
+ name: "quickenrich_phone_search",
128
+ description: "Look up a person's phone using linkedin_url or company_url + first_name + last_name. Quick Enrich docs: GET /api/employees/phone-search.",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ linkedin_url: { type: "string" },
133
+ company_url: { type: "string" },
134
+ first_name: { type: "string" },
135
+ last_name: { type: "string" },
136
+ include_raw: { type: "boolean", default: false },
137
+ response: responseSchema(),
138
+ },
139
+ required: [],
140
+ },
141
+ },
142
+ {
143
+ name: "quickenrich_reverse_email",
144
+ description: "Reverse lookup one exact email and return the best matching employee record. Quick Enrich docs: GET /api/employees/email-search.",
145
+ inputSchema: {
146
+ type: "object",
147
+ properties: {
148
+ email: { type: "string", description: "Email address to look up." },
149
+ include_raw: { type: "boolean", default: false },
150
+ response: responseSchema(),
151
+ },
152
+ required: ["email"],
153
+ },
154
+ },
155
+ {
156
+ name: "quickenrich_batch_reverse_email",
157
+ description: "Batch reverse-email lookup with dedupe, credit budget guard, endpoint throttling, compact output, and CSV/JSONL export.",
158
+ inputSchema: {
159
+ type: "object",
160
+ properties: {
161
+ emails: { type: "array", items: { type: "string" } },
162
+ input_csv: { type: "string", description: "CSV text with an email column." },
163
+ input_jsonl: { type: "string", description: "JSONL text with email fields." },
164
+ input_file: { type: "string", description: "Local CSV/JSONL file path or file:// URL. Keeps large batches out of agent context." },
165
+ concurrency: { type: "integer", default: 8 },
166
+ requests_per_minute: { type: "integer", default: RATE_LIMITS.reverse_email },
167
+ burst_size: { type: "integer", default: 1 },
168
+ throttle_scope: { type: "string", enum: ["process", "shared"], default: "process" },
169
+ max_credits: { type: "integer", description: "Stop starting new paid lookups after this many credits are used." },
170
+ resume_state: { type: "object", description: "Prior batch state with completed/failed keys to skip already processed emails." },
171
+ resume_file: { type: "string", description: "Local JSON resume-state file to read/update after every row." },
172
+ output_file: { type: "string", description: "Local CSV/JSONL output file for results. Format inferred from extension unless export_format is set." },
173
+ export_format: { type: "string", enum: ["none", "jsonl", "csv"], default: "none" },
174
+ projection: projectionSchema(),
175
+ cache_mode: cacheModeSchema(),
176
+ credit_ledger: { type: "boolean", default: false },
177
+ response: responseSchema(),
178
+ },
179
+ required: [],
180
+ },
181
+ },
182
+ {
183
+ name: "quickenrich_batch_employee_search",
184
+ description: "Batch exact-person email lookup using linkedin_url or company_url + first_name + last_name. Supports phone enrichment, credit guard, throttling, CSV/JSONL inputs, and CSV/JSONL export.",
185
+ inputSchema: {
186
+ type: "object",
187
+ properties: {
188
+ persons: {
189
+ type: "array",
190
+ items: {
191
+ type: "object",
192
+ properties: {
193
+ linkedin_url: { type: "string" },
194
+ company_url: { type: "string" },
195
+ first_name: { type: "string" },
196
+ last_name: { type: "string" },
197
+ },
198
+ },
199
+ },
200
+ input_csv: { type: "string", description: "CSV text with linkedin_url or company_url, first_name, last_name columns." },
201
+ input_jsonl: { type: "string", description: "JSONL text with exact-person lookup fields." },
202
+ input_file: { type: "string", description: "Local CSV/JSONL file path or file:// URL." },
203
+ include_phone: { type: "boolean", default: false },
204
+ concurrency: { type: "integer", default: 8 },
205
+ requests_per_minute: { type: "integer", default: RATE_LIMITS.employee_search },
206
+ phone_requests_per_minute: { type: "integer", default: RATE_LIMITS.phone_search },
207
+ burst_size: { type: "integer", default: 1 },
208
+ throttle_scope: { type: "string", enum: ["process", "shared"], default: "process" },
209
+ max_credits: { type: "integer", description: "Stop starting new paid lookups after this many credits are used." },
210
+ resume_state: { type: "object", description: "Prior batch state with completed/failed keys to skip already processed exact-person lookups." },
211
+ resume_file: { type: "string", description: "Local JSON resume-state file to read/update after every row." },
212
+ output_file: { type: "string", description: "Local CSV/JSONL output file for results." },
213
+ export_format: { type: "string", enum: ["none", "jsonl", "csv"], default: "none" },
214
+ projection: projectionSchema(),
215
+ cache_mode: cacheModeSchema(),
216
+ credit_ledger: { type: "boolean", default: false },
217
+ response: responseSchema(),
218
+ },
219
+ required: [],
220
+ },
221
+ },
222
+ {
223
+ name: "quickenrich_domain_search",
224
+ description: "Pull contacts by company_url/domain and optional title. Paginates up to 20 contacts/page. Quick Enrich docs: GET /api/employees/dataset-search.",
225
+ inputSchema: {
226
+ type: "object",
227
+ properties: {
228
+ company_url: { type: "string", description: "Company URL or domain." },
229
+ title: { type: "string", description: "Optional title filter, e.g. Founder or CEO." },
230
+ has_email: { type: "boolean", description: "If true, restrict results to contacts with an email signal." },
231
+ page: { type: "integer", default: 1 },
232
+ per_page: { type: "integer", default: 20 },
233
+ include_raw: { type: "boolean", default: false },
234
+ response: responseSchema(),
235
+ },
236
+ required: ["company_url"],
237
+ },
238
+ },
239
+ {
240
+ name: "quickenrich_contact_finder",
241
+ description: "Discovery search across contact/company filters. Does not return email/phone values, only has_email/has_phone flags. Quick Enrich docs: POST /api/employees/contact-finder.",
242
+ inputSchema: {
243
+ type: "object",
244
+ properties: {
245
+ body: { type: "object", description: "Raw Quick Enrich contact-finder body." },
246
+ filters: { type: "object", description: "Typed include/exclude filters for company_name, city, country_code, industry_linkedin, number_of_employees, revenue, title, and locality." },
247
+ has_email: { type: "boolean" },
248
+ has_phone: { type: "boolean" },
249
+ validate_filters: { type: "boolean", default: false },
250
+ page: { type: "integer", default: 1 },
251
+ per_page: { type: "integer", default: 10 },
252
+ include_raw: { type: "boolean", default: false },
253
+ response: responseSchema(),
254
+ },
255
+ required: [],
256
+ },
257
+ },
258
+ {
259
+ name: "quickenrich_company_finder",
260
+ description: "Search companies by firmographic and text filters. Quick Enrich docs: POST /api/companies/company-finder.",
261
+ inputSchema: {
262
+ type: "object",
263
+ properties: {
264
+ body: { type: "object", description: "Raw Quick Enrich company-finder body." },
265
+ filters: { type: "object", description: "Typed include/exclude filters for home_page_text, bio_li, services, industry, number_of_employees, revenue, country_code, city, and company_name." },
266
+ company_url: { type: "string", description: "Single domain or URL; no include/exclude wrapper." },
267
+ validate_filters: { type: "boolean", default: false },
268
+ page: { type: "integer", default: 1 },
269
+ per_page: { type: "integer", default: 10 },
270
+ include_raw: { type: "boolean", default: false },
271
+ response: responseSchema(),
272
+ },
273
+ required: [],
274
+ },
275
+ },
276
+ {
277
+ name: "quickenrich_batch_pull_leads",
278
+ description: "Batch pull leads from company domains using domain search, contact finder, or company finder. Supports CSV/JSONL inputs, dry_run request planning, concurrency, dedupe, compact output, and optional contact enrichment.",
279
+ inputSchema: {
280
+ type: "object",
281
+ properties: {
282
+ mode: { type: "string", enum: ["domain_search", "contact_finder", "company_finder"], default: "domain_search" },
283
+ companies: {
284
+ type: "array",
285
+ items: {
286
+ type: "object",
287
+ properties: {
288
+ company_url: { type: "string" },
289
+ domain: { type: "string" },
290
+ company_name: { type: "string" },
291
+ title: { type: "string" },
292
+ },
293
+ },
294
+ },
295
+ body: { type: "object", description: "Finder body for contact_finder or company_finder mode." },
296
+ input_csv: { type: "string", description: "CSV text with company_url/domain/url and optional title columns for domain_search mode." },
297
+ input_jsonl: { type: "string", description: "JSONL text with company_url/domain/url and optional title fields for domain_search mode." },
298
+ input_file: { type: "string", description: "Local CSV/JSONL file path or file:// URL." },
299
+ title: { type: "string", description: "Default title filter for domain_search mode." },
300
+ has_email: { type: "boolean", description: "Domain-search filter: only return contacts with an email signal when true." },
301
+ has_phone: { type: "boolean", description: "Contact Finder filter: only return contacts with a phone signal when true." },
302
+ per_company_limit: { type: "integer", default: 20 },
303
+ pages_per_company: { type: "integer", default: 1 },
304
+ per_page: { type: "integer", default: 20 },
305
+ concurrency: { type: "integer", default: 8 },
306
+ requests_per_minute: { type: "integer", default: RATE_LIMITS.domain_search, description: "Domain-search request start ceiling. Default is Quick Enrich's published 300/min. Set higher only with provider approval." },
307
+ burst_size: { type: "integer", default: 1, description: "Initial request burst before pacing. Default 1 is strict; raise only when the account/provider allows burst traffic." },
308
+ throttle_scope: { type: "string", enum: ["process", "shared"], default: "process" },
309
+ filters: { type: "object", description: "Typed Contact/Company Finder include/exclude filters. Arrays become {include:[...],exclude:[]}." },
310
+ max_results: { type: "integer", description: "Maximum records to collect for finder modes." },
311
+ max_credits: { type: "integer", description: "Stop starting new paid requests after this many credits are used." },
312
+ validate_filters: { type: "boolean", default: false },
313
+ resume_state: { type: "object", description: "Prior batch state with completed/failed keys for resumable large runs." },
314
+ resume_file: { type: "string", description: "Local JSON resume-state file to read/update after every page/company." },
315
+ output_file: { type: "string", description: "Local CSV/JSONL output file for results." },
316
+ export_format: { type: "string", enum: ["none", "jsonl", "csv"], default: "none" },
317
+ projection: projectionSchema(),
318
+ cache_mode: cacheModeSchema(),
319
+ credit_ledger: { type: "boolean", default: false },
320
+ enrich_contacts: { type: "boolean", default: false },
321
+ include_phone_enrichment: { type: "boolean", default: false },
322
+ max_enrichments: { type: "integer", default: 50 },
323
+ dry_run: { type: "boolean", default: false },
324
+ response: responseSchema(),
325
+ },
326
+ required: [],
327
+ },
328
+ },
329
+ {
330
+ name: "quickenrich_pipeline",
331
+ description: "Run native Quick Enrich workflows end to end. Starts with company discovery, dedupes domains, pulls contacts, writes durable output/resume files, and returns compact projected rows.",
332
+ inputSchema: {
333
+ type: "object",
334
+ properties: {
335
+ mode: { type: "string", enum: ["company_finder_to_domain_search"], default: "company_finder_to_domain_search" },
336
+ body: { type: "object", description: "Raw Quick Enrich company-finder body for the first pipeline step." },
337
+ filters: { type: "object", description: "Typed Company Finder filters." },
338
+ max_companies: { type: "integer", default: 25 },
339
+ title: { type: "string", description: "Domain-search title filter for the second pipeline step." },
340
+ per_company_limit: { type: "integer", default: 20 },
341
+ pages_per_company: { type: "integer", default: 1 },
342
+ concurrency: { type: "integer", default: 8 },
343
+ requests_per_minute: { type: "integer", default: RATE_LIMITS.domain_search },
344
+ burst_size: { type: "integer", default: 1 },
345
+ throttle_scope: { type: "string", enum: ["process", "shared"], default: "process" },
346
+ max_credits: { type: "integer" },
347
+ output_file: { type: "string" },
348
+ resume_file: { type: "string" },
349
+ export_format: { type: "string", enum: ["none", "jsonl", "csv"], default: "none" },
350
+ projection: projectionSchema(),
351
+ cache_mode: cacheModeSchema(),
352
+ credit_ledger: { type: "boolean", default: false },
353
+ response: responseSchema(),
354
+ },
355
+ required: [],
356
+ },
357
+ },
358
+ {
359
+ name: "quickenrich_lookup_values",
360
+ description: "Read public Quick Enrich lookup values for validated finder fields: country codes, industries, employee ranges, revenue ranges, or company services.",
361
+ inputSchema: {
362
+ type: "object",
363
+ properties: {
364
+ lookup: { type: "string", enum: Object.keys(LOOKUP_PATHS) },
365
+ q: { type: "string", description: "Optional search query for company_services." },
366
+ response: responseSchema(),
367
+ },
368
+ required: ["lookup"],
369
+ },
370
+ },
371
+ ];
372
+
373
+ export function responseSchema() {
374
+ return {
375
+ type: "object",
376
+ properties: {
377
+ mode: { type: "string", enum: ["compact", "full", "meta"], default: "compact" },
378
+ maxItems: { type: "integer", default: 25 },
379
+ maxKeys: { type: "integer", default: 80 },
380
+ maxDepth: { type: "integer", default: 5 },
381
+ maxStringChars: { type: "integer", default: 1200 },
382
+ maxOutputChars: { type: "integer", default: MAX_OUTPUT_CHARS },
383
+ },
384
+ };
385
+ }
386
+
387
+ export function projectionSchema() {
388
+ return { type: "string", enum: [...PROJECTION_MODES], default: "full" };
389
+ }
390
+
391
+ export function cacheModeSchema() {
392
+ return { type: "string", enum: [...CACHE_MODES], default: "off" };
393
+ }
394
+
395
+ export function toolMode() {
396
+ return String(process.env.QUICKENRICH_MCP_TOOL_MODE || "agent").toLowerCase();
397
+ }
398
+
399
+ export function selectTools(tools = TOOL_DEFINITIONS, mode = toolMode()) {
400
+ if (mode === "full") return tools;
401
+ const slim = (tool) => slimAgentToolDefinition(tool);
402
+ if (mode === "minimal") {
403
+ return tools.filter((tool) => [
404
+ "quickenrich_auth_status",
405
+ "quickenrich_agent_graph",
406
+ "quickenrich_natural_language",
407
+ "quickenrich_batch_pull_leads",
408
+ ].includes(tool.name)).map(slim);
409
+ }
410
+ return tools.filter((tool) => AGENT_TOOL_NAMES.has(tool.name)).map(slim);
411
+ }
412
+
413
+ function slimAgentToolDefinition(tool) {
414
+ return {
415
+ name: tool.name,
416
+ description: AGENT_TOOL_BRIEFS[tool.name] || tool.description,
417
+ inputSchema: slimAgentSchema(tool.inputSchema),
418
+ };
419
+ }
420
+
421
+ function slimAgentSchema(schema) {
422
+ if (!schema || typeof schema !== "object") return schema;
423
+ if (Array.isArray(schema)) return schema.map(slimAgentSchema);
424
+ const out = {};
425
+ for (const key of ["type", "enum", "required"]) {
426
+ if (schema[key] !== undefined) out[key] = schema[key];
427
+ }
428
+ if (schema.items) out.items = slimAgentSchema(schema.items);
429
+ if (schema.properties) {
430
+ out.properties = {};
431
+ for (const [key, value] of Object.entries(schema.properties)) {
432
+ out.properties[key] = key === "response" ? { type: "object" } : slimAgentSchema(value);
433
+ }
434
+ }
435
+ return out;
436
+ }
437
+
438
+ export function defaultConfigPath() {
439
+ return process.env.QUICKENRICH_MCP_CONFIG || DEFAULT_CONFIG_PATH;
440
+ }
441
+
442
+ export function readJsonFile(path) {
443
+ try {
444
+ return JSON.parse(readFileSync(path, "utf8"));
445
+ } catch (error) {
446
+ if (error?.code === "ENOENT") return undefined;
447
+ throw new Error(`Failed to read JSON config ${path}: ${error.message || error}`);
448
+ }
449
+ }
450
+
451
+ export function readConfig() {
452
+ const path = defaultConfigPath();
453
+ const config = readJsonFile(path) || {};
454
+ return { path, config };
455
+ }
456
+
457
+ export function writeToken(token, path = defaultConfigPath()) {
458
+ const value = String(token || "").trim();
459
+ if (!value) throw new Error("Quick Enrich API key is required.");
460
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
461
+ writeFileSync(path, `${JSON.stringify({ apiKey: value }, null, 2)}\n`, { mode: 0o600 });
462
+ chmodSync(path, 0o600);
463
+ return path;
464
+ }
465
+
466
+ export function removeToken(path = defaultConfigPath()) {
467
+ try {
468
+ rmSync(path, { force: true });
469
+ } catch {
470
+ // Removal is idempotent.
471
+ }
472
+ return path;
473
+ }
474
+
475
+ export function apiKey() {
476
+ const { config } = readConfig();
477
+ return (
478
+ process.env.QUICKENRICH_API_KEY ||
479
+ process.env.QUICKENRICH_TOKEN ||
480
+ config.apiKey ||
481
+ config.api_key ||
482
+ config.token ||
483
+ ""
484
+ ).trim();
485
+ }
486
+
487
+ export function apiBaseUrl() {
488
+ return (process.env.QUICKENRICH_API_BASE_URL || DEFAULT_API_BASE_URL).replace(/\/+$/, "");
489
+ }
490
+
491
+ export function authStatus() {
492
+ const key = apiKey();
493
+ const { path } = readConfig();
494
+ return {
495
+ configured: Boolean(key),
496
+ source: process.env.QUICKENRICH_API_KEY
497
+ ? "QUICKENRICH_API_KEY"
498
+ : process.env.QUICKENRICH_TOKEN
499
+ ? "QUICKENRICH_TOKEN"
500
+ : key
501
+ ? path
502
+ : "not_configured",
503
+ apiBaseUrl: apiBaseUrl(),
504
+ docsUrl: DOCS_URL,
505
+ configPath: path,
506
+ server: {
507
+ name: process.env.QUICKENRICH_MCP_SERVER_NAME || "quick-enrich",
508
+ version: SERVER_VERSION,
509
+ toolPrefix: "quickenrich_*",
510
+ },
511
+ };
512
+ }
513
+
514
+ export class QuickEnrichClient {
515
+ constructor(options = {}) {
516
+ this.baseUrl = (options.baseUrl || apiBaseUrl()).replace(/\/+$/, "");
517
+ this.fetchImpl = options.fetchImpl || null;
518
+ this.useFetchTransport = Boolean(this.fetchImpl) || USE_FETCH_TRANSPORT;
519
+ this.httpAgent = options.httpAgent || SHARED_HTTP_AGENT;
520
+ this.httpsAgent = options.httpsAgent || SHARED_HTTPS_AGENT;
521
+ this.timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS);
522
+ this.retryCount = positiveInteger(options.retryCount, DEFAULT_RETRY_COUNT);
523
+ this.retryBaseMs = positiveInteger(options.retryBaseMs, DEFAULT_RETRY_BASE_MS);
524
+ this.inflight = new Map();
525
+ this.throttleLimiters = new Map();
526
+ }
527
+
528
+ authHeader() {
529
+ const key = apiKey();
530
+ if (!key) {
531
+ const status = authStatus();
532
+ throw new Error(`Quick Enrich API key is not configured. Set QUICKENRICH_API_KEY or run quickenrich-token set. Config path: ${status.configPath}`);
533
+ }
534
+ return `Bearer ${key}`;
535
+ }
536
+
537
+ buildUrl(path, query = {}) {
538
+ return this.buildUrlTarget(path, query).toString();
539
+ }
540
+
541
+ buildUrlTarget(path, query = {}) {
542
+ if (!path || typeof path !== "string") throw new Error("path is required");
543
+ const base = new URL(this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`);
544
+ const target = /^https?:\/\//i.test(path) ? new URL(path) : new URL(path.replace(/^\/+/, ""), base);
545
+ if (target.origin !== base.origin) {
546
+ throw new Error(`Refusing to send Quick Enrich credentials outside ${base.origin}`);
547
+ }
548
+ for (const [key, value] of Object.entries(cleanObject(query))) {
549
+ if (Array.isArray(value)) {
550
+ for (const item of value) target.searchParams.append(key, String(item));
551
+ } else {
552
+ target.searchParams.set(key, String(value));
553
+ }
554
+ }
555
+ return target;
556
+ }
557
+
558
+ async request({ method = "GET", path, query, body, auth = true, endpoint = "request" }) {
559
+ const target = this.buildUrlTarget(path, query);
560
+ const url = target.toString();
561
+ const headers = {
562
+ Accept: "application/json",
563
+ "User-Agent": "quickenrich-mcp/0.1.0",
564
+ };
565
+ if (auth) headers.Authorization = this.authHeader();
566
+ let payload;
567
+ if (body !== undefined) {
568
+ payload = JSON.stringify(body);
569
+ headers["Content-Type"] = "application/json";
570
+ }
571
+
572
+ const requestKey = `${method.toUpperCase()} ${url} ${payload || ""} auth:${auth ? "1" : "0"}`;
573
+ if (this.inflight.has(requestKey)) return this.inflight.get(requestKey);
574
+ const pending = this._requestOnce({ method, path, endpoint, url, target, headers, payload, requestKey });
575
+ this.inflight.set(requestKey, pending);
576
+ try {
577
+ return await pending;
578
+ } finally {
579
+ this.inflight.delete(requestKey);
580
+ }
581
+ }
582
+
583
+ async _requestOnce({ method, path, endpoint, url, target, headers, payload }) {
584
+ let lastError;
585
+ for (let attempt = 0; attempt <= this.retryCount; attempt += 1) {
586
+ let timeout;
587
+ try {
588
+ let response;
589
+ if (this.useFetchTransport) {
590
+ const controller = new AbortController();
591
+ timeout = setTimeout(() => controller.abort(), this.timeoutMs);
592
+ response = await requestJsonFetch({
593
+ fetchImpl: this.fetchImpl || globalThis.fetch,
594
+ url,
595
+ method,
596
+ headers,
597
+ payload,
598
+ signal: controller.signal,
599
+ });
600
+ } else {
601
+ response = await requestJsonNative({
602
+ target,
603
+ method,
604
+ headers,
605
+ payload,
606
+ timeoutMs: this.timeoutMs,
607
+ httpAgent: this.httpAgent,
608
+ httpsAgent: this.httpsAgent,
609
+ });
610
+ }
611
+ if (timeout) clearTimeout(timeout);
612
+ if (response.ok) {
613
+ return {
614
+ ok: true,
615
+ endpoint,
616
+ status: response.status,
617
+ data: response.data,
618
+ rateLimit: rateLimitHeaders(response.headers),
619
+ };
620
+ }
621
+ const retryAfter = Number(headerValue(response.headers, "retry-after") || 0);
622
+ if ((response.status === 429 || response.status >= 500) && attempt < this.retryCount) {
623
+ await sleep(retryAfter ? retryAfter * 1000 : this.retryBaseMs * 2 ** attempt);
624
+ continue;
625
+ }
626
+ const error = new Error(`Quick Enrich ${method} ${path} failed with HTTP ${response.status}`);
627
+ error.result = { status: response.status, endpoint, data: response.data, rateLimit: rateLimitHeaders(response.headers) };
628
+ throw error;
629
+ } catch (error) {
630
+ if (timeout) clearTimeout(timeout);
631
+ lastError = error;
632
+ if (attempt < this.retryCount && (error?.name === "AbortError" || error?.code === "ECONNRESET")) {
633
+ await sleep(this.retryBaseMs * 2 ** attempt);
634
+ continue;
635
+ }
636
+ throw error;
637
+ }
638
+ }
639
+ throw lastError || new Error(`Quick Enrich ${method} ${path} failed`);
640
+ }
641
+
642
+ throttleLimiter(options) {
643
+ const key = `${options.endpoint}:${options.requestsPerMinute}:${options.burstSize}`;
644
+ if (!this.throttleLimiters.has(key)) {
645
+ this.throttleLimiters.set(key, createTokenBucketLimiter(options));
646
+ }
647
+ return this.throttleLimiters.get(key);
648
+ }
649
+ }
650
+
651
+ export function createClient(options = {}) {
652
+ return new QuickEnrichClient(options);
653
+ }
654
+
655
+ export async function callQuickEnrichTool(name, args = {}, client = createClient()) {
656
+ const source = args && typeof args === "object" ? args : {};
657
+ let result;
658
+ if (name === "quickenrich_auth_status") result = authStatus();
659
+ else if (name === "quickenrich_agent_graph") result = agentGraph();
660
+ else if (name === "quickenrich_natural_language") result = await naturalLanguage(source, client);
661
+ else if (name === "quickenrich_pipeline") result = await quickenrichPipeline(source, client);
662
+ else if (name === "quickenrich_employee_search") result = await employeeSearch(source, client);
663
+ else if (name === "quickenrich_phone_search") result = await phoneSearch(source, client);
664
+ else if (name === "quickenrich_reverse_email") result = await reverseEmail(source, client);
665
+ else if (name === "quickenrich_batch_reverse_email") result = await batchReverseEmail(source, client);
666
+ else if (name === "quickenrich_batch_employee_search") result = await batchEmployeeSearch(source, client);
667
+ else if (name === "quickenrich_domain_search") result = await domainSearch(source, client);
668
+ else if (name === "quickenrich_contact_finder") result = await contactFinder(source, client);
669
+ else if (name === "quickenrich_company_finder") result = await companyFinder(source, client);
670
+ else if (name === "quickenrich_batch_pull_leads") result = await batchPullLeads(source, client);
671
+ else if (name === "quickenrich_lookup_values") result = await lookupValues(source, client);
672
+ else throw new Error(`Unknown Quick Enrich tool: ${name}`);
673
+ return compactToolPayload(result, source.response);
674
+ }
675
+
676
+ export function agentGraph() {
677
+ return {
678
+ ok: true,
679
+ server: authStatus().server,
680
+ docsUrl: DOCS_URL,
681
+ auth: {
682
+ env: ["QUICKENRICH_API_KEY", "QUICKENRICH_TOKEN"],
683
+ configPath: defaultConfigPath(),
684
+ header: "Authorization: Bearer <api-key>",
685
+ },
686
+ defaultTools: [...AGENT_TOOL_NAMES],
687
+ surfaceModes: {
688
+ agent: "Default: 9 compact tools for normal agents, including lookup discovery.",
689
+ minimal: "Auth, graph, natural-language planner, and batch pull only.",
690
+ full: "All documented Quick Enrich endpoints and verbose schemas.",
691
+ },
692
+ envelopePolicy: {
693
+ default: { mode: "compact", maxOutputChars: MAX_OUTPUT_CHARS, maxItems: 12, maxDepth: 4, maxStringChars: 240 },
694
+ planning: { response: { mode: "meta" }, useWhen: "Need counts/shape only or large batches." },
695
+ export: "Use output_file for large jobs, export_format for small inline handoff, projection to shrink row fields.",
696
+ },
697
+ bulkIO: {
698
+ input_file: "Local .csv/.jsonl path or file:// URL on batch tools; avoids pasting rows into agent context.",
699
+ output_file: "Local .jsonl/.csv artifact for records; response returns path/count/bytes.",
700
+ resume_file: "Local JSON checkpoint merged with resume_state and updated after each row/page.",
701
+ },
702
+ projections: {
703
+ modes: ["full", "people", "companies", "emails", "phones", "ids"],
704
+ default: "full for compatibility; use emails/people/companies/ids for agent-token savings.",
705
+ },
706
+ cacheLedger: {
707
+ toolArgs: ["cache_mode", "credit_ledger"],
708
+ cacheModes: ["off", "read", "write", "readwrite", "refresh", "cache_only"],
709
+ paths: {
710
+ cacheDir: process.env.QUICKENRICH_MCP_CACHE_DIR || join(DEFAULT_DATA_DIR, "cache"),
711
+ ledgerPath: process.env.QUICKENRICH_MCP_LEDGER_PATH || join(DEFAULT_DATA_DIR, "credits.jsonl"),
712
+ },
713
+ },
714
+ evalBudgets: { toolsListBytes: 8_000, graphCompactBytes: 8_000, graphMetaBytes: 1_600 },
715
+ inputHints: {
716
+ domain_csv: "company_url/domain/url,title; use input_file/input_csv on batch_pull_leads mode=domain_search.",
717
+ reverse_csv: "email/email_address/value; use input_file/input_csv on batch_reverse_email.",
718
+ exact_jsonl: "company_url/domain/url + first_name + last_name, or linkedin_url; use input_file/input_jsonl.",
719
+ finder_resume: "Pass prior resumeState unchanged with identical mode+filters; stale nextPage is ignored when fingerprint changes.",
720
+ },
721
+ budgetMath: {
722
+ domain_search: "Estimate domains * pages_per_company; duplicates coalesce before spend.",
723
+ reverse_email: "Reserve 1 per unique valid email; charged only on match.",
724
+ exact_email_phone: "Reserve up to 1 for email plus 1 more for phone per found person when include_phone=true.",
725
+ company_finder: "Reserve roughly max_results; credit-limited per_page shrinks to remaining budget.",
726
+ contact_finder: "Discovery search is free; enrichment follow-up uses employee/phone budgets.",
727
+ },
728
+ lookupHints: {
729
+ tool: "quickenrich_lookup_values",
730
+ fields: {
731
+ country_code: "country_codes",
732
+ industry_linkedin: "industries",
733
+ industry: "industries",
734
+ number_of_employees: "employee_ranges",
735
+ revenue: "revenue_ranges",
736
+ services: "company_services",
737
+ },
738
+ },
739
+ rateLimitsPerMinute: RATE_LIMITS,
740
+ endpointCoverage: [
741
+ { endpoint: "employee_search", method: "GET", path: "/api/employees/search", tool: "quickenrich_employee_search", batchTool: "quickenrich_batch_employee_search", auth: true },
742
+ { endpoint: "phone_search", method: "GET", path: "/api/employees/phone-search", tool: "quickenrich_phone_search", batchTool: "quickenrich_batch_employee_search", auth: true },
743
+ { endpoint: "reverse_email", method: "GET", path: "/api/employees/email-search", tool: "quickenrich_reverse_email", batchTool: "quickenrich_batch_reverse_email", auth: true },
744
+ { endpoint: "domain_search", method: "GET", path: "/api/employees/dataset-search", tool: "quickenrich_domain_search", batchTool: "quickenrich_batch_pull_leads", auth: true },
745
+ { endpoint: "contact_finder", method: "POST", path: "/api/employees/contact-finder", tool: "quickenrich_contact_finder", batchTool: "quickenrich_batch_pull_leads", auth: true },
746
+ { endpoint: "company_finder", method: "POST", path: "/api/companies/company-finder", tool: "quickenrich_company_finder", batchTool: "quickenrich_batch_pull_leads", auth: true },
747
+ { endpoint: "lookup_values", method: "GET", path: "/api/lookups/*", tool: "quickenrich_lookup_values", auth: false },
748
+ ],
749
+ agentRouting: [
750
+ { intent: "company filters to contact rows", tool: "quickenrich_pipeline", args: ["mode=company_finder_to_domain_search", "filters/body", "max_companies", "title?", "output_file?", "projection?"], why: ["native chaining", "dedupe", "durable artifacts"] },
751
+ { intent: "domains to leads", tool: "quickenrich_batch_pull_leads", args: ["mode=domain_search", "companies or input_csv/input_jsonl", "title?", "max_credits?", "resume_state?"], why: ["dedupe", "300/min pacing", "export", "compact/meta"] },
752
+ { intent: "contact discovery filters", tool: "quickenrich_batch_pull_leads", args: ["mode=contact_finder", "filters/body", "max_results", "resume_state?"], why: ["pagination", "free discovery", "optional enrichment"] },
753
+ { intent: "company discovery filters", tool: "quickenrich_batch_pull_leads", args: ["mode=company_finder", "filters/body", "max_results", "max_credits?", "resume_state?"], why: ["pagination", "company-shaped output", "credit guard"] },
754
+ { intent: "many reverse emails", tool: "quickenrich_batch_reverse_email", args: ["emails or input_csv/input_jsonl", "max_credits?", "resume_state?", "export_format?"], why: ["dedupe", "invalid email preflight"] },
755
+ { intent: "many exact people", tool: "quickenrich_batch_employee_search", args: ["persons or input_csv/input_jsonl", "include_phone?", "max_credits?", "resume_state?", "export_format?"], why: ["email+phone lanes", "endpoint throttles"] },
756
+ { intent: "single exact lookup", tool: "quickenrich_batch_employee_search", args: ["persons:[{linkedin_url or company_url+first_name+last_name}]"], why: ["default surface", "same endpoint", "resume/cache ready"] },
757
+ ],
758
+ coldStartRecipes: [
759
+ { ask: "I have a file/CSV/JSONL", use: "Set input_file on the matching batch tool; avoid pasting file rows into natural_language." },
760
+ { ask: "Large run", use: "Set max_credits, output_file, resume_file, projection, response:{mode:'meta'}." },
761
+ { ask: "Ambiguous request", use: "Call quickenrich_natural_language with execute=false; execute=true only when plan.requires is empty." },
762
+ ],
763
+ batchThrottleDefaults: {
764
+ domain_search: throttleOptions({}, "domain_search"),
765
+ contact_finder: throttleOptions({}, "contact_finder"),
766
+ company_finder: throttleOptions({}, "company_finder"),
767
+ employee_search: throttleOptions({}, "employee_search"),
768
+ phone_search: throttleOptions({}, "phone_search"),
769
+ reverse_email: throttleOptions({}, "reverse_email"),
770
+ },
771
+ creditNotes: {
772
+ employee_search: "Credit behavior follows Quick Enrich's Email Search endpoint.",
773
+ phone_search: "One credit is deducted only when a phone number is returned.",
774
+ reverse_email: "One credit is deducted only when a match is found and returned.",
775
+ domain_search: "If title is provided, credits are per employee with email/phone on the current page; if omitted, a flat credit is charged when the page has results.",
776
+ contact_finder: "Search is free and discovery-only; responses omit email/phone values.",
777
+ company_finder: "One credit is deducted per company returned unless account plan waives it.",
778
+ },
779
+ };
780
+ }
781
+
782
+ export async function employeeSearch(args, client) {
783
+ requirePersonLocator(args);
784
+ const response = await client.request({
785
+ endpoint: "employee_search",
786
+ path: "/api/employees/search",
787
+ query: {
788
+ linkedin_url: args.linkedin_url,
789
+ company_url: args.company_url,
790
+ first_name: args.first_name,
791
+ last_name: args.last_name,
792
+ },
793
+ });
794
+ return normalizedSingle("employee_search", response, args.include_raw);
795
+ }
796
+
797
+ export async function phoneSearch(args, client) {
798
+ requirePersonLocator(args);
799
+ const response = await client.request({
800
+ endpoint: "phone_search",
801
+ path: "/api/employees/phone-search",
802
+ query: {
803
+ linkedin_url: args.linkedin_url,
804
+ company_url: args.company_url,
805
+ first_name: args.first_name,
806
+ last_name: args.last_name,
807
+ },
808
+ });
809
+ return normalizedSingle("phone_search", response, args.include_raw);
810
+ }
811
+
812
+ export async function reverseEmail(args, client) {
813
+ const email = String(args.email || "").trim();
814
+ const validationError = emailValidationError(email);
815
+ if (validationError) throw new Error(validationError);
816
+ const response = await client.request({
817
+ endpoint: "reverse_email",
818
+ path: "/api/employees/email-search",
819
+ query: { email },
820
+ });
821
+ return normalizedSingle("reverse_email", response, args.include_raw);
822
+ }
823
+
824
+ export async function domainSearch(args, client) {
825
+ const companyUrl = args.company_url || args.domain;
826
+ if (!companyUrl) throw new Error("company_url is required.");
827
+ const response = await client.request({
828
+ endpoint: "domain_search",
829
+ path: "/api/employees/dataset-search",
830
+ query: {
831
+ company_url: companyUrl,
832
+ title: args.title,
833
+ has_email: booleanQuery(args.has_email),
834
+ page: clampInt(args.page, 1, 10_000, 1),
835
+ per_page: clampInt(args.per_page, 1, 20, 20),
836
+ },
837
+ });
838
+ return normalizedList("domain_search", response, args.include_raw);
839
+ }
840
+
841
+ export async function contactFinder(args, client) {
842
+ const body = contactFinderBody(args);
843
+ const validation = args.validate_filters ? await validateFinderFilters("contact_finder", args, client) : undefined;
844
+ if (validation && !validation.ok) {
845
+ return {
846
+ ok: false,
847
+ endpoint: "contact_finder",
848
+ count: 0,
849
+ records: [],
850
+ creditsUsed: 0,
851
+ validation,
852
+ rateLimitPerMinute: RATE_LIMITS.contact_finder,
853
+ };
854
+ }
855
+ const response = await client.request({
856
+ endpoint: "contact_finder",
857
+ method: "POST",
858
+ path: "/api/employees/contact-finder",
859
+ body,
860
+ });
861
+ return {
862
+ ...normalizedList("contact_finder", response, args.include_raw),
863
+ validation,
864
+ };
865
+ }
866
+
867
+ export async function companyFinder(args, client) {
868
+ const body = companyFinderBody(args);
869
+ const validation = args.validate_filters ? await validateFinderFilters("company_finder", args, client) : undefined;
870
+ if (validation && !validation.ok) {
871
+ return {
872
+ ok: false,
873
+ endpoint: "company_finder",
874
+ count: 0,
875
+ records: [],
876
+ creditsUsed: 0,
877
+ validation,
878
+ rateLimitPerMinute: RATE_LIMITS.company_finder,
879
+ };
880
+ }
881
+ const response = await client.request({
882
+ endpoint: "company_finder",
883
+ method: "POST",
884
+ path: "/api/companies/company-finder",
885
+ body,
886
+ });
887
+ return {
888
+ ...normalizedList("company_finder", response, args.include_raw),
889
+ validation,
890
+ };
891
+ }
892
+
893
+ export async function lookupValues(args, client) {
894
+ const lookup = args.lookup;
895
+ const path = LOOKUP_PATHS[lookup];
896
+ if (!path) throw new Error(`Unknown lookup: ${lookup}`);
897
+ const response = await client.request({
898
+ endpoint: "lookup_values",
899
+ path,
900
+ query: lookup === "company_services" ? { q: args.q } : {},
901
+ auth: false,
902
+ });
903
+ return {
904
+ ok: true,
905
+ endpoint: "lookup_values",
906
+ lookup,
907
+ data: response.data,
908
+ rateLimit: response.rateLimit,
909
+ };
910
+ }
911
+
912
+ export async function quickenrichPipeline(args, client) {
913
+ args = prepareBulkArgs(args);
914
+ const mode = args.mode || "company_finder_to_domain_search";
915
+ if (mode !== "company_finder_to_domain_search") throw new Error(`Unknown pipeline mode: ${mode}`);
916
+ const maxCompanies = clampInt(args.max_companies, 1, 1_000, 25);
917
+ const startedAt = Date.now();
918
+ const companyStep = await batchPullLeads({
919
+ ...args,
920
+ mode: "company_finder",
921
+ max_results: maxCompanies,
922
+ per_page: clampInt(args.per_page, 1, 100, Math.min(100, maxCompanies)),
923
+ pages_per_company: args.company_pages || args.pages_per_company || 1,
924
+ output_file: undefined,
925
+ resume_file: undefined,
926
+ export_format: "none",
927
+ projection: "full",
928
+ response: { mode: "full", maxOutputChars: 5_000_000 },
929
+ }, client);
930
+ const companies = Array.isArray(companyStep.companies) ? companyStep.companies.slice(0, maxCompanies) : [];
931
+ const domainInputs = companies
932
+ .map((company) => ({
933
+ company_url: company.domain || company.url || company.email_domain || company.final_email_domain,
934
+ company_name: company.company_name,
935
+ title: args.title,
936
+ }))
937
+ .filter((company) => company.company_url);
938
+ const pipelineMaxCredits = Number(args.max_credits);
939
+ const remainingPipelineCredits = Number.isFinite(pipelineMaxCredits) && pipelineMaxCredits >= 0
940
+ ? Math.max(0, Math.floor(pipelineMaxCredits) - Number(companyStep.creditsUsed || 0))
941
+ : undefined;
942
+ const domainStep = domainInputs.length
943
+ ? await batchPullLeads({
944
+ ...args,
945
+ mode: "domain_search",
946
+ companies: domainInputs,
947
+ input_file: undefined,
948
+ input_csv: undefined,
949
+ input_jsonl: undefined,
950
+ pages_per_company: args.domain_pages || 1,
951
+ per_company_limit: args.per_company_limit,
952
+ title: args.title,
953
+ max_credits: remainingPipelineCredits,
954
+ output_file: args.output_file,
955
+ resume_file: args.resume_file,
956
+ export_format: args.export_format,
957
+ projection: args.projection,
958
+ response: { mode: "full", maxOutputChars: 5_000_000 },
959
+ }, client)
960
+ : { ok: true, requestCount: 0, creditsUsed: 0, leadCount: 0, leads: [], errors: [] };
961
+ const result = {
962
+ ok: Boolean(companyStep.ok && domainStep.ok),
963
+ mode,
964
+ steps: [
965
+ {
966
+ tool: "quickenrich_batch_pull_leads",
967
+ mode: "company_finder",
968
+ requestCount: companyStep.requestCount,
969
+ creditsUsed: companyStep.creditsUsed,
970
+ companyCount: companyStep.companyCount,
971
+ },
972
+ {
973
+ tool: "quickenrich_batch_pull_leads",
974
+ mode: "domain_search",
975
+ requestCount: domainStep.requestCount,
976
+ creditsUsed: domainStep.creditsUsed,
977
+ leadCount: domainStep.leadCount,
978
+ },
979
+ ],
980
+ requestCount: (companyStep.requestCount || 0) + (domainStep.requestCount || 0),
981
+ creditsUsed: (companyStep.creditsUsed || 0) + (domainStep.creditsUsed || 0),
982
+ remainingCredits: minDefined([companyStep.remainingCredits, domainStep.remainingCredits]),
983
+ companyCount: companies.length,
984
+ companies,
985
+ leadCount: domainStep.leadCount || 0,
986
+ leads: domainStep.leads || [],
987
+ errors: [...(companyStep.errors || []), ...(domainStep.errors || [])],
988
+ output: domainStep.output,
989
+ resume: domainStep.resume,
990
+ ledger: args.credit_ledger ? appendCreditLedger(args, { ...domainStep, mode }) : domainStep.ledger,
991
+ durationMs: Date.now() - startedAt,
992
+ };
993
+ if (projectionMode(args) === "companies") {
994
+ result.companies = projectRows(companies, "companies", "company_finder");
995
+ }
996
+ return result;
997
+ }
998
+
999
+ export async function batchPullLeads(args, client) {
1000
+ args = prepareBulkArgs(args);
1001
+ const mode = args.mode || "domain_search";
1002
+ const perPage = mode === "domain_search" ? clampInt(args.per_page, 1, 20, 20) : clampInt(args.per_page, 1, 100, 10);
1003
+ const pagesPerCompany = clampInt(args.pages_per_company, 1, 10, 1);
1004
+ const perCompanyLimit = clampInt(args.per_company_limit, 1, 200, 20);
1005
+ const concurrency = clampInt(args.concurrency, 1, MAX_BATCH_CONCURRENCY, 8);
1006
+ const maxEnrichments = clampInt(args.max_enrichments, 0, 500, 50);
1007
+ const maxResults = clampInt(args.max_results, 1, 10_000, perPage);
1008
+ const exportFormat = exportFormatOption(args.export_format);
1009
+ const creditBudget = createCreditBudget(args.max_credits);
1010
+
1011
+ if (mode === "domain_search") {
1012
+ const companies = normalizeCompanyInputs([
1013
+ ...(Array.isArray(args.companies) ? args.companies : []),
1014
+ ...recordsFromTextInputs(args),
1015
+ ]);
1016
+ const uniqueCompanies = uniqueDomainSearchCompanies(companies, args.title);
1017
+ const resume = createResumeTracker(args.resume_state, uniqueCompanies, domainSearchBatchKey, resumeTrackerOptions(args));
1018
+ const plannedRequests = resume.items.length * pagesPerCompany;
1019
+ const throttle = requestThrottle(client, args, "domain_search");
1020
+ const domainCreditEstimate = 1;
1021
+ if (args.dry_run) {
1022
+ return batchPlan({ mode, companies: resume.items, inputCompanyCount: companies.length, plannedRequests, concurrency, perPage, pagesPerCompany, perCompanyLimit, throttle: throttle.summary(), creditBudget: creditBudget.summary(), resumeState: resume.summary() });
1023
+ }
1024
+ const startedAt = Date.now();
1025
+ const pulled = await withConcurrency(resume.items, concurrency, async (company) => {
1026
+ const entry = await pullCompanyDomainLeads(company, {
1027
+ perPage,
1028
+ pagesPerCompany,
1029
+ perCompanyLimit,
1030
+ title: company.title || args.title,
1031
+ hasEmail: args.has_email,
1032
+ creditEstimate: domainCreditEstimate,
1033
+ throttle,
1034
+ creditBudget,
1035
+ }, client);
1036
+ if (entry.errors?.length) resume.fail(company);
1037
+ else if (entry.requestCount > 0) resume.complete(company);
1038
+ return entry;
1039
+ });
1040
+ const leads = dedupeLeads(pulled.flatMap((entry) => entry.leads));
1041
+ const errors = pulled.flatMap((entry) => entry.errors);
1042
+ const enriched = args.enrich_contacts
1043
+ ? await enrichLeads(leads, { includePhone: Boolean(args.include_phone_enrichment), maxEnrichments, concurrency, creditBudget }, client)
1044
+ : { leads, enrichmentRequests: 0, creditsUsed: 0, remainingCredits: undefined, errors: [] };
1045
+ const outputLeads = enriched.leads;
1046
+ const result = {
1047
+ ok: errors.length === 0 && enriched.errors.length === 0,
1048
+ mode,
1049
+ requestCount: pulled.reduce((sum, entry) => sum + entry.requestCount, 0) + enriched.enrichmentRequests,
1050
+ inputCompanyCount: companies.length,
1051
+ companyCount: uniqueCompanies.length,
1052
+ coalescedCompanyCount: companies.length - uniqueCompanies.length,
1053
+ creditsUsed: pulled.reduce((sum, entry) => sum + entry.creditsUsed, 0) + enriched.creditsUsed,
1054
+ remainingCredits: minDefined(pulled.map((entry) => entry.remainingCredits).concat(enriched.remainingCredits)),
1055
+ leadCount: outputLeads.length,
1056
+ leads: outputLeads,
1057
+ errors: [...errors, ...enriched.errors],
1058
+ durationMs: Date.now() - startedAt,
1059
+ rateLimitPerMinute: RATE_LIMITS.domain_search,
1060
+ throttle: throttle.summary(),
1061
+ creditBudget: creditBudget.summary(),
1062
+ resumeState: resume.summary(),
1063
+ export: formatExport(projectRows(outputLeads, projectionMode(args), "domain_search"), exportFormat),
1064
+ creditNotes: agentGraph().creditNotes.domain_search,
1065
+ };
1066
+ return finalizeRowsResult(result, args, "leads", "domain_search", outputLeads);
1067
+ }
1068
+
1069
+ if (mode === "contact_finder") {
1070
+ if (args.dry_run) return batchPlan({ mode, plannedRequests: 1, concurrency: 1, perPage });
1071
+ const validation = args.validate_filters ? await validateFinderFilters("contact_finder", args, client) : undefined;
1072
+ if (validation && !validation.ok) {
1073
+ return {
1074
+ ok: false,
1075
+ mode,
1076
+ requestCount: 0,
1077
+ creditsUsed: 0,
1078
+ leadCount: 0,
1079
+ leads: [],
1080
+ errors: [],
1081
+ validation,
1082
+ creditBudget: creditBudget.summary(),
1083
+ };
1084
+ }
1085
+ const result = await pagedFinder("contact_finder", args, { perPage, maxResults, maxPages: pagesPerCompany, concurrency, creditBudget, creditEstimate: 0 }, client);
1086
+ const leads = dedupeLeads(result.records || []);
1087
+ const enriched = args.enrich_contacts
1088
+ ? await enrichLeads(leads, { includePhone: Boolean(args.include_phone_enrichment), maxEnrichments, concurrency, creditBudget }, client)
1089
+ : { leads, enrichmentRequests: 0, creditsUsed: 0, remainingCredits: undefined, errors: [] };
1090
+ const outputLeads = enriched.leads;
1091
+ const resultOut = {
1092
+ ok: (result.errors || []).length === 0 && enriched.errors.length === 0,
1093
+ mode,
1094
+ requestCount: result.requestCount + enriched.enrichmentRequests,
1095
+ creditsUsed: (result.creditsUsed || 0) + enriched.creditsUsed,
1096
+ remainingCredits: minDefined([result.remainingCredits, enriched.remainingCredits]),
1097
+ leadCount: outputLeads.length,
1098
+ leads: outputLeads,
1099
+ errors: [...(result.errors || []), ...enriched.errors],
1100
+ rateLimitPerMinute: RATE_LIMITS.contact_finder,
1101
+ throttle: result.throttle,
1102
+ creditBudget: creditBudget.summary(),
1103
+ resumeState: result.resumeState,
1104
+ validation,
1105
+ export: formatExport(projectRows(outputLeads, projectionMode(args), "contact_finder"), exportFormat),
1106
+ creditNotes: agentGraph().creditNotes.contact_finder,
1107
+ };
1108
+ return finalizeRowsResult(resultOut, args, "leads", "contact_finder", outputLeads);
1109
+ }
1110
+
1111
+ if (mode === "company_finder") {
1112
+ if (args.dry_run) return batchPlan({ mode, plannedRequests: 1, concurrency: 1, perPage });
1113
+ const validation = args.validate_filters ? await validateFinderFilters("company_finder", args, client) : undefined;
1114
+ if (validation && !validation.ok) {
1115
+ return {
1116
+ ok: false,
1117
+ mode,
1118
+ requestCount: 0,
1119
+ creditsUsed: 0,
1120
+ companyCount: 0,
1121
+ companies: [],
1122
+ errors: [],
1123
+ validation,
1124
+ creditBudget: creditBudget.summary(),
1125
+ };
1126
+ }
1127
+ const result = await pagedFinder("company_finder", args, { perPage, maxResults, maxPages: pagesPerCompany, concurrency, creditBudget, creditEstimate: Math.max(1, perPage) }, client);
1128
+ const companies = result.records || [];
1129
+ const resultOut = {
1130
+ ok: (result.errors || []).length === 0,
1131
+ mode,
1132
+ requestCount: result.requestCount,
1133
+ creditsUsed: result.creditsUsed,
1134
+ remainingCredits: result.remainingCredits,
1135
+ companyCount: companies.length,
1136
+ companies,
1137
+ errors: result.errors || [],
1138
+ throttle: result.throttle,
1139
+ creditBudget: creditBudget.summary(),
1140
+ resumeState: result.resumeState,
1141
+ validation,
1142
+ export: formatExport(projectRows(companies, projectionMode(args), "company_finder"), exportFormat),
1143
+ rateLimitPerMinute: RATE_LIMITS.company_finder,
1144
+ creditNotes: agentGraph().creditNotes.company_finder,
1145
+ };
1146
+ return finalizeRowsResult(resultOut, args, "companies", "company_finder", companies);
1147
+ }
1148
+
1149
+ throw new Error(`Unknown batch mode: ${mode}`);
1150
+ }
1151
+
1152
+ export async function batchReverseEmail(args, client) {
1153
+ args = prepareBulkArgs(args);
1154
+ const emails = uniqueStrings([
1155
+ ...(Array.isArray(args.emails) ? args.emails : []),
1156
+ ...recordsFromTextInputs(args).map((row) => row.email || row.email_address || row.value),
1157
+ ].filter(Boolean));
1158
+ if (!emails.length) throw new Error("emails or input_csv/input_jsonl must include at least one email.");
1159
+ const concurrency = clampInt(args.concurrency, 1, MAX_EMAIL_BATCH_CONCURRENCY, 8);
1160
+ const throttle = requestThrottle(client, args, "reverse_email");
1161
+ const creditBudget = createCreditBudget(args.max_credits);
1162
+ const resume = createResumeTracker(args.resume_state, emails, (email) => email.toLowerCase(), resumeTrackerOptions(args));
1163
+ const cache = cacheOptions(args);
1164
+ const cacheStats = { hits: 0, misses: 0, writes: 0 };
1165
+ const errors = [];
1166
+ const results = [];
1167
+ const workEmails = [];
1168
+ for (const email of resume.items) {
1169
+ const validationError = emailValidationError(email);
1170
+ if (validationError) {
1171
+ resume.fail(email);
1172
+ errors.push({ email, code: "invalid_email", error: validationError });
1173
+ } else {
1174
+ workEmails.push(email);
1175
+ }
1176
+ }
1177
+ let requestCount = 0;
1178
+ let creditsUsed = 0;
1179
+ let remainingCredits;
1180
+ const startedAt = Date.now();
1181
+
1182
+ await withConcurrency(workEmails, concurrency, async (email) => {
1183
+ const cachePayload = { email: email.toLowerCase() };
1184
+ const cached = readCachedValue(cache, "reverse_email", cachePayload);
1185
+ if (cached !== undefined) {
1186
+ cacheStats.hits += 1;
1187
+ resume.complete(email);
1188
+ results.push(cached);
1189
+ return;
1190
+ }
1191
+ cacheStats.misses += cache.enabled ? 1 : 0;
1192
+ if (cache.cacheOnly) {
1193
+ resume.fail(email);
1194
+ errors.push({ email, code: "cache_miss", error: "Cache-only mode has no reverse-email cache entry." });
1195
+ return;
1196
+ }
1197
+ const reserved = creditBudget.tryReserve(1);
1198
+ if (!reserved) return;
1199
+ try {
1200
+ await throttle.acquire();
1201
+ const result = await reverseEmail({ email }, client);
1202
+ requestCount += 1;
1203
+ creditsUsed += result.creditsUsed || 0;
1204
+ creditBudget.commit(result.creditsUsed || 0, 1);
1205
+ remainingCredits = minDefined([remainingCredits, result.remainingCredits]);
1206
+ resume.complete(email);
1207
+ const row = result.record ? { email, found: result.found, ...result.record } : { email, found: false };
1208
+ results.push(row);
1209
+ writeCachedValue(cache, "reverse_email", cachePayload, row);
1210
+ if (cache.enabled && cache.canWrite) cacheStats.writes += 1;
1211
+ } catch (error) {
1212
+ creditBudget.release(1);
1213
+ throttle.cooldown(retryAfterMs(error));
1214
+ requestCount += 1;
1215
+ resume.fail(email);
1216
+ errors.push({ email, error: error.message || String(error), data: error.result });
1217
+ }
1218
+ });
1219
+
1220
+ const found = results.filter((row) => row.found !== false && (row.email || row.name || row.employee_linkedin));
1221
+ const result = {
1222
+ ok: errors.length === 0,
1223
+ mode: "reverse_email",
1224
+ inputCount: emails.length,
1225
+ requestCount,
1226
+ foundCount: found.length,
1227
+ creditsUsed,
1228
+ remainingCredits,
1229
+ results,
1230
+ errors: redact(errors),
1231
+ durationMs: Date.now() - startedAt,
1232
+ rateLimitPerMinute: RATE_LIMITS.reverse_email,
1233
+ throttle: throttle.summary(),
1234
+ creditBudget: creditBudget.summary(),
1235
+ cache: cache.enabled ? cacheStats : undefined,
1236
+ export: formatExport(projectRows(found, projectionMode(args), "reverse_email"), exportFormatOption(args.export_format)),
1237
+ resumeState: resume.summary(),
1238
+ };
1239
+ return finalizeRowsResult(result, args, "results", "reverse_email", found);
1240
+ }
1241
+
1242
+ export async function batchEmployeeSearch(args, client) {
1243
+ args = prepareBulkArgs(args);
1244
+ const persons = uniquePersons([
1245
+ ...(Array.isArray(args.persons) ? args.persons : []),
1246
+ ...recordsFromTextInputs(args),
1247
+ ]);
1248
+ if (!persons.length) throw new Error("persons or input_csv/input_jsonl must include at least one exact-person lookup.");
1249
+ const concurrency = clampInt(args.concurrency, 1, MAX_EMAIL_BATCH_CONCURRENCY, 8);
1250
+ const includePhone = Boolean(args.include_phone);
1251
+ const emailThrottle = requestThrottle(client, args, "employee_search");
1252
+ let phoneThrottle;
1253
+ const creditBudget = createCreditBudget(args.max_credits);
1254
+ const resume = createResumeTracker(args.resume_state, persons, personKey, resumeTrackerOptions(args));
1255
+ const cache = cacheOptions(args);
1256
+ const cacheStats = { hits: 0, misses: 0, writes: 0 };
1257
+ const errors = [];
1258
+ const results = [];
1259
+ let requestCount = 0;
1260
+ let creditsUsed = 0;
1261
+ let remainingCredits;
1262
+ const startedAt = Date.now();
1263
+
1264
+ await withConcurrency(resume.items, concurrency, async (person) => {
1265
+ const cachePayload = { person, include_phone: includePhone };
1266
+ const cached = readCachedValue(cache, "employee_search", cachePayload);
1267
+ if (cached !== undefined) {
1268
+ cacheStats.hits += 1;
1269
+ resume.complete(person);
1270
+ results.push(cached);
1271
+ return;
1272
+ }
1273
+ cacheStats.misses += cache.enabled ? 1 : 0;
1274
+ if (cache.cacheOnly) {
1275
+ resume.fail(person);
1276
+ errors.push({ person, code: "cache_miss", error: "Cache-only mode has no exact-person cache entry." });
1277
+ return;
1278
+ }
1279
+ if (!creditBudget.tryReserve(1)) return;
1280
+ let phoneReserved = false;
1281
+ try {
1282
+ await emailThrottle.acquire();
1283
+ const result = await employeeSearch(person, client);
1284
+ requestCount += 1;
1285
+ creditsUsed += result.creditsUsed || 0;
1286
+ creditBudget.commit(result.creditsUsed || 0, 1);
1287
+ remainingCredits = minDefined([remainingCredits, result.remainingCredits]);
1288
+ let record = result.record ? { ...person, ...result.record, found: result.found } : { ...person, found: false };
1289
+ if (includePhone && result.record) {
1290
+ const canReservePhone = creditBudget.tryReserve(1);
1291
+ if (canReservePhone) {
1292
+ phoneReserved = true;
1293
+ if (!phoneThrottle) {
1294
+ phoneThrottle = requestThrottle(client, { requests_per_minute: args.phone_requests_per_minute, burst_size: args.burst_size }, "phone_search");
1295
+ }
1296
+ try {
1297
+ await phoneThrottle.acquire();
1298
+ const phone = await phoneSearch({
1299
+ linkedin_url: record.employee_linkedin || person.linkedin_url,
1300
+ company_url: record.company_url || person.company_url,
1301
+ first_name: record.first_name || person.first_name,
1302
+ last_name: record.last_name || person.last_name,
1303
+ }, client);
1304
+ requestCount += 1;
1305
+ creditsUsed += phone.creditsUsed || 0;
1306
+ creditBudget.commit(phone.creditsUsed || 0, 1);
1307
+ remainingCredits = minDefined([remainingCredits, phone.remainingCredits]);
1308
+ if (phone.record) record = { ...record, ...cleanObject(phone.record) };
1309
+ } catch (phoneError) {
1310
+ phoneThrottle.cooldown(retryAfterMs(phoneError));
1311
+ creditBudget.release(1);
1312
+ requestCount += 1;
1313
+ errors.push({ person, error: phoneError.message || String(phoneError), data: phoneError.result });
1314
+ }
1315
+ }
1316
+ }
1317
+ resume.complete(person);
1318
+ results.push(record);
1319
+ writeCachedValue(cache, "employee_search", cachePayload, record);
1320
+ if (cache.enabled && cache.canWrite) cacheStats.writes += 1;
1321
+ } catch (error) {
1322
+ emailThrottle.cooldown(retryAfterMs(error));
1323
+ if (phoneThrottle) phoneThrottle.cooldown(retryAfterMs(error));
1324
+ requestCount += 1;
1325
+ creditBudget.release(1);
1326
+ if (phoneReserved) creditBudget.release(1);
1327
+ resume.fail(person);
1328
+ errors.push({ person, error: error.message || String(error), data: error.result });
1329
+ }
1330
+ });
1331
+
1332
+ const found = results.filter((row) => row.found !== false && (row.email || row.phone || row.employee_linkedin));
1333
+ const result = {
1334
+ ok: errors.length === 0,
1335
+ mode: "employee_search",
1336
+ inputCount: persons.length,
1337
+ requestCount,
1338
+ foundCount: found.length,
1339
+ creditsUsed,
1340
+ remainingCredits,
1341
+ results,
1342
+ errors: redact(errors),
1343
+ durationMs: Date.now() - startedAt,
1344
+ rateLimitPerMinute: RATE_LIMITS.employee_search,
1345
+ throttles: {
1346
+ employee_search: emailThrottle.summary(),
1347
+ phone_search: phoneThrottle?.summary(),
1348
+ },
1349
+ creditBudget: creditBudget.summary(),
1350
+ cache: cache.enabled ? cacheStats : undefined,
1351
+ export: formatExport(projectRows(found, projectionMode(args), "employee_search"), exportFormatOption(args.export_format)),
1352
+ resumeState: resume.summary(),
1353
+ };
1354
+ return finalizeRowsResult(result, args, "results", "employee_search", found);
1355
+ }
1356
+
1357
+ export function planNaturalLanguage(request) {
1358
+ const text = String(request || "").trim();
1359
+ if (!text) throw new Error("request is required.");
1360
+ const lower = text.toLowerCase();
1361
+ const emails = extractEmails(text);
1362
+ const linkedIn = extractLinkedIn(text);
1363
+ const domains = extractDomains(text);
1364
+ const title = titleFromText(lower);
1365
+ const maxCredits = maxCreditsFromText(lower);
1366
+ const inputFormat = inputFormatFromText(lower);
1367
+ const fileBatch = Boolean(inputFormat) || /\b(batch|bulk|list|file|spreadsheet|rows)\b/.test(lower);
1368
+ const exactPersonIntent = /\b(exact|person|people|employee|employees|linkedin)\b/.test(lower)
1369
+ && (fileBatch || /\b(search|enrich|lookup|email|phone|mobile|find)\b/.test(lower));
1370
+
1371
+ if (/auth|status|configured|key/.test(lower)) {
1372
+ return { tool: "quickenrich_auth_status", arguments: {}, confidence: 0.92, rationale: "Request asks about auth/configuration." };
1373
+ }
1374
+ if (/\b(resume|continue|next page|paged|pagination|resume_state)\b/.test(lower) && /\bfinder\b/.test(lower)) {
1375
+ return {
1376
+ tool: "quickenrich_batch_pull_leads",
1377
+ arguments: cleanObject({
1378
+ mode: /compan/.test(lower) ? "company_finder" : /contact|people/.test(lower) ? "contact_finder" : undefined,
1379
+ response: { mode: "meta" },
1380
+ }),
1381
+ requires: ["resume_state", /compan|contact|people/.test(lower) ? undefined : "mode contact_finder or company_finder"].filter(Boolean),
1382
+ confidence: 0.74,
1383
+ rationale: "Request asks to continue finder pagination; batch_pull_leads owns finder resume_state.",
1384
+ };
1385
+ }
1386
+ if (exactPersonIntent || /(?:csv|jsonl).*(person|people|employee|phone)/.test(lower)) {
1387
+ return {
1388
+ tool: fileBatch || /batch|bulk|list/.test(lower) ? "quickenrich_batch_employee_search" : "quickenrich_employee_search",
1389
+ arguments: cleanObject({
1390
+ ...(fileBatch || /batch|bulk|list/.test(lower) ? {} : {
1391
+ linkedin_url: linkedIn,
1392
+ company_url: domains[0],
1393
+ first_name: guessName(text).first,
1394
+ last_name: guessName(text).last,
1395
+ }),
1396
+ include_phone: /phone|mobile|cell/.test(lower) || undefined,
1397
+ max_credits: maxCredits,
1398
+ response: fileBatch ? { mode: "meta" } : undefined,
1399
+ }),
1400
+ requires: fileBatch ? [inputFormat || "persons or input_csv/input_jsonl"] : missingPersonLocatorRequirements({ linkedIn, domains, text }),
1401
+ confidence: linkedIn || fileBatch ? 0.84 : 0.66,
1402
+ rationale: fileBatch ? "Request asks for exact-person batch enrichment from file-shaped input." : "Request appears to ask for exact person enrichment.",
1403
+ };
1404
+ }
1405
+ if ((/reverse|lookup|who/.test(lower) && /email/.test(lower) && fileBatch) || (/email/.test(lower) && inputFormat)) {
1406
+ return {
1407
+ tool: "quickenrich_batch_reverse_email",
1408
+ arguments: cleanObject({
1409
+ emails: emails.length ? emails : undefined,
1410
+ max_credits: maxCredits,
1411
+ response: { mode: "meta" },
1412
+ }),
1413
+ requires: emails.length ? [] : [inputFormat || "emails or input_csv/input_jsonl"],
1414
+ confidence: 0.82,
1415
+ rationale: "Request maps to batch reverse-email lookup from file/list input.",
1416
+ };
1417
+ }
1418
+ if (emails.length > 1 || (emails.length && /batch|bulk|list|csv|jsonl/.test(lower))) {
1419
+ return { tool: "quickenrich_batch_reverse_email", arguments: cleanObject({ emails, max_credits: maxCredits }), confidence: 0.9, rationale: "Request contains multiple or batch email lookups; using deduped batch reverse-email." };
1420
+ }
1421
+ if (emails.length && /reverse|lookup|who|person|phone|enrich/.test(lower)) {
1422
+ return { tool: "quickenrich_reverse_email", arguments: { email: emails[0] }, confidence: 0.84, rationale: "Request contains an email address and asks for lookup/enrichment." };
1423
+ }
1424
+ if (/contact finder|contacts|people database/.test(lower)) {
1425
+ return {
1426
+ tool: "quickenrich_batch_pull_leads",
1427
+ arguments: { mode: "contact_finder", body: cleanObject({ company_url: domains[0], title: title || undefined, page: 1, per_page: 20 }), max_results: 20, pages_per_company: 1 },
1428
+ confidence: 0.78,
1429
+ rationale: "Request asks for contact discovery; using batch_pull_leads contact_finder for pagination and compact envelopes.",
1430
+ };
1431
+ }
1432
+ if (/\b(pipeline|workflow|chain|then)\b/.test(lower) && /compan/.test(lower) && /\b(domain|lead|contact|people)\b/.test(lower)) {
1433
+ return {
1434
+ tool: "quickenrich_pipeline",
1435
+ arguments: cleanObject({
1436
+ mode: "company_finder_to_domain_search",
1437
+ title: title || (/founder|founders/.test(lower) ? FOUNDER_TITLE : undefined),
1438
+ max_credits: maxCredits,
1439
+ projection: /email/.test(lower) ? "emails" : /compan/.test(lower) && !/people|contact|lead/.test(lower) ? "companies" : "people",
1440
+ response: { mode: "meta" },
1441
+ }),
1442
+ requires: ["filters/body or company-finder criteria"],
1443
+ confidence: 0.8,
1444
+ rationale: "Request asks for a chained company-discovery to lead-pull workflow.",
1445
+ };
1446
+ }
1447
+ if (/company finder|companies|firmographic/.test(lower)) {
1448
+ return {
1449
+ tool: "quickenrich_batch_pull_leads",
1450
+ arguments: cleanObject({ mode: "company_finder", body: cleanObject({ company_url: domains[0], page: 1, per_page: 20 }), max_results: 20, pages_per_company: 1, max_credits: maxCredits }),
1451
+ confidence: 0.78,
1452
+ rationale: "Request asks for company discovery; using batch_pull_leads company_finder for pagination, credit guard, and compact envelopes.",
1453
+ };
1454
+ }
1455
+ if (linkedIn || (/email|enrich|find/.test(lower) && domains.length)) {
1456
+ return {
1457
+ tool: "quickenrich_employee_search",
1458
+ arguments: cleanObject({
1459
+ linkedin_url: linkedIn,
1460
+ company_url: domains[0],
1461
+ first_name: guessName(text).first,
1462
+ last_name: guessName(text).last,
1463
+ }),
1464
+ confidence: linkedIn ? 0.88 : 0.62,
1465
+ rationale: "Request appears to ask for exact person enrichment.",
1466
+ };
1467
+ }
1468
+ if (/batch|pull|lead|leads|founder|founders|ceo|owner|domain[_\s-]?search|company domains?|domains?/.test(lower) || domains.length > 1) {
1469
+ return {
1470
+ tool: "quickenrich_batch_pull_leads",
1471
+ arguments: cleanObject({
1472
+ mode: "domain_search",
1473
+ companies: !inputFormat && domains.length ? domains.map((domain) => ({ company_url: domain, title: title || undefined })) : undefined,
1474
+ title: title || (/founder|founders/.test(lower) ? FOUNDER_TITLE : undefined),
1475
+ per_company_limit: 20,
1476
+ pages_per_company: 1,
1477
+ concurrency: 8,
1478
+ has_email: /email|emails|reachable|verified/.test(lower) ? true : undefined,
1479
+ max_credits: maxCredits,
1480
+ response: domains.length && !inputFormat ? undefined : { mode: "meta" },
1481
+ }),
1482
+ requires: inputFormat ? [inputFormat] : domains.length ? [] : ["companies or input_csv/input_jsonl"],
1483
+ confidence: domains.length ? 0.86 : 0.54,
1484
+ rationale: "Request maps to a domain-based batch lead pull.",
1485
+ };
1486
+ }
1487
+ return {
1488
+ tool: "quickenrich_agent_graph",
1489
+ arguments: {},
1490
+ confidence: 0.35,
1491
+ rationale: "Could not confidently map the request; returning the capability graph.",
1492
+ };
1493
+ }
1494
+
1495
+ export async function naturalLanguage(args, client) {
1496
+ const plan = planNaturalLanguage(args.request);
1497
+ if (!args.execute) {
1498
+ return { ok: true, execute: false, plan };
1499
+ }
1500
+ if (plan.requires?.length) {
1501
+ return { ok: false, execute: false, plan, error: `Plan requires: ${plan.requires.join(", ")}` };
1502
+ }
1503
+ const result = await callQuickEnrichTool(plan.tool, { ...plan.arguments, response: args.response }, client);
1504
+ return { ok: true, execute: true, plan, result };
1505
+ }
1506
+
1507
+ function inputFormatFromText(lower) {
1508
+ if (/\bjsonl\b|json lines|ndjson/.test(lower)) return "input_jsonl";
1509
+ if (/\bcsv\b|spreadsheet/.test(lower)) return "input_csv";
1510
+ return "";
1511
+ }
1512
+
1513
+ function maxCreditsFromText(lower) {
1514
+ const patterns = [
1515
+ /\bmax(?:imum)?\D{0,12}(\d{1,8})\s+credits?\b/i,
1516
+ /\bmax(?:imum)?(?:_|\s|-)*credits?\D{0,12}(\d{1,8})\b/i,
1517
+ /\bcredit(?:s)?(?:_|\s|-)*max\D{0,12}(\d{1,8})\b/i,
1518
+ /\bcap(?:ped)?(?: at)?\D{0,12}(\d{1,8})\s+credits?\b/i,
1519
+ /\b(\d{1,8})\s+credits?\s+(?:max|budget|cap)\b/i,
1520
+ ];
1521
+ for (const pattern of patterns) {
1522
+ const match = lower.match(pattern);
1523
+ const value = Number(match?.[1]);
1524
+ if (Number.isFinite(value) && value >= 0) return Math.floor(value);
1525
+ }
1526
+ return undefined;
1527
+ }
1528
+
1529
+ function missingPersonLocatorRequirements({ linkedIn, domains, text }) {
1530
+ if (linkedIn) return [];
1531
+ const guessed = guessName(text);
1532
+ if (domains.length && guessed.first && guessed.last) return [];
1533
+ const missing = [];
1534
+ if (!domains.length) missing.push("linkedin_url or company_url");
1535
+ if (!guessed.first || !guessed.last) missing.push("first_name and last_name");
1536
+ return missing;
1537
+ }
1538
+
1539
+ function normalizedSingle(endpoint, response, includeRaw = false) {
1540
+ const record = firstRecord(response.data);
1541
+ return {
1542
+ ok: true,
1543
+ endpoint,
1544
+ found: Boolean(record && Object.keys(record).length),
1545
+ record: record ? normalizeLead(record, endpoint) : null,
1546
+ meta: response.data?.meta,
1547
+ creditsUsed: numericMeta(response.data?.meta?.credits_used, 0),
1548
+ remainingCredits: numericMeta(response.data?.meta?.remaining_credits),
1549
+ rateLimit: response.rateLimit,
1550
+ rateLimitPerMinute: RATE_LIMITS[endpoint],
1551
+ raw: includeRaw ? response.data : undefined,
1552
+ };
1553
+ }
1554
+
1555
+ function normalizedList(endpoint, response, includeRaw = false) {
1556
+ const rows = records(response.data);
1557
+ const normalized = endpoint === "company_finder"
1558
+ ? rows.map((row) => normalizeCompany(row))
1559
+ : rows.map((row) => normalizeLead(row, endpoint));
1560
+ return {
1561
+ ok: true,
1562
+ endpoint,
1563
+ count: rows.length,
1564
+ records: normalized,
1565
+ meta: response.data?.meta,
1566
+ creditsUsed: numericMeta(response.data?.meta?.credits_used, 0),
1567
+ remainingCredits: numericMeta(response.data?.meta?.remaining_credits),
1568
+ rateLimit: response.rateLimit,
1569
+ rateLimitPerMinute: RATE_LIMITS[endpoint],
1570
+ raw: includeRaw ? response.data : undefined,
1571
+ };
1572
+ }
1573
+
1574
+ async function pullCompanyDomainLeads(company, options, client) {
1575
+ const leads = [];
1576
+ const errors = [];
1577
+ const pageCreditEstimate = Math.max(1, Number(options.creditEstimate || 1));
1578
+ let requestCount = 0;
1579
+ let creditsUsed = 0;
1580
+ let remainingCredits;
1581
+ for (let page = 1; page <= options.pagesPerCompany; page += 1) {
1582
+ if (leads.length >= options.perCompanyLimit) break;
1583
+ if (options.creditBudget && !options.creditBudget.tryReserve(pageCreditEstimate)) break;
1584
+ try {
1585
+ await options.throttle?.acquire();
1586
+ const result = await domainSearch({
1587
+ company_url: company.company_url,
1588
+ title: options.title,
1589
+ has_email: options.hasEmail,
1590
+ page,
1591
+ per_page: options.perPage,
1592
+ include_raw: false,
1593
+ }, client);
1594
+ requestCount += 1;
1595
+ creditsUsed += result.creditsUsed || 0;
1596
+ options.creditBudget?.commit(result.creditsUsed || 0, pageCreditEstimate);
1597
+ remainingCredits = minDefined([remainingCredits, result.remainingCredits]);
1598
+ leads.push(...(result.records || []).map((lead) => ({ ...lead, batch_company_url: company.company_url })));
1599
+ const lastPage = Number(result.meta?.last_page || 0);
1600
+ if (lastPage && page >= lastPage) break;
1601
+ } catch (error) {
1602
+ options.throttle?.cooldown(retryAfterMs(error));
1603
+ requestCount += 1;
1604
+ options.creditBudget?.release(pageCreditEstimate);
1605
+ errors.push({ company_url: company.company_url, error: error.message || String(error), data: error.result });
1606
+ break;
1607
+ }
1608
+ }
1609
+ return { company, leads: leads.slice(0, options.perCompanyLimit), errors: redact(errors), requestCount, creditsUsed, remainingCredits };
1610
+ }
1611
+
1612
+ async function enrichLeads(leads, options, client) {
1613
+ const targets = leads
1614
+ .filter((lead) => !lead.email && (lead.has_email || lead.employee_linkedin) && canLocatePerson(lead))
1615
+ .slice(0, options.maxEnrichments);
1616
+ const errors = [];
1617
+ let enrichmentRequests = 0;
1618
+ let creditsUsed = 0;
1619
+ let remainingCredits;
1620
+ const enrichedByKey = new Map();
1621
+ const emailThrottle = requestThrottle(client, {}, "employee_search");
1622
+ let phoneThrottle;
1623
+ await withConcurrency(targets, options.concurrency, async (lead) => {
1624
+ if (options.creditBudget && !options.creditBudget.tryReserve(1)) return;
1625
+ try {
1626
+ await emailThrottle.acquire();
1627
+ const result = await employeeSearch({
1628
+ linkedin_url: lead.employee_linkedin,
1629
+ company_url: lead.company_url,
1630
+ first_name: lead.first_name,
1631
+ last_name: lead.last_name,
1632
+ }, client);
1633
+ enrichmentRequests += 1;
1634
+ creditsUsed += result.creditsUsed || 0;
1635
+ options.creditBudget?.commit(result.creditsUsed || 0, 1);
1636
+ remainingCredits = minDefined([remainingCredits, result.remainingCredits]);
1637
+ let merged = result.record ? { ...lead, ...cleanObject(result.record) } : lead;
1638
+ if (options.includePhone && !merged.phone) {
1639
+ const phoneReserved = options.creditBudget ? options.creditBudget.tryReserve(1) : true;
1640
+ if (phoneReserved) {
1641
+ if (!phoneThrottle) {
1642
+ phoneThrottle = requestThrottle(client, {}, "phone_search");
1643
+ }
1644
+ try {
1645
+ await phoneThrottle.acquire();
1646
+ const phone = await phoneSearch({
1647
+ linkedin_url: merged.employee_linkedin,
1648
+ company_url: merged.company_url,
1649
+ first_name: merged.first_name,
1650
+ last_name: merged.last_name,
1651
+ }, client);
1652
+ enrichmentRequests += 1;
1653
+ creditsUsed += phone.creditsUsed || 0;
1654
+ options.creditBudget?.commit(phone.creditsUsed || 0, 1);
1655
+ remainingCredits = minDefined([remainingCredits, phone.remainingCredits]);
1656
+ if (phone.record) merged = { ...merged, ...cleanObject(phone.record) };
1657
+ } catch (error) {
1658
+ options.creditBudget?.release(1);
1659
+ errors.push({ lead: compactValue(lead, responseOptions({ mode: "compact", maxItems: 1 })), error: error.message || String(error), data: error.result });
1660
+ }
1661
+ }
1662
+ }
1663
+ enrichedByKey.set(leadKey(lead), merged);
1664
+ } catch (error) {
1665
+ emailThrottle.cooldown(retryAfterMs(error));
1666
+ if (phoneThrottle) phoneThrottle.cooldown(retryAfterMs(error));
1667
+ enrichmentRequests += 1;
1668
+ options.creditBudget?.release(1);
1669
+ errors.push({ lead: compactValue(lead, responseOptions({ mode: "compact", maxItems: 1 })), error: error.message || String(error), data: error.result });
1670
+ }
1671
+ });
1672
+ return {
1673
+ leads: leads.map((lead) => enrichedByKey.get(leadKey(lead)) || lead),
1674
+ enrichmentRequests,
1675
+ creditsUsed,
1676
+ remainingCredits,
1677
+ errors: redact(errors),
1678
+ };
1679
+ }
1680
+
1681
+ function batchPlan(details) {
1682
+ return {
1683
+ ok: true,
1684
+ dryRun: true,
1685
+ mode: details.mode,
1686
+ plannedRequests: details.plannedRequests,
1687
+ inputCompanyCount: details.inputCompanyCount,
1688
+ companies: details.companies,
1689
+ concurrency: details.concurrency,
1690
+ perPage: details.perPage,
1691
+ pagesPerCompany: details.pagesPerCompany,
1692
+ perCompanyLimit: details.perCompanyLimit,
1693
+ rateLimitPerMinute: RATE_LIMITS[details.mode],
1694
+ throttle: details.throttle,
1695
+ creditBudget: details.creditBudget,
1696
+ resumeState: details.resumeState,
1697
+ creditNotes: agentGraph().creditNotes[details.mode],
1698
+ };
1699
+ }
1700
+
1701
+ function throttleOptions(args = {}, endpoint = "domain_search") {
1702
+ const publishedLimit = RATE_LIMITS[endpoint] || 1000;
1703
+ const envPrefix = `QUICKENRICH_${endpoint.toUpperCase()}`;
1704
+ const requestedScope = String(args.throttle_scope || process.env.QUICKENRICH_MCP_THROTTLE_SCOPE || "process").toLowerCase();
1705
+ const defaultRpm = endpoint === "domain_search"
1706
+ ? DEFAULT_DOMAIN_SEARCH_RPM
1707
+ : positiveInteger(process.env[`${envPrefix}_REQUESTS_PER_MINUTE`], publishedLimit);
1708
+ const defaultBurst = endpoint === "domain_search"
1709
+ ? DEFAULT_DOMAIN_SEARCH_BURST
1710
+ : positiveInteger(process.env[`${envPrefix}_BURST_SIZE`], DEFAULT_BURSTS[endpoint] || 4);
1711
+ const requestsPerMinute = clampInt(
1712
+ args.requests_per_minute ?? defaultRpm,
1713
+ 1,
1714
+ 100_000,
1715
+ publishedLimit,
1716
+ );
1717
+ const burstSize = clampInt(
1718
+ args.burst_size ?? defaultBurst,
1719
+ 1,
1720
+ 10_000,
1721
+ 1,
1722
+ );
1723
+ return {
1724
+ requestsPerMinute,
1725
+ burstSize,
1726
+ publishedLimit,
1727
+ endpoint,
1728
+ scope: requestedScope === "shared" ? "shared" : "process",
1729
+ abovePublishedLimit: requestsPerMinute > publishedLimit || undefined,
1730
+ requiresProviderApproval: requestsPerMinute > publishedLimit || undefined,
1731
+ };
1732
+ }
1733
+
1734
+ function requestThrottle(client, args = {}, endpoint = "domain_search") {
1735
+ const options = throttleOptions(args, endpoint);
1736
+ const limiter = options.scope === "shared"
1737
+ ? createSharedTokenBucketLimiter(options)
1738
+ : typeof client?.throttleLimiter === "function"
1739
+ ? client.throttleLimiter(options)
1740
+ : createTokenBucketLimiter(options);
1741
+ return createRequestThrottle(options, limiter);
1742
+ }
1743
+
1744
+ function createRequestThrottle(options, limiter = createTokenBucketLimiter(options)) {
1745
+ const requestsPerMinute = options.requestsPerMinute;
1746
+ const burstSize = options.burstSize;
1747
+ const intervalMs = 60_000 / requestsPerMinute;
1748
+ let acquired = 0;
1749
+ let sleptMs = 0;
1750
+
1751
+ async function acquire() {
1752
+ acquired += 1;
1753
+ sleptMs += await limiter.acquire();
1754
+ }
1755
+
1756
+ function cooldown(ms) {
1757
+ limiter.cooldown(ms);
1758
+ }
1759
+
1760
+ function summary() {
1761
+ return {
1762
+ requestsPerMinute,
1763
+ publishedLimit: options.publishedLimit,
1764
+ abovePublishedLimit: requestsPerMinute > options.publishedLimit || undefined,
1765
+ requiresProviderApproval: requestsPerMinute > options.publishedLimit || undefined,
1766
+ scope: options.scope || "process",
1767
+ burstSize,
1768
+ intervalMs: Math.round(intervalMs * 100) / 100,
1769
+ acquired,
1770
+ sleptMs: Math.round(sleptMs),
1771
+ };
1772
+ }
1773
+
1774
+ return { acquire, cooldown, summary };
1775
+ }
1776
+
1777
+ function createSharedTokenBucketLimiter(options) {
1778
+ const local = createTokenBucketLimiter(options);
1779
+ let cooldownUntil = 0;
1780
+ return {
1781
+ async acquire() {
1782
+ const localSlept = await local.acquire();
1783
+ const cooldownWait = Math.max(0, cooldownUntil - Date.now());
1784
+ if (cooldownWait > 0) await sleep(cooldownWait);
1785
+ return localSlept + cooldownWait + await acquireSharedPermit(options);
1786
+ },
1787
+ cooldown(ms) {
1788
+ local.cooldown(ms);
1789
+ const waitMs = Number(ms);
1790
+ if (Number.isFinite(waitMs) && waitMs > 0) cooldownUntil = Math.max(cooldownUntil, Date.now() + waitMs);
1791
+ },
1792
+ };
1793
+ }
1794
+
1795
+ async function acquireSharedPermit(options) {
1796
+ const dir = process.env.QUICKENRICH_MCP_SHARED_THROTTLE_DIR || join(DEFAULT_DATA_DIR, "throttle");
1797
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1798
+ const safeEndpoint = String(options.endpoint || "request").replace(/[^a-z0-9_-]/gi, "_");
1799
+ const statePath = join(dir, `${safeEndpoint}-${options.requestsPerMinute}.json`);
1800
+ const lockPath = `${statePath}.lock`;
1801
+ const windowMs = 60_000;
1802
+ let slept = 0;
1803
+
1804
+ while (true) {
1805
+ await acquireFileLock(lockPath);
1806
+ let waitMs = 0;
1807
+ try {
1808
+ const now = Date.now();
1809
+ const state = readJsonFile(statePath) || {};
1810
+ const starts = Array.isArray(state.starts) ? state.starts.filter((value) => Number(value) > now - windowMs).map(Number) : [];
1811
+ if (starts.length < options.requestsPerMinute) {
1812
+ starts.push(now);
1813
+ writeJsonFileAtomic(statePath, { starts });
1814
+ return slept;
1815
+ }
1816
+ waitMs = Math.max(1, Math.min(60_000, starts[0] + windowMs - now));
1817
+ writeJsonFileAtomic(statePath, { starts });
1818
+ } finally {
1819
+ rmSync(lockPath, { recursive: true, force: true });
1820
+ }
1821
+ slept += waitMs;
1822
+ await sleep(waitMs);
1823
+ }
1824
+ }
1825
+
1826
+ async function acquireFileLock(lockPath) {
1827
+ while (true) {
1828
+ try {
1829
+ mkdirSync(lockPath, { mode: 0o700 });
1830
+ return;
1831
+ } catch (error) {
1832
+ if (error?.code !== "EEXIST") throw error;
1833
+ await sleep(5);
1834
+ }
1835
+ }
1836
+ }
1837
+
1838
+ function createTokenBucketLimiter(options) {
1839
+ const requestsPerMinute = options.requestsPerMinute;
1840
+ const burstSize = options.burstSize;
1841
+ const intervalMs = 60_000 / requestsPerMinute;
1842
+ const windowMs = 60_000;
1843
+ let chain = Promise.resolve();
1844
+ let tokens = burstSize;
1845
+ let updatedAt = Date.now();
1846
+ let cooldownUntil = 0;
1847
+ let windowStarts = [];
1848
+ let windowOffset = 0;
1849
+
1850
+ function refill(now) {
1851
+ const elapsed = Math.max(0, now - updatedAt);
1852
+ if (elapsed > 0) {
1853
+ tokens = Math.min(burstSize, tokens + elapsed / intervalMs);
1854
+ updatedAt = now;
1855
+ }
1856
+ }
1857
+
1858
+ function pruneWindow(now) {
1859
+ const cutoff = now - windowMs;
1860
+ while (windowOffset < windowStarts.length && windowStarts[windowOffset] <= cutoff) {
1861
+ windowOffset += 1;
1862
+ }
1863
+ if (windowOffset > 1024 && windowOffset * 2 > windowStarts.length) {
1864
+ windowStarts = windowStarts.slice(windowOffset);
1865
+ windowOffset = 0;
1866
+ }
1867
+ }
1868
+
1869
+ function rollingWindowWait(now) {
1870
+ pruneWindow(now);
1871
+ if (windowStarts.length - windowOffset < requestsPerMinute) return 0;
1872
+ return Math.max(1, windowStarts[windowOffset] + windowMs - now);
1873
+ }
1874
+
1875
+ async function acquire() {
1876
+ let totalSlept = 0;
1877
+ while (true) {
1878
+ let waitMs = 0;
1879
+ let permitGranted = false;
1880
+ let release;
1881
+ const previous = chain;
1882
+ chain = new Promise((resolve) => {
1883
+ release = resolve;
1884
+ });
1885
+
1886
+ await previous;
1887
+ try {
1888
+ const now = Date.now();
1889
+ refill(now);
1890
+ const cooldownWait = Math.max(0, cooldownUntil - now);
1891
+ if (cooldownWait > 0) {
1892
+ waitMs = cooldownWait;
1893
+ } else {
1894
+ const windowWait = rollingWindowWait(now);
1895
+ if (windowWait > 0) {
1896
+ waitMs = windowWait;
1897
+ } else if (tokens >= 1) {
1898
+ tokens -= 1;
1899
+ windowStarts.push(now);
1900
+ permitGranted = true;
1901
+ } else {
1902
+ waitMs = Math.max(1, (1 - tokens) * intervalMs);
1903
+ }
1904
+ }
1905
+ } finally {
1906
+ release();
1907
+ }
1908
+
1909
+ if (permitGranted) {
1910
+ return totalSlept;
1911
+ }
1912
+
1913
+ totalSlept += waitMs;
1914
+ await sleep(waitMs);
1915
+ }
1916
+ }
1917
+
1918
+ function cooldown(ms) {
1919
+ const waitMs = Number(ms);
1920
+ if (!Number.isFinite(waitMs) || waitMs <= 0) return;
1921
+ cooldownUntil = Math.max(cooldownUntil, Date.now() + waitMs);
1922
+ }
1923
+
1924
+ return { acquire, cooldown };
1925
+ }
1926
+
1927
+ function uniqueDomainSearchCompanies(companies, defaultTitle) {
1928
+ const seen = new Map();
1929
+ for (const company of companies) {
1930
+ const title = company.title || defaultTitle || "";
1931
+ const key = `${normalizeDomainKey(company.company_url)}\n${title.toLowerCase().trim()}`;
1932
+ if (!seen.has(key)) seen.set(key, { ...company, title });
1933
+ }
1934
+ return [...seen.values()];
1935
+ }
1936
+
1937
+ function normalizeDomainKey(value) {
1938
+ return String(value || "")
1939
+ .trim()
1940
+ .toLowerCase()
1941
+ .replace(/^https?:\/\//, "")
1942
+ .replace(/^www\./, "")
1943
+ .replace(/\/.*$/, "");
1944
+ }
1945
+
1946
+ function domainSearchBatchKey(company) {
1947
+ return `${normalizeDomainKey(company.company_url)}|${String(company.title || "").trim().toLowerCase()}`;
1948
+ }
1949
+
1950
+ function normalizeCompanyInputs(input) {
1951
+ const rows = Array.isArray(input) ? input : [];
1952
+ const companies = rows.map((row) => {
1953
+ if (typeof row === "string") return { company_url: row };
1954
+ return {
1955
+ company_url: row.company_url || row.domain || row.url,
1956
+ company_name: row.company_name || row.name,
1957
+ title: row.title,
1958
+ };
1959
+ }).filter((row) => row.company_url);
1960
+ if (!companies.length) throw new Error("companies must include at least one company_url or domain.");
1961
+ return companies;
1962
+ }
1963
+
1964
+ async function pagedFinder(mode, args, options, client) {
1965
+ const throttle = requestThrottle(client, args, mode);
1966
+ const recordsOut = [];
1967
+ const errors = [];
1968
+ let requestCount = 0;
1969
+ let creditsUsed = 0;
1970
+ let remainingCredits;
1971
+ const fingerprint = finderRequestFingerprint(mode, args);
1972
+ const startPage = finderResumeStartPage(args.resume_state, mode, fingerprint, clampInt(args.page, 1, 10_000, 1));
1973
+ const pages = Array.from({ length: options.maxPages }, (_, index) => ({ page: startPage + index }));
1974
+ const resume = createResumeTracker(args.resume_state, pages, (entry) => finderPageKey(mode, fingerprint, entry.page), resumeTrackerOptions(args));
1975
+ let terminalPage;
1976
+
1977
+ const creditEstimate = Number(options.creditEstimate || 0);
1978
+ const creditLimited = options.creditBudget?.limited && creditEstimate > 0;
1979
+
1980
+ async function fetchPage(entry) {
1981
+ const page = entry.page;
1982
+ const hasCreditCharge = creditLimited;
1983
+ const pageCreditEstimate = creditLimited && mode === "company_finder"
1984
+ ? Math.max(1, Math.min(options.perPage, options.creditBudget.remaining()))
1985
+ : creditEstimate;
1986
+ const hasReserved = hasCreditCharge ? options.creditBudget.tryReserve(pageCreditEstimate) : true;
1987
+ if (!hasReserved) {
1988
+ return { ok: false, entry, page, skipped: true, error: new Error("Credit budget exhausted.") };
1989
+ }
1990
+ try {
1991
+ await throttle.acquire();
1992
+ const perPage = creditLimited && mode === "company_finder"
1993
+ ? pageCreditEstimate
1994
+ : options.perPage;
1995
+ const callArgs = { ...args, page, per_page: perPage, include_raw: false, validate_filters: false };
1996
+ const result = mode === "company_finder"
1997
+ ? await companyFinder(callArgs, client)
1998
+ : await contactFinder(callArgs, client);
1999
+ if (options.creditBudget && creditEstimate > 0) {
2000
+ options.creditBudget.commit(result.creditsUsed || 0, pageCreditEstimate);
2001
+ }
2002
+ return { ok: true, entry, page, result };
2003
+ } catch (error) {
2004
+ throttle.cooldown(retryAfterMs(error));
2005
+ if (options.creditBudget && creditEstimate > 0) {
2006
+ options.creditBudget.release(pageCreditEstimate);
2007
+ }
2008
+ return { ok: false, entry, page, error };
2009
+ }
2010
+ }
2011
+
2012
+ function applyPageOutcome(outcome) {
2013
+ if (!outcome.ok) {
2014
+ if (outcome.skipped) return;
2015
+ requestCount += 1;
2016
+ resume.fail(outcome.entry);
2017
+ errors.push({ page: outcome.page, error: outcome.error.message || String(outcome.error), data: outcome.error.result });
2018
+ return;
2019
+ }
2020
+ requestCount += 1;
2021
+ const result = outcome.result;
2022
+ creditsUsed += result.creditsUsed || 0;
2023
+ remainingCredits = minDefined([remainingCredits, result.remainingCredits]);
2024
+ recordsOut.push(...(result.records || []));
2025
+ resume.complete(outcome.entry);
2026
+ const lastPage = Number(result.meta?.last_page || 0);
2027
+ if (lastPage && outcome.page >= lastPage) {
2028
+ terminalPage = lastPage;
2029
+ for (const future of pages) {
2030
+ if (future.page > lastPage) resume.skip(future);
2031
+ }
2032
+ }
2033
+ }
2034
+
2035
+ async function runSequential(items) {
2036
+ for (const entry of items) {
2037
+ if (recordsOut.length >= options.maxResults) break;
2038
+ const outcome = await fetchPage(entry);
2039
+ applyPageOutcome(outcome);
2040
+ if (!outcome.ok || (terminalPage && entry.page >= terminalPage)) break;
2041
+ }
2042
+ }
2043
+
2044
+ const concurrency = clampInt(options.concurrency, 1, MAX_BATCH_CONCURRENCY, 1);
2045
+ const shouldRunSequentially = concurrency <= 1 || resume.items.length <= 1;
2046
+ if (shouldRunSequentially) {
2047
+ await runSequential(resume.items);
2048
+ } else {
2049
+ const [first, ...rest] = resume.items;
2050
+ if (first && recordsOut.length < options.maxResults) {
2051
+ const firstOutcome = await fetchPage(first);
2052
+ applyPageOutcome(firstOutcome);
2053
+ if (firstOutcome.ok && recordsOut.length < options.maxResults && (!terminalPage || first.page < terminalPage)) {
2054
+ const remainingPageBudget = Math.max(0, Math.ceil((options.maxResults - recordsOut.length) / options.perPage));
2055
+ const creditBudgetPages = creditLimited
2056
+ ? mode === "company_finder"
2057
+ ? Math.floor(options.creditBudget?.remaining() || 0)
2058
+ : Math.floor((options.creditBudget?.remaining() || 0) / creditEstimate)
2059
+ : remainingPageBudget;
2060
+ const maxFanout = Math.max(0, creditLimited ? Math.min(remainingPageBudget, creditBudgetPages) : remainingPageBudget);
2061
+ const fanoutItems = rest
2062
+ .filter((entry) => !terminalPage || entry.page <= terminalPage)
2063
+ .slice(0, maxFanout);
2064
+ const outcomes = await withConcurrency(fanoutItems, concurrency, fetchPage);
2065
+ for (const outcome of outcomes.sort((a, b) => a.page - b.page)) {
2066
+ applyPageOutcome(outcome);
2067
+ }
2068
+ }
2069
+ }
2070
+ }
2071
+ const resumeState = finderResumeSummary(resume, pages, mode, fingerprint, terminalPage);
2072
+ return {
2073
+ records: recordsOut.slice(0, options.maxResults),
2074
+ requestCount,
2075
+ creditsUsed,
2076
+ remainingCredits,
2077
+ errors: redact(errors),
2078
+ throttle: throttle.summary(),
2079
+ resumeState,
2080
+ };
2081
+ }
2082
+
2083
+ function contactFinderBody(args) {
2084
+ return cleanObject({
2085
+ ...(args.body && typeof args.body === "object" ? args.body : {}),
2086
+ ...typedFinderFilters(args, CONTACT_FILTER_FIELDS),
2087
+ ...cleanObject({
2088
+ has_email: boolOrUndefined(args.has_email),
2089
+ has_phone: boolOrUndefined(args.has_phone),
2090
+ }),
2091
+ page: args.page,
2092
+ per_page: args.per_page,
2093
+ });
2094
+ }
2095
+
2096
+ function companyFinderBody(args) {
2097
+ return cleanObject({
2098
+ ...(args.body && typeof args.body === "object" ? args.body : {}),
2099
+ ...typedFinderFilters(args, COMPANY_FILTER_FIELDS),
2100
+ ...cleanObject({ company_url: args.company_url }),
2101
+ page: args.page,
2102
+ per_page: args.per_page,
2103
+ });
2104
+ }
2105
+
2106
+ function finderRequestFingerprint(mode, args) {
2107
+ const body = mode === "company_finder" ? companyFinderBody(args) : contactFinderBody(args);
2108
+ const normalized = { ...body };
2109
+ delete normalized.page;
2110
+ delete normalized.per_page;
2111
+ return createHash("sha1")
2112
+ .update(stableStringify({ mode, body: normalized }))
2113
+ .digest("hex")
2114
+ .slice(0, 12);
2115
+ }
2116
+
2117
+ function finderPageKey(mode, fingerprint, page) {
2118
+ return `${mode}:${fingerprint}:page:${page}`;
2119
+ }
2120
+
2121
+ function finderResumeStartPage(state, mode, fingerprint, fallback) {
2122
+ if (state?.fingerprint && state.fingerprint !== fingerprint) return fallback;
2123
+ const nextPage = Number(state?.nextPage);
2124
+ if (Number.isFinite(nextPage) && nextPage > 0) return Math.floor(nextPage);
2125
+ const pendingPages = arrayOfStrings(state?.pending)
2126
+ .map((key) => key.match(new RegExp(`^${mode}:${fingerprint}:page:(\\d+)$`))?.[1])
2127
+ .filter(Boolean)
2128
+ .map(Number)
2129
+ .filter(Number.isFinite);
2130
+ return pendingPages.length ? Math.min(...pendingPages) : fallback;
2131
+ }
2132
+
2133
+ function finderResumeSummary(resume, pages, mode, fingerprint, terminalPage) {
2134
+ const summary = resume.summary();
2135
+ const pendingPages = pages
2136
+ .filter((entry) => summary.pending.includes(finderPageKey(mode, fingerprint, entry.page)))
2137
+ .map((entry) => entry.page);
2138
+ return cleanObject({
2139
+ ...summary,
2140
+ fingerprint,
2141
+ nextPage: pendingPages.length ? Math.min(...pendingPages) : undefined,
2142
+ terminalPage,
2143
+ });
2144
+ }
2145
+
2146
+ const CONTACT_FILTER_FIELDS = [
2147
+ "company_name",
2148
+ "city",
2149
+ "country_code",
2150
+ "industry_linkedin",
2151
+ "number_of_employees",
2152
+ "revenue",
2153
+ "title",
2154
+ "locality",
2155
+ ];
2156
+
2157
+ const COMPANY_FILTER_FIELDS = [
2158
+ "home_page_text",
2159
+ "bio_li",
2160
+ "services",
2161
+ "industry",
2162
+ "number_of_employees",
2163
+ "revenue",
2164
+ "country_code",
2165
+ "city",
2166
+ "company_name",
2167
+ ];
2168
+
2169
+ function typedFinderFilters(args, fields) {
2170
+ const source = args.filters && typeof args.filters === "object" ? args.filters : args;
2171
+ const output = {};
2172
+ for (const field of fields) {
2173
+ const filter = normalizeFinderFilter(source[field]);
2174
+ if (filter) output[field] = filter;
2175
+ }
2176
+ return output;
2177
+ }
2178
+
2179
+ function normalizeFinderFilter(value) {
2180
+ if (value === undefined || value === null || value === "") return undefined;
2181
+ if (Array.isArray(value) || typeof value === "string" || typeof value === "number") {
2182
+ const include = arrayOfStrings(value);
2183
+ return include.length ? { include, exclude: [] } : undefined;
2184
+ }
2185
+ if (typeof value === "object") {
2186
+ const include = arrayOfStrings(value.include);
2187
+ const exclude = arrayOfStrings(value.exclude);
2188
+ if (!include.length && !exclude.length) return undefined;
2189
+ return { include, exclude };
2190
+ }
2191
+ return undefined;
2192
+ }
2193
+
2194
+ async function validateFinderFilters(mode, args, client) {
2195
+ const body = mode === "company_finder" ? companyFinderBody(args) : contactFinderBody(args);
2196
+ const spec = mode === "company_finder" ? COMPANY_VALIDATED_FIELDS : CONTACT_VALIDATED_FIELDS;
2197
+ const errors = [];
2198
+ const checked = [];
2199
+ for (const [field, lookup] of Object.entries(spec)) {
2200
+ const values = finderFieldValues(body[field]);
2201
+ if (!values.length) continue;
2202
+ checked.push({ field, lookup, values });
2203
+ const allowed = await lookupAllowedValues(lookup, client);
2204
+ for (const value of values) {
2205
+ if (!allowed.has(value.toLowerCase())) {
2206
+ errors.push({ field, value, lookup, message: `${field} must match ${lookup} lookup values.` });
2207
+ }
2208
+ }
2209
+ }
2210
+ return {
2211
+ ok: errors.length === 0,
2212
+ mode,
2213
+ checked,
2214
+ errors,
2215
+ };
2216
+ }
2217
+
2218
+ const CONTACT_VALIDATED_FIELDS = {
2219
+ country_code: "country_codes",
2220
+ industry_linkedin: "industries",
2221
+ number_of_employees: "employee_ranges",
2222
+ revenue: "revenue_ranges",
2223
+ };
2224
+
2225
+ const COMPANY_VALIDATED_FIELDS = {
2226
+ services: "company_services",
2227
+ industry: "industries",
2228
+ country_code: "country_codes",
2229
+ number_of_employees: "employee_ranges",
2230
+ revenue: "revenue_ranges",
2231
+ };
2232
+
2233
+ const lookupCache = new Map();
2234
+
2235
+ async function lookupAllowedValues(lookup, client) {
2236
+ const key = `${lookup}:all`;
2237
+ if (lookupCache.has(key)) return lookupCache.get(key);
2238
+ const result = await lookupValues({ lookup }, client);
2239
+ const values = new Set(extractLookupValues(result.data, lookup).map((value) => value.toLowerCase()));
2240
+ lookupCache.set(key, values);
2241
+ return values;
2242
+ }
2243
+
2244
+ function extractLookupValues(data, lookup) {
2245
+ if (Array.isArray(data)) {
2246
+ return data.flatMap((entry) => {
2247
+ if (typeof entry === "string") return [entry];
2248
+ if (!entry || typeof entry !== "object") return [];
2249
+ return [
2250
+ entry.service,
2251
+ entry.name,
2252
+ entry.value,
2253
+ entry.label,
2254
+ entry.industry,
2255
+ entry.country_code,
2256
+ entry.country_code_2_character,
2257
+ entry.range,
2258
+ ].filter(Boolean);
2259
+ }).map(String);
2260
+ }
2261
+ if (data && typeof data === "object") {
2262
+ return extractLookupValues(data.data || data.values || data.results || [], lookup);
2263
+ }
2264
+ return [];
2265
+ }
2266
+
2267
+ function finderFieldValues(value) {
2268
+ if (!value) return [];
2269
+ if (typeof value === "object" && !Array.isArray(value)) {
2270
+ return [...arrayOfStrings(value.include), ...arrayOfStrings(value.exclude)];
2271
+ }
2272
+ return arrayOfStrings(value);
2273
+ }
2274
+
2275
+ function normalizeLead(row, source) {
2276
+ const first = cleanString(row.first_name || row.firstName);
2277
+ const last = cleanString(row.last_name || row.lastName);
2278
+ const name = cleanString(row.name || [first, last].filter(Boolean).join(" "));
2279
+ return cleanObject({
2280
+ source,
2281
+ first_name: first,
2282
+ last_name: last,
2283
+ name,
2284
+ title: cleanString(row.title || row.job_title || row.jobTitle),
2285
+ email: cleanString(row.email || row.employee_email),
2286
+ phone: normalizePhone(row.employee_phone || row.phone || row.mobile_phone),
2287
+ employee_linkedin: cleanString(row.employee_linkedin || row.linkedin_url || row.linkedin || row.linkedinLink),
2288
+ has_email: boolOrUndefined(row.has_email),
2289
+ has_phone: boolOrUndefined(row.has_phone),
2290
+ company_url: cleanString(row.company_url || row.website || row.domain),
2291
+ company_name: cleanString(row.company_name || row.company || row.name_company),
2292
+ email_domain: cleanString(row.email_domain),
2293
+ company_phone: normalizePhone(row.company_phone),
2294
+ employee_count: cleanString(row.employee_count || row.number_of_employees),
2295
+ revenue: cleanString(row.revenue),
2296
+ industry: cleanString(row.industry || row.industry_linkedin),
2297
+ city: cleanString(row.city),
2298
+ locality: cleanString(row.locality),
2299
+ region_code: cleanString(row.region_code),
2300
+ country_code: cleanString(row.country_code),
2301
+ });
2302
+ }
2303
+
2304
+ function normalizeCompany(row) {
2305
+ const domain = cleanString(row.url || row.company_url || row.domain || row.email_domain || row.final_email_domain);
2306
+ return cleanObject({
2307
+ source: "company_finder",
2308
+ company_name: cleanString(row.company_name || row.name || row.company),
2309
+ domain,
2310
+ url: cleanString(row.url || row.website || domain),
2311
+ email_domain: cleanString(row.email_domain),
2312
+ final_email_domain: cleanString(row.final_email_domain),
2313
+ phone: normalizePhone(row.phone || row.company_phone),
2314
+ linkedin_url: cleanString(row.linkedin_url || row.company_linkedin || row.company_linked),
2315
+ city: cleanString(row.city),
2316
+ region_code: cleanString(row.region_code),
2317
+ country_code: cleanString(row.country_code),
2318
+ industry: cleanString(row.industry || row.industry_linkedin),
2319
+ employee_count: cleanString(row.employee_count || row.number_of_employees),
2320
+ revenue: cleanString(row.revenue),
2321
+ services: Array.isArray(row.services) ? row.services.map(cleanString).filter(Boolean) : undefined,
2322
+ service_count: numericMeta(row.service_count),
2323
+ home_page_text_snippet: cleanString(row.home_page_text_snippet),
2324
+ bio_li_snippet: cleanString(row.bio_li_snippet),
2325
+ });
2326
+ }
2327
+
2328
+ function records(payload) {
2329
+ const data = payload?.data;
2330
+ if (Array.isArray(data)) return data;
2331
+ if (data && typeof data === "object") return [data];
2332
+ if (Array.isArray(payload)) return payload;
2333
+ return [];
2334
+ }
2335
+
2336
+ function firstRecord(payload) {
2337
+ return records(payload)[0] || null;
2338
+ }
2339
+
2340
+ function requirePersonLocator(args) {
2341
+ if (args.linkedin_url) return;
2342
+ if (args.company_url && args.first_name && args.last_name) return;
2343
+ throw new Error("Provide linkedin_url or company_url + first_name + last_name.");
2344
+ }
2345
+
2346
+ function canLocatePerson(lead) {
2347
+ return Boolean(lead.employee_linkedin || (lead.company_url && lead.first_name && lead.last_name));
2348
+ }
2349
+
2350
+ function titleFromText(lower) {
2351
+ if (/founder|cofounder|co-founder/.test(lower)) return FOUNDER_TITLE;
2352
+ if (/\bceo\b|chief executive/.test(lower)) return "CEO";
2353
+ if (/\bowner\b/.test(lower)) return "Owner";
2354
+ if (/\bpresident\b/.test(lower)) return "President";
2355
+ return "";
2356
+ }
2357
+
2358
+ function guessName(text) {
2359
+ const match = text.match(/\b([A-Z][a-z]+)\s+([A-Z][a-z]+)\b/);
2360
+ return { first: match?.[1], last: match?.[2] };
2361
+ }
2362
+
2363
+ function extractEmails(text) {
2364
+ return [...text.matchAll(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi)].map((m) => m[0]);
2365
+ }
2366
+
2367
+ function extractLinkedIn(text) {
2368
+ const match = text.match(/https?:\/\/(?:www\.)?linkedin\.com\/in\/[^\s,;)"']+/i);
2369
+ return match?.[0];
2370
+ }
2371
+
2372
+ function extractDomains(text) {
2373
+ const out = [];
2374
+ const seen = new Set();
2375
+ const matches = text.matchAll(/\b(?:https?:\/\/)?(?:www\.)?([a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+)(?:\/[^\s,;)"']*)?/gi);
2376
+ for (const match of matches) {
2377
+ const raw = match[0].replace(/[.,;:)]+$/, "");
2378
+ if (/linkedin\.com|quickenrich\.io/i.test(raw)) continue;
2379
+ const value = raw.startsWith("http") ? raw : match[1];
2380
+ const key = value.toLowerCase();
2381
+ if (!seen.has(key)) {
2382
+ seen.add(key);
2383
+ out.push(value);
2384
+ }
2385
+ }
2386
+ return out;
2387
+ }
2388
+
2389
+ function dedupeLeads(leads) {
2390
+ const map = new Map();
2391
+ for (const lead of leads) {
2392
+ const key = leadKey(lead);
2393
+ if (!map.has(key)) map.set(key, lead);
2394
+ else map.set(key, { ...map.get(key), ...cleanObject(lead) });
2395
+ }
2396
+ return [...map.values()];
2397
+ }
2398
+
2399
+ function uniqueStrings(values) {
2400
+ const out = [];
2401
+ const seen = new Set();
2402
+ for (const value of values) {
2403
+ const text = String(value || "").trim();
2404
+ if (!text) continue;
2405
+ const key = text.toLowerCase();
2406
+ if (seen.has(key)) continue;
2407
+ seen.add(key);
2408
+ out.push(text);
2409
+ }
2410
+ return out;
2411
+ }
2412
+
2413
+ function emailValidationError(email) {
2414
+ const text = String(email || "").trim();
2415
+ if (!text) return "email is required.";
2416
+ const parts = text.split("@");
2417
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return `Invalid email address: ${text}`;
2418
+ if (text.length > 254 || /\s/.test(text)) return `Invalid email address: ${text}`;
2419
+ const domain = parts[1].toLowerCase();
2420
+ const labels = domain.split(".");
2421
+ if (labels.length < 2) return `Invalid email domain: ${domain}`;
2422
+ for (const label of labels) {
2423
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label)) return `Invalid email domain: ${domain}`;
2424
+ }
2425
+ const tld = labels.at(-1);
2426
+ if (!/^[a-z]{2,63}$/i.test(tld) || RESERVED_EMAIL_TLDS.has(tld)) return `Unsupported email domain: ${domain}`;
2427
+ return null;
2428
+ }
2429
+
2430
+ function uniquePersons(values) {
2431
+ const out = [];
2432
+ const seen = new Set();
2433
+ for (const value of values) {
2434
+ if (!value || typeof value !== "object") continue;
2435
+ const person = cleanObject({
2436
+ linkedin_url: cleanString(value.linkedin_url || value.linkedin),
2437
+ company_url: cleanString(value.company_url || value.domain || value.company_domain || value.url),
2438
+ first_name: cleanString(value.first_name || value.firstName),
2439
+ last_name: cleanString(value.last_name || value.lastName),
2440
+ });
2441
+ if (!person.linkedin_url && !(person.company_url && person.first_name && person.last_name)) continue;
2442
+ const key = `${person.linkedin_url || ""}|${person.company_url || ""}|${person.first_name || ""}|${person.last_name || ""}`.toLowerCase();
2443
+ if (seen.has(key)) continue;
2444
+ seen.add(key);
2445
+ out.push(person);
2446
+ }
2447
+ return out;
2448
+ }
2449
+
2450
+ function createResumeTracker(state, items, keyFn, options = {}) {
2451
+ const completed = new Set(arrayOfStrings(state?.completed).map((key) => key.toLowerCase()));
2452
+ const failed = new Set(arrayOfStrings(state?.failed).map((key) => key.toLowerCase()));
2453
+ const skipped = new Set(arrayOfStrings(state?.skipped).map((key) => key.toLowerCase()));
2454
+ const keyFor = (item) => String(keyFn(item) || "").toLowerCase();
2455
+ const pendingItems = [];
2456
+ for (const item of items) {
2457
+ const key = keyFor(item);
2458
+ if (!key || completed.has(key) || failed.has(key) || skipped.has(key)) continue;
2459
+ pendingItems.push(item);
2460
+ }
2461
+ const tracker = {
2462
+ items: pendingItems,
2463
+ complete(item) {
2464
+ const key = keyFor(item);
2465
+ if (key) completed.add(key);
2466
+ notify();
2467
+ },
2468
+ fail(item) {
2469
+ const key = keyFor(item);
2470
+ if (key) failed.add(key);
2471
+ notify();
2472
+ },
2473
+ skip(item) {
2474
+ const key = keyFor(item);
2475
+ if (key) skipped.add(key);
2476
+ notify();
2477
+ },
2478
+ summary() {
2479
+ const pending = items.map(keyFor).filter((key) => key && !completed.has(key) && !failed.has(key) && !skipped.has(key));
2480
+ return {
2481
+ completed: [...completed],
2482
+ failed: [...failed],
2483
+ ...(skipped.size ? { skipped: [...skipped] } : {}),
2484
+ pending,
2485
+ inputCount: items.length,
2486
+ pendingCount: pending.length,
2487
+ };
2488
+ },
2489
+ };
2490
+ function notify() {
2491
+ if (typeof options.onChange === "function") options.onChange(tracker.summary());
2492
+ }
2493
+ return tracker;
2494
+ }
2495
+
2496
+ function personKey(person) {
2497
+ return (
2498
+ person.linkedin_url ||
2499
+ [person.company_url, person.first_name, person.last_name].filter(Boolean).join("|")
2500
+ ).toLowerCase();
2501
+ }
2502
+
2503
+ function stableStringify(value) {
2504
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
2505
+ if (value && typeof value === "object") {
2506
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
2507
+ }
2508
+ return JSON.stringify(value);
2509
+ }
2510
+
2511
+ function leadKey(lead) {
2512
+ return (
2513
+ lead.employee_linkedin ||
2514
+ lead.email ||
2515
+ [lead.first_name, lead.last_name, lead.company_url || lead.batch_company_url || lead.company_name].filter(Boolean).join("|").toLowerCase() ||
2516
+ JSON.stringify(lead)
2517
+ );
2518
+ }
2519
+
2520
+ async function withConcurrency(items, concurrency, worker) {
2521
+ if (!items.length) return [];
2522
+ const limit = Math.min(concurrency, items.length);
2523
+ if (limit >= items.length) {
2524
+ return Promise.all(items.map((item, index) => worker(item, index)));
2525
+ }
2526
+ const output = new Array(items.length);
2527
+ let next = 0;
2528
+ const workers = Array.from({ length: limit }, async () => {
2529
+ while (next < items.length) {
2530
+ const index = next;
2531
+ next += 1;
2532
+ output[index] = await worker(items[index], index);
2533
+ }
2534
+ });
2535
+ await Promise.all(workers);
2536
+ return output;
2537
+ }
2538
+
2539
+ async function requestJsonFetch({ fetchImpl, url, method, headers, payload, signal }) {
2540
+ const response = await fetchImpl(url, {
2541
+ method,
2542
+ headers,
2543
+ body: payload,
2544
+ signal,
2545
+ });
2546
+ const parsed = await parseFetchResponse(response);
2547
+ return {
2548
+ ok: response.ok,
2549
+ status: response.status,
2550
+ data: parsed.data,
2551
+ headers: response.headers,
2552
+ };
2553
+ }
2554
+
2555
+ function requestJsonNative({ target, method, headers, payload, timeoutMs, httpAgent, httpsAgent }) {
2556
+ const transport = target.protocol === "http:" ? httpRequest : httpsRequest;
2557
+ const agent = target.protocol === "http:" ? httpAgent : httpsAgent;
2558
+ return new Promise((resolve, reject) => {
2559
+ const req = transport(target, { method, headers, agent }, (res) => {
2560
+ let text = "";
2561
+ res.setEncoding("utf8");
2562
+ res.on("data", (chunk) => {
2563
+ text += chunk;
2564
+ });
2565
+ res.on("end", () => {
2566
+ resolve({
2567
+ ok: Number(res.statusCode) >= 200 && Number(res.statusCode) < 300,
2568
+ status: Number(res.statusCode || 0),
2569
+ data: parseJsonText(text),
2570
+ headers: res.headers,
2571
+ });
2572
+ });
2573
+ });
2574
+ req.setTimeout(timeoutMs, () => {
2575
+ const error = new Error(`Quick Enrich request timed out after ${timeoutMs}ms`);
2576
+ error.name = "AbortError";
2577
+ req.destroy(error);
2578
+ });
2579
+ req.on("error", reject);
2580
+ if (payload !== undefined) req.write(payload);
2581
+ req.end();
2582
+ });
2583
+ }
2584
+
2585
+ async function parseFetchResponse(response) {
2586
+ const text = await response.text();
2587
+ return { data: parseJsonText(text) };
2588
+ }
2589
+
2590
+ function parseJsonText(text) {
2591
+ if (!text) return null;
2592
+ try {
2593
+ return JSON.parse(text);
2594
+ } catch {
2595
+ return { text };
2596
+ }
2597
+ }
2598
+
2599
+ function rateLimitHeaders(headers) {
2600
+ return cleanObject({
2601
+ limit: headerValue(headers, "x-ratelimit-limit"),
2602
+ remaining: headerValue(headers, "x-ratelimit-remaining"),
2603
+ reset: headerValue(headers, "x-ratelimit-reset"),
2604
+ retryAfter: headerValue(headers, "retry-after"),
2605
+ });
2606
+ }
2607
+
2608
+ function headerValue(headers, key) {
2609
+ if (!headers) return undefined;
2610
+ if (typeof headers.get === "function") return headers.get(key) || undefined;
2611
+ const value = headers[key.toLowerCase()] ?? headers[key];
2612
+ return Array.isArray(value) ? value.join(", ") : value;
2613
+ }
2614
+
2615
+ function retryAfterMs(error) {
2616
+ const retryAfter = Number(error?.result?.rateLimit?.retryAfter);
2617
+ return Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 0;
2618
+ }
2619
+
2620
+ export function compactToolPayload(payload, options = {}) {
2621
+ const resolved = responseOptions(options);
2622
+ const meta = { truncated: false, paths: [] };
2623
+ const compacted = compactValue(redact(payload), resolved, "$", 0, meta);
2624
+ const output = cleanObject({
2625
+ ...compacted,
2626
+ _meta: cleanObject({
2627
+ responseMode: resolved.mode,
2628
+ truncated: meta.truncated || undefined,
2629
+ truncatedPaths: meta.paths.length ? meta.paths.slice(0, 50) : undefined,
2630
+ }),
2631
+ });
2632
+ const text = JSON.stringify(output);
2633
+ if (Buffer.byteLength(text, "utf8") <= resolved.maxOutputChars) return attachSerializedToolPayload(output, text);
2634
+ const omitted = {
2635
+ ok: Boolean(payload?.ok),
2636
+ omitted: true,
2637
+ bytes: Buffer.byteLength(text, "utf8"),
2638
+ hint: "Output exceeded maxOutputChars. Use response:{mode:'meta'} or lower limits.",
2639
+ summary: summarizeValue(payload),
2640
+ };
2641
+ return attachSerializedToolPayload(omitted, JSON.stringify(omitted));
2642
+ }
2643
+
2644
+ export function toolPayloadText(payload, pretty = false) {
2645
+ if (!pretty && payload && typeof payload === "object" && typeof payload[SERIALIZED_TOOL_PAYLOAD] === "string") {
2646
+ return payload[SERIALIZED_TOOL_PAYLOAD];
2647
+ }
2648
+ return JSON.stringify(payload, null, pretty ? 2 : 0);
2649
+ }
2650
+
2651
+ function attachSerializedToolPayload(payload, text) {
2652
+ if (!payload || typeof payload !== "object") return payload;
2653
+ Object.defineProperty(payload, SERIALIZED_TOOL_PAYLOAD, {
2654
+ value: text,
2655
+ enumerable: false,
2656
+ configurable: false,
2657
+ });
2658
+ return payload;
2659
+ }
2660
+
2661
+ export function responseOptions(options = {}) {
2662
+ const source = options && typeof options === "object" ? options : {};
2663
+ const mode = RESPONSE_MODES.has(source.mode) ? source.mode : RESPONSE_MODES.has(DEFAULT_RESPONSE_MODE) ? DEFAULT_RESPONSE_MODE : "compact";
2664
+ return {
2665
+ mode,
2666
+ maxItems: positiveInteger(source.maxItems, 12),
2667
+ maxKeys: positiveInteger(source.maxKeys, 48),
2668
+ maxDepth: positiveInteger(source.maxDepth, 4),
2669
+ maxStringChars: positiveInteger(source.maxStringChars, 240),
2670
+ maxOutputChars: positiveInteger(source.maxOutputChars, MAX_OUTPUT_CHARS),
2671
+ };
2672
+ }
2673
+
2674
+ export function compactValue(value, options, path = "$", depth = 0, meta = { truncated: false, paths: [] }) {
2675
+ if (options.mode === "full") return value;
2676
+ if (options.mode === "meta") return summarizeValue(value);
2677
+ if (typeof value === "string") {
2678
+ if (value.length <= options.maxStringChars) return value;
2679
+ meta.truncated = true;
2680
+ meta.paths.push(path);
2681
+ return `${value.slice(0, options.maxStringChars)}...`;
2682
+ }
2683
+ if (!value || typeof value !== "object") return value;
2684
+ if (depth >= options.maxDepth) {
2685
+ meta.truncated = true;
2686
+ meta.paths.push(path);
2687
+ return summarizeValue(value);
2688
+ }
2689
+ if (Array.isArray(value)) {
2690
+ const max = Math.min(value.length, options.maxItems);
2691
+ const out = new Array(max);
2692
+ for (let index = 0; index < max; index += 1) {
2693
+ out[index] = compactValue(value[index], options, `${path}[${index}]`, depth + 1, meta);
2694
+ }
2695
+ if (value.length > options.maxItems) {
2696
+ meta.truncated = true;
2697
+ meta.paths.push(`${path}[${options.maxItems}..]`);
2698
+ out.push({ omitted: value.length - options.maxItems });
2699
+ }
2700
+ return out;
2701
+ }
2702
+ const out = {};
2703
+ const keys = Object.keys(value);
2704
+ let written = 0;
2705
+ let omitted = 0;
2706
+ for (const key of keys) {
2707
+ const entry = value[key];
2708
+ if (entry === undefined) continue;
2709
+ if (written >= options.maxKeys) {
2710
+ omitted += 1;
2711
+ continue;
2712
+ }
2713
+ out[key] = compactValue(entry, options, `${path}.${key}`, depth + 1, meta);
2714
+ written += 1;
2715
+ }
2716
+ if (omitted) {
2717
+ meta.truncated = true;
2718
+ meta.paths.push(`${path}.{${options.maxKeys}..}`);
2719
+ out._omittedKeys = omitted;
2720
+ }
2721
+ return out;
2722
+ }
2723
+
2724
+ export function summarizeValue(value) {
2725
+ if (Array.isArray(value)) return { type: "array", length: value.length };
2726
+ if (value && typeof value === "object") {
2727
+ const scalars = {};
2728
+ const collections = {};
2729
+ let collectionCount = 0;
2730
+ for (const [key, entry] of Object.entries(value)) {
2731
+ if (entry === undefined) continue;
2732
+ if (entry === null || typeof entry === "boolean" || typeof entry === "number") {
2733
+ scalars[key] = entry;
2734
+ } else if (typeof entry === "string") {
2735
+ scalars[key] = entry.length <= 160 ? entry : `${entry.slice(0, 160)}...`;
2736
+ } else if (collectionCount < 16 && Array.isArray(entry)) {
2737
+ collections[key] = { type: "array", length: entry.length };
2738
+ collectionCount += 1;
2739
+ } else if (collectionCount < 16 && entry && typeof entry === "object") {
2740
+ collections[key] = { type: "object", keys: Object.keys(entry).slice(0, 8) };
2741
+ collectionCount += 1;
2742
+ }
2743
+ }
2744
+ return cleanObject({
2745
+ type: "object",
2746
+ keys: Object.keys(value).slice(0, 16),
2747
+ scalars: Object.keys(scalars).length ? scalars : undefined,
2748
+ collections: Object.keys(collections).length ? collections : undefined,
2749
+ });
2750
+ }
2751
+ if (typeof value === "string") return { type: "string", length: value.length };
2752
+ return value;
2753
+ }
2754
+
2755
+ export function redact(value) {
2756
+ if (typeof value === "string") {
2757
+ if (!couldContainSecret(value)) return value;
2758
+ return value
2759
+ .replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer <redacted>")
2760
+ .replace(/([?&](?:secret|token|api[_-]?key|password)=)[^&\s"]+/gi, "$1<redacted>");
2761
+ }
2762
+ if (Array.isArray(value)) {
2763
+ let out;
2764
+ for (let index = 0; index < value.length; index += 1) {
2765
+ const entry = value[index];
2766
+ const redacted = redact(entry);
2767
+ if (redacted !== entry) {
2768
+ if (!out) out = value.slice(0, index);
2769
+ out.push(redacted);
2770
+ } else if (out) {
2771
+ out.push(entry);
2772
+ }
2773
+ }
2774
+ return out || value;
2775
+ }
2776
+ if (value && typeof value === "object") {
2777
+ let out;
2778
+ for (const key of Object.keys(value)) {
2779
+ const entry = value[key];
2780
+ const redacted = /api[_-]?key|authorization|password|secret|token/i.test(key) ? "<redacted>" : redact(entry);
2781
+ if (redacted !== entry) {
2782
+ if (!out) out = { ...value };
2783
+ out[key] = redacted;
2784
+ }
2785
+ }
2786
+ return out || value;
2787
+ }
2788
+ return value;
2789
+ }
2790
+
2791
+ function couldContainSecret(value) {
2792
+ const text = String(value);
2793
+ return text.includes("?")
2794
+ || text.includes("&")
2795
+ || /bearer|secret|token|api[_-]?key|password/i.test(text);
2796
+ }
2797
+
2798
+ export function cleanObject(value) {
2799
+ const out = {};
2800
+ for (const key of Object.keys(value || {})) {
2801
+ const entry = value[key];
2802
+ if (entry !== undefined && entry !== null && entry !== "") out[key] = entry;
2803
+ }
2804
+ return out;
2805
+ }
2806
+
2807
+ function normalizePhone(value) {
2808
+ const raw = String(value || "").trim();
2809
+ if (!raw || /^(n\/a|na|none|null|-|unknown)$/i.test(raw)) return "";
2810
+ const digits = raw.replace(/[^\d+]/g, "");
2811
+ if (!digits) return "";
2812
+ if (digits.startsWith("+")) return digits;
2813
+ if (digits.length === 10) return `+1${digits}`;
2814
+ return `+${digits}`;
2815
+ }
2816
+
2817
+ function boolOrUndefined(value) {
2818
+ if (typeof value === "boolean") return value;
2819
+ return undefined;
2820
+ }
2821
+
2822
+ function cleanString(value) {
2823
+ const text = String(value || "").trim();
2824
+ return /^(n\/a|na|none|null|-|unknown)$/i.test(text) ? "" : text;
2825
+ }
2826
+
2827
+ function arrayOfStrings(value) {
2828
+ const values = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
2829
+ return values.map(cleanString).filter(Boolean);
2830
+ }
2831
+
2832
+ function booleanQuery(value) {
2833
+ if (typeof value === "boolean") return value ? "true" : "false";
2834
+ return undefined;
2835
+ }
2836
+
2837
+ function recordsFromTextInputs(args) {
2838
+ return [
2839
+ ...parseFileRecords(args.input_file, args.input_format),
2840
+ ...parseCsvRecords(args.input_csv),
2841
+ ...parseJsonlRecords(args.input_jsonl),
2842
+ ];
2843
+ }
2844
+
2845
+ function parseFileRecords(path, explicitFormat) {
2846
+ if (!path) return [];
2847
+ const filePath = localFilePath(path);
2848
+ const text = readFileSync(filePath, "utf8");
2849
+ const format = String(explicitFormat || extname(filePath).replace(/^\./, "") || "").toLowerCase();
2850
+ if (format === "jsonl" || format === "ndjson") return parseJsonlRecords(text);
2851
+ if (format === "csv") return parseCsvRecords(text);
2852
+ const trimmed = text.trimStart();
2853
+ if (trimmed.startsWith("{")) return parseJsonlRecords(text);
2854
+ return parseCsvRecords(text);
2855
+ }
2856
+
2857
+ function localFilePath(value) {
2858
+ const raw = String(value || "").trim();
2859
+ if (!raw) throw new Error("file path is required.");
2860
+ if (raw.startsWith("file://")) return new URL(raw).pathname;
2861
+ return raw;
2862
+ }
2863
+
2864
+ function prepareBulkArgs(args = {}) {
2865
+ const out = { ...(args && typeof args === "object" ? args : {}) };
2866
+ if (out.resume_file) {
2867
+ out.resume_file = localFilePath(out.resume_file);
2868
+ out.resume_state = mergeResumeStates(readJsonFile(out.resume_file) || {}, out.resume_state || {});
2869
+ }
2870
+ if (out.output_file) out.output_file = localFilePath(out.output_file);
2871
+ return out;
2872
+ }
2873
+
2874
+ function mergeResumeStates(fileState, inlineState) {
2875
+ const file = fileState && typeof fileState === "object" ? fileState : {};
2876
+ const inline = inlineState && typeof inlineState === "object" ? inlineState : {};
2877
+ return cleanObject({
2878
+ ...file,
2879
+ ...inline,
2880
+ completed: uniqueStrings([...arrayOfStrings(file.completed), ...arrayOfStrings(inline.completed)]),
2881
+ failed: uniqueStrings([...arrayOfStrings(file.failed), ...arrayOfStrings(inline.failed)]),
2882
+ skipped: uniqueStrings([...arrayOfStrings(file.skipped), ...arrayOfStrings(inline.skipped)]),
2883
+ });
2884
+ }
2885
+
2886
+ function resumeTrackerOptions(args) {
2887
+ if (!args?.resume_file) return {};
2888
+ return {
2889
+ onChange(summary) {
2890
+ writeJsonFileAtomic(args.resume_file, summary);
2891
+ },
2892
+ };
2893
+ }
2894
+
2895
+ function writeJsonFileAtomic(path, value) {
2896
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
2897
+ const tmpPath = tempArtifactPath(path);
2898
+ writeFileSync(tmpPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
2899
+ renameSync(tmpPath, path);
2900
+ try {
2901
+ chmodSync(path, 0o600);
2902
+ } catch {
2903
+ // Best-effort when the filesystem does not support chmod.
2904
+ }
2905
+ }
2906
+
2907
+ function writeRowsFile(path, rows, format) {
2908
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
2909
+ const exported = formatExport(rows, format);
2910
+ const text = exported?.text || "";
2911
+ const tmpPath = tempArtifactPath(path);
2912
+ writeFileSync(tmpPath, text && !text.endsWith("\n") ? `${text}\n` : text, { mode: 0o600 });
2913
+ renameSync(tmpPath, path);
2914
+ try {
2915
+ chmodSync(path, 0o600);
2916
+ } catch {
2917
+ // Best-effort only.
2918
+ }
2919
+ return {
2920
+ path,
2921
+ format,
2922
+ count: exported?.count || 0,
2923
+ bytes: Buffer.byteLength(text, "utf8"),
2924
+ };
2925
+ }
2926
+
2927
+ function tempArtifactPath(path) {
2928
+ artifactWriteCounter = (artifactWriteCounter + 1) % 1_000_000_000;
2929
+ return `${path}.${process.pid}.${Date.now()}.${artifactWriteCounter}.tmp`;
2930
+ }
2931
+
2932
+ function parseJsonlRecords(text) {
2933
+ return String(text || "")
2934
+ .split(/\r?\n/)
2935
+ .map((line) => line.trim())
2936
+ .filter(Boolean)
2937
+ .map((line) => JSON.parse(line));
2938
+ }
2939
+
2940
+ function parseCsvRecords(text) {
2941
+ const lines = String(text || "").split(/\r?\n/).filter((line) => line.trim());
2942
+ if (lines.length < 2) return [];
2943
+ const headers = parseCsvLine(lines[0]).map((header) => header.trim());
2944
+ return lines.slice(1).map((line) => {
2945
+ const values = parseCsvLine(line);
2946
+ const row = {};
2947
+ headers.forEach((header, index) => {
2948
+ row[header] = values[index] || "";
2949
+ });
2950
+ return row;
2951
+ });
2952
+ }
2953
+
2954
+ function parseCsvLine(line) {
2955
+ const text = String(line || "");
2956
+ if (!text.includes('"')) return text.split(",");
2957
+ const out = [];
2958
+ let value = "";
2959
+ let quoted = false;
2960
+ for (let index = 0; index < text.length; index += 1) {
2961
+ const char = text[index];
2962
+ if (char === '"' && text[index + 1] === '"') {
2963
+ value += '"';
2964
+ index += 1;
2965
+ } else if (char === '"') {
2966
+ quoted = !quoted;
2967
+ } else if (char === "," && !quoted) {
2968
+ out.push(value);
2969
+ value = "";
2970
+ } else {
2971
+ value += char;
2972
+ }
2973
+ }
2974
+ out.push(value);
2975
+ return out;
2976
+ }
2977
+
2978
+ function exportFormatOption(value) {
2979
+ const format = String(value || "none").toLowerCase();
2980
+ return ["jsonl", "csv"].includes(format) ? format : "none";
2981
+ }
2982
+
2983
+ function formatExport(rows, format) {
2984
+ if (!format || format === "none") return undefined;
2985
+ const list = Array.isArray(rows) ? rows : [];
2986
+ if (format === "jsonl") {
2987
+ return { format, count: list.length, text: list.map((row) => JSON.stringify(cleanObject(row))).join("\n") };
2988
+ }
2989
+ return { format, count: list.length, text: toCsv(list) };
2990
+ }
2991
+
2992
+ function toCsv(rows) {
2993
+ const flatRows = new Array(rows.length);
2994
+ const headers = [];
2995
+ const seenHeaders = new Set();
2996
+ rows.forEach((row, index) => {
2997
+ const flat = flatExportRow(row);
2998
+ flatRows[index] = flat;
2999
+ for (const header of Object.keys(flat)) {
3000
+ if (seenHeaders.has(header)) continue;
3001
+ seenHeaders.add(header);
3002
+ headers.push(header);
3003
+ }
3004
+ });
3005
+ const lines = [headers.join(",")];
3006
+ for (const flat of flatRows) {
3007
+ lines.push(headers.map((header) => csvCell(flat[header])).join(","));
3008
+ }
3009
+ return lines.join("\n");
3010
+ }
3011
+
3012
+ function flatExportRow(row) {
3013
+ const out = {};
3014
+ for (const [key, value] of Object.entries(row || {})) {
3015
+ if (value === undefined || value === null) continue;
3016
+ out[key] = Array.isArray(value) ? value.join(";") : typeof value === "object" ? JSON.stringify(value) : value;
3017
+ }
3018
+ return out;
3019
+ }
3020
+
3021
+ function csvCell(value) {
3022
+ const text = String(value ?? "");
3023
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
3024
+ }
3025
+
3026
+ function projectionMode(args = {}) {
3027
+ const mode = String(args.projection || "full").toLowerCase();
3028
+ return PROJECTION_MODES.has(mode) ? mode : "full";
3029
+ }
3030
+
3031
+ function projectRows(rows, projection, endpoint) {
3032
+ const list = Array.isArray(rows) ? rows : [];
3033
+ if (projection === "full") return list;
3034
+ return list.map((row) => projectRow(row, projection, endpoint));
3035
+ }
3036
+
3037
+ function projectRow(row, projection, endpoint) {
3038
+ const quality = qualityFor(row, endpoint);
3039
+ if (projection === "emails") {
3040
+ return cleanObject({
3041
+ email: row.email,
3042
+ found: row.found,
3043
+ first_name: row.first_name,
3044
+ last_name: row.last_name,
3045
+ name: row.name,
3046
+ company_url: row.company_url || row.batch_company_url,
3047
+ company_name: row.company_name,
3048
+ quality,
3049
+ });
3050
+ }
3051
+ if (projection === "phones") {
3052
+ return cleanObject({
3053
+ phone: row.phone,
3054
+ company_phone: row.company_phone,
3055
+ found: row.found,
3056
+ first_name: row.first_name,
3057
+ last_name: row.last_name,
3058
+ name: row.name,
3059
+ company_url: row.company_url || row.batch_company_url,
3060
+ company_name: row.company_name,
3061
+ quality,
3062
+ });
3063
+ }
3064
+ if (projection === "ids") {
3065
+ return cleanObject({
3066
+ email: row.email,
3067
+ phone: row.phone,
3068
+ employee_linkedin: row.employee_linkedin,
3069
+ linkedin_url: row.linkedin_url,
3070
+ company_url: row.company_url || row.batch_company_url,
3071
+ domain: row.domain,
3072
+ company_name: row.company_name,
3073
+ quality,
3074
+ });
3075
+ }
3076
+ if (projection === "companies") {
3077
+ return cleanObject({
3078
+ company_name: row.company_name,
3079
+ domain: row.domain || normalizeDomainKey(row.company_url || row.batch_company_url || row.url),
3080
+ url: row.url || row.company_url || row.batch_company_url,
3081
+ email_domain: row.email_domain,
3082
+ linkedin_url: row.linkedin_url,
3083
+ country_code: row.country_code,
3084
+ industry: row.industry,
3085
+ employee_count: row.employee_count,
3086
+ revenue: row.revenue,
3087
+ services: row.services,
3088
+ quality,
3089
+ });
3090
+ }
3091
+ return cleanObject({
3092
+ first_name: row.first_name,
3093
+ last_name: row.last_name,
3094
+ name: row.name,
3095
+ title: row.title,
3096
+ email: row.email,
3097
+ phone: row.phone,
3098
+ employee_linkedin: row.employee_linkedin,
3099
+ company_url: row.company_url || row.batch_company_url,
3100
+ company_name: row.company_name,
3101
+ quality,
3102
+ });
3103
+ }
3104
+
3105
+ function qualityFor(row, endpoint) {
3106
+ const found = row?.found === false
3107
+ ? false
3108
+ : Boolean(row?.email || row?.phone || row?.employee_linkedin || row?.linkedin_url || row?.domain || row?.company_url || row?.company_name);
3109
+ return cleanObject({
3110
+ match_status: found ? "matched" : "not_found",
3111
+ source_endpoint: row?.source || endpoint,
3112
+ });
3113
+ }
3114
+
3115
+ function outputFormatFor(args, fallback = "jsonl") {
3116
+ const explicit = exportFormatOption(args.export_format);
3117
+ if (explicit !== "none") return explicit;
3118
+ const ext = String(extname(args.output_file || "")).toLowerCase();
3119
+ if (ext === ".csv") return "csv";
3120
+ if (ext === ".jsonl" || ext === ".ndjson") return "jsonl";
3121
+ return fallback;
3122
+ }
3123
+
3124
+ function finalizeRowsResult(result, args, rowsKey, endpoint, exportRows = result?.[rowsKey] || []) {
3125
+ const projection = projectionMode(args);
3126
+ const inlineRows = projectRows(result?.[rowsKey] || [], projection, endpoint);
3127
+ const artifactRows = projectRows(exportRows, projection, endpoint);
3128
+ const out = { ...result };
3129
+ if (rowsKey && Array.isArray(result?.[rowsKey])) out[rowsKey] = inlineRows;
3130
+ if (args.output_file) out.output = writeRowsFile(args.output_file, artifactRows, outputFormatFor(args));
3131
+ if (args.resume_file) out.resume = { path: args.resume_file, state: result.resumeState };
3132
+ if (args.credit_ledger) out.ledger = appendCreditLedger(args, out);
3133
+ return out;
3134
+ }
3135
+
3136
+ function cacheOptions(args = {}) {
3137
+ const mode = String(args.cache_mode || "off").toLowerCase();
3138
+ const resolved = CACHE_MODES.has(mode) ? mode : "off";
3139
+ return {
3140
+ mode: resolved,
3141
+ enabled: resolved !== "off",
3142
+ canRead: resolved === "read" || resolved === "readwrite" || resolved === "cache_only",
3143
+ canWrite: resolved === "write" || resolved === "readwrite" || resolved === "refresh",
3144
+ refresh: resolved === "refresh",
3145
+ cacheOnly: resolved === "cache_only",
3146
+ dir: process.env.QUICKENRICH_MCP_CACHE_DIR || join(DEFAULT_DATA_DIR, "cache"),
3147
+ };
3148
+ }
3149
+
3150
+ function cacheKey(endpoint, payload) {
3151
+ return createHash("sha1")
3152
+ .update(stableStringify({ endpoint, payload }))
3153
+ .digest("hex");
3154
+ }
3155
+
3156
+ function cachePath(options, endpoint, payload) {
3157
+ return join(options.dir, endpoint, `${cacheKey(endpoint, payload)}.json`);
3158
+ }
3159
+
3160
+ function readCachedValue(options, endpoint, payload) {
3161
+ if (!options.enabled || !options.canRead || options.refresh) return undefined;
3162
+ const path = cachePath(options, endpoint, payload);
3163
+ if (!existsSync(path)) return undefined;
3164
+ try {
3165
+ return readJsonFile(path)?.value;
3166
+ } catch {
3167
+ return undefined;
3168
+ }
3169
+ }
3170
+
3171
+ function writeCachedValue(options, endpoint, payload, value) {
3172
+ if (!options.enabled || !options.canWrite) return;
3173
+ const path = cachePath(options, endpoint, payload);
3174
+ writeJsonFileAtomic(path, { endpoint, payload, value, cachedAt: new Date().toISOString() });
3175
+ }
3176
+
3177
+ function appendCreditLedger(args, result) {
3178
+ const path = args.credit_ledger_path || process.env.QUICKENRICH_MCP_LEDGER_PATH || join(DEFAULT_DATA_DIR, "credits.jsonl");
3179
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
3180
+ const entry = cleanObject({
3181
+ at: new Date().toISOString(),
3182
+ mode: result.mode,
3183
+ endpoint: result.endpoint,
3184
+ requestCount: result.requestCount,
3185
+ creditsUsed: result.creditsUsed,
3186
+ remainingCredits: result.remainingCredits,
3187
+ inputCount: result.inputCount ?? result.inputCompanyCount,
3188
+ foundCount: result.foundCount,
3189
+ leadCount: result.leadCount,
3190
+ companyCount: result.companyCount,
3191
+ cache: result.cache,
3192
+ });
3193
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
3194
+ try {
3195
+ chmodSync(path, 0o600);
3196
+ } catch {
3197
+ // Best-effort.
3198
+ }
3199
+ return { path, entries: 1 };
3200
+ }
3201
+
3202
+ function createCreditBudget(maxCredits) {
3203
+ const max = Number(maxCredits);
3204
+ const limited = Number.isFinite(max) && max >= 0;
3205
+ let used = 0;
3206
+ let reserved = 0;
3207
+ let stopped = false;
3208
+
3209
+ function asNumber(value, fallback = 0) {
3210
+ const numeric = Number(value);
3211
+ return Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : fallback;
3212
+ }
3213
+
3214
+ function normalizeEstimate(value) {
3215
+ return asNumber(value, 1);
3216
+ }
3217
+
3218
+ return {
3219
+ limited,
3220
+ canStart(estimate = 1) {
3221
+ if (!limited) return true;
3222
+ const requested = normalizeEstimate(estimate);
3223
+ if (used + reserved + requested > max) {
3224
+ stopped = true;
3225
+ return false;
3226
+ }
3227
+ return true;
3228
+ },
3229
+ tryReserve(estimate = 1) {
3230
+ if (!limited) return true;
3231
+ const requested = normalizeEstimate(estimate);
3232
+ if (used + reserved + requested > max) {
3233
+ stopped = true;
3234
+ return false;
3235
+ }
3236
+ reserved += requested;
3237
+ return true;
3238
+ },
3239
+ commit(consumed = 0, estimate = 0) {
3240
+ if (!limited) return;
3241
+ const committed = normalizeEstimate(estimate);
3242
+ const spend = asNumber(consumed, 0);
3243
+ reserved = Math.max(0, reserved - committed);
3244
+ if (spend > 0) {
3245
+ used = Math.min(max, used + spend);
3246
+ }
3247
+ if (used >= max) stopped = true;
3248
+ },
3249
+ release(estimate = 0) {
3250
+ if (!limited) return;
3251
+ const released = asNumber(estimate, 0);
3252
+ reserved = Math.max(0, reserved - released);
3253
+ },
3254
+ record(credits) {
3255
+ const value = Number(credits || 0);
3256
+ if (Number.isFinite(value) && value > 0) {
3257
+ used = Math.min(max, used + value);
3258
+ }
3259
+ if (limited && used >= max) stopped = true;
3260
+ },
3261
+ remaining() {
3262
+ if (!limited) return Number.POSITIVE_INFINITY;
3263
+ return Math.max(0, max - used - reserved);
3264
+ },
3265
+ summary() {
3266
+ return cleanObject({
3267
+ maxCredits: limited ? max : undefined,
3268
+ used,
3269
+ reserved,
3270
+ remaining: limited ? Math.max(0, max - used - reserved) : undefined,
3271
+ stopped: stopped || undefined,
3272
+ });
3273
+ },
3274
+ };
3275
+ }
3276
+
3277
+ function numericMeta(value, fallback) {
3278
+ const number = Number(value);
3279
+ if (Number.isFinite(number)) return number;
3280
+ return fallback;
3281
+ }
3282
+
3283
+ function minDefined(values) {
3284
+ const nums = values.filter((value) => Number.isFinite(Number(value))).map(Number);
3285
+ return nums.length ? Math.min(...nums) : undefined;
3286
+ }
3287
+
3288
+ export function positiveInteger(value, fallback) {
3289
+ const number = Number(value);
3290
+ const fallbackNumber = Number(fallback);
3291
+ const safeFallback = Number.isFinite(fallbackNumber) && fallbackNumber > 0 ? Math.floor(fallbackNumber) : 1;
3292
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : safeFallback;
3293
+ }
3294
+
3295
+ function clampInt(value, min, max, fallback) {
3296
+ const number = Number(value);
3297
+ if (!Number.isFinite(number)) return fallback;
3298
+ return Math.max(min, Math.min(max, Math.floor(number)));
3299
+ }
3300
+
3301
+ function sleep(ms) {
3302
+ return new Promise((resolve) => setTimeout(resolve, ms));
3303
+ }