@coldiq/mcp 0.1.19 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +7 -1
- package/dist/client.js.map +1 -1
- package/dist/executor.d.ts +11 -0
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +72 -11
- package/dist/executor.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +35 -9
- package/dist/registry.js.map +1 -1
- package/dist/tools/find-emails.d.ts +2 -7
- package/dist/tools/find-emails.d.ts.map +1 -1
- package/dist/tools/find-emails.js +193 -67
- package/dist/tools/find-emails.js.map +1 -1
- package/dist/tools/find-people.d.ts +3 -2
- package/dist/tools/find-people.d.ts.map +1 -1
- package/dist/tools/find-people.js +65 -7
- package/dist/tools/find-people.js.map +1 -1
- package/dist/tools/get-credit-balance.d.ts +17 -0
- package/dist/tools/get-credit-balance.d.ts.map +1 -0
- package/dist/tools/get-credit-balance.js +20 -0
- package/dist/tools/get-credit-balance.js.map +1 -0
- package/dist/utils/compact-people.d.ts +24 -0
- package/dist/utils/compact-people.d.ts.map +1 -0
- package/dist/utils/compact-people.js +311 -0
- package/dist/utils/compact-people.js.map +1 -0
- package/dist/utils/provider-resolver.d.ts.map +1 -1
- package/dist/utils/provider-resolver.js +12 -1
- package/dist/utils/provider-resolver.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +9 -1
- package/src/executor.ts +89 -17
- package/src/index.ts +8 -0
- package/src/registry.ts +41 -9
- package/src/tools/find-emails.ts +251 -80
- package/src/tools/find-people.ts +70 -7
- package/src/tools/get-credit-balance.ts +24 -0
- package/src/utils/compact-people.ts +323 -0
- package/src/utils/provider-resolver.ts +12 -1
- package/tests/executor.test.ts +165 -0
- package/tests/registry-find-people.test.ts +39 -7
- package/tests/registry-search-companies.test.ts +46 -7
- package/tests/tools/find-emails.test.ts +267 -1
- package/tests/tools/find-people.test.ts +269 -5
- package/tests/tools/get-credit-balance.test.ts +56 -0
- package/tests/utils/compact-people.test.ts +487 -0
package/src/tools/find-emails.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import { callApi } from '../client.js'
|
|
2
|
+
import { callApi, type ApiResponse } from '../client.js'
|
|
3
3
|
import { executeWithFallback, isExecutionError } from '../executor.js'
|
|
4
4
|
import { resolvePreferredProviders, buildAllFailedError, FIND_EMAILS_PROVIDERS } from '../utils/provider-resolver.js'
|
|
5
5
|
|
|
@@ -7,6 +7,13 @@ import { resolvePreferredProviders, buildAllFailedError, FIND_EMAILS_PROVIDERS }
|
|
|
7
7
|
// Used as a fallback waterfall for residual misses after Prospeo + FullEnrich + Findymail + Icypeas.
|
|
8
8
|
const SINGLE_ONLY_FIND_EMAIL_PROVIDERS = ['limadata-work-email', 'blitzapi', 'limadata-work-email-linkedin', 'linkupapi'] as const
|
|
9
9
|
|
|
10
|
+
// Chunk inputs of >10 people into independent waterfalls. Empirically, Prospeo bulk on 25-50
|
|
11
|
+
// names regularly exceeds the 30s client timeout; 10 keeps each Prospeo call well under it.
|
|
12
|
+
export const FIND_EMAILS_CHUNK_SIZE = 10
|
|
13
|
+
// Concurrency cap keeps peak in-flight calls (per-chunk fan-out × concurrent chunks) under
|
|
14
|
+
// the undici Agent's 32-connection budget (client.ts).
|
|
15
|
+
const CHUNK_CONCURRENCY = 3
|
|
16
|
+
|
|
10
17
|
// Pulls a string email from any of the provider-specific response shapes used in the
|
|
11
18
|
// find_email registry (registry.ts:1228-1413). Keep in sync with the providers covered there.
|
|
12
19
|
function extractEmail(data: unknown): string | null {
|
|
@@ -37,11 +44,14 @@ export const findEmailsDescription =
|
|
|
37
44
|
'Find professional emails for multiple people in one batch. ' +
|
|
38
45
|
'Uses Prospeo bulk first; for any misses, runs FullEnrich and FindyMail/IcyPeas in parallel — ' +
|
|
39
46
|
'whichever provider returns an email first wins. ' +
|
|
40
|
-
'Much faster than calling find_email one-by-one. Max 50 people per call
|
|
47
|
+
'Much faster than calling find_email one-by-one. Max 50 people per call — ' +
|
|
48
|
+
`inputs are split into chunks of ${FIND_EMAILS_CHUNK_SIZE} internally for reliability, so larger batches work transparently. ` +
|
|
41
49
|
'Each person needs a unique id to match results back. ' +
|
|
42
50
|
'Always pass first_name, last_name, and domain — they are the primary enrichment signal. ' +
|
|
43
51
|
'Also pass linkedin_url when available, but never rely on it alone as some providers cannot resolve vanity URLs. ' +
|
|
44
|
-
'ColdIQ automatically picks the best waterfall — pass use_providers only if you need specific tools.'
|
|
52
|
+
'ColdIQ automatically picks the best waterfall — pass use_providers only if you need specific tools. ' +
|
|
53
|
+
'Response includes `_meta.batch_status` (complete|partial|failed) and per-provider call stats so callers can ' +
|
|
54
|
+
'tell a real "no coverage" result apart from a provider-level failure.'
|
|
45
55
|
|
|
46
56
|
export const findEmailsSchema = {
|
|
47
57
|
people: z
|
|
@@ -74,6 +84,49 @@ interface EmailResult {
|
|
|
74
84
|
provider: string | null
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
interface ProviderStats {
|
|
88
|
+
attempted: number
|
|
89
|
+
failed: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface BatchStats {
|
|
93
|
+
prospeo: ProviderStats
|
|
94
|
+
fullenrich: ProviderStats
|
|
95
|
+
findymail: ProviderStats
|
|
96
|
+
icypeas: ProviderStats
|
|
97
|
+
single_fallback: ProviderStats
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function newBatchStats(): BatchStats {
|
|
101
|
+
return {
|
|
102
|
+
prospeo: { attempted: 0, failed: 0 },
|
|
103
|
+
fullenrich: { attempted: 0, failed: 0 },
|
|
104
|
+
findymail: { attempted: 0, failed: 0 },
|
|
105
|
+
icypeas: { attempted: 0, failed: 0 },
|
|
106
|
+
single_fallback: { attempted: 0, failed: 0 },
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function mergeStats(into: BatchStats, from: BatchStats): void {
|
|
111
|
+
for (const key of Object.keys(into) as (keyof BatchStats)[]) {
|
|
112
|
+
into[key].attempted += from[key].attempted
|
|
113
|
+
into[key].failed += from[key].failed
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function totalFailures(s: BatchStats): number {
|
|
118
|
+
let n = 0
|
|
119
|
+
for (const key of Object.keys(s) as (keyof BatchStats)[]) n += s[key].failed
|
|
120
|
+
return n
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A call counts as a "failure" when callApi never received a usable response: network error,
|
|
124
|
+
// abort/timeout (status === 0), or upstream 5xx. Upstream 4xx is a data-shape problem, not a
|
|
125
|
+
// provider outage — we don't surface it as a batch-level failure.
|
|
126
|
+
function isCallFailure(res: ApiResponse): boolean {
|
|
127
|
+
return !res.ok && (res.status === 0 || res.status >= 500)
|
|
128
|
+
}
|
|
129
|
+
|
|
77
130
|
function sleep(ms: number): Promise<void> {
|
|
78
131
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
79
132
|
}
|
|
@@ -82,7 +135,75 @@ function missesOf(people: PersonInput[], results: EmailResult[]): PersonInput[]
|
|
|
82
135
|
return people.filter((p) => !results.find((r) => r.id === p.id)?.email)
|
|
83
136
|
}
|
|
84
137
|
|
|
85
|
-
|
|
138
|
+
function chunkArray<T>(arr: T[], size: number): T[][] {
|
|
139
|
+
if (arr.length <= size) return [arr]
|
|
140
|
+
const out: T[][] = []
|
|
141
|
+
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function withConcurrency<T, R>(
|
|
146
|
+
items: T[],
|
|
147
|
+
concurrency: number,
|
|
148
|
+
fn: (item: T, idx: number) => Promise<R>,
|
|
149
|
+
): Promise<R[]> {
|
|
150
|
+
const results: R[] = new Array(items.length)
|
|
151
|
+
let cursor = 0
|
|
152
|
+
const workerCount = Math.min(concurrency, items.length)
|
|
153
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
154
|
+
while (true) {
|
|
155
|
+
const i = cursor++
|
|
156
|
+
if (i >= items.length) return
|
|
157
|
+
results[i] = await fn(items[i]!, i)
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
await Promise.all(workers)
|
|
161
|
+
return results
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function prospeoBulkStep(
|
|
165
|
+
chunk: PersonInput[],
|
|
166
|
+
results: EmailResult[],
|
|
167
|
+
stats: BatchStats,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
const bulkBody = {
|
|
170
|
+
data: chunk.map((p) =>
|
|
171
|
+
p.linkedin_url
|
|
172
|
+
? { identifier: p.id, linkedin_url: p.linkedin_url }
|
|
173
|
+
: { identifier: p.id, first_name: p.first_name, last_name: p.last_name, company_name: p.domain },
|
|
174
|
+
),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
stats.prospeo.attempted += 1
|
|
178
|
+
const bulkRes = await callApi('POST', '/prospeo/bulk-enrich-person', bulkBody)
|
|
179
|
+
if (isCallFailure(bulkRes)) stats.prospeo.failed += 1
|
|
180
|
+
if (!bulkRes.ok) return
|
|
181
|
+
|
|
182
|
+
const data = bulkRes.data as {
|
|
183
|
+
results?: Array<{
|
|
184
|
+
identifier: string
|
|
185
|
+
person?: { email?: { email?: string } }
|
|
186
|
+
}>
|
|
187
|
+
}
|
|
188
|
+
for (const item of data.results ?? []) {
|
|
189
|
+
const email = item.person?.email?.email
|
|
190
|
+
if (typeof email === 'string' && email.includes('@')) {
|
|
191
|
+
const hit = results.find((r) => r.id === item.identifier)
|
|
192
|
+
if (hit) {
|
|
193
|
+
hit.email = email
|
|
194
|
+
hit.provider = 'prospeo'
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function fullEnrichStep(
|
|
201
|
+
misses: PersonInput[],
|
|
202
|
+
results: EmailResult[],
|
|
203
|
+
stats: BatchStats,
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
stats.fullenrich.attempted += 1
|
|
206
|
+
|
|
86
207
|
const feBody = {
|
|
87
208
|
name: 'mcp-enrich-batch',
|
|
88
209
|
data: misses.map((p) => ({
|
|
@@ -96,10 +217,16 @@ async function fullEnrichStep(misses: PersonInput[], results: EmailResult[]): Pr
|
|
|
96
217
|
}
|
|
97
218
|
|
|
98
219
|
const feCreateRes = await callApi('POST', '/fullenrich/contact/enrich/bulk', feBody)
|
|
99
|
-
if (!feCreateRes.ok)
|
|
220
|
+
if (!feCreateRes.ok) {
|
|
221
|
+
if (isCallFailure(feCreateRes)) stats.fullenrich.failed += 1
|
|
222
|
+
return
|
|
223
|
+
}
|
|
100
224
|
|
|
101
225
|
const enrichmentId = (feCreateRes.data as Record<string, unknown>).enrichment_id as string | undefined
|
|
102
|
-
if (!enrichmentId)
|
|
226
|
+
if (!enrichmentId) {
|
|
227
|
+
// No id returned but POST 2xx — treat as a soft skip, not a network failure.
|
|
228
|
+
return
|
|
229
|
+
}
|
|
103
230
|
|
|
104
231
|
// Tightened from 90s → 45s: Step 3 runs in parallel and typically fills misses
|
|
105
232
|
// faster, so FullEnrich's marginal value decays past this point.
|
|
@@ -130,9 +257,17 @@ async function fullEnrichStep(misses: PersonInput[], results: EmailResult[]): Pr
|
|
|
130
257
|
}
|
|
131
258
|
return
|
|
132
259
|
}
|
|
260
|
+
// Polling deadline reached without DONE/FAILED — count as a failure so the caller
|
|
261
|
+
// sees fullenrich didn't actually finish.
|
|
262
|
+
stats.fullenrich.failed += 1
|
|
133
263
|
}
|
|
134
264
|
|
|
135
|
-
async function findymailIcypeasStep(
|
|
265
|
+
async function findymailIcypeasStep(
|
|
266
|
+
misses: PersonInput[],
|
|
267
|
+
results: EmailResult[],
|
|
268
|
+
stats: BatchStats,
|
|
269
|
+
allowedProviders?: string[],
|
|
270
|
+
): Promise<void> {
|
|
136
271
|
const useFindymail = !allowedProviders || allowedProviders.includes('findymail')
|
|
137
272
|
const useIcypeas = !allowedProviders || allowedProviders.includes('icypeas')
|
|
138
273
|
|
|
@@ -147,10 +282,12 @@ async function findymailIcypeasStep(misses: PersonInput[], results: EmailResult[
|
|
|
147
282
|
if (useFindymail) {
|
|
148
283
|
const fullName = [person.first_name, person.last_name].filter(Boolean).join(' ')
|
|
149
284
|
|
|
285
|
+
stats.findymail.attempted += 1
|
|
150
286
|
const fmRes = await callApi('POST', '/findymail/search/name', {
|
|
151
287
|
name: fullName,
|
|
152
288
|
domain: person.domain,
|
|
153
289
|
})
|
|
290
|
+
if (isCallFailure(fmRes)) stats.findymail.failed += 1
|
|
154
291
|
if (!hit.email && fmRes.ok) {
|
|
155
292
|
const d = fmRes.data as Record<string, unknown>
|
|
156
293
|
if (typeof d.email === 'string' && d.email.includes('@')) {
|
|
@@ -163,11 +300,13 @@ async function findymailIcypeasStep(misses: PersonInput[], results: EmailResult[
|
|
|
163
300
|
|
|
164
301
|
if (hit.email || !useIcypeas) return
|
|
165
302
|
|
|
303
|
+
stats.icypeas.attempted += 1
|
|
166
304
|
const icyRes = await callApi('POST', '/icypeas/email-search', {
|
|
167
305
|
firstname: person.first_name,
|
|
168
306
|
lastname: person.last_name,
|
|
169
307
|
domainOrCompany: person.domain,
|
|
170
308
|
})
|
|
309
|
+
if (isCallFailure(icyRes)) stats.icypeas.failed += 1
|
|
171
310
|
if (!hit.email && icyRes.ok) {
|
|
172
311
|
const d = icyRes.data as Record<string, unknown>
|
|
173
312
|
const email =
|
|
@@ -185,50 +324,62 @@ async function findymailIcypeasStep(misses: PersonInput[], results: EmailResult[
|
|
|
185
324
|
)
|
|
186
325
|
}
|
|
187
326
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
327
|
+
async function singleFallbackStep(
|
|
328
|
+
stragglers: PersonInput[],
|
|
329
|
+
results: EmailResult[],
|
|
330
|
+
stats: BatchStats,
|
|
331
|
+
fallbackProviders: string[],
|
|
332
|
+
): Promise<void> {
|
|
333
|
+
if (fallbackProviders.length === 0 || stragglers.length === 0) return
|
|
191
334
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
335
|
+
await Promise.all(
|
|
336
|
+
stragglers.map(async (person) => {
|
|
337
|
+
const hit = results.find((r) => r.id === person.id)
|
|
338
|
+
if (!hit || hit.email) return
|
|
339
|
+
const singleInput: Record<string, unknown> = {
|
|
340
|
+
first_name: person.first_name,
|
|
341
|
+
last_name: person.last_name,
|
|
342
|
+
domain: person.domain,
|
|
343
|
+
linkedin_url: person.linkedin_url,
|
|
344
|
+
}
|
|
345
|
+
stats.single_fallback.attempted += 1
|
|
346
|
+
// Per-person 30s ceiling so one slow provider doesn't block the rest.
|
|
347
|
+
const exec = executeWithFallback('find_email', singleInput, { providers: fallbackProviders })
|
|
348
|
+
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 30_000))
|
|
349
|
+
const raced = await Promise.race([exec, timeout])
|
|
350
|
+
if (!raced) {
|
|
351
|
+
// Race timed out — none of the fallback providers returned in time.
|
|
352
|
+
stats.single_fallback.failed += 1
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
if (isExecutionError(raced)) {
|
|
356
|
+
// Every fallback provider returned an error. Only count as `failed` when at least
|
|
357
|
+
// one of them looked like a real network/upstream failure — providers that responded
|
|
358
|
+
// 200 with no result are legitimate "no coverage", not a batch failure.
|
|
359
|
+
const hadRealFailure = raced.providers_tried.some((p) => p.status === 0 || p.status >= 500)
|
|
360
|
+
if (hadRealFailure) stats.single_fallback.failed += 1
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
const email = extractEmail(raced.data)
|
|
364
|
+
if (email && !hit.email) {
|
|
365
|
+
hit.email = email
|
|
366
|
+
hit.provider = raced._meta.provider
|
|
367
|
+
}
|
|
368
|
+
}),
|
|
369
|
+
)
|
|
370
|
+
}
|
|
199
371
|
|
|
200
|
-
|
|
372
|
+
async function processChunk(
|
|
373
|
+
chunk: PersonInput[],
|
|
374
|
+
allowedProviders: string[],
|
|
375
|
+
isConstrained: boolean,
|
|
376
|
+
): Promise<{ results: EmailResult[]; stats: BatchStats }> {
|
|
377
|
+
const results: EmailResult[] = chunk.map((p) => ({ id: p.id, email: null, provider: null }))
|
|
378
|
+
const stats = newBatchStats()
|
|
201
379
|
|
|
202
|
-
// Step 1: Prospeo bulk — 1 call for
|
|
380
|
+
// Step 1: Prospeo bulk — 1 call for everyone in this chunk.
|
|
203
381
|
if (!isConstrained || allowedProviders.includes('prospeo')) {
|
|
204
|
-
|
|
205
|
-
data: people.map((p) =>
|
|
206
|
-
p.linkedin_url
|
|
207
|
-
? { identifier: p.id, linkedin_url: p.linkedin_url }
|
|
208
|
-
: { identifier: p.id, first_name: p.first_name, last_name: p.last_name, company_name: p.domain },
|
|
209
|
-
),
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const bulkRes = await callApi('POST', '/prospeo/bulk-enrich-person', bulkBody)
|
|
213
|
-
|
|
214
|
-
if (bulkRes.ok) {
|
|
215
|
-
const data = bulkRes.data as {
|
|
216
|
-
results?: Array<{
|
|
217
|
-
identifier: string
|
|
218
|
-
person?: { email?: { email?: string } }
|
|
219
|
-
}>
|
|
220
|
-
}
|
|
221
|
-
for (const item of data.results ?? []) {
|
|
222
|
-
const email = item.person?.email?.email
|
|
223
|
-
if (typeof email === 'string' && email.includes('@')) {
|
|
224
|
-
const hit = results.find((r) => r.id === item.identifier)
|
|
225
|
-
if (hit) {
|
|
226
|
-
hit.email = email
|
|
227
|
-
hit.provider = 'prospeo'
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
382
|
+
await prospeoBulkStep(chunk, results, stats)
|
|
232
383
|
}
|
|
233
384
|
|
|
234
385
|
// Steps 2 & 3 run concurrently for misses.
|
|
@@ -237,15 +388,14 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
|
|
|
237
388
|
// Both branches write to the shared `results` array; every write site checks
|
|
238
389
|
// `if (hit.email)` first so whichever provider arrives first wins and the
|
|
239
390
|
// other does not overwrite.
|
|
240
|
-
const misses = missesOf(
|
|
241
|
-
|
|
391
|
+
const misses = missesOf(chunk, results)
|
|
242
392
|
if (misses.length > 0) {
|
|
243
393
|
const steps: Promise<void>[] = []
|
|
244
394
|
if (!isConstrained || allowedProviders.includes('fullenrich')) {
|
|
245
|
-
steps.push(fullEnrichStep(misses, results))
|
|
395
|
+
steps.push(fullEnrichStep(misses, results, stats))
|
|
246
396
|
}
|
|
247
397
|
if (!isConstrained || allowedProviders.includes('findymail') || allowedProviders.includes('icypeas')) {
|
|
248
|
-
steps.push(findymailIcypeasStep(misses, results, isConstrained ? allowedProviders : undefined))
|
|
398
|
+
steps.push(findymailIcypeasStep(misses, results, stats, isConstrained ? allowedProviders : undefined))
|
|
249
399
|
}
|
|
250
400
|
if (steps.length > 0) await Promise.allSettled(steps)
|
|
251
401
|
}
|
|
@@ -254,54 +404,75 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
|
|
|
254
404
|
// Bulk covers Prospeo + FullEnrich + FindyMail + IcyPeas. The single-find_email registry
|
|
255
405
|
// (registry.ts:1228-1413) additionally covers LimaData, BlitzAPI, LimaData-LinkedIn, LinkupAPI.
|
|
256
406
|
// Without this step, agents would observe bulk return 0 and fall back to N+1 single calls.
|
|
257
|
-
const stragglers = missesOf(
|
|
407
|
+
const stragglers = missesOf(chunk, results)
|
|
258
408
|
if (stragglers.length > 0) {
|
|
259
409
|
const fallbackProviders = isConstrained
|
|
260
410
|
? allowedProviders.filter((p) => (SINGLE_ONLY_FIND_EMAIL_PROVIDERS as readonly string[]).includes(p))
|
|
261
411
|
: [...SINGLE_ONLY_FIND_EMAIL_PROVIDERS]
|
|
262
|
-
|
|
263
|
-
await Promise.all(
|
|
264
|
-
stragglers.map(async (person) => {
|
|
265
|
-
const hit = results.find((r) => r.id === person.id)
|
|
266
|
-
if (!hit || hit.email) return
|
|
267
|
-
const singleInput: Record<string, unknown> = {
|
|
268
|
-
first_name: person.first_name,
|
|
269
|
-
last_name: person.last_name,
|
|
270
|
-
domain: person.domain,
|
|
271
|
-
linkedin_url: person.linkedin_url,
|
|
272
|
-
}
|
|
273
|
-
// Per-person 30s ceiling so one slow provider doesn't block the rest.
|
|
274
|
-
const exec = executeWithFallback('find_email', singleInput, { providers: fallbackProviders })
|
|
275
|
-
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 30_000))
|
|
276
|
-
const raced = await Promise.race([exec, timeout])
|
|
277
|
-
if (!raced || isExecutionError(raced)) return
|
|
278
|
-
const email = extractEmail(raced.data)
|
|
279
|
-
if (email && !hit.email) {
|
|
280
|
-
hit.email = email
|
|
281
|
-
hit.provider = raced._meta.provider
|
|
282
|
-
}
|
|
283
|
-
}),
|
|
284
|
-
)
|
|
285
|
-
}
|
|
412
|
+
await singleFallbackStep(stragglers, results, stats, fallbackProviders)
|
|
286
413
|
}
|
|
287
414
|
|
|
415
|
+
return { results, stats }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function computeBatchStatus(found: number, total: number, stats: BatchStats): 'complete' | 'partial' | 'failed' {
|
|
419
|
+
const failures = totalFailures(stats)
|
|
420
|
+
if (failures === 0) return 'complete'
|
|
421
|
+
if (found === 0 && total > 0) return 'failed'
|
|
422
|
+
return 'partial'
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export async function findEmailsHandler(input: Record<string, unknown>) {
|
|
426
|
+
const { use_providers: rawUseProviders, ...restInput } = input
|
|
427
|
+
const people = restInput.people as PersonInput[]
|
|
428
|
+
|
|
429
|
+
const resolved = resolvePreferredProviders('find_emails', restInput, rawUseProviders)
|
|
430
|
+
if (!resolved.ok) {
|
|
431
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const allowedProviders = resolved.providers
|
|
435
|
+
const isConstrained = allowedProviders.length > 0
|
|
436
|
+
|
|
437
|
+
const chunks = chunkArray(people, FIND_EMAILS_CHUNK_SIZE)
|
|
438
|
+
const chunkOutputs = await withConcurrency(chunks, CHUNK_CONCURRENCY, (chunk) =>
|
|
439
|
+
processChunk(chunk, allowedProviders, isConstrained),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
const results: EmailResult[] = chunkOutputs.flatMap((c) => c.results)
|
|
443
|
+
const aggregateStats = newBatchStats()
|
|
444
|
+
for (const c of chunkOutputs) mergeStats(aggregateStats, c.stats)
|
|
445
|
+
|
|
288
446
|
const found = results.filter((r) => r.email !== null).length
|
|
447
|
+
const batchStatus = computeBatchStatus(found, people.length, aggregateStats)
|
|
289
448
|
|
|
449
|
+
// Constrained-mode behaviour preserved: if the caller pinned providers and got zero, push back.
|
|
290
450
|
if (isConstrained && found === 0) {
|
|
291
451
|
const err = buildAllFailedError(allowedProviders, 'find_emails', FIND_EMAILS_PROVIDERS)
|
|
292
452
|
return { content: [{ type: 'text' as const, text: JSON.stringify(err) }], isError: true }
|
|
293
453
|
}
|
|
294
454
|
|
|
295
|
-
const meta
|
|
296
|
-
|
|
297
|
-
:
|
|
455
|
+
const meta: Record<string, unknown> = {
|
|
456
|
+
providers: aggregateStats,
|
|
457
|
+
batch_status: batchStatus,
|
|
458
|
+
chunk_size: FIND_EMAILS_CHUNK_SIZE,
|
|
459
|
+
chunk_count: chunks.length,
|
|
460
|
+
}
|
|
461
|
+
if (resolved.matchedFrom && Object.keys(resolved.matchedFrom).length > 0) {
|
|
462
|
+
meta.matchedFrom = resolved.matchedFrom
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Surface the silent-failure case the user reported: 200 OK with all-nulls is misleading
|
|
466
|
+
// when the nulls were caused by provider failures rather than legitimate no-coverage.
|
|
467
|
+
const isError = batchStatus === 'failed'
|
|
298
468
|
|
|
299
469
|
return {
|
|
300
470
|
content: [
|
|
301
471
|
{
|
|
302
472
|
type: 'text' as const,
|
|
303
|
-
text: JSON.stringify({ data: { results, found, total: people.length },
|
|
473
|
+
text: JSON.stringify({ data: { results, found, total: people.length }, _meta: meta }),
|
|
304
474
|
},
|
|
305
475
|
],
|
|
476
|
+
...(isError ? { isError: true } : {}),
|
|
306
477
|
}
|
|
307
478
|
}
|
package/src/tools/find-people.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { executeWithFallback, isExecutionError } from '../executor.js'
|
|
|
3
3
|
import { callApi } from '../client.js'
|
|
4
4
|
import { getProviders } from '../registry.js'
|
|
5
5
|
import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
|
|
6
|
+
import { compactPayload } from '../utils/compact-people.js'
|
|
6
7
|
|
|
7
8
|
export const findPeopleName = 'find_people'
|
|
8
9
|
|
|
@@ -15,7 +16,9 @@ export const findPeopleDescription =
|
|
|
15
16
|
'Prefer company_linkedin_urls when available — they make the search faster. Only pass company_domains when you have no LinkedIn URLs. ' +
|
|
16
17
|
'Pass job_titles EXACTLY as stated — never expand or generate variants (e.g. do NOT turn "CEO" into ["CEO", "Chief Executive Officer", "Founder"]). LeadsFactory handles fuzzy title matching internally; adding variants wastes personas and degrades results. ' +
|
|
17
18
|
'Do NOT pass seniorities unless explicitly asked — job titles alone produce better results. ' +
|
|
18
|
-
'Returns names, titles, LinkedIn URLs. Default limit: 25.'
|
|
19
|
+
'Returns names, titles, LinkedIn URLs, plus company name/domain/linkedin/headcount when available. Default limit: 25. ' +
|
|
20
|
+
'Response is compact by default (~99% smaller than the raw provider payload). Set fields="verbose" only when you specifically need employment history, skills, languages, or company descriptions. ' +
|
|
21
|
+
'When the matched provider is Apollo, last names are partially obfuscated and emails/LinkedIn URLs are omitted unless reveal=true is passed (adds 1 credit per revealed person).'
|
|
19
22
|
|
|
20
23
|
export const findPeopleSchema = {
|
|
21
24
|
company_linkedin_urls: z.array(z.string()).optional().describe('Company LinkedIn URLs (preferred over domains when available, e.g. ["https://linkedin.com/company/microsoft"])'),
|
|
@@ -24,12 +27,26 @@ export const findPeopleSchema = {
|
|
|
24
27
|
seniorities: z.array(z.string()).optional().describe('Seniority levels (e.g. ["c_suite", "vp", "director"])'),
|
|
25
28
|
locations: z.array(z.string()).optional().describe('Person locations as either ISO-2 country codes ("BR") or English country names ("Brazil") — both are accepted and normalized internally. LeadsFactory uses these to scope contacts to the country; other providers fall back to free-text matching.'),
|
|
26
29
|
keywords: z.array(z.string()).optional().describe('Free-text keyword search terms (e.g. ["growth", "AI"])'),
|
|
27
|
-
limit: z.number().min(1).max(500).default(25).describe('Max results across all companies combined (default: 25, max: 500). For multiple companies multiply: e.g. 10 companies × 5 each = 50.'),
|
|
30
|
+
limit: z.number().min(1).max(500).default(25).describe('Max results across all companies combined (default: 25, max: 500). For multiple companies multiply: e.g. 10 companies × 5 each = 50. Note: when the matched provider is Apollo, results are capped at 100 per call (Apollo upstream limit).'),
|
|
31
|
+
fields: z.enum(['compact', 'verbose']).default('compact').describe('Response shape. "compact" (default) returns a small, predictable per-person record: name, title, linkedin_url, company {name, domain, linkedin_url, headcount}, plus seniority/email/location when the upstream provider includes them. "verbose" returns the raw provider passthrough (~30KB+ per record from FullEnrich: employment history, every company office, descriptions, skills, languages) — only use when you specifically need those fields.'),
|
|
32
|
+
reveal: z.boolean().optional().describe('Apollo-only: when true, follow the obfuscated Apollo people-search results with /apollo/people/bulk-match to reveal full names, emails, and LinkedIn URLs. Costs 1 extra credit per revealed person on top of the search credit. Has no effect for other providers (their data is already unobfuscated). Default false to protect credits.'),
|
|
28
33
|
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.`),
|
|
29
34
|
}
|
|
30
35
|
|
|
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
|
+
|
|
31
46
|
export async function findPeopleHandler(input: Record<string, unknown>) {
|
|
32
|
-
const { use_providers: rawUseProviders, ...restInput } = input
|
|
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'
|
|
33
50
|
const resolved = resolvePreferredProviders('find_people', restInput, rawUseProviders)
|
|
34
51
|
if (!resolved.ok) {
|
|
35
52
|
return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
|
|
@@ -39,6 +56,54 @@ export async function findPeopleHandler(input: Record<string, unknown>) {
|
|
|
39
56
|
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
|
|
40
57
|
}
|
|
41
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
|
+
|
|
42
107
|
// Gap-fill: if LeadsFactory missed some domains, try Apollo for those.
|
|
43
108
|
// Only runs when domains were the input — if we sent LinkedIn URLs, no_results_domains
|
|
44
109
|
// is unreliable (LF may resolve arbitrary domains from URLs, e.g. linkedin.com itself).
|
|
@@ -64,13 +129,11 @@ export async function findPeopleHandler(input: Record<string, unknown>) {
|
|
|
64
129
|
...(apolloRes.data as Record<string, unknown>),
|
|
65
130
|
},
|
|
66
131
|
}
|
|
67
|
-
return {
|
|
68
|
-
content: [{ type: 'text' as const, text: JSON.stringify({ ...result, data: merged }) }],
|
|
69
|
-
}
|
|
132
|
+
return buildSuccess({ ...result, data: merged }, fields)
|
|
70
133
|
}
|
|
71
134
|
}
|
|
72
135
|
}
|
|
73
136
|
}
|
|
74
137
|
|
|
75
|
-
return
|
|
138
|
+
return buildSuccess(result, fields)
|
|
76
139
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { callApi } from '../client.js'
|
|
2
|
+
|
|
3
|
+
export const getCreditBalanceName = 'get_credit_balance'
|
|
4
|
+
|
|
5
|
+
export const getCreditBalanceDescription =
|
|
6
|
+
'Get the current ColdIQ credit balance and month-to-date usage for the authenticated account. Use before bulk or fan-out operations (e.g. find_emails over many people) to project cost and avoid mid-run insufficient-credit failures. Returns { balance, usedThisMonth }.'
|
|
7
|
+
|
|
8
|
+
export const getCreditBalanceSchema = {}
|
|
9
|
+
|
|
10
|
+
export async function getCreditBalanceHandler(_input: Record<string, unknown>) {
|
|
11
|
+
const res = await callApi('GET', '/me/credits')
|
|
12
|
+
if (!res.ok) {
|
|
13
|
+
return {
|
|
14
|
+
content: [
|
|
15
|
+
{
|
|
16
|
+
type: 'text' as const,
|
|
17
|
+
text: JSON.stringify({ error: 'Failed to fetch credit balance', status: res.status, data: res.data }),
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
isError: true,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ data: res.data }) }] }
|
|
24
|
+
}
|