@leadbay/mcp 0.2.1 → 0.3.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 = [];
@@ -240,7 +249,7 @@ var LeadbayClient = class {
240
249
  return this.mockRequest(method, path, body);
241
250
  }
242
251
  if (!this.token) {
243
- throw this.makeError("NOT_AUTHENTICATED", "Not logged in to Leadbay", "Set LEADBAY_TOKEN env var (obtain token at https://app.leadbay.ai/settings/api-tokens), or use the OpenClaw leadbay_login tool", path);
252
+ 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);
244
253
  }
245
254
  await this.acquireSemaphore();
246
255
  try {
@@ -275,7 +284,7 @@ var LeadbayClient = class {
275
284
  return;
276
285
  }
277
286
  if (!this.token) {
278
- throw this.makeError("NOT_AUTHENTICATED", "Not logged in to Leadbay", "Set LEADBAY_TOKEN env var (obtain token at https://app.leadbay.ai/settings/api-tokens), or use the OpenClaw leadbay_login tool", path);
287
+ 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);
279
288
  }
280
289
  await this.acquireSemaphore();
281
290
  try {
@@ -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 {
@@ -332,7 +411,7 @@ var LeadbayClient = class {
332
411
  }
333
412
  const retryAfter = parseRetryAfter(headers["retry-after"]);
334
413
  if (status === 401) {
335
- return this.makeError("AUTH_EXPIRED", "Authentication token expired or invalid", "Your LEADBAY_TOKEN is no longer valid. Regenerate at https://app.leadbay.ai/settings/api-tokens and restart.", endpoint);
414
+ return this.makeError("AUTH_EXPIRED", "Authentication token expired or invalid", "Your LEADBAY_TOKEN is no longer valid. Regenerate it: npx -y @leadbay/mcp login --email <you> --region <us|fr>, then restart your MCP client.", endpoint);
336
415
  }
337
416
  if (status === 429 || status === 402 || parsed?.error === "quota_exceeded" || parsed?.error?.code === "quota_exceeded") {
338
417
  return this.makeError("QUOTA_EXCEEDED", retryAfter ? `Quota exceeded \u2014 retry in ${retryAfter}s` : "Quota exceeded", retryAfter ? `Wait ${retryAfter}s before retrying. Check leadbay_get_quota to see which resource window was hit.` : "Wait, then retry. Check leadbay_get_quota to see which resource window (daily/weekly/monthly) was hit.", endpoint, retryAfter);
@@ -340,9 +419,9 @@ var LeadbayClient = class {
340
419
  if (status === 403) {
341
420
  const msg = parsed?.message || parsed?.error || parsed?.error?.message || "";
342
421
  if (typeof msg === "string" && (msg.includes("suspend") || msg.includes("billing"))) {
343
- return this.makeError("BILLING_SUSPENDED", "Account billing is suspended", "Your Leadbay account billing is suspended. Update at https://app.leadbay.ai", endpoint);
422
+ return this.makeError("BILLING_SUSPENDED", "Account billing is suspended", "Your Leadbay account billing is suspended. Contact Leadbay support.", endpoint);
344
423
  }
345
- return this.makeError("FORBIDDEN", "Insufficient permissions", "Your token does not have access to this resource. Check account permissions at https://app.leadbay.ai", endpoint);
424
+ return this.makeError("FORBIDDEN", "Insufficient permissions", "Your token does not have access to this resource. Contact Leadbay support to verify account permissions.", endpoint);
346
425
  }
347
426
  if (status === 404) {
348
427
  return this.makeError("NOT_FOUND", parsed?.message || parsed?.error?.message || "Resource not found", "Verify the ID is correct", endpoint);
@@ -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`),
@@ -754,7 +838,7 @@ var getTasteProfile = {
754
838
  question: q.question
755
839
  })),
756
840
  ...isEmpty ? {
757
- hint: "No taste profile configured yet. Set it up at app.leadbay.ai for better lead matching."
841
+ hint: "No taste profile configured yet. Use leadbay_refine_prompt or contact Leadbay support to set one up for better lead matching."
758
842
  } : {}
759
843
  };
760
844
  }
@@ -827,7 +911,7 @@ var enrichContacts = {
827
911
  const me = await client.request("GET", "/users/me");
828
912
  creditsRemaining = me.organization.billing?.ai_credits ?? null;
829
913
  if (creditsRemaining !== null && creditsRemaining <= 0) {
830
- throw client.makeError("QUOTA_EXCEEDED", "No enrichment credits remaining", "Purchase more credits at app.leadbay.ai");
914
+ throw client.makeError("QUOTA_EXCEEDED", "No enrichment credits remaining", "Contact Leadbay support to extend your credit quota");
831
915
  }
832
916
  } catch (e) {
833
917
  if (e?.code === "QUOTA_EXCEEDED")
@@ -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 };
@@ -1664,11 +1748,11 @@ function summarise(responses) {
1664
1748
  if (excerpt && excerpt.length > 200) {
1665
1749
  excerpt = excerpt.slice(0, 197) + "...";
1666
1750
  }
1667
- return { answered, total, avg_score_0_to_10: avg, best_response_excerpt: excerpt };
1751
+ return { answered, total, avg_qualification_boost: avg, best_response_excerpt: excerpt };
1668
1752
  }
1669
1753
  var pullLeads = {
1670
1754
  name: "leadbay_pull_leads",
1671
- description: "Pull up new leads from the user's last-active lens (the canonical 'show me prospects to work on' tool). Each returned lead carries a one-line qualification_summary built from leadbay_ai_agent_responses, plus the rich tags / scores / recommended_contact_title / engagement counters / in-flight flags from the lead summary. When to use: as the agent's default opening move when the user wants to see leads. When NOT to use: when the user has named a specific lens \u2014 pass lensId to override the auto-resolution. Replaces the older leadbay_find_prospects (which is removed in v0.2.0).",
1755
+ description: "Pull up new leads from the user's last-active lens \u2014 the canonical 'show me today's prospects' tool. Leadbay works like an inbox: each time the user logs back in, a fresh batch is delivered, paced by how many leads they've actually acted on recently. Pulling more won't produce more; user outreach/skips/saves does. Each returned lead carries a one-line qualification_summary built from leadbay_ai_agent_responses, plus the rich tags / scores / recommended_contact_title / engagement counters / in-flight flags from the lead summary. Roughly the top 10 of the batch come pre-qualified (populated qualification_summary + ai_agent_lead_score); leads below the top ~10 carry only the basic firmographic `score` \u2014 not worse, just resource-saved by the system. Call leadbay_bulk_qualify_leads to deepen any of them on demand. When to use: as the agent's default opening move when the user wants to see leads, or as a daily check-in for what's new today. When NOT to use: when the user has named a specific lens \u2014 pass lensId to override the auto-resolution. Replaces the older leadbay_find_prospects (which is removed in v0.2.0).",
1672
1756
  inputSchema: {
1673
1757
  type: "object",
1674
1758
  properties: {
@@ -1701,7 +1785,7 @@ var pullLeads = {
1701
1785
  summary: {
1702
1786
  answered: 0,
1703
1787
  total: 0,
1704
- avg_score_0_to_10: null,
1788
+ avg_qualification_boost: null,
1705
1789
  best_response_excerpt: null
1706
1790
  }
1707
1791
  };
@@ -1783,7 +1867,7 @@ function reshapeWebFetchContent(content) {
1783
1867
  }
1784
1868
  var researchLead = {
1785
1869
  name: "leadbay_research_lead",
1786
- description: "Tell me everything decision-relevant about a single lead. Bundles the lens-scoped lead profile, the AI qualification answers (the agent's knowledge-base food), the structured web-research signals (with hot flags + sources), the enriched contacts, and the recent notes/epilogue/prospecting activity in one call. Order is deliberate: qualification first, then signals, then firmographics, then contacts, then engagement. When to use: when picking up a single lead from leadbay_pull_leads to decide whether to act on it. When NOT to use: across many leads at once \u2014 that's leadbay_pull_leads' job. (This composite supersedes the lower-level leadbay_get_lead_profile in agent flow; the granular tool stays available for fine-grained access.)",
1870
+ description: "Tell me everything decision-relevant about a single lead. Bundles the lens-scoped lead profile, the AI qualification answers (the agent's knowledge-base food), the structured web-research signals (with hot flags + sources), the enriched contacts, and the recent notes/epilogue/prospecting activity in one call. Order is deliberate: qualification first, then signals, then firmographics, then contacts, then engagement. Scoring has two layers: the basic `score` (firmographic, always present, already decent) and the AI qualification layer (`ai_agent_lead_score` + per-question answers + web_fetch signals). The AI layer is pre-populated for roughly the top 10 of each daily batch, and on-demand (via leadbay_bulk_qualify_leads) for anything below that. Combine both layers when judging a lead. When to use: when picking up a single lead from leadbay_pull_leads to decide whether to act on it. When NOT to use: across many leads at once \u2014 that's leadbay_pull_leads' job. (This composite supersedes the lower-level leadbay_get_lead_profile in agent flow; the granular tool stays available for fine-grained access.)",
1787
1871
  inputSchema: {
1788
1872
  type: "object",
1789
1873
  properties: {
@@ -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`),
@@ -1997,7 +2086,7 @@ var recallOrderedTitles = {
1997
2086
  // ../core/dist/composite/account-status.js
1998
2087
  var accountStatus = {
1999
2088
  name: "leadbay_account_status",
2000
- description: "Show the user's account state \u2014 admin rights, language, last-active lens, current quota usage across daily/weekly/monthly windows for llm_completion / ai_rescore / web_fetch resources, and whether the org's intelligence is mid-regeneration. When to use: at the start of a session to know what the agent can/can't do, or after a 429 to explain to the user which resource window was exhausted and when it resets. When NOT to use: as a pre-flight gate before bulk ops \u2014 operations themselves return 429; this tool is for context, not gating.",
2089
+ description: "Show the user's account state \u2014 admin rights, language, last-active lens, current quota usage across daily/weekly/monthly windows for llm_completion / ai_rescore / web_fetch resources, and whether the org's intelligence is mid-regeneration. Quota windows also hint at the user's consumption pace: heavy recent activity (ai_rescore / web_fetch near their window limits) is a signal that Leadbay will deliver a larger fresh batch next time the user logs back in, since batch size is paced by real consumption. When to use: at the start of a session to know what the agent can/can't do, or after a 429 to explain to the user which resource window was exhausted and when it resets. When NOT to use: as a pre-flight gate before bulk ops \u2014 operations themselves return 429; this tool is for context, not gating.",
2001
2090
  inputSchema: { type: "object", properties: {} },
2002
2091
  execute: async (client, _params, ctx) => {
2003
2092
  const me = await client.resolveMe();
@@ -2042,7 +2131,7 @@ var DEFAULT_PER_LEAD_BUDGET_MS = 9e4;
2042
2131
  var DEFAULT_TOTAL_BUDGET_MS = 5 * 6e4;
2043
2132
  var bulkQualifyLeads = {
2044
2133
  name: "leadbay_bulk_qualify_leads",
2045
- 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. When to use: when the user wants more qualified leads than what's currently shown. When NOT to use: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced).",
2134
+ 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).",
2046
2135
  inputSchema: {
2047
2136
  type: "object",
2048
2137
  properties: {
@@ -2169,7 +2258,7 @@ var bulkQualifyLeads = {
2169
2258
  qualification_summary: responses.length > 0 ? {
2170
2259
  answered: responses.filter((r) => r.score != null).length,
2171
2260
  total: responses.length,
2172
- avg_score_0_to_10: avg
2261
+ avg_qualification_boost: avg
2173
2262
  } : null,
2174
2263
  signals_count: lastWf?.content ? Object.values(lastWf.content).reduce((acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), 0) : null,
2175
2264
  _stillRunning: stillRunning
@@ -2190,11 +2279,515 @@ 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 PUBLIC_MAILBOX_DOMAINS = /* @__PURE__ */ new Set([
2290
+ "gmail.com",
2291
+ "googlemail.com",
2292
+ "yahoo.com",
2293
+ "ymail.com",
2294
+ "outlook.com",
2295
+ "hotmail.com",
2296
+ "live.com",
2297
+ "icloud.com",
2298
+ "me.com",
2299
+ "mac.com",
2300
+ "aol.com",
2301
+ "proton.me",
2302
+ "protonmail.com",
2303
+ "tutanota.com",
2304
+ "gmx.com",
2305
+ "gmx.net",
2306
+ "gmx.de",
2307
+ "mail.com",
2308
+ "yandex.com",
2309
+ "yandex.ru",
2310
+ "qq.com",
2311
+ "163.com",
2312
+ "126.com"
2313
+ ]);
2314
+ function normalizeDomain(input) {
2315
+ if (!input || typeof input !== "string")
2316
+ return null;
2317
+ let v = input.trim().toLowerCase();
2318
+ if (!v)
2319
+ return null;
2320
+ v = v.replace(/^https?:\/\//, "");
2321
+ v = v.replace(/^www\./, "");
2322
+ v = v.split("/")[0].split("?")[0].split("#")[0];
2323
+ v = v.replace(/\.+$/, "");
2324
+ if (!v)
2325
+ return null;
2326
+ if (/\s/.test(v))
2327
+ return null;
2328
+ if (!v.includes("."))
2329
+ return null;
2330
+ if (v.startsWith(".") || v.endsWith("."))
2331
+ return null;
2332
+ const parts = v.split(".");
2333
+ if (parts.length < 2)
2334
+ return null;
2335
+ if (parts.some((p) => p.length === 0))
2336
+ return null;
2337
+ const tld = parts[parts.length - 1];
2338
+ if (!/^[a-z]{2,}$/.test(tld) && !tld.startsWith("xn--"))
2339
+ return null;
2340
+ if (!/^[a-z0-9-]+$/.test(parts[parts.length - 2]))
2341
+ return null;
2342
+ return v;
2343
+ }
2344
+ function escapeCsvCell(raw) {
2345
+ if (raw == null)
2346
+ return "";
2347
+ let s = String(raw);
2348
+ const trimmed = s.replace(/^[\s\r\n\t]+/, "");
2349
+ if (trimmed.length > 0) {
2350
+ const first = trimmed[0];
2351
+ if (first === "=" || first === "+" || first === "-" || first === "@") {
2352
+ s = "'" + s;
2353
+ }
2354
+ }
2355
+ if (/[",\n\r]/.test(s)) {
2356
+ s = '"' + s.replace(/"/g, '""') + '"';
2357
+ }
2358
+ return s;
2359
+ }
2360
+ function synthesizeCsv(rows) {
2361
+ const lines = ["MCP_ROW_ID,LEAD_NAME,LEAD_WEBSITE"];
2362
+ for (const r of rows) {
2363
+ lines.push([escapeCsvCell(r.rowId), escapeCsvCell(r.name), escapeCsvCell(r.website)].join(","));
2364
+ }
2365
+ return lines.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 buildInputLookup(inputs) {
2434
+ const validInputs = [];
2435
+ const malformed = [];
2436
+ const byDomain = /* @__PURE__ */ new Map();
2437
+ const byRowId = /* @__PURE__ */ new Map();
2438
+ inputs.forEach((inp, i) => {
2439
+ void i;
2440
+ const norm = normalizeDomain(inp.domain ?? "");
2441
+ if (!norm) {
2442
+ malformed.push(inp.domain ?? "");
2443
+ return;
2444
+ }
2445
+ if (byDomain.has(norm))
2446
+ return;
2447
+ const rowId = randomUUID();
2448
+ const idx = validInputs.length;
2449
+ validInputs.push({ index: idx, rowId, domain: norm, name: inp.name?.trim() || norm });
2450
+ byDomain.set(norm, idx);
2451
+ byRowId.set(rowId, idx);
2452
+ });
2453
+ return { validInputs, malformed, byDomain, byRowId };
2454
+ }
2455
+ async function pollUntil(fn, done, budgetMs, signal, ctx, label) {
2456
+ const deadline = Date.now() + budgetMs;
2457
+ let last;
2458
+ while (true) {
2459
+ checkAborted(signal);
2460
+ last = await fn();
2461
+ if (done(last))
2462
+ return last;
2463
+ if (Date.now() >= deadline) {
2464
+ ctx?.logger?.warn?.(`import-leads: ${label} budget exhausted (${budgetMs}ms)`);
2465
+ return last;
2466
+ }
2467
+ await sleepWithAbort(POLL_INTERVAL_MS, signal);
2468
+ }
2469
+ }
2470
+ async function pollPreprocess(client, importId, budgetMs, ctx, signal) {
2471
+ const result = await pollUntil(() => client.request("GET", `/imports/${importId}`), (r) => Boolean(r.pre_processing?.finished), budgetMs, signal, ctx, "preprocess");
2472
+ if (!result.pre_processing?.finished) {
2473
+ 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}`);
2474
+ }
2475
+ if (result.pre_processing.error) {
2476
+ throw client.makeError("IMPORT_PREPROCESS_FAILED", `Preprocess failed: ${result.pre_processing.error}`, `Check the input domains. importId=${importId} for backend debugging.`, `GET /imports/${importId}`);
2477
+ }
2478
+ return result;
2479
+ }
2480
+ async function pollProcess(client, importId, budgetMs, ctx, signal) {
2481
+ const result = await pollUntil(() => client.request("GET", `/imports/${importId}`), (r) => Boolean(r.processing?.finished), budgetMs, signal, ctx, "process");
2482
+ if (!result.processing?.finished) {
2483
+ 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}`);
2484
+ }
2485
+ if (result.processing.error != null) {
2486
+ throw client.makeError("IMPORT_PROCESSING_FAILED", `Backend processing failed: ${result.processing.error}`, `importId=${importId}.`, `GET /imports/${importId}`);
2487
+ }
2488
+ return result;
2489
+ }
2490
+ async function pollRecordsToTerminal(client, importId, budgetMs, expectedRowCount, ctx, signal) {
2491
+ const deadline = Date.now() + budgetMs;
2492
+ const maxPagesPerPoll = Math.max(2, Math.ceil(expectedRowCount / 100) * 2 + 4);
2493
+ let stableCounts = 0;
2494
+ let lastSnapshot = null;
2495
+ while (true) {
2496
+ checkAborted(signal);
2497
+ let total = 0;
2498
+ let transient = 0;
2499
+ let pagesFetched = 0;
2500
+ let exhaustedPagination = false;
2501
+ const records = [];
2502
+ for (let page = 0; page < maxPagesPerPoll; page++) {
2503
+ checkAborted(signal);
2504
+ const qs = `count=100&page=${page}&automatic_match=true&manual_match=true&no_match=true&matching=true&importing=true&imported=true`;
2505
+ const res = await client.request("GET", `/imports/${importId}/records?${qs}`);
2506
+ pagesFetched++;
2507
+ records.push(...res.items);
2508
+ total = res.pagination.total ?? records.length;
2509
+ for (const r of res.items) {
2510
+ const status = (r.status ?? "").toString().toUpperCase();
2511
+ const matchType = (r.match_type ?? r.matchType ?? "").toString().toUpperCase();
2512
+ const isTerminal = matchType === "NO_MATCH" || status === "IMPORTED";
2513
+ if (!isTerminal)
2514
+ transient++;
2515
+ }
2516
+ const totalPages = res.pagination.pages ?? 0;
2517
+ if (page + 1 >= totalPages) {
2518
+ exhaustedPagination = true;
2519
+ break;
2520
+ }
2521
+ }
2522
+ if (!exhaustedPagination) {
2523
+ 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`);
2524
+ }
2525
+ const snapshot = { total, transient };
2526
+ const settled = transient === 0;
2527
+ const stableVsLast = lastSnapshot != null && lastSnapshot.total === snapshot.total && lastSnapshot.transient === snapshot.transient;
2528
+ if (settled && stableVsLast) {
2529
+ stableCounts++;
2530
+ } else if (settled) {
2531
+ stableCounts = 1;
2532
+ } else {
2533
+ stableCounts = 0;
2534
+ }
2535
+ lastSnapshot = snapshot;
2536
+ if (settled && stableCounts >= STABILIZATION_POLLS) {
2537
+ return records;
2538
+ }
2539
+ if (Date.now() >= deadline) {
2540
+ ctx?.logger?.warn?.(`import-leads: records did not stabilize (transient=${transient}, total=${total}); returning best-effort`);
2541
+ throw client.makeError("IMPORT_NOT_TERMINAL", `Backend hasn't fully settled records within ${budgetMs}ms`, `Retry leadbay_import_leads with the same domains in 30s, or split the batch. importId=${importId}.`, `GET /imports/${importId}/records`);
2542
+ }
2543
+ await sleepWithAbort(POLL_INTERVAL_MS, signal);
2544
+ }
2545
+ }
2546
+ async function runOneChunk(client, chunk, chunkIdx, totalChunks, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onImportId) {
2547
+ const csv = synthesizeCsv(chunk.map((c) => ({ rowId: c.rowId, name: c.name, website: c.domain })));
2548
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2549
+ const fileName = `mcp-import-${ts}-${chunkIdx}.csv`;
2550
+ ctx?.logger?.info?.(`import-leads: uploading chunk ${chunkIdx + 1}/${totalChunks} (${chunk.length} rows, ${csv.length}B)`);
2551
+ const upload = await client.requestRawBinary("POST", `/imports?file_name=${encodeURIComponent(fileName)}`, "text/csv", csv);
2552
+ const importId = upload.id;
2553
+ onImportId(importId);
2554
+ const phaseBudget = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
2555
+ await pollPreprocess(client, importId, phaseBudget, ctx, signal);
2556
+ ctx?.logger?.info?.(`import-leads: preprocess done for importId=${importId}`);
2557
+ if (dryRun) {
2558
+ return { importId, records: [] };
2559
+ }
2560
+ const mappings = {
2561
+ fields: { LEAD_NAME: "LEAD_NAME", LEAD_WEBSITE: "LEAD_WEBSITE" },
2562
+ statuses: {},
2563
+ default_status: null
2564
+ };
2565
+ await client.requestVoid("POST", `/imports/${importId}/update_mappings`, mappings);
2566
+ ctx?.logger?.info?.(`import-leads: mappings committed for importId=${importId}`);
2567
+ const phaseBudget2 = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
2568
+ await pollProcess(client, importId, phaseBudget2, ctx, signal);
2569
+ ctx?.logger?.info?.(`import-leads: process done for importId=${importId}`);
2570
+ const phaseBudget3 = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
2571
+ const records = await pollRecordsToTerminal(client, importId, phaseBudget3, chunk.length, ctx, signal);
2572
+ ctx?.logger?.info?.(`import-leads: ${records.length} records terminal for importId=${importId}`);
2573
+ return { importId, records };
2574
+ }
2575
+ function reconcileOneChunk(chunk, byRowIdGlobal, byDomainGlobal, validInputsGlobal, matched, notImported) {
2576
+ const seenInputIndex = /* @__PURE__ */ new Set();
2577
+ const sortedRecords = [...chunk.records].sort((a, b) => {
2578
+ const aHasLead = a.lead?.id ? 0 : 1;
2579
+ const bHasLead = b.lead?.id ? 0 : 1;
2580
+ return aHasLead - bHasLead;
2581
+ });
2582
+ for (const rec of sortedRecords) {
2583
+ let inputIdx;
2584
+ const rowIdCell = readCell(rec, "MCP_ROW_ID");
2585
+ if (rowIdCell && byRowIdGlobal.has(rowIdCell)) {
2586
+ inputIdx = byRowIdGlobal.get(rowIdCell);
2587
+ }
2588
+ if (inputIdx === void 0) {
2589
+ const websiteCell = readCell(rec, "LEAD_WEBSITE");
2590
+ if (websiteCell) {
2591
+ const norm = normalizeDomain(websiteCell);
2592
+ if (norm && byDomainGlobal.has(norm)) {
2593
+ inputIdx = byDomainGlobal.get(norm);
2594
+ }
2595
+ }
2596
+ }
2597
+ if (inputIdx === void 0 && rec.lead?.website) {
2598
+ const norm = normalizeDomain(rec.lead.website);
2599
+ if (norm && byDomainGlobal.has(norm)) {
2600
+ inputIdx = byDomainGlobal.get(norm);
2601
+ }
2602
+ }
2603
+ if (inputIdx === void 0)
2604
+ continue;
2605
+ if (seenInputIndex.has(inputIdx)) {
2606
+ if (!matched.has(inputIdx) && !notImported.has(inputIdx)) {
2607
+ const inp2 = validInputsGlobal[inputIdx];
2608
+ notImported.set(inputIdx, { domain: inp2.domain, reason: "ambiguous" });
2609
+ }
2610
+ continue;
2611
+ }
2612
+ seenInputIndex.add(inputIdx);
2613
+ const inp = validInputsGlobal[inputIdx];
2614
+ const matchType = (rec.match_type ?? rec.matchType ?? "").toString();
2615
+ if (rec.lead?.id) {
2616
+ matched.set(inputIdx, {
2617
+ domain: inp.domain,
2618
+ leadId: rec.lead.id,
2619
+ name: rec.lead.name ?? null
2620
+ });
2621
+ } else if (matchType === "NO_MATCH") {
2622
+ const reason = PUBLIC_MAILBOX_DOMAINS.has(inp.domain) ? "no_match" : "uncrawled";
2623
+ notImported.set(inputIdx, { domain: inp.domain, reason });
2624
+ } else {
2625
+ notImported.set(inputIdx, { domain: inp.domain, reason: "internal_error" });
2626
+ }
2627
+ }
2628
+ }
2629
+ var importLeads = {
2630
+ name: "leadbay_import_leads",
2631
+ description: "Import a list of company domains and get back stable Leadbay leadIds for downstream chaining into leadbay_bulk_qualify_leads / leadbay_research_lead.\n\n\u26A0\uFE0F MUTATES USER STATE. This tool wraps Leadbay's CRM-import wizard. 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\nReturns: leads = leadIds for domains Leadbay already knows about (via crawler). not_imported = domains Leadbay doesn't know yet, with a reason. The tool does NOT create new leads for unknown domains; the caller decides what to do.\n\nWhen to use: you have a list of domains from another system (CRM, analytics, email correspondents) and need to map them to Leadbay leadIds.\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.",
2632
+ write: true,
2633
+ version: "0.1.0",
2634
+ inputSchema: {
2635
+ type: "object",
2636
+ properties: {
2637
+ domains: {
2638
+ type: "array",
2639
+ description: "List of company domains to map to Leadbay leadIds.",
2640
+ items: {
2641
+ type: "object",
2642
+ properties: {
2643
+ domain: {
2644
+ type: "string",
2645
+ description: "Company domain (e.g. 'apple.com'). Protocol/path are stripped."
2646
+ },
2647
+ name: {
2648
+ type: "string",
2649
+ description: "Optional display name override; defaults to the domain."
2650
+ }
2651
+ },
2652
+ required: ["domain"]
2653
+ }
2654
+ },
2655
+ dry_run: {
2656
+ type: "boolean",
2657
+ 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 domain format / wizard reachability without polluting the CRM."
2658
+ },
2659
+ per_phase_budget_ms: {
2660
+ type: "number",
2661
+ description: `Single poll-loop cap (default ${DEFAULT_PER_PHASE_BUDGET_MS}ms).`
2662
+ },
2663
+ total_budget_ms: {
2664
+ type: "number",
2665
+ description: `Overall cap across all phases (default ${DEFAULT_TOTAL_BUDGET_MS2}ms).`
2666
+ }
2667
+ },
2668
+ required: ["domains"]
2669
+ },
2670
+ execute: async (client, params, ctx) => {
2671
+ const signal = ctx?.signal;
2672
+ const dryRun = Boolean(params.dry_run);
2673
+ const perPhaseBudget = params.per_phase_budget_ms ?? DEFAULT_PER_PHASE_BUDGET_MS;
2674
+ const totalBudget = params.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS2;
2675
+ const totalDeadline = Date.now() + totalBudget;
2676
+ if (!Array.isArray(params.domains) || params.domains.length === 0) {
2677
+ throw client.makeError("IMPORT_EMPTY_INPUT", "domains[] must contain at least one entry", "Pass at least one domain in domains[].", "POST /imports");
2678
+ }
2679
+ const me = await client.resolveMe();
2680
+ if (!me.admin) {
2681
+ 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");
2682
+ }
2683
+ const lookup = buildInputLookup(params.domains);
2684
+ if (lookup.validInputs.length === 0) {
2685
+ const not_imported2 = lookup.malformed.map((d) => ({
2686
+ domain: d,
2687
+ reason: "malformed"
2688
+ }));
2689
+ return {
2690
+ leads: [],
2691
+ not_imported: not_imported2,
2692
+ importIds: [],
2693
+ region: client.region,
2694
+ dry_run: dryRun || void 0,
2695
+ _meta: client.lastMeta ?? {
2696
+ region: client.region,
2697
+ endpoint: "POST /imports",
2698
+ latency_ms: null,
2699
+ retry_after: null
2700
+ }
2701
+ };
2702
+ }
2703
+ const chunks = chunkAt100(lookup.validInputs);
2704
+ ctx?.logger?.info?.(`import-leads: ${lookup.validInputs.length} domains \u2192 ${chunks.length} chunk(s); dry_run=${dryRun}, totalBudgetMs=${totalBudget}`);
2705
+ const importIds = [];
2706
+ const matched = /* @__PURE__ */ new Map();
2707
+ const notImported = /* @__PURE__ */ new Map();
2708
+ let cancelled = false;
2709
+ const recordImportId = (id) => {
2710
+ if (!importIds.includes(id))
2711
+ importIds.push(id);
2712
+ };
2713
+ try {
2714
+ for (let i = 0; i < chunks.length; i++) {
2715
+ const chunk = chunks[i];
2716
+ const out = await runOneChunk(client, chunk, i, chunks.length, dryRun, perPhaseBudget, totalDeadline, ctx, signal, recordImportId);
2717
+ if (!dryRun) {
2718
+ reconcileOneChunk(out, lookup.byRowId, lookup.byDomain, lookup.validInputs, matched, notImported);
2719
+ } else {
2720
+ for (const c of chunk) {
2721
+ notImported.set(c.index, { domain: c.domain, reason: "dry_run" });
2722
+ }
2723
+ }
2724
+ }
2725
+ } catch (err) {
2726
+ if (err?.name === "AbortError") {
2727
+ cancelled = true;
2728
+ ctx?.logger?.info?.(`import-leads: aborted via signal; importIds=${importIds.join(",")}`);
2729
+ } else if (err?.error === true) {
2730
+ if (err.code === "FORBIDDEN") {
2731
+ 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);
2732
+ }
2733
+ if (err.code === "BILLING_SUSPENDED") {
2734
+ 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);
2735
+ }
2736
+ throw err;
2737
+ } else {
2738
+ throw err;
2739
+ }
2740
+ }
2741
+ for (const m of lookup.malformed) {
2742
+ notImported.set(-1 - notImported.size, { domain: m, reason: "malformed" });
2743
+ }
2744
+ const leads = [];
2745
+ const not_imported = [];
2746
+ if (dryRun) {
2747
+ for (const inp of lookup.validInputs) {
2748
+ not_imported.push({ domain: inp.domain, reason: "dry_run" });
2749
+ }
2750
+ } else {
2751
+ for (const inp of lookup.validInputs) {
2752
+ const m = matched.get(inp.index);
2753
+ if (m) {
2754
+ leads.push(m);
2755
+ continue;
2756
+ }
2757
+ const ni = notImported.get(inp.index);
2758
+ if (ni) {
2759
+ not_imported.push(ni);
2760
+ continue;
2761
+ }
2762
+ not_imported.push({ domain: inp.domain, reason: "internal_error" });
2763
+ }
2764
+ }
2765
+ for (const [k, v] of notImported) {
2766
+ if (k < 0)
2767
+ not_imported.push(v);
2768
+ }
2769
+ return {
2770
+ leads,
2771
+ not_imported,
2772
+ importIds,
2773
+ region: client.region,
2774
+ cancelled: cancelled || void 0,
2775
+ dry_run: dryRun || void 0,
2776
+ _meta: client.lastMeta ?? {
2777
+ region: client.region,
2778
+ endpoint: "POST /imports",
2779
+ latency_ms: null,
2780
+ retry_after: null
2781
+ }
2782
+ };
2783
+ }
2784
+ };
2785
+
2193
2786
  // ../core/dist/composite/enrich-titles.js
2194
2787
  var DEFAULT_CANDIDATE_COUNT = 25;
2195
2788
  var enrichTitles = {
2196
2789
  name: "leadbay_enrich_titles",
2197
- description: "Order contact enrichments by job title across many leads. 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. When NOT to use: to enrich a single contact \u2014 that's leadbay_enrich_contacts (granular).",
2790
+ 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.",
2198
2791
  inputSchema: {
2199
2792
  type: "object",
2200
2793
  properties: {
@@ -2235,9 +2828,11 @@ var enrichTitles = {
2235
2828
  hint: "Set email:true (most common) or phone:true"
2236
2829
  };
2237
2830
  }
2831
+ const explicitLeadIds = params.leadIds && params.leadIds.length > 0;
2832
+ const selectionSource = explicitLeadIds ? "explicit" : "wishlist";
2833
+ const lensId = params.lensId ?? await client.resolveDefaultLens();
2238
2834
  let leadIds = params.leadIds;
2239
2835
  if (!leadIds || leadIds.length === 0) {
2240
- const lensId = params.lensId ?? await client.resolveDefaultLens();
2241
2836
  const cnt = params.candidateCount ?? DEFAULT_CANDIDATE_COUNT;
2242
2837
  const wish = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${Math.min(cnt, 50)}&page=0`);
2243
2838
  leadIds = wish.items.map((l) => l.id);
@@ -2311,9 +2906,53 @@ var enrichTitles = {
2311
2906
  would_launch: { titles: params.titles, email, phone }
2312
2907
  };
2313
2908
  }
2909
+ const tracker = ctx?.bulkTracker;
2910
+ let bulkRecord;
2911
+ let bulkReused = false;
2912
+ let bulkSecondsSinceOriginal;
2913
+ if (tracker) {
2914
+ const res = await tracker.findOrCreatePending({
2915
+ lead_ids: leadIds,
2916
+ titles: params.titles,
2917
+ email,
2918
+ phone,
2919
+ lens_id: lensId,
2920
+ selection_source: selectionSource
2921
+ });
2922
+ bulkRecord = {
2923
+ bulk_id: res.record.bulk_id,
2924
+ launched_at: res.record.launched_at,
2925
+ durability: res.record.durability
2926
+ };
2927
+ bulkReused = res.reused;
2928
+ bulkSecondsSinceOriginal = res.seconds_since_original;
2929
+ if (bulkReused && res.record.status !== "failed") {
2930
+ return {
2931
+ mode: "already_launched",
2932
+ re_used: true,
2933
+ bulk_id: res.record.bulk_id,
2934
+ launched_at: res.record.launched_at,
2935
+ durability: res.record.durability,
2936
+ seconds_since_original_launch: bulkSecondsSinceOriginal ?? 0,
2937
+ titles: params.titles,
2938
+ email,
2939
+ phone,
2940
+ preview,
2941
+ 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.`,
2942
+ next_action: "Call leadbay_bulk_enrich_status({bulk_id}) to check progress; include_contacts=true for the final read."
2943
+ };
2944
+ }
2945
+ }
2314
2946
  try {
2315
2947
  await client.requestVoid("POST", "/leads/selection/enrichment/launch", { titles: params.titles, email, phone });
2316
2948
  } catch (err) {
2949
+ if (bulkRecord && tracker) {
2950
+ try {
2951
+ await tracker.markFailed(bulkRecord.bulk_id);
2952
+ } catch (e) {
2953
+ ctx?.logger?.warn?.(`enrich_titles: tracker.markFailed failed: ${e?.message ?? e}`);
2954
+ }
2955
+ }
2317
2956
  if (err?.code === "QUOTA_EXCEEDED") {
2318
2957
  return {
2319
2958
  status: "quota_exceeded",
@@ -2324,6 +2963,26 @@ var enrichTitles = {
2324
2963
  }
2325
2964
  throw err;
2326
2965
  }
2966
+ if (bulkRecord && tracker) {
2967
+ try {
2968
+ await tracker.markLaunched(bulkRecord.bulk_id);
2969
+ } catch (e) {
2970
+ ctx?.logger?.warn?.(`enrich_titles: tracker.markLaunched failed: ${e?.message ?? e}`);
2971
+ return {
2972
+ mode: "launched_tracker_pending",
2973
+ launched: true,
2974
+ preview,
2975
+ bulk_id: bulkRecord.bulk_id,
2976
+ launched_at: bulkRecord.launched_at,
2977
+ durability: bulkRecord.durability,
2978
+ titles: params.titles,
2979
+ email,
2980
+ phone,
2981
+ 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.",
2982
+ next_action: "Wait ~60s, then call leadbay_bulk_enrich_status({bulk_id}). If it persists, restart the MCP."
2983
+ };
2984
+ }
2985
+ }
2327
2986
  return {
2328
2987
  mode: "launched",
2329
2988
  preview,
@@ -2331,8 +2990,11 @@ var enrichTitles = {
2331
2990
  titles: params.titles,
2332
2991
  email,
2333
2992
  phone,
2334
- message: "Enrichment job launched. The Leadbay backend does not return a bulk_id (probed 2026-04-20) \u2014 track results by polling individual leads via leadbay_get_contacts after ~60s; contact.enrichment.done flips to true.",
2335
- next_action: "Wait ~60s, then call leadbay_research_lead or leadbay_get_contacts on the leads you care about."
2993
+ bulk_id: bulkRecord?.bulk_id,
2994
+ launched_at: bulkRecord?.launched_at,
2995
+ durability: bulkRecord?.durability,
2996
+ 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.",
2997
+ 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."
2336
2998
  };
2337
2999
  } finally {
2338
3000
  try {
@@ -2347,6 +3009,523 @@ var enrichTitles = {
2347
3009
  }
2348
3010
  };
2349
3011
 
3012
+ // ../core/dist/jobs/bulk-store.js
3013
+ import { mkdir as mkdirAsync, lstat, open as fsOpen, readFile, rename, stat, unlink } from "fs/promises";
3014
+ import { constants as fsConstants } from "fs";
3015
+ import { dirname, resolve as resolvePath } from "path";
3016
+ import { homedir, platform } from "os";
3017
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
3018
+ var DEFAULT_IDEMPOTENCY_WINDOW_MS = 5 * 60 * 1e3;
3019
+ var TTL_MS = 30 * 24 * 60 * 60 * 1e3;
3020
+ 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;
3021
+ function isValidBulkId(v) {
3022
+ return typeof v === "string" && UUIDV4_RE.test(v);
3023
+ }
3024
+ function computeIdempotencyKey(args) {
3025
+ const parts = [
3026
+ [...args.lead_ids].sort().join(","),
3027
+ [...args.titles].sort().join(","),
3028
+ args.email ? "e1" : "e0",
3029
+ args.phone ? "p1" : "p0",
3030
+ `l${args.lens_id}`
3031
+ ];
3032
+ return createHash("sha256").update(parts.join("|")).digest("hex");
3033
+ }
3034
+ function normalizeLaunchInputs(args) {
3035
+ return {
3036
+ lead_ids: [...new Set(args.lead_ids)].sort(),
3037
+ titles: [...new Set(args.titles)].sort()
3038
+ };
3039
+ }
3040
+ var AsyncMutex = class {
3041
+ locked = false;
3042
+ queue = [];
3043
+ async lock() {
3044
+ if (!this.locked) {
3045
+ this.locked = true;
3046
+ return;
3047
+ }
3048
+ return new Promise((resolve) => {
3049
+ this.queue.push(() => {
3050
+ this.locked = true;
3051
+ resolve();
3052
+ });
3053
+ });
3054
+ }
3055
+ unlock() {
3056
+ this.locked = false;
3057
+ const next = this.queue.shift();
3058
+ if (next)
3059
+ next();
3060
+ }
3061
+ async run(fn) {
3062
+ await this.lock();
3063
+ try {
3064
+ return await fn();
3065
+ } finally {
3066
+ this.unlock();
3067
+ }
3068
+ }
3069
+ };
3070
+ var LocalBulkStore = class {
3071
+ backend;
3072
+ path;
3073
+ logger;
3074
+ allowUnsafePath;
3075
+ now;
3076
+ mutex = new AsyncMutex();
3077
+ memory = [];
3078
+ // Cached file resolution — computed lazily on first access.
3079
+ initialized = false;
3080
+ constructor(opts) {
3081
+ this.backend = opts.backend;
3082
+ this.logger = opts.logger;
3083
+ this.allowUnsafePath = !!opts.allowUnsafePath;
3084
+ this.now = opts.now ?? Date.now;
3085
+ if (this.backend === "file") {
3086
+ if (!opts.path) {
3087
+ throw new Error("LocalBulkStore: path is required when backend=file");
3088
+ }
3089
+ this.path = resolvePath(opts.path);
3090
+ this.validatePath(this.path);
3091
+ }
3092
+ }
3093
+ get durability() {
3094
+ return this.backend;
3095
+ }
3096
+ // Exposed for tests and ops tooling.
3097
+ get resolvedPath() {
3098
+ return this.path;
3099
+ }
3100
+ validatePath(p) {
3101
+ if (this.allowUnsafePath)
3102
+ return;
3103
+ const home = resolvePath(homedir());
3104
+ if (p !== home && !p.startsWith(home + "/") && !p.startsWith(home + "\\")) {
3105
+ throw new Error(`LocalBulkStore: path ${p} is outside $HOME (${home}). Set LEADBAY_BULK_STORE_PATH_UNSAFE=1 to override.`);
3106
+ }
3107
+ }
3108
+ async ensureInitialized() {
3109
+ if (this.initialized || this.backend !== "file") {
3110
+ this.initialized = true;
3111
+ return;
3112
+ }
3113
+ const dir = dirname(this.path);
3114
+ await mkdirAsync(dir, { recursive: true, mode: 448 });
3115
+ try {
3116
+ const st = await lstat(this.path);
3117
+ if (st.isSymbolicLink()) {
3118
+ throw new Error(`LocalBulkStore: refusing to use ${this.path} \u2014 path is a symlink. Set LEADBAY_BULK_STORE_PATH_UNSAFE=1 to override.`);
3119
+ }
3120
+ } catch (err) {
3121
+ if (err?.code !== "ENOENT")
3122
+ throw err;
3123
+ }
3124
+ this.initialized = true;
3125
+ }
3126
+ // ─── Storage layer (file or memory) ──────────────────────────────────────
3127
+ async readAll() {
3128
+ if (this.backend === "memory")
3129
+ return [...this.memory];
3130
+ await this.ensureInitialized();
3131
+ let raw;
3132
+ try {
3133
+ raw = await readFile(this.path, "utf8");
3134
+ } catch (err) {
3135
+ if (err?.code === "ENOENT")
3136
+ return [];
3137
+ throw err;
3138
+ }
3139
+ let parsed;
3140
+ try {
3141
+ parsed = JSON.parse(raw);
3142
+ } catch (err) {
3143
+ this.logger?.warn?.(`bulk.record_dropped file_parse_failed ${err?.message ?? err}`);
3144
+ return [];
3145
+ }
3146
+ if (!Array.isArray(parsed)) {
3147
+ this.logger?.warn?.("bulk.record_dropped file_not_array");
3148
+ return [];
3149
+ }
3150
+ const out = [];
3151
+ for (const entry of parsed) {
3152
+ try {
3153
+ out.push(this.validateRecord(entry));
3154
+ } catch (err) {
3155
+ this.logger?.warn?.(`bulk.record_dropped invalid_record ${err?.message ?? err}`);
3156
+ }
3157
+ }
3158
+ return out;
3159
+ }
3160
+ validateRecord(raw) {
3161
+ if (!raw || typeof raw !== "object")
3162
+ throw new Error("not an object");
3163
+ const r = raw;
3164
+ if (!isValidBulkId(r.bulk_id))
3165
+ throw new Error("invalid bulk_id");
3166
+ if (typeof r.launched_at !== "string")
3167
+ throw new Error("missing launched_at");
3168
+ if (!Array.isArray(r.lead_ids) || !r.lead_ids.every((x) => typeof x === "string"))
3169
+ throw new Error("invalid lead_ids");
3170
+ if (!Array.isArray(r.titles) || !r.titles.every((x) => typeof x === "string"))
3171
+ throw new Error("invalid titles");
3172
+ if (typeof r.email !== "boolean")
3173
+ throw new Error("invalid email");
3174
+ if (typeof r.phone !== "boolean")
3175
+ throw new Error("invalid phone");
3176
+ if (typeof r.lens_id !== "number")
3177
+ throw new Error("invalid lens_id");
3178
+ if (r.selection_source !== "explicit" && r.selection_source !== "wishlist")
3179
+ throw new Error("invalid selection_source");
3180
+ if (r.status !== "pending" && r.status !== "launched" && r.status !== "failed")
3181
+ throw new Error("invalid status");
3182
+ if (typeof r.idempotency_key !== "string")
3183
+ throw new Error("invalid idempotency_key");
3184
+ return {
3185
+ bulk_id: r.bulk_id,
3186
+ launched_at: r.launched_at,
3187
+ lead_ids: r.lead_ids,
3188
+ titles: r.titles,
3189
+ email: r.email,
3190
+ phone: r.phone,
3191
+ lens_id: r.lens_id,
3192
+ selection_source: r.selection_source,
3193
+ status: r.status,
3194
+ idempotency_key: r.idempotency_key,
3195
+ durability: this.backend
3196
+ };
3197
+ }
3198
+ async writeAll(records) {
3199
+ if (this.backend === "memory") {
3200
+ this.memory = records.map((r) => ({ ...r, durability: "memory" }));
3201
+ return;
3202
+ }
3203
+ await this.ensureInitialized();
3204
+ const payload = records.map((r) => ({ ...r, durability: "file" }));
3205
+ const json = JSON.stringify(payload, null, 2);
3206
+ const tmp = this.path + ".tmp";
3207
+ let fh = await openTmpFileExclusive(tmp);
3208
+ try {
3209
+ await fh.writeFile(json, { encoding: "utf8" });
3210
+ await fh.sync();
3211
+ } finally {
3212
+ await fh.close();
3213
+ }
3214
+ if (platform() === "win32") {
3215
+ try {
3216
+ await unlink(this.path);
3217
+ } catch (err) {
3218
+ if (err?.code !== "ENOENT")
3219
+ throw err;
3220
+ }
3221
+ }
3222
+ await rename(tmp, this.path);
3223
+ try {
3224
+ const dirFh = await fsOpen(dirname(this.path), "r");
3225
+ try {
3226
+ await dirFh.sync();
3227
+ } finally {
3228
+ await dirFh.close();
3229
+ }
3230
+ } catch {
3231
+ }
3232
+ }
3233
+ // ─── TTL cleanup ─────────────────────────────────────────────────────────
3234
+ prune(records) {
3235
+ const cutoff = this.now() - TTL_MS;
3236
+ const kept = [];
3237
+ for (const r of records) {
3238
+ const launched = Date.parse(r.launched_at);
3239
+ if (Number.isFinite(launched) && launched >= cutoff) {
3240
+ kept.push(r);
3241
+ } else {
3242
+ this.logger?.info?.(`bulk.ttl_dropped bulk_id=${r.bulk_id} launched_at=${r.launched_at}`);
3243
+ }
3244
+ }
3245
+ return kept;
3246
+ }
3247
+ // ─── BulkTracker API ────────────────────────────────────────────────────
3248
+ async findOrCreatePending(args) {
3249
+ const { lead_ids, titles } = normalizeLaunchInputs(args);
3250
+ const idempotency_key = computeIdempotencyKey({
3251
+ lead_ids,
3252
+ titles,
3253
+ email: args.email,
3254
+ phone: args.phone,
3255
+ lens_id: args.lens_id
3256
+ });
3257
+ const window = args.idempotency_window_ms ?? DEFAULT_IDEMPOTENCY_WINDOW_MS;
3258
+ return this.mutex.run(async () => {
3259
+ const all = this.prune(await this.readAll());
3260
+ const nowMs = this.now();
3261
+ const existing = all.find((r) => r.idempotency_key === idempotency_key && r.status !== "failed" && nowMs - Date.parse(r.launched_at) < window);
3262
+ if (existing) {
3263
+ this.logger?.info?.(`bulk.reused bulk_id=${existing.bulk_id} seconds_since_original=${Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)}`);
3264
+ return {
3265
+ record: existing,
3266
+ reused: true,
3267
+ seconds_since_original: Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)
3268
+ };
3269
+ }
3270
+ const record = {
3271
+ bulk_id: randomUUID2(),
3272
+ launched_at: new Date(nowMs).toISOString(),
3273
+ lead_ids,
3274
+ titles,
3275
+ email: args.email,
3276
+ phone: args.phone,
3277
+ lens_id: args.lens_id,
3278
+ selection_source: args.selection_source,
3279
+ status: "pending",
3280
+ idempotency_key,
3281
+ durability: this.backend
3282
+ };
3283
+ all.push(record);
3284
+ await this.writeAll(all);
3285
+ 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}`);
3286
+ return { record, reused: false };
3287
+ });
3288
+ }
3289
+ async markLaunched(bulk_id) {
3290
+ return this.mutex.run(async () => {
3291
+ const all = this.prune(await this.readAll());
3292
+ const idx = all.findIndex((r) => r.bulk_id === bulk_id);
3293
+ if (idx < 0) {
3294
+ throw new Error(`bulk_id not found: ${bulk_id}`);
3295
+ }
3296
+ all[idx] = { ...all[idx], status: "launched" };
3297
+ await this.writeAll(all);
3298
+ this.logger?.info?.(`bulk.launched bulk_id=${bulk_id}`);
3299
+ return all[idx];
3300
+ });
3301
+ }
3302
+ async markFailed(bulk_id) {
3303
+ return this.mutex.run(async () => {
3304
+ const all = this.prune(await this.readAll());
3305
+ const idx = all.findIndex((r) => r.bulk_id === bulk_id);
3306
+ if (idx < 0) {
3307
+ return;
3308
+ }
3309
+ all[idx] = { ...all[idx], status: "failed" };
3310
+ await this.writeAll(all);
3311
+ this.logger?.info?.(`bulk.launch_failed bulk_id=${bulk_id}`);
3312
+ });
3313
+ }
3314
+ async get(bulk_id) {
3315
+ return this.mutex.run(async () => {
3316
+ const all = this.prune(await this.readAll());
3317
+ return all.find((r) => r.bulk_id === bulk_id);
3318
+ });
3319
+ }
3320
+ async list() {
3321
+ return this.mutex.run(async () => {
3322
+ const all = this.prune(await this.readAll());
3323
+ return [...all].sort((a, b) => Date.parse(b.launched_at) - Date.parse(a.launched_at));
3324
+ });
3325
+ }
3326
+ };
3327
+ async function openTmpFileExclusive(path) {
3328
+ try {
3329
+ return await fsOpen(path, fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL, 384);
3330
+ } catch (err) {
3331
+ if (err?.code === "EEXIST") {
3332
+ await unlink(path).catch(() => {
3333
+ });
3334
+ return fsOpen(path, fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL, 384);
3335
+ }
3336
+ throw err;
3337
+ }
3338
+ }
3339
+ var InMemoryBulkStore = class extends LocalBulkStore {
3340
+ constructor(opts = {}) {
3341
+ super({ backend: "memory", logger: opts.logger, now: opts.now });
3342
+ }
3343
+ };
3344
+ async function createDefaultBulkStore(opts = {}) {
3345
+ const env = opts.env ?? process.env;
3346
+ const allowMemory = env.LEADBAY_BULK_STORE_ALLOW_MEMORY === "1";
3347
+ const allowUnsafePath = env.LEADBAY_BULK_STORE_PATH_UNSAFE === "1";
3348
+ const path = env.LEADBAY_BULK_STORE_PATH ?? resolvePath(homedir(), ".leadbay", "bulks.json");
3349
+ try {
3350
+ const store = new LocalBulkStore({
3351
+ backend: "file",
3352
+ path,
3353
+ logger: opts.logger,
3354
+ allowUnsafePath
3355
+ });
3356
+ await store.ensureInitialized();
3357
+ await stat(dirname(path));
3358
+ return store;
3359
+ } catch (err) {
3360
+ if (!allowMemory) {
3361
+ 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.`;
3362
+ opts.logger?.error?.(msg);
3363
+ throw new Error(msg);
3364
+ }
3365
+ opts.logger?.warn?.(`bulk.fallback_memory path=${path} reason=${err?.message ?? err}`);
3366
+ return new LocalBulkStore({ backend: "memory", logger: opts.logger });
3367
+ }
3368
+ }
3369
+
3370
+ // ../core/dist/composite/bulk-enrich-status.js
3371
+ var STATUS_FETCH_CONCURRENCY = 5;
3372
+ async function pMap(items, fn, concurrency) {
3373
+ const out = new Array(items.length);
3374
+ let next = 0;
3375
+ async function worker() {
3376
+ while (true) {
3377
+ const i = next++;
3378
+ if (i >= items.length)
3379
+ return;
3380
+ out[i] = await fn(items[i], i);
3381
+ }
3382
+ }
3383
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
3384
+ return out;
3385
+ }
3386
+ var bulkEnrichStatus = {
3387
+ name: "leadbay_bulk_enrich_status",
3388
+ description: "Check status + per-lead contacts for a bulk enrichment you previously launched via leadbay_enrich_titles. Returns the bulk_id, progress per lead (done/total enrichable contacts), and overall progress. When include_contacts=true (opt-in), includes each contact's email/phone/job_title/enrichment.done. When to use: poll this after leadbay_enrich_titles returns a bulk_id. Default include_contacts=false for cheap status polls; set include_contacts=true once all_done flips for the final read. When NOT to use: as a substitute for leadbay_research_lead \u2014 that already includes enriched contacts for a single lead.",
3389
+ inputSchema: {
3390
+ type: "object",
3391
+ properties: {
3392
+ bulk_id: {
3393
+ type: "string",
3394
+ description: "UUIDv4 returned by leadbay_enrich_titles at launch time. Required."
3395
+ },
3396
+ include_contacts: {
3397
+ type: "boolean",
3398
+ description: "If true, return the full contact list per lead (email, phone, enrichment.done). Default false \u2014 cheap status polls."
3399
+ }
3400
+ },
3401
+ required: ["bulk_id"]
3402
+ },
3403
+ execute: async (client, params, ctx) => {
3404
+ if (!isValidBulkId(params.bulk_id)) {
3405
+ return {
3406
+ error: true,
3407
+ code: "BULK_INVALID_ID",
3408
+ message: "bulk_id is not a valid UUIDv4",
3409
+ hint: "Pass the bulk_id returned by leadbay_enrich_titles verbatim."
3410
+ };
3411
+ }
3412
+ if (!ctx?.bulkTracker) {
3413
+ return {
3414
+ error: true,
3415
+ code: "BULK_TRACKER_UNAVAILABLE",
3416
+ message: "No BulkTracker configured on this MCP instance",
3417
+ hint: "This composite requires a BulkTracker in ToolContext. Upgrade to @leadbay/mcp \u22650.3.0 or run with LEADBAY_BULK_STORE_ALLOW_MEMORY=1."
3418
+ };
3419
+ }
3420
+ const includeContacts = params.include_contacts ?? false;
3421
+ const startMs = Date.now();
3422
+ let record;
3423
+ try {
3424
+ record = await ctx.bulkTracker.get(params.bulk_id);
3425
+ } catch (err) {
3426
+ return {
3427
+ error: true,
3428
+ code: "BULK_STORE_UNAVAILABLE",
3429
+ message: `Bulk store read failed: ${err?.message ?? err}`,
3430
+ hint: "Check the file at $LEADBAY_BULK_STORE_PATH (default ~/.leadbay/bulks.json). Set LEADBAY_BULK_STORE_ALLOW_MEMORY=1 to fall back to in-memory storage on startup (handles won't survive restart)."
3431
+ };
3432
+ }
3433
+ if (!record) {
3434
+ return {
3435
+ error: true,
3436
+ code: "BULK_NOT_FOUND",
3437
+ message: "No bulk record for that bulk_id",
3438
+ 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."
3439
+ };
3440
+ }
3441
+ if (record.status === "pending") {
3442
+ return {
3443
+ error: true,
3444
+ code: "BULK_PENDING",
3445
+ message: "Bulk is in 'pending' state \u2014 the launch is in flight or the MCP crashed between launch and ack.",
3446
+ hint: "Retry leadbay_bulk_enrich_status in a few seconds. If it persists >60s, relaunch via leadbay_enrich_titles.",
3447
+ bulk_id: record.bulk_id,
3448
+ launched_at: record.launched_at
3449
+ };
3450
+ }
3451
+ if (record.status === "failed") {
3452
+ return {
3453
+ error: true,
3454
+ code: "BULK_LAUNCH_FAILED",
3455
+ message: "The original /enrichment/launch POST failed; no backend enrichment was ordered.",
3456
+ hint: "Call leadbay_enrich_titles again \u2014 the failed record won't block a fresh launch.",
3457
+ bulk_id: record.bulk_id,
3458
+ launched_at: record.launched_at
3459
+ };
3460
+ }
3461
+ const results = await pMap(record.lead_ids, async (leadId) => {
3462
+ try {
3463
+ const out = await getContacts.execute(client, { leadId });
3464
+ const contacts = Array.isArray(out?.contacts) ? out.contacts : [];
3465
+ const enrichable = contacts.filter((c) => c && c.enrichment);
3466
+ const done = enrichable.filter((c) => c.enrichment?.done === true).length;
3467
+ const total = enrichable.length;
3468
+ return {
3469
+ kind: "ok",
3470
+ lead_id: leadId,
3471
+ done,
3472
+ total,
3473
+ contacts: includeContacts ? contacts : void 0
3474
+ };
3475
+ } catch (err) {
3476
+ return {
3477
+ kind: "fail",
3478
+ lead_id: leadId,
3479
+ code: err?.code ?? "UNKNOWN",
3480
+ retry_after: err?._meta?.retry_after
3481
+ };
3482
+ }
3483
+ }, STATUS_FETCH_CONCURRENCY);
3484
+ const leads = [];
3485
+ const partialFailures = [];
3486
+ let totalDone = 0;
3487
+ let totalAll = 0;
3488
+ for (const r of results) {
3489
+ if (r.kind === "fail") {
3490
+ partialFailures.push({
3491
+ lead_id: r.lead_id,
3492
+ code: r.code,
3493
+ ...r.retry_after !== void 0 ? { retry_after: r.retry_after } : {}
3494
+ });
3495
+ continue;
3496
+ }
3497
+ leads.push({
3498
+ lead_id: r.lead_id,
3499
+ ...r.contacts ? { contacts: r.contacts } : {},
3500
+ enrichment_progress: { done: r.done, total: r.total }
3501
+ });
3502
+ totalDone += r.done;
3503
+ totalAll += r.total;
3504
+ }
3505
+ const overallProgress = {
3506
+ done: totalDone,
3507
+ total: totalAll,
3508
+ done_ratio: totalAll === 0 ? 0 : totalDone / totalAll
3509
+ };
3510
+ const allDone = totalAll > 0 && totalDone === totalAll && partialFailures.length === 0;
3511
+ ctx?.logger?.info?.(`bulk.status_checked bulk_id=${record.bulk_id} done=${totalDone} total=${totalAll} wall_ms=${Date.now() - startMs}`);
3512
+ return {
3513
+ bulk_id: record.bulk_id,
3514
+ launched_at: record.launched_at,
3515
+ status: record.status,
3516
+ durability: record.durability,
3517
+ titles: record.titles,
3518
+ email: record.email,
3519
+ phone: record.phone,
3520
+ lens_id: record.lens_id,
3521
+ leads,
3522
+ overall_progress: overallProgress,
3523
+ all_done: allDone,
3524
+ ...partialFailures.length > 0 ? { partial_failures: partialFailures } : {}
3525
+ };
3526
+ }
3527
+ };
3528
+
2350
3529
  // ../core/dist/composite/adjust-audience.js
2351
3530
  function tokens(s) {
2352
3531
  return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
@@ -2619,14 +3798,14 @@ var refinePrompt = {
2619
3798
  would_call: {
2620
3799
  method: "POST",
2621
3800
  path: `/organizations/${orgId}/user_prompt`,
2622
- body: { prompt: params.prompt }
3801
+ body: { user_prompt: params.prompt }
2623
3802
  }
2624
3803
  };
2625
3804
  }
2626
3805
  const postedAt = Date.now();
2627
3806
  const STALE_GUARD_MS = 5e3;
2628
3807
  await client.requestVoid("POST", `/organizations/${orgId}/user_prompt`, {
2629
- prompt: params.prompt
3808
+ user_prompt: params.prompt
2630
3809
  });
2631
3810
  client.invalidateMe();
2632
3811
  const attempts = params.clarification_poll_attempts ?? DEFAULT_POLL_ATTEMPTS;
@@ -2924,6 +4103,7 @@ var compositeReadTools = [
2924
4103
  researchLead,
2925
4104
  recallOrderedTitles,
2926
4105
  accountStatus,
4106
+ bulkEnrichStatus,
2927
4107
  // Keep the existing composites available too.
2928
4108
  researchCompany,
2929
4109
  prepareOutreach
@@ -2934,7 +4114,8 @@ var compositeWriteTools = [
2934
4114
  adjustAudience,
2935
4115
  refinePrompt,
2936
4116
  answerClarification,
2937
- reportOutreach
4117
+ reportOutreach,
4118
+ importLeads
2938
4119
  ];
2939
4120
  var compositeTools = [
2940
4121
  ...compositeReadTools,
@@ -2945,6 +4126,7 @@ var tools = [...compositeTools, ...granularTools];
2945
4126
  export {
2946
4127
  REGIONS,
2947
4128
  createClient,
4129
+ formatLoginError,
2948
4130
  resolveRegion,
2949
4131
  getMockJournal,
2950
4132
  clearMockJournal,
@@ -2995,7 +4177,13 @@ export {
2995
4177
  recallOrderedTitles,
2996
4178
  accountStatus,
2997
4179
  bulkQualifyLeads,
4180
+ importLeads,
2998
4181
  enrichTitles,
4182
+ isValidBulkId,
4183
+ LocalBulkStore,
4184
+ InMemoryBulkStore,
4185
+ createDefaultBulkStore,
4186
+ bulkEnrichStatus,
2999
4187
  adjustAudience,
3000
4188
  refinePrompt,
3001
4189
  answerClarification,