@leadbay/mcp 0.2.2 → 0.4.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.
@@ -17,7 +17,7 @@ function httpsRequest(method, url, headers, body) {
17
17
  const start = Date.now();
18
18
  const parsed = new URL(url);
19
19
  const reqHeaders = { ...headers };
20
- if (body) {
20
+ if (body !== void 0) {
21
21
  reqHeaders["Content-Length"] = Buffer.byteLength(body);
22
22
  }
23
23
  const req = https.request({
@@ -39,7 +39,7 @@ function httpsRequest(method, url, headers, body) {
39
39
  });
40
40
  });
41
41
  req.on("error", reject);
42
- if (body)
42
+ if (body !== void 0)
43
43
  req.write(body);
44
44
  req.end();
45
45
  });
@@ -52,6 +52,14 @@ function createClient(config = {}) {
52
52
  }
53
53
  return new LeadbayClient(baseUrl, config.token, region);
54
54
  }
55
+ function formatLoginError(status, body, baseUrl) {
56
+ const trimmed = body.trim();
57
+ const head = `login failed (${status}) at ${baseUrl}`;
58
+ const hint = status === 401 ? " (wrong email or password?)" : status === 429 ? " (rate-limited; wait and retry)" : status >= 500 ? " (server error; try again shortly)" : "";
59
+ if (!trimmed)
60
+ return head + hint;
61
+ return `${head}: ${trimmed.slice(0, 200)}${hint}`;
62
+ }
55
63
  async function resolveRegion(email, password, startWith = "us") {
56
64
  const order = startWith === "fr" ? ["fr", "us"] : ["us", "fr"];
57
65
  let lastErr = null;
@@ -71,12 +79,13 @@ async function resolveRegion(email, password, startWith = "us") {
71
79
  };
72
80
  }
73
81
  }
74
- lastErr = { status: res.status, body: res.body, region };
82
+ lastErr = { kind: "http", status: res.status, body: res.body, region, baseUrl };
75
83
  } catch (e) {
76
- lastErr = { error: e, region };
84
+ lastErr = { kind: "network", error: e, region, baseUrl };
77
85
  }
78
86
  }
79
- throw new Error(`Leadbay login failed in both regions (us, fr). Last response: ${JSON.stringify(lastErr)}`);
87
+ const detail = lastErr?.kind === "http" ? formatLoginError(lastErr.status, lastErr.body, lastErr.baseUrl) : lastErr?.kind === "network" ? `network error at ${lastErr.baseUrl}: ${lastErr.error?.message ?? String(lastErr.error)}` : "no attempts made";
88
+ throw new Error(`Leadbay login failed in both regions (us, fr). ${detail}`);
80
89
  }
81
90
  var _mockFixtures = null;
82
91
  var _mockJournal = [];
@@ -300,6 +309,43 @@ var LeadbayClient = class {
300
309
  this.releaseSemaphore();
301
310
  }
302
311
  }
