@leadbay/mcp 0.38.2 → 0.39.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.
@@ -2912,8 +2912,8 @@ var PROMPT_CATALOG_BULLETS = {
2912
2912
  };
2913
2913
 
2914
2914
  // src/prompts.ts
2915
- function userMessage(text2) {
2916
- return { role: "user", content: { type: "text", text: text2 } };
2915
+ function userMessage(text3) {
2916
+ return { role: "user", content: { type: "text", text: text3 } };
2917
2917
  }
2918
2918
  function substitutePlaceholders(body, substitutions) {
2919
2919
  let out = body;
@@ -5791,7 +5791,7 @@ measured:
5791
5791
  \`employees_max: number\`. FLAT numbers \u2014 nested \`employees: {min, max}\`
5792
5792
  exists only in RESULT payloads. \`example_lead.employees\` does not filter.
5793
5793
  \`locations\` take city/state/region names ("Dallas, TX", "\xCEle-de-France");
5794
- a country name is refused in code \u2014 whole-country intent = omit it.
5794
+ never a country: this workspace's own is dropped, any other is refused.
5795
5795
  7. **Prefer \`example_lead\` over \`query\`.** Query matches topic *vocabulary*:
5796
5796
  "gyms that need durable flooring" surfaced flooring VENDORS, 0 delivered.
5797
5797
  Use \`query\` only for signal an example can't express.
@@ -6861,8 +6861,31 @@ server-side: past the 30-min wall clock a job reads \`completed_partial\`
6861
6861
  delivered leads via leadbay_qualify_leads \`prior_deliveries\`). A 404 means
6862
6862
  unknown job or another org's job.
6863
6863
 
6864
+ \`compact: true\` (pass it whenever \`next_poll\` carries it) returns the rows of
6865
+ a free identity pass, 100 at a time from \`offset\`, instead of leads.
6866
+
6864
6867
  ---
6865
6868
 
6869
+ ## RENDERING \u2014 identity pass (\`rows[]\`)
6870
+
6871
+ \`rows[]\` replaces \`leads[]\`: one row per company, in the user's order. One
6872
+ table: **Company** (\`input\`, plus \`name\` when it differs) \xB7 **Website** \xB7
6873
+ **LinkedIn**. Skipped rows stay in it: \`not_in_universe\` \u2192 "not in Leadbay",
6874
+ \`low_confidence_identity\` \u2192 "several matches \u2014 add a city or website".
6875
+
6876
+ Then ONE coverage line from \`summary\`: found \`resolved\` of \`rows_total\` \xB7
6877
+ \`with_website\` with a website \xB7 \`with_linkedin\` with a LinkedIn \xB7
6878
+ \`ambiguous\` unclear \xB7 \`not_found\` not found. When most rows lack what the
6879
+ user asked for, that line is the answer: say so, and do not look the
6880
+ missing ones up one by one.
6881
+
6882
+ Done job with \`next_poll.offset\`: say "showing N of \`rows_total\`" and offer
6883
+ the rest via \`leadbay_lead_job_status(job_id, compact: true, offset)\`.
6884
+
6885
+ \`file\` set (local install only): every row is saved there as a CSV. Give the
6886
+ user that path.
6887
+
6888
+
6866
6889
  ## RENDERING \u2014 delivery table + honest funnel line
6867
6890
 
6868
6891
  Render delivered leads (\`leads[]\`, i.e. items with status \`delivered\` or
@@ -8105,13 +8128,14 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
8105
8128
  `;
8106
8129
  var leadbay_qualify_leads = `## WHEN TO USE
8107
8130
 
8108
- Trigger phrases: "qualify these companies", "vet this list", "which of these fit our ICP", "score these websites / accounts", "get me the right contact at these companies", "re-qualify what you delivered last week".
8131
+ Trigger phrases: "for each of these companies, give me the website / LinkedIn", "qualify these companies", "vet this list", "which of these fit our ICP", "score these websites / accounts", "get me the right contact at these companies", "re-qualify what you delivered last week".
8109
8132
 
8110
8133
  Do NOT use for: "find me new leads / companies that <profile>" \u2192 \`leadbay_find_new_leads\`; "qualify the top N of my lens batch" \u2192 \`leadbay_bulk_qualify_leads\`; "import this CSV file" \u2192 \`leadbay_import_leads\`; "tell me about <one company> in depth" \u2192 \`leadbay_research_lead_by_name_fuzzy\`; "add emails to the contacts I selected" \u2192 \`leadbay_enrich_titles\`.
8111
8134
 
8112
8135
  Prefer when: the user points at SPECIFIC companies (ids, websites, names, a pasted list, "what you found yesterday") and wants fit verdicts and/or the right person to talk to.
8113
8136
 
8114
8137
  Examples that SHOULD invoke this tool:
8138
+ - "For each of these 300 brokers, give me the website and LinkedIn, nothing else."
8115
8139
  - "Here are 60 restaurant websites from my Austin sweep \u2014 which fit, and who's the owner?"
8116
8140
  - "Re-qualify last week's delivery and get phone numbers for the good ones."
8117
8141
  - "Vet these 12 accounts from my spreadsheet against our criteria."
@@ -8123,7 +8147,8 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
8123
8147
 
8124
8148
  ## RENDER (quick)
8125
8149
 
8126
- 3-col table for delivered items (fit bar + company / why-fits \u226420 words /
8150
+ Identity pass (\`rows[]\`): one table Company / Website / LinkedIn, then the
8151
+ coverage line. Otherwise a 3-col table for delivered items (fit bar + company / why-fits \u226420 words /
8127
8152
  contact + channels) in returned order, then a compact Ref \u2192 Outcome table
8128
8153
  for skipped refs (not_in_universe, low_confidence_identity, ... in plain
8129
8154
  words), then the honest funnel line. Full algorithm below.
@@ -8138,6 +8163,14 @@ when asked \u2014 enriched with verified channels. Answers arrive per-item from
8138
8163
  job; this tool polls up to \`wait_seconds\` (default 45) and hands off to
8139
8164
  \`leadbay_lead_job_status\` when the batch needs longer.
8140
8165
 
8166
+ **A list of companies is ONE call, never a loop** of
8167
+ \`leadbay_research_lead_by_name_fuzzy\`: "for each of these companies give me
8168
+ X", a pasted list, a file. Identity only (in Leadbay or not, website,
8169
+ LinkedIn) \u2192 \`qualify: false\`: free, no \`confirm\`, returns \`rows[]\` and
8170
+ coverage counts. A fit question over the list ("which of these do
8171
+ factoring") keeps \`qualify: true\` and its quote. Over 500: one call per 500,
8172
+ each with its own \`request_id\`.
8173
+
8141
8174
  **Refs are flexible; outcomes are per-item.** \`lead_refs\` accepts any mix of
8142
8175
  \`lead_id\`, \`website\`, \`name\`(+\`location\`), or a stable \`contact_id\` from a
8143
8176
  prior result (enrichment then targets exactly that person, never a re-match).
@@ -8174,6 +8207,26 @@ Retry-After beyond \u2014 wait, don't hammer), 30-min job wall clock.
8174
8207
 
8175
8208
  ---
8176
8209
 
8210
+ ## RENDERING \u2014 identity pass (\`rows[]\`)
8211
+
8212
+ \`rows[]\` replaces \`leads[]\`: one row per company, in the user's order. One
8213
+ table: **Company** (\`input\`, plus \`name\` when it differs) \xB7 **Website** \xB7
8214
+ **LinkedIn**. Skipped rows stay in it: \`not_in_universe\` \u2192 "not in Leadbay",
8215
+ \`low_confidence_identity\` \u2192 "several matches \u2014 add a city or website".
8216
+
8217
+ Then ONE coverage line from \`summary\`: found \`resolved\` of \`rows_total\` \xB7
8218
+ \`with_website\` with a website \xB7 \`with_linkedin\` with a LinkedIn \xB7
8219
+ \`ambiguous\` unclear \xB7 \`not_found\` not found. When most rows lack what the
8220
+ user asked for, that line is the answer: say so, and do not look the
8221
+ missing ones up one by one.
8222
+
8223
+ Done job with \`next_poll.offset\`: say "showing N of \`rows_total\`" and offer
8224
+ the rest via \`leadbay_lead_job_status(job_id, compact: true, offset)\`.
8225
+
8226
+ \`file\` set (local install only): every row is saved there as a CSV. Give the
8227
+ user that path.
8228
+
8229
+
8177
8230
  ## RENDERING \u2014 delivery table + honest funnel line
8178
8231
 
8179
8232
  Render delivered leads (\`leads[]\`, i.e. items with status \`delivered\` or
@@ -8328,6 +8381,8 @@ Pick the 2-3 options that match what actually happened:
8328
8381
  | Items skipped \`low_confidence_identity\` | "Pick the right match" (show \`resolution.alternatives\`) | leadbay_qualify_leads with the chosen lead_id |
8329
8382
  | Contacts delivered without channels | "Get verified emails/phones for the keepers (uses quota \u2014 say so first)" | leadbay_qualify_leads(lead_refs with contact_id, channels) |
8330
8383
  | Disqualified with evidence | "Review why \u2014 adjust qualification questions if the criteria are off" | leadbay_get_qualification_questions |
8384
+ | Identity pass, list over 500 | "Run the next 500" | leadbay_qualify_leads(next 500, qualify: false) |
8385
+ | Identity pass, \`summary.without_website\` > 0. LAST option, never run unasked | "N have no website in Leadbay. Web research can look for one. It uses your plan's quota and may find none." | leadbay_qualify_leads(lead_refs: [{lead_id}] of rows without website, qualify: true) \u2192 quote first, confirm on the user's yes |
8331
8386
  `;
8332
8387
  var leadbay_qualify_status = `Retrieve the current state of a bulk_qualify_leads or import_and_qualify launch. Which ids to pass depends on which tool launched it, because only one of them creates a qualification job on the backend:
8333
8388
 
@@ -8806,9 +8861,9 @@ var leadbay_research_lead_by_name_fuzzy = `## WHEN TO USE
8806
8861
 
8807
8862
  Trigger phrases: "look up <Company>", "research <Company>", "what do we know about <Company>".
8808
8863
 
8809
- Do NOT use for: "picked row with leadId" \u2192 \`leadbay_research_lead_by_id\`; "draft outreach for <Contact>" \u2192 \`leadbay_prepare_outreach\`.
8864
+ Do NOT use for: "picked row with leadId" \u2192 \`leadbay_research_lead_by_id\`; "draft outreach for <Contact>" \u2192 \`leadbay_prepare_outreach\`; "for each of these companies\u2026" \u2192 \`leadbay_qualify_leads\`.
8810
8865
 
8811
- Prefer when: a company name or domain in prose, no Leadbay id yet \u2014 always pass \`website\` if a domain was mentioned
8866
+ Prefer when: ONE company name or domain in prose, no Leadbay id yet \u2014 always pass \`website\` if a domain was mentioned
8812
8867
 
8813
8868
  Examples that SHOULD invoke this tool:
8814
8869
  - "Look up Acme Corp for me."
@@ -8818,7 +8873,7 @@ Examples that SHOULD invoke this tool:
8818
8873
  Examples that should NOT invoke this tool (sound similar, route elsewhere):
8819
8874
  - "Tell me about that lead I just picked."
8820
8875
  - "Draft outreach to Acme's CTO."
8821
- - "Show me today's leads."
8876
+ - "Websites and LinkedIn for these 200 companies."
8822
8877
 
8823
8878
  ---
8824
8879
 
@@ -8832,15 +8887,13 @@ list" into an answer. With only a contact email, pass \`email\`: the company
8832
8887
  domain is derived from it, consumer mailboxes ignored.
8833
8888
 
8834
8889
  When the registry cannot pick one company it returns \`{resolution:
8835
- "ambiguous", query, candidates:[{leadId, name, website, location, \u2026}]}\`
8836
- instead of a card. Ask which one; never guess from \`score\`.
8890
+ "ambiguous", query, candidates:[\u2026]}\` instead of a card. Ask which one; never
8891
+ guess from \`score\`.
8837
8892
 
8838
8893
  \`LEAD_NOT_FOUND\` is not a dead end: its hint names the field that would have
8839
8894
  found it \u2014 \`website\` or \`registry_number\`, both params. Ask for it and call
8840
8895
  again. Do not offer an import before asking.
8841
8896
 
8842
- Offer \`_meta.match_candidates\` when present.
8843
-
8844
8897
  ---
8845
8898
 
8846
8899
  ## RENDERING \u2014 single-record research card, mode-adaptive
@@ -10147,7 +10200,7 @@ measured:
10147
10200
  \`employees_max: number\`. FLAT numbers \u2014 nested \`employees: {min, max}\`
10148
10201
  exists only in RESULT payloads. \`example_lead.employees\` does not filter.
10149
10202
  \`locations\` take city/state/region names ("Dallas, TX", "\xCEle-de-France");
10150
- a country name is refused in code \u2014 whole-country intent = omit it.
10203
+ never a country: this workspace's own is dropped, any other is refused.
10151
10204
  7. **Prefer \`example_lead\` over \`query\`.** Query matches topic *vocabulary*:
10152
10205
  "gyms that need durable flooring" surfaced flooring VENDORS, 0 delivered.
10153
10206
  Use \`query\` only for signal an example can't express.
@@ -16931,12 +16984,12 @@ var CITY_ALIASES = {
16931
16984
  vegas: "Las Vegas",
16932
16985
  nola: "New Orleans"
16933
16986
  };
16934
- function expandAlias(text2) {
16935
- const key = text2.trim().toLowerCase();
16936
- return CITY_ALIASES[key] ?? text2;
16987
+ function expandAlias(text3) {
16988
+ const key = text3.trim().toLowerCase();
16989
+ return CITY_ALIASES[key] ?? text3;
16937
16990
  }
16938
- function scoreMatch(text2, match) {
16939
- const t = text2.trim().toLowerCase();
16991
+ function scoreMatch(text3, match) {
16992
+ const t = text3.trim().toLowerCase();
16940
16993
  const n = match.name.trim().toLowerCase();
16941
16994
  if (n === t)
16942
16995
  return 1;
@@ -16959,8 +17012,8 @@ async function resolveLocations(client, texts) {
16959
17012
  const resolved = [...direct];
16960
17013
  const ambiguities = [];
16961
17014
  for (const originalText of free) {
16962
- const text2 = expandAlias(originalText);
16963
- const path = `/geo/search?q=${encodeURIComponent(text2)}`;
17015
+ const text3 = expandAlias(originalText);
17016
+ const path = `/geo/search?q=${encodeURIComponent(text3)}`;
16964
17017
  let response;
16965
17018
  try {
16966
17019
  response = await client.request("GET", path);
@@ -16999,7 +17052,7 @@ async function resolveLocations(client, texts) {
16999
17052
  name: r.name,
17000
17053
  country: r.country,
17001
17054
  level: r.level,
17002
- score: scoreMatch(text2, r)
17055
+ score: scoreMatch(text3, r)
17003
17056
  })).sort((a, b) => {
17004
17057
  if (b.score !== a.score)
17005
17058
  return b.score - a.score;
@@ -18165,9 +18218,9 @@ ${firm.short_description}`);
18165
18218
  out.push(`### ${sec.section_emoji ?? ""} ${label}`.trim());
18166
18219
  const entries = Array.isArray(sec.entries) ? sec.entries : [];
18167
18220
  for (const e of entries.slice(0, 5)) {
18168
- const text2 = e.text ?? e.summary ?? JSON.stringify(e).slice(0, 200);
18221
+ const text3 = e.text ?? e.summary ?? JSON.stringify(e).slice(0, 200);
18169
18222
  const hot = e.hot === true ? " \u{1F525}" : "";
18170
- out.push(`- ${text2}${hot}`);
18223
+ out.push(`- ${text3}${hot}`);
18171
18224
  }
18172
18225
  if (entries.length > 5)
18173
18226
  out.push(`- _${entries.length - 5} more \u2026_`);
@@ -22904,8 +22957,8 @@ function tokens(s) {
22904
22957
  return [];
22905
22958
  return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
22906
22959
  }
22907
- function bestMatches(text2, taxonomy) {
22908
- const want = new Set(tokens(text2));
22960
+ function bestMatches(text3, taxonomy) {
22961
+ const want = new Set(tokens(text3));
22909
22962
  if (want.size === 0)
22910
22963
  return [];
22911
22964
  const ranked = taxonomy.map((s) => {
@@ -22934,12 +22987,12 @@ async function resolveSectors(client, texts, ctx) {
22934
22987
  }
22935
22988
  const resolved = [...direct];
22936
22989
  const ambiguities = [];
22937
- for (const text2 of free) {
22938
- const matches = bestMatches(text2, taxonomy);
22990
+ for (const text3 of free) {
22991
+ const matches = bestMatches(text3, taxonomy);
22939
22992
  if (matches.length === 1 || matches.length >= 2 && matches[0].score >= 0.66 && matches[0].score - matches[1].score >= 0.34) {
22940
22993
  resolved.push(matches[0].id);
22941
22994
  } else {
22942
- ambiguities.push({ sector_text: text2, matches });
22995
+ ambiguities.push({ sector_text: text3, matches });
22943
22996
  }
22944
22997
  }
22945
22998
  return { resolved, ambiguities };
@@ -24356,6 +24409,21 @@ var findNewLeads = {
24356
24409
  "channels",
24357
24410
  "exclude_lead_ids"
24358
24411
  ]);
24412
+ const homeCountry = detectCountryLocations(params.filters?.locations, "filters.locations", client.region).filter((hit) => hit.kind === "home_country");
24413
+ let countryNote;
24414
+ if (homeCountry.length > 0) {
24415
+ const dropped = new Set(homeCountry.map((hit) => hit.value));
24416
+ const raw = params.filters.locations;
24417
+ const rest = (Array.isArray(raw) ? raw : [raw]).filter((v) => !dropped.has(v));
24418
+ params = {
24419
+ ...params,
24420
+ filters: { ...params.filters, locations: rest.length > 0 ? rest : void 0 }
24421
+ };
24422
+ const removed = [...dropped].map((v) => `"${v}"`).join(", ");
24423
+ countryNote = {
24424
+ note: rest.length > 0 ? `Removed ${removed} from filters.locations: a country is never a location filter. The search covers ${rest.map((v) => `"${v}"`).join(", ")} only.` : `Removed ${removed} from filters.locations: this workspace holds ${homeCountry[0].country} companies only, so the search already covers all of it.`
24425
+ };
24426
+ }
24359
24427
  rejectCountryLocations(params.filters?.locations, client.region);
24360
24428
  rejectMalformedExclusions(params.exclude_lead_ids);
24361
24429
  rejectOversizedExclusions(params.exclude_lead_ids);
@@ -24433,6 +24501,7 @@ var findNewLeads = {
24433
24501
  return {
24434
24502
  dry_run: true,
24435
24503
  ...forecast,
24504
+ ...countryNote,
24436
24505
  region: client.region
24437
24506
  };
24438
24507
  }
@@ -24453,6 +24522,7 @@ var findNewLeads = {
24453
24522
  estimated_cost: forecast?.estimated_cost ?? null,
24454
24523
  items_requested: forecast?.items_requested ?? null,
24455
24524
  hint: vetoed ? "confirm:false vetoed the run \u2014 nothing was submitted. Re-call with confirm:true to proceed, or drop qualify/channels for a free search." : "Tell the user what will run and that it uses their plan's quota (no amounts, no money), get an explicit go-ahead, then re-call with confirm:true. For a free search instead: omit qualify and channels.",
24525
+ ...countryNote,
24456
24526
  region: client.region
24457
24527
  };
24458
24528
  }
@@ -24507,11 +24577,142 @@ var findNewLeads = {
24507
24577
  since: snapshot.next_since ?? null,
24508
24578
  suggested_wait_seconds: done ? 0 : 60
24509
24579
  },
24580
+ ...countryNote,
24510
24581
  region: client.region
24511
24582
  };
24512
24583
  }
24513
24584
  };
24514
24585
 
24586
+ // ../core/dist/composite/_identity-rows.js
24587
+ var IDENTITY_ROWS_PER_RESULT = 100;
24588
+ function text(v) {
24589
+ return typeof v === "string" && v.trim() ? v.trim() : null;
24590
+ }
24591
+ function toRow(item) {
24592
+ const asked = item.ref?.requested_as ?? {};
24593
+ const row = {
24594
+ input_indexes: item.ref?.input_indexes ?? null,
24595
+ input: text(asked.name) ?? text(asked.website) ?? text(asked.lead_id) ?? text(asked.contact_id),
24596
+ status: item.status
24597
+ };
24598
+ if (item.status_reason)
24599
+ row.status_reason = item.status_reason;
24600
+ const lead = item.lead;
24601
+ if (!lead)
24602
+ return row;
24603
+ const company = lead.company ?? {};
24604
+ row.lead_id = lead.lead_id ?? item.ref?.lead_id ?? void 0;
24605
+ row.name = company.name;
24606
+ row.website = text(company.website);
24607
+ row.linkedin = text(company.socials?.linkedin);
24608
+ return row;
24609
+ }
24610
+ function identityRows(items) {
24611
+ const first = (i) => {
24612
+ const idx = i.ref?.input_indexes;
24613
+ return Array.isArray(idx) && idx.length > 0 ? Math.min(...idx) : Number.MAX_SAFE_INTEGER;
24614
+ };
24615
+ return [...items].sort((a, b) => first(a) - first(b) || a.seq - b.seq).map(toRow);
24616
+ }
24617
+ function identityCoverage(rows) {
24618
+ let resolved = 0;
24619
+ let ambiguous = 0;
24620
+ let notFound = 0;
24621
+ let otherSkipped = 0;
24622
+ let withWebsite = 0;
24623
+ let withLinkedin = 0;
24624
+ for (const r of rows) {
24625
+ if (r.status !== "skipped") {
24626
+ resolved += 1;
24627
+ if (r.website)
24628
+ withWebsite += 1;
24629
+ if (r.linkedin)
24630
+ withLinkedin += 1;
24631
+ } else if (r.status_reason === "low_confidence_identity")
24632
+ ambiguous += 1;
24633
+ else if (r.status_reason === "not_in_universe")
24634
+ notFound += 1;
24635
+ else
24636
+ otherSkipped += 1;
24637
+ }
24638
+ return {
24639
+ resolved,
24640
+ ambiguous,
24641
+ not_found: notFound,
24642
+ other_skipped: otherSkipped,
24643
+ with_website: withWebsite,
24644
+ with_linkedin: withLinkedin,
24645
+ without_website: resolved - withWebsite
24646
+ };
24647
+ }
24648
+ var CSV_HEADER = [
24649
+ "row",
24650
+ "input",
24651
+ "status",
24652
+ "reason",
24653
+ "company",
24654
+ "website",
24655
+ "linkedin",
24656
+ "lead_id"
24657
+ ];
24658
+ function identityCsv(rows) {
24659
+ return synthesizeCsv(CSV_HEADER, rows.map((r) => ({
24660
+ row: (r.input_indexes ?? []).map((i) => i + 1).join(";"),
24661
+ input: r.input ?? "",
24662
+ status: r.status,
24663
+ reason: r.status_reason ?? "",
24664
+ company: r.name ?? "",
24665
+ website: r.website ?? "",
24666
+ linkedin: r.linkedin ?? "",
24667
+ lead_id: r.lead_id ?? ""
24668
+ })));
24669
+ }
24670
+ async function saveIdentityFile(ctx, jobId, snapshot, items) {
24671
+ if (!ctx?.saveFile)
24672
+ return {};
24673
+ if (!TERMINAL_JOB_STATES.has(snapshot.job.state))
24674
+ return {};
24675
+ if (snapshot.items_truncated || items.length === 0)
24676
+ return {};
24677
+ try {
24678
+ const name = `leadbay-companies-${jobId.slice(0, 8)}.csv`;
24679
+ return { file: await ctx.saveFile(name, identityCsv(identityRows(items))) };
24680
+ } catch (e) {
24681
+ return { file_error: e instanceof Error ? e.message : String(e) };
24682
+ }
24683
+ }
24684
+ function readOffset(v) {
24685
+ const n = typeof v === "string" ? Number(v) : v;
24686
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
24687
+ }
24688
+ function identityAnswer(jobId, snapshot, items, offset) {
24689
+ const all = identityRows(items);
24690
+ const rows = all.slice(offset, offset + IDENTITY_ROWS_PER_RESULT);
24691
+ const end = offset + rows.length;
24692
+ const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
24693
+ const truncated = snapshot.items_truncated ?? false;
24694
+ const nextOffset = !done || truncated ? 0 : end < all.length ? end : null;
24695
+ return {
24696
+ summary: {
24697
+ ...identityCoverage(all),
24698
+ stop_reason: snapshot.funnel?.stop_reason ?? null
24699
+ },
24700
+ rows,
24701
+ rows_total: all.length,
24702
+ rows_offset: offset,
24703
+ items_truncated: truncated,
24704
+ scope_notes: snapshot.explain?.scope_notes ?? [],
24705
+ still_running: !done,
24706
+ next_poll: nextOffset === null ? null : {
24707
+ tool: "leadbay_lead_job_status",
24708
+ job_id: jobId,
24709
+ compact: true,
24710
+ offset: nextOffset,
24711
+ suggested_wait_seconds: done ? 0 : 60
24712
+ }
24713
+ };
24714
+ }
24715
+
24515
24716
  // ../core/dist/composite/qualify-leads.js
24516
24717
  var DEFAULT_WAIT_SECONDS2 = 45;
24517
24718
  function normalizeLeadRefs(refs) {
@@ -24560,7 +24761,7 @@ function rejectMalformedLeadRefs(refs) {
24560
24761
  hint: "Each ref is an object whose fields are STRINGS \u2014 {lead_id} | {website} | {name, location?} | {contact_id}. A bare string is accepted and reshaped; null, numbers, arrays and non-string field values are not. Fix or drop those entries and re-call."
24561
24762
  };
24562
24763
  }
24563
- function text(value) {
24764
+ function text2(value) {
24564
24765
  if (typeof value !== "string")
24565
24766
  return null;
24566
24767
  const v = value.trim().toLowerCase();
@@ -24568,7 +24769,7 @@ function text(value) {
24568
24769
  }
24569
24770
  function derivedRequestId(params) {
24570
24771
  const refs = canonicalSet((params.lead_refs ?? []).map((r) => {
24571
- const website = text(r.website);
24772
+ const website = text2(r.website);
24572
24773
  return [
24573
24774
  // UUIDs are case-insensitive to the backend, so an uppercase id and
24574
24775
  // its lowercase form are the same lead and must share a key.
@@ -24579,8 +24780,8 @@ function derivedRequestId(params) {
24579
24780
  // AND to one key. Fall back to the trimmed/lowercased raw value when
24580
24781
  // it is not domain-shaped, rather than dropping the field.
24581
24782
  website ? normalizeDomain(website) ?? website : null,
24582
- text(r.name),
24583
- text(r.location)
24783
+ text2(r.name),
24784
+ text2(r.location)
24584
24785
  ];
24585
24786
  }));
24586
24787
  const shape = {
@@ -24726,6 +24927,7 @@ var qualifyLeads = {
24726
24927
  const buysChannels = (params.channels?.length ?? 0) > 0;
24727
24928
  const buysQualification = qualify !== false;
24728
24929
  const isPaid = buysQualification || buysChannels;
24930
+ const identityOnly = !isPaid && (params.contact_titles?.length ?? 0) === 0 && params.prior_deliveries == null;
24729
24931
  const vetoed = confirm === false;
24730
24932
  const consented = !vetoed && confirm === true;
24731
24933
  const requestId = presentRequestId(params.request_id) ?? (isPaid ? derivedRequestId(params) : void 0);
@@ -24774,6 +24976,25 @@ var qualifyLeads = {
24774
24976
  const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
24775
24977
  const indexed = submit.duplicate ?? false ? remapInputIndexes(snapshot.items, params.lead_refs) : { items: snapshot.items, remapped: true };
24776
24978
  const view = { ...snapshot, items: indexed.items };
24979
+ if (identityOnly) {
24980
+ const answer = identityAnswer(submit.job_id, snapshot, view.items, 0);
24981
+ return {
24982
+ job_id: submit.job_id,
24983
+ request_id: requestId ?? null,
24984
+ duplicate_submit: submit.duplicate ?? false,
24985
+ state: snapshot.job.state,
24986
+ done,
24987
+ ...answer,
24988
+ summary: {
24989
+ refs_submitted: params.lead_refs?.length ?? 0,
24990
+ items_requested: submit.items_requested,
24991
+ ...answer.summary
24992
+ },
24993
+ input_indexes_remapped: submit.duplicate ?? false ? indexed.remapped : null,
24994
+ ...await saveIdentityFile(ctx, submit.job_id, snapshot, view.items),
24995
+ region: client.region
24996
+ };
24997
+ }
24777
24998
  return {
24778
24999
  job_id: submit.job_id,
24779
25000
  // Echo the key actually sent, so a retry can reuse it verbatim.
@@ -24863,6 +25084,14 @@ var leadJobStatus = {
24863
25084
  wait_seconds: {
24864
25085
  type: "number",
24865
25086
  description: "0 (default) = instant snapshot. >0 = keep polling up to this many seconds until the job is terminal \u2014 use ~60 when the user asked to wait for results."
25087
+ },
25088
+ compact: {
25089
+ type: "boolean",
25090
+ description: "true = one row per company (name, website, LinkedIn) in the order of the user's list, plus coverage counts, instead of full leads. Pass it when next_poll carries it (a leadbay_qualify_leads identity pass). Reads the whole job; since is ignored."
25091
+ },
25092
+ offset: {
25093
+ type: "number",
25094
+ description: "With compact: the first row to return (default 0). Each result carries at most 100 rows; take the next value from next_poll.offset."
24866
25095
  }
24867
25096
  },
24868
25097
  required: ["job_id"],
@@ -24870,8 +25099,19 @@ var leadJobStatus = {
24870
25099
  },
24871
25100
  execute: async (client, params, ctx) => {
24872
25101
  const waitSeconds = clampWaitSeconds(params.wait_seconds, 0);
24873
- const snapshot = waitSeconds > 0 ? await waitForJob(client, params.job_id, waitSeconds, ctx, void 0, params.since, params.limit) : await collectJobSnapshot(client, params.job_id, params.since, params.limit, ctx?.signal);
25102
+ const compact = params.compact === true || params.compact === "true";
25103
+ const since = compact ? void 0 : params.since;
25104
+ const snapshot = waitSeconds > 0 ? await waitForJob(client, params.job_id, waitSeconds, ctx, void 0, since, params.limit) : await collectJobSnapshot(client, params.job_id, since, params.limit, ctx?.signal);
24874
25105
  const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
25106
+ if (compact) {
25107
+ return {
25108
+ job_id: params.job_id,
25109
+ state: snapshot.job.state,
25110
+ done,
25111
+ ...identityAnswer(params.job_id, snapshot, snapshot.items, readOffset(params.offset)),
25112
+ region: client.region
25113
+ };
25114
+ }
24875
25115
  const { leads, skipped } = splitItems(snapshot);
24876
25116
  return {
24877
25117
  job_id: params.job_id,
@@ -26251,8 +26491,8 @@ var sendFeedback = {
26251
26491
  }
26252
26492
  },
26253
26493
  execute: async (client, params, ctx) => {
26254
- const text2 = typeof params.message === "string" ? params.message.trim() : "";
26255
- if (!text2) {
26494
+ const text3 = typeof params.message === "string" ? params.message.trim() : "";
26495
+ if (!text3) {
26256
26496
  return {
26257
26497
  error: true,
26258
26498
  code: "BAD_INPUT",
@@ -26260,7 +26500,7 @@ var sendFeedback = {
26260
26500
  hint: "Ask the user what they'd like to tell the Leadbay team, then call again with their words in `message`."
26261
26501
  };
26262
26502
  }
26263
- const message = text2.length > MESSAGE_MAX2 ? `${text2.slice(0, MESSAGE_MAX2 - 1)}\u2026` : text2;
26503
+ const message = text3.length > MESSAGE_MAX2 ? `${text3.slice(0, MESSAGE_MAX2 - 1)}\u2026` : text3;
26264
26504
  if (!ctx?.sendFeedback) {
26265
26505
  return {
26266
26506
  sent: false,
@@ -27951,6 +28191,7 @@ ${url}
27951
28191
  // RAW value: the friction redaction above is an ANALYTICS control, and
27952
28192
  // must not change in-process tool behaviour.
27953
28193
  triggered_by: rawTriggeredBy,
28194
+ saveFile: opts.saveFile,
27954
28195
  // Route leadbay_send_feedback to Sentry's feedback inbox (same place
27955
28196
  // the web app's form lands). NOOP_TELEMETRY returns false, so the
27956
28197
  // tool reports honestly when telemetry is off.
@@ -28372,7 +28613,7 @@ function parseWriteEnv(env = process.env) {
28372
28613
  }
28373
28614
 
28374
28615
  // src/http-server.ts
28375
- var VERSION = true ? "0.38.2" : "0.0.0-dev";
28616
+ var VERSION = true ? "0.39.0" : "0.0.0-dev";
28376
28617
  var PORT = Number(process.env.PORT ?? 8080);
28377
28618
  var HOST = process.env.HOST ?? "0.0.0.0";
28378
28619
  var logger = {
@@ -1805,7 +1805,7 @@ var init_installer_gui = __esm({
1805
1805
  init_install_dxt();
1806
1806
  init_install_shared();
1807
1807
  init_oauth();
1808
- VERSION = true ? "0.38.2" : "0.0.0-dev";
1808
+ VERSION = true ? "0.39.0" : "0.0.0-dev";
1809
1809
  MESSAGES = {
1810
1810
  en: {
1811
1811
  installer: {
@@ -1068,7 +1068,7 @@ async function oauthLogin(opts) {
1068
1068
  }
1069
1069
 
1070
1070
  // installer/installer-gui.ts
1071
- var VERSION = true ? "0.38.2" : "0.0.0-dev";
1071
+ var VERSION = true ? "0.39.0" : "0.0.0-dev";
1072
1072
  var MESSAGES = {
1073
1073
  en: {
1074
1074
  installer: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leadbay/mcp",
3
- "version": "0.38.2",
3
+ "version": "0.39.0",
4
4
  "mcpName": "io.github.leadbay/leadbay-mcp",
5
5
  "description": "Model Context Protocol (MCP) server for Leadbay — AI lead discovery, qualification, and enrichment for Claude Desktop, Cursor, and Claude Code.",
6
6
  "type": "module",