@leadbay/mcp 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/MIGRATION.md +140 -0
- package/README.md +9 -1
- package/dist/bin.js +4 -4
- package/dist/{chunk-O2UOXRZO.js → chunk-MCTWP5F2.js} +2369 -1101
- package/dist/{dist-RONMQBYU.js → dist-GUZQWQOO.js} +7 -1
- package/package.json +1 -1
|
@@ -1175,6 +1175,1294 @@ var getEnrichmentJobTitles = {
|
|
|
1175
1175
|
}
|
|
1176
1176
|
};
|
|
1177
1177
|
|
|
1178
|
+
// ../core/dist/composite/_qualify-helpers.js
|
|
1179
|
+
import { createHash } from "crypto";
|
|
1180
|
+
var POLL_INTERVAL_MS = 5e3;
|
|
1181
|
+
function buildQuestionOrder(questions) {
|
|
1182
|
+
const out = /* @__PURE__ */ new Map();
|
|
1183
|
+
questions.forEach((q, i) => {
|
|
1184
|
+
if (q.question && !out.has(q.question))
|
|
1185
|
+
out.set(q.question, i);
|
|
1186
|
+
});
|
|
1187
|
+
return out;
|
|
1188
|
+
}
|
|
1189
|
+
function sortQualifications(quals, order) {
|
|
1190
|
+
if (!order || order.size === 0) {
|
|
1191
|
+
return [...quals].sort((a, b) => a.question.localeCompare(b.question));
|
|
1192
|
+
}
|
|
1193
|
+
return [...quals].sort((a, b) => {
|
|
1194
|
+
const ai = order.has(a.question) ? order.get(a.question) : Number.MAX_SAFE_INTEGER;
|
|
1195
|
+
const bi = order.has(b.question) ? order.get(b.question) : Number.MAX_SAFE_INTEGER;
|
|
1196
|
+
if (ai !== bi)
|
|
1197
|
+
return ai - bi;
|
|
1198
|
+
return a.question.localeCompare(b.question);
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
function summarizeQualifications(quals) {
|
|
1202
|
+
if (quals.length === 0)
|
|
1203
|
+
return void 0;
|
|
1204
|
+
const scored = quals.filter((q) => q.score != null);
|
|
1205
|
+
if (scored.length === 0)
|
|
1206
|
+
return void 0;
|
|
1207
|
+
const top = [...scored].sort((a, b) => Math.abs(b.score) - Math.abs(a.score)).slice(0, 2);
|
|
1208
|
+
const labelOf = (s) => s >= 20 ? "strong positive" : s >= 10 ? "positive" : s <= -10 ? "negative" : "neutral";
|
|
1209
|
+
const headlines = top.map((q) => `${labelOf(q.score)} on '${q.question}'`).join(", ");
|
|
1210
|
+
return `answered ${scored.length}/${quals.length} \u2014 ${headlines}`;
|
|
1211
|
+
}
|
|
1212
|
+
async function fanOutWebFetchAndPoll(client, leadIds, opts) {
|
|
1213
|
+
const { perLeadBudgetMs, totalDeadlineMs, signal, ctx, skipAlreadyQualifiedLensId, questionOrder } = opts;
|
|
1214
|
+
const skipAlreadyQualifiedLaunch = opts.skipAlreadyQualifiedLaunch ?? true;
|
|
1215
|
+
const launched = [];
|
|
1216
|
+
const failed = [];
|
|
1217
|
+
let quotaExceeded = false;
|
|
1218
|
+
let cancelled = false;
|
|
1219
|
+
let alreadyQualified = /* @__PURE__ */ new Set();
|
|
1220
|
+
let notInLens = /* @__PURE__ */ new Set();
|
|
1221
|
+
if (skipAlreadyQualifiedLensId !== void 0) {
|
|
1222
|
+
try {
|
|
1223
|
+
const pre = await prequalifiedLeads(client, leadIds, skipAlreadyQualifiedLensId, ctx);
|
|
1224
|
+
alreadyQualified = pre.already_qualified;
|
|
1225
|
+
notInLens = pre.not_in_lens;
|
|
1226
|
+
if (alreadyQualified.size > 0) {
|
|
1227
|
+
ctx?.logger?.info?.(`qualify: ${alreadyQualified.size}/${leadIds.length} leads already qualified \u2014 skipping web_fetch launch`);
|
|
1228
|
+
}
|
|
1229
|
+
if (notInLens.size > 0) {
|
|
1230
|
+
ctx?.logger?.info?.(`qualify: ${notInLens.size}/${leadIds.length} leads NOT in active lens \u2014 surfacing in not_in_lens (won't be qualified server-side)`);
|
|
1231
|
+
}
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
ctx?.logger?.warn?.(`qualify: prequalified preflight failed (${err?.code ?? err?.message ?? "unknown"}) \u2014 proceeding with full fan-out`);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
for (let i = 0; i < leadIds.length; i++) {
|
|
1237
|
+
if (signal?.aborted) {
|
|
1238
|
+
cancelled = true;
|
|
1239
|
+
break;
|
|
1240
|
+
}
|
|
1241
|
+
if (quotaExceeded)
|
|
1242
|
+
break;
|
|
1243
|
+
const leadId = leadIds[i];
|
|
1244
|
+
if (notInLens.has(leadId)) {
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
if (skipAlreadyQualifiedLaunch && alreadyQualified.has(leadId)) {
|
|
1248
|
+
launched.push(leadId);
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
try {
|
|
1252
|
+
await client.requestVoid("POST", `/leads/${leadId}/web_fetch?force_fetch=false`);
|
|
1253
|
+
launched.push(leadId);
|
|
1254
|
+
} catch (err) {
|
|
1255
|
+
if (err?.code === "QUOTA_EXCEEDED") {
|
|
1256
|
+
quotaExceeded = true;
|
|
1257
|
+
ctx?.logger?.warn?.(`qualify: 429 mid-fanout after launching ${launched.length}/${leadIds.length} \u2014 keeping polls in flight`);
|
|
1258
|
+
} else if (err?.code === "NOT_FOUND") {
|
|
1259
|
+
failed.push({ lead_id: leadId, error: "lead not found" });
|
|
1260
|
+
} else if (err?.name === "AbortError") {
|
|
1261
|
+
cancelled = true;
|
|
1262
|
+
break;
|
|
1263
|
+
} else {
|
|
1264
|
+
failed.push({
|
|
1265
|
+
lead_id: leadId,
|
|
1266
|
+
error: err?.message ?? err?.code ?? "unknown"
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
const launchedSet = new Set(launched);
|
|
1272
|
+
const not_launched = leadIds.filter((id) => !launchedSet.has(id) && !failed.some((f) => f.lead_id === id));
|
|
1273
|
+
const skipped_already_qualified = launched.filter((id) => alreadyQualified.has(id));
|
|
1274
|
+
const results = await Promise.all(launched.map(async (leadId) => {
|
|
1275
|
+
const leadDeadline = Math.min(Date.now() + perLeadBudgetMs, totalDeadlineMs);
|
|
1276
|
+
let lastQual = null;
|
|
1277
|
+
let lastWf = null;
|
|
1278
|
+
while (Date.now() < leadDeadline) {
|
|
1279
|
+
if (signal?.aborted) {
|
|
1280
|
+
cancelled = true;
|
|
1281
|
+
break;
|
|
1282
|
+
}
|
|
1283
|
+
try {
|
|
1284
|
+
const [wfR, qualR] = await Promise.allSettled([
|
|
1285
|
+
client.request("GET", `/leads/${leadId}/web_fetch`),
|
|
1286
|
+
client.request("GET", `/leads/${leadId}/ai_agent_responses`)
|
|
1287
|
+
]);
|
|
1288
|
+
if (wfR.status === "fulfilled")
|
|
1289
|
+
lastWf = wfR.value;
|
|
1290
|
+
if (qualR.status === "fulfilled")
|
|
1291
|
+
lastQual = qualR.value;
|
|
1292
|
+
const done = lastWf !== null && lastWf.in_progress !== true && Array.isArray(lastQual) && lastQual.length > 0 && lastQual.every((r) => r.score != null);
|
|
1293
|
+
if (done)
|
|
1294
|
+
break;
|
|
1295
|
+
} catch {
|
|
1296
|
+
}
|
|
1297
|
+
await sleepWithAbort(POLL_INTERVAL_MS, signal);
|
|
1298
|
+
if (signal?.aborted) {
|
|
1299
|
+
cancelled = true;
|
|
1300
|
+
break;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
const stillRunning = lastWf?.in_progress === true || !lastQual || lastQual.length === 0 || lastQual.some((r) => r.score == null);
|
|
1304
|
+
const responses = lastQual ?? [];
|
|
1305
|
+
const scores = responses.map((r) => r.score).filter((s) => s != null);
|
|
1306
|
+
const avg = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 10) / 10 : null;
|
|
1307
|
+
const qualifications = sortQualifications(responses.map((r) => {
|
|
1308
|
+
const out = {
|
|
1309
|
+
question: r.question,
|
|
1310
|
+
score: r.score,
|
|
1311
|
+
response: r.response,
|
|
1312
|
+
computed_at: r.computed_at
|
|
1313
|
+
};
|
|
1314
|
+
if (r.outdated_at !== void 0)
|
|
1315
|
+
out.outdated_at = r.outdated_at;
|
|
1316
|
+
return out;
|
|
1317
|
+
}), questionOrder ?? null);
|
|
1318
|
+
const human = summarizeQualifications(qualifications);
|
|
1319
|
+
const result = {
|
|
1320
|
+
lead_id: leadId,
|
|
1321
|
+
qualification_summary: responses.length > 0 ? {
|
|
1322
|
+
answered: responses.filter((r) => r.score != null).length,
|
|
1323
|
+
total: responses.length,
|
|
1324
|
+
avg_qualification_boost: avg
|
|
1325
|
+
} : null,
|
|
1326
|
+
qualifications,
|
|
1327
|
+
signals_count: lastWf?.content ? Object.values(lastWf.content).reduce((acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), 0) : null,
|
|
1328
|
+
_stillRunning: stillRunning
|
|
1329
|
+
};
|
|
1330
|
+
if (human)
|
|
1331
|
+
result.human_summary = human;
|
|
1332
|
+
return result;
|
|
1333
|
+
}));
|
|
1334
|
+
return {
|
|
1335
|
+
results,
|
|
1336
|
+
failed,
|
|
1337
|
+
not_launched,
|
|
1338
|
+
skipped_already_qualified,
|
|
1339
|
+
not_in_lens: [...notInLens],
|
|
1340
|
+
quota_exceeded: quotaExceeded,
|
|
1341
|
+
cancelled
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
async function sleepWithAbort(ms, signal) {
|
|
1345
|
+
if (!signal) {
|
|
1346
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
if (signal.aborted)
|
|
1350
|
+
return;
|
|
1351
|
+
await new Promise((resolve) => {
|
|
1352
|
+
const t = setTimeout(() => {
|
|
1353
|
+
signal.removeEventListener("abort", onAbort);
|
|
1354
|
+
resolve();
|
|
1355
|
+
}, ms);
|
|
1356
|
+
const onAbort = () => {
|
|
1357
|
+
clearTimeout(t);
|
|
1358
|
+
signal.removeEventListener("abort", onAbort);
|
|
1359
|
+
resolve();
|
|
1360
|
+
};
|
|
1361
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
var PREQUALIFIED_CHUNK_SIZE = 25;
|
|
1365
|
+
async function prequalifiedLeads(client, leadIds, lensId, ctx) {
|
|
1366
|
+
const already_qualified = /* @__PURE__ */ new Set();
|
|
1367
|
+
const not_in_lens = /* @__PURE__ */ new Set();
|
|
1368
|
+
for (let i = 0; i < leadIds.length; i += PREQUALIFIED_CHUNK_SIZE) {
|
|
1369
|
+
const chunk = leadIds.slice(i, i + PREQUALIFIED_CHUNK_SIZE);
|
|
1370
|
+
const results = await Promise.allSettled(chunk.map((leadId) => client.request("GET", `/lenses/${lensId}/leads/${leadId}`)));
|
|
1371
|
+
let chunkSawQuota = false;
|
|
1372
|
+
results.forEach((r, idx) => {
|
|
1373
|
+
if (r.status !== "fulfilled") {
|
|
1374
|
+
const code = r.reason?.code ?? r.reason?.message ?? "unknown";
|
|
1375
|
+
if (code === "QUOTA_EXCEEDED")
|
|
1376
|
+
chunkSawQuota = true;
|
|
1377
|
+
if (code === "NOT_FOUND") {
|
|
1378
|
+
not_in_lens.add(chunk[idx]);
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
ctx?.logger?.warn?.(`qualify: prequalified GET failed for ${chunk[idx]}: ${code}`);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const lead = r.value;
|
|
1385
|
+
if (lead.ai_agent_lead_score != null && lead.web_fetch_in_progress !== true) {
|
|
1386
|
+
already_qualified.add(lead.id);
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
if (chunkSawQuota) {
|
|
1390
|
+
ctx?.logger?.warn?.(`qualify: prequalified preflight 429'd at chunk ${Math.floor(i / PREQUALIFIED_CHUNK_SIZE) + 1} \u2014 ${already_qualified.size} leads probed; remaining ${leadIds.length - (i + chunk.length)} leads will go through full fan-out`);
|
|
1391
|
+
break;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
return { already_qualified, not_in_lens };
|
|
1395
|
+
}
|
|
1396
|
+
function extractHintsAndCandidates(fileImport, catalog) {
|
|
1397
|
+
const mapping_hints = [];
|
|
1398
|
+
const hintsRaw = fileImport.pre_processing?.hints;
|
|
1399
|
+
if (hintsRaw && typeof hintsRaw === "object" && hintsRaw.fields) {
|
|
1400
|
+
for (const [col, h] of Object.entries(hintsRaw.fields)) {
|
|
1401
|
+
mapping_hints.push({
|
|
1402
|
+
column: col,
|
|
1403
|
+
suggested_field: String(h?.field ?? ""),
|
|
1404
|
+
ai_confidence: typeof h?.ai_confidence === "number" ? h.ai_confidence : null
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
const suggested = new Set(mapping_hints.map((m) => m.column));
|
|
1409
|
+
const samplesRaw = fileImport.pre_processing?.samples ?? [];
|
|
1410
|
+
const sample_rows = Array.isArray(samplesRaw) ? samplesRaw.slice(0, 50) : [];
|
|
1411
|
+
const allColumns = /* @__PURE__ */ new Set();
|
|
1412
|
+
for (const row of sample_rows) {
|
|
1413
|
+
if (row && typeof row === "object") {
|
|
1414
|
+
for (const k of Object.keys(row))
|
|
1415
|
+
allColumns.add(k);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1419
|
+
const custom_field_candidates = [];
|
|
1420
|
+
for (const col of allColumns) {
|
|
1421
|
+
if (suggested.has(col))
|
|
1422
|
+
continue;
|
|
1423
|
+
const colNorm = norm(col);
|
|
1424
|
+
const exact = catalog.filter((c) => c.name === col);
|
|
1425
|
+
const ci = catalog.filter((c) => c.name.toLowerCase() === col.toLowerCase() && c.name !== col);
|
|
1426
|
+
const seenIds = new Set([...exact, ...ci].map((c) => c.id));
|
|
1427
|
+
const fuzzy = catalog.filter((c) => {
|
|
1428
|
+
if (seenIds.has(c.id))
|
|
1429
|
+
return false;
|
|
1430
|
+
const n = norm(c.name);
|
|
1431
|
+
return n.length > 0 && (n.includes(colNorm) || colNorm.includes(n));
|
|
1432
|
+
});
|
|
1433
|
+
if (exact.length === 0 && ci.length === 0 && fuzzy.length === 0)
|
|
1434
|
+
continue;
|
|
1435
|
+
custom_field_candidates.push({
|
|
1436
|
+
column: col,
|
|
1437
|
+
candidates: [
|
|
1438
|
+
...exact.map((c) => ({
|
|
1439
|
+
id: c.id,
|
|
1440
|
+
name: c.name,
|
|
1441
|
+
type: c.type,
|
|
1442
|
+
mapping_value: `CUSTOM.${c.id}`,
|
|
1443
|
+
reason: "exact_name_match"
|
|
1444
|
+
})),
|
|
1445
|
+
...ci.map((c) => ({
|
|
1446
|
+
id: c.id,
|
|
1447
|
+
name: c.name,
|
|
1448
|
+
type: c.type,
|
|
1449
|
+
mapping_value: `CUSTOM.${c.id}`,
|
|
1450
|
+
reason: "case_insensitive_match"
|
|
1451
|
+
})),
|
|
1452
|
+
...fuzzy.map((c) => ({
|
|
1453
|
+
id: c.id,
|
|
1454
|
+
name: c.name,
|
|
1455
|
+
type: c.type,
|
|
1456
|
+
mapping_value: `CUSTOM.${c.id}`,
|
|
1457
|
+
reason: "fuzzy_substring_match"
|
|
1458
|
+
}))
|
|
1459
|
+
]
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
return { mapping_hints, custom_field_candidates, sample_rows };
|
|
1463
|
+
}
|
|
1464
|
+
function fingerprintMapping(mapping) {
|
|
1465
|
+
const sorted = Object.keys(mapping).sort();
|
|
1466
|
+
const flat = sorted.map((k) => `${k}=${mapping[k]}`).join("|");
|
|
1467
|
+
return createHash("sha256").update(flat).digest("hex").slice(0, 32);
|
|
1468
|
+
}
|
|
1469
|
+
async function refreshLeadStates(client, leadIds, questionOrder) {
|
|
1470
|
+
return Promise.all(leadIds.map(async (leadId) => {
|
|
1471
|
+
let lastQual = null;
|
|
1472
|
+
let lastWf = null;
|
|
1473
|
+
let wfErrorCode = null;
|
|
1474
|
+
let qualErrorCode = null;
|
|
1475
|
+
try {
|
|
1476
|
+
const [wfR, qualR] = await Promise.allSettled([
|
|
1477
|
+
client.request("GET", `/leads/${leadId}/web_fetch`),
|
|
1478
|
+
client.request("GET", `/leads/${leadId}/ai_agent_responses`)
|
|
1479
|
+
]);
|
|
1480
|
+
if (wfR.status === "fulfilled")
|
|
1481
|
+
lastWf = wfR.value;
|
|
1482
|
+
else
|
|
1483
|
+
wfErrorCode = wfR.reason?.code ?? "UNKNOWN";
|
|
1484
|
+
if (qualR.status === "fulfilled")
|
|
1485
|
+
lastQual = qualR.value;
|
|
1486
|
+
else
|
|
1487
|
+
qualErrorCode = qualR.reason?.code ?? "UNKNOWN";
|
|
1488
|
+
} catch {
|
|
1489
|
+
}
|
|
1490
|
+
const both404 = wfErrorCode === "NOT_FOUND" && qualErrorCode === "NOT_FOUND";
|
|
1491
|
+
const stillRunning = !both404 && (lastWf?.in_progress === true || !lastQual || lastQual.length === 0 || lastQual.some((r) => r.score == null));
|
|
1492
|
+
const responses = lastQual ?? [];
|
|
1493
|
+
const scores = responses.map((r) => r.score).filter((s) => s != null);
|
|
1494
|
+
const avg = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 10) / 10 : null;
|
|
1495
|
+
const qualifications = sortQualifications(responses.map((r) => {
|
|
1496
|
+
const q = {
|
|
1497
|
+
question: r.question,
|
|
1498
|
+
score: r.score,
|
|
1499
|
+
response: r.response,
|
|
1500
|
+
computed_at: r.computed_at
|
|
1501
|
+
};
|
|
1502
|
+
if (r.outdated_at !== void 0)
|
|
1503
|
+
q.outdated_at = r.outdated_at;
|
|
1504
|
+
return q;
|
|
1505
|
+
}), questionOrder ?? null);
|
|
1506
|
+
const human = summarizeQualifications(qualifications);
|
|
1507
|
+
const out = {
|
|
1508
|
+
lead_id: leadId,
|
|
1509
|
+
qualification_summary: responses.length > 0 ? {
|
|
1510
|
+
answered: responses.filter((r) => r.score != null).length,
|
|
1511
|
+
total: responses.length,
|
|
1512
|
+
avg_qualification_boost: avg
|
|
1513
|
+
} : null,
|
|
1514
|
+
qualifications,
|
|
1515
|
+
signals_count: lastWf?.content ? Object.values(lastWf.content).reduce((acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), 0) : null,
|
|
1516
|
+
_stillRunning: stillRunning
|
|
1517
|
+
};
|
|
1518
|
+
if (human)
|
|
1519
|
+
out.human_summary = human;
|
|
1520
|
+
if (both404)
|
|
1521
|
+
out._failedCode = "NOT_FOUND";
|
|
1522
|
+
return out;
|
|
1523
|
+
}));
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// ../core/dist/composite/import-leads.js
|
|
1527
|
+
import { randomUUID } from "crypto";
|
|
1528
|
+
var CHUNK_SIZE = 100;
|
|
1529
|
+
var POLL_INTERVAL_MS2 = 2e3;
|
|
1530
|
+
var DEFAULT_PER_PHASE_BUDGET_MS = 6e4;
|
|
1531
|
+
var DEFAULT_TOTAL_BUDGET_MS = 3e5;
|
|
1532
|
+
var STABILIZATION_POLLS = 2;
|
|
1533
|
+
var MAX_COLUMN_NAME_LEN = 128;
|
|
1534
|
+
var RESERVED_COLUMN_RE = /^mcp_row_id$/i;
|
|
1535
|
+
var CUSTOM_FIELD_RE = /^CUSTOM\.(\d+)$/;
|
|
1536
|
+
function isCustomFieldMappingValue(v) {
|
|
1537
|
+
return CUSTOM_FIELD_RE.test(v);
|
|
1538
|
+
}
|
|
1539
|
+
function customFieldIdOf(v) {
|
|
1540
|
+
const m = CUSTOM_FIELD_RE.exec(v);
|
|
1541
|
+
return m ? m[1] : null;
|
|
1542
|
+
}
|
|
1543
|
+
var PUBLIC_MAILBOX_DOMAINS = /* @__PURE__ */ new Set([
|
|
1544
|
+
"gmail.com",
|
|
1545
|
+
"googlemail.com",
|
|
1546
|
+
"yahoo.com",
|
|
1547
|
+
"ymail.com",
|
|
1548
|
+
"outlook.com",
|
|
1549
|
+
"hotmail.com",
|
|
1550
|
+
"live.com",
|
|
1551
|
+
"icloud.com",
|
|
1552
|
+
"me.com",
|
|
1553
|
+
"mac.com",
|
|
1554
|
+
"aol.com",
|
|
1555
|
+
"proton.me",
|
|
1556
|
+
"protonmail.com",
|
|
1557
|
+
"tutanota.com",
|
|
1558
|
+
"gmx.com",
|
|
1559
|
+
"gmx.net",
|
|
1560
|
+
"gmx.de",
|
|
1561
|
+
"mail.com",
|
|
1562
|
+
"yandex.com",
|
|
1563
|
+
"yandex.ru",
|
|
1564
|
+
"qq.com",
|
|
1565
|
+
"163.com",
|
|
1566
|
+
"126.com"
|
|
1567
|
+
]);
|
|
1568
|
+
function normalizeDomain(input) {
|
|
1569
|
+
if (!input || typeof input !== "string")
|
|
1570
|
+
return null;
|
|
1571
|
+
let v = input.trim().toLowerCase();
|
|
1572
|
+
if (!v)
|
|
1573
|
+
return null;
|
|
1574
|
+
v = v.replace(/^https?:\/\//, "");
|
|
1575
|
+
v = v.replace(/^www\./, "");
|
|
1576
|
+
v = v.split("/")[0].split("?")[0].split("#")[0];
|
|
1577
|
+
v = v.replace(/\.+$/, "");
|
|
1578
|
+
if (!v)
|
|
1579
|
+
return null;
|
|
1580
|
+
if (/\s/.test(v))
|
|
1581
|
+
return null;
|
|
1582
|
+
if (!v.includes("."))
|
|
1583
|
+
return null;
|
|
1584
|
+
if (v.startsWith(".") || v.endsWith("."))
|
|
1585
|
+
return null;
|
|
1586
|
+
const parts = v.split(".");
|
|
1587
|
+
if (parts.length < 2)
|
|
1588
|
+
return null;
|
|
1589
|
+
if (parts.some((p) => p.length === 0))
|
|
1590
|
+
return null;
|
|
1591
|
+
const tld = parts[parts.length - 1];
|
|
1592
|
+
if (!/^[a-z]{2,}$/.test(tld) && !tld.startsWith("xn--"))
|
|
1593
|
+
return null;
|
|
1594
|
+
if (!/^[a-z0-9-]+$/.test(parts[parts.length - 2]))
|
|
1595
|
+
return null;
|
|
1596
|
+
return v;
|
|
1597
|
+
}
|
|
1598
|
+
function escapeCsvCell(raw) {
|
|
1599
|
+
if (raw == null)
|
|
1600
|
+
return "";
|
|
1601
|
+
let s = String(raw);
|
|
1602
|
+
const trimmed = s.replace(/^[\s\r\n\t]+/, "");
|
|
1603
|
+
if (trimmed.length > 0) {
|
|
1604
|
+
const first = trimmed[0];
|
|
1605
|
+
if (first === "=" || first === "+" || first === "-" || first === "@") {
|
|
1606
|
+
s = "'" + s;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
if (/[",\n\r]/.test(s)) {
|
|
1610
|
+
s = '"' + s.replace(/"/g, '""') + '"';
|
|
1611
|
+
}
|
|
1612
|
+
return s;
|
|
1613
|
+
}
|
|
1614
|
+
function synthesizeCsv(header, rows) {
|
|
1615
|
+
const headerLine = header.map(escapeCsvCell).join(",");
|
|
1616
|
+
const dataLines = rows.map((r) => header.map((col) => escapeCsvCell(r[col] ?? "")).join(","));
|
|
1617
|
+
return [headerLine, ...dataLines].join("\n") + "\n";
|
|
1618
|
+
}
|
|
1619
|
+
function chunkAt100(items) {
|
|
1620
|
+
if (items.length === 0)
|
|
1621
|
+
return [];
|
|
1622
|
+
const chunks = [];
|
|
1623
|
+
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
|
|
1624
|
+
chunks.push(items.slice(i, i + CHUNK_SIZE));
|
|
1625
|
+
}
|
|
1626
|
+
return chunks;
|
|
1627
|
+
}
|
|
1628
|
+
function checkAborted(signal) {
|
|
1629
|
+
if (signal?.aborted) {
|
|
1630
|
+
throw Object.assign(new Error("aborted"), { name: "AbortError" });
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
async function sleepWithAbort2(ms, signal) {
|
|
1634
|
+
if (!signal) {
|
|
1635
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
if (signal.aborted) {
|
|
1639
|
+
checkAborted(signal);
|
|
1640
|
+
}
|
|
1641
|
+
await new Promise((resolve, reject) => {
|
|
1642
|
+
const t = setTimeout(() => {
|
|
1643
|
+
signal.removeEventListener("abort", onAbort);
|
|
1644
|
+
resolve();
|
|
1645
|
+
}, ms);
|
|
1646
|
+
const onAbort = () => {
|
|
1647
|
+
clearTimeout(t);
|
|
1648
|
+
signal.removeEventListener("abort", onAbort);
|
|
1649
|
+
reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
|
|
1650
|
+
};
|
|
1651
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
function readCell(record, key) {
|
|
1655
|
+
const want = key.toLowerCase();
|
|
1656
|
+
const arr = record.records;
|
|
1657
|
+
if (Array.isArray(arr)) {
|
|
1658
|
+
for (const c of arr) {
|
|
1659
|
+
const k = (c?.column_name ?? c?.key ?? c?.field ?? "").toString().toLowerCase();
|
|
1660
|
+
if (k === want) {
|
|
1661
|
+
const v = c?.value ?? null;
|
|
1662
|
+
return v != null ? String(v) : null;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
const cells = record.cells;
|
|
1667
|
+
if (cells && typeof cells === "object" && !Array.isArray(cells)) {
|
|
1668
|
+
for (const [k, v] of Object.entries(cells)) {
|
|
1669
|
+
if (k.toLowerCase() === want) {
|
|
1670
|
+
return v != null ? String(v) : null;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
if (Array.isArray(cells)) {
|
|
1675
|
+
for (const c of cells) {
|
|
1676
|
+
const k = (c?.key ?? c?.field ?? c?.column_name ?? "").toString().toLowerCase();
|
|
1677
|
+
if (k === want) {
|
|
1678
|
+
const v = c?.value ?? null;
|
|
1679
|
+
return v != null ? String(v) : null;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
return null;
|
|
1684
|
+
}
|
|
1685
|
+
function validateColumnName(client, name, path) {
|
|
1686
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
1687
|
+
throw client.makeError("IMPORT_INVALID_COLUMN_NAME", `Column name at ${path} must be a non-empty string`, `Use a plain string column name (1-${MAX_COLUMN_NAME_LEN} chars).`, "POST /imports");
|
|
1688
|
+
}
|
|
1689
|
+
if (name.length > MAX_COLUMN_NAME_LEN) {
|
|
1690
|
+
throw client.makeError("IMPORT_INVALID_COLUMN_NAME", `Column name at ${path} exceeds ${MAX_COLUMN_NAME_LEN} chars`, `Shorten the column name to ${MAX_COLUMN_NAME_LEN} chars or fewer.`, "POST /imports");
|
|
1691
|
+
}
|
|
1692
|
+
if (/[\x00-\x1F\x7F]/.test(name)) {
|
|
1693
|
+
throw client.makeError("IMPORT_INVALID_COLUMN_NAME", `Column name at ${path} contains control characters`, `Strip control characters (e.g. \\n, \\t, \\x00) from column names.`, "POST /imports");
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
function coerceCell(client, v, path) {
|
|
1697
|
+
if (v == null)
|
|
1698
|
+
return "";
|
|
1699
|
+
if (typeof v === "string")
|
|
1700
|
+
return v;
|
|
1701
|
+
if (typeof v === "number" || typeof v === "boolean")
|
|
1702
|
+
return String(v);
|
|
1703
|
+
throw client.makeError("IMPORT_INVALID_CELL_TYPE", `Cell at ${path} is ${Array.isArray(v) ? "an array" : typeof v}, expected string|number|boolean|null`, `Convert the value to a string before passing.`, "POST /imports");
|
|
1704
|
+
}
|
|
1705
|
+
function prepareDomainsMode(client, inputs) {
|
|
1706
|
+
const validInputs = [];
|
|
1707
|
+
const malformedDomains = [];
|
|
1708
|
+
const byDomain = /* @__PURE__ */ new Map();
|
|
1709
|
+
const byRowId = /* @__PURE__ */ new Map();
|
|
1710
|
+
for (const inp of inputs) {
|
|
1711
|
+
const norm = normalizeDomain(inp?.domain ?? "");
|
|
1712
|
+
if (!norm) {
|
|
1713
|
+
malformedDomains.push(inp?.domain ?? "");
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1716
|
+
if (byDomain.has(norm))
|
|
1717
|
+
continue;
|
|
1718
|
+
const rowId = randomUUID();
|
|
1719
|
+
const idx = validInputs.length;
|
|
1720
|
+
const name = inp.name?.trim() || norm;
|
|
1721
|
+
validInputs.push({
|
|
1722
|
+
index: idx,
|
|
1723
|
+
rowId,
|
|
1724
|
+
row: { MCP_ROW_ID: rowId, LEAD_NAME: name, LEAD_WEBSITE: norm },
|
|
1725
|
+
domain: norm,
|
|
1726
|
+
outputDomain: norm
|
|
1727
|
+
});
|
|
1728
|
+
byDomain.set(norm, idx);
|
|
1729
|
+
byRowId.set(rowId, idx);
|
|
1730
|
+
}
|
|
1731
|
+
return {
|
|
1732
|
+
mode: "domains",
|
|
1733
|
+
validInputs,
|
|
1734
|
+
malformedDomains,
|
|
1735
|
+
byDomain,
|
|
1736
|
+
byRowId,
|
|
1737
|
+
header: ["MCP_ROW_ID", "LEAD_NAME", "LEAD_WEBSITE"],
|
|
1738
|
+
mappings: {
|
|
1739
|
+
fields: { LEAD_NAME: "LEAD_NAME", LEAD_WEBSITE: "LEAD_WEBSITE" },
|
|
1740
|
+
statuses: {},
|
|
1741
|
+
default_status: null
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
function prepareRecordsMode(client, records, mappings, customFieldCatalog) {
|
|
1746
|
+
if (!mappings || !mappings.fields || typeof mappings.fields !== "object") {
|
|
1747
|
+
throw client.makeError("IMPORT_MAPPING_REQUIRED", "records[] requires a mappings.fields object", "Pass `mappings: { fields: { CsvColumn: 'LEAD_NAME', ... } }`.", "POST /imports");
|
|
1748
|
+
}
|
|
1749
|
+
const normalizedFields = normalizeFieldsAndCustomShorthand(client, mappings.fields, mappings.custom_fields, customFieldCatalog);
|
|
1750
|
+
const fieldEntries = Object.entries(normalizedFields);
|
|
1751
|
+
if (fieldEntries.length === 0) {
|
|
1752
|
+
throw client.makeError("IMPORT_MAPPING_REQUIRED", "mappings.fields must contain at least one column \u2192 CRM field entry", "Map at least one CSV column to LEAD_NAME or LEAD_WEBSITE.", "POST /imports");
|
|
1753
|
+
}
|
|
1754
|
+
const targets = new Set(fieldEntries.map(([, v]) => v));
|
|
1755
|
+
if (!targets.has("LEAD_NAME") && !targets.has("LEAD_WEBSITE")) {
|
|
1756
|
+
throw client.makeError("IMPORT_MAPPING_NO_RESOLVER", "mappings.fields must include LEAD_NAME or LEAD_WEBSITE", "The wizard needs at least one of those fields to match a lead. Map a CSV column to one of them.", "POST /imports");
|
|
1757
|
+
}
|
|
1758
|
+
const targetCounts = /* @__PURE__ */ new Map();
|
|
1759
|
+
for (const [col, target] of fieldEntries) {
|
|
1760
|
+
if (typeof target !== "string")
|
|
1761
|
+
continue;
|
|
1762
|
+
if (target.startsWith("CUSTOM."))
|
|
1763
|
+
continue;
|
|
1764
|
+
const cols = targetCounts.get(target) ?? [];
|
|
1765
|
+
cols.push(col);
|
|
1766
|
+
targetCounts.set(target, cols);
|
|
1767
|
+
}
|
|
1768
|
+
for (const [target, cols] of targetCounts) {
|
|
1769
|
+
if (cols.length > 1) {
|
|
1770
|
+
throw client.makeError("IMPORT_MAPPING_CONFLICT_TARGET", `Multiple columns map to the same target ${target}: ${cols.map((c) => JSON.stringify(c)).join(", ")}`, `Each StandardCrmFieldType can be the destination of only one column (the wizard would silently keep one and drop the others). Pick the column you want and remove the duplicates.`, "POST /imports");
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
for (const [colName] of fieldEntries) {
|
|
1774
|
+
validateColumnName(client, colName, `mappings.fields[${JSON.stringify(colName)}]`);
|
|
1775
|
+
if (RESERVED_COLUMN_RE.test(colName)) {
|
|
1776
|
+
throw client.makeError("IMPORT_RESERVED_COLUMN", `mappings.fields key '${colName}' collides with reserved synthetic column MCP_ROW_ID`, `Rename the column. MCP_ROW_ID (any case) is reserved for tool-internal reconciliation.`, "POST /imports");
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
const headerSet = /* @__PURE__ */ new Set();
|
|
1780
|
+
const coercedRecords = [];
|
|
1781
|
+
records.forEach((rec, i) => {
|
|
1782
|
+
if (rec == null || typeof rec !== "object" || Array.isArray(rec)) {
|
|
1783
|
+
throw client.makeError("IMPORT_INVALID_CELL_TYPE", `records[${i}] must be a plain object`, `Pass each record as { ColumnName: value, ... }.`, "POST /imports");
|
|
1784
|
+
}
|
|
1785
|
+
const out = {};
|
|
1786
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
1787
|
+
validateColumnName(client, k, `records[${i}] key`);
|
|
1788
|
+
if (RESERVED_COLUMN_RE.test(k)) {
|
|
1789
|
+
throw client.makeError("IMPORT_RESERVED_COLUMN", `records[${i}] key '${k}' collides with reserved synthetic column MCP_ROW_ID`, `Rename the column in your records (any case variant of MCP_ROW_ID is reserved).`, "POST /imports");
|
|
1790
|
+
}
|
|
1791
|
+
out[k] = coerceCell(client, v, `records[${i}].${k}`);
|
|
1792
|
+
headerSet.add(k);
|
|
1793
|
+
}
|
|
1794
|
+
coercedRecords.push(out);
|
|
1795
|
+
});
|
|
1796
|
+
for (const [colName] of fieldEntries) {
|
|
1797
|
+
if (!headerSet.has(colName)) {
|
|
1798
|
+
throw client.makeError("IMPORT_MAPPING_KEY_UNKNOWN", `mappings.fields key '${colName}' is not present in any record`, `Add a value for '${colName}' to at least one record, or remove it from mappings.`, "POST /imports");
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
const userKeys = [...headerSet].sort();
|
|
1802
|
+
const header = ["MCP_ROW_ID", ...userKeys];
|
|
1803
|
+
const websiteCol = fieldEntries.find(([, v]) => v === "LEAD_WEBSITE")?.[0];
|
|
1804
|
+
const validInputs = [];
|
|
1805
|
+
const byDomain = /* @__PURE__ */ new Map();
|
|
1806
|
+
const byRowId = /* @__PURE__ */ new Map();
|
|
1807
|
+
coercedRecords.forEach((row) => {
|
|
1808
|
+
const rowId = randomUUID();
|
|
1809
|
+
const idx = validInputs.length;
|
|
1810
|
+
let normDomain = null;
|
|
1811
|
+
if (websiteCol) {
|
|
1812
|
+
normDomain = normalizeDomain(row[websiteCol] ?? "");
|
|
1813
|
+
}
|
|
1814
|
+
const fullRow = { MCP_ROW_ID: rowId, ...row };
|
|
1815
|
+
validInputs.push({
|
|
1816
|
+
index: idx,
|
|
1817
|
+
rowId,
|
|
1818
|
+
row: fullRow,
|
|
1819
|
+
domain: normDomain,
|
|
1820
|
+
outputDomain: normDomain ?? void 0
|
|
1821
|
+
});
|
|
1822
|
+
byRowId.set(rowId, idx);
|
|
1823
|
+
if (normDomain && !byDomain.has(normDomain))
|
|
1824
|
+
byDomain.set(normDomain, idx);
|
|
1825
|
+
});
|
|
1826
|
+
return {
|
|
1827
|
+
mode: "records",
|
|
1828
|
+
validInputs,
|
|
1829
|
+
malformedDomains: [],
|
|
1830
|
+
byDomain,
|
|
1831
|
+
byRowId,
|
|
1832
|
+
header,
|
|
1833
|
+
mappings: {
|
|
1834
|
+
fields: { ...normalizedFields },
|
|
1835
|
+
statuses: mappings.statuses ?? {},
|
|
1836
|
+
default_status: mappings.default_status ?? null
|
|
1837
|
+
}
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
function normalizeFieldsAndCustomShorthand(client, fields, customShorthand, catalog) {
|
|
1841
|
+
const out = {};
|
|
1842
|
+
const seenColumns = /* @__PURE__ */ new Set();
|
|
1843
|
+
for (const [col, raw] of Object.entries(fields)) {
|
|
1844
|
+
if (typeof raw !== "string") {
|
|
1845
|
+
throw client.makeError("IMPORT_INVALID_CUSTOM_MAPPING", `mappings.fields[${JSON.stringify(col)}] must be a string (got ${typeof raw})`, "Pass a StandardCrmFieldType name (e.g. 'LEAD_NAME') or 'CUSTOM.<id>'.", "POST /imports");
|
|
1846
|
+
}
|
|
1847
|
+
if (raw.startsWith("CUSTOM.")) {
|
|
1848
|
+
if (!isCustomFieldMappingValue(raw)) {
|
|
1849
|
+
throw client.makeError("IMPORT_INVALID_CUSTOM_MAPPING", `mappings.fields[${JSON.stringify(col)}] = ${JSON.stringify(raw)} is not a well-formed custom mapping`, "Custom field mappings must look like 'CUSTOM.<digits>'. Call leadbay_list_mappable_fields to see valid ids.", "POST /imports");
|
|
1850
|
+
}
|
|
1851
|
+
if (catalog !== null) {
|
|
1852
|
+
const id = customFieldIdOf(raw);
|
|
1853
|
+
const found = catalog.find((c) => c.id === id);
|
|
1854
|
+
if (!found) {
|
|
1855
|
+
throw client.makeError("IMPORT_CUSTOM_FIELD_UNKNOWN", `mappings.fields[${JSON.stringify(col)}] = ${JSON.stringify(raw)} references custom field id ${id}, not present on this org`, `Org has ${catalog.length} custom field(s): ${catalog.length === 0 ? "none \u2014 create one in the Leadbay web UI first" : catalog.map((c) => `${c.id}=${c.name} (${c.type})`).join(", ")}`, "POST /imports");
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
out[col] = raw;
|
|
1860
|
+
seenColumns.add(col);
|
|
1861
|
+
}
|
|
1862
|
+
if (customShorthand && Object.keys(customShorthand).length > 0) {
|
|
1863
|
+
if (catalog === null) {
|
|
1864
|
+
throw client.makeError("IMPORT_CUSTOM_FIELD_CATALOG_REQUIRED", "mappings.custom_fields shorthand requires the org's custom-field catalog", "This is an internal error \u2014 the composite should have fetched /crm/custom_fields. Retry, or use raw 'CUSTOM.<id>' in mappings.fields.", "POST /imports");
|
|
1865
|
+
}
|
|
1866
|
+
for (const [col, val] of Object.entries(customShorthand)) {
|
|
1867
|
+
if (seenColumns.has(col)) {
|
|
1868
|
+
throw client.makeError("IMPORT_MAPPING_DUPLICATE_CUSTOM", `Column ${JSON.stringify(col)} is in BOTH mappings.fields and mappings.custom_fields`, "Each column maps to exactly one field. Drop the duplicate from one of the two maps.", "POST /imports");
|
|
1869
|
+
}
|
|
1870
|
+
let resolved;
|
|
1871
|
+
const numericVal = typeof val === "number" ? val : typeof val === "string" && /^\d+$/.test(val) ? Number(val) : null;
|
|
1872
|
+
if (numericVal !== null) {
|
|
1873
|
+
const idStr = String(numericVal);
|
|
1874
|
+
resolved = catalog.find((c) => c.id === idStr);
|
|
1875
|
+
if (!resolved) {
|
|
1876
|
+
throw client.makeError("IMPORT_CUSTOM_FIELD_UNKNOWN", `mappings.custom_fields[${JSON.stringify(col)}] = ${val} is not a custom field on this org`, `Org has ${catalog.length} custom field(s): ${catalog.length === 0 ? "none \u2014 create one in the Leadbay web UI first" : catalog.map((c) => `${c.id}=${c.name} (${c.type})`).join(", ")}`, "POST /imports");
|
|
1877
|
+
}
|
|
1878
|
+
} else if (typeof val === "string") {
|
|
1879
|
+
const matches = catalog.filter((c) => c.name === val);
|
|
1880
|
+
if (matches.length === 0) {
|
|
1881
|
+
const ci = catalog.filter((c) => c.name.toLowerCase() === val.toLowerCase());
|
|
1882
|
+
if (ci.length === 0) {
|
|
1883
|
+
throw client.makeError("IMPORT_CUSTOM_FIELD_UNKNOWN", `mappings.custom_fields[${JSON.stringify(col)}] = ${JSON.stringify(val)} doesn't match any custom field name`, `Org has ${catalog.length} custom field(s): ${catalog.length === 0 ? "none \u2014 create one in the Leadbay web UI first" : catalog.map((c) => `${c.id}=${c.name} (${c.type})`).join(", ")}`, "POST /imports");
|
|
1884
|
+
}
|
|
1885
|
+
if (ci.length > 1) {
|
|
1886
|
+
throw client.makeError("IMPORT_CUSTOM_FIELD_NAME_AMBIGUOUS", `mappings.custom_fields[${JSON.stringify(col)}] = ${JSON.stringify(val)} matches ${ci.length} custom fields case-insensitively`, `Pass the numeric id instead. Candidates: ${ci.map((c) => `${c.id}=${c.name}`).join(", ")}`, "POST /imports");
|
|
1887
|
+
}
|
|
1888
|
+
resolved = ci[0];
|
|
1889
|
+
} else if (matches.length > 1) {
|
|
1890
|
+
throw client.makeError("IMPORT_CUSTOM_FIELD_NAME_AMBIGUOUS", `mappings.custom_fields[${JSON.stringify(col)}] = ${JSON.stringify(val)} matches ${matches.length} custom fields exactly`, `Pass the numeric id instead. Candidates: ${matches.map((c) => `${c.id}=${c.name}`).join(", ")}`, "POST /imports");
|
|
1891
|
+
} else {
|
|
1892
|
+
resolved = matches[0];
|
|
1893
|
+
}
|
|
1894
|
+
} else {
|
|
1895
|
+
throw client.makeError("IMPORT_INVALID_CUSTOM_MAPPING", `mappings.custom_fields[${JSON.stringify(col)}] must be a number (id) or string (name); got ${typeof val}`, "Pass either the numeric id (e.g. 8) or the field name (e.g. 'priority').", "POST /imports");
|
|
1896
|
+
}
|
|
1897
|
+
out[col] = `CUSTOM.${resolved.id}`;
|
|
1898
|
+
seenColumns.add(col);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
return out;
|
|
1902
|
+
}
|
|
1903
|
+
async function pollUntil(fn, done, budgetMs, signal, ctx, label) {
|
|
1904
|
+
const deadline = Date.now() + budgetMs;
|
|
1905
|
+
let last;
|
|
1906
|
+
while (true) {
|
|
1907
|
+
checkAborted(signal);
|
|
1908
|
+
last = await fn();
|
|
1909
|
+
if (done(last))
|
|
1910
|
+
return last;
|
|
1911
|
+
if (Date.now() >= deadline) {
|
|
1912
|
+
ctx?.logger?.warn?.(`import-leads: ${label} budget exhausted (${budgetMs}ms)`);
|
|
1913
|
+
return last;
|
|
1914
|
+
}
|
|
1915
|
+
await sleepWithAbort2(POLL_INTERVAL_MS2, signal);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
async function pollPreprocess(client, importId, budgetMs, ctx, signal) {
|
|
1919
|
+
const result = await pollUntil(() => client.request("GET", `/imports/${importId}`), (r) => Boolean(r.pre_processing?.finished), budgetMs, signal, ctx, "preprocess");
|
|
1920
|
+
if (!result.pre_processing?.finished) {
|
|
1921
|
+
throw client.makeError("IMPORT_BUDGET_EXHAUSTED", `Preprocess phase did not finish within ${budgetMs}ms`, `Increase per_phase_budget_ms (current: ${budgetMs}) or split the batch. importId=${importId}.`, `GET /imports/${importId}`);
|
|
1922
|
+
}
|
|
1923
|
+
if (result.pre_processing.error) {
|
|
1924
|
+
throw client.makeError("IMPORT_PREPROCESS_FAILED", `Preprocess failed: ${result.pre_processing.error}`, `Check the input domains. importId=${importId} for backend debugging.`, `GET /imports/${importId}`);
|
|
1925
|
+
}
|
|
1926
|
+
return result;
|
|
1927
|
+
}
|
|
1928
|
+
async function pollProcess(client, importId, budgetMs, ctx, signal) {
|
|
1929
|
+
const result = await pollUntil(() => client.request("GET", `/imports/${importId}`), (r) => Boolean(r.processing?.finished), budgetMs, signal, ctx, "process");
|
|
1930
|
+
if (!result.processing?.finished) {
|
|
1931
|
+
throw client.makeError("IMPORT_BUDGET_EXHAUSTED", `Process phase did not finish within ${budgetMs}ms`, `Increase per_phase_budget_ms (current: ${budgetMs}) or split the batch. importId=${importId}.`, `GET /imports/${importId}`);
|
|
1932
|
+
}
|
|
1933
|
+
if (result.processing.error != null) {
|
|
1934
|
+
throw client.makeError("IMPORT_PROCESSING_FAILED", `Backend processing failed: ${result.processing.error}`, `importId=${importId}.`, `GET /imports/${importId}`);
|
|
1935
|
+
}
|
|
1936
|
+
return result;
|
|
1937
|
+
}
|
|
1938
|
+
async function pollRecordsToTerminal(client, importId, budgetMs, expectedRowCount, ctx, signal) {
|
|
1939
|
+
const deadline = Date.now() + budgetMs;
|
|
1940
|
+
const maxPagesPerPoll = Math.max(2, Math.ceil(expectedRowCount / 100) * 2 + 4);
|
|
1941
|
+
let stableCounts = 0;
|
|
1942
|
+
let lastSnapshot = null;
|
|
1943
|
+
while (true) {
|
|
1944
|
+
checkAborted(signal);
|
|
1945
|
+
let total = 0;
|
|
1946
|
+
let transient = 0;
|
|
1947
|
+
let pagesFetched = 0;
|
|
1948
|
+
let exhaustedPagination = false;
|
|
1949
|
+
const records = [];
|
|
1950
|
+
for (let page = 0; page < maxPagesPerPoll; page++) {
|
|
1951
|
+
checkAborted(signal);
|
|
1952
|
+
const qs = `count=100&page=${page}&automatic_match=true&manual_match=true&no_match=true&matching=true&importing=true&imported=true`;
|
|
1953
|
+
const res = await client.request("GET", `/imports/${importId}/records?${qs}`);
|
|
1954
|
+
pagesFetched++;
|
|
1955
|
+
records.push(...res.items);
|
|
1956
|
+
total = res.pagination.total ?? records.length;
|
|
1957
|
+
for (const r of res.items) {
|
|
1958
|
+
const status = (r.status ?? "").toString().toUpperCase();
|
|
1959
|
+
const matchType = (r.match_type ?? r.matchType ?? "").toString().toUpperCase();
|
|
1960
|
+
const isTerminal = matchType === "NO_MATCH" || status === "IMPORTED";
|
|
1961
|
+
if (!isTerminal)
|
|
1962
|
+
transient++;
|
|
1963
|
+
}
|
|
1964
|
+
const totalPages = res.pagination.pages ?? 0;
|
|
1965
|
+
if (page + 1 >= totalPages) {
|
|
1966
|
+
exhaustedPagination = true;
|
|
1967
|
+
break;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
if (!exhaustedPagination) {
|
|
1971
|
+
throw client.makeError("IMPORT_PAGINATION_RUNAWAY", `Records pagination exceeded ${maxPagesPerPoll} pages`, `importId=${importId}. Please file a bug at https://github.com/leadbay/leadclaw/issues.`, `GET /imports/${importId}/records`);
|
|
1972
|
+
}
|
|
1973
|
+
const snapshot = { total, transient };
|
|
1974
|
+
const settled = transient === 0;
|
|
1975
|
+
const stableVsLast = lastSnapshot != null && lastSnapshot.total === snapshot.total && lastSnapshot.transient === snapshot.transient;
|
|
1976
|
+
if (settled && stableVsLast) {
|
|
1977
|
+
stableCounts++;
|
|
1978
|
+
} else if (settled) {
|
|
1979
|
+
stableCounts = 1;
|
|
1980
|
+
} else {
|
|
1981
|
+
stableCounts = 0;
|
|
1982
|
+
}
|
|
1983
|
+
lastSnapshot = snapshot;
|
|
1984
|
+
if (settled && stableCounts >= STABILIZATION_POLLS) {
|
|
1985
|
+
return records;
|
|
1986
|
+
}
|
|
1987
|
+
if (Date.now() >= deadline) {
|
|
1988
|
+
ctx?.logger?.warn?.(`import-leads: records did not stabilize (transient=${transient}, total=${total}); returning best-effort`);
|
|
1989
|
+
throw client.makeError("IMPORT_NOT_TERMINAL", `Backend hasn't fully settled records within ${budgetMs}ms`, `Retry leadbay_import_leads with the same input in 30s, or split the batch. importId=${importId}.`, `GET /imports/${importId}/records`);
|
|
1990
|
+
}
|
|
1991
|
+
await sleepWithAbort2(POLL_INTERVAL_MS2, signal);
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
async function runOneChunk(client, chunk, chunkIdx, totalChunks, header, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onImportId) {
|
|
1995
|
+
const csv = synthesizeCsv(header, chunk.map((c) => c.row));
|
|
1996
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1997
|
+
const fileName = `mcp-import-${ts}-${chunkIdx}.csv`;
|
|
1998
|
+
ctx?.logger?.info?.(`import-leads: uploading chunk ${chunkIdx + 1}/${totalChunks} (${chunk.length} rows, ${csv.length}B)`);
|
|
1999
|
+
const upload = await client.requestRawBinary("POST", `/imports?file_name=${encodeURIComponent(fileName)}`, "text/csv", csv);
|
|
2000
|
+
const importId = upload.id;
|
|
2001
|
+
onImportId(importId);
|
|
2002
|
+
const phaseBudget = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
|
|
2003
|
+
await pollPreprocess(client, importId, phaseBudget, ctx, signal);
|
|
2004
|
+
ctx?.logger?.info?.(`import-leads: preprocess done for importId=${importId}`);
|
|
2005
|
+
if (dryRun) {
|
|
2006
|
+
return { importId, records: [] };
|
|
2007
|
+
}
|
|
2008
|
+
await client.requestVoid("POST", `/imports/${importId}/update_mappings`, mappings);
|
|
2009
|
+
ctx?.logger?.info?.(`import-leads: mappings committed for importId=${importId}`);
|
|
2010
|
+
const phaseBudget2 = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
|
|
2011
|
+
await pollProcess(client, importId, phaseBudget2, ctx, signal);
|
|
2012
|
+
ctx?.logger?.info?.(`import-leads: process done for importId=${importId}`);
|
|
2013
|
+
const phaseBudget3 = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
|
|
2014
|
+
const records = await pollRecordsToTerminal(client, importId, phaseBudget3, chunk.length, ctx, signal);
|
|
2015
|
+
ctx?.logger?.info?.(`import-leads: ${records.length} records terminal for importId=${importId}`);
|
|
2016
|
+
return { importId, records };
|
|
2017
|
+
}
|
|
2018
|
+
function reconcileOneChunk(prep, chunk, matched, notImported) {
|
|
2019
|
+
const seenInputIndex = /* @__PURE__ */ new Set();
|
|
2020
|
+
const sortedRecords = [...chunk.records].sort((a, b) => {
|
|
2021
|
+
const aHasLead = a.lead?.id ? 0 : 1;
|
|
2022
|
+
const bHasLead = b.lead?.id ? 0 : 1;
|
|
2023
|
+
return aHasLead - bHasLead;
|
|
2024
|
+
});
|
|
2025
|
+
for (const rec of sortedRecords) {
|
|
2026
|
+
let inputIdx;
|
|
2027
|
+
const rowIdCell = readCell(rec, "MCP_ROW_ID");
|
|
2028
|
+
if (rowIdCell && prep.byRowId.has(rowIdCell)) {
|
|
2029
|
+
inputIdx = prep.byRowId.get(rowIdCell);
|
|
2030
|
+
}
|
|
2031
|
+
if (inputIdx === void 0) {
|
|
2032
|
+
const websiteCell = readCell(rec, "LEAD_WEBSITE");
|
|
2033
|
+
if (websiteCell) {
|
|
2034
|
+
const norm = normalizeDomain(websiteCell);
|
|
2035
|
+
if (norm && prep.byDomain.has(norm)) {
|
|
2036
|
+
inputIdx = prep.byDomain.get(norm);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
if (inputIdx === void 0 && rec.lead?.website) {
|
|
2041
|
+
const norm = normalizeDomain(rec.lead.website);
|
|
2042
|
+
if (norm && prep.byDomain.has(norm)) {
|
|
2043
|
+
inputIdx = prep.byDomain.get(norm);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
if (inputIdx === void 0)
|
|
2047
|
+
continue;
|
|
2048
|
+
if (seenInputIndex.has(inputIdx)) {
|
|
2049
|
+
if (!matched.has(inputIdx) && !notImported.has(inputIdx)) {
|
|
2050
|
+
const inp2 = prep.validInputs[inputIdx];
|
|
2051
|
+
notImported.set(inputIdx, { domain: inp2.outputDomain, reason: "ambiguous" });
|
|
2052
|
+
}
|
|
2053
|
+
continue;
|
|
2054
|
+
}
|
|
2055
|
+
seenInputIndex.add(inputIdx);
|
|
2056
|
+
const inp = prep.validInputs[inputIdx];
|
|
2057
|
+
const matchType = (rec.match_type ?? rec.matchType ?? "").toString();
|
|
2058
|
+
if (rec.lead?.id) {
|
|
2059
|
+
matched.set(inputIdx, {
|
|
2060
|
+
domain: inp.outputDomain,
|
|
2061
|
+
leadId: rec.lead.id,
|
|
2062
|
+
name: rec.lead.name ?? null
|
|
2063
|
+
});
|
|
2064
|
+
} else if (matchType === "NO_MATCH") {
|
|
2065
|
+
const reason = inp.domain && PUBLIC_MAILBOX_DOMAINS.has(inp.domain) ? "no_match" : "uncrawled";
|
|
2066
|
+
notImported.set(inputIdx, { domain: inp.outputDomain, reason });
|
|
2067
|
+
} else {
|
|
2068
|
+
notImported.set(inputIdx, { domain: inp.outputDomain, reason: "internal_error" });
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
var importLeads = {
|
|
2073
|
+
name: "leadbay_import_leads",
|
|
2074
|
+
description: "Import leads into Leadbay's CRM via the file-import wizard. Returns stable Leadbay leadIds for downstream chaining into leadbay_bulk_qualify_leads / leadbay_research_lead.\n\nTWO MODES:\n A) Domain-list shortcut \u2014 pass `domains: [{domain, name?}]`. The tool builds a 2-column CSV (LEAD_NAME, LEAD_WEBSITE) and imports with the default mapping. Output: { leads: [{domain, leadId, name}], not_imported: [{domain, reason}], importIds, _meta }.\n B) Custom records + mapping \u2014 pass `records: [{Col1, Col2, ...}]` plus `mappings.fields: {Col1: 'LEAD_NAME', Col2: 'LEAD_WEBSITE', ...}`. The tool synthesizes a CSV from the union of record keys (deterministic order) and POSTs the caller-supplied mapping to the wizard. mappings.fields must include LEAD_NAME or LEAD_WEBSITE (the resolver needs at least one). Output: { leads: [{rowId, domain?, leadId, name}], not_imported: [{rowId, domain?, reason}], importIds, _meta }. `rowId` round-trips your input order.\n\nPass exactly one of `domains` / `records`. Reserved column MCP_ROW_ID (any case) cannot appear in records or mappings \u2014 the tool injects it for stable reconciliation.\n\n\u26A0\uFE0F MUTATES USER STATE. Each call:\n - creates a row in the user's CRM-imports list (visible in the web UI)\n - touches onboarding state (startFileless, onboarding step \u2192 PROCESSING)\nSuitable for occasional automation. NOT suitable for high-cadence (>5 calls/day) \u2014 wait for the backend programmatic endpoint (issue: leadbay/backend prolonged-import-with-crawl).\n\n\u2139\uFE0F Monitor-tab membership: imported leads are NOT auto-promoted to the user's Monitor view. Lens-scoring decides \u2014 only above-threshold leads get `in_monitor: true` server-side.\n\nWhen to use: you have a list of company domains from another system (CRM, analytics, email correspondents) and need stable Leadbay leadIds; or you have CRM-shaped rows with custom columns (sector, location, status, etc.) and want to drive the wizard with explicit field mappings.\nWhen NOT to use: for prospect discovery (use leadbay_pull_leads); for one specific company's profile (use leadbay_research_company); when you can't tolerate the side effects above.\n\nCustom fields: pass org-defined custom field mappings as 'CUSTOM.<id>' (raw wire format) in `mappings.fields`, OR use the ergonomic `mappings.custom_fields` shorthand: `{ColName: 8}` (numeric id) or `{ColName: 'priority_test'}` (field name). Discover available custom fields via leadbay_list_mappable_fields.\n\nRequires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role on the Leadbay account; active billing.",
|
|
2075
|
+
write: true,
|
|
2076
|
+
version: "0.3.0",
|
|
2077
|
+
inputSchema: {
|
|
2078
|
+
type: "object",
|
|
2079
|
+
properties: {
|
|
2080
|
+
domains: {
|
|
2081
|
+
type: "array",
|
|
2082
|
+
description: "Mode A: list of company domains to map to Leadbay leadIds. Mutually exclusive with `records`.",
|
|
2083
|
+
items: {
|
|
2084
|
+
type: "object",
|
|
2085
|
+
properties: {
|
|
2086
|
+
domain: {
|
|
2087
|
+
type: "string",
|
|
2088
|
+
description: "Company domain (e.g. 'apple.com'). Protocol/path are stripped."
|
|
2089
|
+
},
|
|
2090
|
+
name: {
|
|
2091
|
+
type: "string",
|
|
2092
|
+
description: "Optional display name override; defaults to the domain."
|
|
2093
|
+
}
|
|
2094
|
+
},
|
|
2095
|
+
required: ["domain"]
|
|
2096
|
+
}
|
|
2097
|
+
},
|
|
2098
|
+
records: {
|
|
2099
|
+
type: "array",
|
|
2100
|
+
description: "Mode B: arbitrary CSV-shaped rows. Each record is an object whose keys are column names and values are scalar (string/number/boolean/null). Mutually exclusive with `domains`. Must be accompanied by `mappings.fields`. The tool synthesizes a CSV from the union of all record keys.",
|
|
2101
|
+
items: { type: "object" }
|
|
2102
|
+
},
|
|
2103
|
+
mappings: {
|
|
2104
|
+
type: "object",
|
|
2105
|
+
description: "Mode B: how each CSV column maps to Leadbay's CRM field schema. Required when `records` is supplied; ignored otherwise.",
|
|
2106
|
+
properties: {
|
|
2107
|
+
fields: {
|
|
2108
|
+
type: "object",
|
|
2109
|
+
description: "Object whose keys are CSV column names (matching keys in `records`) and whose values are either Leadbay's StandardCrmFieldType (LEAD_NAME, LEAD_WEBSITE, LEAD_STATUS, LEAD_LOCATION, LEAD_LOCATION_*, LEAD_SECTOR, LEAD_SIZE, CRM_ID, LEADBAY_ID, EMAIL, DEAL_CRM_ID, CONTACT_FIRST_NAME, CONTACT_LAST_NAME, CONTACT_EMAIL, CONTACT_PHONE_NUMBER, CONTACT_TITLE, CONTACT_LINKEDIN, LEAD_STATUS_DATE, OWNER, SCORE, SIREN) or the wire-format string 'CUSTOM.<id>' for org-defined custom fields. At least one entry must target LEAD_NAME or LEAD_WEBSITE \u2014 the wizard needs that to find leads. Use leadbay_list_mappable_fields to discover the org's custom fields."
|
|
2110
|
+
},
|
|
2111
|
+
custom_fields: {
|
|
2112
|
+
type: "object",
|
|
2113
|
+
description: "Ergonomic shorthand: `{CsvColumn: <number-id>}` or `{CsvColumn: '<field-name>'}` for custom-field mappings. Resolved against the org's /crm/custom_fields catalog before the import is committed. Mutually exclusive with `fields[col] = 'CUSTOM.<id>'` for the same column. Useful when the agent doesn't want to deal with the 'CUSTOM.<id>' wire format."
|
|
2114
|
+
},
|
|
2115
|
+
statuses: {
|
|
2116
|
+
type: "object",
|
|
2117
|
+
description: "Optional status string mapping (rarely needed). Defaults to {}."
|
|
2118
|
+
},
|
|
2119
|
+
default_status: {
|
|
2120
|
+
type: ["string", "null"],
|
|
2121
|
+
description: "Optional default status. Defaults to null."
|
|
2122
|
+
}
|
|
2123
|
+
},
|
|
2124
|
+
required: ["fields"]
|
|
2125
|
+
},
|
|
2126
|
+
dry_run: {
|
|
2127
|
+
type: "boolean",
|
|
2128
|
+
description: "If true, run preprocess only \u2014 do NOT commit lead-CRM linking. Note: an import row still appears in the user's CRM-imports list as 'incomplete'. Use to verify input format / wizard reachability without polluting the CRM."
|
|
2129
|
+
},
|
|
2130
|
+
per_phase_budget_ms: {
|
|
2131
|
+
type: "number",
|
|
2132
|
+
description: `Single poll-loop cap (default ${DEFAULT_PER_PHASE_BUDGET_MS}ms).`
|
|
2133
|
+
},
|
|
2134
|
+
total_budget_ms: {
|
|
2135
|
+
type: "number",
|
|
2136
|
+
description: `Overall cap across all phases (default ${DEFAULT_TOTAL_BUDGET_MS}ms).`
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
// Neither field is "required" at the schema level; xor + presence is
|
|
2140
|
+
// enforced in execute() so we can produce specific error codes.
|
|
2141
|
+
},
|
|
2142
|
+
execute: async (client, params, ctx) => {
|
|
2143
|
+
const signal = ctx?.signal;
|
|
2144
|
+
const dryRun = Boolean(params.dry_run);
|
|
2145
|
+
const perPhaseBudget = params.per_phase_budget_ms ?? DEFAULT_PER_PHASE_BUDGET_MS;
|
|
2146
|
+
const totalBudget = params.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS;
|
|
2147
|
+
const totalDeadline = Date.now() + totalBudget;
|
|
2148
|
+
const hasDomains = Array.isArray(params.domains) && params.domains.length > 0;
|
|
2149
|
+
const hasRecords = Array.isArray(params.records) && params.records.length > 0;
|
|
2150
|
+
if (hasDomains && hasRecords) {
|
|
2151
|
+
throw client.makeError("IMPORT_INPUT_CONFLICT", "Pass exactly one of `domains` or `records`, not both", "Use `domains` for the simple shortcut, or `records`+`mappings` for arbitrary CSV input.", "POST /imports");
|
|
2152
|
+
}
|
|
2153
|
+
if (!hasDomains && !hasRecords) {
|
|
2154
|
+
throw client.makeError("IMPORT_EMPTY_INPUT", "domains[] or records[] must contain at least one entry", "Pass at least one entry. See the tool description for the two input modes.", "POST /imports");
|
|
2155
|
+
}
|
|
2156
|
+
const me = await client.resolveMe();
|
|
2157
|
+
if (!me.admin) {
|
|
2158
|
+
throw client.makeError("IMPORT_ADMIN_REQUIRED", "This tool requires admin role on the Leadbay account", "Ask the account owner to grant import permission, or use a token from an admin user.", "POST /imports");
|
|
2159
|
+
}
|
|
2160
|
+
let customFieldCatalog = null;
|
|
2161
|
+
if (hasRecords) {
|
|
2162
|
+
const m = params.mappings;
|
|
2163
|
+
const referencesCustom = m?.custom_fields && Object.keys(m.custom_fields).length > 0 || m?.fields && Object.values(m.fields).some((v) => typeof v === "string" && v.startsWith("CUSTOM."));
|
|
2164
|
+
if (referencesCustom) {
|
|
2165
|
+
try {
|
|
2166
|
+
customFieldCatalog = await client.request("GET", "/crm/custom_fields");
|
|
2167
|
+
} catch (err) {
|
|
2168
|
+
throw client.makeError("IMPORT_CUSTOM_FIELD_CATALOG_UNAVAILABLE", `Failed to fetch /crm/custom_fields for preflight: ${err?.message ?? err?.code ?? "unknown"}`, "Custom field references can't be validated. Retry, or remove custom-field mappings.", "GET /crm/custom_fields");
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
const prep = hasDomains ? prepareDomainsMode(client, params.domains) : prepareRecordsMode(client, params.records, params.mappings, customFieldCatalog);
|
|
2173
|
+
if (prep.validInputs.length === 0) {
|
|
2174
|
+
const not_imported2 = prep.malformedDomains.map((d) => ({
|
|
2175
|
+
domain: d,
|
|
2176
|
+
reason: "malformed"
|
|
2177
|
+
}));
|
|
2178
|
+
return {
|
|
2179
|
+
leads: [],
|
|
2180
|
+
not_imported: not_imported2,
|
|
2181
|
+
importIds: [],
|
|
2182
|
+
region: client.region,
|
|
2183
|
+
dry_run: dryRun || void 0,
|
|
2184
|
+
_meta: client.lastMeta ?? {
|
|
2185
|
+
region: client.region,
|
|
2186
|
+
endpoint: "POST /imports",
|
|
2187
|
+
latency_ms: null,
|
|
2188
|
+
retry_after: null
|
|
2189
|
+
}
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
const chunks = chunkAt100(prep.validInputs);
|
|
2193
|
+
ctx?.logger?.info?.(`import-leads(${prep.mode}): ${prep.validInputs.length} rows \u2192 ${chunks.length} chunk(s); dry_run=${dryRun}, totalBudgetMs=${totalBudget}`);
|
|
2194
|
+
const importIds = [];
|
|
2195
|
+
const matched = /* @__PURE__ */ new Map();
|
|
2196
|
+
const notImported = /* @__PURE__ */ new Map();
|
|
2197
|
+
let cancelled = false;
|
|
2198
|
+
const recordImportId = (id) => {
|
|
2199
|
+
if (!importIds.includes(id))
|
|
2200
|
+
importIds.push(id);
|
|
2201
|
+
};
|
|
2202
|
+
try {
|
|
2203
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
2204
|
+
const chunk = chunks[i];
|
|
2205
|
+
const out = await runOneChunk(client, chunk, i, chunks.length, prep.header, prep.mappings, dryRun, perPhaseBudget, totalDeadline, ctx, signal, recordImportId);
|
|
2206
|
+
if (!dryRun) {
|
|
2207
|
+
reconcileOneChunk(prep, out, matched, notImported);
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
} catch (err) {
|
|
2211
|
+
if (err?.name === "AbortError") {
|
|
2212
|
+
cancelled = true;
|
|
2213
|
+
ctx?.logger?.info?.(`import-leads: aborted via signal; importIds=${importIds.join(",")}`);
|
|
2214
|
+
} else if (err?.error === true) {
|
|
2215
|
+
if (err.code === "FORBIDDEN") {
|
|
2216
|
+
throw client.makeError("IMPORT_ADMIN_REQUIRED", err.message || "Insufficient permissions for /imports", "This tool requires admin role on the Leadbay account. Ask the account owner.", err._meta?.endpoint);
|
|
2217
|
+
}
|
|
2218
|
+
if (err.code === "BILLING_SUSPENDED") {
|
|
2219
|
+
throw client.makeError("IMPORT_BILLING_REQUIRED", err.message || "Active billing required for imports", "Upgrade at https://app.leadbay.ai/billing, then retry.", err._meta?.endpoint);
|
|
2220
|
+
}
|
|
2221
|
+
throw err;
|
|
2222
|
+
} else {
|
|
2223
|
+
throw err;
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
const leads = [];
|
|
2227
|
+
const not_imported = [];
|
|
2228
|
+
if (dryRun) {
|
|
2229
|
+
for (const inp of prep.validInputs) {
|
|
2230
|
+
if (prep.mode === "domains") {
|
|
2231
|
+
not_imported.push({ domain: inp.outputDomain, reason: "dry_run" });
|
|
2232
|
+
} else {
|
|
2233
|
+
const entry = { rowId: inp.rowId, reason: "dry_run" };
|
|
2234
|
+
if (inp.outputDomain)
|
|
2235
|
+
entry.domain = inp.outputDomain;
|
|
2236
|
+
not_imported.push(entry);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
} else {
|
|
2240
|
+
for (const inp of prep.validInputs) {
|
|
2241
|
+
const m = matched.get(inp.index);
|
|
2242
|
+
if (m) {
|
|
2243
|
+
if (prep.mode === "domains") {
|
|
2244
|
+
leads.push({
|
|
2245
|
+
domain: inp.outputDomain,
|
|
2246
|
+
leadId: m.leadId,
|
|
2247
|
+
name: m.name
|
|
2248
|
+
});
|
|
2249
|
+
} else {
|
|
2250
|
+
const e = {
|
|
2251
|
+
rowId: inp.rowId,
|
|
2252
|
+
leadId: m.leadId,
|
|
2253
|
+
name: m.name
|
|
2254
|
+
};
|
|
2255
|
+
if (m.domain ?? inp.outputDomain)
|
|
2256
|
+
e.domain = m.domain ?? inp.outputDomain;
|
|
2257
|
+
leads.push(e);
|
|
2258
|
+
}
|
|
2259
|
+
continue;
|
|
2260
|
+
}
|
|
2261
|
+
const ni = notImported.get(inp.index);
|
|
2262
|
+
if (ni) {
|
|
2263
|
+
if (prep.mode === "domains") {
|
|
2264
|
+
not_imported.push({ domain: inp.outputDomain, reason: ni.reason });
|
|
2265
|
+
} else {
|
|
2266
|
+
const e = { rowId: inp.rowId, reason: ni.reason };
|
|
2267
|
+
if (ni.domain ?? inp.outputDomain)
|
|
2268
|
+
e.domain = ni.domain ?? inp.outputDomain;
|
|
2269
|
+
not_imported.push(e);
|
|
2270
|
+
}
|
|
2271
|
+
continue;
|
|
2272
|
+
}
|
|
2273
|
+
if (prep.mode === "domains") {
|
|
2274
|
+
not_imported.push({ domain: inp.outputDomain, reason: "internal_error" });
|
|
2275
|
+
} else {
|
|
2276
|
+
const e = { rowId: inp.rowId, reason: "internal_error" };
|
|
2277
|
+
if (inp.outputDomain)
|
|
2278
|
+
e.domain = inp.outputDomain;
|
|
2279
|
+
not_imported.push(e);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
for (const m of prep.malformedDomains) {
|
|
2284
|
+
not_imported.push({ domain: m, reason: "malformed" });
|
|
2285
|
+
}
|
|
2286
|
+
return {
|
|
2287
|
+
leads,
|
|
2288
|
+
not_imported,
|
|
2289
|
+
importIds,
|
|
2290
|
+
region: client.region,
|
|
2291
|
+
cancelled: cancelled || void 0,
|
|
2292
|
+
dry_run: dryRun || void 0,
|
|
2293
|
+
_meta: client.lastMeta ?? {
|
|
2294
|
+
region: client.region,
|
|
2295
|
+
endpoint: "POST /imports",
|
|
2296
|
+
latency_ms: null,
|
|
2297
|
+
retry_after: null
|
|
2298
|
+
}
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
};
|
|
2302
|
+
|
|
2303
|
+
// ../core/dist/tools/list-mappable-fields.js
|
|
2304
|
+
var STANDARD_FIELDS = [
|
|
2305
|
+
{ name: "LEAD_NAME", description: "Company name. Required for fuzzy match." },
|
|
2306
|
+
{ name: "LEAD_WEBSITE", description: "Company domain (preferred matcher; protocol/path auto-stripped)." },
|
|
2307
|
+
{ name: "EMAIL", description: "Email \u2014 domain part used as a website-fallback matcher." },
|
|
2308
|
+
{ name: "CRM_ID", description: "Your CRM's stable lead identifier (round-trips back as crm_id on the lead)." },
|
|
2309
|
+
{ name: "LEADBAY_ID", description: "Leadbay UUID, if you already have one (matches by id, no fuzzy needed)." },
|
|
2310
|
+
{ name: "DEAL_CRM_ID", description: "Your CRM's deal id (one deal per row; combined with LEAD_STATUS forms a sales record)." },
|
|
2311
|
+
{ name: "LEAD_STATUS", description: "Free-form status string (use mappings.statuses to map per-status)." },
|
|
2312
|
+
{ name: "LEAD_STATUS_DATE", description: "ISO date for the LEAD_STATUS transition (parsed permissively)." },
|
|
2313
|
+
{ name: "LEAD_SECTOR", description: "Industry/sector free-text (matched against Leadbay's sector taxonomy)." },
|
|
2314
|
+
{ name: "LEAD_SIZE", description: "Headcount or revenue range as free text." },
|
|
2315
|
+
{ name: "LEAD_LOCATION", description: "Single-cell address (preferred when CSV has one column)." },
|
|
2316
|
+
{ name: "LEAD_LOCATION_STREET_NUM", description: "Street number \u2014 combined with the other LEAD_LOCATION_* parts to form the address." },
|
|
2317
|
+
{ name: "LEAD_LOCATION_STREET", description: "Street name." },
|
|
2318
|
+
{ name: "LEAD_LOCATION_POSTCODE", description: "Postal/ZIP code." },
|
|
2319
|
+
{ name: "LEAD_LOCATION_CITY", description: "City." },
|
|
2320
|
+
{ name: "OWNER", description: "Free-text owner identifier (no auto-match against users)." },
|
|
2321
|
+
{ name: "SCORE", description: "Free-text caller-supplied score (informational only \u2014 NOT Leadbay's ai_agent_lead_score)." },
|
|
2322
|
+
{ name: "SIREN", description: "French SIREN registry number (9 digits) \u2014 auto-matches against the FR registry." },
|
|
2323
|
+
{ name: "CONTACT_FIRST_NAME", description: "Contact first name (creates an org contact)." },
|
|
2324
|
+
{ name: "CONTACT_LAST_NAME", description: "Contact last name." },
|
|
2325
|
+
{ name: "CONTACT_EMAIL", description: "Contact email." },
|
|
2326
|
+
{ name: "CONTACT_PHONE_NUMBER", description: "Contact phone (free-form)." },
|
|
2327
|
+
{ name: "CONTACT_TITLE", description: "Contact job title." },
|
|
2328
|
+
{ name: "CONTACT_LINKEDIN", description: "Contact LinkedIn URL." }
|
|
2329
|
+
];
|
|
2330
|
+
function describeCustomField(f) {
|
|
2331
|
+
switch (f.type) {
|
|
2332
|
+
case "TEXT":
|
|
2333
|
+
return `Custom TEXT field \u2014 free-form string.`;
|
|
2334
|
+
case "NUMBER":
|
|
2335
|
+
return `Custom NUMBER field \u2014 numeric value (parseFloat coerced server-side).`;
|
|
2336
|
+
case "PRICE":
|
|
2337
|
+
return `Custom PRICE field${f.config?.currency ? ` (${f.config.currency})` : ""} \u2014 numeric.`;
|
|
2338
|
+
case "DATE":
|
|
2339
|
+
return `Custom DATE field${f.config?.format ? ` (format: ${f.config.format})` : " (ISO yyyy-MM-dd)"}.`;
|
|
2340
|
+
case "DATETIME":
|
|
2341
|
+
return `Custom DATETIME field${f.config?.format ? ` (format: ${f.config.format})` : " (ISO datetime)"}.`;
|
|
2342
|
+
case "EXTERNAL_ID":
|
|
2343
|
+
return `Custom EXTERNAL_ID field \u2014 opaque id${f.config?.urlTemplate ? ` (deep-link template configured)` : ""}.`;
|
|
2344
|
+
default:
|
|
2345
|
+
return `Custom field of unrecognized type "${f.type}" \u2014 pass values as strings.`;
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
var PREVIEW_SAMPLE_CAP = 50;
|
|
2349
|
+
var PREPROCESS_BUDGET_MS = 6e4;
|
|
2350
|
+
var listMappableFields = {
|
|
2351
|
+
name: "leadbay_list_mappable_fields",
|
|
2352
|
+
description: "List every CRM field the agent can target when calling leadbay_import_leads or leadbay_import_and_qualify. Returns two arrays: `standard_fields` (Leadbay's built-in StandardCrmFieldType enum \u2014 LEAD_NAME, LEAD_WEBSITE, LEAD_STATUS, contact + location + sector fields) and `custom_fields` (this org's user-defined fields \u2014 id, name, type, and the literal `mapping_value` you pass in `mappings.fields`). For custom fields, `mapping_value` is the wire-format string `CUSTOM.<id>` \u2014 pass it verbatim.\n\nOptional `for_records` param: pass a sample of CSV-shaped rows and the tool also runs the wizard's preprocess on them, attaching `mapping_hints` (per-column AI-confidence suggestions) and `custom_field_candidates` (custom fields that match unmapped columns by exact / case-insensitive / fuzzy name) to the response. Saves a separate preview round-trip when the agent already has data in hand.\n\nWhen to use: before authoring an import mapping, especially when the CSV has columns that aren't obvious matches for standard fields. When NOT to use: when you already know the mapping \u2014 this call is cheap (~50ms with no for_records, ~5\u201310s with) but unnecessary if the agent has already cached the catalog within the same conversation.",
|
|
2353
|
+
inputSchema: {
|
|
2354
|
+
type: "object",
|
|
2355
|
+
properties: {
|
|
2356
|
+
for_records: {
|
|
2357
|
+
type: "array",
|
|
2358
|
+
items: { type: "object" },
|
|
2359
|
+
description: "Optional sample of CSV-shaped rows (objects). When provided, the tool runs the wizard's preprocess on the first 50 rows and attaches mapping_hints + custom_field_candidates to the response."
|
|
2360
|
+
}
|
|
2361
|
+
},
|
|
2362
|
+
additionalProperties: false
|
|
2363
|
+
},
|
|
2364
|
+
execute: async (client, params, ctx) => {
|
|
2365
|
+
const signal = ctx?.signal;
|
|
2366
|
+
const customs = await client.request("GET", "/crm/custom_fields");
|
|
2367
|
+
const standard_fields = STANDARD_FIELDS.map((s) => ({
|
|
2368
|
+
name: s.name,
|
|
2369
|
+
description: s.description,
|
|
2370
|
+
mapping_value: s.name
|
|
2371
|
+
}));
|
|
2372
|
+
const catalog = customs ?? [];
|
|
2373
|
+
const custom_fields = catalog.map((f) => ({
|
|
2374
|
+
id: f.id,
|
|
2375
|
+
name: f.name,
|
|
2376
|
+
type: f.type,
|
|
2377
|
+
description: describeCustomField(f),
|
|
2378
|
+
mapping_value: `CUSTOM.${f.id}`
|
|
2379
|
+
}));
|
|
2380
|
+
const result = {
|
|
2381
|
+
standard_fields,
|
|
2382
|
+
custom_fields,
|
|
2383
|
+
region: client.region,
|
|
2384
|
+
_meta: client.lastMeta ?? {
|
|
2385
|
+
region: client.region,
|
|
2386
|
+
endpoint: "GET /crm/custom_fields",
|
|
2387
|
+
latency_ms: null,
|
|
2388
|
+
retry_after: null
|
|
2389
|
+
}
|
|
2390
|
+
};
|
|
2391
|
+
if (Array.isArray(params.for_records) && params.for_records.length > 0) {
|
|
2392
|
+
const notes = [];
|
|
2393
|
+
try {
|
|
2394
|
+
const sample = params.for_records.slice(0, PREVIEW_SAMPLE_CAP);
|
|
2395
|
+
const headerSet = /* @__PURE__ */ new Set();
|
|
2396
|
+
for (const r of sample)
|
|
2397
|
+
if (r && typeof r === "object")
|
|
2398
|
+
for (const k of Object.keys(r))
|
|
2399
|
+
headerSet.add(k);
|
|
2400
|
+
const header = [...headerSet];
|
|
2401
|
+
const lines = [header.map(escapeCsvCell).join(",")];
|
|
2402
|
+
for (const r of sample) {
|
|
2403
|
+
lines.push(header.map((c) => escapeCsvCell(coerceCsvValue(r[c]))).join(","));
|
|
2404
|
+
}
|
|
2405
|
+
const csv = lines.join("\n") + "\n";
|
|
2406
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2407
|
+
const fileName = `mcp-preview-${ts}.csv`;
|
|
2408
|
+
const upload = await client.requestRawBinary("POST", `/imports?file_name=${encodeURIComponent(fileName)}`, "text/csv", csv);
|
|
2409
|
+
const importId = upload.id;
|
|
2410
|
+
const deadline = Date.now() + PREPROCESS_BUDGET_MS;
|
|
2411
|
+
let fileImport = null;
|
|
2412
|
+
while (Date.now() < deadline) {
|
|
2413
|
+
if (signal?.aborted) {
|
|
2414
|
+
throw Object.assign(new Error("aborted"), { name: "AbortError" });
|
|
2415
|
+
}
|
|
2416
|
+
const r = await client.request("GET", `/imports/${importId}`);
|
|
2417
|
+
if (r.pre_processing?.finished) {
|
|
2418
|
+
fileImport = r;
|
|
2419
|
+
break;
|
|
2420
|
+
}
|
|
2421
|
+
await new Promise((res) => {
|
|
2422
|
+
const t = setTimeout(() => {
|
|
2423
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2424
|
+
res();
|
|
2425
|
+
}, 2e3);
|
|
2426
|
+
const onAbort = () => {
|
|
2427
|
+
clearTimeout(t);
|
|
2428
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2429
|
+
res();
|
|
2430
|
+
};
|
|
2431
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2432
|
+
});
|
|
2433
|
+
if (signal?.aborted) {
|
|
2434
|
+
throw Object.assign(new Error("aborted"), { name: "AbortError" });
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
if (!fileImport) {
|
|
2438
|
+
notes.push(`for_records preprocess did not finish within ${PREPROCESS_BUDGET_MS}ms \u2014 hints omitted`);
|
|
2439
|
+
} else if (fileImport.pre_processing?.error) {
|
|
2440
|
+
notes.push(`for_records preprocess failed: ${fileImport.pre_processing.error}`);
|
|
2441
|
+
} else {
|
|
2442
|
+
const extracted = extractHintsAndCandidates(fileImport, catalog);
|
|
2443
|
+
result.mapping_hints = extracted.mapping_hints;
|
|
2444
|
+
result.custom_field_candidates = extracted.custom_field_candidates;
|
|
2445
|
+
result.sample_rows = extracted.sample_rows;
|
|
2446
|
+
}
|
|
2447
|
+
} catch (err) {
|
|
2448
|
+
notes.push(`for_records preprocess error: ${err?.code ?? err?.message ?? "unknown"} \u2014 hints omitted`);
|
|
2449
|
+
}
|
|
2450
|
+
if (notes.length > 0)
|
|
2451
|
+
result.notes = notes;
|
|
2452
|
+
}
|
|
2453
|
+
return result;
|
|
2454
|
+
}
|
|
2455
|
+
};
|
|
2456
|
+
function coerceCsvValue(v) {
|
|
2457
|
+
if (v == null)
|
|
2458
|
+
return "";
|
|
2459
|
+
if (typeof v === "string")
|
|
2460
|
+
return v;
|
|
2461
|
+
if (typeof v === "number" || typeof v === "boolean")
|
|
2462
|
+
return String(v);
|
|
2463
|
+
return "";
|
|
2464
|
+
}
|
|
2465
|
+
|
|
1178
2466
|
// ../core/dist/tools/select-leads.js
|
|
1179
2467
|
var selectLeads = {
|
|
1180
2468
|
name: "leadbay_select_leads",
|
|
@@ -2128,7 +3416,7 @@ var PAGE_SIZE = 50;
|
|
|
2128
3416
|
var DEFAULT_COUNT = 10;
|
|
2129
3417
|
var MAX_COUNT = 25;
|
|
2130
3418
|
var DEFAULT_PER_LEAD_BUDGET_MS = 9e4;
|
|
2131
|
-
var
|
|
3419
|
+
var DEFAULT_TOTAL_BUDGET_MS2 = 5 * 6e4;
|
|
2132
3420
|
var bulkQualifyLeads = {
|
|
2133
3421
|
name: "leadbay_bulk_qualify_leads",
|
|
2134
3422
|
description: "Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch), polling until the answers are populated or a budget is exhausted. Already-qualified leads (those with a non-null ai_agent_lead_score) are silently no-ops on the backend, so this composite paginates past them to find fresh candidates. On 429 mid-fanout, stops launching but keeps polling already-launched leads. Context: Leadbay auto-qualifies roughly the top 10 of each daily batch. Leads below the top ~10 are NOT worse \u2014 the system is saving resources. This tool is how the agent spends more resources to go deeper on promising-looking leads the user hasn't had time to surface yet. When to use: when the user wants more qualified leads than what's currently shown, or when a lead looks promising in leadbay_pull_leads but has an empty qualification_summary. When NOT to use: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced).",
|
|
@@ -2154,14 +3442,14 @@ var bulkQualifyLeads = {
|
|
|
2154
3442
|
},
|
|
2155
3443
|
total_budget_ms: {
|
|
2156
3444
|
type: "number",
|
|
2157
|
-
description: `Total polling budget in ms (default ${
|
|
3445
|
+
description: `Total polling budget in ms (default ${DEFAULT_TOTAL_BUDGET_MS2})`
|
|
2158
3446
|
}
|
|
2159
3447
|
}
|
|
2160
3448
|
},
|
|
2161
3449
|
execute: async (client, params, ctx) => {
|
|
2162
3450
|
const wantCount = Math.min(params.count ?? DEFAULT_COUNT, MAX_COUNT);
|
|
2163
3451
|
const perLeadBudget = params.per_lead_budget_ms ?? DEFAULT_PER_LEAD_BUDGET_MS;
|
|
2164
|
-
const totalBudget = params.total_budget_ms ??
|
|
3452
|
+
const totalBudget = params.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS2;
|
|
2165
3453
|
const totalDeadline = Date.now() + totalBudget;
|
|
2166
3454
|
let candidates;
|
|
2167
3455
|
let exhausted = false;
|
|
@@ -2250,502 +3538,143 @@ var bulkQualifyLeads = {
|
|
|
2250
3538
|
await new Promise((r) => setTimeout(r, 5e3));
|
|
2251
3539
|
}
|
|
2252
3540
|
const stillRunning = lastWf?.in_progress === true || !lastQual || lastQual.length === 0 || lastQual.some((r) => r.score == null);
|
|
2253
|
-
const responses = lastQual ?? [];
|
|
2254
|
-
const scores = responses.map((r) => r.score).filter((s) => s != null);
|
|
2255
|
-
const avg = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 10) / 10 : null;
|
|
2256
|
-
return {
|
|
2257
|
-
lead_id: leadId,
|
|
2258
|
-
qualification_summary: responses.length > 0 ? {
|
|
2259
|
-
answered: responses.filter((r) => r.score != null).length,
|
|
2260
|
-
total: responses.length,
|
|
2261
|
-
avg_qualification_boost: avg
|
|
2262
|
-
} : null,
|
|
2263
|
-
signals_count: lastWf?.content ? Object.values(lastWf.content).reduce((acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), 0) : null,
|
|
2264
|
-
_stillRunning: stillRunning
|
|
2265
|
-
};
|
|
2266
|
-
}));
|
|
2267
|
-
const qualified = results.filter((r) => !r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
|
|
2268
|
-
const still_running = results.filter((r) => r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
|
|
2269
|
-
return {
|
|
2270
|
-
qualified,
|
|
2271
|
-
still_running,
|
|
2272
|
-
failed,
|
|
2273
|
-
quota_exceeded: quotaExceeded,
|
|
2274
|
-
exhausted,
|
|
2275
|
-
total_unqualified_found: totalUnqualifiedFound,
|
|
2276
|
-
lens_id: lensId,
|
|
2277
|
-
_meta: { region: client.region }
|
|
2278
|
-
};
|
|
2279
|
-
}
|
|
2280
|
-
};
|
|
2281
|
-
|
|
2282
|
-
// ../core/dist/composite/import-leads.js
|
|
2283
|
-
import { randomUUID } from "crypto";
|
|
2284
|
-
var CHUNK_SIZE = 100;
|
|
2285
|
-
var POLL_INTERVAL_MS = 2e3;
|
|
2286
|
-
var DEFAULT_PER_PHASE_BUDGET_MS = 6e4;
|
|
2287
|
-
var DEFAULT_TOTAL_BUDGET_MS2 = 3e5;
|
|
2288
|
-
var STABILIZATION_POLLS = 2;
|
|
2289
|
-
var MAX_COLUMN_NAME_LEN = 128;
|
|
2290
|
-
var RESERVED_COLUMN_RE = /^mcp_row_id$/i;
|
|
2291
|
-
var PUBLIC_MAILBOX_DOMAINS = /* @__PURE__ */ new Set([
|
|
2292
|
-
"gmail.com",
|
|
2293
|
-
"googlemail.com",
|
|
2294
|
-
"yahoo.com",
|
|
2295
|
-
"ymail.com",
|
|
2296
|
-
"outlook.com",
|
|
2297
|
-
"hotmail.com",
|
|
2298
|
-
"live.com",
|
|
2299
|
-
"icloud.com",
|
|
2300
|
-
"me.com",
|
|
2301
|
-
"mac.com",
|
|
2302
|
-
"aol.com",
|
|
2303
|
-
"proton.me",
|
|
2304
|
-
"protonmail.com",
|
|
2305
|
-
"tutanota.com",
|
|
2306
|
-
"gmx.com",
|
|
2307
|
-
"gmx.net",
|
|
2308
|
-
"gmx.de",
|
|
2309
|
-
"mail.com",
|
|
2310
|
-
"yandex.com",
|
|
2311
|
-
"yandex.ru",
|
|
2312
|
-
"qq.com",
|
|
2313
|
-
"163.com",
|
|
2314
|
-
"126.com"
|
|
2315
|
-
]);
|
|
2316
|
-
function normalizeDomain(input) {
|
|
2317
|
-
if (!input || typeof input !== "string")
|
|
2318
|
-
return null;
|
|
2319
|
-
let v = input.trim().toLowerCase();
|
|
2320
|
-
if (!v)
|
|
2321
|
-
return null;
|
|
2322
|
-
v = v.replace(/^https?:\/\//, "");
|
|
2323
|
-
v = v.replace(/^www\./, "");
|
|
2324
|
-
v = v.split("/")[0].split("?")[0].split("#")[0];
|
|
2325
|
-
v = v.replace(/\.+$/, "");
|
|
2326
|
-
if (!v)
|
|
2327
|
-
return null;
|
|
2328
|
-
if (/\s/.test(v))
|
|
2329
|
-
return null;
|
|
2330
|
-
if (!v.includes("."))
|
|
2331
|
-
return null;
|
|
2332
|
-
if (v.startsWith(".") || v.endsWith("."))
|
|
2333
|
-
return null;
|
|
2334
|
-
const parts = v.split(".");
|
|
2335
|
-
if (parts.length < 2)
|
|
2336
|
-
return null;
|
|
2337
|
-
if (parts.some((p) => p.length === 0))
|
|
2338
|
-
return null;
|
|
2339
|
-
const tld = parts[parts.length - 1];
|
|
2340
|
-
if (!/^[a-z]{2,}$/.test(tld) && !tld.startsWith("xn--"))
|
|
2341
|
-
return null;
|
|
2342
|
-
if (!/^[a-z0-9-]+$/.test(parts[parts.length - 2]))
|
|
2343
|
-
return null;
|
|
2344
|
-
return v;
|
|
2345
|
-
}
|
|
2346
|
-
function escapeCsvCell(raw) {
|
|
2347
|
-
if (raw == null)
|
|
2348
|
-
return "";
|
|
2349
|
-
let s = String(raw);
|
|
2350
|
-
const trimmed = s.replace(/^[\s\r\n\t]+/, "");
|
|
2351
|
-
if (trimmed.length > 0) {
|
|
2352
|
-
const first = trimmed[0];
|
|
2353
|
-
if (first === "=" || first === "+" || first === "-" || first === "@") {
|
|
2354
|
-
s = "'" + s;
|
|
2355
|
-
}
|
|
2356
|
-
}
|
|
2357
|
-
if (/[",\n\r]/.test(s)) {
|
|
2358
|
-
s = '"' + s.replace(/"/g, '""') + '"';
|
|
2359
|
-
}
|
|
2360
|
-
return s;
|
|
2361
|
-
}
|
|
2362
|
-
function synthesizeCsv(header, rows) {
|
|
2363
|
-
const headerLine = header.map(escapeCsvCell).join(",");
|
|
2364
|
-
const dataLines = rows.map((r) => header.map((col) => escapeCsvCell(r[col] ?? "")).join(","));
|
|
2365
|
-
return [headerLine, ...dataLines].join("\n") + "\n";
|
|
2366
|
-
}
|
|
2367
|
-
function chunkAt100(items) {
|
|
2368
|
-
if (items.length === 0)
|
|
2369
|
-
return [];
|
|
2370
|
-
const chunks = [];
|
|
2371
|
-
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
|
|
2372
|
-
chunks.push(items.slice(i, i + CHUNK_SIZE));
|
|
2373
|
-
}
|
|
2374
|
-
return chunks;
|
|
2375
|
-
}
|
|
2376
|
-
function checkAborted(signal) {
|
|
2377
|
-
if (signal?.aborted) {
|
|
2378
|
-
throw Object.assign(new Error("aborted"), { name: "AbortError" });
|
|
2379
|
-
}
|
|
2380
|
-
}
|
|
2381
|
-
async function sleepWithAbort(ms, signal) {
|
|
2382
|
-
if (!signal) {
|
|
2383
|
-
await new Promise((r) => setTimeout(r, ms));
|
|
2384
|
-
return;
|
|
2385
|
-
}
|
|
2386
|
-
if (signal.aborted) {
|
|
2387
|
-
checkAborted(signal);
|
|
2388
|
-
}
|
|
2389
|
-
await new Promise((resolve, reject) => {
|
|
2390
|
-
const t = setTimeout(() => {
|
|
2391
|
-
signal.removeEventListener("abort", onAbort);
|
|
2392
|
-
resolve();
|
|
2393
|
-
}, ms);
|
|
2394
|
-
const onAbort = () => {
|
|
2395
|
-
clearTimeout(t);
|
|
2396
|
-
signal.removeEventListener("abort", onAbort);
|
|
2397
|
-
reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
|
|
2398
|
-
};
|
|
2399
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
2400
|
-
});
|
|
2401
|
-
}
|
|
2402
|
-
function readCell(record, key) {
|
|
2403
|
-
const want = key.toLowerCase();
|
|
2404
|
-
const arr = record.records;
|
|
2405
|
-
if (Array.isArray(arr)) {
|
|
2406
|
-
for (const c of arr) {
|
|
2407
|
-
const k = (c?.column_name ?? c?.key ?? c?.field ?? "").toString().toLowerCase();
|
|
2408
|
-
if (k === want) {
|
|
2409
|
-
const v = c?.value ?? null;
|
|
2410
|
-
return v != null ? String(v) : null;
|
|
2411
|
-
}
|
|
2412
|
-
}
|
|
2413
|
-
}
|
|
2414
|
-
const cells = record.cells;
|
|
2415
|
-
if (cells && typeof cells === "object" && !Array.isArray(cells)) {
|
|
2416
|
-
for (const [k, v] of Object.entries(cells)) {
|
|
2417
|
-
if (k.toLowerCase() === want) {
|
|
2418
|
-
return v != null ? String(v) : null;
|
|
2419
|
-
}
|
|
2420
|
-
}
|
|
2421
|
-
}
|
|
2422
|
-
if (Array.isArray(cells)) {
|
|
2423
|
-
for (const c of cells) {
|
|
2424
|
-
const k = (c?.key ?? c?.field ?? c?.column_name ?? "").toString().toLowerCase();
|
|
2425
|
-
if (k === want) {
|
|
2426
|
-
const v = c?.value ?? null;
|
|
2427
|
-
return v != null ? String(v) : null;
|
|
2428
|
-
}
|
|
2429
|
-
}
|
|
2430
|
-
}
|
|
2431
|
-
return null;
|
|
2432
|
-
}
|
|
2433
|
-
function validateColumnName(client, name, path) {
|
|
2434
|
-
if (typeof name !== "string" || name.length === 0) {
|
|
2435
|
-
throw client.makeError("IMPORT_INVALID_COLUMN_NAME", `Column name at ${path} must be a non-empty string`, `Use a plain string column name (1-${MAX_COLUMN_NAME_LEN} chars).`, "POST /imports");
|
|
2436
|
-
}
|
|
2437
|
-
if (name.length > MAX_COLUMN_NAME_LEN) {
|
|
2438
|
-
throw client.makeError("IMPORT_INVALID_COLUMN_NAME", `Column name at ${path} exceeds ${MAX_COLUMN_NAME_LEN} chars`, `Shorten the column name to ${MAX_COLUMN_NAME_LEN} chars or fewer.`, "POST /imports");
|
|
2439
|
-
}
|
|
2440
|
-
if (/[\x00-\x1F\x7F]/.test(name)) {
|
|
2441
|
-
throw client.makeError("IMPORT_INVALID_COLUMN_NAME", `Column name at ${path} contains control characters`, `Strip control characters (e.g. \\n, \\t, \\x00) from column names.`, "POST /imports");
|
|
3541
|
+
const responses = lastQual ?? [];
|
|
3542
|
+
const scores = responses.map((r) => r.score).filter((s) => s != null);
|
|
3543
|
+
const avg = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 10) / 10 : null;
|
|
3544
|
+
return {
|
|
3545
|
+
lead_id: leadId,
|
|
3546
|
+
qualification_summary: responses.length > 0 ? {
|
|
3547
|
+
answered: responses.filter((r) => r.score != null).length,
|
|
3548
|
+
total: responses.length,
|
|
3549
|
+
avg_qualification_boost: avg
|
|
3550
|
+
} : null,
|
|
3551
|
+
signals_count: lastWf?.content ? Object.values(lastWf.content).reduce((acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), 0) : null,
|
|
3552
|
+
_stillRunning: stillRunning
|
|
3553
|
+
};
|
|
3554
|
+
}));
|
|
3555
|
+
const qualified = results.filter((r) => !r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
|
|
3556
|
+
const still_running = results.filter((r) => r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
|
|
3557
|
+
return {
|
|
3558
|
+
qualified,
|
|
3559
|
+
still_running,
|
|
3560
|
+
failed,
|
|
3561
|
+
quota_exceeded: quotaExceeded,
|
|
3562
|
+
exhausted,
|
|
3563
|
+
total_unqualified_found: totalUnqualifiedFound,
|
|
3564
|
+
lens_id: lensId,
|
|
3565
|
+
_meta: { region: client.region }
|
|
3566
|
+
};
|
|
2442
3567
|
}
|
|
3568
|
+
};
|
|
3569
|
+
|
|
3570
|
+
// ../core/dist/composite/import-and-qualify.js
|
|
3571
|
+
function escapeCsv(v) {
|
|
3572
|
+
return escapeCsvCell(v);
|
|
2443
3573
|
}
|
|
2444
|
-
function
|
|
3574
|
+
function coerceCellToString(v) {
|
|
2445
3575
|
if (v == null)
|
|
2446
3576
|
return "";
|
|
2447
3577
|
if (typeof v === "string")
|
|
2448
3578
|
return v;
|
|
2449
3579
|
if (typeof v === "number" || typeof v === "boolean")
|
|
2450
3580
|
return String(v);
|
|
2451
|
-
|
|
2452
|
-
}
|
|
2453
|
-
function prepareDomainsMode(client, inputs) {
|
|
2454
|
-
const validInputs = [];
|
|
2455
|
-
const malformedDomains = [];
|
|
2456
|
-
const byDomain = /* @__PURE__ */ new Map();
|
|
2457
|
-
const byRowId = /* @__PURE__ */ new Map();
|
|
2458
|
-
for (const inp of inputs) {
|
|
2459
|
-
const norm = normalizeDomain(inp?.domain ?? "");
|
|
2460
|
-
if (!norm) {
|
|
2461
|
-
malformedDomains.push(inp?.domain ?? "");
|
|
2462
|
-
continue;
|
|
2463
|
-
}
|
|
2464
|
-
if (byDomain.has(norm))
|
|
2465
|
-
continue;
|
|
2466
|
-
const rowId = randomUUID();
|
|
2467
|
-
const idx = validInputs.length;
|
|
2468
|
-
const name = inp.name?.trim() || norm;
|
|
2469
|
-
validInputs.push({
|
|
2470
|
-
index: idx,
|
|
2471
|
-
rowId,
|
|
2472
|
-
row: { MCP_ROW_ID: rowId, LEAD_NAME: name, LEAD_WEBSITE: norm },
|
|
2473
|
-
domain: norm,
|
|
2474
|
-
outputDomain: norm
|
|
2475
|
-
});
|
|
2476
|
-
byDomain.set(norm, idx);
|
|
2477
|
-
byRowId.set(rowId, idx);
|
|
2478
|
-
}
|
|
2479
|
-
return {
|
|
2480
|
-
mode: "domains",
|
|
2481
|
-
validInputs,
|
|
2482
|
-
malformedDomains,
|
|
2483
|
-
byDomain,
|
|
2484
|
-
byRowId,
|
|
2485
|
-
header: ["MCP_ROW_ID", "LEAD_NAME", "LEAD_WEBSITE"],
|
|
2486
|
-
mappings: {
|
|
2487
|
-
fields: { LEAD_NAME: "LEAD_NAME", LEAD_WEBSITE: "LEAD_WEBSITE" },
|
|
2488
|
-
statuses: {},
|
|
2489
|
-
default_status: null
|
|
2490
|
-
}
|
|
2491
|
-
};
|
|
3581
|
+
return "";
|
|
2492
3582
|
}
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
for (const [colName] of fieldEntries) {
|
|
2506
|
-
validateColumnName(client, colName, `mappings.fields[${JSON.stringify(colName)}]`);
|
|
2507
|
-
if (RESERVED_COLUMN_RE.test(colName)) {
|
|
2508
|
-
throw client.makeError("IMPORT_RESERVED_COLUMN", `mappings.fields key '${colName}' collides with reserved synthetic column MCP_ROW_ID`, `Rename the column. MCP_ROW_ID (any case) is reserved for tool-internal reconciliation.`, "POST /imports");
|
|
2509
|
-
}
|
|
3583
|
+
var DEFAULT_PER_LEAD_BUDGET_MS2 = 9e4;
|
|
3584
|
+
var DEFAULT_TOTAL_BUDGET_MS3 = 15 * 6e4;
|
|
3585
|
+
var DEFAULT_PER_PHASE_BUDGET_MS2 = 5 * 6e4;
|
|
3586
|
+
function pickAdaptiveBudgets(inputSize) {
|
|
3587
|
+
if (inputSize <= 5) {
|
|
3588
|
+
return {
|
|
3589
|
+
per_lead_budget_ms: 6e4,
|
|
3590
|
+
total_budget_ms: 3 * 6e4,
|
|
3591
|
+
per_phase_budget_ms: 9e4,
|
|
3592
|
+
wall_clock_estimate_ms: Math.max(6e4, inputSize * 6e4),
|
|
3593
|
+
strategy: "small"
|
|
3594
|
+
};
|
|
2510
3595
|
}
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
validateColumnName(client, k, `records[${i}] key`);
|
|
2520
|
-
if (RESERVED_COLUMN_RE.test(k)) {
|
|
2521
|
-
throw client.makeError("IMPORT_RESERVED_COLUMN", `records[${i}] key '${k}' collides with reserved synthetic column MCP_ROW_ID`, `Rename the column in your records (any case variant of MCP_ROW_ID is reserved).`, "POST /imports");
|
|
2522
|
-
}
|
|
2523
|
-
out[k] = coerceCell(client, v, `records[${i}].${k}`);
|
|
2524
|
-
headerSet.add(k);
|
|
2525
|
-
}
|
|
2526
|
-
coercedRecords.push(out);
|
|
2527
|
-
});
|
|
2528
|
-
for (const [colName] of fieldEntries) {
|
|
2529
|
-
if (!headerSet.has(colName)) {
|
|
2530
|
-
throw client.makeError("IMPORT_MAPPING_KEY_UNKNOWN", `mappings.fields key '${colName}' is not present in any record`, `Add a value for '${colName}' to at least one record, or remove it from mappings.`, "POST /imports");
|
|
2531
|
-
}
|
|
3596
|
+
if (inputSize <= 20) {
|
|
3597
|
+
return {
|
|
3598
|
+
per_lead_budget_ms: 9e4,
|
|
3599
|
+
total_budget_ms: 10 * 6e4,
|
|
3600
|
+
per_phase_budget_ms: 3 * 6e4,
|
|
3601
|
+
wall_clock_estimate_ms: Math.min(10 * 6e4, inputSize * 6e4),
|
|
3602
|
+
strategy: "default"
|
|
3603
|
+
};
|
|
2532
3604
|
}
|
|
2533
|
-
const userKeys = [...headerSet].sort();
|
|
2534
|
-
const header = ["MCP_ROW_ID", ...userKeys];
|
|
2535
|
-
const websiteCol = fieldEntries.find(([, v]) => v === "LEAD_WEBSITE")?.[0];
|
|
2536
|
-
const validInputs = [];
|
|
2537
|
-
const byDomain = /* @__PURE__ */ new Map();
|
|
2538
|
-
const byRowId = /* @__PURE__ */ new Map();
|
|
2539
|
-
coercedRecords.forEach((row) => {
|
|
2540
|
-
const rowId = randomUUID();
|
|
2541
|
-
const idx = validInputs.length;
|
|
2542
|
-
let normDomain = null;
|
|
2543
|
-
if (websiteCol) {
|
|
2544
|
-
normDomain = normalizeDomain(row[websiteCol] ?? "");
|
|
2545
|
-
}
|
|
2546
|
-
const fullRow = { MCP_ROW_ID: rowId, ...row };
|
|
2547
|
-
validInputs.push({
|
|
2548
|
-
index: idx,
|
|
2549
|
-
rowId,
|
|
2550
|
-
row: fullRow,
|
|
2551
|
-
domain: normDomain,
|
|
2552
|
-
outputDomain: normDomain ?? void 0
|
|
2553
|
-
});
|
|
2554
|
-
byRowId.set(rowId, idx);
|
|
2555
|
-
if (normDomain && !byDomain.has(normDomain))
|
|
2556
|
-
byDomain.set(normDomain, idx);
|
|
2557
|
-
});
|
|
2558
3605
|
return {
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
mappings: {
|
|
2566
|
-
fields: { ...mappings.fields },
|
|
2567
|
-
statuses: mappings.statuses ?? {},
|
|
2568
|
-
default_status: mappings.default_status ?? null
|
|
2569
|
-
}
|
|
3606
|
+
per_lead_budget_ms: 12e4,
|
|
3607
|
+
total_budget_ms: 25 * 6e4,
|
|
3608
|
+
per_phase_budget_ms: 5 * 6e4,
|
|
3609
|
+
// For >20 leads, expect handle-mode return. Estimate is the clip.
|
|
3610
|
+
wall_clock_estimate_ms: 25 * 6e4,
|
|
3611
|
+
strategy: "large"
|
|
2570
3612
|
};
|
|
2571
3613
|
}
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
if (done(last))
|
|
2579
|
-
return last;
|
|
2580
|
-
if (Date.now() >= deadline) {
|
|
2581
|
-
ctx?.logger?.warn?.(`import-leads: ${label} budget exhausted (${budgetMs}ms)`);
|
|
2582
|
-
return last;
|
|
2583
|
-
}
|
|
2584
|
-
await sleepWithAbort(POLL_INTERVAL_MS, signal);
|
|
2585
|
-
}
|
|
2586
|
-
}
|
|
2587
|
-
async function pollPreprocess(client, importId, budgetMs, ctx, signal) {
|
|
2588
|
-
const result = await pollUntil(() => client.request("GET", `/imports/${importId}`), (r) => Boolean(r.pre_processing?.finished), budgetMs, signal, ctx, "preprocess");
|
|
2589
|
-
if (!result.pre_processing?.finished) {
|
|
2590
|
-
throw client.makeError("IMPORT_BUDGET_EXHAUSTED", `Preprocess phase did not finish within ${budgetMs}ms`, `Increase per_phase_budget_ms (current: ${budgetMs}) or split the batch. importId=${importId}.`, `GET /imports/${importId}`);
|
|
2591
|
-
}
|
|
2592
|
-
if (result.pre_processing.error) {
|
|
2593
|
-
throw client.makeError("IMPORT_PREPROCESS_FAILED", `Preprocess failed: ${result.pre_processing.error}`, `Check the input domains. importId=${importId} for backend debugging.`, `GET /imports/${importId}`);
|
|
2594
|
-
}
|
|
2595
|
-
return result;
|
|
2596
|
-
}
|
|
2597
|
-
async function pollProcess(client, importId, budgetMs, ctx, signal) {
|
|
2598
|
-
const result = await pollUntil(() => client.request("GET", `/imports/${importId}`), (r) => Boolean(r.processing?.finished), budgetMs, signal, ctx, "process");
|
|
2599
|
-
if (!result.processing?.finished) {
|
|
2600
|
-
throw client.makeError("IMPORT_BUDGET_EXHAUSTED", `Process phase did not finish within ${budgetMs}ms`, `Increase per_phase_budget_ms (current: ${budgetMs}) or split the batch. importId=${importId}.`, `GET /imports/${importId}`);
|
|
2601
|
-
}
|
|
2602
|
-
if (result.processing.error != null) {
|
|
2603
|
-
throw client.makeError("IMPORT_PROCESSING_FAILED", `Backend processing failed: ${result.processing.error}`, `importId=${importId}.`, `GET /imports/${importId}`);
|
|
2604
|
-
}
|
|
2605
|
-
return result;
|
|
2606
|
-
}
|
|
2607
|
-
async function pollRecordsToTerminal(client, importId, budgetMs, expectedRowCount, ctx, signal) {
|
|
2608
|
-
const deadline = Date.now() + budgetMs;
|
|
2609
|
-
const maxPagesPerPoll = Math.max(2, Math.ceil(expectedRowCount / 100) * 2 + 4);
|
|
2610
|
-
let stableCounts = 0;
|
|
2611
|
-
let lastSnapshot = null;
|
|
2612
|
-
while (true) {
|
|
2613
|
-
checkAborted(signal);
|
|
2614
|
-
let total = 0;
|
|
2615
|
-
let transient = 0;
|
|
2616
|
-
let pagesFetched = 0;
|
|
2617
|
-
let exhaustedPagination = false;
|
|
2618
|
-
const records = [];
|
|
2619
|
-
for (let page = 0; page < maxPagesPerPoll; page++) {
|
|
2620
|
-
checkAborted(signal);
|
|
2621
|
-
const qs = `count=100&page=${page}&automatic_match=true&manual_match=true&no_match=true&matching=true&importing=true&imported=true`;
|
|
2622
|
-
const res = await client.request("GET", `/imports/${importId}/records?${qs}`);
|
|
2623
|
-
pagesFetched++;
|
|
2624
|
-
records.push(...res.items);
|
|
2625
|
-
total = res.pagination.total ?? records.length;
|
|
2626
|
-
for (const r of res.items) {
|
|
2627
|
-
const status = (r.status ?? "").toString().toUpperCase();
|
|
2628
|
-
const matchType = (r.match_type ?? r.matchType ?? "").toString().toUpperCase();
|
|
2629
|
-
const isTerminal = matchType === "NO_MATCH" || status === "IMPORTED";
|
|
2630
|
-
if (!isTerminal)
|
|
2631
|
-
transient++;
|
|
2632
|
-
}
|
|
2633
|
-
const totalPages = res.pagination.pages ?? 0;
|
|
2634
|
-
if (page + 1 >= totalPages) {
|
|
2635
|
-
exhaustedPagination = true;
|
|
2636
|
-
break;
|
|
2637
|
-
}
|
|
2638
|
-
}
|
|
2639
|
-
if (!exhaustedPagination) {
|
|
2640
|
-
throw client.makeError("IMPORT_PAGINATION_RUNAWAY", `Records pagination exceeded ${maxPagesPerPoll} pages`, `importId=${importId}. Please file a bug at https://github.com/leadbay/leadclaw/issues.`, `GET /imports/${importId}/records`);
|
|
2641
|
-
}
|
|
2642
|
-
const snapshot = { total, transient };
|
|
2643
|
-
const settled = transient === 0;
|
|
2644
|
-
const stableVsLast = lastSnapshot != null && lastSnapshot.total === snapshot.total && lastSnapshot.transient === snapshot.transient;
|
|
2645
|
-
if (settled && stableVsLast) {
|
|
2646
|
-
stableCounts++;
|
|
2647
|
-
} else if (settled) {
|
|
2648
|
-
stableCounts = 1;
|
|
2649
|
-
} else {
|
|
2650
|
-
stableCounts = 0;
|
|
2651
|
-
}
|
|
2652
|
-
lastSnapshot = snapshot;
|
|
2653
|
-
if (settled && stableCounts >= STABILIZATION_POLLS) {
|
|
2654
|
-
return records;
|
|
2655
|
-
}
|
|
2656
|
-
if (Date.now() >= deadline) {
|
|
2657
|
-
ctx?.logger?.warn?.(`import-leads: records did not stabilize (transient=${transient}, total=${total}); returning best-effort`);
|
|
2658
|
-
throw client.makeError("IMPORT_NOT_TERMINAL", `Backend hasn't fully settled records within ${budgetMs}ms`, `Retry leadbay_import_leads with the same input in 30s, or split the batch. importId=${importId}.`, `GET /imports/${importId}/records`);
|
|
2659
|
-
}
|
|
2660
|
-
await sleepWithAbort(POLL_INTERVAL_MS, signal);
|
|
2661
|
-
}
|
|
3614
|
+
function inputSizeOf(params) {
|
|
3615
|
+
if (Array.isArray(params.domains))
|
|
3616
|
+
return params.domains.length;
|
|
3617
|
+
if (Array.isArray(params.records))
|
|
3618
|
+
return params.records.length;
|
|
3619
|
+
return 0;
|
|
2662
3620
|
}
|
|
2663
|
-
|
|
2664
|
-
const
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
onImportId(importId);
|
|
2671
|
-
const phaseBudget = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
|
|
2672
|
-
await pollPreprocess(client, importId, phaseBudget, ctx, signal);
|
|
2673
|
-
ctx?.logger?.info?.(`import-leads: preprocess done for importId=${importId}`);
|
|
2674
|
-
if (dryRun) {
|
|
2675
|
-
return { importId, records: [] };
|
|
2676
|
-
}
|
|
2677
|
-
await client.requestVoid("POST", `/imports/${importId}/update_mappings`, mappings);
|
|
2678
|
-
ctx?.logger?.info?.(`import-leads: mappings committed for importId=${importId}`);
|
|
2679
|
-
const phaseBudget2 = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
|
|
2680
|
-
await pollProcess(client, importId, phaseBudget2, ctx, signal);
|
|
2681
|
-
ctx?.logger?.info?.(`import-leads: process done for importId=${importId}`);
|
|
2682
|
-
const phaseBudget3 = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
|
|
2683
|
-
const records = await pollRecordsToTerminal(client, importId, phaseBudget3, chunk.length, ctx, signal);
|
|
2684
|
-
ctx?.logger?.info?.(`import-leads: ${records.length} records terminal for importId=${importId}`);
|
|
2685
|
-
return { importId, records };
|
|
3621
|
+
function toNotImportedEntry(n) {
|
|
3622
|
+
const out = { reason: n.reason };
|
|
3623
|
+
if (n.rowId !== void 0)
|
|
3624
|
+
out.rowId = n.rowId;
|
|
3625
|
+
if (n.domain !== void 0)
|
|
3626
|
+
out.domain = n.domain;
|
|
3627
|
+
return out;
|
|
2686
3628
|
}
|
|
2687
|
-
function
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
let inputIdx;
|
|
2696
|
-
const rowIdCell = readCell(rec, "MCP_ROW_ID");
|
|
2697
|
-
if (rowIdCell && prep.byRowId.has(rowIdCell)) {
|
|
2698
|
-
inputIdx = prep.byRowId.get(rowIdCell);
|
|
2699
|
-
}
|
|
2700
|
-
if (inputIdx === void 0) {
|
|
2701
|
-
const websiteCell = readCell(rec, "LEAD_WEBSITE");
|
|
2702
|
-
if (websiteCell) {
|
|
2703
|
-
const norm = normalizeDomain(websiteCell);
|
|
2704
|
-
if (norm && prep.byDomain.has(norm)) {
|
|
2705
|
-
inputIdx = prep.byDomain.get(norm);
|
|
2706
|
-
}
|
|
2707
|
-
}
|
|
2708
|
-
}
|
|
2709
|
-
if (inputIdx === void 0 && rec.lead?.website) {
|
|
2710
|
-
const norm = normalizeDomain(rec.lead.website);
|
|
2711
|
-
if (norm && prep.byDomain.has(norm)) {
|
|
2712
|
-
inputIdx = prep.byDomain.get(norm);
|
|
2713
|
-
}
|
|
2714
|
-
}
|
|
2715
|
-
if (inputIdx === void 0)
|
|
2716
|
-
continue;
|
|
2717
|
-
if (seenInputIndex.has(inputIdx)) {
|
|
2718
|
-
if (!matched.has(inputIdx) && !notImported.has(inputIdx)) {
|
|
2719
|
-
const inp2 = prep.validInputs[inputIdx];
|
|
2720
|
-
notImported.set(inputIdx, { domain: inp2.outputDomain, reason: "ambiguous" });
|
|
2721
|
-
}
|
|
2722
|
-
continue;
|
|
3629
|
+
function buildFingerprintInput(mappings) {
|
|
3630
|
+
if (!mappings)
|
|
3631
|
+
return { LEAD_NAME: "LEAD_NAME", LEAD_WEBSITE: "LEAD_WEBSITE" };
|
|
3632
|
+
const out = {};
|
|
3633
|
+
const fields = mappings.fields;
|
|
3634
|
+
if (fields && typeof fields === "object") {
|
|
3635
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
3636
|
+
out[k] = String(v);
|
|
2723
3637
|
}
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
domain: inp.outputDomain,
|
|
2730
|
-
leadId: rec.lead.id,
|
|
2731
|
-
name: rec.lead.name ?? null
|
|
2732
|
-
});
|
|
2733
|
-
} else if (matchType === "NO_MATCH") {
|
|
2734
|
-
const reason = inp.domain && PUBLIC_MAILBOX_DOMAINS.has(inp.domain) ? "no_match" : "uncrawled";
|
|
2735
|
-
notImported.set(inputIdx, { domain: inp.outputDomain, reason });
|
|
2736
|
-
} else {
|
|
2737
|
-
notImported.set(inputIdx, { domain: inp.outputDomain, reason: "internal_error" });
|
|
3638
|
+
}
|
|
3639
|
+
const cf = mappings.custom_fields;
|
|
3640
|
+
if (cf && typeof cf === "object") {
|
|
3641
|
+
for (const [k, v] of Object.entries(cf)) {
|
|
3642
|
+
out[`__cf__${k}`] = String(v);
|
|
2738
3643
|
}
|
|
2739
3644
|
}
|
|
3645
|
+
return out;
|
|
2740
3646
|
}
|
|
2741
|
-
var
|
|
2742
|
-
name: "
|
|
2743
|
-
description:
|
|
3647
|
+
var importAndQualify = {
|
|
3648
|
+
name: "leadbay_import_and_qualify",
|
|
3649
|
+
description: `Composite: import a list of leads (CSV-shaped records OR a list of domains), then trigger Leadbay's AI qualification (web research + per-question scoring) on every imported leadId, and return both the import outcome and the per-lead qualification answers \u2014 in one call. Honours a total wall-clock budget; when the budget is exhausted before all leads finish qualifying, returns a \`qualify_id\` UUID handle that survives MCP restart and can be passed to leadbay_qualify_status to retrieve the rest of the answers later.
|
|
3650
|
+
|
|
3651
|
+
Inputs:
|
|
3652
|
+
- \`domains\`: list of \`{domain, name?}\` (Mode A) \u2014 mutually exclusive with \`records\`.
|
|
3653
|
+
- \`records\`: list of CSV-shaped objects (Mode B), accompanied by \`mappings\`. Use \`mappings.fields\` with StandardCrmFieldType names or 'CUSTOM.<id>' wire values; or \`mappings.custom_fields\` with field id or name shorthand. Discover the org's mappable surface via leadbay_list_mappable_fields.
|
|
3654
|
+
- Budgets: \`total_budget_ms\` (default ${DEFAULT_TOTAL_BUDGET_MS3 / 6e4} min) caps the entire wall-clock; \`per_lead_budget_ms\` (default ${DEFAULT_PER_LEAD_BUDGET_MS2 / 1e3}s) caps each lead's individual qualification poll.
|
|
3655
|
+
|
|
3656
|
+
Outputs include \`qualified[]\` (per-lead question answers), \`still_running[]\` (lead ids whose qualification exceeded the budget), \`not_imported[]\` (rows the wizard couldn't match), and \`qualify_id\` (the resumable handle when at least one lead is still running). Idempotent within a 5-min window: re-calling with the same records+mapping returns the same qualify_id (\`reused: true\`). The result has a \`kind\` discriminator (\`'result' | 'preview'\`); preview-mode (\`dry_run: 'preview'\`) returns mapping hints + custom-field candidates instead of importing. Pass \`dry_run: true\` for input-validation only (top-level \`dry_run: true\` appears in the result so the agent can distinguish from all-malformed input).
|
|
3657
|
+
|
|
3658
|
+
When to use: the agent has a list of companies (domains, or CSV-shaped rows from the user's CRM) and wants Leadbay's full AI qualification \u2014 qualification answers, web-research signals \u2014 without orchestrating import + bulk_qualify_leads + lead_profile chains by hand.
|
|
3659
|
+
When NOT to use: discovery (use leadbay_pull_leads); single-lead deep dive (use leadbay_research_lead); high-cadence or untrusted automation \u2014 this mutates user state by creating CRM-import rows and consumes ai_rescore + web_fetch quota.
|
|
3660
|
+
|
|
3661
|
+
\u26A0\uFE0F MUTATES USER STATE. Each call:
|
|
3662
|
+
- creates a CRM-imports row (visible in the web UI)
|
|
3663
|
+
- touches onboarding state
|
|
3664
|
+
- launches up to N\xD7ai_rescore + N\xD7web_fetch quota where N = imported lead count (unless \`skip_already_qualified: true\` (default) excludes already-scored leads)
|
|
3665
|
+
|
|
3666
|
+
\u2139\uFE0F Monitor-tab membership: imported leads are NOT auto-promoted to the user's Monitor view. The lens-scoring rule decides \u2014 only leads that score above the lens threshold get \`in_monitor: true\` server-side. Lower-scoring imports stay invisible to the Monitor tab. Tell the user this if they ask 'where did my import go?' \u2014 answer is the CRM-imports list, not Monitor.
|
|
3667
|
+
|
|
3668
|
+
\u2139\uFE0F In-lens-but-unscored leads: a lead may be admitted to the active lens (lens GET returns 200) but the lens-scoring job may not materialize for it (qualification never starts). The composite today surfaces hard-rejected leads (404 from lens GET) in \`not_in_lens[]\`, but in-lens-unscored leads currently sit in \`still_running[]\` \u2014 there's no per-lead 'scoring queued vs won't_compute' signal from the backend yet (tracked: leadbay/product#3571). If still_running stays non-empty for a lead more than 5 minutes after a successful import, suggest the user verify the lens covers that lead's profile (e.g., sector match) \u2014 qualify_status's poll won't ever produce answers.
|
|
3669
|
+
|
|
3670
|
+
Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role; active billing.`,
|
|
2744
3671
|
write: true,
|
|
2745
3672
|
version: "0.2.0",
|
|
2746
3673
|
inputSchema: {
|
|
2747
3674
|
type: "object",
|
|
2748
3675
|
properties: {
|
|
3676
|
+
// Pass exactly one of `domains` or `records`. Schema doesn't enforce
|
|
3677
|
+
// XOR (JSON Schema 7 has limited oneOf support); execute() validates.
|
|
2749
3678
|
domains: {
|
|
2750
3679
|
type: "array",
|
|
2751
3680
|
description: "Mode A: list of company domains to map to Leadbay leadIds. Mutually exclusive with `records`.",
|
|
@@ -2766,425 +3695,341 @@ var importLeads = {
|
|
|
2766
3695
|
},
|
|
2767
3696
|
records: {
|
|
2768
3697
|
type: "array",
|
|
2769
|
-
description: "Mode B: arbitrary CSV-shaped rows. Each record is an object whose keys are column names and values are scalar (string/number/boolean/null). Mutually exclusive with `domains`.
|
|
3698
|
+
description: "Mode B: arbitrary CSV-shaped rows. Each record is an object whose keys are column names and values are scalar (string/number/boolean/null). Mutually exclusive with `domains`. Accompany with `mappings` UNLESS using `dry_run: 'preview'` \u2014 preview returns the wizard's mapping suggestions without requiring a mapping up front.",
|
|
2770
3699
|
items: { type: "object" }
|
|
2771
3700
|
},
|
|
2772
3701
|
mappings: {
|
|
2773
3702
|
type: "object",
|
|
2774
|
-
description: "
|
|
3703
|
+
description: "How each CSV column maps to Leadbay's CRM field schema. Required for records-mode WITHOUT `dry_run: 'preview'`; ignored otherwise. The `fields` sub-property is REQUIRED only when at least one column maps to a StandardCrmFieldType target; pure custom-field mappings can be supplied via `custom_fields` shorthand alone.",
|
|
2775
3704
|
properties: {
|
|
2776
3705
|
fields: {
|
|
2777
3706
|
type: "object",
|
|
2778
|
-
description: "Object whose keys are CSV column names
|
|
3707
|
+
description: "Object whose keys are CSV column names and whose values are either StandardCrmFieldType (LEAD_NAME, LEAD_WEBSITE, ..., CONTACT_TITLE) or 'CUSTOM.<id>'. Discover via leadbay_list_mappable_fields. At least one entry must target LEAD_NAME or LEAD_WEBSITE."
|
|
2779
3708
|
},
|
|
2780
|
-
|
|
3709
|
+
custom_fields: {
|
|
2781
3710
|
type: "object",
|
|
2782
|
-
description: "
|
|
3711
|
+
description: "Ergonomic shorthand: `{CsvColumn: <number-id>}` or `{CsvColumn: '<field-name>'}` for custom-field mappings. Resolved against /crm/custom_fields catalog."
|
|
2783
3712
|
},
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
}
|
|
2788
|
-
},
|
|
2789
|
-
required: ["fields"]
|
|
2790
|
-
},
|
|
2791
|
-
dry_run: {
|
|
2792
|
-
type: "boolean",
|
|
2793
|
-
description: "If true, run preprocess only \u2014 do NOT commit lead-CRM linking. Note: an import row still appears in the user's CRM-imports list as 'incomplete'. Use to verify input format / wizard reachability without polluting the CRM."
|
|
3713
|
+
statuses: { type: "object", description: "Optional status string mapping." },
|
|
3714
|
+
default_status: { type: ["string", "null"], description: "Optional default status." }
|
|
3715
|
+
}
|
|
2794
3716
|
},
|
|
2795
|
-
|
|
3717
|
+
per_lead_budget_ms: {
|
|
2796
3718
|
type: "number",
|
|
2797
|
-
description: `
|
|
3719
|
+
description: `Polling budget per lead in ms (default ${DEFAULT_PER_LEAD_BUDGET_MS2}).`
|
|
2798
3720
|
},
|
|
2799
3721
|
total_budget_ms: {
|
|
2800
3722
|
type: "number",
|
|
2801
|
-
description: `
|
|
2802
|
-
}
|
|
2803
|
-
}
|
|
2804
|
-
// Neither field is "required" at the schema level; xor + presence is
|
|
2805
|
-
// enforced in execute() so we can produce specific error codes.
|
|
2806
|
-
},
|
|
2807
|
-
execute: async (client, params, ctx) => {
|
|
2808
|
-
const signal = ctx?.signal;
|
|
2809
|
-
const dryRun = Boolean(params.dry_run);
|
|
2810
|
-
const perPhaseBudget = params.per_phase_budget_ms ?? DEFAULT_PER_PHASE_BUDGET_MS;
|
|
2811
|
-
const totalBudget = params.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS2;
|
|
2812
|
-
const totalDeadline = Date.now() + totalBudget;
|
|
2813
|
-
const hasDomains = Array.isArray(params.domains) && params.domains.length > 0;
|
|
2814
|
-
const hasRecords = Array.isArray(params.records) && params.records.length > 0;
|
|
2815
|
-
if (hasDomains && hasRecords) {
|
|
2816
|
-
throw client.makeError("IMPORT_INPUT_CONFLICT", "Pass exactly one of `domains` or `records`, not both", "Use `domains` for the simple shortcut, or `records`+`mappings` for arbitrary CSV input.", "POST /imports");
|
|
2817
|
-
}
|
|
2818
|
-
if (!hasDomains && !hasRecords) {
|
|
2819
|
-
throw client.makeError("IMPORT_EMPTY_INPUT", "domains[] or records[] must contain at least one entry", "Pass at least one entry. See the tool description for the two input modes.", "POST /imports");
|
|
2820
|
-
}
|
|
2821
|
-
const me = await client.resolveMe();
|
|
2822
|
-
if (!me.admin) {
|
|
2823
|
-
throw client.makeError("IMPORT_ADMIN_REQUIRED", "This tool requires admin role on the Leadbay account", "Ask the account owner to grant import permission, or use a token from an admin user.", "POST /imports");
|
|
2824
|
-
}
|
|
2825
|
-
const prep = hasDomains ? prepareDomainsMode(client, params.domains) : prepareRecordsMode(client, params.records, params.mappings);
|
|
2826
|
-
if (prep.validInputs.length === 0) {
|
|
2827
|
-
const not_imported2 = prep.malformedDomains.map((d) => ({
|
|
2828
|
-
domain: d,
|
|
2829
|
-
reason: "malformed"
|
|
2830
|
-
}));
|
|
2831
|
-
return {
|
|
2832
|
-
leads: [],
|
|
2833
|
-
not_imported: not_imported2,
|
|
2834
|
-
importIds: [],
|
|
2835
|
-
region: client.region,
|
|
2836
|
-
dry_run: dryRun || void 0,
|
|
2837
|
-
_meta: client.lastMeta ?? {
|
|
2838
|
-
region: client.region,
|
|
2839
|
-
endpoint: "POST /imports",
|
|
2840
|
-
latency_ms: null,
|
|
2841
|
-
retry_after: null
|
|
2842
|
-
}
|
|
2843
|
-
};
|
|
2844
|
-
}
|
|
2845
|
-
const chunks = chunkAt100(prep.validInputs);
|
|
2846
|
-
ctx?.logger?.info?.(`import-leads(${prep.mode}): ${prep.validInputs.length} rows \u2192 ${chunks.length} chunk(s); dry_run=${dryRun}, totalBudgetMs=${totalBudget}`);
|
|
2847
|
-
const importIds = [];
|
|
2848
|
-
const matched = /* @__PURE__ */ new Map();
|
|
2849
|
-
const notImported = /* @__PURE__ */ new Map();
|
|
2850
|
-
let cancelled = false;
|
|
2851
|
-
const recordImportId = (id) => {
|
|
2852
|
-
if (!importIds.includes(id))
|
|
2853
|
-
importIds.push(id);
|
|
2854
|
-
};
|
|
2855
|
-
try {
|
|
2856
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
2857
|
-
const chunk = chunks[i];
|
|
2858
|
-
const out = await runOneChunk(client, chunk, i, chunks.length, prep.header, prep.mappings, dryRun, perPhaseBudget, totalDeadline, ctx, signal, recordImportId);
|
|
2859
|
-
if (!dryRun) {
|
|
2860
|
-
reconcileOneChunk(prep, out, matched, notImported);
|
|
2861
|
-
}
|
|
2862
|
-
}
|
|
2863
|
-
} catch (err) {
|
|
2864
|
-
if (err?.name === "AbortError") {
|
|
2865
|
-
cancelled = true;
|
|
2866
|
-
ctx?.logger?.info?.(`import-leads: aborted via signal; importIds=${importIds.join(",")}`);
|
|
2867
|
-
} else if (err?.error === true) {
|
|
2868
|
-
if (err.code === "FORBIDDEN") {
|
|
2869
|
-
throw client.makeError("IMPORT_ADMIN_REQUIRED", err.message || "Insufficient permissions for /imports", "This tool requires admin role on the Leadbay account. Ask the account owner.", err._meta?.endpoint);
|
|
2870
|
-
}
|
|
2871
|
-
if (err.code === "BILLING_SUSPENDED") {
|
|
2872
|
-
throw client.makeError("IMPORT_BILLING_REQUIRED", err.message || "Active billing required for imports", "Upgrade at https://app.leadbay.ai/billing, then retry.", err._meta?.endpoint);
|
|
2873
|
-
}
|
|
2874
|
-
throw err;
|
|
2875
|
-
} else {
|
|
2876
|
-
throw err;
|
|
2877
|
-
}
|
|
2878
|
-
}
|
|
2879
|
-
const leads = [];
|
|
2880
|
-
const not_imported = [];
|
|
2881
|
-
if (dryRun) {
|
|
2882
|
-
for (const inp of prep.validInputs) {
|
|
2883
|
-
if (prep.mode === "domains") {
|
|
2884
|
-
not_imported.push({ domain: inp.outputDomain, reason: "dry_run" });
|
|
2885
|
-
} else {
|
|
2886
|
-
const entry = { rowId: inp.rowId, reason: "dry_run" };
|
|
2887
|
-
if (inp.outputDomain)
|
|
2888
|
-
entry.domain = inp.outputDomain;
|
|
2889
|
-
not_imported.push(entry);
|
|
2890
|
-
}
|
|
2891
|
-
}
|
|
2892
|
-
} else {
|
|
2893
|
-
for (const inp of prep.validInputs) {
|
|
2894
|
-
const m = matched.get(inp.index);
|
|
2895
|
-
if (m) {
|
|
2896
|
-
if (prep.mode === "domains") {
|
|
2897
|
-
leads.push({
|
|
2898
|
-
domain: inp.outputDomain,
|
|
2899
|
-
leadId: m.leadId,
|
|
2900
|
-
name: m.name
|
|
2901
|
-
});
|
|
2902
|
-
} else {
|
|
2903
|
-
const e = {
|
|
2904
|
-
rowId: inp.rowId,
|
|
2905
|
-
leadId: m.leadId,
|
|
2906
|
-
name: m.name
|
|
2907
|
-
};
|
|
2908
|
-
if (m.domain ?? inp.outputDomain)
|
|
2909
|
-
e.domain = m.domain ?? inp.outputDomain;
|
|
2910
|
-
leads.push(e);
|
|
2911
|
-
}
|
|
2912
|
-
continue;
|
|
2913
|
-
}
|
|
2914
|
-
const ni = notImported.get(inp.index);
|
|
2915
|
-
if (ni) {
|
|
2916
|
-
if (prep.mode === "domains") {
|
|
2917
|
-
not_imported.push({ domain: inp.outputDomain, reason: ni.reason });
|
|
2918
|
-
} else {
|
|
2919
|
-
const e = { rowId: inp.rowId, reason: ni.reason };
|
|
2920
|
-
if (ni.domain ?? inp.outputDomain)
|
|
2921
|
-
e.domain = ni.domain ?? inp.outputDomain;
|
|
2922
|
-
not_imported.push(e);
|
|
2923
|
-
}
|
|
2924
|
-
continue;
|
|
2925
|
-
}
|
|
2926
|
-
if (prep.mode === "domains") {
|
|
2927
|
-
not_imported.push({ domain: inp.outputDomain, reason: "internal_error" });
|
|
2928
|
-
} else {
|
|
2929
|
-
const e = { rowId: inp.rowId, reason: "internal_error" };
|
|
2930
|
-
if (inp.outputDomain)
|
|
2931
|
-
e.domain = inp.outputDomain;
|
|
2932
|
-
not_imported.push(e);
|
|
2933
|
-
}
|
|
2934
|
-
}
|
|
2935
|
-
}
|
|
2936
|
-
for (const m of prep.malformedDomains) {
|
|
2937
|
-
not_imported.push({ domain: m, reason: "malformed" });
|
|
2938
|
-
}
|
|
2939
|
-
return {
|
|
2940
|
-
leads,
|
|
2941
|
-
not_imported,
|
|
2942
|
-
importIds,
|
|
2943
|
-
region: client.region,
|
|
2944
|
-
cancelled: cancelled || void 0,
|
|
2945
|
-
dry_run: dryRun || void 0,
|
|
2946
|
-
_meta: client.lastMeta ?? {
|
|
2947
|
-
region: client.region,
|
|
2948
|
-
endpoint: "POST /imports",
|
|
2949
|
-
latency_ms: null,
|
|
2950
|
-
retry_after: null
|
|
2951
|
-
}
|
|
2952
|
-
};
|
|
2953
|
-
}
|
|
2954
|
-
};
|
|
2955
|
-
|
|
2956
|
-
// ../core/dist/composite/enrich-titles.js
|
|
2957
|
-
var DEFAULT_CANDIDATE_COUNT = 25;
|
|
2958
|
-
var enrichTitles = {
|
|
2959
|
-
name: "leadbay_enrich_titles",
|
|
2960
|
-
description: "Order contact enrichments by job title across many leads. Contacts are NOT returned by default with a lead (Leadbay keeps enrichment out-of-band to control cost); the agent requests them on demand via this tool when it's ready to actually reach out. Two modes: (A) NO titles param \u2014 returns the available titles + Leadbay's title_suggestions + auto_included_titles + a count of enrichable contacts, so the agent can ask the user which titles to enrich. (B) titles given \u2014 calls preview, then launches if there's anything enrichable. On 429 returns {status:'quota_exceeded'} cleanly. Selection lifecycle is wrapped in a try/finally so the user's selection is left clean even on error. When to use: as the agent's go-to enrichment entry point, immediately before proposing outreach. When NOT to use: to enrich a single contact \u2014 that's leadbay_enrich_contacts (granular). Speculatively, before the user has committed to outreaching \u2014 enrichment spends credits.",
|
|
2961
|
-
inputSchema: {
|
|
2962
|
-
type: "object",
|
|
2963
|
-
properties: {
|
|
2964
|
-
titles: {
|
|
2965
|
-
type: "array",
|
|
2966
|
-
items: { type: "string" },
|
|
2967
|
-
description: "Job titles to enrich. Omit to discover what's available without launching."
|
|
2968
|
-
},
|
|
2969
|
-
leadIds: {
|
|
2970
|
-
type: "array",
|
|
2971
|
-
items: { type: "string" },
|
|
2972
|
-
description: "Lead UUIDs to enrich. Omit to use the top page of the active lens's wishlist."
|
|
3723
|
+
description: `Total wall-clock budget across import + qualify in ms (default ${DEFAULT_TOTAL_BUDGET_MS3}). When exhausted, the response returns qualify_id for resume via leadbay_qualify_status.`
|
|
2973
3724
|
},
|
|
2974
|
-
|
|
3725
|
+
per_phase_budget_ms: {
|
|
2975
3726
|
type: "number",
|
|
2976
|
-
description:
|
|
3727
|
+
description: `Per-phase budget for the import wizard (default ${DEFAULT_PER_PHASE_BUDGET_MS2}); mirrors leadbay_import_leads.`
|
|
2977
3728
|
},
|
|
2978
|
-
|
|
2979
|
-
phone: { type: "boolean", description: "Enrich phone numbers (default false)" },
|
|
2980
|
-
candidateCount: {
|
|
3729
|
+
lensId: {
|
|
2981
3730
|
type: "number",
|
|
2982
|
-
description:
|
|
3731
|
+
description: "Lens id (escape hatch \u2014 defaults to active)."
|
|
2983
3732
|
},
|
|
2984
3733
|
dry_run: {
|
|
3734
|
+
description: "Optional. `true` runs preprocess only (no commit, no qualify). `'preview'` runs preprocess and returns the wizard's per-column AI mapping hints + sample rows + custom-field candidates from the org catalog so the agent can choose a mapping. Default: false (full flow)."
|
|
3735
|
+
},
|
|
3736
|
+
skip_already_qualified: {
|
|
2985
3737
|
type: "boolean",
|
|
2986
|
-
description: "
|
|
3738
|
+
description: "When true (default), skips web_fetch launch on leads whose ai_agent_lead_score is already non-null. Saves quota. Set false to force fresh re-qualification."
|
|
2987
3739
|
}
|
|
2988
3740
|
}
|
|
2989
3741
|
},
|
|
2990
3742
|
execute: async (client, params, ctx) => {
|
|
2991
|
-
const
|
|
2992
|
-
|
|
2993
|
-
if (
|
|
3743
|
+
const signal = ctx?.signal;
|
|
3744
|
+
let chosenBudgets;
|
|
3745
|
+
if (params.dry_run !== "preview" && params.per_lead_budget_ms === void 0 && params.total_budget_ms === void 0 && params.per_phase_budget_ms === void 0) {
|
|
3746
|
+
chosenBudgets = pickAdaptiveBudgets(inputSizeOf(params));
|
|
3747
|
+
}
|
|
3748
|
+
const perLeadBudget = params.per_lead_budget_ms ?? chosenBudgets?.per_lead_budget_ms ?? DEFAULT_PER_LEAD_BUDGET_MS2;
|
|
3749
|
+
const totalBudget = params.total_budget_ms ?? chosenBudgets?.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS3;
|
|
3750
|
+
const perPhaseBudget = params.per_phase_budget_ms ?? chosenBudgets?.per_phase_budget_ms ?? DEFAULT_PER_PHASE_BUDGET_MS2;
|
|
3751
|
+
const totalDeadline = Date.now() + totalBudget;
|
|
3752
|
+
const skipAlreadyQualified = params.skip_already_qualified ?? true;
|
|
3753
|
+
if (params.dry_run === "preview") {
|
|
3754
|
+
return await runPreview(client, params, ctx, perPhaseBudget, totalBudget);
|
|
3755
|
+
}
|
|
3756
|
+
if (!ctx?.bulkTracker) {
|
|
3757
|
+
throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_and_qualify needs a BulkTracker (qualify_id persistence). Upgrade to @leadbay/mcp \u22650.5.0 or set LEADBAY_BULK_STORE_ALLOW_MEMORY=1.", "");
|
|
3758
|
+
}
|
|
3759
|
+
const importResult = await importLeads.execute(client, {
|
|
3760
|
+
domains: params.domains,
|
|
3761
|
+
records: params.records,
|
|
3762
|
+
mappings: params.mappings,
|
|
3763
|
+
per_phase_budget_ms: perPhaseBudget,
|
|
3764
|
+
total_budget_ms: totalBudget,
|
|
3765
|
+
...params.dry_run === true ? { dry_run: true } : {}
|
|
3766
|
+
}, ctx);
|
|
3767
|
+
if (importResult.cancelled) {
|
|
2994
3768
|
return {
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
3769
|
+
kind: "result",
|
|
3770
|
+
...params.dry_run === true ? { dry_run: true } : {},
|
|
3771
|
+
...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
|
|
3772
|
+
qualify_id: null,
|
|
3773
|
+
import_ids: importResult.importIds,
|
|
3774
|
+
imported: [],
|
|
3775
|
+
not_imported: importResult.not_imported.map(toNotImportedEntry),
|
|
3776
|
+
qualified: [],
|
|
3777
|
+
still_running: [],
|
|
3778
|
+
failed: [],
|
|
3779
|
+
quota_exceeded: false,
|
|
3780
|
+
skipped_already_qualified: [],
|
|
3781
|
+
not_in_lens: [],
|
|
3782
|
+
cancelled: true,
|
|
3783
|
+
region: client.region,
|
|
3784
|
+
_meta: client.lastMeta ?? {
|
|
3785
|
+
region: client.region,
|
|
3786
|
+
endpoint: "POST /imports",
|
|
3787
|
+
latency_ms: null,
|
|
3788
|
+
retry_after: null
|
|
3789
|
+
}
|
|
2999
3790
|
};
|
|
3000
3791
|
}
|
|
3001
|
-
const
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3792
|
+
const imported = importResult.leads.map((l) => {
|
|
3793
|
+
const out = {
|
|
3794
|
+
leadId: l.leadId,
|
|
3795
|
+
name: l.name
|
|
3796
|
+
};
|
|
3797
|
+
if (l.domain)
|
|
3798
|
+
out.domain = l.domain;
|
|
3799
|
+
if (l.rowId)
|
|
3800
|
+
out.rowId = l.rowId;
|
|
3801
|
+
return out;
|
|
3802
|
+
});
|
|
3803
|
+
const not_imported = importResult.not_imported.map(toNotImportedEntry);
|
|
3804
|
+
if (imported.length === 0) {
|
|
3011
3805
|
return {
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3806
|
+
kind: "result",
|
|
3807
|
+
...params.dry_run === true ? { dry_run: true } : {},
|
|
3808
|
+
...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
|
|
3809
|
+
qualify_id: null,
|
|
3810
|
+
import_ids: importResult.importIds,
|
|
3811
|
+
imported,
|
|
3812
|
+
not_imported,
|
|
3813
|
+
qualified: [],
|
|
3814
|
+
still_running: [],
|
|
3815
|
+
failed: [],
|
|
3816
|
+
quota_exceeded: false,
|
|
3817
|
+
skipped_already_qualified: [],
|
|
3818
|
+
not_in_lens: [],
|
|
3819
|
+
region: client.region,
|
|
3820
|
+
_meta: client.lastMeta ?? {
|
|
3821
|
+
region: client.region,
|
|
3822
|
+
endpoint: "POST /imports",
|
|
3823
|
+
latency_ms: null,
|
|
3824
|
+
retry_after: null
|
|
3825
|
+
}
|
|
3016
3826
|
};
|
|
3017
3827
|
}
|
|
3018
|
-
await client.
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3828
|
+
const lensId = params.lensId ?? await client.resolveDefaultLens();
|
|
3829
|
+
const leadIdsFromImported = imported.map((l) => l.leadId);
|
|
3830
|
+
const leadIdSet = new Set(leadIdsFromImported);
|
|
3831
|
+
const leadsResults = await Promise.allSettled(importResult.importIds.map((importId) => client.request("GET", `/imports/${importId}/leads`)));
|
|
3832
|
+
leadsResults.forEach((r, i) => {
|
|
3833
|
+
if (r.status === "fulfilled" && Array.isArray(r.value?.lead_ids)) {
|
|
3834
|
+
for (const id of r.value.lead_ids)
|
|
3835
|
+
leadIdSet.add(id);
|
|
3836
|
+
} else if (r.status === "rejected") {
|
|
3837
|
+
const err = r.reason;
|
|
3838
|
+
ctx?.logger?.warn?.(`import_and_qualify: /imports/${importResult.importIds[i]}/leads unavailable (${err?.code ?? err?.message ?? "unknown"}) \u2014 using per-record reconciliation`);
|
|
3839
|
+
}
|
|
3840
|
+
});
|
|
3841
|
+
const leadIds = [...leadIdSet];
|
|
3842
|
+
const mappingFp = fingerprintMapping(
|
|
3843
|
+
// For domains-mode the mapping is the canonical {LEAD_NAME, LEAD_WEBSITE}.
|
|
3844
|
+
// For records-mode include BOTH `fields` and `custom_fields` shorthand
|
|
3845
|
+
// so two calls with the same `fields` but different custom-field
|
|
3846
|
+
// targets do NOT collide on the same qualify_id.
|
|
3847
|
+
buildFingerprintInput(params.mappings)
|
|
3848
|
+
);
|
|
3849
|
+
const reservation = await ctx.bulkTracker.findOrCreatePendingQualify({
|
|
3850
|
+
lead_ids: leadIds,
|
|
3851
|
+
import_ids: importResult.importIds,
|
|
3852
|
+
lens_id: lensId,
|
|
3853
|
+
mapping_fingerprint: mappingFp,
|
|
3854
|
+
per_lead_budget_ms: perLeadBudget,
|
|
3855
|
+
total_budget_ms: totalBudget
|
|
3856
|
+
});
|
|
3857
|
+
if (reservation.reused) {
|
|
3858
|
+
ctx?.logger?.info?.(`import_and_qualify: reusing qualify_id=${reservation.record.bulk_id} (seconds_since_original=${reservation.seconds_since_original})`);
|
|
3859
|
+
}
|
|
3860
|
+
let launchMarked = false;
|
|
3861
|
+
for (const attempt of [1, 2]) {
|
|
3022
3862
|
try {
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
let enrichableContacts = 0;
|
|
3029
|
-
try {
|
|
3030
|
-
const prev = await client.request("POST", "/leads/selection/enrichment/preview", { titles: [] });
|
|
3031
|
-
suggestions = prev.title_suggestions ?? [];
|
|
3032
|
-
autoIncluded = prev.auto_included_titles ?? [];
|
|
3033
|
-
previouslyEnriched = prev.previously_enriched_titles ?? [];
|
|
3034
|
-
enrichableContacts = prev.enrichable_contacts;
|
|
3035
|
-
} catch (e) {
|
|
3036
|
-
ctx?.logger?.warn?.(`enrich_titles: 0-titles preview failed: ${e?.message}`);
|
|
3037
|
-
}
|
|
3038
|
-
return {
|
|
3039
|
-
mode: "discover",
|
|
3040
|
-
available_titles: availableTitles,
|
|
3041
|
-
recommendations: suggestions,
|
|
3042
|
-
auto_included: autoIncluded,
|
|
3043
|
-
previously_enriched: previouslyEnriched,
|
|
3044
|
-
enrichable_contacts: enrichableContacts,
|
|
3045
|
-
selected_lead_count: leadIds.length,
|
|
3046
|
-
next_action: "Pick titles to enrich and call leadbay_enrich_titles again with titles=[...]"
|
|
3047
|
-
};
|
|
3048
|
-
}
|
|
3049
|
-
let preview;
|
|
3050
|
-
try {
|
|
3051
|
-
preview = await client.request("POST", "/leads/selection/enrichment/preview", { titles: params.titles });
|
|
3052
|
-
} catch (err) {
|
|
3053
|
-
if (err?.code === "QUOTA_EXCEEDED") {
|
|
3054
|
-
return {
|
|
3055
|
-
status: "quota_exceeded",
|
|
3056
|
-
message: "Quota exceeded on preview",
|
|
3057
|
-
retry_after_seconds: err?._meta?.retry_after ?? null
|
|
3058
|
-
};
|
|
3059
|
-
}
|
|
3060
|
-
throw err;
|
|
3061
|
-
}
|
|
3062
|
-
if (preview.enrichable_contacts === 0) {
|
|
3063
|
-
return {
|
|
3064
|
-
mode: "preview_only",
|
|
3065
|
-
preview,
|
|
3066
|
-
launched: false,
|
|
3067
|
-
message: "No enrichable contacts for the chosen titles. Try other titles from available_titles or recommendations.",
|
|
3068
|
-
available_titles: availableTitles
|
|
3069
|
-
};
|
|
3070
|
-
}
|
|
3071
|
-
if (params.dry_run) {
|
|
3072
|
-
return {
|
|
3073
|
-
mode: "dry_run",
|
|
3074
|
-
preview,
|
|
3075
|
-
launched: false,
|
|
3076
|
-
would_launch: { titles: params.titles, email, phone }
|
|
3077
|
-
};
|
|
3078
|
-
}
|
|
3079
|
-
const tracker = ctx?.bulkTracker;
|
|
3080
|
-
let bulkRecord;
|
|
3081
|
-
let bulkReused = false;
|
|
3082
|
-
let bulkSecondsSinceOriginal;
|
|
3083
|
-
if (tracker) {
|
|
3084
|
-
const res = await tracker.findOrCreatePending({
|
|
3085
|
-
lead_ids: leadIds,
|
|
3086
|
-
titles: params.titles,
|
|
3087
|
-
email,
|
|
3088
|
-
phone,
|
|
3089
|
-
lens_id: lensId,
|
|
3090
|
-
selection_source: selectionSource
|
|
3091
|
-
});
|
|
3092
|
-
bulkRecord = {
|
|
3093
|
-
bulk_id: res.record.bulk_id,
|
|
3094
|
-
launched_at: res.record.launched_at,
|
|
3095
|
-
durability: res.record.durability
|
|
3096
|
-
};
|
|
3097
|
-
bulkReused = res.reused;
|
|
3098
|
-
bulkSecondsSinceOriginal = res.seconds_since_original;
|
|
3099
|
-
if (bulkReused && res.record.status !== "failed") {
|
|
3100
|
-
return {
|
|
3101
|
-
mode: "already_launched",
|
|
3102
|
-
re_used: true,
|
|
3103
|
-
bulk_id: res.record.bulk_id,
|
|
3104
|
-
launched_at: res.record.launched_at,
|
|
3105
|
-
durability: res.record.durability,
|
|
3106
|
-
seconds_since_original_launch: bulkSecondsSinceOriginal ?? 0,
|
|
3107
|
-
titles: params.titles,
|
|
3108
|
-
email,
|
|
3109
|
-
phone,
|
|
3110
|
-
preview,
|
|
3111
|
-
message: `No new enrichment was ordered; quota not spent. An identical bulk was launched ${bulkSecondsSinceOriginal ?? 0}s ago. Poll leadbay_bulk_enrich_status with this bulk_id for results.`,
|
|
3112
|
-
next_action: "Call leadbay_bulk_enrich_status({bulk_id}) to check progress; include_contacts=true for the final read."
|
|
3113
|
-
};
|
|
3114
|
-
}
|
|
3115
|
-
}
|
|
3116
|
-
try {
|
|
3117
|
-
await client.requestVoid("POST", "/leads/selection/enrichment/launch", { titles: params.titles, email, phone });
|
|
3118
|
-
} catch (err) {
|
|
3119
|
-
if (bulkRecord && tracker) {
|
|
3120
|
-
try {
|
|
3121
|
-
await tracker.markFailed(bulkRecord.bulk_id);
|
|
3122
|
-
} catch (e) {
|
|
3123
|
-
ctx?.logger?.warn?.(`enrich_titles: tracker.markFailed failed: ${e?.message ?? e}`);
|
|
3124
|
-
}
|
|
3125
|
-
}
|
|
3126
|
-
if (err?.code === "QUOTA_EXCEEDED") {
|
|
3127
|
-
return {
|
|
3128
|
-
status: "quota_exceeded",
|
|
3129
|
-
preview,
|
|
3130
|
-
message: "Quota exceeded on launch",
|
|
3131
|
-
retry_after_seconds: err?._meta?.retry_after ?? null
|
|
3132
|
-
};
|
|
3133
|
-
}
|
|
3134
|
-
throw err;
|
|
3135
|
-
}
|
|
3136
|
-
if (bulkRecord && tracker) {
|
|
3137
|
-
try {
|
|
3138
|
-
await tracker.markLaunched(bulkRecord.bulk_id);
|
|
3139
|
-
} catch (e) {
|
|
3140
|
-
ctx?.logger?.warn?.(`enrich_titles: tracker.markLaunched failed: ${e?.message ?? e}`);
|
|
3141
|
-
return {
|
|
3142
|
-
mode: "launched_tracker_pending",
|
|
3143
|
-
launched: true,
|
|
3144
|
-
preview,
|
|
3145
|
-
bulk_id: bulkRecord.bulk_id,
|
|
3146
|
-
launched_at: bulkRecord.launched_at,
|
|
3147
|
-
durability: bulkRecord.durability,
|
|
3148
|
-
titles: params.titles,
|
|
3149
|
-
email,
|
|
3150
|
-
phone,
|
|
3151
|
-
message: "Enrichment job launched on the backend, but the local tracker record could not be flipped to 'launched'. The bulk_id is still valid \u2014 leadbay_bulk_enrich_status will return status:'pending' until the tracker heals.",
|
|
3152
|
-
next_action: "Wait ~60s, then call leadbay_bulk_enrich_status({bulk_id}). If it persists, restart the MCP."
|
|
3153
|
-
};
|
|
3154
|
-
}
|
|
3155
|
-
}
|
|
3156
|
-
return {
|
|
3157
|
-
mode: "launched",
|
|
3158
|
-
preview,
|
|
3159
|
-
launched: true,
|
|
3160
|
-
titles: params.titles,
|
|
3161
|
-
email,
|
|
3162
|
-
phone,
|
|
3163
|
-
bulk_id: bulkRecord?.bulk_id,
|
|
3164
|
-
launched_at: bulkRecord?.launched_at,
|
|
3165
|
-
durability: bulkRecord?.durability,
|
|
3166
|
-
message: bulkRecord ? "Enrichment job launched. Backend has no server-side bulk_id yet; MCP minted a client-side bulk_id (persisted to disk by default) so you can poll via leadbay_bulk_enrich_status." : "Enrichment job launched. No bulk_id tracker configured \u2014 poll leadbay_get_contacts per lead after ~60s; contact.enrichment.done flips to true.",
|
|
3167
|
-
next_action: bulkRecord ? "Call leadbay_bulk_enrich_status({bulk_id}) after ~60s; pass include_contacts=true for the final read." : "Wait ~60s, then call leadbay_research_lead or leadbay_get_contacts on the leads you care about."
|
|
3168
|
-
};
|
|
3169
|
-
} finally {
|
|
3170
|
-
try {
|
|
3171
|
-
await client.requestVoid("POST", "/leads/selection/clear");
|
|
3172
|
-
} catch (e) {
|
|
3173
|
-
ctx?.logger?.warn?.(`enrich_titles: selection.clear failed: ${e?.message ?? e?.code}`);
|
|
3174
|
-
}
|
|
3863
|
+
await ctx.bulkTracker.markLaunched(reservation.record.bulk_id);
|
|
3864
|
+
launchMarked = true;
|
|
3865
|
+
break;
|
|
3866
|
+
} catch (err) {
|
|
3867
|
+
ctx?.logger?.warn?.(`import_and_qualify: markLaunched attempt ${attempt} failed: ${err?.message ?? err}`);
|
|
3175
3868
|
}
|
|
3176
|
-
} finally {
|
|
3177
|
-
client.releaseSelectionLock();
|
|
3178
3869
|
}
|
|
3870
|
+
if (!launchMarked) {
|
|
3871
|
+
ctx?.logger?.warn?.(`import_and_qualify: markLaunched failed twice \u2014 qualify_status may BULK_PENDING-trap immediate retrieval; agent should poll, not relaunch`);
|
|
3872
|
+
}
|
|
3873
|
+
let questionOrder = void 0;
|
|
3874
|
+
try {
|
|
3875
|
+
const taste = await client.resolveTasteProfile();
|
|
3876
|
+
questionOrder = buildQuestionOrder(taste.qualificationQuestions ?? []);
|
|
3877
|
+
} catch (err) {
|
|
3878
|
+
ctx?.logger?.warn?.(`qualify: question order unavailable (${err?.code ?? err?.message ?? "unknown"}) \u2014 falling back to alphabetical`);
|
|
3879
|
+
}
|
|
3880
|
+
const fanOut = await fanOutWebFetchAndPoll(client, leadIds, {
|
|
3881
|
+
perLeadBudgetMs: perLeadBudget,
|
|
3882
|
+
totalDeadlineMs: totalDeadline,
|
|
3883
|
+
signal,
|
|
3884
|
+
ctx,
|
|
3885
|
+
skipAlreadyQualifiedLensId: lensId,
|
|
3886
|
+
skipAlreadyQualifiedLaunch: skipAlreadyQualified,
|
|
3887
|
+
...questionOrder ? { questionOrder } : {}
|
|
3888
|
+
});
|
|
3889
|
+
const qualified = fanOut.results.filter((r) => !r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
|
|
3890
|
+
const notInLensSet = new Set(fanOut.not_in_lens);
|
|
3891
|
+
const stillRunningIds = new Set([
|
|
3892
|
+
...fanOut.results.filter((r) => r._stillRunning).map((r) => r.lead_id),
|
|
3893
|
+
...fanOut.not_launched
|
|
3894
|
+
].filter((id) => !notInLensSet.has(id)));
|
|
3895
|
+
const still_running = [...stillRunningIds].map((lead_id) => ({ lead_id }));
|
|
3896
|
+
const budgetExhausted = Date.now() >= totalDeadline && still_running.length > 0;
|
|
3897
|
+
const quotaBlocked = fanOut.quota_exceeded && still_running.length > 0 && !budgetExhausted;
|
|
3898
|
+
const skipped_already_qualified = skipAlreadyQualified && fanOut.skipped_already_qualified ? [...fanOut.skipped_already_qualified] : [];
|
|
3899
|
+
return {
|
|
3900
|
+
kind: "result",
|
|
3901
|
+
...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
|
|
3902
|
+
qualify_id: reservation.record.bulk_id,
|
|
3903
|
+
import_ids: importResult.importIds,
|
|
3904
|
+
imported,
|
|
3905
|
+
not_imported,
|
|
3906
|
+
qualified,
|
|
3907
|
+
still_running,
|
|
3908
|
+
failed: fanOut.failed,
|
|
3909
|
+
quota_exceeded: fanOut.quota_exceeded,
|
|
3910
|
+
skipped_already_qualified,
|
|
3911
|
+
not_in_lens: fanOut.not_in_lens,
|
|
3912
|
+
...reservation.reused ? {
|
|
3913
|
+
reused: true,
|
|
3914
|
+
seconds_since_original: reservation.seconds_since_original
|
|
3915
|
+
} : {},
|
|
3916
|
+
...fanOut.cancelled ? { cancelled: true } : {},
|
|
3917
|
+
...budgetExhausted ? { budget_exhausted: true } : {},
|
|
3918
|
+
...quotaBlocked ? { quota_blocked: true } : {},
|
|
3919
|
+
region: client.region,
|
|
3920
|
+
_meta: client.lastMeta ?? {
|
|
3921
|
+
region: client.region,
|
|
3922
|
+
endpoint: "POST /imports \u2192 /web_fetch",
|
|
3923
|
+
latency_ms: null,
|
|
3924
|
+
retry_after: null
|
|
3925
|
+
}
|
|
3926
|
+
};
|
|
3179
3927
|
}
|
|
3180
3928
|
};
|
|
3929
|
+
async function runPreview(client, params, ctx, perPhaseBudget, _totalBudget) {
|
|
3930
|
+
const me = await client.resolveMe();
|
|
3931
|
+
if (!me.admin) {
|
|
3932
|
+
throw client.makeError("IMPORT_ADMIN_REQUIRED", "Preview mode requires admin role on the Leadbay account", "Ask the account owner to grant import permission, or use a token from an admin user.", "POST /imports");
|
|
3933
|
+
}
|
|
3934
|
+
const PREVIEW_SAMPLE_CAP2 = 50;
|
|
3935
|
+
let csv;
|
|
3936
|
+
if (Array.isArray(params.domains) && params.domains.length > 0) {
|
|
3937
|
+
const sample = params.domains.slice(0, PREVIEW_SAMPLE_CAP2);
|
|
3938
|
+
const lines = ["LEAD_NAME,LEAD_WEBSITE"];
|
|
3939
|
+
for (const d of sample) {
|
|
3940
|
+
const dom = (d.domain ?? "").replace(/[",\n\r]/g, " ").trim();
|
|
3941
|
+
const name = (d.name ?? dom).replace(/[",\n\r]/g, " ").trim();
|
|
3942
|
+
lines.push(`${escapeCsv(name)},${escapeCsv(dom)}`);
|
|
3943
|
+
}
|
|
3944
|
+
csv = lines.join("\n") + "\n";
|
|
3945
|
+
} else if (Array.isArray(params.records) && params.records.length > 0) {
|
|
3946
|
+
const sample = params.records.slice(0, PREVIEW_SAMPLE_CAP2);
|
|
3947
|
+
const headerSet = /* @__PURE__ */ new Set();
|
|
3948
|
+
for (const r of sample)
|
|
3949
|
+
for (const k of Object.keys(r))
|
|
3950
|
+
headerSet.add(k);
|
|
3951
|
+
const header = [...headerSet];
|
|
3952
|
+
const lines = [header.map(escapeCsv).join(",")];
|
|
3953
|
+
for (const r of sample) {
|
|
3954
|
+
lines.push(header.map((c) => escapeCsv(coerceCellToString(r[c]))).join(","));
|
|
3955
|
+
}
|
|
3956
|
+
csv = lines.join("\n") + "\n";
|
|
3957
|
+
} else {
|
|
3958
|
+
throw client.makeError("IMPORT_EMPTY_INPUT", "Preview mode requires `domains` or `records`", "Pass at least one row to preview against.", "POST /imports");
|
|
3959
|
+
}
|
|
3960
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
3961
|
+
const fileName = `mcp-preview-${ts}.csv`;
|
|
3962
|
+
ctx?.logger?.info?.(`import_and_qualify(preview): uploading sample (${csv.length}B) for hints`);
|
|
3963
|
+
const upload = await client.requestRawBinary("POST", `/imports?file_name=${encodeURIComponent(fileName)}`, "text/csv", csv);
|
|
3964
|
+
const importId = upload.id;
|
|
3965
|
+
const signal = ctx?.signal;
|
|
3966
|
+
const deadline = Date.now() + perPhaseBudget;
|
|
3967
|
+
let fileImport = null;
|
|
3968
|
+
while (Date.now() < deadline) {
|
|
3969
|
+
if (signal?.aborted) {
|
|
3970
|
+
throw Object.assign(new Error("aborted"), { name: "AbortError" });
|
|
3971
|
+
}
|
|
3972
|
+
const r = await client.request("GET", `/imports/${importId}`);
|
|
3973
|
+
if (r.pre_processing?.finished) {
|
|
3974
|
+
fileImport = r;
|
|
3975
|
+
break;
|
|
3976
|
+
}
|
|
3977
|
+
await new Promise((res) => {
|
|
3978
|
+
const t = setTimeout(() => {
|
|
3979
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3980
|
+
res();
|
|
3981
|
+
}, 2e3);
|
|
3982
|
+
const onAbort = () => {
|
|
3983
|
+
clearTimeout(t);
|
|
3984
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3985
|
+
res();
|
|
3986
|
+
};
|
|
3987
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3988
|
+
});
|
|
3989
|
+
}
|
|
3990
|
+
if (signal?.aborted) {
|
|
3991
|
+
throw Object.assign(new Error("aborted"), { name: "AbortError" });
|
|
3992
|
+
}
|
|
3993
|
+
if (!fileImport) {
|
|
3994
|
+
throw client.makeError("IMPORT_BUDGET_EXHAUSTED", `Preview preprocess did not finish within ${perPhaseBudget}ms`, "Increase per_phase_budget_ms or shrink the input. The wizard row will eventually be cleaned up.", `GET /imports/${importId}`);
|
|
3995
|
+
}
|
|
3996
|
+
if (fileImport.pre_processing?.error) {
|
|
3997
|
+
throw client.makeError("IMPORT_PREPROCESS_FAILED", `Preview preprocess failed: ${fileImport.pre_processing.error}`, "Inspect the input rows for encoding / shape issues.", `GET /imports/${importId}`);
|
|
3998
|
+
}
|
|
3999
|
+
const notes = [];
|
|
4000
|
+
if (Array.isArray(params.domains)) {
|
|
4001
|
+
notes.push("domains-mode: hints reflect the synthesized LEAD_NAME/LEAD_WEBSITE columns only.");
|
|
4002
|
+
}
|
|
4003
|
+
let catalog = [];
|
|
4004
|
+
try {
|
|
4005
|
+
catalog = await client.request("GET", "/crm/custom_fields") ?? [];
|
|
4006
|
+
} catch (err) {
|
|
4007
|
+
notes.push(`custom-field catalog unavailable: ${err?.code ?? err?.message ?? "unknown"}`);
|
|
4008
|
+
}
|
|
4009
|
+
const { mapping_hints, custom_field_candidates, sample_rows } = extractHintsAndCandidates(fileImport, catalog);
|
|
4010
|
+
return {
|
|
4011
|
+
kind: "preview",
|
|
4012
|
+
mapping_hints,
|
|
4013
|
+
custom_field_candidates,
|
|
4014
|
+
sample_rows,
|
|
4015
|
+
notes,
|
|
4016
|
+
import_id: importId,
|
|
4017
|
+
region: client.region,
|
|
4018
|
+
_meta: client.lastMeta ?? {
|
|
4019
|
+
region: client.region,
|
|
4020
|
+
endpoint: `GET /imports/${importId}`,
|
|
4021
|
+
latency_ms: null,
|
|
4022
|
+
retry_after: null
|
|
4023
|
+
}
|
|
4024
|
+
};
|
|
4025
|
+
}
|
|
3181
4026
|
|
|
3182
4027
|
// ../core/dist/jobs/bulk-store.js
|
|
3183
4028
|
import { mkdir as mkdirAsync, lstat, open as fsOpen, readFile, rename, stat, unlink } from "fs/promises";
|
|
3184
4029
|
import { constants as fsConstants } from "fs";
|
|
3185
4030
|
import { dirname, resolve as resolvePath } from "path";
|
|
3186
4031
|
import { homedir, platform } from "os";
|
|
3187
|
-
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
4032
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
3188
4033
|
var DEFAULT_IDEMPOTENCY_WINDOW_MS = 5 * 60 * 1e3;
|
|
3189
4034
|
var TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
3190
4035
|
var UUIDV4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
@@ -3199,7 +4044,17 @@ function computeIdempotencyKey(args) {
|
|
|
3199
4044
|
args.phone ? "p1" : "p0",
|
|
3200
4045
|
`l${args.lens_id}`
|
|
3201
4046
|
];
|
|
3202
|
-
return
|
|
4047
|
+
return createHash2("sha256").update(parts.join("|")).digest("hex");
|
|
4048
|
+
}
|
|
4049
|
+
function computeQualifyIdempotencyKey(args) {
|
|
4050
|
+
const parts = [
|
|
4051
|
+
"qualify",
|
|
4052
|
+
[...args.lead_ids].sort().join(","),
|
|
4053
|
+
[...args.import_ids].sort().join(","),
|
|
4054
|
+
`l${args.lens_id}`,
|
|
4055
|
+
args.mapping_fingerprint
|
|
4056
|
+
];
|
|
4057
|
+
return createHash2("sha256").update(parts.join("|")).digest("hex");
|
|
3203
4058
|
}
|
|
3204
4059
|
function normalizeLaunchInputs(args) {
|
|
3205
4060
|
return {
|
|
@@ -3267,275 +4122,669 @@ var LocalBulkStore = class {
|
|
|
3267
4122
|
get resolvedPath() {
|
|
3268
4123
|
return this.path;
|
|
3269
4124
|
}
|
|
3270
|
-
validatePath(p) {
|
|
3271
|
-
if (this.allowUnsafePath)
|
|
3272
|
-
return;
|
|
3273
|
-
const home = resolvePath(homedir());
|
|
3274
|
-
if (p !== home && !p.startsWith(home + "/") && !p.startsWith(home + "\\")) {
|
|
3275
|
-
throw new Error(`LocalBulkStore: path ${p} is outside $HOME (${home}). Set LEADBAY_BULK_STORE_PATH_UNSAFE=1 to override.`);
|
|
4125
|
+
validatePath(p) {
|
|
4126
|
+
if (this.allowUnsafePath)
|
|
4127
|
+
return;
|
|
4128
|
+
const home = resolvePath(homedir());
|
|
4129
|
+
if (p !== home && !p.startsWith(home + "/") && !p.startsWith(home + "\\")) {
|
|
4130
|
+
throw new Error(`LocalBulkStore: path ${p} is outside $HOME (${home}). Set LEADBAY_BULK_STORE_PATH_UNSAFE=1 to override.`);
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
async ensureInitialized() {
|
|
4134
|
+
if (this.initialized || this.backend !== "file") {
|
|
4135
|
+
this.initialized = true;
|
|
4136
|
+
return;
|
|
4137
|
+
}
|
|
4138
|
+
const dir = dirname(this.path);
|
|
4139
|
+
await mkdirAsync(dir, { recursive: true, mode: 448 });
|
|
4140
|
+
try {
|
|
4141
|
+
const st = await lstat(this.path);
|
|
4142
|
+
if (st.isSymbolicLink()) {
|
|
4143
|
+
throw new Error(`LocalBulkStore: refusing to use ${this.path} \u2014 path is a symlink. Set LEADBAY_BULK_STORE_PATH_UNSAFE=1 to override.`);
|
|
4144
|
+
}
|
|
4145
|
+
} catch (err) {
|
|
4146
|
+
if (err?.code !== "ENOENT")
|
|
4147
|
+
throw err;
|
|
4148
|
+
}
|
|
4149
|
+
this.initialized = true;
|
|
4150
|
+
}
|
|
4151
|
+
// ─── Storage layer (file or memory) ──────────────────────────────────────
|
|
4152
|
+
async readAll() {
|
|
4153
|
+
if (this.backend === "memory")
|
|
4154
|
+
return [...this.memory];
|
|
4155
|
+
await this.ensureInitialized();
|
|
4156
|
+
let raw;
|
|
4157
|
+
try {
|
|
4158
|
+
raw = await readFile(this.path, "utf8");
|
|
4159
|
+
} catch (err) {
|
|
4160
|
+
if (err?.code === "ENOENT")
|
|
4161
|
+
return [];
|
|
4162
|
+
throw err;
|
|
4163
|
+
}
|
|
4164
|
+
let parsed;
|
|
4165
|
+
try {
|
|
4166
|
+
parsed = JSON.parse(raw);
|
|
4167
|
+
} catch (err) {
|
|
4168
|
+
this.logger?.warn?.(`bulk.record_dropped file_parse_failed ${err?.message ?? err}`);
|
|
4169
|
+
return [];
|
|
4170
|
+
}
|
|
4171
|
+
if (!Array.isArray(parsed)) {
|
|
4172
|
+
this.logger?.warn?.("bulk.record_dropped file_not_array");
|
|
4173
|
+
return [];
|
|
4174
|
+
}
|
|
4175
|
+
const out = [];
|
|
4176
|
+
for (const entry of parsed) {
|
|
4177
|
+
try {
|
|
4178
|
+
out.push(this.validateRecord(entry));
|
|
4179
|
+
} catch (err) {
|
|
4180
|
+
this.logger?.warn?.(`bulk.record_dropped invalid_record ${err?.message ?? err}`);
|
|
4181
|
+
}
|
|
4182
|
+
}
|
|
4183
|
+
return out;
|
|
4184
|
+
}
|
|
4185
|
+
validateRecord(raw) {
|
|
4186
|
+
if (!raw || typeof raw !== "object")
|
|
4187
|
+
throw new Error("not an object");
|
|
4188
|
+
const r = raw;
|
|
4189
|
+
if (!isValidBulkId(r.bulk_id))
|
|
4190
|
+
throw new Error("invalid bulk_id");
|
|
4191
|
+
if (typeof r.launched_at !== "string")
|
|
4192
|
+
throw new Error("missing launched_at");
|
|
4193
|
+
if (!Array.isArray(r.lead_ids) || !r.lead_ids.every((x) => typeof x === "string"))
|
|
4194
|
+
throw new Error("invalid lead_ids");
|
|
4195
|
+
if (r.status !== "pending" && r.status !== "launched" && r.status !== "failed")
|
|
4196
|
+
throw new Error("invalid status");
|
|
4197
|
+
if (typeof r.idempotency_key !== "string")
|
|
4198
|
+
throw new Error("invalid idempotency_key");
|
|
4199
|
+
const kind = r.kind ?? "enrich";
|
|
4200
|
+
if (kind === "qualify") {
|
|
4201
|
+
if (!Array.isArray(r.import_ids) || !r.import_ids.every((x) => typeof x === "string"))
|
|
4202
|
+
throw new Error("invalid import_ids");
|
|
4203
|
+
if (typeof r.lens_id !== "number")
|
|
4204
|
+
throw new Error("invalid lens_id");
|
|
4205
|
+
const out = {
|
|
4206
|
+
kind: "qualify",
|
|
4207
|
+
bulk_id: r.bulk_id,
|
|
4208
|
+
launched_at: r.launched_at,
|
|
4209
|
+
lead_ids: r.lead_ids,
|
|
4210
|
+
import_ids: r.import_ids,
|
|
4211
|
+
lens_id: r.lens_id,
|
|
4212
|
+
status: r.status,
|
|
4213
|
+
idempotency_key: r.idempotency_key,
|
|
4214
|
+
durability: this.backend
|
|
4215
|
+
};
|
|
4216
|
+
if (typeof r.per_lead_budget_ms === "number")
|
|
4217
|
+
out.per_lead_budget_ms = r.per_lead_budget_ms;
|
|
4218
|
+
if (typeof r.total_budget_ms === "number")
|
|
4219
|
+
out.total_budget_ms = r.total_budget_ms;
|
|
4220
|
+
return out;
|
|
4221
|
+
}
|
|
4222
|
+
if (kind === "enrich") {
|
|
4223
|
+
if (!Array.isArray(r.titles) || !r.titles.every((x) => typeof x === "string"))
|
|
4224
|
+
throw new Error("invalid titles");
|
|
4225
|
+
if (typeof r.email !== "boolean")
|
|
4226
|
+
throw new Error("invalid email");
|
|
4227
|
+
if (typeof r.phone !== "boolean")
|
|
4228
|
+
throw new Error("invalid phone");
|
|
4229
|
+
if (typeof r.lens_id !== "number")
|
|
4230
|
+
throw new Error("invalid lens_id");
|
|
4231
|
+
if (r.selection_source !== "explicit" && r.selection_source !== "wishlist")
|
|
4232
|
+
throw new Error("invalid selection_source");
|
|
4233
|
+
return {
|
|
4234
|
+
kind: "enrich",
|
|
4235
|
+
bulk_id: r.bulk_id,
|
|
4236
|
+
launched_at: r.launched_at,
|
|
4237
|
+
lead_ids: r.lead_ids,
|
|
4238
|
+
titles: r.titles,
|
|
4239
|
+
email: r.email,
|
|
4240
|
+
phone: r.phone,
|
|
4241
|
+
lens_id: r.lens_id,
|
|
4242
|
+
selection_source: r.selection_source,
|
|
4243
|
+
status: r.status,
|
|
4244
|
+
idempotency_key: r.idempotency_key,
|
|
4245
|
+
durability: this.backend
|
|
4246
|
+
};
|
|
4247
|
+
}
|
|
4248
|
+
throw new Error(`unknown kind: ${String(kind)}`);
|
|
4249
|
+
}
|
|
4250
|
+
async writeAll(records) {
|
|
4251
|
+
if (this.backend === "memory") {
|
|
4252
|
+
this.memory = records.map((r) => ({ ...r, durability: "memory" }));
|
|
4253
|
+
return;
|
|
4254
|
+
}
|
|
4255
|
+
await this.ensureInitialized();
|
|
4256
|
+
const payload = records.map((r) => ({ ...r, durability: "file" }));
|
|
4257
|
+
const json = JSON.stringify(payload, null, 2);
|
|
4258
|
+
const tmp = this.path + ".tmp";
|
|
4259
|
+
let fh = await openTmpFileExclusive(tmp);
|
|
4260
|
+
try {
|
|
4261
|
+
await fh.writeFile(json, { encoding: "utf8" });
|
|
4262
|
+
await fh.sync();
|
|
4263
|
+
} finally {
|
|
4264
|
+
await fh.close();
|
|
4265
|
+
}
|
|
4266
|
+
if (platform() === "win32") {
|
|
4267
|
+
try {
|
|
4268
|
+
await unlink(this.path);
|
|
4269
|
+
} catch (err) {
|
|
4270
|
+
if (err?.code !== "ENOENT")
|
|
4271
|
+
throw err;
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
await rename(tmp, this.path);
|
|
4275
|
+
try {
|
|
4276
|
+
const dirFh = await fsOpen(dirname(this.path), "r");
|
|
4277
|
+
try {
|
|
4278
|
+
await dirFh.sync();
|
|
4279
|
+
} finally {
|
|
4280
|
+
await dirFh.close();
|
|
4281
|
+
}
|
|
4282
|
+
} catch {
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4285
|
+
// ─── TTL cleanup ─────────────────────────────────────────────────────────
|
|
4286
|
+
prune(records) {
|
|
4287
|
+
const cutoff = this.now() - TTL_MS;
|
|
4288
|
+
const kept = [];
|
|
4289
|
+
for (const r of records) {
|
|
4290
|
+
const launched = Date.parse(r.launched_at);
|
|
4291
|
+
if (Number.isFinite(launched) && launched >= cutoff) {
|
|
4292
|
+
kept.push(r);
|
|
4293
|
+
} else {
|
|
4294
|
+
this.logger?.info?.(`bulk.ttl_dropped bulk_id=${r.bulk_id} launched_at=${r.launched_at}`);
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
return kept;
|
|
4298
|
+
}
|
|
4299
|
+
// ─── BulkTracker API ────────────────────────────────────────────────────
|
|
4300
|
+
async findOrCreatePending(args) {
|
|
4301
|
+
const { lead_ids, titles } = normalizeLaunchInputs(args);
|
|
4302
|
+
const idempotency_key = computeIdempotencyKey({
|
|
4303
|
+
lead_ids,
|
|
4304
|
+
titles,
|
|
4305
|
+
email: args.email,
|
|
4306
|
+
phone: args.phone,
|
|
4307
|
+
lens_id: args.lens_id
|
|
4308
|
+
});
|
|
4309
|
+
const window = args.idempotency_window_ms ?? DEFAULT_IDEMPOTENCY_WINDOW_MS;
|
|
4310
|
+
return this.mutex.run(async () => {
|
|
4311
|
+
const all = this.prune(await this.readAll());
|
|
4312
|
+
const nowMs = this.now();
|
|
4313
|
+
const existing = all.find((r) => r.kind === "enrich" && r.idempotency_key === idempotency_key && r.status !== "failed" && nowMs - Date.parse(r.launched_at) < window);
|
|
4314
|
+
if (existing) {
|
|
4315
|
+
this.logger?.info?.(`bulk.reused bulk_id=${existing.bulk_id} seconds_since_original=${Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)}`);
|
|
4316
|
+
return {
|
|
4317
|
+
record: existing,
|
|
4318
|
+
reused: true,
|
|
4319
|
+
seconds_since_original: Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)
|
|
4320
|
+
};
|
|
4321
|
+
}
|
|
4322
|
+
const record = {
|
|
4323
|
+
kind: "enrich",
|
|
4324
|
+
bulk_id: randomUUID2(),
|
|
4325
|
+
launched_at: new Date(nowMs).toISOString(),
|
|
4326
|
+
lead_ids,
|
|
4327
|
+
titles,
|
|
4328
|
+
email: args.email,
|
|
4329
|
+
phone: args.phone,
|
|
4330
|
+
lens_id: args.lens_id,
|
|
4331
|
+
selection_source: args.selection_source,
|
|
4332
|
+
status: "pending",
|
|
4333
|
+
idempotency_key,
|
|
4334
|
+
durability: this.backend
|
|
4335
|
+
};
|
|
4336
|
+
all.push(record);
|
|
4337
|
+
await this.writeAll(all);
|
|
4338
|
+
this.logger?.info?.(`bulk.registered kind=enrich bulk_id=${record.bulk_id} lens_id=${record.lens_id} lead_count=${record.lead_ids.length} titles_count=${record.titles.length} durability=${record.durability}`);
|
|
4339
|
+
return { record, reused: false };
|
|
4340
|
+
});
|
|
4341
|
+
}
|
|
4342
|
+
async findOrCreatePendingQualify(args) {
|
|
4343
|
+
const lead_ids = [...new Set(args.lead_ids)].sort();
|
|
4344
|
+
const import_ids = [...new Set(args.import_ids)].sort();
|
|
4345
|
+
const idempotency_key = computeQualifyIdempotencyKey({
|
|
4346
|
+
lead_ids,
|
|
4347
|
+
import_ids,
|
|
4348
|
+
lens_id: args.lens_id,
|
|
4349
|
+
mapping_fingerprint: args.mapping_fingerprint
|
|
4350
|
+
});
|
|
4351
|
+
const window = args.idempotency_window_ms ?? DEFAULT_IDEMPOTENCY_WINDOW_MS;
|
|
4352
|
+
return this.mutex.run(async () => {
|
|
4353
|
+
const all = this.prune(await this.readAll());
|
|
4354
|
+
const nowMs = this.now();
|
|
4355
|
+
const existing = all.find((r) => r.kind === "qualify" && r.idempotency_key === idempotency_key && r.status !== "failed" && nowMs - Date.parse(r.launched_at) < window);
|
|
4356
|
+
if (existing) {
|
|
4357
|
+
this.logger?.info?.(`bulk.reused kind=qualify bulk_id=${existing.bulk_id} seconds_since_original=${Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)}`);
|
|
4358
|
+
return {
|
|
4359
|
+
record: existing,
|
|
4360
|
+
reused: true,
|
|
4361
|
+
seconds_since_original: Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)
|
|
4362
|
+
};
|
|
4363
|
+
}
|
|
4364
|
+
const record = {
|
|
4365
|
+
kind: "qualify",
|
|
4366
|
+
bulk_id: randomUUID2(),
|
|
4367
|
+
launched_at: new Date(nowMs).toISOString(),
|
|
4368
|
+
lead_ids,
|
|
4369
|
+
import_ids,
|
|
4370
|
+
lens_id: args.lens_id,
|
|
4371
|
+
status: "pending",
|
|
4372
|
+
idempotency_key,
|
|
4373
|
+
durability: this.backend
|
|
4374
|
+
};
|
|
4375
|
+
if (args.per_lead_budget_ms !== void 0)
|
|
4376
|
+
record.per_lead_budget_ms = args.per_lead_budget_ms;
|
|
4377
|
+
if (args.total_budget_ms !== void 0)
|
|
4378
|
+
record.total_budget_ms = args.total_budget_ms;
|
|
4379
|
+
all.push(record);
|
|
4380
|
+
await this.writeAll(all);
|
|
4381
|
+
this.logger?.info?.(`bulk.registered kind=qualify bulk_id=${record.bulk_id} lens_id=${record.lens_id} lead_count=${record.lead_ids.length} import_count=${record.import_ids.length} durability=${record.durability}`);
|
|
4382
|
+
return { record, reused: false };
|
|
4383
|
+
});
|
|
4384
|
+
}
|
|
4385
|
+
async getQualify(bulk_id) {
|
|
4386
|
+
const r = await this.get(bulk_id);
|
|
4387
|
+
return r && r.kind === "qualify" ? r : void 0;
|
|
4388
|
+
}
|
|
4389
|
+
async markLaunched(bulk_id) {
|
|
4390
|
+
return this.mutex.run(async () => {
|
|
4391
|
+
const all = this.prune(await this.readAll());
|
|
4392
|
+
const idx = all.findIndex((r) => r.bulk_id === bulk_id);
|
|
4393
|
+
if (idx < 0) {
|
|
4394
|
+
throw new Error(`bulk_id not found: ${bulk_id}`);
|
|
4395
|
+
}
|
|
4396
|
+
all[idx] = { ...all[idx], status: "launched" };
|
|
4397
|
+
await this.writeAll(all);
|
|
4398
|
+
this.logger?.info?.(`bulk.launched bulk_id=${bulk_id}`);
|
|
4399
|
+
return all[idx];
|
|
4400
|
+
});
|
|
4401
|
+
}
|
|
4402
|
+
async markFailed(bulk_id) {
|
|
4403
|
+
return this.mutex.run(async () => {
|
|
4404
|
+
const all = this.prune(await this.readAll());
|
|
4405
|
+
const idx = all.findIndex((r) => r.bulk_id === bulk_id);
|
|
4406
|
+
if (idx < 0) {
|
|
4407
|
+
return;
|
|
4408
|
+
}
|
|
4409
|
+
all[idx] = { ...all[idx], status: "failed" };
|
|
4410
|
+
await this.writeAll(all);
|
|
4411
|
+
this.logger?.info?.(`bulk.launch_failed bulk_id=${bulk_id}`);
|
|
4412
|
+
});
|
|
4413
|
+
}
|
|
4414
|
+
async get(bulk_id) {
|
|
4415
|
+
return this.mutex.run(async () => {
|
|
4416
|
+
const all = this.prune(await this.readAll());
|
|
4417
|
+
return all.find((r) => r.bulk_id === bulk_id);
|
|
4418
|
+
});
|
|
4419
|
+
}
|
|
4420
|
+
async list() {
|
|
4421
|
+
return this.mutex.run(async () => {
|
|
4422
|
+
const all = this.prune(await this.readAll());
|
|
4423
|
+
return [...all].sort((a, b) => Date.parse(b.launched_at) - Date.parse(a.launched_at));
|
|
4424
|
+
});
|
|
4425
|
+
}
|
|
4426
|
+
};
|
|
4427
|
+
async function openTmpFileExclusive(path) {
|
|
4428
|
+
try {
|
|
4429
|
+
return await fsOpen(path, fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL, 384);
|
|
4430
|
+
} catch (err) {
|
|
4431
|
+
if (err?.code === "EEXIST") {
|
|
4432
|
+
await unlink(path).catch(() => {
|
|
4433
|
+
});
|
|
4434
|
+
return fsOpen(path, fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL, 384);
|
|
4435
|
+
}
|
|
4436
|
+
throw err;
|
|
4437
|
+
}
|
|
4438
|
+
}
|
|
4439
|
+
var InMemoryBulkStore = class extends LocalBulkStore {
|
|
4440
|
+
constructor(opts = {}) {
|
|
4441
|
+
super({ backend: "memory", logger: opts.logger, now: opts.now });
|
|
4442
|
+
}
|
|
4443
|
+
};
|
|
4444
|
+
async function createDefaultBulkStore(opts = {}) {
|
|
4445
|
+
const env = opts.env ?? process.env;
|
|
4446
|
+
const allowMemory = env.LEADBAY_BULK_STORE_ALLOW_MEMORY === "1";
|
|
4447
|
+
const allowUnsafePath = env.LEADBAY_BULK_STORE_PATH_UNSAFE === "1";
|
|
4448
|
+
const path = env.LEADBAY_BULK_STORE_PATH ?? resolvePath(homedir(), ".leadbay", "bulks.json");
|
|
4449
|
+
try {
|
|
4450
|
+
const store = new LocalBulkStore({
|
|
4451
|
+
backend: "file",
|
|
4452
|
+
path,
|
|
4453
|
+
logger: opts.logger,
|
|
4454
|
+
allowUnsafePath
|
|
4455
|
+
});
|
|
4456
|
+
await store.ensureInitialized();
|
|
4457
|
+
await stat(dirname(path));
|
|
4458
|
+
return store;
|
|
4459
|
+
} catch (err) {
|
|
4460
|
+
if (!allowMemory) {
|
|
4461
|
+
const msg = `bulk store init failed at ${path}: ${err?.message ?? err}. Set LEADBAY_BULK_STORE_ALLOW_MEMORY=1 to fall back to in-memory (handles won't survive MCP restart), or set LEADBAY_BULK_STORE_PATH to a writable path.`;
|
|
4462
|
+
opts.logger?.error?.(msg);
|
|
4463
|
+
throw new Error(msg);
|
|
3276
4464
|
}
|
|
4465
|
+
opts.logger?.warn?.(`bulk.fallback_memory path=${path} reason=${err?.message ?? err}`);
|
|
4466
|
+
return new LocalBulkStore({ backend: "memory", logger: opts.logger });
|
|
3277
4467
|
}
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
4468
|
+
}
|
|
4469
|
+
|
|
4470
|
+
// ../core/dist/composite/qualify-status.js
|
|
4471
|
+
var qualifyStatus = {
|
|
4472
|
+
name: "leadbay_qualify_status",
|
|
4473
|
+
description: "Retrieve the current state of an import_and_qualify launch by `qualify_id`. Returns the same `qualified[]` / `still_running[]` shape as the original composite, refreshed against the backend at call time. The handle is persisted to ~/.leadbay/bulks.json with a 30-day TTL and survives MCP restart.\n\nWhen to use: after leadbay_import_and_qualify returned a qualify_id with non-empty `still_running[]`, call this tool a few minutes later (or hours) to retrieve the now-completed qualifications without re-running the import or re-spending qualify quota.\nWhen NOT to use: as a substitute for leadbay_research_lead \u2014 that's a deeper per-lead profile and includes contacts. This tool is purely the qualification answers + signals_count.",
|
|
4474
|
+
inputSchema: {
|
|
4475
|
+
type: "object",
|
|
4476
|
+
properties: {
|
|
4477
|
+
qualify_id: {
|
|
4478
|
+
type: "string",
|
|
4479
|
+
description: "UUIDv4 returned by leadbay_import_and_qualify when at least one lead was still running."
|
|
4480
|
+
}
|
|
4481
|
+
},
|
|
4482
|
+
required: ["qualify_id"]
|
|
4483
|
+
},
|
|
4484
|
+
execute: async (client, params, ctx) => {
|
|
4485
|
+
if (!isValidBulkId(params.qualify_id)) {
|
|
4486
|
+
throw client.makeError("BULK_INVALID_ID", "qualify_id is not a valid UUIDv4", "Pass the qualify_id returned by leadbay_import_and_qualify verbatim.", "");
|
|
3282
4487
|
}
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
4488
|
+
if (!ctx?.bulkTracker) {
|
|
4489
|
+
throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_qualify_status needs a BulkTracker. Upgrade to @leadbay/mcp \u22650.5.0 or set LEADBAY_BULK_STORE_ALLOW_MEMORY=1.", "");
|
|
4490
|
+
}
|
|
4491
|
+
const record = await ctx.bulkTracker.getQualify(params.qualify_id);
|
|
4492
|
+
if (!record) {
|
|
4493
|
+
const any = await ctx.bulkTracker.get(params.qualify_id);
|
|
4494
|
+
if (any && any.kind !== "qualify") {
|
|
4495
|
+
throw client.makeError("BULK_WRONG_KIND", "This bulk_id was created by leadbay_enrich_titles, not leadbay_import_and_qualify", "Call leadbay_bulk_enrich_status with this id instead.", "");
|
|
3289
4496
|
}
|
|
3290
|
-
|
|
3291
|
-
if (err?.code !== "ENOENT")
|
|
3292
|
-
throw err;
|
|
4497
|
+
throw client.makeError("BULK_NOT_FOUND", "No qualify record for that qualify_id", "It may have expired (30-day TTL) or the MCP process was restarted without persistence. Re-launch via leadbay_import_and_qualify.", "");
|
|
3293
4498
|
}
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
// ─── Storage layer (file or memory) ──────────────────────────────────────
|
|
3297
|
-
async readAll() {
|
|
3298
|
-
if (this.backend === "memory")
|
|
3299
|
-
return [...this.memory];
|
|
3300
|
-
await this.ensureInitialized();
|
|
3301
|
-
let raw;
|
|
3302
|
-
try {
|
|
3303
|
-
raw = await readFile(this.path, "utf8");
|
|
3304
|
-
} catch (err) {
|
|
3305
|
-
if (err?.code === "ENOENT")
|
|
3306
|
-
return [];
|
|
3307
|
-
throw err;
|
|
4499
|
+
if (record.status === "pending") {
|
|
4500
|
+
throw client.makeError("BULK_PENDING", "Qualify record is in 'pending' state \u2014 the launch may be in flight or crashed before launch ack", "Retry leadbay_qualify_status in a few seconds. If it persists >60s, relaunch via leadbay_import_and_qualify.", "");
|
|
3308
4501
|
}
|
|
3309
|
-
|
|
4502
|
+
if (record.status === "failed") {
|
|
4503
|
+
throw client.makeError("BULK_LAUNCH_FAILED", "The original import_and_qualify launch failed; no qualifications were ordered", "Call leadbay_import_and_qualify again \u2014 the failed record won't block a fresh launch.", "");
|
|
4504
|
+
}
|
|
4505
|
+
let questionOrder = void 0;
|
|
3310
4506
|
try {
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
return [];
|
|
4507
|
+
const taste = await client.resolveTasteProfile();
|
|
4508
|
+
questionOrder = buildQuestionOrder(taste.qualificationQuestions ?? []);
|
|
4509
|
+
} catch {
|
|
3315
4510
|
}
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
4511
|
+
let notInLensSet = /* @__PURE__ */ new Set();
|
|
4512
|
+
try {
|
|
4513
|
+
const pre = await prequalifiedLeads(client, record.lead_ids, record.lens_id, ctx);
|
|
4514
|
+
notInLensSet = pre.not_in_lens;
|
|
4515
|
+
} catch {
|
|
3319
4516
|
}
|
|
3320
|
-
const
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
4517
|
+
const fresh = await refreshLeadStates(client, record.lead_ids, questionOrder);
|
|
4518
|
+
const failed = [];
|
|
4519
|
+
const qualified = [];
|
|
4520
|
+
const still_running = [];
|
|
4521
|
+
for (const r of fresh) {
|
|
4522
|
+
if (r._failedCode) {
|
|
4523
|
+
failed.push({ lead_id: r.lead_id, error: r._failedCode });
|
|
4524
|
+
continue;
|
|
4525
|
+
}
|
|
4526
|
+
if (notInLensSet.has(r.lead_id) && r._stillRunning) {
|
|
4527
|
+
continue;
|
|
4528
|
+
}
|
|
4529
|
+
if (r._stillRunning) {
|
|
4530
|
+
still_running.push({ lead_id: r.lead_id });
|
|
4531
|
+
continue;
|
|
3326
4532
|
}
|
|
4533
|
+
const { _stillRunning, _failedCode, ...rest } = r;
|
|
4534
|
+
qualified.push(rest);
|
|
3327
4535
|
}
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
if (typeof r.lens_id !== "number")
|
|
3347
|
-
throw new Error("invalid lens_id");
|
|
3348
|
-
if (r.selection_source !== "explicit" && r.selection_source !== "wishlist")
|
|
3349
|
-
throw new Error("invalid selection_source");
|
|
3350
|
-
if (r.status !== "pending" && r.status !== "launched" && r.status !== "failed")
|
|
3351
|
-
throw new Error("invalid status");
|
|
3352
|
-
if (typeof r.idempotency_key !== "string")
|
|
3353
|
-
throw new Error("invalid idempotency_key");
|
|
3354
|
-
return {
|
|
3355
|
-
bulk_id: r.bulk_id,
|
|
3356
|
-
launched_at: r.launched_at,
|
|
3357
|
-
lead_ids: r.lead_ids,
|
|
3358
|
-
titles: r.titles,
|
|
3359
|
-
email: r.email,
|
|
3360
|
-
phone: r.phone,
|
|
3361
|
-
lens_id: r.lens_id,
|
|
3362
|
-
selection_source: r.selection_source,
|
|
3363
|
-
status: r.status,
|
|
3364
|
-
idempotency_key: r.idempotency_key,
|
|
3365
|
-
durability: this.backend
|
|
4536
|
+
const out = {
|
|
4537
|
+
qualify_id: record.bulk_id,
|
|
4538
|
+
launched_at: record.launched_at,
|
|
4539
|
+
status: record.status,
|
|
4540
|
+
import_ids: record.import_ids,
|
|
4541
|
+
lens_id: record.lens_id,
|
|
4542
|
+
lead_ids: record.lead_ids,
|
|
4543
|
+
qualified,
|
|
4544
|
+
still_running,
|
|
4545
|
+
failed,
|
|
4546
|
+
not_in_lens: [...notInLensSet],
|
|
4547
|
+
region: client.region,
|
|
4548
|
+
_meta: client.lastMeta ?? {
|
|
4549
|
+
region: client.region,
|
|
4550
|
+
endpoint: "GET /leads/<id>/web_fetch + /ai_agent_responses",
|
|
4551
|
+
latency_ms: null,
|
|
4552
|
+
retry_after: null
|
|
4553
|
+
}
|
|
3366
4554
|
};
|
|
4555
|
+
if (record.per_lead_budget_ms !== void 0)
|
|
4556
|
+
out.per_lead_budget_ms = record.per_lead_budget_ms;
|
|
4557
|
+
if (record.total_budget_ms !== void 0)
|
|
4558
|
+
out.total_budget_ms = record.total_budget_ms;
|
|
4559
|
+
return out;
|
|
3367
4560
|
}
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
4561
|
+
};
|
|
4562
|
+
|
|
4563
|
+
// ../core/dist/composite/enrich-titles.js
|
|
4564
|
+
var DEFAULT_CANDIDATE_COUNT = 25;
|
|
4565
|
+
var enrichTitles = {
|
|
4566
|
+
name: "leadbay_enrich_titles",
|
|
4567
|
+
description: "Order contact enrichments by job title across many leads. Contacts are NOT returned by default with a lead (Leadbay keeps enrichment out-of-band to control cost); the agent requests them on demand via this tool when it's ready to actually reach out. Two modes: (A) NO titles param \u2014 returns the available titles + Leadbay's title_suggestions + auto_included_titles + a count of enrichable contacts, so the agent can ask the user which titles to enrich. (B) titles given \u2014 calls preview, then launches if there's anything enrichable. On 429 returns {status:'quota_exceeded'} cleanly. Selection lifecycle is wrapped in a try/finally so the user's selection is left clean even on error. When to use: as the agent's go-to enrichment entry point, immediately before proposing outreach. When NOT to use: to enrich a single contact \u2014 that's leadbay_enrich_contacts (granular). Speculatively, before the user has committed to outreaching \u2014 enrichment spends credits.",
|
|
4568
|
+
inputSchema: {
|
|
4569
|
+
type: "object",
|
|
4570
|
+
properties: {
|
|
4571
|
+
titles: {
|
|
4572
|
+
type: "array",
|
|
4573
|
+
items: { type: "string" },
|
|
4574
|
+
description: "Job titles to enrich. Omit to discover what's available without launching."
|
|
4575
|
+
},
|
|
4576
|
+
leadIds: {
|
|
4577
|
+
type: "array",
|
|
4578
|
+
items: { type: "string" },
|
|
4579
|
+
description: "Lead UUIDs to enrich. Omit to use the top page of the active lens's wishlist."
|
|
4580
|
+
},
|
|
4581
|
+
lensId: {
|
|
4582
|
+
type: "number",
|
|
4583
|
+
description: "Lens id (escape hatch \u2014 defaults to active)"
|
|
4584
|
+
},
|
|
4585
|
+
email: { type: "boolean", description: "Enrich emails (default true)" },
|
|
4586
|
+
phone: { type: "boolean", description: "Enrich phone numbers (default false)" },
|
|
4587
|
+
candidateCount: {
|
|
4588
|
+
type: "number",
|
|
4589
|
+
description: `When leadIds is omitted, how many top-of-wishlist leads to use (default ${DEFAULT_CANDIDATE_COUNT})`
|
|
4590
|
+
},
|
|
4591
|
+
dry_run: {
|
|
4592
|
+
type: "boolean",
|
|
4593
|
+
description: "If true, don't launch \u2014 only preview."
|
|
4594
|
+
}
|
|
3372
4595
|
}
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
const
|
|
3376
|
-
const
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
4596
|
+
},
|
|
4597
|
+
execute: async (client, params, ctx) => {
|
|
4598
|
+
const email = params.email ?? true;
|
|
4599
|
+
const phone = params.phone ?? false;
|
|
4600
|
+
if (!email && !phone) {
|
|
4601
|
+
return {
|
|
4602
|
+
error: true,
|
|
4603
|
+
code: "BAD_INPUT",
|
|
4604
|
+
message: "Either email or phone must be true",
|
|
4605
|
+
hint: "Set email:true (most common) or phone:true"
|
|
4606
|
+
};
|
|
3383
4607
|
}
|
|
3384
|
-
|
|
4608
|
+
const explicitLeadIds = params.leadIds && params.leadIds.length > 0;
|
|
4609
|
+
const selectionSource = explicitLeadIds ? "explicit" : "wishlist";
|
|
4610
|
+
const lensId = params.lensId ?? await client.resolveDefaultLens();
|
|
4611
|
+
let leadIds = params.leadIds;
|
|
4612
|
+
if (!leadIds || leadIds.length === 0) {
|
|
4613
|
+
const cnt = params.candidateCount ?? DEFAULT_CANDIDATE_COUNT;
|
|
4614
|
+
const wish = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${Math.min(cnt, 50)}&page=0`);
|
|
4615
|
+
leadIds = wish.items.map((l) => l.id);
|
|
4616
|
+
}
|
|
4617
|
+
if (leadIds.length === 0) {
|
|
4618
|
+
return {
|
|
4619
|
+
error: true,
|
|
4620
|
+
code: "NO_CANDIDATES",
|
|
4621
|
+
message: "No candidate leads",
|
|
4622
|
+
hint: "Pass leadIds explicitly or wait for the wishlist to compute"
|
|
4623
|
+
};
|
|
4624
|
+
}
|
|
4625
|
+
await client.acquireSelectionLock();
|
|
4626
|
+
try {
|
|
4627
|
+
const qs = leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
|
|
4628
|
+
await client.requestVoid("POST", `/leads/selection/select?${qs}`);
|
|
3385
4629
|
try {
|
|
3386
|
-
await
|
|
3387
|
-
|
|
3388
|
-
|
|
4630
|
+
const availableTitles = await client.request("GET", "/leads/selection/enrichment/job_titles");
|
|
4631
|
+
if (!params.titles || params.titles.length === 0) {
|
|
4632
|
+
let suggestions = [];
|
|
4633
|
+
let autoIncluded = [];
|
|
4634
|
+
let previouslyEnriched = [];
|
|
4635
|
+
let enrichableContacts = 0;
|
|
4636
|
+
try {
|
|
4637
|
+
const prev = await client.request("POST", "/leads/selection/enrichment/preview", { titles: [] });
|
|
4638
|
+
suggestions = prev.title_suggestions ?? [];
|
|
4639
|
+
autoIncluded = prev.auto_included_titles ?? [];
|
|
4640
|
+
previouslyEnriched = prev.previously_enriched_titles ?? [];
|
|
4641
|
+
enrichableContacts = prev.enrichable_contacts;
|
|
4642
|
+
} catch (e) {
|
|
4643
|
+
ctx?.logger?.warn?.(`enrich_titles: 0-titles preview failed: ${e?.message}`);
|
|
4644
|
+
}
|
|
4645
|
+
return {
|
|
4646
|
+
mode: "discover",
|
|
4647
|
+
available_titles: availableTitles,
|
|
4648
|
+
recommendations: suggestions,
|
|
4649
|
+
auto_included: autoIncluded,
|
|
4650
|
+
previously_enriched: previouslyEnriched,
|
|
4651
|
+
enrichable_contacts: enrichableContacts,
|
|
4652
|
+
selected_lead_count: leadIds.length,
|
|
4653
|
+
next_action: "Pick titles to enrich and call leadbay_enrich_titles again with titles=[...]"
|
|
4654
|
+
};
|
|
4655
|
+
}
|
|
4656
|
+
let preview;
|
|
4657
|
+
try {
|
|
4658
|
+
preview = await client.request("POST", "/leads/selection/enrichment/preview", { titles: params.titles });
|
|
4659
|
+
} catch (err) {
|
|
4660
|
+
if (err?.code === "QUOTA_EXCEEDED") {
|
|
4661
|
+
return {
|
|
4662
|
+
status: "quota_exceeded",
|
|
4663
|
+
message: "Quota exceeded on preview",
|
|
4664
|
+
retry_after_seconds: err?._meta?.retry_after ?? null
|
|
4665
|
+
};
|
|
4666
|
+
}
|
|
4667
|
+
throw err;
|
|
4668
|
+
}
|
|
4669
|
+
if (preview.enrichable_contacts === 0) {
|
|
4670
|
+
return {
|
|
4671
|
+
mode: "preview_only",
|
|
4672
|
+
preview,
|
|
4673
|
+
launched: false,
|
|
4674
|
+
message: "No enrichable contacts for the chosen titles. Try other titles from available_titles or recommendations.",
|
|
4675
|
+
available_titles: availableTitles
|
|
4676
|
+
};
|
|
4677
|
+
}
|
|
4678
|
+
if (params.dry_run) {
|
|
4679
|
+
return {
|
|
4680
|
+
mode: "dry_run",
|
|
4681
|
+
preview,
|
|
4682
|
+
launched: false,
|
|
4683
|
+
would_launch: { titles: params.titles, email, phone }
|
|
4684
|
+
};
|
|
4685
|
+
}
|
|
4686
|
+
const tracker = ctx?.bulkTracker;
|
|
4687
|
+
let bulkRecord;
|
|
4688
|
+
let bulkReused = false;
|
|
4689
|
+
let bulkSecondsSinceOriginal;
|
|
4690
|
+
if (tracker) {
|
|
4691
|
+
const res = await tracker.findOrCreatePending({
|
|
4692
|
+
lead_ids: leadIds,
|
|
4693
|
+
titles: params.titles,
|
|
4694
|
+
email,
|
|
4695
|
+
phone,
|
|
4696
|
+
lens_id: lensId,
|
|
4697
|
+
selection_source: selectionSource
|
|
4698
|
+
});
|
|
4699
|
+
bulkRecord = {
|
|
4700
|
+
bulk_id: res.record.bulk_id,
|
|
4701
|
+
launched_at: res.record.launched_at,
|
|
4702
|
+
durability: res.record.durability
|
|
4703
|
+
};
|
|
4704
|
+
bulkReused = res.reused;
|
|
4705
|
+
bulkSecondsSinceOriginal = res.seconds_since_original;
|
|
4706
|
+
if (bulkReused && res.record.status !== "failed") {
|
|
4707
|
+
return {
|
|
4708
|
+
mode: "already_launched",
|
|
4709
|
+
re_used: true,
|
|
4710
|
+
bulk_id: res.record.bulk_id,
|
|
4711
|
+
launched_at: res.record.launched_at,
|
|
4712
|
+
durability: res.record.durability,
|
|
4713
|
+
seconds_since_original_launch: bulkSecondsSinceOriginal ?? 0,
|
|
4714
|
+
titles: params.titles,
|
|
4715
|
+
email,
|
|
4716
|
+
phone,
|
|
4717
|
+
preview,
|
|
4718
|
+
message: `No new enrichment was ordered; quota not spent. An identical bulk was launched ${bulkSecondsSinceOriginal ?? 0}s ago. Poll leadbay_bulk_enrich_status with this bulk_id for results.`,
|
|
4719
|
+
next_action: "Call leadbay_bulk_enrich_status({bulk_id}) to check progress; include_contacts=true for the final read."
|
|
4720
|
+
};
|
|
4721
|
+
}
|
|
4722
|
+
}
|
|
4723
|
+
try {
|
|
4724
|
+
await client.requestVoid("POST", "/leads/selection/enrichment/launch", { titles: params.titles, email, phone });
|
|
4725
|
+
} catch (err) {
|
|
4726
|
+
if (bulkRecord && tracker) {
|
|
4727
|
+
try {
|
|
4728
|
+
await tracker.markFailed(bulkRecord.bulk_id);
|
|
4729
|
+
} catch (e) {
|
|
4730
|
+
ctx?.logger?.warn?.(`enrich_titles: tracker.markFailed failed: ${e?.message ?? e}`);
|
|
4731
|
+
}
|
|
4732
|
+
}
|
|
4733
|
+
if (err?.code === "QUOTA_EXCEEDED") {
|
|
4734
|
+
return {
|
|
4735
|
+
status: "quota_exceeded",
|
|
4736
|
+
preview,
|
|
4737
|
+
message: "Quota exceeded on launch",
|
|
4738
|
+
retry_after_seconds: err?._meta?.retry_after ?? null
|
|
4739
|
+
};
|
|
4740
|
+
}
|
|
3389
4741
|
throw err;
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
} else {
|
|
3412
|
-
this.logger?.info?.(`bulk.ttl_dropped bulk_id=${r.bulk_id} launched_at=${r.launched_at}`);
|
|
3413
|
-
}
|
|
3414
|
-
}
|
|
3415
|
-
return kept;
|
|
3416
|
-
}
|
|
3417
|
-
// ─── BulkTracker API ────────────────────────────────────────────────────
|
|
3418
|
-
async findOrCreatePending(args) {
|
|
3419
|
-
const { lead_ids, titles } = normalizeLaunchInputs(args);
|
|
3420
|
-
const idempotency_key = computeIdempotencyKey({
|
|
3421
|
-
lead_ids,
|
|
3422
|
-
titles,
|
|
3423
|
-
email: args.email,
|
|
3424
|
-
phone: args.phone,
|
|
3425
|
-
lens_id: args.lens_id
|
|
3426
|
-
});
|
|
3427
|
-
const window = args.idempotency_window_ms ?? DEFAULT_IDEMPOTENCY_WINDOW_MS;
|
|
3428
|
-
return this.mutex.run(async () => {
|
|
3429
|
-
const all = this.prune(await this.readAll());
|
|
3430
|
-
const nowMs = this.now();
|
|
3431
|
-
const existing = all.find((r) => r.idempotency_key === idempotency_key && r.status !== "failed" && nowMs - Date.parse(r.launched_at) < window);
|
|
3432
|
-
if (existing) {
|
|
3433
|
-
this.logger?.info?.(`bulk.reused bulk_id=${existing.bulk_id} seconds_since_original=${Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)}`);
|
|
4742
|
+
}
|
|
4743
|
+
if (bulkRecord && tracker) {
|
|
4744
|
+
try {
|
|
4745
|
+
await tracker.markLaunched(bulkRecord.bulk_id);
|
|
4746
|
+
} catch (e) {
|
|
4747
|
+
ctx?.logger?.warn?.(`enrich_titles: tracker.markLaunched failed: ${e?.message ?? e}`);
|
|
4748
|
+
return {
|
|
4749
|
+
mode: "launched_tracker_pending",
|
|
4750
|
+
launched: true,
|
|
4751
|
+
preview,
|
|
4752
|
+
bulk_id: bulkRecord.bulk_id,
|
|
4753
|
+
launched_at: bulkRecord.launched_at,
|
|
4754
|
+
durability: bulkRecord.durability,
|
|
4755
|
+
titles: params.titles,
|
|
4756
|
+
email,
|
|
4757
|
+
phone,
|
|
4758
|
+
message: "Enrichment job launched on the backend, but the local tracker record could not be flipped to 'launched'. The bulk_id is still valid \u2014 leadbay_bulk_enrich_status will return status:'pending' until the tracker heals.",
|
|
4759
|
+
next_action: "Wait ~60s, then call leadbay_bulk_enrich_status({bulk_id}). If it persists, restart the MCP."
|
|
4760
|
+
};
|
|
4761
|
+
}
|
|
4762
|
+
}
|
|
3434
4763
|
return {
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
4764
|
+
mode: "launched",
|
|
4765
|
+
preview,
|
|
4766
|
+
launched: true,
|
|
4767
|
+
titles: params.titles,
|
|
4768
|
+
email,
|
|
4769
|
+
phone,
|
|
4770
|
+
bulk_id: bulkRecord?.bulk_id,
|
|
4771
|
+
launched_at: bulkRecord?.launched_at,
|
|
4772
|
+
durability: bulkRecord?.durability,
|
|
4773
|
+
message: bulkRecord ? "Enrichment job launched. Backend has no server-side bulk_id yet; MCP minted a client-side bulk_id (persisted to disk by default) so you can poll via leadbay_bulk_enrich_status." : "Enrichment job launched. No bulk_id tracker configured \u2014 poll leadbay_get_contacts per lead after ~60s; contact.enrichment.done flips to true.",
|
|
4774
|
+
next_action: bulkRecord ? "Call leadbay_bulk_enrich_status({bulk_id}) after ~60s; pass include_contacts=true for the final read." : "Wait ~60s, then call leadbay_research_lead or leadbay_get_contacts on the leads you care about."
|
|
3438
4775
|
};
|
|
4776
|
+
} finally {
|
|
4777
|
+
try {
|
|
4778
|
+
await client.requestVoid("POST", "/leads/selection/clear");
|
|
4779
|
+
} catch (e) {
|
|
4780
|
+
ctx?.logger?.warn?.(`enrich_titles: selection.clear failed: ${e?.message ?? e?.code}`);
|
|
4781
|
+
}
|
|
3439
4782
|
}
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
launched_at: new Date(nowMs).toISOString(),
|
|
3443
|
-
lead_ids,
|
|
3444
|
-
titles,
|
|
3445
|
-
email: args.email,
|
|
3446
|
-
phone: args.phone,
|
|
3447
|
-
lens_id: args.lens_id,
|
|
3448
|
-
selection_source: args.selection_source,
|
|
3449
|
-
status: "pending",
|
|
3450
|
-
idempotency_key,
|
|
3451
|
-
durability: this.backend
|
|
3452
|
-
};
|
|
3453
|
-
all.push(record);
|
|
3454
|
-
await this.writeAll(all);
|
|
3455
|
-
this.logger?.info?.(`bulk.registered bulk_id=${record.bulk_id} lens_id=${record.lens_id} lead_count=${record.lead_ids.length} titles_count=${record.titles.length} durability=${record.durability}`);
|
|
3456
|
-
return { record, reused: false };
|
|
3457
|
-
});
|
|
3458
|
-
}
|
|
3459
|
-
async markLaunched(bulk_id) {
|
|
3460
|
-
return this.mutex.run(async () => {
|
|
3461
|
-
const all = this.prune(await this.readAll());
|
|
3462
|
-
const idx = all.findIndex((r) => r.bulk_id === bulk_id);
|
|
3463
|
-
if (idx < 0) {
|
|
3464
|
-
throw new Error(`bulk_id not found: ${bulk_id}`);
|
|
3465
|
-
}
|
|
3466
|
-
all[idx] = { ...all[idx], status: "launched" };
|
|
3467
|
-
await this.writeAll(all);
|
|
3468
|
-
this.logger?.info?.(`bulk.launched bulk_id=${bulk_id}`);
|
|
3469
|
-
return all[idx];
|
|
3470
|
-
});
|
|
3471
|
-
}
|
|
3472
|
-
async markFailed(bulk_id) {
|
|
3473
|
-
return this.mutex.run(async () => {
|
|
3474
|
-
const all = this.prune(await this.readAll());
|
|
3475
|
-
const idx = all.findIndex((r) => r.bulk_id === bulk_id);
|
|
3476
|
-
if (idx < 0) {
|
|
3477
|
-
return;
|
|
3478
|
-
}
|
|
3479
|
-
all[idx] = { ...all[idx], status: "failed" };
|
|
3480
|
-
await this.writeAll(all);
|
|
3481
|
-
this.logger?.info?.(`bulk.launch_failed bulk_id=${bulk_id}`);
|
|
3482
|
-
});
|
|
3483
|
-
}
|
|
3484
|
-
async get(bulk_id) {
|
|
3485
|
-
return this.mutex.run(async () => {
|
|
3486
|
-
const all = this.prune(await this.readAll());
|
|
3487
|
-
return all.find((r) => r.bulk_id === bulk_id);
|
|
3488
|
-
});
|
|
3489
|
-
}
|
|
3490
|
-
async list() {
|
|
3491
|
-
return this.mutex.run(async () => {
|
|
3492
|
-
const all = this.prune(await this.readAll());
|
|
3493
|
-
return [...all].sort((a, b) => Date.parse(b.launched_at) - Date.parse(a.launched_at));
|
|
3494
|
-
});
|
|
3495
|
-
}
|
|
3496
|
-
};
|
|
3497
|
-
async function openTmpFileExclusive(path) {
|
|
3498
|
-
try {
|
|
3499
|
-
return await fsOpen(path, fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL, 384);
|
|
3500
|
-
} catch (err) {
|
|
3501
|
-
if (err?.code === "EEXIST") {
|
|
3502
|
-
await unlink(path).catch(() => {
|
|
3503
|
-
});
|
|
3504
|
-
return fsOpen(path, fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL, 384);
|
|
4783
|
+
} finally {
|
|
4784
|
+
client.releaseSelectionLock();
|
|
3505
4785
|
}
|
|
3506
|
-
throw err;
|
|
3507
|
-
}
|
|
3508
|
-
}
|
|
3509
|
-
var InMemoryBulkStore = class extends LocalBulkStore {
|
|
3510
|
-
constructor(opts = {}) {
|
|
3511
|
-
super({ backend: "memory", logger: opts.logger, now: opts.now });
|
|
3512
4786
|
}
|
|
3513
4787
|
};
|
|
3514
|
-
async function createDefaultBulkStore(opts = {}) {
|
|
3515
|
-
const env = opts.env ?? process.env;
|
|
3516
|
-
const allowMemory = env.LEADBAY_BULK_STORE_ALLOW_MEMORY === "1";
|
|
3517
|
-
const allowUnsafePath = env.LEADBAY_BULK_STORE_PATH_UNSAFE === "1";
|
|
3518
|
-
const path = env.LEADBAY_BULK_STORE_PATH ?? resolvePath(homedir(), ".leadbay", "bulks.json");
|
|
3519
|
-
try {
|
|
3520
|
-
const store = new LocalBulkStore({
|
|
3521
|
-
backend: "file",
|
|
3522
|
-
path,
|
|
3523
|
-
logger: opts.logger,
|
|
3524
|
-
allowUnsafePath
|
|
3525
|
-
});
|
|
3526
|
-
await store.ensureInitialized();
|
|
3527
|
-
await stat(dirname(path));
|
|
3528
|
-
return store;
|
|
3529
|
-
} catch (err) {
|
|
3530
|
-
if (!allowMemory) {
|
|
3531
|
-
const msg = `bulk store init failed at ${path}: ${err?.message ?? err}. Set LEADBAY_BULK_STORE_ALLOW_MEMORY=1 to fall back to in-memory (handles won't survive MCP restart), or set LEADBAY_BULK_STORE_PATH to a writable path.`;
|
|
3532
|
-
opts.logger?.error?.(msg);
|
|
3533
|
-
throw new Error(msg);
|
|
3534
|
-
}
|
|
3535
|
-
opts.logger?.warn?.(`bulk.fallback_memory path=${path} reason=${err?.message ?? err}`);
|
|
3536
|
-
return new LocalBulkStore({ backend: "memory", logger: opts.logger });
|
|
3537
|
-
}
|
|
3538
|
-
}
|
|
3539
4788
|
|
|
3540
4789
|
// ../core/dist/composite/bulk-enrich-status.js
|
|
3541
4790
|
var STATUS_FETCH_CONCURRENCY = 5;
|
|
@@ -3608,6 +4857,15 @@ var bulkEnrichStatus = {
|
|
|
3608
4857
|
hint: "The record may have aged out (30-day TTL) or the MCP process was restarted without persistence. Launch a new enrichment via leadbay_enrich_titles."
|
|
3609
4858
|
};
|
|
3610
4859
|
}
|
|
4860
|
+
if (record.kind === "qualify") {
|
|
4861
|
+
return {
|
|
4862
|
+
error: true,
|
|
4863
|
+
code: "BULK_WRONG_KIND",
|
|
4864
|
+
message: "This bulk_id was created by leadbay_import_and_qualify, not leadbay_enrich_titles.",
|
|
4865
|
+
hint: "Call leadbay_qualify_status with this id instead.",
|
|
4866
|
+
bulk_id: record.bulk_id
|
|
4867
|
+
};
|
|
4868
|
+
}
|
|
3611
4869
|
if (record.status === "pending") {
|
|
3612
4870
|
return {
|
|
3613
4871
|
error: true,
|
|
@@ -4236,7 +5494,8 @@ var granularReadTools = [
|
|
|
4236
5494
|
getProspectingActions,
|
|
4237
5495
|
getWebFetch,
|
|
4238
5496
|
getSelectionIds,
|
|
4239
|
-
getEnrichmentJobTitles
|
|
5497
|
+
getEnrichmentJobTitles,
|
|
5498
|
+
listMappableFields
|
|
4240
5499
|
];
|
|
4241
5500
|
var granularWriteTools = [
|
|
4242
5501
|
qualifyLead,
|
|
@@ -4274,6 +5533,11 @@ var compositeReadTools = [
|
|
|
4274
5533
|
recallOrderedTitles,
|
|
4275
5534
|
accountStatus,
|
|
4276
5535
|
bulkEnrichStatus,
|
|
5536
|
+
qualifyStatus,
|
|
5537
|
+
// listMappableFields is granular-shaped but the import composites depend on
|
|
5538
|
+
// it for discoverability; expose it always-on so agents can find custom fields
|
|
5539
|
+
// without needing LEADBAY_MCP_ADVANCED=1.
|
|
5540
|
+
listMappableFields,
|
|
4277
5541
|
// Keep the existing composites available too.
|
|
4278
5542
|
researchCompany,
|
|
4279
5543
|
prepareOutreach
|
|
@@ -4285,7 +5549,8 @@ var compositeWriteTools = [
|
|
|
4285
5549
|
refinePrompt,
|
|
4286
5550
|
answerClarification,
|
|
4287
5551
|
reportOutreach,
|
|
4288
|
-
importLeads
|
|
5552
|
+
importLeads,
|
|
5553
|
+
importAndQualify
|
|
4289
5554
|
];
|
|
4290
5555
|
var compositeTools = [
|
|
4291
5556
|
...compositeReadTools,
|
|
@@ -4323,6 +5588,8 @@ export {
|
|
|
4323
5588
|
getWebFetch,
|
|
4324
5589
|
getSelectionIds,
|
|
4325
5590
|
getEnrichmentJobTitles,
|
|
5591
|
+
importLeads,
|
|
5592
|
+
listMappableFields,
|
|
4326
5593
|
selectLeads,
|
|
4327
5594
|
deselectLeads,
|
|
4328
5595
|
clearSelection,
|
|
@@ -4347,12 +5614,13 @@ export {
|
|
|
4347
5614
|
recallOrderedTitles,
|
|
4348
5615
|
accountStatus,
|
|
4349
5616
|
bulkQualifyLeads,
|
|
4350
|
-
|
|
4351
|
-
enrichTitles,
|
|
5617
|
+
importAndQualify,
|
|
4352
5618
|
isValidBulkId,
|
|
4353
5619
|
LocalBulkStore,
|
|
4354
5620
|
InMemoryBulkStore,
|
|
4355
5621
|
createDefaultBulkStore,
|
|
5622
|
+
qualifyStatus,
|
|
5623
|
+
enrichTitles,
|
|
4356
5624
|
bulkEnrichStatus,
|
|
4357
5625
|
adjustAudience,
|
|
4358
5626
|
refinePrompt,
|