@coldiq/mcp 0.3.12 → 0.3.13

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 (115) hide show
  1. package/dist/tools/enrich-company.d.ts +3 -9
  2. package/dist/tools/enrich-company.d.ts.map +1 -1
  3. package/dist/tools/enrich-company.js +3 -16
  4. package/dist/tools/enrich-company.js.map +1 -1
  5. package/dist/tools/enrich-person.d.ts +3 -9
  6. package/dist/tools/enrich-person.d.ts.map +1 -1
  7. package/dist/tools/enrich-person.js +3 -12
  8. package/dist/tools/enrich-person.js.map +1 -1
  9. package/dist/tools/fetch-page-content.d.ts +3 -9
  10. package/dist/tools/fetch-page-content.d.ts.map +1 -1
  11. package/dist/tools/fetch-page-content.js +3 -12
  12. package/dist/tools/fetch-page-content.js.map +1 -1
  13. package/dist/tools/find-email.d.ts +3 -9
  14. package/dist/tools/find-email.d.ts.map +1 -1
  15. package/dist/tools/find-email.js +3 -40
  16. package/dist/tools/find-email.js.map +1 -1
  17. package/dist/tools/find-emails.d.ts +3 -4
  18. package/dist/tools/find-emails.d.ts.map +1 -1
  19. package/dist/tools/find-emails.js +8 -423
  20. package/dist/tools/find-emails.js.map +1 -1
  21. package/dist/tools/find-influencers.d.ts +3 -9
  22. package/dist/tools/find-influencers.d.ts.map +1 -1
  23. package/dist/tools/find-influencers.js +3 -12
  24. package/dist/tools/find-influencers.js.map +1 -1
  25. package/dist/tools/find-people.d.ts +3 -8
  26. package/dist/tools/find-people.d.ts.map +1 -1
  27. package/dist/tools/find-people.js +3 -98
  28. package/dist/tools/find-people.js.map +1 -1
  29. package/dist/tools/find-phone.d.ts +3 -9
  30. package/dist/tools/find-phone.d.ts.map +1 -1
  31. package/dist/tools/find-phone.js +4 -41
  32. package/dist/tools/find-phone.js.map +1 -1
  33. package/dist/tools/find-signals.d.ts +3 -9
  34. package/dist/tools/find-signals.d.ts.map +1 -1
  35. package/dist/tools/find-signals.js +3 -83
  36. package/dist/tools/find-signals.js.map +1 -1
  37. package/dist/tools/get-place-reviews.d.ts +3 -9
  38. package/dist/tools/get-place-reviews.d.ts.map +1 -1
  39. package/dist/tools/get-place-reviews.js +3 -12
  40. package/dist/tools/get-place-reviews.js.map +1 -1
  41. package/dist/tools/search-ads.d.ts +3 -9
  42. package/dist/tools/search-ads.d.ts.map +1 -1
  43. package/dist/tools/search-ads.js +3 -12
  44. package/dist/tools/search-ads.js.map +1 -1
  45. package/dist/tools/search-companies.d.ts +3 -9
  46. package/dist/tools/search-companies.d.ts.map +1 -1
  47. package/dist/tools/search-companies.js +3 -24
  48. package/dist/tools/search-companies.js.map +1 -1
  49. package/dist/tools/search-jobs.d.ts +3 -9
  50. package/dist/tools/search-jobs.d.ts.map +1 -1
  51. package/dist/tools/search-jobs.js +3 -27
  52. package/dist/tools/search-jobs.js.map +1 -1
  53. package/dist/tools/search-places.d.ts +3 -15
  54. package/dist/tools/search-places.d.ts.map +1 -1
  55. package/dist/tools/search-places.js +5 -76
  56. package/dist/tools/search-places.js.map +1 -1
  57. package/dist/tools/search-reddit.d.ts +3 -9
  58. package/dist/tools/search-reddit.d.ts.map +1 -1
  59. package/dist/tools/search-reddit.js +3 -12
  60. package/dist/tools/search-reddit.js.map +1 -1
  61. package/dist/tools/search-seo.d.ts +3 -9
  62. package/dist/tools/search-seo.d.ts.map +1 -1
  63. package/dist/tools/search-seo.js +3 -12
  64. package/dist/tools/search-seo.js.map +1 -1
  65. package/dist/tools/search-web.d.ts +3 -9
  66. package/dist/tools/search-web.d.ts.map +1 -1
  67. package/dist/tools/search-web.js +3 -12
  68. package/dist/tools/search-web.js.map +1 -1
  69. package/dist/tools/verify-email.d.ts +3 -10
  70. package/dist/tools/verify-email.d.ts.map +1 -1
  71. package/dist/tools/verify-email.js +3 -68
  72. package/dist/tools/verify-email.js.map +1 -1
  73. package/dist/verb-client.d.ts +35 -0
  74. package/dist/verb-client.d.ts.map +1 -0
  75. package/dist/verb-client.js +57 -0
  76. package/dist/verb-client.js.map +1 -0
  77. package/package.json +1 -1
  78. package/src/tools/enrich-company.ts +3 -16
  79. package/src/tools/enrich-person.ts +3 -12
  80. package/src/tools/fetch-page-content.ts +3 -12
  81. package/src/tools/find-email.ts +3 -42
  82. package/src/tools/find-emails.ts +8 -506
  83. package/src/tools/find-influencers.ts +3 -12
  84. package/src/tools/find-people.ts +3 -107
  85. package/src/tools/find-phone.ts +4 -42
  86. package/src/tools/find-signals.ts +3 -88
  87. package/src/tools/get-place-reviews.ts +3 -12
  88. package/src/tools/search-ads.ts +3 -12
  89. package/src/tools/search-companies.ts +3 -23
  90. package/src/tools/search-jobs.ts +3 -30
  91. package/src/tools/search-places.ts +5 -72
  92. package/src/tools/search-reddit.ts +3 -12
  93. package/src/tools/search-seo.ts +3 -12
  94. package/src/tools/search-web.ts +3 -12
  95. package/src/tools/verify-email.ts +3 -76
  96. package/src/verb-client.ts +93 -0
  97. package/tests/verb-client.test.ts +152 -0
  98. package/tests/tools/enrich-company.test.ts +0 -92
  99. package/tests/tools/enrich-person.test.ts +0 -140
  100. package/tests/tools/fetch-page-content.test.ts +0 -108
  101. package/tests/tools/find-email.test.ts +0 -137
  102. package/tests/tools/find-emails.test.ts +0 -818
  103. package/tests/tools/find-influencers.test.ts +0 -91
  104. package/tests/tools/find-people.test.ts +0 -621
  105. package/tests/tools/find-phone.test.ts +0 -100
  106. package/tests/tools/find-signals.test.ts +0 -175
  107. package/tests/tools/get-place-reviews.test.ts +0 -73
  108. package/tests/tools/search-ads.test.ts +0 -182
  109. package/tests/tools/search-companies.test.ts +0 -192
  110. package/tests/tools/search-jobs.test.ts +0 -210
  111. package/tests/tools/search-places.test.ts +0 -210
  112. package/tests/tools/search-reddit.test.ts +0 -194
  113. package/tests/tools/search-seo.test.ts +0 -183
  114. package/tests/tools/search-web.test.ts +0 -79
  115. package/tests/tools/verify-email.test.ts +0 -142