312
+ // Like request(), but the caller supplies the Content-Type and the already-
313
+ // serialized body (string for text payloads such as CSV; Buffer for binary
314
+ // uploads). Auth, semaphore, error mapping, _lastMeta, and mock-mode all
315
+ // mirror request() exactly. Used by leadbay_import_leads to upload CSVs to
316
+ // the wizard at POST /1.5/imports.
317
+ async requestRawBinary(method, path, contentType, body) {
318
+ if (process.env.LEADBAY_MOCK === "1") {
319
+ return this.mockRequestBinary(method, path, contentType, body);
320
+ }
321
+ if (!this.token) {
322
+ throw this.makeError("NOT_AUTHENTICATED", "Not logged in to Leadbay", "Set LEADBAY_TOKEN in your MCP client config, or run: npx -y @leadbay/mcp install --email <you> --region <us|fr>", path);
323
+ }
324
+ await this.acquireSemaphore();
325
+ try {
326
+ const url = `${this._baseUrl}/1.5${path}`;
327
+ const headers = {
328
+ Authorization: `Bearer ${this.token}`,
329
+ "Content-Type": contentType
330
+ };
331
+ const res = await httpsRequest(method, url, headers, body);
332
+ this._lastMeta = {
333
+ region: this._region,
334
+ endpoint: `${method} ${path}`,
335
+ latency_ms: res.latency_ms,
336
+ retry_after: parseRetryAfter(res.headers["retry-after"])
337
+ };
338
+ if (res.status === 204) {
339
+ return null;
340
+ }
341
+ if (res.status < 200 || res.status >= 300) {
342
+ throw this.mapErrorResponse(res.status, res.body, path, res.headers);
343
+ }
344
+ return JSON.parse(res.body);
345
+ } finally {
346
+ this.releaseSemaphore();
347
+ }
348
+ }
303
349
  mockRequest(method, path, body) {
304
350
  const fullPath = `/1.5${path}`;
305
351
  this._lastMeta = {
@@ -323,6 +369,39 @@ var LeadbayClient = class {
323
369
  would_call: { method, path: fullPath, body }
324
370
  };
325
371
  }
372
+ mockRequestBinary(method, path, contentType, body) {
373
+ const fullPath = `/1.5${path}`;
374
+ this._lastMeta = {
375
+ region: this._region,
376
+ endpoint: `${method} ${path}`,
377
+ latency_ms: 0,
378
+ retry_after: null
379
+ };
380
+ if (method === "GET") {
381
+ const fixture = findMockFixture("GET", fullPath);
382
+ if (!fixture) {
383
+ throw this.makeError("MOCK_NOT_FOUND", `LEADBAY_MOCK=1: no fixture for GET ${path}`, `Add a fixture to LEADBAY_MOCK_DIR (default: ./.context/leadbay-live-shapes/) \u2014 run a probe script to generate one.`, path);
384
+ }
385
+ if (fixture.status === 204)
386
+ return null;
387
+ return fixture.body;
388
+ }
389
+ const journalBody = {
390
+ _binary: true,
391
+ length: Buffer.byteLength(body),
392
+ content_type: contentType
393
+ };
394
+ _mockJournal.push({
395
+ method,
396
+ path: fullPath,
397
+ body: journalBody,
398
+ ts: Date.now()
399
+ });
400
+ return {
401
+ mocked: true,
402
+ would_call: { method, path: fullPath, body: journalBody }
403
+ };
404
+ }
326
405
  mapErrorResponse(status, rawBody, endpoint, headers) {
327
406
  let parsed;
328
407
  try {
@@ -587,6 +666,11 @@ var getLeadProfile = {
587
666
  },
588
667
  execute: async (client, params) => {
589
668
  const lensId = params.lensId ?? await client.resolveDefaultLens();
669
+ void client.request("POST", "/interactions", [
670
+ { type: "LEAD_SEEN", leadId: params.leadId, lensId: String(lensId) },
671
+ { type: "LEAD_CLICKED", leadId: params.leadId, lensId: String(lensId) }
672
+ ]).catch(() => {
673
+ });
590
674
  const [leadResult, qualResult, contactsResult, paidContactsResult, webFetchResult] = await Promise.allSettled([
591
675
  client.request("GET", `/lenses/${lensId}/leads/${params.leadId}`),
592
676
  client.request("GET", `/leads/${params.leadId}/ai_agent_responses`),
@@ -1322,12 +1406,12 @@ var setUserPrompt = {
1322
1406
  would_call: {
1323
1407
  method: "POST",
1324
1408
  path: `/organizations/${orgId}/user_prompt`,
1325
- body: { prompt: params.prompt }
1409
+ body: { user_prompt: params.prompt }
1326
1410
  }
1327
1411
  };
1328
1412
  }
1329
1413
  await client.requestVoid("POST", `/organizations/${orgId}/user_prompt`, {
1330
- prompt: params.prompt
1414
+ user_prompt: params.prompt
1331
1415
  });
1332
1416
  client.invalidateMe();
1333
1417
  return { set: true };
@@ -1802,6 +1886,11 @@ var researchLead = {
1802
1886
  execute: async (client, params, ctx) => {
1803
1887
  const lensId = params.lensId ?? await client.resolveDefaultLens();
1804
1888
  const leadId = params.leadId;
1889
+ void client.request("POST", "/interactions", [
1890
+ { type: "LEAD_SEEN", leadId, lensId: String(lensId) },
1891
+ { type: "LEAD_CLICKED", leadId, lensId: String(lensId) }
1892
+ ]).catch(() => {
1893
+ });
1805
1894
  const [profileR, qualR, contactsR, webFetchR] = await Promise.allSettled([
1806
1895
  client.request("GET", `/lenses/${lensId}/leads/${leadId}`),
1807
1896
  client.request("GET", `/leads/${leadId}/ai_agent_responses`),
@@ -2190,6 +2279,680 @@ var bulkQualifyLeads = {
2190
2279
  }
2191
2280
  };
2192
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");
2442
+ }
2443
+ }
2444
+ function coerceCell(client, v, path) {
2445
+ if (v == null)
2446
+ return "";
2447
+ if (typeof v === "string")
2448
+ return v;
2449
+ if (typeof v === "number" || typeof v === "boolean")
2450
+ return String(v);
2451
+ 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");
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
+ };
2492
+ }
2493
+ function prepareRecordsMode(client, records, mappings) {
2494
+ if (!mappings || !mappings.fields || typeof mappings.fields !== "object") {
2495
+ throw client.makeError("IMPORT_MAPPING_REQUIRED", "records[] requires a mappings.fields object", "Pass `mappings: { fields: { CsvColumn: 'LEAD_NAME', ... } }`.", "POST /imports");
2496
+ }
2497
+ const fieldEntries = Object.entries(mappings.fields);
2498
+ if (fieldEntries.length === 0) {
2499
+ 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");
2500
+ }
2501
+ const targets = new Set(fieldEntries.map(([, v]) => v));
2502
+ if (!targets.has("LEAD_NAME") && !targets.has("LEAD_WEBSITE")) {
2503
+ 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");
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
+ }
2510
+ }
2511
+ const headerSet = /* @__PURE__ */ new Set();
2512
+ const coercedRecords = [];
2513
+ records.forEach((rec, i) => {
2514
+ if (rec == null || typeof rec !== "object" || Array.isArray(rec)) {
2515
+ throw client.makeError("IMPORT_INVALID_CELL_TYPE", `records[${i}] must be a plain object`, `Pass each record as { ColumnName: value, ... }.`, "POST /imports");
2516
+ }
2517
+ const out = {};
2518
+ for (const [k, v] of Object.entries(rec)) {
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
+ }
2532
+ }
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
+ return {
2559
+ mode: "records",
2560
+ validInputs,
2561
+ malformedDomains: [],
2562
+ byDomain,
2563
+ byRowId,
2564
+ header,
2565
+ mappings: {
2566
+ fields: { ...mappings.fields },
2567
+ statuses: mappings.statuses ?? {},
2568
+ default_status: mappings.default_status ?? null
2569
+ }
2570
+ };
2571
+ }
2572
+ async function pollUntil(fn, done, budgetMs, signal, ctx, label) {
2573
+ const deadline = Date.now() + budgetMs;
2574
+ let last;
2575
+ while (true) {
2576
+ checkAborted(signal);
2577
+ last = await fn();
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
+ }
2662
+ }
2663
+ async function runOneChunk(client, chunk, chunkIdx, totalChunks, header, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onImportId) {
2664
+ const csv = synthesizeCsv(header, chunk.map((c) => c.row));
2665
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2666
+ const fileName = `mcp-import-${ts}-${chunkIdx}.csv`;
2667
+ ctx?.logger?.info?.(`import-leads: uploading chunk ${chunkIdx + 1}/${totalChunks} (${chunk.length} rows, ${csv.length}B)`);
2668
+ const upload = await client.requestRawBinary("POST", `/imports?file_name=${encodeURIComponent(fileName)}`, "text/csv", csv);
2669
+ const importId = upload.id;
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 };
2686
+ }
2687
+ function reconcileOneChunk(prep, chunk, matched, notImported) {
2688
+ const seenInputIndex = /* @__PURE__ */ new Set();
2689
+ const sortedRecords = [...chunk.records].sort((a, b) => {
2690
+ const aHasLead = a.lead?.id ? 0 : 1;
2691
+ const bHasLead = b.lead?.id ? 0 : 1;
2692
+ return aHasLead - bHasLead;
2693
+ });
2694
+ for (const rec of sortedRecords) {
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;
2723
+ }
2724
+ seenInputIndex.add(inputIdx);
2725
+ const inp = prep.validInputs[inputIdx];
2726
+ const matchType = (rec.match_type ?? rec.matchType ?? "").toString();
2727
+ if (rec.lead?.id) {
2728
+ matched.set(inputIdx, {
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" });
2738
+ }
2739
+ }
2740
+ }
2741
+ var importLeads = {
2742
+ name: "leadbay_import_leads",
2743
+ 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\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\nRequires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role on the Leadbay account; active billing.",
2744
+ write: true,
2745
+ version: "0.2.0",
2746
+ inputSchema: {
2747
+ type: "object",
2748
+ properties: {
2749
+ domains: {
2750
+ type: "array",
2751
+ description: "Mode A: list of company domains to map to Leadbay leadIds. Mutually exclusive with `records`.",
2752
+ items: {
2753
+ type: "object",
2754
+ properties: {
2755
+ domain: {
2756
+ type: "string",
2757
+ description: "Company domain (e.g. 'apple.com'). Protocol/path are stripped."
2758
+ },
2759
+ name: {
2760
+ type: "string",
2761
+ description: "Optional display name override; defaults to the domain."
2762
+ }
2763
+ },
2764
+ required: ["domain"]
2765
+ }
2766
+ },
2767
+ records: {
2768
+ 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`. Must be accompanied by `mappings.fields`. The tool synthesizes a CSV from the union of all record keys.",
2770
+ items: { type: "object" }
2771
+ },
2772
+ mappings: {
2773
+ type: "object",
2774
+ description: "Mode B: how each CSV column maps to Leadbay's CRM field schema. Required when `records` is supplied; ignored otherwise.",
2775
+ properties: {
2776
+ fields: {
2777
+ type: "object",
2778
+ description: "Object whose keys are CSV column names (matching keys in `records`) and whose values are Leadbay CRM field types (LEAD_NAME, LEAD_WEBSITE, LEAD_STATUS, LEAD_LOCATION, LEAD_SECTOR, LEAD_SIZE, CRM_ID, LEADBAY_ID, EMAIL, DEAL_CRM_ID, CONTACT_TITLE, LEAD_STATUS_DATE). At least one entry must target LEAD_NAME or LEAD_WEBSITE \u2014 the wizard needs that to find leads."
2779
+ },
2780
+ statuses: {
2781
+ type: "object",
2782
+ description: "Optional status string mapping (rarely needed). Defaults to {}."
2783
+ },
2784
+ default_status: {
2785
+ type: ["string", "null"],
2786
+ description: "Optional default status. Defaults to null."
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."
2794
+ },
2795
+ per_phase_budget_ms: {
2796
+ type: "number",
2797
+ description: `Single poll-loop cap (default ${DEFAULT_PER_PHASE_BUDGET_MS}ms).`
2798
+ },
2799
+ total_budget_ms: {
2800
+ type: "number",
2801
+ description: `Overall cap across all phases (default ${DEFAULT_TOTAL_BUDGET_MS2}ms).`
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
+
2193
2956
  // ../core/dist/composite/enrich-titles.js
2194
2957
  var DEFAULT_CANDIDATE_COUNT = 25;
2195
2958
  var enrichTitles = {
@@ -2421,7 +3184,7 @@ import { mkdir as mkdirAsync, lstat, open as fsOpen, readFile, rename, stat, unl
2421
3184
  import { constants as fsConstants } from "fs";
2422
3185
  import { dirname, resolve as resolvePath } from "path";
2423
3186
  import { homedir, platform } from "os";
2424
- import { createHash, randomUUID } from "crypto";
3187
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
2425
3188
  var DEFAULT_IDEMPOTENCY_WINDOW_MS = 5 * 60 * 1e3;
2426
3189
  var TTL_MS = 30 * 24 * 60 * 60 * 1e3;
2427
3190
  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;
@@ -2675,7 +3438,7 @@ var LocalBulkStore = class {
2675
3438
  };
2676
3439
  }
2677
3440
  const record = {
2678
- bulk_id: randomUUID(),
3441
+ bulk_id: randomUUID2(),
2679
3442
  launched_at: new Date(nowMs).toISOString(),
2680
3443
  lead_ids,
2681
3444
  titles,
@@ -3205,14 +3968,14 @@ var refinePrompt = {
3205
3968
  would_call: {
3206
3969
  method: "POST",
3207
3970
  path: `/organizations/${orgId}/user_prompt`,
3208
- body: { prompt: params.prompt }
3971
+ body: { user_prompt: params.prompt }
3209
3972
  }
3210
3973
  };
3211
3974
  }
3212
3975
  const postedAt = Date.now();
3213
3976
  const STALE_GUARD_MS = 5e3;
3214
3977
  await client.requestVoid("POST", `/organizations/${orgId}/user_prompt`, {
3215
- prompt: params.prompt
3978
+ user_prompt: params.prompt
3216
3979
  });
3217
3980
  client.invalidateMe();
3218
3981
  const attempts = params.clarification_poll_attempts ?? DEFAULT_POLL_ATTEMPTS;
@@ -3521,7 +4284,8 @@ var compositeWriteTools = [
3521
4284
  adjustAudience,
3522
4285
  refinePrompt,
3523
4286
  answerClarification,
3524
- reportOutreach
4287
+ reportOutreach,
4288
+ importLeads
3525
4289
  ];
3526
4290
  var compositeTools = [
3527
4291
  ...compositeReadTools,
@@ -3532,6 +4296,7 @@ var tools = [...compositeTools, ...granularTools];
3532
4296
  export {
3533
4297
  REGIONS,
3534
4298
  createClient,
4299
+ formatLoginError,
3535
4300
  resolveRegion,
3536
4301
  getMockJournal,
3537
4302
  clearMockJournal,
@@ -3582,6 +4347,7 @@ export {
3582
4347
  recallOrderedTitles,
3583
4348
  accountStatus,
3584
4349
  bulkQualifyLeads,
4350
+ importLeads,
3585
4351
  enrichTitles,
3586
4352
  isValidBulkId,
3587
4353
  LocalBulkStore,