@leadbay/mcp 0.38.2 → 0.39.1

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.
@@ -10596,6 +10649,16 @@ var listLenses = {
10596
10649
  }
10597
10650
  };
10598
10651
 
10652
+ // ../core/dist/interactions.js
10653
+ function reportLeadInteractions(client, lensId, leadIds, types, logger2) {
10654
+ if (leadIds.length === 0 || types.length === 0)
10655
+ return;
10656
+ const events = leadIds.flatMap((leadId) => types.map((type) => ({ type, lead_id: leadId, lens_id: String(lensId) })));
10657
+ void client.request("POST", "/interactions", events).catch((err) => {
10658
+ logger2?.warn?.(`interactions: ${types.join("+")} for ${leadIds.length} lead(s) not recorded: ${err?.message ?? err?.code ?? err}`);
10659
+ });
10660
+ }
10661
+
10599
10662
  // ../core/dist/tools/discover-leads.js
10600
10663
  var discoverLeads = {
10601
10664
  name: "leadbay_discover_leads",
@@ -10625,11 +10688,12 @@ var discoverLeads = {
10625
10688
  },
10626
10689
  additionalProperties: false
10627
10690
  },
10628
- execute: async (client, params) => {
10691
+ execute: async (client, params, ctx) => {
10629
10692
  const lensId = params.lensId ?? await client.resolveDefaultLens();
10630
10693
  const page = params.page ?? 0;
10631
10694
  const count = Math.min(params.count ?? 20, 50);
10632
10695
  const res = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${count}&page=${page}&contacts=true`);
10696
+ reportLeadInteractions(client, lensId, res.items.map((lead) => lead.id), ["LEAD_SEEN"], ctx?.logger);
10633
10697
  const totalPages = res.pagination?.pages ?? 0;
10634
10698
  const currentPage = res.pagination?.page ?? page;
10635
10699
  const hasMore = currentPage < totalPages - 1;
@@ -10718,13 +10782,9 @@ var getLeadProfile = {
10718
10782
  },
10719
10783
  required: ["lead", "contacts"]
10720
10784
  },
10721
- execute: async (client, params) => {
10785
+ execute: async (client, params, ctx) => {
10722
10786
  const lensId = params.lensId ?? await client.resolveDefaultLens();
10723
- void client.request("POST", "/interactions", [
10724
- { type: "LEAD_SEEN", leadId: params.leadId, lensId: String(lensId) },
10725
- { type: "LEAD_CLICKED", leadId: params.leadId, lensId: String(lensId) }
10726
- ]).catch(() => {
10727
- });
10787
+ reportLeadInteractions(client, lensId, [params.leadId], ["LEAD_SEEN", "LEAD_CLICKED"], ctx?.logger);
10728
10788
  const [leadResult, qualResult, contactsResult, paidContactsResult, webFetchResult] = await Promise.allSettled([
10729
10789
  client.request("GET", `/lenses/${lensId}/leads/${params.leadId}`),
10730
10790
  client.request("GET", `/leads/${params.leadId}/ai_agent_responses`),
@@ -16818,6 +16878,9 @@ var pullLeads = {
16818
16878
  return resolvedOrder.error;
16819
16879
  const orderQs = resolvedOrder.order ? `&order=${encodeURIComponent(resolvedOrder.order)}` : "";
16820
16880
  const res = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${count}&page=${page}&contacts=true${orderQs}`);
16881
+ if (params._reportSeen !== false) {
16882
+ reportLeadInteractions(client, lensId, res.items.map((lead) => lead.id), ["LEAD_SEEN"], ctx?.logger);
16883
+ }
16821
16884
  const summaries = await Promise.all(res.items.map(async (lead) => {
16822
16885
  try {
16823
16886
  const r = await client.request("GET", `/leads/${lead.id}/ai_agent_responses`);
@@ -16931,12 +16994,12 @@ var CITY_ALIASES = {
16931
16994
  vegas: "Las Vegas",
16932
16995
  nola: "New Orleans"
16933
16996
  };
16934
- function expandAlias(text2) {
16935
- const key = text2.trim().toLowerCase();
16936
- return CITY_ALIASES[key] ?? text2;
16997
+ function expandAlias(text3) {
16998
+ const key = text3.trim().toLowerCase();
16999
+ return CITY_ALIASES[key] ?? text3;
16937
17000
  }
16938
- function scoreMatch(text2, match) {
16939
- const t = text2.trim().toLowerCase();
17001
+ function scoreMatch(text3, match) {
17002
+ const t = text3.trim().toLowerCase();
16940
17003
  const n = match.name.trim().toLowerCase();
16941
17004
  if (n === t)
16942
17005
  return 1;
@@ -16959,8 +17022,8 @@ async function resolveLocations(client, texts) {
16959
17022
  const resolved = [...direct];
16960
17023
  const ambiguities = [];
16961
17024
  for (const originalText of free) {
16962
- const text2 = expandAlias(originalText);
16963
- const path = `/geo/search?q=${encodeURIComponent(text2)}`;
17025
+ const text3 = expandAlias(originalText);
17026
+ const path = `/geo/search?q=${encodeURIComponent(text3)}`;
16964
17027
  let response;
16965
17028
  try {
16966
17029
  response = await client.request("GET", path);
@@ -16999,7 +17062,7 @@ async function resolveLocations(client, texts) {
16999
17062
  name: r.name,
17000
17063
  country: r.country,
17001
17064
  level: r.level,
17002
- score: scoreMatch(text2, r)
17065
+ score: scoreMatch(text3, r)
17003
17066
  })).sort((a, b) => {
17004
17067
  if (b.score !== a.score)
17005
17068
  return b.score - a.score;
@@ -17496,7 +17559,7 @@ var tourPlan = {
17496
17559
  city_id: params.city_id,
17497
17560
  count: followupsCount
17498
17561
  }, ctx),
17499
- pullLeads.execute(client, { count: DISCOVER_OVER_PULL }, ctx)
17562
+ pullLeads.execute(client, { count: DISCOVER_OVER_PULL, _reportSeen: false }, ctx)
17500
17563
  ]);
17501
17564
  if (followupsResult.status === "fulfilled") {
17502
17565
  const r = followupsResult.value;
@@ -17532,6 +17595,10 @@ var tourPlan = {
17532
17595
  }
17533
17596
  const filtered = rawDiscover.filter((l) => cityMatches(l, params.city));
17534
17597
  const discoverLeads2 = filtered.slice(0, discoverCount);
17598
+ const pulledLensId = leadsResult.status === "fulfilled" ? leadsResult.value?.lens?.id : null;
17599
+ if (pulledLensId != null) {
17600
+ reportLeadInteractions(client, pulledLensId, discoverLeads2.map((l) => l.id), ["LEAD_SEEN"], ctx?.logger);
17601
+ }
17535
17602
  const filterNote = params.city ? `Matched ${filtered.length}/${rawDiscover.length} Discover leads to '${params.city}'; returning top ${discoverLeads2.length}.` : `No city filter applied; returning top ${discoverLeads2.length} Discover leads.`;
17536
17603
  return {
17537
17604
  city: params.city ?? null,
@@ -18165,9 +18232,9 @@ ${firm.short_description}`);
18165
18232
  out.push(`### ${sec.section_emoji ?? ""} ${label}`.trim());
18166
18233
  const entries = Array.isArray(sec.entries) ? sec.entries : [];
18167
18234
  for (const e of entries.slice(0, 5)) {
18168
- const text2 = e.text ?? e.summary ?? JSON.stringify(e).slice(0, 200);
18235
+ const text3 = e.text ?? e.summary ?? JSON.stringify(e).slice(0, 200);
18169
18236
  const hot = e.hot === true ? " \u{1F525}" : "";
18170
- out.push(`- ${text2}${hot}`);
18237
+ out.push(`- ${text3}${hot}`);
18171
18238
  }
18172
18239
  if (entries.length > 5)
18173
18240
  out.push(`- _${entries.length - 5} more \u2026_`);
@@ -18486,11 +18553,7 @@ var researchLeadById = {
18486
18553
  }
18487
18554
  const lensId = params.lensId ?? await client.resolveDefaultLens();
18488
18555
  const leadId = params.leadId;
18489
- void client.request("POST", "/interactions", [
18490
- { type: "LEAD_SEEN", leadId, lensId: String(lensId) },
18491
- { type: "LEAD_CLICKED", leadId, lensId: String(lensId) }
18492
- ]).catch(() => {
18493
- });
18556
+ reportLeadInteractions(client, lensId, [leadId], ["LEAD_SEEN", "LEAD_CLICKED"], _ctx?.logger);
18494
18557
  const [profileR, qualR, contactsR, webFetchR, activitiesR, orgContactsR] = await Promise.allSettled([
18495
18558
  client.request("GET", `/lenses/${lensId}/leads/${leadId}`),
18496
18559
  client.request("GET", `/leads/${leadId}/ai_agent_responses`),
@@ -19532,11 +19595,7 @@ var getLeadCustomFields = {
19532
19595
  },
19533
19596
  execute: async (client, params, ctx) => {
19534
19597
  const lensId = params.lensId ?? await client.resolveDefaultLens();
19535
- void client.request("POST", "/interactions", [
19536
- { type: "LEAD_SEEN", leadId: params.leadId, lensId: String(lensId) },
19537
- { type: "LEAD_CLICKED", leadId: params.leadId, lensId: String(lensId) }
19538
- ]).catch(() => {
19539
- });
19598
+ reportLeadInteractions(client, lensId, [params.leadId], ["LEAD_SEEN", "LEAD_CLICKED"], ctx?.logger);
19540
19599
  const lead = await client.request("GET", `/lenses/${lensId}/leads/${params.leadId}`);
19541
19600
  const entries = lead.custom_fields ?? [];
19542
19601
  const needsCatalog = entries.some((e) => !e?.field?.name);
@@ -22904,8 +22963,8 @@ function tokens(s) {
22904
22963
  return [];
22905
22964
  return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
22906
22965
  }
22907
- function bestMatches(text2, taxonomy) {
22908
- const want = new Set(tokens(text2));
22966
+ function bestMatches(text3, taxonomy) {
22967
+ const want = new Set(tokens(text3));
22909
22968
  if (want.size === 0)
22910
22969
  return [];
22911
22970
  const ranked = taxonomy.map((s) => {
@@ -22934,12 +22993,12 @@ async function resolveSectors(client, texts, ctx) {
22934
22993
  }
22935
22994
  const resolved = [...direct];
22936
22995
  const ambiguities = [];
22937
- for (const text2 of free) {
22938
- const matches = bestMatches(text2, taxonomy);
22996
+ for (const text3 of free) {
22997
+ const matches = bestMatches(text3, taxonomy);
22939
22998
  if (matches.length === 1 || matches.length >= 2 && matches[0].score >= 0.66 && matches[0].score - matches[1].score >= 0.34) {
22940
22999
  resolved.push(matches[0].id);
22941
23000
  } else {
22942
- ambiguities.push({ sector_text: text2, matches });
23001
+ ambiguities.push({ sector_text: text3, matches });
22943
23002
  }
22944
23003
  }
22945
23004
  return { resolved, ambiguities };
@@ -24356,6 +24415,21 @@ var findNewLeads = {
24356
24415
  "channels",
24357
24416
  "exclude_lead_ids"
24358
24417
  ]);
24418
+ const homeCountry = detectCountryLocations(params.filters?.locations, "filters.locations", client.region).filter((hit) => hit.kind === "home_country");
24419
+ let countryNote;
24420
+ if (homeCountry.length > 0) {
24421
+ const dropped = new Set(homeCountry.map((hit) => hit.value));
24422
+ const raw = params.filters.locations;
24423
+ const rest = (Array.isArray(raw) ? raw : [raw]).filter((v) => !dropped.has(v));
24424
+ params = {
24425
+ ...params,
24426
+ filters: { ...params.filters, locations: rest.length > 0 ? rest : void 0 }
24427
+ };
24428
+ const removed = [...dropped].map((v) => `"${v}"`).join(", ");
24429
+ countryNote = {
24430
+ 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.`
24431
+ };
24432
+ }
24359
24433
  rejectCountryLocations(params.filters?.locations, client.region);
24360
24434
  rejectMalformedExclusions(params.exclude_lead_ids);
24361
24435
  rejectOversizedExclusions(params.exclude_lead_ids);
@@ -24433,6 +24507,7 @@ var findNewLeads = {
24433
24507
  return {
24434
24508
  dry_run: true,
24435
24509
  ...forecast,
24510
+ ...countryNote,
24436
24511
  region: client.region
24437
24512
  };
24438
24513
  }
@@ -24453,6 +24528,7 @@ var findNewLeads = {
24453
24528
  estimated_cost: forecast?.estimated_cost ?? null,
24454
24529
  items_requested: forecast?.items_requested ?? null,
24455
24530
  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.",
24531
+ ...countryNote,
24456
24532
  region: client.region
24457
24533
  };
24458
24534
  }
@@ -24507,11 +24583,142 @@ var findNewLeads = {
24507
24583
  since: snapshot.next_since ?? null,
24508
24584
  suggested_wait_seconds: done ? 0 : 60
24509
24585
  },
24586
+ ...countryNote,
24510
24587
  region: client.region
24511
24588
  };
24512
24589
  }
24513
24590
  };
24514
24591
 
24592
+ // ../core/dist/composite/_identity-rows.js
24593
+ var IDENTITY_ROWS_PER_RESULT = 100;
24594
+ function text(v) {
24595
+ return typeof v === "string" && v.trim() ? v.trim() : null;
24596
+ }
24597
+ function toRow(item) {
24598
+ const asked = item.ref?.requested_as ?? {};
24599
+ const row = {
24600
+ input_indexes: item.ref?.input_indexes ?? null,
24601
+ input: text(asked.name) ?? text(asked.website) ?? text(asked.lead_id) ?? text(asked.contact_id),
24602
+ status: item.status
24603
+ };
24604
+ if (item.status_reason)
24605
+ row.status_reason = item.status_reason;
24606
+ const lead = item.lead;
24607
+ if (!lead)
24608
+ return row;
24609
+ const company = lead.company ?? {};
24610
+ row.lead_id = lead.lead_id ?? item.ref?.lead_id ?? void 0;
24611
+ row.name = company.name;
24612
+ row.website = text(company.website);
24613
+ row.linkedin = text(company.socials?.linkedin);
24614
+ return row;
24615
+ }
24616
+ function identityRows(items) {
24617
+ const first = (i) => {
24618
+ const idx = i.ref?.input_indexes;
24619
+ return Array.isArray(idx) && idx.length > 0 ? Math.min(...idx) : Number.MAX_SAFE_INTEGER;
24620
+ };
24621
+ return [...items].sort((a, b) => first(a) - first(b) || a.seq - b.seq).map(toRow);
24622
+ }
24623
+ function identityCoverage(rows) {
24624
+ let resolved = 0;
24625
+ let ambiguous = 0;
24626
+ let notFound = 0;
24627
+ let otherSkipped = 0;
24628
+ let withWebsite = 0;
24629
+ let withLinkedin = 0;
24630
+ for (const r of rows) {
24631
+ if (r.status !== "skipped") {
24632
+ resolved += 1;
24633
+ if (r.website)
24634
+ withWebsite += 1;
24635
+ if (r.linkedin)
24636
+ withLinkedin += 1;
24637
+ } else if (r.status_reason === "low_confidence_identity")
24638
+ ambiguous += 1;
24639
+ else if (r.status_reason === "not_in_universe")
24640
+ notFound += 1;
24641
+ else
24642
+ otherSkipped += 1;
24643
+ }
24644
+ return {
24645
+ resolved,
24646
+ ambiguous,
24647
+ not_found: notFound,
24648
+ other_skipped: otherSkipped,
24649
+ with_website: withWebsite,
24650
+ with_linkedin: withLinkedin,
24651
+ without_website: resolved - withWebsite
24652
+ };
24653
+ }
24654
+ var CSV_HEADER = [
24655
+ "row",
24656
+ "input",
24657
+ "status",
24658
+ "reason",
24659
+ "company",
24660
+ "website",
24661
+ "linkedin",
24662
+ "lead_id"
24663
+ ];
24664
+ function identityCsv(rows) {
24665
+ return synthesizeCsv(CSV_HEADER, rows.map((r) => ({
24666
+ row: (r.input_indexes ?? []).map((i) => i + 1).join(";"),
24667
+ input: r.input ?? "",
24668
+ status: r.status,
24669
+ reason: r.status_reason ?? "",
24670
+ company: r.name ?? "",
24671
+ website: r.website ?? "",
24672
+ linkedin: r.linkedin ?? "",
24673
+ lead_id: r.lead_id ?? ""
24674
+ })));
24675
+ }
24676
+ async function saveIdentityFile(ctx, jobId, snapshot, items) {
24677
+ if (!ctx?.saveFile)
24678
+ return {};
24679
+ if (!TERMINAL_JOB_STATES.has(snapshot.job.state))
24680
+ return {};
24681
+ if (snapshot.items_truncated || items.length === 0)
24682
+ return {};
24683
+ try {
24684
+ const name = `leadbay-companies-${jobId.slice(0, 8)}.csv`;
24685
+ return { file: await ctx.saveFile(name, identityCsv(identityRows(items))) };
24686
+ } catch (e) {
24687
+ return { file_error: e instanceof Error ? e.message : String(e) };
24688
+ }
24689
+ }
24690
+ function readOffset(v) {
24691
+ const n = typeof v === "string" ? Number(v) : v;
24692
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
24693
+ }
24694
+ function identityAnswer(jobId, snapshot, items, offset) {
24695
+ const all = identityRows(items);
24696
+ const rows = all.slice(offset, offset + IDENTITY_ROWS_PER_RESULT);
24697
+ const end = offset + rows.length;
24698
+ const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
24699
+ const truncated = snapshot.items_truncated ?? false;
24700
+ const nextOffset = !done || truncated ? 0 : end < all.length ? end : null;
24701
+ return {
24702
+ summary: {
24703
+ ...identityCoverage(all),
24704
+ stop_reason: snapshot.funnel?.stop_reason ?? null
24705
+ },
24706
+ rows,
24707
+ rows_total: all.length,
24708
+ rows_offset: offset,
24709
+ items_truncated: truncated,
24710
+ scope_notes: snapshot.explain?.scope_notes ?? [],
24711
+ still_running: !done,
24712
+ next_poll: nextOffset === null ? null : {
24713
+ tool: "leadbay_lead_job_status",
24714
+ job_id: jobId,
24715
+ compact: true,
24716
+ offset: nextOffset,
24717
+ suggested_wait_seconds: done ? 0 : 60
24718
+ }
24719
+ };
24720
+ }
24721
+
24515
24722
  // ../core/dist/composite/qualify-leads.js
24516
24723
  var DEFAULT_WAIT_SECONDS2 = 45;
24517
24724
  function normalizeLeadRefs(refs) {
@@ -24560,7 +24767,7 @@ function rejectMalformedLeadRefs(refs) {
24560
24767
  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
24768
  };
24562
24769
  }
24563
- function text(value) {
24770
+ function text2(value) {
24564
24771
  if (typeof value !== "string")
24565
24772
  return null;
24566
24773
  const v = value.trim().toLowerCase();
@@ -24568,7 +24775,7 @@ function text(value) {
24568
24775
  }
24569
24776
  function derivedRequestId(params) {
24570
24777
  const refs = canonicalSet((params.lead_refs ?? []).map((r) => {
24571
- const website = text(r.website);
24778
+ const website = text2(r.website);
24572
24779
  return [
24573
24780
  // UUIDs are case-insensitive to the backend, so an uppercase id and
24574
24781
  // its lowercase form are the same lead and must share a key.
@@ -24579,8 +24786,8 @@ function derivedRequestId(params) {
24579
24786
  // AND to one key. Fall back to the trimmed/lowercased raw value when
24580
24787
  // it is not domain-shaped, rather than dropping the field.
24581
24788
  website ? normalizeDomain(website) ?? website : null,
24582
- text(r.name),
24583
- text(r.location)
24789
+ text2(r.name),
24790
+ text2(r.location)
24584
24791
  ];
24585
24792
  }));
24586
24793
  const shape = {
@@ -24726,6 +24933,7 @@ var qualifyLeads = {
24726
24933
  const buysChannels = (params.channels?.length ?? 0) > 0;
24727
24934
  const buysQualification = qualify !== false;
24728
24935
  const isPaid = buysQualification || buysChannels;
24936
+ const identityOnly = !isPaid && (params.contact_titles?.length ?? 0) === 0 && params.prior_deliveries == null;
24729
24937
  const vetoed = confirm === false;
24730
24938
  const consented = !vetoed && confirm === true;
24731
24939
  const requestId = presentRequestId(params.request_id) ?? (isPaid ? derivedRequestId(params) : void 0);
@@ -24774,6 +24982,25 @@ var qualifyLeads = {
24774
24982
  const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
24775
24983
  const indexed = submit.duplicate ?? false ? remapInputIndexes(snapshot.items, params.lead_refs) : { items: snapshot.items, remapped: true };
24776
24984
  const view = { ...snapshot, items: indexed.items };
24985
+ if (identityOnly) {
24986
+ const answer = identityAnswer(submit.job_id, snapshot, view.items, 0);
24987
+ return {
24988
+ job_id: submit.job_id,
24989
+ request_id: requestId ?? null,
24990
+ duplicate_submit: submit.duplicate ?? false,
24991
+ state: snapshot.job.state,
24992
+ done,
24993
+ ...answer,
24994
+ summary: {
24995
+ refs_submitted: params.lead_refs?.length ?? 0,
24996
+ items_requested: submit.items_requested,
24997
+ ...answer.summary
24998
+ },
24999
+ input_indexes_remapped: submit.duplicate ?? false ? indexed.remapped : null,
25000
+ ...await saveIdentityFile(ctx, submit.job_id, snapshot, view.items),
25001
+ region: client.region
25002
+ };
25003
+ }
24777
25004
  return {
24778
25005
  job_id: submit.job_id,
24779
25006
  // Echo the key actually sent, so a retry can reuse it verbatim.
@@ -24863,6 +25090,14 @@ var leadJobStatus = {
24863
25090
  wait_seconds: {
24864
25091
  type: "number",
24865
25092
  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."
25093
+ },
25094
+ compact: {
25095
+ type: "boolean",
25096
+ 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."
25097
+ },
25098
+ offset: {
25099
+ type: "number",
25100
+ 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
25101
  }
24867
25102
  },
24868
25103
  required: ["job_id"],
@@ -24870,8 +25105,19 @@ var leadJobStatus = {
24870
25105
  },
24871
25106
  execute: async (client, params, ctx) => {
24872
25107
  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);
25108
+ const compact = params.compact === true || params.compact === "true";
25109
+ const since = compact ? void 0 : params.since;
25110
+ 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
25111
  const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
25112
+ if (compact) {
25113
+ return {
25114
+ job_id: params.job_id,
25115
+ state: snapshot.job.state,
25116
+ done,
25117
+ ...identityAnswer(params.job_id, snapshot, snapshot.items, readOffset(params.offset)),
25118
+ region: client.region
25119
+ };
25120
+ }
24875
25121
  const { leads, skipped } = splitItems(snapshot);
24876
25122
  return {
24877
25123
  job_id: params.job_id,
@@ -26251,8 +26497,8 @@ var sendFeedback = {
26251
26497
  }
26252
26498
  },
26253
26499
  execute: async (client, params, ctx) => {
26254
- const text2 = typeof params.message === "string" ? params.message.trim() : "";
26255
- if (!text2) {
26500
+ const text3 = typeof params.message === "string" ? params.message.trim() : "";
26501
+ if (!text3) {
26256
26502
  return {
26257
26503
  error: true,
26258
26504
  code: "BAD_INPUT",
@@ -26260,7 +26506,7 @@ var sendFeedback = {
26260
26506
  hint: "Ask the user what they'd like to tell the Leadbay team, then call again with their words in `message`."
26261
26507
  };
26262
26508
  }
26263
- const message = text2.length > MESSAGE_MAX2 ? `${text2.slice(0, MESSAGE_MAX2 - 1)}\u2026` : text2;
26509
+ const message = text3.length > MESSAGE_MAX2 ? `${text3.slice(0, MESSAGE_MAX2 - 1)}\u2026` : text3;
26264
26510
  if (!ctx?.sendFeedback) {
26265
26511
  return {
26266
26512
  sent: false,
@@ -27951,6 +28197,7 @@ ${url}
27951
28197
  // RAW value: the friction redaction above is an ANALYTICS control, and
27952
28198
  // must not change in-process tool behaviour.
27953
28199
  triggered_by: rawTriggeredBy,
28200
+ saveFile: opts.saveFile,
27954
28201
  // Route leadbay_send_feedback to Sentry's feedback inbox (same place
27955
28202
  // the web app's form lands). NOOP_TELEMETRY returns false, so the
27956
28203
  // tool reports honestly when telemetry is off.
@@ -28372,7 +28619,7 @@ function parseWriteEnv(env = process.env) {
28372
28619
  }
28373
28620
 
28374
28621
  // src/http-server.ts
28375
- var VERSION = true ? "0.38.2" : "0.0.0-dev";
28622
+ var VERSION = true ? "0.39.1" : "0.0.0-dev";
28376
28623
  var PORT = Number(process.env.PORT ?? 8080);
28377
28624
  var HOST = process.env.HOST ?? "0.0.0.0";
28378
28625
  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.1" : "0.0.0-dev";
1809
1809
  MESSAGES = {
1810
1810
  en: {
1811
1811
  installer: {