@@ -1,59 +1,17 @@
1
1
  import { z } from 'zod'
2
- import { callApi, type ApiResponse } from '../client.js'
3
- import { executeWithFallback, isExecutionError } from '../executor.js'
4
- import { resolvePreferredProviders, buildAllFailedError, FIND_EMAILS_PROVIDERS } from '../utils/provider-resolver.js'
5
- import { providerDisplayName } from '../utils/provider-display.js'
6
- import { buildSelectionInsight } from '../utils/selection-insight.js'
7
-
8
- // Single-find_email registry providers that the bulk pipeline does NOT already cover.
9
- // Used as a fallback waterfall for residual misses after Prospeo + FullEnrich + Findymail + Icypeas.
10
- const SINGLE_ONLY_FIND_EMAIL_PROVIDERS = ['limadata-work-email', 'blitzapi', 'limadata-work-email-linkedin', 'linkupapi'] as const
11
-
12
- // Chunk inputs of >10 people into independent waterfalls. Empirically, Prospeo bulk on 25-50
13
- // names regularly exceeds the 30s client timeout; 10 keeps each Prospeo call well under it.
14
- export const FIND_EMAILS_CHUNK_SIZE = 10
15
- // Concurrency cap keeps peak in-flight calls (per-chunk fan-out × concurrent chunks) under
16
- // the undici Agent's 32-connection budget (client.ts).
17
- const CHUNK_CONCURRENCY = 3
18
-
19
- // Pulls a string email from any of the provider-specific response shapes used in the
20
- // find_email registry (registry.ts:1228-1413). Keep in sync with the providers covered there.
21
- function extractEmail(data: unknown): string | null {
22
- if (!data || typeof data !== 'object') return null
23
- const d = data as Record<string, unknown>
24
- if (typeof d.email === 'string' && d.email.includes('@')) return d.email
25
- if (Array.isArray(d.emails) && typeof d.emails[0] === 'string' && (d.emails[0] as string).includes('@')) return d.emails[0] as string
26
- const person = d.person as Record<string, unknown> | undefined
27
- const personEmail = person?.email as Record<string, unknown> | undefined
28
- if (typeof personEmail?.email === 'string' && (personEmail.email as string).includes('@')) return personEmail.email as string
29
- const nested = d.data
30
- if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
31
- const inner = (nested as Record<string, unknown>).email
32
- if (typeof inner === 'string' && inner.includes('@')) return inner
33
- }
34
- if (Array.isArray(nested) && nested[0] && typeof nested[0] === 'object') {
35
- const first = nested[0] as Record<string, unknown>
36
- if (Array.isArray(first.emails) && typeof first.emails[0] === 'string' && (first.emails[0] as string).includes('@')) {
37
- return first.emails[0] as string
38
- }
39
- }
40
- return null
41
- }
2
+ import { callVerb } from '../verb-client.js'
3
+ import { FIND_EMAILS_PROVIDERS } from '../utils/provider-resolver.js'
42
4
 
43
5
  export const findEmailsName = 'find_emails'
44
6
 
45
7
  export const findEmailsDescription =
46
8
  'Find professional emails for multiple people in one batch. ' +
47
- 'Uses Prospeo bulk first; for any misses, runs FullEnrich and FindyMail/IcyPeas in parallel — ' +
48
- 'whichever provider returns an email first wins. ' +
49
- 'Much faster than calling find_email one-by-one. Max 50 people per call — ' +
50
- `inputs are split into chunks of ${FIND_EMAILS_CHUNK_SIZE} internally for reliability, so larger batches work transparently. ` +
9
+ 'Runs the managed email waterfall in parallel across all people — ' +
10
+ 'much faster than calling find_email one-by-one. Max 50 people per call. ' +
51
11
  'Each person needs a unique id to match results back. ' +
52
12
  'Always pass first_name, last_name, and domain — they are the primary enrichment signal. ' +
53
13
  'Also pass linkedin_url when available, but never rely on it alone as some providers cannot resolve vanity URLs. ' +
54
- 'ColdIQ automatically picks the best waterfall — pass use_providers only if you need specific tools. ' +
55
- 'Response includes `_meta.batch_status` (complete|partial|failed) and per-provider call stats so callers can ' +
56
- 'tell a real "no coverage" result apart from a provider-level failure.'
14
+ 'ColdIQ automatically picks the best waterfall — pass use_providers only if you need specific tools.'
57
15
 
58
16
  export const findEmailsSchema = {
59
17
  people: z
@@ -72,464 +30,8 @@ export const findEmailsSchema = {
72
30
  use_providers: z.array(z.string()).optional().describe(`Optional ordered list of providers to use. Leave empty to let ColdIQ automatically run the best waterfall — recommended for most use cases. Available providers: ${FIND_EMAILS_PROVIDERS.join(', ')}. Provider names are matched fuzzily, so minor typos are tolerated.`),
73
31
  }
74
32
 
75
- interface PersonInput {
76
- id: string
77
- first_name?: string
78
- last_name?: string
79
- domain?: string
80
- linkedin_url?: string
81
- }
82
-
83
- interface EmailResult {
84
- id: string
85
- email: string | null
86
- provider: string | null
87
- }
88
-
89
- interface ProviderStats {
90
- attempted: number
91
- failed: number
92
- }
93
-
94
- interface BatchStats {
95
- prospeo: ProviderStats
96
- fullenrich: ProviderStats
97
- findymail: ProviderStats
98
- icypeas: ProviderStats
99
- single_fallback: ProviderStats
100
- }
101
-
102
- function newBatchStats(): BatchStats {
103
- return {
104
- prospeo: { attempted: 0, failed: 0 },
105
- fullenrich: { attempted: 0, failed: 0 },
106
- findymail: { attempted: 0, failed: 0 },
107
- icypeas: { attempted: 0, failed: 0 },
108
- single_fallback: { attempted: 0, failed: 0 },
109
- }
110
- }
111
-
112
- function mergeStats(into: BatchStats, from: BatchStats): void {
113
- for (const key of Object.keys(into) as (keyof BatchStats)[]) {
114
- into[key].attempted += from[key].attempted
115
- into[key].failed += from[key].failed
116
- }
117
- }
118
-
119
- function totalFailures(s: BatchStats): number {
120
- let n = 0
121
- for (const key of Object.keys(s) as (keyof BatchStats)[]) n += s[key].failed
122
- return n
123
- }
124
-
125
- // A call counts as a "failure" when callApi never received a usable response: network error,
126
- // abort/timeout (status === 0), or upstream 5xx. Upstream 4xx is a data-shape problem, not a
127
- // provider outage — we don't surface it as a batch-level failure.
128
- function isCallFailure(res: ApiResponse): boolean {
129
- return !res.ok && (res.status === 0 || res.status >= 500)
130
- }
131
-
132
- function sleep(ms: number): Promise<void> {
133
- return new Promise((resolve) => setTimeout(resolve, ms))
134
- }
135
-
136
- function missesOf(people: PersonInput[], results: EmailResult[]): PersonInput[] {
137
- return people.filter((p) => !results.find((r) => r.id === p.id)?.email)
138
- }
139
-
140
- function chunkArray<T>(arr: T[], size: number): T[][] {
141
- if (arr.length <= size) return [arr]
142
- const out: T[][] = []
143
- for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
144
- return out
145
- }
146
-
147
- async function withConcurrency<T, R>(
148
- items: T[],
149
- concurrency: number,
150
- fn: (item: T, idx: number) => Promise<R>,
151
- ): Promise<R[]> {
152
- const results: R[] = new Array(items.length)
153
- let cursor = 0
154
- const workerCount = Math.min(concurrency, items.length)
155
- const workers = Array.from({ length: workerCount }, async () => {
156
- while (true) {
157
- const i = cursor++
158
- if (i >= items.length) return
159
- results[i] = await fn(items[i]!, i)
160
- }
161
- })
162
- await Promise.all(workers)
163
- return results
164
- }
165
-
166
- async function prospeoBulkStep(
167
- chunk: PersonInput[],
168
- results: EmailResult[],
169
- stats: BatchStats,
170
- ): Promise<void> {
171
- const bulkBody = {
172
- data: chunk.map((p) =>
173
- p.linkedin_url
174
- ? { identifier: p.id, linkedin_url: p.linkedin_url }
175
- : { identifier: p.id, first_name: p.first_name, last_name: p.last_name, company_name: p.domain },
176
- ),
177
- }
178
-
179
- stats.prospeo.attempted += 1
180
- const bulkRes = await callApi('POST', '/prospeo/bulk-enrich-person', bulkBody)
181
- if (isCallFailure(bulkRes)) stats.prospeo.failed += 1
182
- if (!bulkRes.ok) return
183
-
184
- const data = bulkRes.data as {
185
- results?: Array<{
186
- identifier: string
187
- person?: { email?: { email?: string } }
188
- }>
189
- }
190
- for (const item of data.results ?? []) {
191
- const email = item.person?.email?.email
192
- if (typeof email === 'string' && email.includes('@')) {
193
- const hit = results.find((r) => r.id === item.identifier)
194
- if (hit) {
195
- hit.email = email
196
- hit.provider = 'prospeo'
197
- }
198
- }
199
- }
200
- }
201
-
202
- async function fullEnrichStep(
203
- misses: PersonInput[],
204
- results: EmailResult[],
205
- stats: BatchStats,
206
- ): Promise<void> {
207
- stats.fullenrich.attempted += 1
208
-
209
- const feBody = {
210
- name: 'mcp-enrich-batch',
211
- data: misses.map((p) => ({
212
- custom_id: p.id,
213
- first_name: p.first_name,
214
- last_name: p.last_name,
215
- domain: p.domain,
216
- ...(p.linkedin_url ? { linkedin_url: p.linkedin_url } : {}),
217
- enrich_fields: ['contact.emails'],
218
- })),
219
- }
220
-
221
- const feCreateRes = await callApi('POST', '/fullenrich/contact/enrich/bulk', feBody)
222
- if (!feCreateRes.ok) {
223
- if (isCallFailure(feCreateRes)) stats.fullenrich.failed += 1
224
- return
225
- }
226
-
227
- const enrichmentId = (feCreateRes.data as Record<string, unknown>).enrichment_id as string | undefined
228
- if (!enrichmentId) {
229
- // No id returned but POST 2xx — treat as a soft skip, not a network failure.
230
- return
231
- }
232
-
233
- // Tightened from 90s → 45s: Step 3 runs in parallel and typically fills misses
234
- // faster, so FullEnrich's marginal value decays past this point.
235
- const deadline = Date.now() + 45_000
236
- // Bail out once the poll endpoint fails repeatedly (e.g. upstream 502s) instead
237
- // of hammering it for the full 45s. A poll that keeps erroring will never return
238
- // data, and this branch gates the chunk's critical path (Promise.allSettled in
239
- // processChunk), so polling to the deadline added ~30s of pure latency with zero
240
- // emails. Mirrors the executor's COLDIQ_MAX_POLL_ERRORS guard (executor.ts).
241
- const maxPollErrors = parseInt(process.env.COLDIQ_MAX_POLL_ERRORS ?? '3', 10)
242
- let consecutivePollErrors = 0
243
- while (Date.now() < deadline) {
244
- await sleep(2000)
245
- const pollRes = await callApi('GET', `/fullenrich/contact/enrich/bulk/${enrichmentId}`)
246
- if (!pollRes.ok) {
247
- consecutivePollErrors += 1
248
- if (consecutivePollErrors >= maxPollErrors) {
249
- stats.fullenrich.failed += 1
250
- return
251
- }
252
- continue
253
- }
254
- consecutivePollErrors = 0
255
-
256
- const pd = pollRes.data as Record<string, unknown>
257
- const status = pd.status as string | undefined
258
- if (status !== 'DONE' && status !== 'FAILED') continue
259
-
260
- if (status === 'DONE') {
261
- const feItems = pd.data as Array<Record<string, unknown>> | undefined
262
- if (Array.isArray(feItems)) {
263
- for (const item of feItems) {
264
- const personId = item.custom_id as string | undefined
265
- const hit = personId ? results.find((r) => r.id === personId) : undefined
266
- if (!hit || hit.email) continue
267
- const emails = item.emails as string[] | undefined
268
- if (Array.isArray(emails) && emails.length > 0 && typeof emails[0] === 'string' && emails[0].includes('@')) {
269
- hit.email = emails[0]
270
- hit.provider = 'fullenrich'
271
- }
272
- }
273
- }
274
- }
275
- return
276
- }
277
- // Polling deadline reached without DONE/FAILED — count as a failure so the caller
278
- // sees fullenrich didn't actually finish.
279
- stats.fullenrich.failed += 1
280
- }
281
-
282
- async function findymailIcypeasStep(
283
- misses: PersonInput[],
284
- results: EmailResult[],
285
- stats: BatchStats,
286
- allowedProviders?: string[],
287
- ): Promise<void> {
288
- const useFindymail = !allowedProviders || allowedProviders.includes('findymail')
289
- const useIcypeas = !allowedProviders || allowedProviders.includes('icypeas')
290
-
291
- await Promise.all(
292
- misses.map(async (person) => {
293
- const hit = results.find((r) => r.id === person.id)
294
- if (!hit) return
295
-
296
- // Skip if the parallel FullEnrich branch already filled this person.
297
- if (hit.email) return
298
-
299
- if (useFindymail) {
300
- const fullName = [person.first_name, person.last_name].filter(Boolean).join(' ')
301
-
302
- stats.findymail.attempted += 1
303
- const fmRes = await callApi('POST', '/findymail/search/name', {
304
- name: fullName,
305
- domain: person.domain,
306
- })
307
- if (isCallFailure(fmRes)) stats.findymail.failed += 1
308
- if (!hit.email && fmRes.ok) {
309
- const d = fmRes.data as Record<string, unknown>
310
- if (typeof d.email === 'string' && d.email.includes('@')) {
311
- hit.email = d.email
312
- hit.provider = 'findymail'
313
- return
314
- }
315
- }
316
- }
317
-
318
- if (hit.email || !useIcypeas) return
319
-
320
- stats.icypeas.attempted += 1
321
- const icyRes = await callApi('POST', '/icypeas/email-search', {
322
- firstname: person.first_name,
323
- lastname: person.last_name,
324
- domainOrCompany: person.domain,
325
- })
326
- if (isCallFailure(icyRes)) stats.icypeas.failed += 1
327
- if (!hit.email && icyRes.ok) {
328
- const d = icyRes.data as Record<string, unknown>
329
- const email =
330
- typeof d.email === 'string' && d.email.includes('@')
331
- ? d.email
332
- : Array.isArray(d.emails) && typeof d.emails[0] === 'string' && d.emails[0].includes('@')
333
- ? (d.emails[0] as string)
334
- : null
335
- if (email) {
336
- hit.email = email
337
- hit.provider = 'icypeas'
338
- }
339
- }
340
- }),
341
- )
342
- }
343
-
344
- async function singleFallbackStep(
345
- stragglers: PersonInput[],
346
- results: EmailResult[],
347
- stats: BatchStats,
348
- fallbackProviders: string[],
349
- ): Promise<void> {
350
- if (fallbackProviders.length === 0 || stragglers.length === 0) return
351
-
352
- await Promise.all(
353
- stragglers.map(async (person) => {
354
- const hit = results.find((r) => r.id === person.id)
355
- if (!hit || hit.email) return
356
- const singleInput: Record<string, unknown> = {
357
- first_name: person.first_name,
358
- last_name: person.last_name,
359
- domain: person.domain,
360
- linkedin_url: person.linkedin_url,
361
- }
362
- stats.single_fallback.attempted += 1
363
- // Per-person 30s ceiling so one slow provider doesn't block the rest.
364
- const exec = executeWithFallback('find_email', singleInput, { providers: fallbackProviders })
365
- const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 30_000))
366
- const raced = await Promise.race([exec, timeout])
367
- if (!raced) {
368
- // Race timed out — none of the fallback providers returned in time.
369
- stats.single_fallback.failed += 1
370
- return
371
- }
372
- if (isExecutionError(raced)) {
373
- // Every fallback provider returned an error. Only count as `failed` when at least
374
- // one of them looked like a real network/upstream failure — providers that responded
375
- // 200 with no result are legitimate "no coverage", not a batch failure.
376
- const hadRealFailure = raced.providers_tried.some((p) => p.status === 0 || p.status >= 500)
377
- if (hadRealFailure) stats.single_fallback.failed += 1
378
- return
379
- }
380
- const email = extractEmail(raced.data)
381
- if (email && !hit.email) {
382
- hit.email = email
383
- hit.provider = raced._meta.provider
384
- }
385
- }),
386
- )
387
- }
388
-
389
- async function processChunk(
390
- chunk: PersonInput[],
391
- allowedProviders: string[],
392
- isConstrained: boolean,
393
- ): Promise<{ results: EmailResult[]; stats: BatchStats }> {
394
- const results: EmailResult[] = chunk.map((p) => ({ id: p.id, email: null, provider: null }))
395
- const stats = newBatchStats()
396
-
397
- // Step 1: Prospeo bulk — 1 call for everyone in this chunk.
398
- if (!isConstrained || allowedProviders.includes('prospeo')) {
399
- await prospeoBulkStep(chunk, results, stats)
400
- }
401
-
402
- // Steps 2 & 3 run concurrently for misses.
403
- // Step 2: FullEnrich bulk async (slow tail, better European coverage)
404
- // Step 3: per-person FindyMail → IcyPeas waterfall
405
- // Both branches write to the shared `results` array; every write site checks
406
- // `if (hit.email)` first so whichever provider arrives first wins and the
407
- // other does not overwrite.
408
- const misses = missesOf(chunk, results)
409
- if (misses.length > 0) {
410
- const steps: Promise<void>[] = []
411
- if (!isConstrained || allowedProviders.includes('fullenrich')) {
412
- steps.push(fullEnrichStep(misses, results, stats))
413
- }
414
- if (!isConstrained || allowedProviders.includes('findymail') || allowedProviders.includes('icypeas')) {
415
- steps.push(findymailIcypeasStep(misses, results, stats, isConstrained ? allowedProviders : undefined))
416
- }
417
- if (steps.length > 0) await Promise.allSettled(steps)
418
- }
419
-
420
- // Step 4 — single-find_email fallback for stragglers.
421
- // Bulk covers Prospeo + FullEnrich + FindyMail + IcyPeas. The single-find_email registry
422
- // (registry.ts:1228-1413) additionally covers LimaData, BlitzAPI, LimaData-LinkedIn, LinkupAPI.
423
- // Without this step, agents would observe bulk return 0 and fall back to N+1 single calls.
424
- const stragglers = missesOf(chunk, results)
425
- if (stragglers.length > 0) {
426
- const fallbackProviders = isConstrained
427
- ? allowedProviders.filter((p) => (SINGLE_ONLY_FIND_EMAIL_PROVIDERS as readonly string[]).includes(p))
428
- : [...SINGLE_ONLY_FIND_EMAIL_PROVIDERS]
429
- await singleFallbackStep(stragglers, results, stats, fallbackProviders)
430
- }
431
-
432
- return { results, stats }
433
- }
434
-
435
- function computeBatchStatus(found: number, total: number, stats: BatchStats): 'complete' | 'partial' | 'failed' {
436
- const failures = totalFailures(stats)
437
- if (failures === 0) return 'complete'
438
- if (found === 0 && total > 0) return 'failed'
439
- return 'partial'
440
- }
441
-
442
33
  export async function findEmailsHandler(input: Record<string, unknown>) {
443
- const { use_providers: rawUseProviders, ...restInput } = input
444
- const people = restInput.people as PersonInput[]
445
-
446
- const resolved = resolvePreferredProviders('find_emails', restInput, rawUseProviders)
447
- if (!resolved.ok) {
448
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
449
- }
450
-
451
- const allowedProviders = resolved.providers
452
- const isConstrained = allowedProviders.length > 0
453
-
454
- const chunks = chunkArray(people, FIND_EMAILS_CHUNK_SIZE)
455
- const chunkOutputs = await withConcurrency(chunks, CHUNK_CONCURRENCY, (chunk) =>
456
- processChunk(chunk, allowedProviders, isConstrained),
457
- )
458
-
459
- const results: EmailResult[] = chunkOutputs.flatMap((c) => c.results)
460
- const aggregateStats = newBatchStats()
461
- for (const c of chunkOutputs) mergeStats(aggregateStats, c.stats)
462
-
463
- const found = results.filter((r) => r.email !== null).length
464
- const batchStatus = computeBatchStatus(found, people.length, aggregateStats)
465
-
466
- // Constrained-mode behaviour preserved: if the caller pinned providers and got zero, push back.
467
- if (isConstrained && found === 0) {
468
- const err = buildAllFailedError(allowedProviders, 'find_emails', FIND_EMAILS_PROVIDERS)
469
- return { content: [{ type: 'text' as const, text: JSON.stringify(err) }], isError: true }
470
- }
471
-
472
- const meta: Record<string, unknown> = {
473
- providers: aggregateStats,
474
- batch_status: batchStatus,
475
- chunk_size: FIND_EMAILS_CHUNK_SIZE,
476
- chunk_count: chunks.length,
477
- }
478
- if (resolved.matchedFrom && Object.keys(resolved.matchedFrom).length > 0) {
479
- meta.matchedFrom = resolved.matchedFrom
480
- }
481
-
482
- // Batch-level selection insight: the per-person waterfall returns a provider per
483
- // result, so attribute the batch to the provider that resolved the most emails.
484
- // Prospeo is the primary bulk step (Step 1); anything else means we relied on a
485
- // fallback step, so soften the wording.
486
- if (found > 0) {
487
- const winCounts = new Map<string, number>()
488
- for (const r of results) {
489
- if (r.email && r.provider) winCounts.set(r.provider, (winCounts.get(r.provider) ?? 0) + 1)
490
- }
491
- // Tie-break deterministically by waterfall rank: scan in FIND_EMAILS_PROVIDERS
492
- // order and replace only on a strictly greater count, so an equal-count primary
493
- // (Prospeo, Step 1) is never displaced by a later fallback step — keeps the
494
- // attribution stable regardless of people-input order.
495
- let dominant: string | undefined
496
- let best = 0
497
- for (const id of FIND_EMAILS_PROVIDERS) {
498
- const count = winCounts.get(id) ?? 0
499
- if (count > best) { best = count; dominant = id }
500
- }
501
- if (dominant) {
502
- const insight = buildSelectionInsight('find_emails', dominant, restInput, {
503
- // Steps 2-4 only run on Prospeo's misses, so a non-Prospeo winner is a
504
- // genuine fallthrough — unless the caller pinned providers (no Prospeo step),
505
- // in which case the pinnedByUser branch governs the wording anyway.
506
- wasFallback: dominant !== 'prospeo' && !isConstrained,
507
- pinnedByUser: isConstrained,
508
- })
509
- if (insight) {
510
- meta.selection_insight = insight.insight
511
- if (insight.signals.length > 0) meta.selection_signals = insight.signals
512
- }
513
- }
514
- }
515
-
516
- // Surface branded provider names per person so users never see a raw slug.
517
- const brandedResults = results.map((r) => ({
518
- ...r,
519
- provider_name: r.provider ? providerDisplayName(r.provider) : null,
520
- }))
521
-
522
- // Surface the silent-failure case the user reported: 200 OK with all-nulls is misleading
523
- // when the nulls were caused by provider failures rather than legitimate no-coverage.
524
- const isError = batchStatus === 'failed'
525
-
526
- return {
527
- content: [
528
- {
529
- type: 'text' as const,
530
- text: JSON.stringify({ data: { results: brandedResults, found, total: people.length }, _meta: meta }),
531
- },
532
- ],
533
- ...(isError ? { isError: true } : {}),
534
- }
34
+ // Bulk verb returns results positionally with no id echo — re-attach each
35
+ // person's `id` so callers can still match results back.
36
+ return callVerb('/email/find', input, { bulkField: 'people', reattachIdField: 'id' })
535
37
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { executeWithFallback, isExecutionError } from '../executor.js'
3
- import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
2
+ import { callVerb } from '../verb-client.js'
3
+ import { getProvidersForCapability } from '../utils/provider-resolver.js'
4
4
 
5
5
  export const findInfluencersName = 'find_influencers'
6
6
 
@@ -29,14 +29,5 @@ export const findInfluencersSchema = {
29
29
  }
30
30
 
31
31
  export async function findInfluencersHandler(input: Record<string, unknown>) {
32
- const { use_providers: rawUseProviders, ...restInput } = input
33
- const resolved = resolvePreferredProviders('find_influencers', restInput, rawUseProviders)
34
- if (!resolved.ok) {
35
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
36
- }
37
- const result = await executeWithFallback('find_influencers', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
38
- if (isExecutionError(result)) {
39
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
40
- }
41
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
32
+ return callVerb('/influencers/find', input)
42
33
  }
@@ -1,9 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { executeWithFallback, isExecutionError } from '../executor.js'
3
- import { callApi } from '../client.js'
4
- import { getProviders } from '../registry.js'
5
- import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
6
- import { compactPayload } from '../utils/compact-people.js'
2
+ import { callVerb } from '../verb-client.js'
3
+ import { getProvidersForCapability } from '../utils/provider-resolver.js'
7
4
 
8
5
  export const findPeopleName = 'find_people'
9
6
 
@@ -33,107 +30,6 @@ export const findPeopleSchema = {
33
30
  use_providers: z.array(z.string()).optional().describe(`Optional ordered list of providers to use. Leave empty to let ColdIQ automatically pick the best tool for your inputs — recommended for most use cases. Available providers: ${getProvidersForCapability('find_people').join(', ')}. Provider names are matched fuzzily, so minor typos are tolerated.`),
34
31
  }
35
32
 
36
- function buildSuccess(
37
- result: { data: unknown; _meta: { provider: string; latencyMs: number; matchedFrom?: Record<string, string> } },
38
- fields: 'compact' | 'verbose',
39
- ) {
40
- const payload = fields === 'compact'
41
- ? { ...result, data: compactPayload(result.data, result._meta.provider) }
42
- : result
43
- return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }] }
44
- }
45
-
46
33
  export async function findPeopleHandler(input: Record<string, unknown>) {
47
- const { use_providers: rawUseProviders, reveal: rawReveal, fields: rawFields, ...restInput } = input
48
- const reveal = rawReveal === true
49
- const fields: 'compact' | 'verbose' = rawFields === 'verbose' ? 'verbose' : 'compact'
50
- const resolved = resolvePreferredProviders('find_people', restInput, rawUseProviders)
51
- if (!resolved.ok) {
52
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
53
- }
54
- const result = await executeWithFallback('find_people', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
55
- if (isExecutionError(result)) {
56
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
57
- }
58
-
59
- // Apollo's /people/search returns obfuscated data (last names like "Ar***t",
60
- // no emails). When the caller opts in with reveal=true, follow up with
61
- // /apollo/people/bulk-match (10 IDs per call, Apollo's bulk cap) to swap each
62
- // person object for its fully-revealed counterpart. Costs 1 extra credit per
63
- // revealed person on top of the original search credit.
64
- if (reveal && result._meta.provider === 'apollo') {
65
- const data = result.data as Record<string, unknown>
66
- const people = (data.people as Array<Record<string, unknown>> | undefined) ?? []
67
- const ids = people
68
- .map((p) => p.id)
69
- .filter((id): id is string => typeof id === 'string' && id.length > 0)
70
-
71
- if (ids.length > 0) {
72
- const matches: Array<Record<string, unknown>> = []
73
- for (let i = 0; i < ids.length; i += 10) {
74
- const chunk = ids.slice(i, i + 10)
75
- const res = await callApi('POST', '/apollo/people/bulk-match', {
76
- details: chunk.map((id) => ({ id })),
77
- reveal_personal_emails: true,
78
- })
79
- if (res.ok && res.data && typeof res.data === 'object') {
80
- const body = res.data as Record<string, unknown>
81
- const chunkMatches = (body.matches as Array<Record<string, unknown>> | undefined) ?? []
82
- matches.push(...chunkMatches)
83
- }
84
- }
85
-
86
- if (matches.length > 0) {
87
- const matchById = new Map<string, Record<string, unknown>>()
88
- for (const m of matches) {
89
- const id = m.id
90
- if (typeof id === 'string') matchById.set(id, m)
91
- }
92
- const revealedPeople = people.map((p) => {
93
- const id = p.id
94
- if (typeof id === 'string' && matchById.has(id)) return matchById.get(id)!
95
- return p
96
- })
97
- // Only claim `revealed: true` when bulk-match returned a result for every ID.
98
- // Partial reveals (some entries remain obfuscated) would otherwise mislead
99
- // callers into trusting `last_name` / `email` that aren't actually filled.
100
- const fullyRevealed = matchById.size >= ids.length
101
- const merged = { ...data, people: revealedPeople, revealed: fullyRevealed }
102
- return buildSuccess({ ...result, data: merged }, fields)
103
- }
104
- }
105
- }
106
-
107
- // Gap-fill: if LeadsFactory missed some domains, try Apollo for those.
108
- // Only runs when domains were the input — if we sent LinkedIn URLs, no_results_domains
109
- // is unreliable (LF may resolve arbitrary domains from URLs, e.g. linkedin.com itself).
110
- const inputHadDomains = Array.isArray(restInput.company_domains) && (restInput.company_domains as unknown[]).length > 0
111
- const inputHadLinkedInUrls = Array.isArray(restInput.company_linkedin_urls) && (restInput.company_linkedin_urls as unknown[]).length > 0
112
- if (resolved.providers.length === 0 && result._meta.provider === 'leadsfactory' && inputHadDomains && !inputHadLinkedInUrls) {
113
- const data = result.data as Record<string, unknown>
114
- const missedDomains = (data.no_results_domains as string[] | undefined) ?? []
115
-
116
- if (missedDomains.length > 0) {
117
- const apollo = getProviders('find_people').find((p) => p.id === 'apollo')
118
- if (apollo) {
119
- const gapInput = { ...restInput, company_domains: missedDomains, company_linkedin_urls: undefined }
120
- const payload = apollo.mapParams(gapInput)
121
- const apolloRes = await callApi(apollo.method, apollo.endpoint, payload.body, payload.queryParams)
122
-
123
- if (apolloRes.ok && apollo.hasResult(apolloRes.data)) {
124
- const merged = {
125
- ...data,
126
- gap_fill: {
127
- provider: 'apollo',
128
- domains: missedDomains,
129
- ...(apolloRes.data as Record<string, unknown>),
130
- },
131
- }
132
- return buildSuccess({ ...result, data: merged }, fields)
133
- }
134
- }
135
- }
136
- }
137
-
138
- return buildSuccess(result, fields)
34
+ return callVerb('/people/search', input)
139
35
  }