@leadbay/mcp 0.38.1 → 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.
- package/CHANGELOG.md +48 -0
- package/dist/bin.js +579 -149
- package/dist/http-server.js +476 -80
- package/dist/installer-electron.js +1 -1
- package/dist/installer-gui.js +1 -1
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -1369,12 +1369,12 @@ var init_ws_client = __esm({
|
|
|
1369
1369
|
this.pingTimer = setInterval(() => this.sendPing(), PING_INTERVAL_MS);
|
|
1370
1370
|
});
|
|
1371
1371
|
ws.addEventListener("message", (ev) => {
|
|
1372
|
-
const
|
|
1373
|
-
if (!
|
|
1372
|
+
const text3 = typeof ev.data === "string" ? ev.data : String(ev.data ?? "");
|
|
1373
|
+
if (!text3)
|
|
1374
1374
|
return;
|
|
1375
1375
|
let msg;
|
|
1376
1376
|
try {
|
|
1377
|
-
msg = JSON.parse(
|
|
1377
|
+
msg = JSON.parse(text3);
|
|
1378
1378
|
} catch {
|
|
1379
1379
|
this.logger?.warn?.("notifications.ws non_json_frame");
|
|
1380
1380
|
return;
|
|
@@ -1621,9 +1621,9 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
1621
1621
|
|
|
1622
1622
|
## RENDER (quick)
|
|
1623
1623
|
|
|
1624
|
-
Report user + org
|
|
1625
|
-
|
|
1626
|
-
\`last_requested_lens_name\`, never the id). SILENT on quota
|
|
1624
|
+
Report user + org; show quota only when asked (quota / account status) or a
|
|
1625
|
+
window is exhausted. NEVER mention the lens unless asked (use
|
|
1626
|
+
\`last_requested_lens_name\`, never the id). SILENT on quota when
|
|
1627
1627
|
\`quota_error\` set, \`unlimited_credits\` true, or quota null. Else render
|
|
1628
1628
|
Daily/Weekly/Monthly from \`quota.user\` (fall back to \`quota.org\` if \`user\`
|
|
1629
1629
|
absent) as \`$used / $cap (N% used) \xB7 resets\` (or a resource-count table when
|
|
@@ -1631,7 +1631,7 @@ absent) as \`$used / $cap (N% used) \xB7 resets\` (or a resource-count table whe
|
|
|
1631
1631
|
|
|
1632
1632
|
---
|
|
1633
1633
|
|
|
1634
|
-
Show the user's account state \u2014 admin rights, language, last-active lens, quota usage across daily/weekly/monthly windows, and whether the org's intelligence is mid-regeneration. **
|
|
1634
|
+
Show the user's account state \u2014 admin rights, language, last-active lens, quota usage across daily/weekly/monthly windows, and whether the org's intelligence is mid-regeneration. **When you show quota, show it the way the web app does \u2014 a percentage-used + dollar-spend gauge per window, never raw "credits".** Each window in \`quota.<group>.spend[]\` carries \`current_units\` / \`max_units\` in dollar_cents (% used = the ratio, $ = \`/100\`); the \`quota.<group>.resources[]\` list gives the per-resource usage breakdown (\`count\`, plus \`max_units\` when a per-resource cap exists). **Pre-check the \`LENS_EXTRA_REFILL\` resource here before calling \`leadbay_extend_lens\`** \u2014 look in **\`quota.org.resources[]\`** first (admins), and fall back to **\`quota.user.resources[]\`** when \`quota.org\` is absent (non-admin callers only get the \`user\` group), matching the resource type **case-insensitively** (it may arrive as \`LENS_EXTRA_REFILL\` or \`lens_extra_refill\`). Its full requested batch must fit into the remaining daily quota or the call is rejected outright. 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.
|
|
1635
1635
|
|
|
1636
1636
|
**Top-ups always beat waiting.** When a quota window is hit, the user has two options: wait for the window reset (\`resets_at\` in each quota entry) OR top up AI credits. Top-ups clear the throttle IMMEDIATELY; they are not subject to the same window. When you tell the user about a 429 / quota exhaustion, ALWAYS surface both options \u2014 "wait until <reset>" or "top up now (I can generate the link)" \u2014 and let them pick. Never default-recommend "wait until tomorrow" when a 30-second top-up unblocks the same operation.
|
|
1637
1637
|
|
|
@@ -1687,10 +1687,10 @@ Mirror the Leadbay web quota widget: three windows side by side \u2014 **Daily**
|
|
|
1687
1687
|
$ cap** figure, with a per-resource usage breakdown underneath. **Never speak in
|
|
1688
1688
|
raw "credits"** for quota \u2014 the unit is a percentage and a dollar spend.
|
|
1689
1689
|
|
|
1690
|
-
**
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1690
|
+
**Show the quota only when it matters** \u2014 when the user asks about their quota,
|
|
1691
|
+
usage or account status, or when a window is exhausted and blocks what they
|
|
1692
|
+
asked for. A plain "what account am I connected to?" is answered with user +
|
|
1693
|
+
org alone. Even then, the silence gate below comes first.
|
|
1694
1694
|
|
|
1695
1695
|
**Silence gate (check FIRST).** Render NOTHING about quota when any of these
|
|
1696
1696
|
holds \u2014 do not mention quota at all, do not say "unreadable", never tell the user
|
|
@@ -1758,7 +1758,7 @@ Keep it secondary \u2014 the three window gauges are the headline. Omit when nul
|
|
|
1758
1758
|
|
|
1759
1759
|
---
|
|
1760
1760
|
|
|
1761
|
-
WHEN TO USE:
|
|
1761
|
+
WHEN TO USE: when the user asks about their account, quota or version, after a 429 to explain to the user which resource window was exhausted and when it resets (and to offer the top-up alternative), and after the user signals a top-up so the agent can resume the interrupted workflow.
|
|
1762
1762
|
|
|
1763
1763
|
WHEN NOT TO USE: as a pre-flight gate before bulk ops \u2014 operations themselves return 429; this tool is for context, not gating. And: a recent quota snapshot showing "exhausted" is NOT a reason to refuse a write call when the user has just topped up \u2014 re-call this tool first, then proceed.
|
|
1764
1764
|
`;
|
|
@@ -2020,13 +2020,13 @@ a launcher only for a subset that never started, never for the whole batch:
|
|
|
2020
2020
|
rather than guess.
|
|
2021
2021
|
|
|
2022
2022
|
|
|
2023
|
-
## QUOTA
|
|
2023
|
+
## QUOTA, NOT CREDITS
|
|
2024
2024
|
|
|
2025
|
-
Enrichment consumes QUOTA (the per-window allowance), not a separate credit wall.
|
|
2025
|
+
Enrichment consumes QUOTA (the per-window allowance), not a separate credit wall. The result's \`credits_remaining\` field is **advisory internal context only \u2014 do NOT display it**: it comes from \`billing.ai_credits\` (a consumed counter, not remaining), so printing \`_(N credits remaining)_\` can show a fresh/quota-backed account a false "0 remaining." Never render a credits balance; the \`leadbay_account_status\` quota gauge is the only place the user's standing is shown. Do NOT report a "credits used" figure for this run either: the per-contact cost can't be scoped to this specific enrichment (a lead's contact list mixes in earlier runs), so any "X used" number would be misleading.
|
|
2026
2026
|
|
|
2027
2027
|
## COMPLETION REPORT \u2014 what to tell the user when the job is done
|
|
2028
2028
|
|
|
2029
|
-
The result always carries \`overall_progress:{done,total,done_ratio}\` and, with \`include_contacts:true\`, \`leads[]\` each with contacts' \`email\` / \`phone_number\` / \`job_title\` / \`enrichment.done\`. \`bulk_progress:{total_count,success_count,failure_count,quota_hit_count}\` is present only when you passed a \`notification_id\` AND the job was found; derive counts from \`overall_progress\` rather than assuming \`bulk_progress\` is there. With \`lead_ids\`, each entry carries \`enrichment_progress:{done,total}\` \u2014 \`total\` counts the reservations this run made (scoped to the \`titles\` it enriched, and to reservations whose \`enrichment.email_requested\` / \`phone_requested\` match the \`email\` / \`phone\` you pass, so a lead's pre-existing CFO email cannot inflate a CEO run) and \`done\` counts those that have settled (\`enrichment.done:true\`, found or not). The \`enrichment\` record sits on the \`source:"paid"\` entry and never carries \`email\` / \`phone_number\`: the revealed values land on the same person's \`source:"org"\` entry (match on name and \`job_title\`). Read what landed from the org entries \u2014 an org entry with \`email\` = email found, with \`phone_number\` = phone found \u2014 and a settled paid entry with \`credits_used:0\` = nothing found for that person. \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), so it can include contacts of other roles that were enriched in earlier runs \u2014 filter your report to the \`titles\` this bulk enriched (match each contact's \`job_title\`), don't attribute a pre-existing email of an unrelated role to this run. Report it yourself in the SAME turn, without a reprompt and without deferring to a scheduled re-check: name which of the just-enriched contacts now have emails / phones, the done/total counts, and \u2014 if \`bulk_progress\` is present \u2014 any \`quota_hit_count\` (if non-zero, say some contacts were skipped because the quota window was exhausted, and point to \`leadbay_account_status\` for the wait-or-top-up choice). If you stopped on a plateau (not \`all_done\`), say so plainly \u2014 report the resolved contacts and name the ones that didn't resolve, keyed to the requested channel and the returned fields (no \`email\` \u2192 "no email found"; no \`phone_number\` \u2192 "no phone number found") \u2014 rather than implying the job fully finished.
|
|
2029
|
+
The result always carries \`overall_progress:{done,total,done_ratio}\` and, with \`include_contacts:true\`, \`leads[]\` each with contacts' \`email\` / \`phone_number\` / \`job_title\` / \`enrichment.done\`. \`bulk_progress:{total_count,success_count,failure_count,quota_hit_count}\` is present only when you passed a \`notification_id\` AND the job was found; derive counts from \`overall_progress\` rather than assuming \`bulk_progress\` is there. With \`lead_ids\`, each entry carries \`enrichment_progress:{done,total}\` \u2014 \`total\` counts the reservations this run made (scoped to the \`titles\` it enriched, and to reservations whose \`enrichment.email_requested\` / \`phone_requested\` match the \`email\` / \`phone\` you pass, so a lead's pre-existing CFO email cannot inflate a CEO run) and \`done\` counts those that have settled (\`enrichment.done:true\`, found or not). The \`enrichment\` record sits on the \`source:"paid"\` entry and never carries \`email\` / \`phone_number\`: the revealed values land on the same person's \`source:"org"\` entry (match on name and \`job_title\`). Read what landed from the org entries \u2014 an org entry with \`email\` = email found, with \`phone_number\` = phone found \u2014 and a settled paid entry with \`credits_used:0\` = nothing found for that person. \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), so it can include contacts of other roles that were enriched in earlier runs \u2014 filter your report to the \`titles\` this bulk enriched (match each contact's \`job_title\`), don't attribute a pre-existing email of an unrelated role to this run. Report it yourself in the SAME turn, without a reprompt and without deferring to a scheduled re-check: name which of the just-enriched contacts now have emails / phones, the done/total counts, and \u2014 if \`bulk_progress\` is present \u2014 any \`quota_hit_count\` (if non-zero, say some contacts were skipped because the quota window was exhausted, and point to \`leadbay_account_status\` for the wait-or-top-up choice). If you stopped on a plateau (not \`all_done\`), say so plainly \u2014 report the resolved contacts and name the ones that didn't resolve, keyed to the requested channel and the returned fields (no \`email\` \u2192 "no email found"; no \`phone_number\` \u2192 "no phone number found") \u2014 rather than implying the job fully finished. Do NOT print a credits-remaining line.
|
|
2030
2030
|
`;
|
|
2031
2031
|
leadbay_bulk_qualify_leads = `Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch). Pass \`wait_for_completion:false\` to return quickly with \`{status:'running', notification_id}\`; poll leadbay_qualify_status with that id. With \`wait_for_completion\` omitted/true, the legacy behavior polls 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.
|
|
2032
2032
|
|
|
@@ -2571,7 +2571,7 @@ run before running it again.
|
|
|
2571
2571
|
|
|
2572
2572
|
## QUOTA, NOT CREDITS
|
|
2573
2573
|
|
|
2574
|
-
Enrichment is gated by QUOTA (the per-window allowance in \`leadbay_account_status\`), not a credit balance. **Never pre-refuse because a credit number looks low or zero** \u2014 a freemium/fresh account with quota left can enrich even when its credit counter reads 0. The reveal either fits the remaining quota or the backend returns 429 (\`quota_exceeded\`); only THEN surface the exhausted window + wait-or-top-up choice. The \`credits_remaining\` field on the result is **advisory internal context only \u2014 do NOT display it**. Because it can read \`0\` on an account that still has quota, printing \`_(N credits remaining)_\` would falsely tell the user they're out. Do not render a credits balance at all; if the user asks where they stand, call \`leadbay_account_status\` and show the quota gauge instead.
|
|
2574
|
+
Enrichment is gated by QUOTA (the per-window allowance in \`leadbay_account_status\`), not a credit balance. **Never pre-refuse because a credit number looks low or zero** \u2014 a freemium/fresh account with quota left can enrich even when its credit counter reads 0. The reveal either fits the remaining quota or the backend returns 429 (\`quota_exceeded\`); only THEN surface the exhausted window + wait-or-top-up choice. The \`credits_remaining\` field on the result is **advisory internal context only \u2014 do NOT display it**. Because it can read \`0\` on an account that still has quota, printing \`_(N credits remaining)_\` would falsely tell the user they're out. Do not render a credits balance at all; if the user asks where they stand, call \`leadbay_account_status\` and show the quota gauge instead.
|
|
2575
2575
|
|
|
2576
2576
|
**Channels: when the user asks to enrich a contact without naming a channel, confirm scope via \`ask_user_input_v0\`** \u2014 \`"Enrich email only, or email + phone? (phone uses more quota)"\` \u2192 \`["Email only", "Email + phone"]\` \u2014 then pass the chosen \`email\`/\`phone\` flags. Skip the question only if they already said which channel(s) they want.
|
|
2577
2577
|
|
|
@@ -2627,15 +2627,15 @@ The \`email\` channel defaults **ON**; \`phone\` defaults **OFF**. A bare "enric
|
|
|
2627
2627
|
|
|
2628
2628
|
Do NOT rely on a bare call (no \`confirm\`, no \`dry_run\`, no channels) as a "safe preview": on an elicitation-capable host it asks the user and withholds on decline (\`mode:"needs_confirmation"\`), but on a host WITHOUT elicitation (some direct/embedded callers) a bare call launches the default email reveal directly. If you're unsure whether the host can elicit, use \`dry_run:true\`/\`confirm:false\` for the preview. Passing \`email:true\`/\`phone:true\` (or \`confirm:true\`) always counts as consent and launches.
|
|
2629
2629
|
|
|
2630
|
-
## SHOW WHAT WILL RUN
|
|
2630
|
+
## SHOW WHAT WILL RUN
|
|
2631
2631
|
|
|
2632
2632
|
**BEFORE (confirm before launching).** The discover / preview_only / dry_run modes return \`enrichable_contacts\` (the volume that would be enriched). Tell the user plainly: **"This will enrich {enrichable_contacts} contacts (email + phone reveals consume quota)."** then confirm the channels + go-ahead via \`ask_user_input_v0\` before launching. Do NOT quote an exact cost or a "credits" figure \u2014 the per-reveal rate is backend-side and enrichment is gated by quota, not a credit balance. The \`credits_remaining\` field is advisory context only; never present it as a gate and never refuse based on it.
|
|
2633
2633
|
|
|
2634
|
-
**AFTER
|
|
2634
|
+
**AFTER.** Do NOT invent a "credits used" figure for the run (per-run cost can't be scoped reliably \u2014 a lead's contacts mix earlier enrichments).
|
|
2635
2635
|
|
|
2636
2636
|
## AFTER LAUNCH \u2014 STAY ACTIVE UNTIL DONE
|
|
2637
2637
|
|
|
2638
|
-
When a launch returns \`mode:"launched"\` with a \`notification_id\`, the enrichment runs ASYNC on the backend \u2014 the tool returns immediately, before any email/phone is attached. **Unless the user explicitly said to start it in the background / not to wait** (e.g. "kick it off, I'll check later", "don't wait for it"), stay active and report in-turn \u2014 do NOT end your turn on the ack, and do NOT say "I'll let you know when it's done." (If the user DID ask you not to wait, honor that: hand back the \`notification_id\` and a one-line "running \u2014 you can ask any time". Only promise that completion will auto-surface via \`_meta.notifications\` when the launch returned a non-null \`notification_id\`; if \`notification_id\` is null (the nullable-backend path), say instead that you'll re-check when asked / they should ask again later \u2014 nothing surfaces automatically without a notification id. Don't force a poll loop against explicit intent.) In the default (stay-active) case: call \`leadbay_bulk_enrich_status({notification_id})\` in a loop, re-polling until the job is done (small batches typically finish in under ~2 min). Pass \`include_contacts:true\` on the read you intend to report from, so you get each lead's enriched contacts back. Note that \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), which can include contacts of OTHER roles that were already enriched in earlier runs \u2014 so **filter your report to the \`titles\` you just enriched** (match each contact's \`job_title\` to the requested titles). Don't present a pre-existing CFO/Sales email as part of this CEO/Owner/Manager run. Then \u2014 on your own, without waiting for the user to reprompt \u2014 report the enrichment: which of the just-enriched contacts now have emails / phones, and the counts from \`overall_progress\` (\`done\`/\`total\`). \`leadbay_bulk_enrich_status\` also returns \`bulk_progress.success_count\` / \`failure_count\` / \`quota_hit_count\` on the notification fast path \u2014 use those when present, but the per-lead path returns \`overall_progress\` only, so don't assume \`bulk_progress\` exists (see the status tool's COMPLETION REPORT).
|
|
2638
|
+
When a launch returns \`mode:"launched"\` with a \`notification_id\`, the enrichment runs ASYNC on the backend \u2014 the tool returns immediately, before any email/phone is attached. **Unless the user explicitly said to start it in the background / not to wait** (e.g. "kick it off, I'll check later", "don't wait for it"), stay active and report in-turn \u2014 do NOT end your turn on the ack, and do NOT say "I'll let you know when it's done." (If the user DID ask you not to wait, honor that: hand back the \`notification_id\` and a one-line "running \u2014 you can ask any time". Only promise that completion will auto-surface via \`_meta.notifications\` when the launch returned a non-null \`notification_id\`; if \`notification_id\` is null (the nullable-backend path), say instead that you'll re-check when asked / they should ask again later \u2014 nothing surfaces automatically without a notification id. Don't force a poll loop against explicit intent.) In the default (stay-active) case: call \`leadbay_bulk_enrich_status({notification_id})\` in a loop, re-polling until the job is done (small batches typically finish in under ~2 min). Pass \`include_contacts:true\` on the read you intend to report from, so you get each lead's enriched contacts back. Note that \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), which can include contacts of OTHER roles that were already enriched in earlier runs \u2014 so **filter your report to the \`titles\` you just enriched** (match each contact's \`job_title\` to the requested titles). Don't present a pre-existing CFO/Sales email as part of this CEO/Owner/Manager run. Then \u2014 on your own, without waiting for the user to reprompt \u2014 report the enrichment: which of the just-enriched contacts now have emails / phones, and the counts from \`overall_progress\` (\`done\`/\`total\`). \`leadbay_bulk_enrich_status\` also returns \`bulk_progress.success_count\` / \`failure_count\` / \`quota_hit_count\` on the notification fast path \u2014 use those when present, but the per-lead path returns \`overall_progress\` only, so don't assume \`bulk_progress\` exists (see the status tool's COMPLETION REPORT).
|
|
2639
2639
|
|
|
2640
2640
|
**"Done" = \`all_done:true\` OR the resolvable work has plateaued.** Keep polling while \`overall_progress.done\` is still climbing. But a reservation the provider never answers stays \`done:false\` \u2014 so a job can sit below 100% with \`all_done:false\` forever (a contact with no findable email does flip, to \`done:true\` with \`credits_used:0\`). A plateau is only real once the job has had time to run: do NOT declare it from the first few back-to-back reads (early on \`done\` can sit at its initial value while the backend is still spinning the job up). Give it at least ~90s\u20132 min of actual elapsed polling \u2014 space your polls out (~15\u201330s apart) rather than firing them back-to-back \u2014 and only treat the set as complete when \`overall_progress.done\` has held steady across several spaced polls over that window. Then stop polling and report what resolved, naming the ones that didn't. Key the "didn't resolve" wording off the channels the user actually requested and the returned contact fields (contacts carry \`email\` and \`phone_number\`) \u2014 a contact enriched for phone that came back with no \`phone_number\` is "no phone number found", one with no \`email\` is "no email found", email+phone that got neither is "no contact details found"; if \`quota_hit_count\` is non-zero say those were skipped because the quota window was exhausted. Do NOT hard-label every non-success as "no email found" when phone was requested. Do NOT spin indefinitely waiting for \`all_done\` on contacts the engine won't resolve, and do NOT \`ScheduleWakeup\` / defer the finished list to a later turn \u2014 deliver the resolved results in THIS reply.
|
|
2641
2641
|
|
|
@@ -2843,7 +2843,7 @@ measured:
|
|
|
2843
2843
|
\`employees_max: number\`. FLAT numbers \u2014 nested \`employees: {min, max}\`
|
|
2844
2844
|
exists only in RESULT payloads. \`example_lead.employees\` does not filter.
|
|
2845
2845
|
\`locations\` take city/state/region names ("Dallas, TX", "\xCEle-de-France");
|
|
2846
|
-
a country
|
|
2846
|
+
never a country: this workspace's own is dropped, any other is refused.
|
|
2847
2847
|
7. **Prefer \`example_lead\` over \`query\`.** Query matches topic *vocabulary*:
|
|
2848
2848
|
"gyms that need durable flooring" surfaced flooring VENDORS, 0 delivered.
|
|
2849
2849
|
Use \`query\` only for signal an example can't express.
|
|
@@ -3913,8 +3913,31 @@ server-side: past the 30-min wall clock a job reads \`completed_partial\`
|
|
|
3913
3913
|
delivered leads via leadbay_qualify_leads \`prior_deliveries\`). A 404 means
|
|
3914
3914
|
unknown job or another org's job.
|
|
3915
3915
|
|
|
3916
|
+
\`compact: true\` (pass it whenever \`next_poll\` carries it) returns the rows of
|
|
3917
|
+
a free identity pass, 100 at a time from \`offset\`, instead of leads.
|
|
3918
|
+
|
|
3916
3919
|
---
|
|
3917
3920
|
|
|
3921
|
+
## RENDERING \u2014 identity pass (\`rows[]\`)
|
|
3922
|
+
|
|
3923
|
+
\`rows[]\` replaces \`leads[]\`: one row per company, in the user's order. One
|
|
3924
|
+
table: **Company** (\`input\`, plus \`name\` when it differs) \xB7 **Website** \xB7
|
|
3925
|
+
**LinkedIn**. Skipped rows stay in it: \`not_in_universe\` \u2192 "not in Leadbay",
|
|
3926
|
+
\`low_confidence_identity\` \u2192 "several matches \u2014 add a city or website".
|
|
3927
|
+
|
|
3928
|
+
Then ONE coverage line from \`summary\`: found \`resolved\` of \`rows_total\` \xB7
|
|
3929
|
+
\`with_website\` with a website \xB7 \`with_linkedin\` with a LinkedIn \xB7
|
|
3930
|
+
\`ambiguous\` unclear \xB7 \`not_found\` not found. When most rows lack what the
|
|
3931
|
+
user asked for, that line is the answer: say so, and do not look the
|
|
3932
|
+
missing ones up one by one.
|
|
3933
|
+
|
|
3934
|
+
Done job with \`next_poll.offset\`: say "showing N of \`rows_total\`" and offer
|
|
3935
|
+
the rest via \`leadbay_lead_job_status(job_id, compact: true, offset)\`.
|
|
3936
|
+
|
|
3937
|
+
\`file\` set (local install only): every row is saved there as a CSV. Give the
|
|
3938
|
+
user that path.
|
|
3939
|
+
|
|
3940
|
+
|
|
3918
3941
|
## RENDERING \u2014 delivery table + honest funnel line
|
|
3919
3942
|
|
|
3920
3943
|
Render delivered leads (\`leads[]\`, i.e. items with status \`delivered\` or
|
|
@@ -5157,13 +5180,14 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
|
|
|
5157
5180
|
`;
|
|
5158
5181
|
leadbay_qualify_leads = `## WHEN TO USE
|
|
5159
5182
|
|
|
5160
|
-
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".
|
|
5183
|
+
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".
|
|
5161
5184
|
|
|
5162
5185
|
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\`.
|
|
5163
5186
|
|
|
5164
5187
|
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.
|
|
5165
5188
|
|
|
5166
5189
|
Examples that SHOULD invoke this tool:
|
|
5190
|
+
- "For each of these 300 brokers, give me the website and LinkedIn, nothing else."
|
|
5167
5191
|
- "Here are 60 restaurant websites from my Austin sweep \u2014 which fit, and who's the owner?"
|
|
5168
5192
|
- "Re-qualify last week's delivery and get phone numbers for the good ones."
|
|
5169
5193
|
- "Vet these 12 accounts from my spreadsheet against our criteria."
|
|
@@ -5175,7 +5199,8 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
5175
5199
|
|
|
5176
5200
|
## RENDER (quick)
|
|
5177
5201
|
|
|
5178
|
-
|
|
5202
|
+
Identity pass (\`rows[]\`): one table Company / Website / LinkedIn, then the
|
|
5203
|
+
coverage line. Otherwise a 3-col table for delivered items (fit bar + company / why-fits \u226420 words /
|
|
5179
5204
|
contact + channels) in returned order, then a compact Ref \u2192 Outcome table
|
|
5180
5205
|
for skipped refs (not_in_universe, low_confidence_identity, ... in plain
|
|
5181
5206
|
words), then the honest funnel line. Full algorithm below.
|
|
@@ -5190,6 +5215,14 @@ when asked \u2014 enriched with verified channels. Answers arrive per-item from
|
|
|
5190
5215
|
job; this tool polls up to \`wait_seconds\` (default 45) and hands off to
|
|
5191
5216
|
\`leadbay_lead_job_status\` when the batch needs longer.
|
|
5192
5217
|
|
|
5218
|
+
**A list of companies is ONE call, never a loop** of
|
|
5219
|
+
\`leadbay_research_lead_by_name_fuzzy\`: "for each of these companies give me
|
|
5220
|
+
X", a pasted list, a file. Identity only (in Leadbay or not, website,
|
|
5221
|
+
LinkedIn) \u2192 \`qualify: false\`: free, no \`confirm\`, returns \`rows[]\` and
|
|
5222
|
+
coverage counts. A fit question over the list ("which of these do
|
|
5223
|
+
factoring") keeps \`qualify: true\` and its quote. Over 500: one call per 500,
|
|
5224
|
+
each with its own \`request_id\`.
|
|
5225
|
+
|
|
5193
5226
|
**Refs are flexible; outcomes are per-item.** \`lead_refs\` accepts any mix of
|
|
5194
5227
|
\`lead_id\`, \`website\`, \`name\`(+\`location\`), or a stable \`contact_id\` from a
|
|
5195
5228
|
prior result (enrichment then targets exactly that person, never a re-match).
|
|
@@ -5226,6 +5259,26 @@ Retry-After beyond \u2014 wait, don't hammer), 30-min job wall clock.
|
|
|
5226
5259
|
|
|
5227
5260
|
---
|
|
5228
5261
|
|
|
5262
|
+
## RENDERING \u2014 identity pass (\`rows[]\`)
|
|
5263
|
+
|
|
5264
|
+
\`rows[]\` replaces \`leads[]\`: one row per company, in the user's order. One
|
|
5265
|
+
table: **Company** (\`input\`, plus \`name\` when it differs) \xB7 **Website** \xB7
|
|
5266
|
+
**LinkedIn**. Skipped rows stay in it: \`not_in_universe\` \u2192 "not in Leadbay",
|
|
5267
|
+
\`low_confidence_identity\` \u2192 "several matches \u2014 add a city or website".
|
|
5268
|
+
|
|
5269
|
+
Then ONE coverage line from \`summary\`: found \`resolved\` of \`rows_total\` \xB7
|
|
5270
|
+
\`with_website\` with a website \xB7 \`with_linkedin\` with a LinkedIn \xB7
|
|
5271
|
+
\`ambiguous\` unclear \xB7 \`not_found\` not found. When most rows lack what the
|
|
5272
|
+
user asked for, that line is the answer: say so, and do not look the
|
|
5273
|
+
missing ones up one by one.
|
|
5274
|
+
|
|
5275
|
+
Done job with \`next_poll.offset\`: say "showing N of \`rows_total\`" and offer
|
|
5276
|
+
the rest via \`leadbay_lead_job_status(job_id, compact: true, offset)\`.
|
|
5277
|
+
|
|
5278
|
+
\`file\` set (local install only): every row is saved there as a CSV. Give the
|
|
5279
|
+
user that path.
|
|
5280
|
+
|
|
5281
|
+
|
|
5229
5282
|
## RENDERING \u2014 delivery table + honest funnel line
|
|
5230
5283
|
|
|
5231
5284
|
Render delivered leads (\`leads[]\`, i.e. items with status \`delivered\` or
|
|
@@ -5380,6 +5433,8 @@ Pick the 2-3 options that match what actually happened:
|
|
|
5380
5433
|
| Items skipped \`low_confidence_identity\` | "Pick the right match" (show \`resolution.alternatives\`) | leadbay_qualify_leads with the chosen lead_id |
|
|
5381
5434
|
| 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) |
|
|
5382
5435
|
| Disqualified with evidence | "Review why \u2014 adjust qualification questions if the criteria are off" | leadbay_get_qualification_questions |
|
|
5436
|
+
| Identity pass, list over 500 | "Run the next 500" | leadbay_qualify_leads(next 500, qualify: false) |
|
|
5437
|
+
| 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 |
|
|
5383
5438
|
`;
|
|
5384
5439
|
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:
|
|
5385
5440
|
|
|
@@ -5858,9 +5913,9 @@ out?"\`
|
|
|
5858
5913
|
|
|
5859
5914
|
Trigger phrases: "look up <Company>", "research <Company>", "what do we know about <Company>".
|
|
5860
5915
|
|
|
5861
|
-
Do NOT use for: "picked row with leadId" \u2192 \`leadbay_research_lead_by_id\`; "draft outreach for <Contact>" \u2192 \`leadbay_prepare_outreach\`.
|
|
5916
|
+
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\`.
|
|
5862
5917
|
|
|
5863
|
-
Prefer when:
|
|
5918
|
+
Prefer when: ONE company name or domain in prose, no Leadbay id yet \u2014 always pass \`website\` if a domain was mentioned
|
|
5864
5919
|
|
|
5865
5920
|
Examples that SHOULD invoke this tool:
|
|
5866
5921
|
- "Look up Acme Corp for me."
|
|
@@ -5870,7 +5925,7 @@ Examples that SHOULD invoke this tool:
|
|
|
5870
5925
|
Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
5871
5926
|
- "Tell me about that lead I just picked."
|
|
5872
5927
|
- "Draft outreach to Acme's CTO."
|
|
5873
|
-
- "
|
|
5928
|
+
- "Websites and LinkedIn for these 200 companies."
|
|
5874
5929
|
|
|
5875
5930
|
---
|
|
5876
5931
|
|
|
@@ -5884,15 +5939,13 @@ list" into an answer. With only a contact email, pass \`email\`: the company
|
|
|
5884
5939
|
domain is derived from it, consumer mailboxes ignored.
|
|
5885
5940
|
|
|
5886
5941
|
When the registry cannot pick one company it returns \`{resolution:
|
|
5887
|
-
"ambiguous", query, candidates:[
|
|
5888
|
-
|
|
5942
|
+
"ambiguous", query, candidates:[\u2026]}\` instead of a card. Ask which one; never
|
|
5943
|
+
guess from \`score\`.
|
|
5889
5944
|
|
|
5890
5945
|
\`LEAD_NOT_FOUND\` is not a dead end: its hint names the field that would have
|
|
5891
5946
|
found it \u2014 \`website\` or \`registry_number\`, both params. Ask for it and call
|
|
5892
5947
|
again. Do not offer an import before asking.
|
|
5893
5948
|
|
|
5894
|
-
Offer \`_meta.match_candidates\` when present.
|
|
5895
|
-
|
|
5896
5949
|
---
|
|
5897
5950
|
|
|
5898
5951
|
## RENDERING \u2014 single-record research card, mode-adaptive
|
|
@@ -6976,9 +7029,9 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
6976
7029
|
|
|
6977
7030
|
## RENDER (quick)
|
|
6978
7031
|
|
|
6979
|
-
Report user + org
|
|
6980
|
-
|
|
6981
|
-
\`last_requested_lens_name\`, never the id). SILENT on quota
|
|
7032
|
+
Report user + org; show quota only when asked (quota / account status) or a
|
|
7033
|
+
window is exhausted. NEVER mention the lens unless asked (use
|
|
7034
|
+
\`last_requested_lens_name\`, never the id). SILENT on quota when
|
|
6982
7035
|
\`quota_error\` set, \`unlimited_credits\` true, or quota null. Else render
|
|
6983
7036
|
Daily/Weekly/Monthly from \`quota.user\` (fall back to \`quota.org\` if \`user\`
|
|
6984
7037
|
absent) as \`$used / $cap (N% used) \xB7 resets\` (or a resource-count table when
|
|
@@ -6986,7 +7039,7 @@ absent) as \`$used / $cap (N% used) \xB7 resets\` (or a resource-count table whe
|
|
|
6986
7039
|
|
|
6987
7040
|
---
|
|
6988
7041
|
|
|
6989
|
-
Show the user's account state \u2014 admin rights, language, last-active lens, quota usage across daily/weekly/monthly windows, and whether the org's intelligence is mid-regeneration. **
|
|
7042
|
+
Show the user's account state \u2014 admin rights, language, last-active lens, quota usage across daily/weekly/monthly windows, and whether the org's intelligence is mid-regeneration. **When you show quota, show it the way the web app does \u2014 a percentage-used + dollar-spend gauge per window, never raw "credits".** Each window in \`quota.<group>.spend[]\` carries \`current_units\` / \`max_units\` in dollar_cents (% used = the ratio, $ = \`/100\`); the \`quota.<group>.resources[]\` list gives the per-resource usage breakdown (\`count\`, plus \`max_units\` when a per-resource cap exists). **Pre-check the \`LENS_EXTRA_REFILL\` resource here before calling \`leadbay_extend_lens\`** \u2014 look in **\`quota.org.resources[]\`** first (admins), and fall back to **\`quota.user.resources[]\`** when \`quota.org\` is absent (non-admin callers only get the \`user\` group), matching the resource type **case-insensitively** (it may arrive as \`LENS_EXTRA_REFILL\` or \`lens_extra_refill\`). Its full requested batch must fit into the remaining daily quota or the call is rejected outright. 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.
|
|
6990
7043
|
|
|
6991
7044
|
**After a user tops up, do NOT keep refusing \u2014 RETRY.** If the user signals they topped up / bought credits / added credits, the previous QUOTA_EXCEEDED is invalidated the moment the Stripe webhook lands. RE-CALL \`leadbay_account_status\` to pick up the new state AND retry the originally failed call. The retry itself does not require a successful account_status check first \u2014 a topped-up user has cleared the throttle whether or not your cached snapshot reflects it yet. **A stale quota snapshot is never a reason to gate-keep a topped-up user.**
|
|
6992
7045
|
|
|
@@ -7038,10 +7091,10 @@ Mirror the Leadbay web quota widget: three windows side by side \u2014 **Daily**
|
|
|
7038
7091
|
$ cap** figure, with a per-resource usage breakdown underneath. **Never speak in
|
|
7039
7092
|
raw "credits"** for quota \u2014 the unit is a percentage and a dollar spend.
|
|
7040
7093
|
|
|
7041
|
-
**
|
|
7042
|
-
|
|
7043
|
-
|
|
7044
|
-
|
|
7094
|
+
**Show the quota only when it matters** \u2014 when the user asks about their quota,
|
|
7095
|
+
usage or account status, or when a window is exhausted and blocks what they
|
|
7096
|
+
asked for. A plain "what account am I connected to?" is answered with user +
|
|
7097
|
+
org alone. Even then, the silence gate below comes first.
|
|
7045
7098
|
|
|
7046
7099
|
**Silence gate (check FIRST).** Render NOTHING about quota when any of these
|
|
7047
7100
|
holds \u2014 do not mention quota at all, do not say "unreadable", never tell the user
|
|
@@ -7109,7 +7162,7 @@ Keep it secondary \u2014 the three window gauges are the headline. Omit when nul
|
|
|
7109
7162
|
|
|
7110
7163
|
---
|
|
7111
7164
|
|
|
7112
|
-
WHEN TO USE:
|
|
7165
|
+
WHEN TO USE: when the user asks about their account, quota or version, after a 429 to explain to the user which resource window was exhausted and when it resets, and after the user signals a top-up so the agent can resume the interrupted workflow.
|
|
7113
7166
|
|
|
7114
7167
|
WHEN NOT TO USE: as a pre-flight gate before bulk ops \u2014 operations themselves return 429; this tool is for context, not gating. And: a recent quota snapshot showing "exhausted" is NOT a reason to refuse a write call when the user has just topped up \u2014 re-call this tool first, then proceed.
|
|
7115
7168
|
`,
|
|
@@ -7199,7 +7252,7 @@ measured:
|
|
|
7199
7252
|
\`employees_max: number\`. FLAT numbers \u2014 nested \`employees: {min, max}\`
|
|
7200
7253
|
exists only in RESULT payloads. \`example_lead.employees\` does not filter.
|
|
7201
7254
|
\`locations\` take city/state/region names ("Dallas, TX", "\xCEle-de-France");
|
|
7202
|
-
a country
|
|
7255
|
+
never a country: this workspace's own is dropped, any other is refused.
|
|
7203
7256
|
7. **Prefer \`example_lead\` over \`query\`.** Query matches topic *vocabulary*:
|
|
7204
7257
|
"gyms that need durable flooring" surfaced flooring VENDORS, 0 delivered.
|
|
7205
7258
|
Use \`query\` only for signal an example can't express.
|
|
@@ -14469,12 +14522,12 @@ var init_pull_leads = __esm({
|
|
|
14469
14522
|
});
|
|
14470
14523
|
|
|
14471
14524
|
// ../core/dist/composite/_geo-helpers.js
|
|
14472
|
-
function expandAlias(
|
|
14473
|
-
const key =
|
|
14474
|
-
return CITY_ALIASES[key] ??
|
|
14525
|
+
function expandAlias(text3) {
|
|
14526
|
+
const key = text3.trim().toLowerCase();
|
|
14527
|
+
return CITY_ALIASES[key] ?? text3;
|
|
14475
14528
|
}
|
|
14476
|
-
function scoreMatch(
|
|
14477
|
-
const t =
|
|
14529
|
+
function scoreMatch(text3, match) {
|
|
14530
|
+
const t = text3.trim().toLowerCase();
|
|
14478
14531
|
const n = match.name.trim().toLowerCase();
|
|
14479
14532
|
if (n === t)
|
|
14480
14533
|
return 1;
|
|
@@ -14497,8 +14550,8 @@ async function resolveLocations(client, texts) {
|
|
|
14497
14550
|
const resolved = [...direct];
|
|
14498
14551
|
const ambiguities = [];
|
|
14499
14552
|
for (const originalText of free) {
|
|
14500
|
-
const
|
|
14501
|
-
const path = `/geo/search?q=${encodeURIComponent(
|
|
14553
|
+
const text3 = expandAlias(originalText);
|
|
14554
|
+
const path = `/geo/search?q=${encodeURIComponent(text3)}`;
|
|
14502
14555
|
let response;
|
|
14503
14556
|
try {
|
|
14504
14557
|
response = await client.request("GET", path);
|
|
@@ -14537,7 +14590,7 @@ async function resolveLocations(client, texts) {
|
|
|
14537
14590
|
name: r.name,
|
|
14538
14591
|
country: r.country,
|
|
14539
14592
|
level: r.level,
|
|
14540
|
-
score: scoreMatch(
|
|
14593
|
+
score: scoreMatch(text3, r)
|
|
14541
14594
|
})).sort((a, b) => {
|
|
14542
14595
|
if (b.score !== a.score)
|
|
14543
14596
|
return b.score - a.score;
|
|
@@ -15807,9 +15860,9 @@ ${firm.short_description}`);
|
|
|
15807
15860
|
out.push(`### ${sec.section_emoji ?? ""} ${label}`.trim());
|
|
15808
15861
|
const entries = Array.isArray(sec.entries) ? sec.entries : [];
|
|
15809
15862
|
for (const e of entries.slice(0, 5)) {
|
|
15810
|
-
const
|
|
15863
|
+
const text3 = e.text ?? e.summary ?? JSON.stringify(e).slice(0, 200);
|
|
15811
15864
|
const hot = e.hot === true ? " \u{1F525}" : "";
|
|
15812
|
-
out.push(`- ${
|
|
15865
|
+
out.push(`- ${text3}${hot}`);
|
|
15813
15866
|
}
|
|
15814
15867
|
if (entries.length > 5)
|
|
15815
15868
|
out.push(`- _${entries.length - 5} more \u2026_`);
|
|
@@ -16129,6 +16182,9 @@ var init_research_lead_by_id = __esm({
|
|
|
16129
16182
|
]
|
|
16130
16183
|
},
|
|
16131
16184
|
execute: async (client, params, _ctx) => {
|
|
16185
|
+
if (typeof params.leadId !== "string" || params.leadId.trim() === "") {
|
|
16186
|
+
throw client.makeError("INVALID_PARAMS", "leadId is required and must be a non-empty string", "Pass the lead's full 36-character UUID as `leadId`, exactly as leadbay_pull_leads items[].id returned it. If you only have the company name, call leadbay_research_lead_by_name_fuzzy instead.");
|
|
16187
|
+
}
|
|
16132
16188
|
const lensId = params.lensId ?? await client.resolveDefaultLens();
|
|
16133
16189
|
const leadId = params.leadId;
|
|
16134
16190
|
void client.request("POST", "/interactions", [
|
|
@@ -16310,6 +16366,139 @@ var init_research_lead_by_id = __esm({
|
|
|
16310
16366
|
}
|
|
16311
16367
|
});
|
|
16312
16368
|
|
|
16369
|
+
// ../../node_modules/.pnpm/fastest-levenshtein@1.0.16/node_modules/fastest-levenshtein/esm/mod.js
|
|
16370
|
+
var peq, myers_32, myers_x, distance;
|
|
16371
|
+
var init_mod = __esm({
|
|
16372
|
+
"../../node_modules/.pnpm/fastest-levenshtein@1.0.16/node_modules/fastest-levenshtein/esm/mod.js"() {
|
|
16373
|
+
"use strict";
|
|
16374
|
+
peq = new Uint32Array(65536);
|
|
16375
|
+
myers_32 = (a, b) => {
|
|
16376
|
+
const n = a.length;
|
|
16377
|
+
const m = b.length;
|
|
16378
|
+
const lst = 1 << n - 1;
|
|
16379
|
+
let pv = -1;
|
|
16380
|
+
let mv = 0;
|
|
16381
|
+
let sc = n;
|
|
16382
|
+
let i = n;
|
|
16383
|
+
while (i--) {
|
|
16384
|
+
peq[a.charCodeAt(i)] |= 1 << i;
|
|
16385
|
+
}
|
|
16386
|
+
for (i = 0; i < m; i++) {
|
|
16387
|
+
let eq = peq[b.charCodeAt(i)];
|
|
16388
|
+
const xv = eq | mv;
|
|
16389
|
+
eq |= (eq & pv) + pv ^ pv;
|
|
16390
|
+
mv |= ~(eq | pv);
|
|
16391
|
+
pv &= eq;
|
|
16392
|
+
if (mv & lst) {
|
|
16393
|
+
sc++;
|
|
16394
|
+
}
|
|
16395
|
+
if (pv & lst) {
|
|
16396
|
+
sc--;
|
|
16397
|
+
}
|
|
16398
|
+
mv = mv << 1 | 1;
|
|
16399
|
+
pv = pv << 1 | ~(xv | mv);
|
|
16400
|
+
mv &= xv;
|
|
16401
|
+
}
|
|
16402
|
+
i = n;
|
|
16403
|
+
while (i--) {
|
|
16404
|
+
peq[a.charCodeAt(i)] = 0;
|
|
16405
|
+
}
|
|
16406
|
+
return sc;
|
|
16407
|
+
};
|
|
16408
|
+
myers_x = (b, a) => {
|
|
16409
|
+
const n = a.length;
|
|
16410
|
+
const m = b.length;
|
|
16411
|
+
const mhc = [];
|
|
16412
|
+
const phc = [];
|
|
16413
|
+
const hsize = Math.ceil(n / 32);
|
|
16414
|
+
const vsize = Math.ceil(m / 32);
|
|
16415
|
+
for (let i = 0; i < hsize; i++) {
|
|
16416
|
+
phc[i] = -1;
|
|
16417
|
+
mhc[i] = 0;
|
|
16418
|
+
}
|
|
16419
|
+
let j = 0;
|
|
16420
|
+
for (; j < vsize - 1; j++) {
|
|
16421
|
+
let mv2 = 0;
|
|
16422
|
+
let pv2 = -1;
|
|
16423
|
+
const start2 = j * 32;
|
|
16424
|
+
const vlen2 = Math.min(32, m) + start2;
|
|
16425
|
+
for (let k = start2; k < vlen2; k++) {
|
|
16426
|
+
peq[b.charCodeAt(k)] |= 1 << k;
|
|
16427
|
+
}
|
|
16428
|
+
for (let i = 0; i < n; i++) {
|
|
16429
|
+
const eq = peq[a.charCodeAt(i)];
|
|
16430
|
+
const pb = phc[i / 32 | 0] >>> i & 1;
|
|
16431
|
+
const mb = mhc[i / 32 | 0] >>> i & 1;
|
|
16432
|
+
const xv = eq | mv2;
|
|
16433
|
+
const xh = ((eq | mb) & pv2) + pv2 ^ pv2 | eq | mb;
|
|
16434
|
+
let ph = mv2 | ~(xh | pv2);
|
|
16435
|
+
let mh = pv2 & xh;
|
|
16436
|
+
if (ph >>> 31 ^ pb) {
|
|
16437
|
+
phc[i / 32 | 0] ^= 1 << i;
|
|
16438
|
+
}
|
|
16439
|
+
if (mh >>> 31 ^ mb) {
|
|
16440
|
+
mhc[i / 32 | 0] ^= 1 << i;
|
|
16441
|
+
}
|
|
16442
|
+
ph = ph << 1 | pb;
|
|
16443
|
+
mh = mh << 1 | mb;
|
|
16444
|
+
pv2 = mh | ~(xv | ph);
|
|
16445
|
+
mv2 = ph & xv;
|
|
16446
|
+
}
|
|
16447
|
+
for (let k = start2; k < vlen2; k++) {
|
|
16448
|
+
peq[b.charCodeAt(k)] = 0;
|
|
16449
|
+
}
|
|
16450
|
+
}
|
|
16451
|
+
let mv = 0;
|
|
16452
|
+
let pv = -1;
|
|
16453
|
+
const start = j * 32;
|
|
16454
|
+
const vlen = Math.min(32, m - start) + start;
|
|
16455
|
+
for (let k = start; k < vlen; k++) {
|
|
16456
|
+
peq[b.charCodeAt(k)] |= 1 << k;
|
|
16457
|
+
}
|
|
16458
|
+
let score = m;
|
|
16459
|
+
for (let i = 0; i < n; i++) {
|
|
16460
|
+
const eq = peq[a.charCodeAt(i)];
|
|
16461
|
+
const pb = phc[i / 32 | 0] >>> i & 1;
|
|
16462
|
+
const mb = mhc[i / 32 | 0] >>> i & 1;
|
|
16463
|
+
const xv = eq | mv;
|
|
16464
|
+
const xh = ((eq | mb) & pv) + pv ^ pv | eq | mb;
|
|
16465
|
+
let ph = mv | ~(xh | pv);
|
|
16466
|
+
let mh = pv & xh;
|
|
16467
|
+
score += ph >>> m - 1 & 1;
|
|
16468
|
+
score -= mh >>> m - 1 & 1;
|
|
16469
|
+
if (ph >>> 31 ^ pb) {
|
|
16470
|
+
phc[i / 32 | 0] ^= 1 << i;
|
|
16471
|
+
}
|
|
16472
|
+
if (mh >>> 31 ^ mb) {
|
|
16473
|
+
mhc[i / 32 | 0] ^= 1 << i;
|
|
16474
|
+
}
|
|
16475
|
+
ph = ph << 1 | pb;
|
|
16476
|
+
mh = mh << 1 | mb;
|
|
16477
|
+
pv = mh | ~(xv | ph);
|
|
16478
|
+
mv = ph & xv;
|
|
16479
|
+
}
|
|
16480
|
+
for (let k = start; k < vlen; k++) {
|
|
16481
|
+
peq[b.charCodeAt(k)] = 0;
|
|
16482
|
+
}
|
|
16483
|
+
return score;
|
|
16484
|
+
};
|
|
16485
|
+
distance = (a, b) => {
|
|
16486
|
+
if (a.length < b.length) {
|
|
16487
|
+
const tmp = b;
|
|
16488
|
+
b = a;
|
|
16489
|
+
a = tmp;
|
|
16490
|
+
}
|
|
16491
|
+
if (b.length === 0) {
|
|
16492
|
+
return a.length;
|
|
16493
|
+
}
|
|
16494
|
+
if (a.length <= 32) {
|
|
16495
|
+
return myers_32(a, b);
|
|
16496
|
+
}
|
|
16497
|
+
return myers_x(a, b);
|
|
16498
|
+
};
|
|
16499
|
+
}
|
|
16500
|
+
});
|
|
16501
|
+
|
|
16313
16502
|
// ../core/dist/composite/research-lead-by-name-fuzzy.js
|
|
16314
16503
|
function parseLensId(value) {
|
|
16315
16504
|
const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN;
|
|
@@ -16322,6 +16511,28 @@ function suggestionName(suggestion) {
|
|
|
16322
16511
|
function suggestionLeadId(suggestion) {
|
|
16323
16512
|
return suggestion.lead_id ?? suggestion.leadId;
|
|
16324
16513
|
}
|
|
16514
|
+
function nameWords(name) {
|
|
16515
|
+
return name.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
16516
|
+
}
|
|
16517
|
+
function isSameWord(a, b) {
|
|
16518
|
+
if (Math.max(a.length, b.length) > 3)
|
|
16519
|
+
return distance(a, b) <= 1;
|
|
16520
|
+
const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a];
|
|
16521
|
+
return shorter === longer || longer.length === shorter.length + 1 && longer.startsWith(shorter);
|
|
16522
|
+
}
|
|
16523
|
+
function coversWords(words, pool, lastMayBeCut) {
|
|
16524
|
+
return words.every((word, i) => pool.some((other) => isSameWord(word, other) || lastMayBeCut && i === words.length - 1 && other.startsWith(word)));
|
|
16525
|
+
}
|
|
16526
|
+
function isWordMatch(query, suggestion) {
|
|
16527
|
+
const matchType = suggestion.match_type ?? suggestion.matchType;
|
|
16528
|
+
if (matchType === "DOMAIN" || matchType === "PERSON")
|
|
16529
|
+
return true;
|
|
16530
|
+
const asked = nameWords(query);
|
|
16531
|
+
const found = nameWords(suggestionName(suggestion));
|
|
16532
|
+
if (asked.length === 0 || found.length === 0)
|
|
16533
|
+
return false;
|
|
16534
|
+
return coversWords(asked, found, true) || coversWords(found, asked, false) || asked.join("") === found.join("");
|
|
16535
|
+
}
|
|
16325
16536
|
function isLeadbayError(error) {
|
|
16326
16537
|
return typeof error === "object" && error !== null && error.error === true && typeof error.code === "string" && typeof error.message === "string" && typeof error.hint === "string";
|
|
16327
16538
|
}
|
|
@@ -16364,7 +16575,7 @@ async function resolveWithinLens(client, query, lensId) {
|
|
|
16364
16575
|
}
|
|
16365
16576
|
async function resolveAcrossVisibleCorpus(client, query) {
|
|
16366
16577
|
const suggestions = await client.request("GET", `/search/suggest?q=${encodeURIComponent(query)}`);
|
|
16367
|
-
return suggestions.map((suggestion) => {
|
|
16578
|
+
return suggestions.filter((suggestion) => isWordMatch(query, suggestion)).map((suggestion) => {
|
|
16368
16579
|
const id = suggestionLeadId(suggestion);
|
|
16369
16580
|
return {
|
|
16370
16581
|
id: id ?? "",
|
|
@@ -16396,6 +16607,7 @@ var RESOLVE_TIMEOUT_MS, MAX_AMBIGUOUS_CANDIDATES, researchLeadByNameFuzzy;
|
|
|
16396
16607
|
var init_research_lead_by_name_fuzzy = __esm({
|
|
16397
16608
|
"../core/dist/composite/research-lead-by-name-fuzzy.js"() {
|
|
16398
16609
|
"use strict";
|
|
16610
|
+
init_mod();
|
|
16399
16611
|
init_research_lead_by_id();
|
|
16400
16612
|
init_import_leads();
|
|
16401
16613
|
init_tool_descriptions_generated();
|
|
@@ -16889,12 +17101,12 @@ var init_set_qualification_questions = __esm({
|
|
|
16889
17101
|
properties: {
|
|
16890
17102
|
questions: {
|
|
16891
17103
|
type: "array",
|
|
16892
|
-
items: { type: "string" },
|
|
17104
|
+
items: { type: "string", maxLength: 255 },
|
|
16893
17105
|
description: "Full replacement list of qualification questions (replaces ALL current questions). Mutually exclusive with add/remove."
|
|
16894
17106
|
},
|
|
16895
17107
|
add: {
|
|
16896
17108
|
type: "array",
|
|
16897
|
-
items: { type: "string" },
|
|
17109
|
+
items: { type: "string", maxLength: 255 },
|
|
16898
17110
|
description: "Questions to append to the current list (deduped). Mutually exclusive with `questions`."
|
|
16899
17111
|
},
|
|
16900
17112
|
remove: {
|
|
@@ -17790,7 +18002,7 @@ var init_account_status = __esm({
|
|
|
17790
18002
|
},
|
|
17791
18003
|
quota: {
|
|
17792
18004
|
type: ["object", "null"],
|
|
17793
|
-
description: "Quota state, shaped like the web app. Prefer the `user` group (present for every caller; `org` is admin-only). Each group has `spend[]` and `resources[]` per daily/weekly/monthly window. `spend[]` is the PERCENTAGE-USED + DOLLAR gauge: `current_units`/`max_units` are dollar_cents, so % used = current/max and $ = /100 \u2014 render this like the frontend, never raw 'credits'. `spend[]` is empty for internal/free orgs with no OVERALL_SPEND quota; fall back to the `resources[]` breakdown then. `resources[]` gives per-resource usage: {resource_type, count (used), max_units (per-resource cap or null), window_type, resets_at}. `topup` (when present) is {remaining_cents, total_credit_cents}. Null if /quota_status failed (see quota_error) or genuinely returned nothing. NOTE: the `user`-group preference is for the DISPLAY gauge only \u2014 to pre-check LENS_EXTRA_REFILL before leadbay_extend_lens, look in `quota.org.resources[]` first (admins) and fall back to `quota.user.resources[]` when `org` is absent (non-admins), matching the resource type case-insensitively (LENS_EXTRA_REFILL / lens_extra_refill)."
|
|
18005
|
+
description: "Quota state, shaped like the web app. Prefer the `user` group (present for every caller; `org` is admin-only). Each group has `spend[]` and `resources[]` per daily/weekly/monthly window. `spend[]` is the PERCENTAGE-USED + DOLLAR gauge: `current_units`/`max_units` are dollar_cents, so % used = current/max and $ = /100 \u2014 render this like the frontend, never raw 'credits', and only when the user asks about quota or a window is exhausted. `spend[]` is empty for internal/free orgs with no OVERALL_SPEND quota; fall back to the `resources[]` breakdown then. `resources[]` gives per-resource usage: {resource_type, count (used), max_units (per-resource cap or null), window_type, resets_at}. `topup` (when present) is {remaining_cents, total_credit_cents}. Null if /quota_status failed (see quota_error) or genuinely returned nothing. NOTE: the `user`-group preference is for the DISPLAY gauge only \u2014 to pre-check LENS_EXTRA_REFILL before leadbay_extend_lens, look in `quota.org.resources[]` first (admins) and fall back to `quota.user.resources[]` when `org` is absent (non-admins), matching the resource type case-insensitively (LENS_EXTRA_REFILL / lens_extra_refill)."
|
|
17794
18006
|
},
|
|
17795
18007
|
quota_error: {
|
|
17796
18008
|
type: ["object", "null"],
|
|
@@ -20350,7 +20562,7 @@ var init_bulk_enrich_status = __esm({
|
|
|
20350
20562
|
},
|
|
20351
20563
|
credits_remaining: {
|
|
20352
20564
|
type: ["number", "string", "null"],
|
|
20353
|
-
description: `Advisory internal context only \u2014 do NOT display it. It's billing.ai_credits (a CONSUMED counter, not a remaining balance), so it can read 0 on a fresh/quota-backed account and printing _(N credits remaining)_ would falsely say 'out of credits'. Enrichment is gated by QUOTA, not this number
|
|
20565
|
+
description: `Advisory internal context only \u2014 do NOT display it. It's billing.ai_credits (a CONSUMED counter, not a remaining balance), so it can read 0 on a fresh/quota-backed account and printing _(N credits remaining)_ would falsely say 'out of credits'. Enrichment is gated by QUOTA, not this number \u2014 never a credits line here. ("unlimited" = internal/unlimited account; still nothing to display.) A per-run 'credits used' figure is intentionally NOT returned \u2014 getContacts can't scope cost to this bulk.`
|
|
20354
20566
|
},
|
|
20355
20567
|
partial_failures: {
|
|
20356
20568
|
type: "array",
|
|
@@ -20543,8 +20755,8 @@ function tokens(s) {
|
|
|
20543
20755
|
return [];
|
|
20544
20756
|
return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
20545
20757
|
}
|
|
20546
|
-
function bestMatches(
|
|
20547
|
-
const want = new Set(tokens(
|
|
20758
|
+
function bestMatches(text3, taxonomy) {
|
|
20759
|
+
const want = new Set(tokens(text3));
|
|
20548
20760
|
if (want.size === 0)
|
|
20549
20761
|
return [];
|
|
20550
20762
|
const ranked = taxonomy.map((s) => {
|
|
@@ -20573,12 +20785,12 @@ async function resolveSectors(client, texts, ctx) {
|
|
|
20573
20785
|
}
|
|
20574
20786
|
const resolved = [...direct];
|
|
20575
20787
|
const ambiguities = [];
|
|
20576
|
-
for (const
|
|
20577
|
-
const matches = bestMatches(
|
|
20788
|
+
for (const text3 of free) {
|
|
20789
|
+
const matches = bestMatches(text3, taxonomy);
|
|
20578
20790
|
if (matches.length === 1 || matches.length >= 2 && matches[0].score >= 0.66 && matches[0].score - matches[1].score >= 0.34) {
|
|
20579
20791
|
resolved.push(matches[0].id);
|
|
20580
20792
|
} else {
|
|
20581
|
-
ambiguities.push({ sector_text:
|
|
20793
|
+
ambiguities.push({ sector_text: text3, matches });
|
|
20582
20794
|
}
|
|
20583
20795
|
}
|
|
20584
20796
|
return { resolved, ambiguities };
|
|
@@ -21431,7 +21643,7 @@ async function waitForJob(client, jobId, waitSeconds, ctx, itemsRequested, since
|
|
|
21431
21643
|
ctx?.progress?.({
|
|
21432
21644
|
progress: f.delivered ?? 0,
|
|
21433
21645
|
total: itemsRequested,
|
|
21434
|
-
message: `${snap.job.state}: ${f.examined ?? 0} examined, ${f.delivered ?? 0} delivered
|
|
21646
|
+
message: `${snap.job.state}: ${f.examined ?? 0} examined, ${f.delivered ?? 0} delivered`
|
|
21435
21647
|
});
|
|
21436
21648
|
}
|
|
21437
21649
|
return snap;
|
|
@@ -21907,6 +22119,7 @@ var init_find_new_leads = __esm({
|
|
|
21907
22119
|
"../core/dist/composite/find-new-leads.js"() {
|
|
21908
22120
|
"use strict";
|
|
21909
22121
|
init_mcp_job_helpers();
|
|
22122
|
+
init_country_guard();
|
|
21910
22123
|
init_tool_descriptions_generated();
|
|
21911
22124
|
DEFAULT_WAIT_SECONDS = 45;
|
|
21912
22125
|
findNewLeads = {
|
|
@@ -21966,11 +22179,14 @@ var init_find_new_leads = __esm({
|
|
|
21966
22179
|
},
|
|
21967
22180
|
min_ai_score: {
|
|
21968
22181
|
type: "number",
|
|
22182
|
+
minimum: -30,
|
|
22183
|
+
maximum: 30,
|
|
21969
22184
|
description: "Disqualification floor on the [-30,+30] qualification DELTA (not the 0-100 fit score). Default 0. Lower to -30 to keep every evaluated lead with its evidence."
|
|
21970
22185
|
},
|
|
21971
22186
|
contact_titles: {
|
|
21972
22187
|
type: "array",
|
|
21973
22188
|
items: { type: "string" },
|
|
22189
|
+
maxItems: 10,
|
|
21974
22190
|
description: "Wanted decision-maker titles (max 10), matched semantically cross-language."
|
|
21975
22191
|
},
|
|
21976
22192
|
title_gate: {
|
|
@@ -22028,6 +22244,21 @@ var init_find_new_leads = __esm({
|
|
|
22028
22244
|
"channels",
|
|
22029
22245
|
"exclude_lead_ids"
|
|
22030
22246
|
]);
|
|
22247
|
+
const homeCountry = detectCountryLocations(params.filters?.locations, "filters.locations", client.region).filter((hit) => hit.kind === "home_country");
|
|
22248
|
+
let countryNote;
|
|
22249
|
+
if (homeCountry.length > 0) {
|
|
22250
|
+
const dropped = new Set(homeCountry.map((hit) => hit.value));
|
|
22251
|
+
const raw = params.filters.locations;
|
|
22252
|
+
const rest = (Array.isArray(raw) ? raw : [raw]).filter((v) => !dropped.has(v));
|
|
22253
|
+
params = {
|
|
22254
|
+
...params,
|
|
22255
|
+
filters: { ...params.filters, locations: rest.length > 0 ? rest : void 0 }
|
|
22256
|
+
};
|
|
22257
|
+
const removed = [...dropped].map((v) => `"${v}"`).join(", ");
|
|
22258
|
+
countryNote = {
|
|
22259
|
+
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.`
|
|
22260
|
+
};
|
|
22261
|
+
}
|
|
22031
22262
|
rejectCountryLocations(params.filters?.locations, client.region);
|
|
22032
22263
|
rejectMalformedExclusions(params.exclude_lead_ids);
|
|
22033
22264
|
rejectOversizedExclusions(params.exclude_lead_ids);
|
|
@@ -22105,6 +22336,7 @@ var init_find_new_leads = __esm({
|
|
|
22105
22336
|
return {
|
|
22106
22337
|
dry_run: true,
|
|
22107
22338
|
...forecast,
|
|
22339
|
+
...countryNote,
|
|
22108
22340
|
region: client.region
|
|
22109
22341
|
};
|
|
22110
22342
|
}
|
|
@@ -22125,6 +22357,7 @@ var init_find_new_leads = __esm({
|
|
|
22125
22357
|
estimated_cost: forecast?.estimated_cost ?? null,
|
|
22126
22358
|
items_requested: forecast?.items_requested ?? null,
|
|
22127
22359
|
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.",
|
|
22360
|
+
...countryNote,
|
|
22128
22361
|
region: client.region
|
|
22129
22362
|
};
|
|
22130
22363
|
}
|
|
@@ -22179,6 +22412,7 @@ var init_find_new_leads = __esm({
|
|
|
22179
22412
|
since: snapshot.next_since ?? null,
|
|
22180
22413
|
suggested_wait_seconds: done ? 0 : 60
|
|
22181
22414
|
},
|
|
22415
|
+
...countryNote,
|
|
22182
22416
|
region: client.region
|
|
22183
22417
|
};
|
|
22184
22418
|
}
|
|
@@ -22186,6 +22420,144 @@ var init_find_new_leads = __esm({
|
|
|
22186
22420
|
}
|
|
22187
22421
|
});
|
|
22188
22422
|
|
|
22423
|
+
// ../core/dist/composite/_identity-rows.js
|
|
22424
|
+
function text(v) {
|
|
22425
|
+
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
22426
|
+
}
|
|
22427
|
+
function toRow(item) {
|
|
22428
|
+
const asked = item.ref?.requested_as ?? {};
|
|
22429
|
+
const row = {
|
|
22430
|
+
input_indexes: item.ref?.input_indexes ?? null,
|
|
22431
|
+
input: text(asked.name) ?? text(asked.website) ?? text(asked.lead_id) ?? text(asked.contact_id),
|
|
22432
|
+
status: item.status
|
|
22433
|
+
};
|
|
22434
|
+
if (item.status_reason)
|
|
22435
|
+
row.status_reason = item.status_reason;
|
|
22436
|
+
const lead = item.lead;
|
|
22437
|
+
if (!lead)
|
|
22438
|
+
return row;
|
|
22439
|
+
const company = lead.company ?? {};
|
|
22440
|
+
row.lead_id = lead.lead_id ?? item.ref?.lead_id ?? void 0;
|
|
22441
|
+
row.name = company.name;
|
|
22442
|
+
row.website = text(company.website);
|
|
22443
|
+
row.linkedin = text(company.socials?.linkedin);
|
|
22444
|
+
return row;
|
|
22445
|
+
}
|
|
22446
|
+
function identityRows(items) {
|
|
22447
|
+
const first = (i) => {
|
|
22448
|
+
const idx = i.ref?.input_indexes;
|
|
22449
|
+
return Array.isArray(idx) && idx.length > 0 ? Math.min(...idx) : Number.MAX_SAFE_INTEGER;
|
|
22450
|
+
};
|
|
22451
|
+
return [...items].sort((a, b) => first(a) - first(b) || a.seq - b.seq).map(toRow);
|
|
22452
|
+
}
|
|
22453
|
+
function identityCoverage(rows) {
|
|
22454
|
+
let resolved = 0;
|
|
22455
|
+
let ambiguous = 0;
|
|
22456
|
+
let notFound = 0;
|
|
22457
|
+
let otherSkipped = 0;
|
|
22458
|
+
let withWebsite = 0;
|
|
22459
|
+
let withLinkedin = 0;
|
|
22460
|
+
for (const r of rows) {
|
|
22461
|
+
if (r.status !== "skipped") {
|
|
22462
|
+
resolved += 1;
|
|
22463
|
+
if (r.website)
|
|
22464
|
+
withWebsite += 1;
|
|
22465
|
+
if (r.linkedin)
|
|
22466
|
+
withLinkedin += 1;
|
|
22467
|
+
} else if (r.status_reason === "low_confidence_identity")
|
|
22468
|
+
ambiguous += 1;
|
|
22469
|
+
else if (r.status_reason === "not_in_universe")
|
|
22470
|
+
notFound += 1;
|
|
22471
|
+
else
|
|
22472
|
+
otherSkipped += 1;
|
|
22473
|
+
}
|
|
22474
|
+
return {
|
|
22475
|
+
resolved,
|
|
22476
|
+
ambiguous,
|
|
22477
|
+
not_found: notFound,
|
|
22478
|
+
other_skipped: otherSkipped,
|
|
22479
|
+
with_website: withWebsite,
|
|
22480
|
+
with_linkedin: withLinkedin,
|
|
22481
|
+
without_website: resolved - withWebsite
|
|
22482
|
+
};
|
|
22483
|
+
}
|
|
22484
|
+
function identityCsv(rows) {
|
|
22485
|
+
return synthesizeCsv(CSV_HEADER, rows.map((r) => ({
|
|
22486
|
+
row: (r.input_indexes ?? []).map((i) => i + 1).join(";"),
|
|
22487
|
+
input: r.input ?? "",
|
|
22488
|
+
status: r.status,
|
|
22489
|
+
reason: r.status_reason ?? "",
|
|
22490
|
+
company: r.name ?? "",
|
|
22491
|
+
website: r.website ?? "",
|
|
22492
|
+
linkedin: r.linkedin ?? "",
|
|
22493
|
+
lead_id: r.lead_id ?? ""
|
|
22494
|
+
})));
|
|
22495
|
+
}
|
|
22496
|
+
async function saveIdentityFile(ctx, jobId, snapshot, items) {
|
|
22497
|
+
if (!ctx?.saveFile)
|
|
22498
|
+
return {};
|
|
22499
|
+
if (!TERMINAL_JOB_STATES.has(snapshot.job.state))
|
|
22500
|
+
return {};
|
|
22501
|
+
if (snapshot.items_truncated || items.length === 0)
|
|
22502
|
+
return {};
|
|
22503
|
+
try {
|
|
22504
|
+
const name = `leadbay-companies-${jobId.slice(0, 8)}.csv`;
|
|
22505
|
+
return { file: await ctx.saveFile(name, identityCsv(identityRows(items))) };
|
|
22506
|
+
} catch (e) {
|
|
22507
|
+
return { file_error: e instanceof Error ? e.message : String(e) };
|
|
22508
|
+
}
|
|
22509
|
+
}
|
|
22510
|
+
function readOffset(v) {
|
|
22511
|
+
const n = typeof v === "string" ? Number(v) : v;
|
|
22512
|
+
return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
22513
|
+
}
|
|
22514
|
+
function identityAnswer(jobId, snapshot, items, offset) {
|
|
22515
|
+
const all = identityRows(items);
|
|
22516
|
+
const rows = all.slice(offset, offset + IDENTITY_ROWS_PER_RESULT);
|
|
22517
|
+
const end = offset + rows.length;
|
|
22518
|
+
const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
|
|
22519
|
+
const truncated = snapshot.items_truncated ?? false;
|
|
22520
|
+
const nextOffset = !done || truncated ? 0 : end < all.length ? end : null;
|
|
22521
|
+
return {
|
|
22522
|
+
summary: {
|
|
22523
|
+
...identityCoverage(all),
|
|
22524
|
+
stop_reason: snapshot.funnel?.stop_reason ?? null
|
|
22525
|
+
},
|
|
22526
|
+
rows,
|
|
22527
|
+
rows_total: all.length,
|
|
22528
|
+
rows_offset: offset,
|
|
22529
|
+
items_truncated: truncated,
|
|
22530
|
+
scope_notes: snapshot.explain?.scope_notes ?? [],
|
|
22531
|
+
still_running: !done,
|
|
22532
|
+
next_poll: nextOffset === null ? null : {
|
|
22533
|
+
tool: "leadbay_lead_job_status",
|
|
22534
|
+
job_id: jobId,
|
|
22535
|
+
compact: true,
|
|
22536
|
+
offset: nextOffset,
|
|
22537
|
+
suggested_wait_seconds: done ? 0 : 60
|
|
22538
|
+
}
|
|
22539
|
+
};
|
|
22540
|
+
}
|
|
22541
|
+
var IDENTITY_ROWS_PER_RESULT, CSV_HEADER;
|
|
22542
|
+
var init_identity_rows = __esm({
|
|
22543
|
+
"../core/dist/composite/_identity-rows.js"() {
|
|
22544
|
+
"use strict";
|
|
22545
|
+
init_mcp_job_helpers();
|
|
22546
|
+
init_import_leads();
|
|
22547
|
+
IDENTITY_ROWS_PER_RESULT = 100;
|
|
22548
|
+
CSV_HEADER = [
|
|
22549
|
+
"row",
|
|
22550
|
+
"input",
|
|
22551
|
+
"status",
|
|
22552
|
+
"reason",
|
|
22553
|
+
"company",
|
|
22554
|
+
"website",
|
|
22555
|
+
"linkedin",
|
|
22556
|
+
"lead_id"
|
|
22557
|
+
];
|
|
22558
|
+
}
|
|
22559
|
+
});
|
|
22560
|
+
|
|
22189
22561
|
// ../core/dist/composite/qualify-leads.js
|
|
22190
22562
|
function normalizeLeadRefs(refs) {
|
|
22191
22563
|
if (!Array.isArray(refs))
|
|
@@ -22226,7 +22598,7 @@ function rejectMalformedLeadRefs(refs) {
|
|
|
22226
22598
|
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."
|
|
22227
22599
|
};
|
|
22228
22600
|
}
|
|
22229
|
-
function
|
|
22601
|
+
function text2(value) {
|
|
22230
22602
|
if (typeof value !== "string")
|
|
22231
22603
|
return null;
|
|
22232
22604
|
const v = value.trim().toLowerCase();
|
|
@@ -22234,7 +22606,7 @@ function text(value) {
|
|
|
22234
22606
|
}
|
|
22235
22607
|
function derivedRequestId(params) {
|
|
22236
22608
|
const refs = canonicalSet((params.lead_refs ?? []).map((r) => {
|
|
22237
|
-
const website =
|
|
22609
|
+
const website = text2(r.website);
|
|
22238
22610
|
return [
|
|
22239
22611
|
// UUIDs are case-insensitive to the backend, so an uppercase id and
|
|
22240
22612
|
// its lowercase form are the same lead and must share a key.
|
|
@@ -22245,8 +22617,8 @@ function derivedRequestId(params) {
|
|
|
22245
22617
|
// AND to one key. Fall back to the trimmed/lowercased raw value when
|
|
22246
22618
|
// it is not domain-shaped, rather than dropping the field.
|
|
22247
22619
|
website ? normalizeDomain(website) ?? website : null,
|
|
22248
|
-
|
|
22249
|
-
|
|
22620
|
+
text2(r.name),
|
|
22621
|
+
text2(r.location)
|
|
22250
22622
|
];
|
|
22251
22623
|
}));
|
|
22252
22624
|
const shape = {
|
|
@@ -22290,6 +22662,7 @@ var init_qualify_leads = __esm({
|
|
|
22290
22662
|
"use strict";
|
|
22291
22663
|
init_mcp_job_helpers();
|
|
22292
22664
|
init_import_leads();
|
|
22665
|
+
init_identity_rows();
|
|
22293
22666
|
init_tool_descriptions_generated();
|
|
22294
22667
|
DEFAULT_WAIT_SECONDS2 = 45;
|
|
22295
22668
|
LEAD_REF_FIELDS = [
|
|
@@ -22407,6 +22780,7 @@ var init_qualify_leads = __esm({
|
|
|
22407
22780
|
const buysChannels = (params.channels?.length ?? 0) > 0;
|
|
22408
22781
|
const buysQualification = qualify !== false;
|
|
22409
22782
|
const isPaid = buysQualification || buysChannels;
|
|
22783
|
+
const identityOnly = !isPaid && (params.contact_titles?.length ?? 0) === 0 && params.prior_deliveries == null;
|
|
22410
22784
|
const vetoed = confirm === false;
|
|
22411
22785
|
const consented = !vetoed && confirm === true;
|
|
22412
22786
|
const requestId = presentRequestId(params.request_id) ?? (isPaid ? derivedRequestId(params) : void 0);
|
|
@@ -22455,6 +22829,25 @@ var init_qualify_leads = __esm({
|
|
|
22455
22829
|
const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
|
|
22456
22830
|
const indexed = submit.duplicate ?? false ? remapInputIndexes(snapshot.items, params.lead_refs) : { items: snapshot.items, remapped: true };
|
|
22457
22831
|
const view = { ...snapshot, items: indexed.items };
|
|
22832
|
+
if (identityOnly) {
|
|
22833
|
+
const answer = identityAnswer(submit.job_id, snapshot, view.items, 0);
|
|
22834
|
+
return {
|
|
22835
|
+
job_id: submit.job_id,
|
|
22836
|
+
request_id: requestId ?? null,
|
|
22837
|
+
duplicate_submit: submit.duplicate ?? false,
|
|
22838
|
+
state: snapshot.job.state,
|
|
22839
|
+
done,
|
|
22840
|
+
...answer,
|
|
22841
|
+
summary: {
|
|
22842
|
+
refs_submitted: params.lead_refs?.length ?? 0,
|
|
22843
|
+
items_requested: submit.items_requested,
|
|
22844
|
+
...answer.summary
|
|
22845
|
+
},
|
|
22846
|
+
input_indexes_remapped: submit.duplicate ?? false ? indexed.remapped : null,
|
|
22847
|
+
...await saveIdentityFile(ctx, submit.job_id, snapshot, view.items),
|
|
22848
|
+
region: client.region
|
|
22849
|
+
};
|
|
22850
|
+
}
|
|
22458
22851
|
return {
|
|
22459
22852
|
job_id: submit.job_id,
|
|
22460
22853
|
// Echo the key actually sent, so a retry can reuse it verbatim.
|
|
@@ -22523,6 +22916,7 @@ var init_lead_job_status = __esm({
|
|
|
22523
22916
|
"../core/dist/composite/lead-job-status.js"() {
|
|
22524
22917
|
"use strict";
|
|
22525
22918
|
init_mcp_job_helpers();
|
|
22919
|
+
init_identity_rows();
|
|
22526
22920
|
init_tool_descriptions_generated();
|
|
22527
22921
|
leadJobStatus = {
|
|
22528
22922
|
name: "leadbay_lead_job_status",
|
|
@@ -22552,6 +22946,14 @@ var init_lead_job_status = __esm({
|
|
|
22552
22946
|
wait_seconds: {
|
|
22553
22947
|
type: "number",
|
|
22554
22948
|
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."
|
|
22949
|
+
},
|
|
22950
|
+
compact: {
|
|
22951
|
+
type: "boolean",
|
|
22952
|
+
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."
|
|
22953
|
+
},
|
|
22954
|
+
offset: {
|
|
22955
|
+
type: "number",
|
|
22956
|
+
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."
|
|
22555
22957
|
}
|
|
22556
22958
|
},
|
|
22557
22959
|
required: ["job_id"],
|
|
@@ -22559,8 +22961,19 @@ var init_lead_job_status = __esm({
|
|
|
22559
22961
|
},
|
|
22560
22962
|
execute: async (client, params, ctx) => {
|
|
22561
22963
|
const waitSeconds = clampWaitSeconds(params.wait_seconds, 0);
|
|
22562
|
-
const
|
|
22964
|
+
const compact = params.compact === true || params.compact === "true";
|
|
22965
|
+
const since = compact ? void 0 : params.since;
|
|
22966
|
+
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);
|
|
22563
22967
|
const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
|
|
22968
|
+
if (compact) {
|
|
22969
|
+
return {
|
|
22970
|
+
job_id: params.job_id,
|
|
22971
|
+
state: snapshot.job.state,
|
|
22972
|
+
done,
|
|
22973
|
+
...identityAnswer(params.job_id, snapshot, snapshot.items, readOffset(params.offset)),
|
|
22974
|
+
region: client.region
|
|
22975
|
+
};
|
|
22976
|
+
}
|
|
22564
22977
|
const { leads, skipped } = splitItems(snapshot);
|
|
22565
22978
|
return {
|
|
22566
22979
|
job_id: params.job_id,
|
|
@@ -23076,7 +23489,7 @@ var init_new_lens = __esm({
|
|
|
23076
23489
|
inputSchema: {
|
|
23077
23490
|
type: "object",
|
|
23078
23491
|
properties: {
|
|
23079
|
-
name: { type: "string", description: "Display name for the new lens (required)." },
|
|
23492
|
+
name: { type: "string", maxLength: 255, description: "Display name for the new lens (required)." },
|
|
23080
23493
|
sectors: {
|
|
23081
23494
|
type: "array",
|
|
23082
23495
|
items: { type: "string" },
|
|
@@ -23109,7 +23522,7 @@ var init_new_lens = __esm({
|
|
|
23109
23522
|
type: "number",
|
|
23110
23523
|
description: "Lens id to clone from. Defaults to the active/default lens."
|
|
23111
23524
|
},
|
|
23112
|
-
description: { type: "string", description: "Optional lens description." },
|
|
23525
|
+
description: { type: "string", maxLength: 255, description: "Optional lens description." },
|
|
23113
23526
|
confirm: {
|
|
23114
23527
|
type: "boolean",
|
|
23115
23528
|
description: "Safety gate. Defaults to false \u2192 the tool returns a PREVIEW and creates nothing. Show the preview to the user, get their explicit go-ahead, then re-call the SAME args with confirm:true to actually create the lens."
|
|
@@ -24002,8 +24415,8 @@ var init_send_feedback = __esm({
|
|
|
24002
24415
|
}
|
|
24003
24416
|
},
|
|
24004
24417
|
execute: async (client, params, ctx) => {
|
|
24005
|
-
const
|
|
24006
|
-
if (!
|
|
24418
|
+
const text3 = typeof params.message === "string" ? params.message.trim() : "";
|
|
24419
|
+
if (!text3) {
|
|
24007
24420
|
return {
|
|
24008
24421
|
error: true,
|
|
24009
24422
|
code: "BAD_INPUT",
|
|
@@ -24011,7 +24424,7 @@ var init_send_feedback = __esm({
|
|
|
24011
24424
|
hint: "Ask the user what they'd like to tell the Leadbay team, then call again with their words in `message`."
|
|
24012
24425
|
};
|
|
24013
24426
|
}
|
|
24014
|
-
const message =
|
|
24427
|
+
const message = text3.length > MESSAGE_MAX2 ? `${text3.slice(0, MESSAGE_MAX2 - 1)}\u2026` : text3;
|
|
24015
24428
|
if (!ctx?.sendFeedback) {
|
|
24016
24429
|
return {
|
|
24017
24430
|
sent: false,
|
|
@@ -24044,8 +24457,8 @@ var init_artifact_runtime_generated = __esm({
|
|
|
24044
24457
|
"../core/dist/artifact-runtime.generated.js"() {
|
|
24045
24458
|
"use strict";
|
|
24046
24459
|
ARTIFACT_KIT_VERSION = "0.5.0";
|
|
24047
|
-
ARTIFACT_RUNTIME = '"use strict";(()=>{var L=Object.defineProperty;var A=(e,r,t)=>r in e?L(e,r,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[r]=t;var a=(e,r,t)=>A(e,typeof r!="symbol"?r+"":r,t);var _=`\n:root{\n--color-black:#191919;--color-white:#fff;\n--color-gray-1:#f9f9f9;--color-gray-2:#f0f0f0;--color-gray-3:#e0e0e0;--color-gray-4:#cecece;\n--color-gray-5:#c4c4c4;--color-gray-6:#8d8d8d;--color-gray-7:#787878;--color-gray-8:#646464;\n--color-gray-9:#202020;\n--color-linkedin:#0a66c2;\n--color-blue-background:oklch(0.947 0.029 251);--color-blue-foreground:oklch(0.564 0.181 251);\n--color-green-background:oklch(0.947 0.029 141);--color-green-foreground:oklch(0.564 0.181 141);\n--color-red-background:oklch(0.947 0.029 26);--color-red-foreground:oklch(0.564 0.191 26);\n--color-gold-background:oklch(0.972 0.049 91);--color-gold-foreground:oklch(0.667 0.177 91);\n--color-cherry-background:oklch(0.947 0.029 15);--color-cherry-foreground:oklch(0.44 0.146 15);\n--color-red-like:var(--color-cherry-foreground);\n--lb-font:"Nikkei Maru",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;\n--lb-radius:1rem;--lb-radius-sm:0.625rem;--lb-gap:0.75rem;\n--lb-surface:var(--color-gray-1);--lb-border:var(--color-gray-3);\n--lb-fg:var(--color-black);--lb-muted:var(--color-gray-8);--lb-field:var(--color-white);\n}\n:root[data-theme=dark],:root[data-lb-theme=dark]{\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);--lb-field:var(--color-gray-9);\n}\n@media(prefers-color-scheme:dark){:root:not([data-theme=light]):not([data-lb-theme=light]){\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);--lb-field:var(--color-gray-9);\n}}\n.lb-card{display:grid;gap:var(--lb-gap);padding:0.875rem;\nbackground-color:var(--lb-surface);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius);corner-shape:squircle;color:var(--lb-fg);\nfont-family:var(--lb-font);\nbox-shadow:0 1rem 2.5rem color-mix(in srgb,var(--color-gray-9) 6%,transparent),\n0 0.125rem 0.5rem color-mix(in srgb,var(--color-gray-9) 4%,transparent)}\n.lb-card-head{display:flex;justify-content:space-between;align-items:baseline;gap:var(--lb-gap)}\n.lb-title{font-size:0.875rem;font-weight:600;line-height:1.25rem;color:var(--lb-fg)}\n.lb-sub{font-size:0.8125rem;line-height:1.125rem;color:var(--lb-muted)}\n.lb-row{display:flex;align-items:center;gap:var(--lb-gap);flex-wrap:wrap}\n.lb-stack{display:grid;gap:var(--lb-gap)}\n.lb-select,.lb-input{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;color:var(--lb-fg);\nbackground-color:var(--lb-field);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.55rem;min-height:2.125rem}\n.lb-btn{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;font-weight:600;\ncolor:var(--lb-fg);background-color:var(--lb-field);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.85rem;min-height:2.125rem;\ncursor:pointer;transition:background-color .15s,border-color .15s,color .15s}\n.lb-btn:hover:not([disabled]){border-color:var(--color-gray-6)}\n.lb-btn:focus-visible,.lb-select:focus-visible,.lb-input:focus-visible{\noutline:2px solid var(--color-blue-foreground);outline-offset:1px}\n.lb-btn[data-lb-state=loading]{opacity:.55;cursor:progress}\n.lb-btn[data-lb-state=success]{background-color:var(--color-green-background);\nborder-color:var(--color-green-foreground);color:var(--color-green-foreground)}\n.lb-btn[data-lb-state=error],.lb-select[data-lb-state=error]{\nbackground-color:var(--color-red-background);border-color:var(--color-red-foreground);\ncolor:var(--color-red-foreground)}\n.lb-btn[data-lb-state=unavailable],.lb-btn[disabled]{opacity:.5;cursor:not-allowed}\n.lb-msg{font-size:0.8125rem;line-height:1.125rem;color:var(--lb-muted)}\n.lb-msg[data-tone=error]{color:var(--color-red-foreground)}\n.lb-msg[data-tone=ok]{color:var(--color-green-foreground)}\n.lb-chip{display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap;\nfont-size:0.75rem;font-weight:600;line-height:1rem;padding:0.125rem 0.5rem;\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;\nbackground-color:var(--color-gray-2);color:var(--lb-muted)}\n.lb-chip[data-status=WANTED]{background-color:var(--color-blue-background);color:var(--color-blue-foreground)}\n.lb-chip[data-status=WON]{background-color:var(--color-green-background);color:var(--color-green-foreground)}\n.lb-chip[data-status=LOST]{background-color:var(--color-red-background);color:var(--color-red-foreground)}\n.lb-chip[data-status=UNWANTED]{background-color:var(--color-gray-2);color:var(--color-gray-7)}\n.lb-chip[data-taste=liked]{background-color:var(--color-cherry-background);color:var(--color-red-like)}\n.lb-chip[data-taste=disliked]{background-color:var(--color-gray-2);color:var(--color-gray-7)}\n.lb-chips{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}\n.lb-chip[hidden]{display:none}\n.lb-table{width:100%;border-collapse:collapse;font-family:var(--lb-font);color:var(--lb-fg)}\n.lb-table th,.lb-table td{text-align:left;padding:0.5rem 0.4rem;\nborder-bottom:1px solid var(--lb-border);vertical-align:middle;font-size:0.8125rem}\n.lb-table th{font-size:0.75rem;font-weight:600;color:var(--lb-muted);\ntext-transform:uppercase;letter-spacing:.04em}\n.lb-link{color:var(--color-blue-foreground);text-decoration:none}\n.lb-link:hover{text-decoration:underline}\n/* Quiet text link out of the artifact. Button-height so it shares the row\'s\n baseline; understated so it never competes with the actions beside it. The\n arrow is a bare diagonal stroke \\u2014 an escape-hatch marker, not an icon that\n asks to be read. */\n.lb-link-out{display:inline-flex;align-items:center;gap:.3rem;\nfont-size:0.75rem;line-height:1rem;min-height:2.125rem;\ncolor:var(--lb-fg);text-decoration:none;opacity:.65;transition:opacity .15s}\n.lb-link-out:hover{opacity:1;text-decoration:underline}\n.lb-link-out:focus-visible{outline:2px solid var(--color-blue-foreground);outline-offset:1px;\nborder-radius:var(--lb-radius-sm)}\n.lb-link-out svg{width:.85em;height:.85em;flex-shrink:0}\n/* Pushes whatever follows it to the right edge of an .lb-row, so a trailing\n link sits on the SAME baseline as the row\'s buttons instead of on its own\n line below them. */\n.lb-spacer{flex:1 1 auto}\n.lb-spinner{display:inline-block;width:.7em;height:.7em;border:2px solid var(--lb-border);\nborder-top-color:var(--color-blue-foreground);border-radius:50%;animation:lb-spin .8s linear infinite}\n@keyframes lb-spin{to{transform:rotate(1turn)}}\n@media(prefers-reduced-motion:reduce){.lb-spinner{animation:none}\n.lb-btn{transition-property:none}}\n`,p="lb-styles";var x="0.5.0",c=class extends Error{constructor(t,o={}){super(t);a(this,"code");a(this,"raw");this.name="LbError",this.code=o.code,this.raw=o.raw}},y=null,f=3e4;function C(){let e=globalThis.cowork;return e&&typeof e.callMcpTool=="function"?(r,t)=>e.callMcpTool(r,t):null}function S(e){if(e&&typeof e=="object"&&"content"in e){let r=e.content;if(Array.isArray(r)&&r[0]&&typeof r[0].text=="string")return r[0].text}return null}function E(e){if(!e||typeof e!="object")return e;let r=e;if(r.isError)throw new c(S(e)??"tool call failed",{raw:e});if("structuredContent"in r&&r.structuredContent!=null)return r.structuredContent;let t=S(e);if(t!=null)try{return JSON.parse(t)}catch{return t}return e}function I(e){return e instanceof Error?e.message:String(e)}function v(e){let r=e instanceof c?e.code:void 0;return{message:I(e),unavailable:r==="unavailable",code:r}}function O(e={}){y=e.call??null,f=e.timeoutMs??3e4}function R(){if(typeof document>"u"||!document.head)return null;let e=document.getElementById(p);if(e)return e;let r=document.createElement("style");return r.id=p,r.textContent=_,document.head.appendChild(r),r}async function T(e,r){if(!f||f<=0)return e;let t,o=new Promise((n,i)=>{t=setTimeout(()=>i(new c(`"${r}" timed out after ${f}ms`,{code:"timeout"})),f)});try{return await Promise.race([e,o])}finally{t&&clearTimeout(t)}}async function l(e,r={}){if(y)return E(await T(Promise.resolve(y(e,r)),e));let t=C();if(!t)throw new c("Leadbay bridge unavailable (window.cowork absent)",{code:"unavailable"});return E(await T(Promise.resolve(t(e,r)),e))}var g=class{constructor(){a(this,"subs",new Set)}subscribe(r){return this.subs.add(r),r(this),()=>this.subs.delete(r)}emit(){for(let r of this.subs)r(this)}};function N(e){return Array.isArray(e)?e.map(r=>r&&typeof r=="object"?r:{value:r,label:String(r)}):[]}var b=class extends g{constructor(t={}){super();a(this,"kind");a(this,"value");a(this,"options",[]);a(this,"loading",!1);a(this,"error",null);a(this,"ready",!1);a(this,"cfg");a(this,"depUnsubs",[]);a(this,"seq",0);this.cfg=t,this.kind=t.kind,this.value=t.value??"";for(let o of t.dependsOn??[]){let n=o.value;this.depUnsubs.push(o.subscribe(()=>{o.value!==n&&(n=o.value,this.cfg.load&&this.load())}))}t.load&&(t.autoLoad??!0)&&this.load()}async load(){if(!this.cfg.load)return;let t=++this.seq;this.loading=!0,this.error=null,this.emit();try{let o=await this.cfg.load();if(t!==this.seq)return;this.options=this.cfg.options?this.cfg.options(o):N(o),this.ready=!0;let n=this.value==null?"":String(this.value);this.options.length&&(n===""||!this.options.some(i=>String(i.value)===n))&&(this.value=this.options[0].value)}catch(o){if(t!==this.seq)return;this.options=[],this.error=v(o)}finally{t===this.seq&&(this.loading=!1,this.emit())}}setValue(t){this.value=t;let o=this.validate();this.error=o?{message:o,unavailable:!1}:null,this.emit()}validate(){return this.cfg.validate?this.cfg.validate(this.value):null}get valid(){return this.validate()==null}reset(){this.value=this.cfg.value??"",this.error=null,this.emit()}dispose(){for(let t of this.depUnsubs)t();this.depUnsubs=[]}};function P(e){if(!e||typeof e!="object")return null;let r=e;if(r.error!==!0)return null;let t=typeof r.message=="string"&&r.message?r.message:"tool call failed",o=typeof r.hint=="string"&&r.hint?` \\u2014 ${r.hint}`:"";return`${t}${o}`}var s=class extends g{constructor(t){super();a(this,"loading",!1);a(this,"error",null);a(this,"lastResult",null);a(this,"cfg");this.cfg=t}async run(){if(this.loading)return;for(let n of this.cfg.fields??[]){let i=n.validate();if(i!=null){this.error={message:i,unavailable:!1},this.emit();return}}if(this.cfg.confirm&&typeof globalThis.confirm=="function"&&!globalThis.confirm(this.cfg.confirm))return;this.loading=!0,this.error=null,this.emit();let t;try{let n=typeof this.cfg.args=="function"?this.cfg.args():this.cfg.args??{};t=await l(this.cfg.tool,n)}catch(n){this.error=v(n),this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}let o=P(t)??this.cfg.checkResult?.(t)??null;if(o!=null){this.error={message:o,unavailable:!1},this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}return this.lastResult=t,this.loading=!1,this.emit(),this.cfg.onSuccess?.(t),t}reset(){this.error=null,this.lastResult=null,this.emit()}},u=class extends g{constructor(t){super();a(this,"data",null);a(this,"loading",!1);a(this,"refreshing",!1);a(this,"error",null);a(this,"done",!1);a(this,"cfg");a(this,"timer",null);a(this,"seq",0);this.cfg=t,(t.autoLoad??!0)&&this.load()}async load(){this.clearTimer();let t=++this.seq;this.data==null?this.loading=!0:this.refreshing=!0,this.error=null,this.emit();try{let n=await this.cfg.load();if(t!==this.seq)return;this.data=n,this.done=this.cfg.until?this.cfg.until(n):!0,this.cfg.pollEvery&&!this.done&&(this.timer=setTimeout(()=>void this.load(),this.cfg.pollEvery))}catch(n){if(t!==this.seq)return;this.error=v(n)}finally{t===this.seq&&(this.loading=!1,this.refreshing=!1,this.emit())}}refresh(){return this.load()}stop(){this.clearTimer()}clearTimer(){this.timer&&(clearTimeout(this.timer),this.timer=null)}},h=class extends g{constructor(t){super();a(this,"items",[]);a(this,"page",0);a(this,"pageSize");a(this,"total",0);a(this,"loading",!1);a(this,"error",null);a(this,"cfg");a(this,"seq",0);this.cfg=t,this.pageSize=t.pageSize??20,(t.autoLoad??!0)&&this.loadPage(0)}async loadPage(t){let o=++this.seq;this.loading=!0,this.error=null,this.emit();try{let n=await this.cfg.load({page:t,pageSize:this.pageSize});if(o!==this.seq)return;this.items=n.items??[],this.total=n.total??this.items.length,this.page=t}catch(n){if(o!==this.seq)return;this.error=v(n)}finally{o===this.seq&&(this.loading=!1,this.emit())}}next(){return this.loadPage(this.page+1)}prev(){return this.loadPage(Math.max(0,this.page-1))}get hasMore(){return(this.page+1)*this.pageSize<this.total}};function M(e,r){let t=r.error?.unavailable?"unavailable":r.loading?"loading":r.error?"error":"ready";e.setAttribute("data-lb-state",t),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")}function D(e,r){let t=()=>r.setValue(e.value);e.addEventListener("change",t);let o=r.subscribe(()=>{M(e,r),e.disabled=r.loading,e.innerHTML="";for(let n of r.options){let i=document.createElement("option");i.value=String(n.value),i.textContent=n.label,e.appendChild(i)}e.value=r.value==null?"":String(r.value)});return()=>{e.removeEventListener("change",t),o()}}function z(e,r){let t=e.type==="checkbox",o=e.tagName==="SELECT"?"change":"input",n=()=>r.setValue(t?e.checked:e.value);e.addEventListener(o,n);let i=r.subscribe(()=>{if(t)e.checked=!!r.value;else{let d=r.value==null?"":String(r.value);e.value!==d&&(e.value=d)}e.setAttribute("data-lb-state",r.error?"error":"ready"),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener(o,n),i()}}function F(e,r){let t=n=>{n.preventDefault(),r.run()};e.addEventListener("click",t);let o=r.subscribe(()=>{let n=r.error?.unavailable?"unavailable":r.loading?"loading":r.error?"error":r.lastResult!=null?"success":"idle";e.setAttribute("data-lb-state",n),"disabled"in e&&(e.disabled=r.loading),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener("click",t),o()}}var q=["STILL_CHASING","COULD_NOT_REACH_STILL_TRYING","INTEREST_VALIDATED_OR_MEETING_PLANED","NOT_INTERESTED_LOST"],k=[{value:"",label:"Default ranking"},{value:"SCORE:DESC",label:"Score \\u2193"},{value:"SCORE:ASC",label:"Score \\u2191"},{value:"NAME:ASC",label:"Name A\\u2192Z"},{value:"NAME:DESC",label:"Name Z\\u2192A"},{value:"SIZE:DESC",label:"Size \\u2193"},{value:"SIZE:ASC",label:"Size \\u2191"},{value:"SECTOR:ASC",label:"Sector A\\u2192Z"},{value:"STATUS:ASC",label:"Status A\\u2192Z"},{value:"CONTACT_COUNT:DESC",label:"Contacts \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:DESC",label:"Last action \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:ASC",label:"Last action \\u2191"},{value:"EPILOGUE_STATUS_SET_AT:DESC",label:"Outcome set \\u2193"},{value:"LIKED:DESC",label:"Liked first"},{value:"DISLIKED:DESC",label:"Disliked first"}];function U(e){let r=String(e??"").trim().toUpperCase(),t=k.some(o=>o.value===r);return new b({kind:"select",value:t?r:"",load:async()=>k.slice()})}var m=[{value:"WANTED",label:"Wanted"},{value:"WON",label:"Won"},{value:"LOST",label:"Lost"},{value:"UNWANTED",label:"Unwanted"}],H={value:"",label:"\\u2014 Not set \\u2014"};function j(e){let r=String(e??"").trim().toUpperCase(),t=m.some(o=>o.value===r);return new b({kind:"select",value:t?r:"",validate:o=>String(o??"")===""?"Pick a status":null,load:async()=>t?m.slice():[H,...m]})}function W(e){let r=()=>{let t=typeof e.leadIds=="function"?e.leadIds():e.leadIds;return Array.isArray(t)?t:e.leadId?[e.leadId]:[]};return new s({tool:"leadbay_set_lead_status",fields:e.date?[e.status,e.date]:[e.status],confirm:e.confirm,args:()=>({lead_ids:r(),status:e.status.value,...e.date&&e.date.value?{status_date:e.date.value}:{},...e.ask?{_triggered_by:e.ask}:{}}),checkResult:t=>{let o=t?.failed;if(!Array.isArray(o)||o.length===0)return null;let n=r().length,i=o[0]?.message??"write rejected";return o.length===n?`Status not applied: ${i}`:`${o.length} of ${n} leads failed: ${i}`}})}function $(e){return new b({kind:"select",load:()=>l("leadbay_list_campaigns",{_triggered_by:e}),options:r=>(r?.campaigns??[]).map(o=>{let n=o?.campaign??o;return n?.id?{value:n.id,label:n.name??n.ai_generated_name??String(n.id)}:null}).filter(o=>o!=null)})}function V(e){return new s({tool:"leadbay_report_outreach",fields:e.note?[e.note]:[],args:()=>({lead_id:e.leadId,...e.status?{epilogue_status:e.status.value}:{},note:e.note?e.note.value:"",verification:{source:"user_confirmed",ref:e.ref??"logged from artifact"},_triggered_by:e.ask})})}function G(e){return new s({tool:"leadbay_add_note",fields:[e.note],args:()=>({leadId:e.leadId,note:e.note.value})})}function Z(e){return new s({tool:"leadbay_like_lead",args:{lead_id:e}})}function Y(e){return new s({tool:"leadbay_dislike_lead",args:{lead_id:e}})}function B(e,r){return new u({autoLoad:!1,load:()=>l("leadbay_account_history",{leadId:e,_triggered_by:r})})}function K(e,r){return new u({autoLoad:!1,load:()=>l("leadbay_research_lead_by_id",{leadId:e,_triggered_by:r})})}function J(e){let r=null;return new u({...e.autoLoad!==void 0?{autoLoad:e.autoLoad}:{},pollEvery:e.pollEvery??4e3,until:t=>!!t?.all_done,load:async()=>{if(!r){let t=await l("leadbay_enrich_titles",{...e.leadIds?{leadIds:e.leadIds}:{},titles:e.titles,...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},...e.confirm!==void 0?{confirm:e.confirm}:{},_triggered_by:e.ask}),o=Array.isArray(t?.lead_ids)?t.lead_ids:[],n=t?.notification_id??null;if(r=n||o.length>0?{notification_id:n,lead_ids:o}:null,!r)return{...t,all_done:!0,no_job:!0}}return l("leadbay_bulk_enrich_status",{...r.notification_id?{notification_id:r.notification_id}:{},...r.lead_ids.length>0?{lead_ids:r.lead_ids}:{},...e.titles?{titles:e.titles}:{},...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},_triggered_by:e.ask})}})}function Q(e){let r=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new h({pageSize:e.pageSize??20,load:async({page:t,pageSize:o})=>{let n=await l("leadbay_pull_leads",{page:t,count:o,...e.lensId?{lensId:e.lensId}:{},...r()?{order:r()}:{},_triggered_by:e.ask}),i=n.leads??[];return{items:i,total:n.pagination?.total??i.length}}})}function X(e){let r=e.source??"followups",t=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new h({pageSize:e.pageSize??20,load:async({page:o,pageSize:n})=>{let d=r==="campaign"?await l("leadbay_campaign_call_sheet",{campaign_id:e.campaignId,page:o,count:n,_triggered_by:e.ask}):await l("leadbay_pull_followups",{page:o,count:n,...e.city?{city:e.city}:{},...t()?{order:t()}:{},_triggered_by:e.ask}),w=d.leads??d.items??[];return{items:w,total:d.total_leads??d.pagination?.total??w.length}}})}function ee(e){return new u({load:()=>l("leadbay_team_activity",{weeks:e.weeks??4,_triggered_by:e.ask})})}var re={VERSION:x,configure:O,styles:R,call:l,field:e=>new b(e),action:e=>new s(e),resource:e=>new u(e),list:e=>new h(e),bindSelect:D,bindValue:z,bindAction:F,campaigns:$,outreach:V,note:G,like:Z,dislike:Y,leadStatus:j,setStatus:W,sortOrder:U,leadHistory:B,leadProfile:K,enrichment:J,callList:X,leadList:Q,teamActivity:ee,EPILOGUE_STATUSES:q,LEAD_STATUSES:m,SORT_ORDERS:k};typeof globalThis<"u"&&(globalThis.LeadbayArtifacts=re);})();';
|
|
24048
|
-
ARTIFACT_USAGE_GUIDE = '# Leadbay Artifact Kit \u2014 headless domain components\n\nYou are building a single-file HTML **artifact** the user runs inside cowork. This\nkit gives you **headless view-models** that own a control\'s whole data lifecycle \u2014\nload/populate from a Leadbay call, hold value/state, poll, validate, and\nencapsulate the API call + business rules. **You own 100% of markup/layout/style.**\nThe library renders nothing. Inline the runtime once as a `<script>`; it exposes\none global `window.LeadbayArtifacts` (call it `lb`). Vanilla, no React, no build.\n\nPass every tool you use as the artifact\'s `mcp_tools` so the host permits it.\n\n## Two layers\n\n**Primitives** (generic):\n- `lb.field({ load, options, value, validate, dependsOn })` \u2014 a value + optionally\n API-populated options. `.value/.setValue/.options/.loading/.error/.valid/.subscribe`.\n- `lb.action({ tool, args, fields, confirm, onSuccess, onError })` \u2014 a write/submit.\n `.run()/.loading/.error/.lastResult/.subscribe`.\n- `lb.resource({ load, pollEvery?, until?, autoLoad? })` \u2014 one read that may change:\n load-on-click or poll-until-`until`. `.data/.loading/.refreshing/.error/.done/.load()/.refresh()/.stop()/.subscribe`.\n- `lb.list({ load, pageSize })` \u2014 paginated rows. `.items/.page/.total/.loading/.loadPage(n)/.next()/.prev()/.hasMore/.subscribe`.\n\n`.error` is `{ message, unavailable } | null`. `subscribe(cb)` fires immediately\nthen on every change \u2014 render your own DOM from it.\n\n**Domain components** (pre-wired \u2014 bake in the tool name, arg shape, and footguns):\n\n| Call | Returns | For |\n|---|---|---|\n| `lb.campaigns(ask)` | field | a campaign `<select>`, options from `leadbay_list_campaigns` |\n| `lb.outreach({leadId, ask, status?, note?})` | action | log a call \u2192 `report_outreach` (verification + `_triggered_by` baked in) |\n| `lb.note({leadId, note})` | action | add a note \u2192 `add_note` |\n| `lb.like(leadId)` / `lb.dislike(leadId)` | action | taste signal |\n| `lb.leadStatus(current?)` | field | a status `<select>` (Wanted/Won/Lost/Unwanted) |\n| `lb.setStatus({leadId or leadIds, status, date?, ask})` | action | write the org CRM status \u2192 `set_lead_status` |\n| `lb.leadHistory(leadId, ask)` | resource (lazy) | notes + activities + engagement \u2192 `account_history` |\n| `lb.leadProfile(leadId, ask)` | resource (lazy) | full lead profile \u2192 `research_lead_by_id` |\n| `lb.sortOrder(current?)` | field | a sort `<select>` mirroring the app\'s TableSort |\n| `lb.leadList({lensId?, order?, ask})` | list | a sortable Discover batch \u2192 `pull_leads` |\n| `lb.callList({source:\'followups\'\\|\'campaign\', campaignId?, city?, ask})` | list | a cold-call list (Monitor or a campaign) |\n| `lb.enrichment({leadIds, titles, ask, pollEvery?})` | resource (polling) | launch + watch contact enrichment |\n| `lb.teamActivity({weeks, ask})` | resource | manager leaderboard + activity trend \u2192 `leadbay_team_activity` |\n\n`lb.EPILOGUE_STATUSES` = the 4 disposition values\n(`STILL_CHASING`, `COULD_NOT_REACH_STILL_TRYING`, `INTEREST_VALIDATED_OR_MEETING_PLANED`, `NOT_INTERESTED_LOST`).\n`lb.LEAD_STATUSES` = the 4 org CRM statuses as `{value,label}` (`WANTED`, `WON`, `LOST`, `UNWANTED`).\n`lb.SORT_ORDERS` = the sort options as `{value,label}`; values are the backend `FIELD:ASC|DESC` enum.\n\n**Sorting is a SERVER concern.** `lb.leadList` and `lb.callList` take an `order`\n(a `lb.sortOrder()` field or a literal) and send it upstream; the backend sorts\nthe whole lens / Monitor and returns the requested page of that. Never re-sort\nrows in the browser \u2014 you would be reordering one page of a larger set, showing\nleads that do not belong at that position. The empty value means "no order\nparam", i.e. the tab\'s own ranking, which is the right default. Changing the\nsort should reset to page 0. Campaign call sheets cannot sort:\n`leadbay_campaign_call_sheet` has no `order` param, and `lb.callList` drops it\nfor that source rather than sending something the tool would reject.\n\n**Two different systems.** Epilogue = how one outreach attempt went (drives\nfollow-up ranking). Lead status = the commercial outcome, org-wide \u2014 the same\nfield the website\'s status selector writes. A won deal is a LEAD STATUS;\n"she didn\'t pick up" is an EPILOGUE. Setting one never sets the other, so when\nthe user reports both in one breath, fire both actions.\n\n**Binding sugar** (optional; binds a view-model to YOUR native element, no style):\n`lb.bindSelect(selectEl, field)` (populates options + value), `lb.bindValue(inputEl, field)`,\n`lb.bindAction(buttonEl, action)`. They set `data-lb-state`\n(`ready|loading|error|success|unavailable`) + `data-lb-error` on your element as\nstyling hooks. For lists/resources, use `.subscribe()` and render yourself.\n\n`ask` is the user\'s request this artifact serves \u2014 it becomes `_triggered_by`.\n\n## The skin (optional) \u2014 `lb.styles()`\n\nCall it once and you get a small `lb-*` stylesheet, so every artifact you build\nshares one visual language instead of re-inventing padding and colours. It is\n**opt-in**: skip it and you get exactly the unstyled HTML you wrote. It injects\nno markup and never touches your `class` attributes.\n\n```js\nlb.styles(); // idempotent \u2014 safe to call per row\n```\n\n| Class | For |\n|---|---|\n| `lb-card` / `lb-card-head` / `lb-title` / `lb-sub` | a lead card + its header |\n| `lb-row` / `lb-stack` / `lb-spacer` | control row / vertical spacing / flex filler that right-aligns what follows |\n| `lb-link-out` | quiet external link (icon inherits currentColor) \u2014 "Open in Leadbay" |\n| `lb-select` / `lb-input` / `lb-btn` | form controls (state-aware, see below) |\n| `lb-msg` (`data-tone="error\\|ok"`) | inline feedback |\n| `lb-chip` (`data-status="WON\\|LOST"`) | a status pill |\n| `lb-table` | leads table |\n| `lb-spinner` | inline busy indicator |\n\nControls react to the `data-lb-state` the bind helpers already set \u2014 a bound\n`lb-btn` dims while loading, goes green on success, red on error, all with no\nextra CSS from you.\n\nThe palette is the **product design system**, ported from\n`frontend/packages/style/color.css` \u2014 same `--color-gray-1\u20269` ramp, same\nsemantic `--color-{green,red,blue,gold}-{background,foreground}` pairs, same\n`1rem` / `0.625rem` radii and `corner-shape: squircle` as the app\'s components.\nAn artifact therefore looks like Leadbay, not like a generic page.\n\nUse the tokens rather than hardcoded colours \u2014 the same rule the style package\nenforces. Re-theme by overriding them; don\'t fight specificity:\n\n```css\n:root { --lb-surface: var(--color-gray-2); --lb-radius: 0.5rem; }\n```\n\nDark mode works two ways: `data-theme="dark"` on `<html>` (the frontend\'s own\nhook) **and** `prefers-color-scheme`, because an artifact renders inside a host\nwhose theme attribute it cannot set. Never hardcode a light background over the\nskin.\n\nThe product face is `Nikkei Maru`; the stack names it first and falls back to\nthe system UI font. Do **not** add an `@font-face` \u2014 artifacts are inline-only\nand a remote font URL will silently fail.\n\n## What every lead card MUST carry\n\nA card is the artifact form of the `pull_leads` table, and it inherits that\ntable\'s rules. A card with a name and a button is not enough: the rep cannot\ntell *why* this lead is on screen. Four lines, in this order.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title"></span> <!-- 1. company -->\n <span class="lb-chips"> <!-- 2. state -->\n <span class="lb-chip" data-taste hidden></span>\n <span class="lb-chip" data-status hidden></span>\n </span>\n </div>\n <div class="lb-sub"></div> <!-- 3. firmographics -->\n <div class="lb-sub" data-why></div> <!-- 4. why it fits -->\n <div class="lb-row"><!-- actions --></div>\n</div>\n```\n\n1. **Company** \u2014 `name`, linked to `website` (prefix `https://` on a bare host).\n Never render the numeric `score`; use the `\u25B0\u2756\u25B1` bar if you want the signal.\n\n Also give every card an **Open in Leadbay** link to the lead\'s panel in the\n product. Put it at the **right-hand end of the card\'s last action row** \u2014\n same row as the buttons, pushed right by an `lb-spacer`, not on a line of\n its own. Style it `lb-link-out`: quiet text plus a plain arrow-up-right,\n never a filled button. It is an escape hatch, not a call to action.\n\n ```html\n <div class="lb-row">\n <button class="lb-btn">Like</button>\n <button class="lb-btn">Set status</button>\n <span class="lb-spacer"></span> <!-- pushes the link right -->\n <a class="lb-link-out" data-k="open" target="_blank" rel="noopener">\n Open in Leadbay\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/>\n </svg>\n </a>\n </div>\n ```\n\n Keep the arrow a bare diagonal stroke \u2014 the text already says where the link\n goes, so the glyph only has to mark "leaves this page". Mark the `<svg>`\n `aria-hidden="true"`: it is decorative, and the link text is the accessible\n name.\n **Pick the view the lead actually lives in** \u2014 the URL is\n `/app/<view>?lead=<uuid>`, and the three views are `discover`, `monitor`,\n `campaign`. Landing a Monitor lead on Discover drops the rep into a list\n that does not contain it:\n\n ```js\n function leadUrl(lead, campaignId) {\n const id = encodeURIComponent(lead.id);\n // A campaign card carries TWO params \u2014 the campaign selects the list, the\n // lead opens the panel inside it. Campaign wins even when in_monitor is\n // also true, because that is the list the rep is looking at.\n if (campaignId) {\n return `https://leadbay.app/app/campaign?campaign=${encodeURIComponent(campaignId)}&lead=${id}`;\n }\n const view = lead.in_monitor ? "monitor" : "discover";\n return `https://leadbay.app/app/${view}?lead=${id}`;\n }\n openEl.href = leadUrl(lead, campaignId);\n ```\n\n `in_monitor` / `in_discover` are booleans on the `pull_followups` payload \u2014\n every follow-up carries `in_monitor: true`, so a call sheet must link to\n `monitor`. `pull_leads` omits both flags entirely; its leads are the Discover\n batch by definition, so `discover` is the default. A campaign card\n (`lb.callList({source:"campaign", campaignId})`) needs `?campaign=<id>&lead=<id>`\n \u2014 the param names are `CAMPAIGN_QUERY_PARAM` and `LEAD_QUERY_PARAM`, and the\n app\'s own `useLeadPanel` preserves whatever params are already set, so the\n two coexist by design. Omitting `campaign=` opens an empty campaign view.\n\n Inline the glyph as SVG rather than an emoji or `\u2197` \u2014 it inherits\n `currentColor` and scales with the text, so it stays legible in both themes.\n `?lead=<uuid>` is the real deep-link (`LEAD_QUERY_PARAM` in the web app, read\n on load; the panel is an overlay, so the view choice only decides what sits\n behind it). This is the ONE place a card may use `lead.id`: as a link target,\n never as visible text.\n2. **State chips** \u2014 taste (`data-taste`) and CRM status (`data-status`) are\n INDEPENDENT axes; render both, hide the empty one. Never collapse to one chip.\n3. **Firmographics** \u2014 sector of activity first, then city, then size, then the\n contact. `sector_id` is a RAW ID (`"5136"`), not a label: resolve it via\n `leadbay_list_sectors` (1346 rows \u2014 fetch once, cache, never inline the lot)\n or omit it. Never print the raw id.\n\n **Always show whether the lead is reachable \u2014 and never merge the person\n with the company\'s switchboard.** These are two separate lines:\n\n ```js\n // WHO \u2014 recommended_contact. Name, and job_title ONLY when present; on list\n // payloads it is usually null, and inventing one is worse than omitting it.\n const rc = lead.recommended_contact;\n const who = rc ? [rc.first_name, rc.last_name].filter(Boolean).join(" ") : null;\n const whoLine = who ? who + (rc.job_title ? " \xB7 " + rc.job_title : "") : "No named contact";\n\n // HOW \u2014 company-level channels. `phone_numbers` and `email` belong to the\n // COMPANY, not to `recommended_contact`. Rendering "Jean \xB7 \u260E 0123\u2026" claims a\n // direct line that does not exist; it is the switchboard.\n const phone = (lead.phone_numbers || [])[0] || null;\n // The API returns the STRING "null" for a missing email \u2014 guard for it or\n // you will print the word "null" as an address.\n const email = lead.email && lead.email !== "null" ? lead.email : null;\n const howLine = [phone && "\u260E " + phone, email && "\u2709 " + email].filter(Boolean)\n .join(" \xB7 ") || "No direct channel \u2014 enrich to reveal";\n ```\n\n ```html\n <div class="lb-sub">Sector \xB7 City \xB7 Size</div>\n <div class="lb-sub">\u{1F464} Jean-Fran\xE7ois Froemer \xB7 G\xE9rant</div> <!-- WHO -->\n <div class="lb-sub">\u{1F3E2} \u260E 01 23 45 67 89</div> <!-- HOW: company -->\n ```\n\n Label the channel line as the **company\'s**, so a rep reading fast cannot\n mistake it for a direct line. A per-contact email or phone exists only after\n enrichment \u2014 `research_lead_by_id` exposes it as `contacts.reachable[]`, and\n `_meta.has_reachable_contact` is the authoritative flag. The list payloads\n carry neither, so a card built from `pull_leads` / `pull_followups` can only\n ever show company channels. Say "enrich to reveal" rather than implying the\n contact is callable.\n\n Two things that look like reachability and are not: a `linkedin_page` alone\n (the rep cannot message a URL without leaving the artifact \u2014 same rule\n `research_lead_by_id` applies), and `contacts_count > 0` (it counts known\n people, not people you can contact; a lead can show 2518 contacts and zero\n channels). `pull_followups` carries `has_phone` as a ready-made boolean;\n `pull_leads` omits it, so derive from `phone_numbers` there.\n4. **Why it fits** \u2014 one sentence, \u226420 words. Walk this chain and stop at the\n first hit:\n\n 1. `short_description`\n 2. `description` (longer; only on `research_lead_by_id` /\n `research_lead_by_name_fuzzy` \u2014 the trim payloads omit it)\n 3. top 2 `tags[].display_name`\n 4. `qualification_summary.best_response_excerpt`, trimmed to one sentence\n 5. `keywords`, first 3, joined with ` \xB7 `\n 6. the resolved sector label \u2014 better than nothing, and if step 3 already\n printed the sector on the firmographics line, skip to step 7\n 7. the literal *"No description yet \u2014 run qualification to generate one"*\n\n Never leave this line blank: a silent gap reads as a rendering bug, whereas\n the fallback tells the rep the data is missing and what fixes it.\n\n **The two list payloads are complementary, so the chain must span both.**\n `pull_leads` returns `short_description` on every lead but no `sector_id`;\n `pull_followups` returns `sector_id` but no `short_description` at all. A\n card fed by one will fall through to a different step than the same card fed\n by the other \u2014 that is expected, not a bug. Never call\n `research_lead_by_id` per row just to fill this line: it is one request per\n lead. Fetch it lazily when the rep expands a card.\n\n**Never show** on a card: `id`, `sector_id`, `location.pos`, `location.country`\n(unless city and state are both missing), `is_hq`, `*_in_progress`,\n`highlighted_fields`, `custom_fields`, `stale_at`, `deal_insights`,\n`need_attention*`, any count that is 0, any value that is the string `"null"`.\n\n**Minimum actions.** A card that only displays is a table row that costs more \u2014\nif you are not wiring an action, render the markdown table instead. Wire at\nleast one write, and prefer the set the rep actually needs:\n\n| Card is for | Wire |\n|---|---|\n| triage a discovery batch | `lb.like` / `lb.dislike` + `lb.setStatus` |\n| working a call list | `lb.outreach` (gated on a note) + `lb.leadHistory` |\n| pipeline review | `lb.setStatus` + `lb.note` |\n\nAlways render the `.error` branch of every view-model \u2014 a control that cannot\nreach the host must say so, not sit silent.\n\n## Recipe: cold-call sheet (one row per lead)\n\n```js\nconst lb = window.LeadbayArtifacts; lb.configure();\nconst ASK = "<the user\'s request>";\n\nconst list = lb.callList({ source: "campaign", campaignId: CID, ask: ASK });\nlist.subscribe((l) => renderRows(l.items, l.loading)); // your render\n\n// per lead row (call when you build a row):\nfunction wireRow(lead, els) {\n const status = lb.field({ value: "STILL_CHASING" }); // static-enum <select>\n const note = lb.field({ validate: (v) => (v && v.trim() ? null : "Add a note") });\n lb.bindValue(els.status, status);\n lb.bindValue(els.note, note);\n lb.bindAction(els.log, lb.outreach({ leadId: lead.id, ask: ASK, status, note }));\n lb.bindAction(els.like, lb.like(lead.id));\n\n const history = lb.leadHistory(lead.id, ASK); // lazy\n history.subscribe((h) => renderHistory(els.history, h));\n els.expand.onclick = () => history.load(); // load on click\n}\n```\n\n## Recipe: lead-status dropdown (Wanted / Won / Lost)\n\nThe org-wide CRM status, as a `<select>` + Apply button. You write the markup;\n`lb.leadStatus` fills the options and holds the value, `lb.setStatus` does the write.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title">Acme Corp</span>\n <span class="lb-chips">\n <span id="taste" class="lb-chip" data-taste="liked">Liked</span>\n <span id="crm" class="lb-chip" data-status="WANTED">Wanted</span>\n </span>\n </div>\n <div class="lb-row">\n <select id="st" class="lb-select"></select>\n <button id="go" class="lb-btn">Apply</button>\n <span id="msg" class="lb-msg"></span>\n </div>\n</div>\n```\n\n**Two badges, never one.** Taste (`liked`/`disliked`, from `lb.like`/`lb.dislike`)\nand CRM status (`WANTED`/`WON`/`LOST`/`UNWANTED`, from `lb.setStatus`) are\nindependent axes \u2014 a lead can be liked *and* lost. Collapsing them into a single\nchip destroys information: the rep can no longer see that a lead they liked went\nnowhere. Render `data-taste` and `data-status` as separate chips inside\n`lb-chips`, and hide the one that has no value rather than reusing it.\n\n```js\nlb.styles(); // once per artifact \u2014 see below\n\nconst status = lb.leadStatus(lead.org_lead_status); // seed with the CURRENT value\nconst save = lb.setStatus({ leadId: lead.id, status, ask: ASK });\n\nlb.bindSelect(document.getElementById("st"), status); // populates the 4 options\nlb.bindAction(document.getElementById("go"), save); // click \u2192 write\n\nsave.subscribe((a) => { // render your own feedback\n msg.textContent = a.loading ? "Saving\u2026"\n : a.error ? a.error.message // includes partial failures\n : a.lastResult ? `Set to ${a.lastResult.status}` : "";\n msg.dataset.tone = a.error ? "error" : a.lastResult ? "ok" : "";\n});\n```\n\nLoading / success / error styling comes free: `bindAction` and `bindSelect` set\n`data-lb-state` (`ready|loading|error|success|unavailable`) and the skin already\ntargets those attributes. No extra wiring.\n\nSave-on-change instead of an Apply button \u2014 drop `bindAction` and run it yourself:\n\n```js\ndocument.getElementById("st").addEventListener("change", () => save.run());\n```\n\n**Bulk apply** across checked rows \u2014 pass `leadIds` and a `confirm`, since one\nclick rewrites a field every rep in the org sees:\n\n```js\nconst bulk = lb.setStatus({\n leadIds: () => checkedIds, // \u2190 read at run() time, not at build time\n status, ask: ASK,\n confirm: "Set this status on every selected lead?",\n});\n```\n\n`leadIds` is read when the action runs, so a live selection works \u2014 but pass the\narray itself if your selection is fixed. A partial write (some leads rejected)\nsurfaces as `.error`, never as a green button: `setStatus` checks the `failed[]`\nthe tool returns.\n\nThe backend stamps the status date as "now" on every write, which is what a rep\nclicking a dropdown means. Don\'t add a date picker unless the user asks to\nbackdate \u2014 then pass an optional `date` field holding `YYYY-MM-DD`:\n`lb.setStatus({ leadId, status, date, ask })`.\n\n## Recipe: manager dashboard\n\n```js\nconst team = lb.teamActivity({ weeks: 4, ask: ASK });\nteam.subscribe((t) => {\n if (t.loading) showSpinner();\n if (t.data) {\n renderLeaderboard(t.data.reps); // sorted by total_activities; cols: name, notes, meetings_or_interest, lost\u2026\n renderTrendChart(t.data.trend); // [{date,count}] \u2192 Chart.js (allowed from CDN)\n }\n});\nrefreshBtn.onclick = () => team.refresh();\n```\n\n## Recipe: live enrichment\n\n```js\nconst job = lb.enrichment({ leadIds: [LEAD], titles: ["CEO", "VP Sales"], ask: ASK });\njob.subscribe((j) => {\n const p = j.data && j.data.overall_progress; // {done,total,done_ratio}\n renderBar(p);\n if (j.done) renderContacts(j.data.leads); // enriched contacts\n});\nrefreshBtn.onclick = () => job.refresh();\n```\n\n## Write-call rules\n\nThe domain factories handle these for you. If you hand-roll an action:\n`leadbay_report_outreach` args MUST include `verification:{source:"user_confirmed", ref}`\nAND `_triggered_by`; `leadbay_add_leads_to_campaign` needs `_triggered_by`;\n`add_note`/`like_lead`/`dislike_lead` take only their own args. `epilogue_status` is\none of `lb.EPILOGUE_STATUSES`. Snoozing (pushback) is advanced-gated \u2014 not\ncallable from a default artifact. Org lead status IS on the default surface:\nuse `lb.setStatus`, which owns the arg shape AND the partial-write check \u2014\n`leadbay_set_lead_status` writes each lead separately, so it can resolve 200\nwith a non-empty `failed[]`. Hand-rolling that action will report a green\nbutton over a write that never landed.\n\n## Degradation + live updates\n\nIf the host bridge is absent, a view-model\'s `.error` is set with `.error.unavailable\n=== true` (bind helpers set `data-lb-state="unavailable"`) \u2014 nothing throws. Every\ncall also has a **30s timeout** (configurable via `lb.configure({ timeoutMs })`): a\nhost call that never settles becomes `.error` with `code:"timeout"`, so a control is\nnever stuck loading forever \u2014 always render the `.error` branch so the user can retry.\nAuto-poll (`pollEvery`) depends on the cowork host serving FRESH reads; `.refresh()`\nis the guaranteed manual path \u2014 always wire a Refresh control for polling resources.';
|
|
24460
|
+
ARTIFACT_RUNTIME = '"use strict";(()=>{var E=Object.defineProperty;var A=(e,t,r)=>t in e?E(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var a=(e,t,r)=>A(e,typeof t!="symbol"?t+"":t,r);var S=`\n:root{\n--color-black:#191919;--color-white:#fff;\n--color-gray-1:#f9f9f9;--color-gray-2:#f0f0f0;--color-gray-3:#e0e0e0;--color-gray-4:#cecece;\n--color-gray-5:#c4c4c4;--color-gray-6:#8d8d8d;--color-gray-7:#787878;--color-gray-8:#646464;\n--color-gray-9:#202020;\n--color-linkedin:#0a66c2;\n--color-blue-background:oklch(0.947 0.029 251);--color-blue-foreground:oklch(0.564 0.181 251);\n--color-green-background:oklch(0.947 0.029 141);--color-green-foreground:oklch(0.564 0.181 141);\n--color-red-background:oklch(0.947 0.029 26);--color-red-foreground:oklch(0.564 0.191 26);\n--color-gold-background:oklch(0.972 0.049 91);--color-gold-foreground:oklch(0.667 0.177 91);\n--color-cherry-background:oklch(0.947 0.029 15);--color-cherry-foreground:oklch(0.44 0.146 15);\n--color-red-like:var(--color-cherry-foreground);\n--lb-font:"Nikkei Maru",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;\n/* Concentric: outer = inner + padding (0.625 + 0.875 = 1.5rem). At 1rem the\n inner control\'s arc outran the card\'s in the corner. */\n--lb-radius:1.5rem;--lb-radius-sm:0.625rem;--lb-gap:0.75rem;\n--lb-surface:var(--color-gray-1);--lb-border:var(--color-gray-3);\n--lb-fg:var(--color-black);--lb-muted:var(--color-gray-8);--lb-field:var(--color-white);\n/* Chips and shadows need their own aliases: a chip painted with a raw ramp\n value cannot theme, and a shadow tinted with --color-gray-9 is invisible in\n dark because gray-9 IS the dark surface. */\n--lb-chip-bg:var(--color-gray-2);\n--lb-control-border:var(--color-gray-6);\n--lb-shadow:0 0.5rem 1.5rem oklch(0 0 0/.06),0 0.125rem 0.5rem oklch(0 0 0/.04);\ncolor-scheme:light;\n}\n:root[data-theme=dark],:root[data-lb-theme=dark]{\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);\n/* --lb-field must NOT equal --lb-surface: a control needs its own ground or it\n reads as text on the card (was 1.00:1). */\n--lb-field:#2c2c2c;\n--lb-chip-bg:#3b3b3b;\n--lb-control-border:var(--color-gray-6);\n--lb-shadow:0 0.5rem 1.5rem oklch(0 0 0/.5),0 0.125rem 0.5rem oklch(0 0 0/.35);\ncolor-scheme:dark;\n--color-blue-background:oklch(0.30 0.055 251);--color-blue-foreground:oklch(0.80 0.11 251);\n--color-green-background:oklch(0.30 0.055 141);--color-green-foreground:oklch(0.80 0.13 141);\n--color-red-background:oklch(0.30 0.060 26);--color-red-foreground:oklch(0.80 0.11 26);\n--color-gold-background:oklch(0.30 0.055 91);--color-gold-foreground:oklch(0.82 0.13 91);\n--color-cherry-background:oklch(0.30 0.055 15);--color-cherry-foreground:oklch(0.82 0.10 15);\n}\n@media(prefers-color-scheme:dark){:root:not([data-theme=light]):not([data-lb-theme=light]){\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);\n/* --lb-field must NOT equal --lb-surface: a control needs its own ground or it\n reads as text on the card (was 1.00:1). */\n--lb-field:#2c2c2c;\n--lb-chip-bg:#3b3b3b;\n--lb-control-border:var(--color-gray-6);\n--lb-shadow:0 0.5rem 1.5rem oklch(0 0 0/.5),0 0.125rem 0.5rem oklch(0 0 0/.35);\ncolor-scheme:dark;\n--color-blue-background:oklch(0.30 0.055 251);--color-blue-foreground:oklch(0.80 0.11 251);\n--color-green-background:oklch(0.30 0.055 141);--color-green-foreground:oklch(0.80 0.13 141);\n--color-red-background:oklch(0.30 0.060 26);--color-red-foreground:oklch(0.80 0.11 26);\n--color-gold-background:oklch(0.30 0.055 91);--color-gold-foreground:oklch(0.82 0.13 91);\n--color-cherry-background:oklch(0.30 0.055 15);--color-cherry-foreground:oklch(0.82 0.10 15);\n}}\n.lb-card{display:grid;gap:var(--lb-gap);padding:0.875rem;\nbackground-color:var(--lb-surface);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius);corner-shape:squircle;color:var(--lb-fg);\nfont-family:var(--lb-font);\nbox-shadow:var(--lb-shadow)}\n/* The head must be able to wrap. Both children default to flex:0 1 auto, so\n without this they shrink together and a long company name is crushed to three\n lines while the chips keep full width. flex-basis:11rem is a content breakpoint:\n below ~176px for the title, the chips drop to their own line instead. */\n.lb-card-head{display:flex;flex-wrap:wrap;justify-content:space-between;\nalign-items:baseline;gap:var(--lb-gap)}\n/* An email or URL has no break opportunity \\u2014 browsers do not break at "@" or\n ".", so without this a company address escapes the card and the chat host\n clips it with no way to recover the tail. */\n.lb-title{font-size:0.875rem;font-weight:600;line-height:1.3;color:var(--lb-fg);\noverflow-wrap:break-word;min-width:0;flex:1 1 11rem}\n.lb-sub{font-size:0.8125rem;line-height:1.45;color:var(--lb-muted);\noverflow-wrap:break-word;max-width:68ch}\n/* An empty fact line is a 30px void that reads as a rendering bug. */\n.lb-sub:empty{display:none}\n/* Every card child shared one 12px gap, so nothing grouped: the switchboard\n number, the contact and the fit judgement read as one paragraph. 4px inside\n the facts vs 12px to the why line is a 3x ratio \\u2014 grouping by space, with no\n rule or border. */\n.lb-facts{display:grid;gap:0.25rem}\n/* The contract\'s canonical skeleton already emits data-why; the sheet just did\n not honour it, so consumers hand-patched an inline font-style. */\n.lb-sub[data-why]{font-style:italic;color:var(--lb-fg)}\n/* Icon column: the glyph gets its own cell so a wrapping line hangs on one\n shared text edge instead of running back under the emoji. */\n.lb-fact{display:grid;grid-template-columns:1.15rem 1fr;gap:0.35rem;align-items:baseline}\n.lb-row{display:flex;align-items:center;gap:var(--lb-gap);flex-wrap:wrap}\n/* row-gap matters once the row wraps: without it the wrapped line sits flush\n against the one above. */\n.lb-row{row-gap:0.5rem}\n.lb-stack{display:grid;gap:var(--lb-gap)}\n.lb-select,.lb-input{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;color:var(--lb-fg);\nbackground-color:var(--lb-field);border:1px solid var(--lb-control-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.55rem;min-height:2.125rem}\n.lb-btn{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;font-weight:600;\ncolor:var(--lb-fg);background-color:var(--lb-field);border:1px solid var(--lb-control-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.85rem;min-height:2.125rem;\ncursor:pointer;transition:background-color .15s,border-color .15s,color .15s}\n.lb-btn:hover:not([disabled]){border-color:var(--color-gray-7)}\n/* A SUBMIT is not a toggle. Like/Dislike are independent switches; "Set status"\n commits the value sitting in the select beside it. Rendered identically, the\n row reads as five peers and the rep cannot see which control ends the task.\n The accent border + tinted ground marks it as the commit without making it a\n loud filled button \\u2014 the state styles above still win when they apply. */\n/* INVERTED, not recoloured. A normal button is --lb-fg on --lb-field; the\n submit swaps them. That introduces no new hue, and it themes for free: light\n gives near-black on white, dark gives white on near-black \\u2014 the polarity flip\n reads as "this one commits" in both, without competing with the semantic\n state colours (success green / error red) that still override it. */\n.lb-btn-submit{background-color:var(--lb-fg);color:var(--lb-field);\nborder-color:var(--lb-fg)}\n.lb-btn-submit:hover:not([disabled]){\nbackground-color:color-mix(in srgb,var(--lb-fg) 85%,var(--lb-field));\nborder-color:var(--lb-fg)}\n/* Toggles that act on the same axis belong in one visual unit, so the row reads\n as "taste | status | inspect" rather than five equal buttons. The gap inside\n a group is half the gap between groups \\u2014 the 2x rule, applied horizontally. */\n.lb-group{display:inline-flex;align-items:center;gap:0.375rem;flex-wrap:wrap}\n/* Square icon button. Only for actions whose glyph is unambiguous on its own \\u2014\n thumbs up/down qualify, "Set status" does not. It MUST carry an aria-label\n and a title: with no text the icon is the entire affordance, so an unlabelled\n one is unreadable to a screen reader and to anyone who does not know the\n glyph. Same 2.125rem box as a text button, so it keeps the row\'s baseline. */\n.lb-btn-icon{padding:0;width:2.125rem;justify-content:center;display:inline-flex;\nalign-items:center}\n.lb-btn-icon svg{width:1.05rem;height:1.05rem;flex-shrink:0}\n/* Pressed state. The taste chip carries the durable fact; this is the control\n reflecting it, and it must not rely on colour alone \\u2014 aria-pressed is the\n accessible half and the fill is the visible half. */\n/* A heart that stays hollow does not read as "liked" \\u2014 fill it from the same\n currentColor the stroke uses, so one rule covers both themes. */\n.lb-btn-icon[aria-pressed=true] svg{fill:currentColor}\n.lb-btn-icon[aria-pressed=true]{border-color:var(--color-red-like);\ncolor:var(--color-red-like);\nbackground-color:color-mix(in srgb,var(--color-red-like) 10%,var(--lb-field))}\n.lb-btn-icon[data-taste=disliked][aria-pressed=true]{border-color:var(--lb-muted);\ncolor:var(--lb-muted);\nbackground-color:color-mix(in srgb,var(--lb-muted) 10%,var(--lb-field))}\n/* A glyph-only control has no text to widen its hit area, so extend the target\n past the 34px box without inflating the row. */\n.lb-btn-icon{position:relative}\n.lb-btn-icon::before{content:"";position:absolute;inset:-5px}\n.lb-btn:focus-visible,.lb-select:focus-visible,.lb-input:focus-visible,\n.lb-link:focus-visible{\noutline:2px solid var(--color-blue-foreground);outline-offset:1px;\nborder-radius:var(--lb-radius-sm)}\n/* Forced colours replaces every background and custom outline with a system\n colour, collapsing the state styling above into one flat control. */\n@media(forced-colors:active){\n.lb-btn,.lb-select,.lb-input,.lb-chip{border:1px solid CanvasText}\n.lb-btn:focus-visible,.lb-select:focus-visible,.lb-input:focus-visible,\n.lb-link:focus-visible,.lb-link-out:focus-visible{outline:2px solid Highlight}\n.lb-link-out{opacity:1}}\n/* Each state carries a glyph as well as a hue: success and error were two\n pills with identical labels, indistinguishable to a red-green colour-blind\n reader and identical under forced colours. */\n.lb-btn[data-lb-state=loading]{opacity:.55;cursor:progress}\n.lb-btn[data-lb-state=loading]::after{content:"\\u2026";margin-inline-start:.35rem}\n/* An icon button has no room for a trailing glyph \\u2014 it would push the icon\n off-centre. Its states read through border and fill, which the forced-colors\n block below keeps distinguishable. */\n.lb-btn-icon::after{content:none!important}\n.lb-btn[data-lb-state=success]::after{content:"\\u2713";margin-inline-start:.35rem}\n.lb-btn[data-lb-state=error]::after{content:"\\u2715";margin-inline-start:.35rem}\n.lb-btn[data-lb-state=unavailable]::after{content:"\\u26A0";margin-inline-start:.35rem}\n/* With save-on-change there is no button to reflect the write, so the select\n itself must. Only error was styled before; loading and success were invisible\n on a select, leaving the rep unsure whether the pick had landed. */\n.lb-select[data-lb-state=loading]{opacity:.55;cursor:progress}\n.lb-select[data-lb-state=success]{border-color:var(--color-green-foreground)}\n.lb-msg[data-tone=error]::before{content:"\\u2715 "}\n.lb-msg[data-tone=ok]::before{content:"\\u2713 "}\n.lb-btn[data-lb-state=success]{background-color:var(--color-green-background);\nborder-color:var(--color-green-foreground);color:var(--color-green-foreground)}\n.lb-btn[data-lb-state=error],.lb-select[data-lb-state=error]{\nbackground-color:var(--color-red-background);border-color:var(--color-red-foreground);\ncolor:var(--color-red-foreground)}\n.lb-btn[data-lb-state=unavailable],.lb-btn[disabled]{opacity:.5;cursor:not-allowed}\n.lb-msg{font-size:0.8125rem;line-height:1.45;color:var(--lb-muted)}\n/* Screen-reader-only text: control labels, unit suffixes, "opens in a new tab". */\n.lb-vh{position:absolute;width:1px;height:1px;margin:-1px;padding:0;\noverflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}\n.lb-msg[data-tone=error]{color:var(--color-red-foreground)}\n.lb-msg[data-tone=ok]{color:var(--color-green-foreground)}\n.lb-chip{display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap;\nfont-size:0.75rem;font-weight:600;line-height:1rem;padding:0.125rem 0.5rem;\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;\nbackground-color:var(--lb-chip-bg);color:var(--lb-muted)}\n.lb-chip[data-status=WANTED]{background-color:var(--color-blue-background);color:var(--color-blue-foreground)}\n.lb-chip[data-status=WON]{background-color:var(--color-green-background);color:var(--color-green-foreground)}\n.lb-chip[data-status=LOST]{background-color:var(--color-red-background);color:var(--color-red-foreground)}\n.lb-chip[data-status=UNWANTED]{background-color:var(--lb-chip-bg);color:var(--lb-muted)}\n.lb-chip[data-taste=liked]{background-color:var(--color-cherry-background);color:var(--color-red-like)}\n.lb-chip[data-taste=disliked]{background-color:var(--lb-chip-bg);color:var(--lb-muted)}\n.lb-chips{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}\n.lb-chip[hidden]{display:none}\n.lb-table{width:100%;border-collapse:collapse;font-family:var(--lb-font);color:var(--lb-fg)}\n.lb-table th,.lb-table td{text-align:start;padding:0.5rem 0.4rem;\nborder-bottom:1px solid var(--lb-border);vertical-align:middle;font-size:0.8125rem}\n.lb-table th{font-size:0.75rem;font-weight:600;color:var(--lb-muted);\ntext-transform:uppercase;letter-spacing:.04em}\n.lb-link{color:var(--color-blue-foreground);text-decoration:none}\n.lb-link:hover{text-decoration:underline}\n/* Quiet text link out of the artifact. Button-height so it shares the row\'s\n baseline; understated so it never competes with the actions beside it. The\n arrow is a bare diagonal stroke \\u2014 an escape-hatch marker, not an icon that\n asks to be read. */\n.lb-link-out{display:inline-flex;align-items:center;gap:.3rem;\nfont-size:0.75rem;line-height:1rem;min-height:2.125rem;\ncolor:var(--lb-fg);text-decoration:none;opacity:.65;transition:opacity .15s}\n.lb-link-out:hover{opacity:1;text-decoration:underline}\n.lb-link-out:focus-visible{outline:2px solid var(--color-blue-foreground);outline-offset:1px;\nborder-radius:var(--lb-radius-sm)}\n.lb-link-out svg{width:.85em;height:.85em;flex-shrink:0}\n/* Pushes whatever follows it to the right edge of an .lb-row, so a trailing\n link sits on the SAME baseline as the row\'s buttons instead of on its own\n line below them. */\n/* A spacer only aligns items on its OWN flex line. .lb-row wraps, so once the\n link falls to a second line the spacer stays behind and the link renders\n left-aligned \\u2014 the opposite of what the contract promises. The auto margin\n re-anchors it to the trailing edge of whichever line it lands on, and is a\n no-op while the row is unwrapped. */\n.lb-spacer{flex:1 1 auto;min-width:0}\n.lb-row>.lb-link-out{margin-inline-start:auto}\n.lb-spinner{display:inline-block;width:.7em;height:.7em;border:2px solid var(--lb-border);\nborder-top-color:var(--color-blue-foreground);border-radius:50%;animation:lb-spin .8s linear infinite}\n@keyframes lb-spin{to{transform:rotate(1turn)}}\n/* animation:none left a static ring \\u2014 under reduced motion "sending" and\n "cannot click" looked identical. An opacity pulse is the sanctioned\n substitute. The 150ms colour fade is fine and stays. */\n@media(prefers-reduced-motion:reduce){.lb-spinner{animation:lb-pulse 1.4s ease-in-out infinite}}\n@keyframes lb-pulse{50%{opacity:.35}}\n`,v="lb-styles";var L="0.5.0",u=class extends Error{constructor(r,o={}){super(r);a(this,"code");a(this,"raw");this.name="LbError",this.code=o.code,this.raw=o.raw}},k=null,h=3e4;function C(){let e=globalThis.cowork;return e&&typeof e.callMcpTool=="function"?(t,r)=>e.callMcpTool(t,r):null}function T(e){if(e&&typeof e=="object"&&"content"in e){let t=e.content;if(Array.isArray(t)&&t[0]&&typeof t[0].text=="string")return t[0].text}return null}function _(e){if(!e||typeof e!="object")return e;let t=e;if(t.isError)throw new u(T(e)??"tool call failed",{raw:e});if("structuredContent"in t&&t.structuredContent!=null)return t.structuredContent;let r=T(e);if(r!=null)try{return JSON.parse(r)}catch{return r}return e}function I(e){return e instanceof Error?e.message:String(e)}function p(e){let t=e instanceof u?e.code:void 0;return{message:I(e),unavailable:t==="unavailable",code:t}}function O(e={}){k=e.call??null,h=e.timeoutMs??3e4}function R(){if(typeof document>"u"||!document.head)return null;let e=document.getElementById(v);if(e)return e;let t=document.createElement("style");return t.id=v,t.textContent=S,document.head.appendChild(t),t}async function x(e,t){if(!h||h<=0)return e;let r,o=new Promise((n,i)=>{r=setTimeout(()=>i(new u(`"${t}" timed out after ${h}ms`,{code:"timeout"})),h)});try{return await Promise.race([e,o])}finally{r&&clearTimeout(r)}}async function l(e,t={}){if(k)return _(await x(Promise.resolve(k(e,t)),e));let r=C();if(!r)throw new u("Leadbay bridge unavailable (window.cowork absent)",{code:"unavailable"});return _(await x(Promise.resolve(r(e,t)),e))}var b=class{constructor(){a(this,"subs",new Set)}subscribe(t){return this.subs.add(t),t(this),()=>this.subs.delete(t)}emit(){for(let t of this.subs)t(this)}};function N(e){return Array.isArray(e)?e.map(t=>t&&typeof t=="object"?t:{value:t,label:String(t)}):[]}var g=class extends b{constructor(r={}){super();a(this,"kind");a(this,"value");a(this,"options",[]);a(this,"loading",!1);a(this,"error",null);a(this,"ready",!1);a(this,"cfg");a(this,"depUnsubs",[]);a(this,"seq",0);this.cfg=r,this.kind=r.kind,this.value=r.value??"";for(let o of r.dependsOn??[]){let n=o.value;this.depUnsubs.push(o.subscribe(()=>{o.value!==n&&(n=o.value,this.cfg.load&&this.load())}))}r.load&&(r.autoLoad??!0)&&this.load()}async load(){if(!this.cfg.load)return;let r=++this.seq;this.loading=!0,this.error=null,this.emit();try{let o=await this.cfg.load();if(r!==this.seq)return;this.options=this.cfg.options?this.cfg.options(o):N(o),this.ready=!0;let n=this.value==null?"":String(this.value);this.options.length&&(n===""||!this.options.some(i=>String(i.value)===n))&&(this.value=this.options[0].value)}catch(o){if(r!==this.seq)return;this.options=[],this.error=p(o)}finally{r===this.seq&&(this.loading=!1,this.emit())}}setValue(r){this.value=r;let o=this.validate();this.error=o?{message:o,unavailable:!1}:null,this.emit()}validate(){return this.cfg.validate?this.cfg.validate(this.value):null}get valid(){return this.validate()==null}reset(){this.value=this.cfg.value??"",this.error=null,this.emit()}dispose(){for(let r of this.depUnsubs)r();this.depUnsubs=[]}};function P(e){if(!e||typeof e!="object")return null;let t=e;if(t.error!==!0)return null;let r=typeof t.message=="string"&&t.message?t.message:"tool call failed",o=typeof t.hint=="string"&&t.hint?` \\u2014 ${t.hint}`:"";return`${r}${o}`}var s=class extends b{constructor(r){super();a(this,"loading",!1);a(this,"error",null);a(this,"lastResult",null);a(this,"cfg");this.cfg=r}async run(){if(this.loading)return;for(let n of this.cfg.fields??[]){let i=n.validate();if(i!=null){this.error={message:i,unavailable:!1},this.emit();return}}if(this.cfg.confirm&&typeof globalThis.confirm=="function"&&!globalThis.confirm(this.cfg.confirm))return;this.loading=!0,this.error=null,this.emit();let r;try{let n=typeof this.cfg.args=="function"?this.cfg.args():this.cfg.args??{};r=await l(this.cfg.tool,n)}catch(n){this.error=p(n),this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}let o=P(r)??this.cfg.checkResult?.(r)??null;if(o!=null){this.error={message:o,unavailable:!1},this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}return this.lastResult=r,this.loading=!1,this.emit(),this.cfg.onSuccess?.(r),r}reset(){this.error=null,this.lastResult=null,this.emit()}},d=class extends b{constructor(r){super();a(this,"data",null);a(this,"loading",!1);a(this,"refreshing",!1);a(this,"error",null);a(this,"done",!1);a(this,"cfg");a(this,"timer",null);a(this,"seq",0);this.cfg=r,(r.autoLoad??!0)&&this.load()}async load(){this.clearTimer();let r=++this.seq;this.data==null?this.loading=!0:this.refreshing=!0,this.error=null,this.emit();try{let n=await this.cfg.load();if(r!==this.seq)return;this.data=n,this.done=this.cfg.until?this.cfg.until(n):!0,this.cfg.pollEvery&&!this.done&&(this.timer=setTimeout(()=>void this.load(),this.cfg.pollEvery))}catch(n){if(r!==this.seq)return;this.error=p(n)}finally{r===this.seq&&(this.loading=!1,this.refreshing=!1,this.emit())}}refresh(){return this.load()}stop(){this.clearTimer()}clearTimer(){this.timer&&(clearTimeout(this.timer),this.timer=null)}},f=class extends b{constructor(r){super();a(this,"items",[]);a(this,"page",0);a(this,"pageSize");a(this,"total",0);a(this,"loading",!1);a(this,"error",null);a(this,"cfg");a(this,"seq",0);this.cfg=r,this.pageSize=r.pageSize??20,(r.autoLoad??!0)&&this.loadPage(0)}async loadPage(r){let o=++this.seq;this.loading=!0,this.error=null,this.emit();try{let n=await this.cfg.load({page:r,pageSize:this.pageSize});if(o!==this.seq)return;this.items=n.items??[],this.total=n.total??this.items.length,this.page=r}catch(n){if(o!==this.seq)return;this.error=p(n)}finally{o===this.seq&&(this.loading=!1,this.emit())}}next(){return this.loadPage(this.page+1)}prev(){return this.loadPage(Math.max(0,this.page-1))}get hasMore(){return(this.page+1)*this.pageSize<this.total}};function M(e,t){let r=t.error?.unavailable?"unavailable":t.loading?"loading":t.error?"error":"ready";e.setAttribute("data-lb-state",r),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error")}function D(e,t){let r=()=>t.setValue(e.value);e.addEventListener("change",r);let o=t.subscribe(()=>{M(e,t),e.disabled=t.loading,e.innerHTML="";for(let n of t.options){let i=document.createElement("option");i.value=String(n.value),i.textContent=n.label,e.appendChild(i)}e.value=t.value==null?"":String(t.value)});return()=>{e.removeEventListener("change",r),o()}}function z(e,t){let r=e.type==="checkbox",o=e.tagName==="SELECT"?"change":"input",n=()=>t.setValue(r?e.checked:e.value);e.addEventListener(o,n);let i=t.subscribe(()=>{if(r)e.checked=!!t.value;else{let c=t.value==null?"":String(t.value);e.value!==c&&(e.value=c)}e.setAttribute("data-lb-state",t.error?"error":"ready"),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener(o,n),i()}}function q(e,t){let r=i=>{i.preventDefault(),t.run()};e.addEventListener("click",r);let o,n=t.subscribe(()=>{let i=t.error?.unavailable?"unavailable":t.loading?"loading":t.error?"error":t.lastResult!=null?"success":"ready";e.setAttribute("data-lb-state",i),"disabled"in e&&(e.disabled=t.loading),e.setAttribute("aria-busy",String(t.loading)),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error"),o&&clearTimeout(o),i==="success"&&(o=setTimeout(()=>{!t.loading&&!t.error&&e.setAttribute("data-lb-state","ready")},1600))});return()=>{e.removeEventListener("click",r),n(),o&&clearTimeout(o)}}var F=["STILL_CHASING","COULD_NOT_REACH_STILL_TRYING","INTEREST_VALIDATED_OR_MEETING_PLANED","NOT_INTERESTED_LOST"],y=[{value:"",label:"Default ranking"},{value:"SCORE:DESC",label:"Score \\u2193"},{value:"SCORE:ASC",label:"Score \\u2191"},{value:"NAME:ASC",label:"Name A\\u2192Z"},{value:"NAME:DESC",label:"Name Z\\u2192A"},{value:"SIZE:DESC",label:"Size \\u2193"},{value:"SIZE:ASC",label:"Size \\u2191"},{value:"SECTOR:ASC",label:"Sector A\\u2192Z"},{value:"STATUS:ASC",label:"Status A\\u2192Z"},{value:"CONTACT_COUNT:DESC",label:"Contacts \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:DESC",label:"Last action \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:ASC",label:"Last action \\u2191"},{value:"EPILOGUE_STATUS_SET_AT:DESC",label:"Outcome set \\u2193"},{value:"LIKED:DESC",label:"Liked first"},{value:"DISLIKED:DESC",label:"Disliked first"}];function U(e){let t=String(e??"").trim().toUpperCase(),r=y.some(o=>o.value===t);return new g({kind:"select",value:r?t:"",load:async()=>y.slice()})}var m=[{value:"WANTED",label:"Wanted"},{value:"WON",label:"Won"},{value:"LOST",label:"Lost"},{value:"UNWANTED",label:"Unwanted"}],H={value:"",label:"Pick a status"};function j(e){let t=String(e??"").trim().toUpperCase(),r=m.some(o=>o.value===t);return new g({kind:"select",value:r?t:"",validate:o=>String(o??"")===""?"Pick a status":null,load:async()=>r?m.slice():[H,...m]})}function W(e){let t=()=>{let r=typeof e.leadIds=="function"?e.leadIds():e.leadIds;return Array.isArray(r)?r:e.leadId?[e.leadId]:[]};return new s({tool:"leadbay_set_lead_status",fields:e.date?[e.status,e.date]:[e.status],confirm:e.confirm,args:()=>({lead_ids:t(),status:e.status.value,...e.date&&e.date.value?{status_date:e.date.value}:{},...e.ask?{_triggered_by:e.ask}:{}}),checkResult:r=>{let o=r?.failed;if(!Array.isArray(o)||o.length===0)return null;let n=t().length,i=o[0]?.message??"write rejected";return o.length===n?`Status not applied: ${i}`:`${o.length} of ${n} leads failed: ${i}`}})}function V(e){return new g({kind:"select",load:()=>l("leadbay_list_campaigns",{_triggered_by:e}),options:t=>(t?.campaigns??[]).map(o=>{let n=o?.campaign??o;return n?.id?{value:n.id,label:n.name??n.ai_generated_name??String(n.id)}:null}).filter(o=>o!=null)})}function $(e){return new s({tool:"leadbay_report_outreach",fields:e.note?[e.note]:[],args:()=>({lead_id:e.leadId,...e.status?{epilogue_status:e.status.value}:{},note:e.note?e.note.value:"",verification:{source:"user_confirmed",ref:e.ref??"logged from artifact"},_triggered_by:e.ask})})}function G(e){return new s({tool:"leadbay_add_note",fields:[e.note],args:()=>({leadId:e.leadId,note:e.note.value})})}function B(e){return new s({tool:"leadbay_like_lead",args:{lead_id:e}})}function Z(e){return new s({tool:"leadbay_dislike_lead",args:{lead_id:e}})}function Y(e,t){return new d({autoLoad:!1,load:()=>l("leadbay_account_history",{leadId:e,_triggered_by:t})})}function K(e,t){return new d({autoLoad:!1,load:()=>l("leadbay_research_lead_by_id",{leadId:e,_triggered_by:t})})}function J(e){let t=null;return new d({...e.autoLoad!==void 0?{autoLoad:e.autoLoad}:{},pollEvery:e.pollEvery??4e3,until:r=>!!r?.all_done,load:async()=>{if(!t){let r=await l("leadbay_enrich_titles",{...e.leadIds?{leadIds:e.leadIds}:{},titles:e.titles,...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},...e.confirm!==void 0?{confirm:e.confirm}:{},_triggered_by:e.ask}),o=Array.isArray(r?.lead_ids)?r.lead_ids:[],n=r?.notification_id??null;if(t=n||o.length>0?{notification_id:n,lead_ids:o}:null,!t)return{...r,all_done:!0,no_job:!0}}return l("leadbay_bulk_enrich_status",{...t.notification_id?{notification_id:t.notification_id}:{},...t.lead_ids.length>0?{lead_ids:t.lead_ids}:{},...e.titles?{titles:e.titles}:{},...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},_triggered_by:e.ask})}})}function Q(e){let t=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new f({pageSize:e.pageSize??20,load:async({page:r,pageSize:o})=>{let n=await l("leadbay_pull_leads",{page:r,count:o,...e.lensId?{lensId:e.lensId}:{},...t()?{order:t()}:{},_triggered_by:e.ask}),i=n.leads??[];return{items:i,total:n.pagination?.total??i.length}}})}function X(e){let t=e.source??"followups",r=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new f({pageSize:e.pageSize??20,load:async({page:o,pageSize:n})=>{let c=t==="campaign"?await l("leadbay_campaign_call_sheet",{campaign_id:e.campaignId,page:o,count:n,_triggered_by:e.ask}):await l("leadbay_pull_followups",{page:o,count:n,...e.city?{city:e.city}:{},...r()?{order:r()}:{},_triggered_by:e.ask}),w=c.leads??c.items??[];return{items:w,total:c.total_leads??c.pagination?.total??w.length}}})}function ee(e){return new d({load:()=>l("leadbay_team_activity",{weeks:e.weeks??4,_triggered_by:e.ask})})}var te={VERSION:L,configure:O,styles:R,call:l,field:e=>new g(e),action:e=>new s(e),resource:e=>new d(e),list:e=>new f(e),bindSelect:D,bindValue:z,bindAction:q,campaigns:V,outreach:$,note:G,like:B,dislike:Z,leadStatus:j,setStatus:W,sortOrder:U,leadHistory:Y,leadProfile:K,enrichment:J,callList:X,leadList:Q,teamActivity:ee,EPILOGUE_STATUSES:F,LEAD_STATUSES:m,SORT_ORDERS:y};typeof globalThis<"u"&&(globalThis.LeadbayArtifacts=te);})();';
|
|
24461
|
+
ARTIFACT_USAGE_GUIDE = '# Leadbay Artifact Kit \u2014 headless domain components\n\nYou are building a single-file HTML **artifact** the user runs inside cowork. This\nkit gives you **headless view-models** that own a control\'s whole data lifecycle \u2014\nload/populate from a Leadbay call, hold value/state, poll, validate, and\nencapsulate the API call + business rules. **You own 100% of markup/layout/style.**\nThe library renders nothing. Inline the runtime once as a `<script>`; it exposes\none global `window.LeadbayArtifacts` (call it `lb`). Vanilla, no React, no build.\n\nPass every tool you use as the artifact\'s `mcp_tools` so the host permits it.\n\n## Two layers\n\n**Primitives** (generic):\n- `lb.field({ load, options, value, validate, dependsOn })` \u2014 a value + optionally\n API-populated options. `.value/.setValue/.options/.loading/.error/.valid/.subscribe`.\n- `lb.action({ tool, args, fields, confirm, onSuccess, onError })` \u2014 a write/submit.\n `.run()/.loading/.error/.lastResult/.subscribe`.\n- `lb.resource({ load, pollEvery?, until?, autoLoad? })` \u2014 one read that may change:\n load-on-click or poll-until-`until`. `.data/.loading/.refreshing/.error/.done/.load()/.refresh()/.stop()/.subscribe`.\n- `lb.list({ load, pageSize })` \u2014 paginated rows. `.items/.page/.total/.loading/.loadPage(n)/.next()/.prev()/.hasMore/.subscribe`.\n\n`.error` is `{ message, unavailable } | null`. `subscribe(cb)` fires immediately\nthen on every change \u2014 render your own DOM from it.\n\n**Domain components** (pre-wired \u2014 bake in the tool name, arg shape, and footguns):\n\n| Call | Returns | For |\n|---|---|---|\n| `lb.campaigns(ask)` | field | a campaign `<select>`, options from `leadbay_list_campaigns` |\n| `lb.outreach({leadId, ask, status?, note?})` | action | log a call \u2192 `report_outreach` (verification + `_triggered_by` baked in) |\n| `lb.note({leadId, note})` | action | add a note \u2192 `add_note` |\n| `lb.like(leadId)` / `lb.dislike(leadId)` | action | taste signal |\n| `lb.leadStatus(current?)` | field | a status `<select>` (Wanted/Won/Lost/Unwanted) |\n| `lb.setStatus({leadId or leadIds, status, date?, ask})` | action | write the org CRM status \u2192 `set_lead_status` |\n| `lb.leadHistory(leadId, ask)` | resource (lazy) | notes + activities + engagement \u2192 `account_history` |\n| `lb.leadProfile(leadId, ask)` | resource (lazy) | full lead profile \u2192 `research_lead_by_id` |\n| `lb.sortOrder(current?)` | field | a sort `<select>` mirroring the app\'s TableSort |\n| `lb.leadList({lensId?, order?, ask})` | list | a sortable Discover batch \u2192 `pull_leads` |\n| `lb.callList({source:\'followups\'\\|\'campaign\', campaignId?, city?, ask})` | list | a cold-call list (Monitor or a campaign) |\n| `lb.enrichment({leadIds, titles, ask, pollEvery?})` | resource (polling) | launch + watch contact enrichment |\n| `lb.teamActivity({weeks, ask})` | resource | manager leaderboard + activity trend \u2192 `leadbay_team_activity` |\n\n`lb.EPILOGUE_STATUSES` = the 4 disposition values\n(`STILL_CHASING`, `COULD_NOT_REACH_STILL_TRYING`, `INTEREST_VALIDATED_OR_MEETING_PLANED`, `NOT_INTERESTED_LOST`).\n`lb.LEAD_STATUSES` = the 4 org CRM statuses as `{value,label}` (`WANTED`, `WON`, `LOST`, `UNWANTED`).\n`lb.SORT_ORDERS` = the sort options as `{value,label}`; values are the backend `FIELD:ASC|DESC` enum.\n\n**Sorting is a SERVER concern.** `lb.leadList` and `lb.callList` take an `order`\n(a `lb.sortOrder()` field or a literal) and send it upstream; the backend sorts\nthe whole lens / Monitor and returns the requested page of that. Never re-sort\nrows in the browser \u2014 you would be reordering one page of a larger set, showing\nleads that do not belong at that position. The empty value means "no order\nparam", i.e. the tab\'s own ranking, which is the right default. Changing the\nsort should reset to page 0. Campaign call sheets cannot sort:\n`leadbay_campaign_call_sheet` has no `order` param, and `lb.callList` drops it\nfor that source rather than sending something the tool would reject.\n\n**Two different systems.** Epilogue = how one outreach attempt went (drives\nfollow-up ranking). Lead status = the commercial outcome, org-wide \u2014 the same\nfield the website\'s status selector writes. A won deal is a LEAD STATUS;\n"she didn\'t pick up" is an EPILOGUE. Setting one never sets the other, so when\nthe user reports both in one breath, fire both actions.\n\n**Binding sugar** (optional; binds a view-model to YOUR native element, no style):\n`lb.bindSelect(selectEl, field)` (populates options + value), `lb.bindValue(inputEl, field)`,\n`lb.bindAction(buttonEl, action)`. They set `data-lb-state`\n(`ready|loading|error|success|unavailable`) + `data-lb-error` on your element as\nstyling hooks. For lists/resources, use `.subscribe()` and render yourself.\n\n`ask` is the user\'s request this artifact serves \u2014 it becomes `_triggered_by`.\n\n## The skin (optional) \u2014 `lb.styles()`\n\nCall it once and you get a small `lb-*` stylesheet, so every artifact you build\nshares one visual language instead of re-inventing padding and colours. It is\n**opt-in**: skip it and you get exactly the unstyled HTML you wrote. It injects\nno markup and never touches your `class` attributes.\n\n```js\nlb.styles(); // idempotent \u2014 safe to call per row\n```\n\n| Class | For |\n|---|---|\n| `lb-card` / `lb-card-head` / `lb-title` / `lb-sub` | a lead card + its header |\n| `lb-row` / `lb-stack` / `lb-spacer` | control row / vertical spacing / flex filler that right-aligns what follows |\n| `lb-link-out` | quiet external link (icon inherits currentColor) \u2014 "Open in Leadbay" |\n| `lb-select` / `lb-input` / `lb-btn` | form controls (state-aware, see below) |\n| `lb-msg` (`data-tone="error\\|ok"`) | inline feedback |\n| `lb-chip` (`data-status="WON\\|LOST"`) | a status pill |\n| `lb-table` | leads table |\n| `lb-spinner` | inline busy indicator \u2014 decorative, mark it `aria-hidden` |\n| `lb-vh` | visually-hidden text \u2014 labels heard but not seen |\n\nControls react to the `data-lb-state` the bind helpers already set \u2014 a bound\n`lb-btn` dims while loading, goes green on success, red on error, all with no\nextra CSS from you.\n\nThe palette is the **product design system**, ported from\n`frontend/packages/style/color.css` \u2014 same `--color-gray-1\u20269` ramp, same\nsemantic `--color-{green,red,blue,gold}-{background,foreground}` pairs, same\n`1.5rem` / `0.625rem` radii (concentric: outer = inner + padding) and\n`corner-shape: squircle`, matching the app\'s components.\nAn artifact therefore looks like Leadbay, not like a generic page.\n\nUse the tokens rather than hardcoded colours \u2014 the same rule the style package\nenforces. Re-theme by overriding them; don\'t fight specificity:\n\n```css\n:root { --lb-surface: var(--color-gray-2); --lb-radius: 0.5rem; }\n```\n\nDark mode works two ways: `data-theme="dark"` on `<html>` (the frontend\'s own\nhook) **and** `prefers-color-scheme`, because an artifact renders inside a host\nwhose theme attribute it cannot set. Never hardcode a light background over the\nskin.\n\nThe product face is `Nikkei Maru`; the stack names it first and falls back to\nthe system UI font. Do **not** add an `@font-face` \u2014 artifacts are inline-only\nand a remote font URL will silently fail.\n\n## What every lead card MUST carry\n\nA card is the artifact form of the `pull_leads` table, and it inherits that\ntable\'s rules. A card with a name and a button is not enough: the rep cannot\ntell *why* this lead is on screen. Five lines, in this order.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title"></span> <!-- 1. company -->\n <span class="lb-chips"> <!-- 2. state -->\n <span class="lb-chip" data-taste hidden></span>\n <span class="lb-chip" data-status hidden></span>\n </span>\n </div>\n <div class="lb-facts"> <!-- one group, tight 4px gap -->\n <div class="lb-sub"></div> <!-- 3. firmographics -->\n <div class="lb-sub" data-who></div> <!-- 4. the person -->\n <div class="lb-sub" data-how></div> <!-- 5. company channels -->\n </div>\n <div class="lb-sub" data-why></div> <!-- 6. why it fits -->\n <div class="lb-row"><!-- actions --></div>\n <div class="lb-msg" role="status" aria-live="polite"></div>\n</div>\n```\n\n1. **Company** \u2014 `name`, linked to `website` (prefix `https://` on a bare host).\n Never render the numeric `score`; use the `\u25B0\u2756\u25B1` bar if you want the signal.\n\n Keep `.lb-msg` OUT of `.lb-row`. The result of a write \u2014 "Could not reach the\n host" \u2014 is the most important thing on the card at that moment; parked between\n two buttons it reads as a control, and as a wide flex item it forces the\n trailing link onto a line of its own. Give it its own row after the actions.\n\n Also give every card an **Open in Leadbay** link to the lead\'s panel in the\n product. Put it at the **trailing end of the card\'s last action row** \u2014\n same row as the buttons, pushed right by an `lb-spacer`, not on a line of\n its own. Style it `lb-link-out`: quiet text plus a plain arrow-up-right,\n never a filled button. It is an escape hatch, not a call to action.\n\n Group the controls by what they act on. A flat row of five buttons reads as\n five peers; the rep cannot see that "Set status" commits the select beside it\n while Like/Dislike are independent toggles. Wrap each axis in an `lb-group`\n and mark the commit with `lb-btn-submit`.\n\n Taste is the one pair worth reducing to icons: thumbs up/down are unambiguous,\n they repeat on every card, and dropping the words buys the width a narrow chat\n host needs. Use `lb-btn-icon` \u2014 and note the three attributes it REQUIRES,\n because with no text the glyph is the whole affordance:\n\n - `aria-label` naming the lead ("Like Acme Corp"), since the control repeats\n N times down the list;\n - `title` so a sighted user who does not know the glyph gets a tooltip;\n - `aria-pressed` reflecting the current taste \u2014 a toggle must say whether it\n is on, and `[aria-pressed=true]` is what the skin styles.\n\n Do **not** reduce "Set status" to an icon: no glyph says "commit the value in\n the select beside me". Icons work for a fixed, well-known action; they fail\n for one whose meaning comes from a neighbouring control.\n\n ```html\n <div class="lb-row">\n <span class="lb-group"> <!-- taste: two toggles -->\n <button class="lb-btn lb-btn-icon" aria-label="Like Acme Corp"\n title="Like" aria-pressed="false">\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>\n </svg>\n </button>\n <button class="lb-btn lb-btn-icon" data-taste="disliked"\n aria-label="Dislike Acme Corp" title="Dislike" aria-pressed="false">\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <path d="M17 14V2"/>\n <path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/>\n </svg>\n </button>\n </span>\n <select class="lb-select" aria-label="Lead status for Acme Corp"></select>\n <span class="lb-spacer"></span> <!-- pushes the link to the end -->\n <a class="lb-link-out" data-k="open" target="_blank" rel="noopener">\n Open in Leadbay\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/>\n </svg>\n </a>\n </div>\n ```\n\n Keep the arrow a bare diagonal stroke \u2014 the text already says where the link\n goes, so the glyph only has to mark "leaves this page". Mark the `<svg>`\n `aria-hidden="true"`: it is decorative, and the link text is the accessible\n name.\n **Pick the view the lead actually lives in** \u2014 the URL is\n `/app/<view>?lead=<uuid>`, and the three views are `discover`, `monitor`,\n `campaign`. Landing a Monitor lead on Discover drops the rep into a list\n that does not contain it:\n\n ```js\n function leadUrl(lead, campaignId) {\n const id = encodeURIComponent(lead.id);\n // A campaign card carries TWO params \u2014 the campaign selects the list, the\n // lead opens the panel inside it. Campaign wins even when in_monitor is\n // also true, because that is the list the rep is looking at.\n if (campaignId) {\n return `https://leadbay.app/app/campaign?campaign=${encodeURIComponent(campaignId)}&lead=${id}`;\n }\n const view = lead.in_monitor ? "monitor" : "discover";\n return `https://leadbay.app/app/${view}?lead=${id}`;\n }\n openEl.href = leadUrl(lead, campaignId);\n ```\n\n `in_monitor` / `in_discover` are booleans on the `pull_followups` payload \u2014\n every follow-up carries `in_monitor: true`, so a call sheet must link to\n `monitor`. `pull_leads` omits both flags entirely; its leads are the Discover\n batch by definition, so `discover` is the default. A campaign card\n (`lb.callList({source:"campaign", campaignId})`) needs `?campaign=<id>&lead=<id>`\n \u2014 the param names are `CAMPAIGN_QUERY_PARAM` and `LEAD_QUERY_PARAM`, and the\n app\'s own `useLeadPanel` preserves whatever params are already set, so the\n two coexist by design. Omitting `campaign=` opens an empty campaign view.\n\n Inline the glyph as SVG rather than an emoji or `\u2197` \u2014 it inherits\n `currentColor` and scales with the text, so it stays legible in both themes.\n `?lead=<uuid>` is the real deep-link (`LEAD_QUERY_PARAM` in the web app, read\n on load; the panel is an overlay, so the view choice only decides what sits\n behind it). This is the ONE place a card may use `lead.id`: as a link target,\n never as visible text.\n2. **State chips** \u2014 taste (`data-taste`) and CRM status (`data-status`) are\n INDEPENDENT axes; render both, hide the empty one. Never collapse to one chip.\n3. **Firmographics** \u2014 sector of activity first, then city, then size, then the\n contact. `sector_id` is a RAW ID (`"5136"`), not a label: resolve it via\n `leadbay_list_sectors` (1346 rows \u2014 fetch once, cache, never inline the lot)\n or omit it. Never print the raw id.\n\n **Always show whether the lead is reachable \u2014 and never merge the person\n with the company\'s switchboard.** These are two separate lines:\n\n ```js\n // WHO \u2014 recommended_contact. Name, and job_title ONLY when present; on list\n // payloads it is usually null, and inventing one is worse than omitting it.\n const rc = lead.recommended_contact;\n const who = rc ? [rc.first_name, rc.last_name].filter(Boolean).join(" ") : null;\n const whoLine = who ? who + (rc.job_title ? " \xB7 " + rc.job_title : "") : "No contact yet \u2014 enrich to find one";\n\n // HOW \u2014 company-level channels. `phone_numbers` and `email` belong to the\n // COMPANY, not to `recommended_contact`. Rendering "Jean \xB7 \u260E 0123\u2026" claims a\n // direct line that does not exist; it is the switchboard.\n // The API returns the literal STRING "null" for a missing value \u2014 in\n // phone_numbers as well as email (a real lead ships phone_numbers:["null"]).\n // Guard BOTH or the card prints "\u260E null" as if it were a number.\n const real = (v) => (v && v !== "null" ? v : null);\n const phone = real((lead.phone_numbers || [])[0]);\n const email = real(lead.email);\n const howLine = [phone && "\u260E " + phone, email && "\u2709 " + email].filter(Boolean)\n .join(" \xB7 ") || "No phone or email \u2014 enrich to look for them";\n ```\n\n ```html\n <div class="lb-sub">Sector \xB7 City \xB7 Size</div>\n <div class="lb-sub"><span aria-hidden="true">\u{1F464}</span> Jean-Fran\xE7ois Froemer \xB7 G\xE9rant</div>\n <div class="lb-sub"><span class="lb-vh">Company switchboard: </span><span aria-hidden="true">\u{1F3E2} \u260E</span> 01 23 45 67 89 (company line)</div>\n ```\n\n Label the channel line as the **company\'s**, so a rep reading fast cannot\n mistake it for a direct line. A per-contact email or phone exists only after\n enrichment \u2014 `research_lead_by_id` exposes it as `contacts.reachable[]`, and\n `_meta.has_reachable_contact` is the authoritative flag. The list payloads\n carry neither, so a card built from `pull_leads` / `pull_followups` can only\n ever show company channels. Say "enrich to reveal" rather than implying the\n contact is callable.\n\n Two things that look like reachability and are not: a `linkedin_page` alone\n (the rep cannot message a URL without leaving the artifact \u2014 same rule\n `research_lead_by_id` applies), and `contacts_count > 0` (it counts known\n people, not people you can contact; a lead can show 2518 contacts and zero\n channels). `pull_followups` carries `has_phone` as a ready-made boolean;\n `pull_leads` omits it, so derive from `phone_numbers` there.\n4. **Why it fits** \u2014 one sentence, \u226420 words. Walk this chain and stop at the\n first hit:\n\n 1. `short_description`\n 2. `description` (longer; only on `research_lead_by_id` /\n `research_lead_by_name_fuzzy` \u2014 the trim payloads omit it)\n 3. top 2 `tags[].display_name`\n 4. `qualification_summary.best_response_excerpt`, trimmed to one sentence\n 5. `keywords`, first 3, joined with ` \xB7 `\n 6. the resolved sector label \u2014 better than nothing, and if step 3 already\n printed the sector on the firmographics line, skip to step 7\n 7. the literal *"No description yet \u2014 qualify to add one"* ("qualify" is the\n product\'s own verb; "run qualification to generate one" is a nominalisation)\n\n Never leave this line blank: a silent gap reads as a rendering bug, whereas\n the fallback tells the rep the data is missing and what fixes it.\n\n **The two list payloads are complementary, so the chain must span both.**\n `pull_leads` returns `short_description` on every lead but no `sector_id`;\n `pull_followups` returns `sector_id` but no `short_description` at all. A\n card fed by one will fall through to a different step than the same card fed\n by the other \u2014 that is expected, not a bug. Never call\n `research_lead_by_id` per row just to fill this line: it is one request per\n lead. Fetch it lazily when the rep expands a card.\n\n**Never show** on a card: `id`, `sector_id`, `location.pos`, `location.country`\n(unless city and state are both missing), `is_hq`, `*_in_progress`,\n`highlighted_fields`, `custom_fields`, `stale_at`, `deal_insights`,\n`need_attention*`, any count that is 0, any value that is the string `"null"`.\n\n**Minimum actions.** A card that only displays is a table row that costs more \u2014\nif you are not wiring an action, render the markdown table instead. Wire at\nleast one write, and prefer the set the rep actually needs:\n\n| Card is for | Wire |\n|---|---|\n| triage a discovery batch | `lb.like` / `lb.dislike` + `lb.setStatus` |\n| working a call list | `lb.outreach` (gated on a note) + `lb.leadHistory` |\n| pipeline review | `lb.setStatus` + `lb.note` |\n\nAlways render the `.error` branch of every view-model \u2014 a control that cannot\nreach the host must say so, not sit silent.\n\n **Accessibility is the markup\'s job, not the skin\'s.** `data-lb-state` is a\n STYLING hook; it sets no ARIA. A card renders N times in a list, so every\n repeated control needs a name that says *which* lead it acts on:\n\n ```html\n <select class="lb-select" aria-label="Lead status for Acme Corp"></select>\n <button class="lb-btn" aria-label="Like Acme Corp">Like</button>\n <a class="lb-link-out" aria-label="Open Acme Corp in Leadbay" \u2026>\n <span class="lb-msg" role="status" aria-live="polite"></span>\n ```\n\n Without the `role="status"` node the rep hears nothing when a write fails \u2014\n `bindAction` puts the message in a `data-lb-error` attribute that nothing\n renders. Mark the `\u25B0\u2756\u25B1` bar `aria-hidden="true"` and follow it with\n `<span class="lb-vh">Fit: strong</span>`; the glyphs otherwise read aloud as\n "black parallelogram black parallelogram\u2026" before the company name. Use\n `lb-vh` for any label that should be heard but not seen.\n\n## Recipe: cold-call sheet (one row per lead)\n\n```js\nconst lb = window.LeadbayArtifacts; lb.configure();\nconst ASK = "<the user\'s request>";\n\nconst list = lb.callList({ source: "campaign", campaignId: CID, ask: ASK });\nlist.subscribe((l) => renderRows(l.items, l.loading)); // your render\n\n// per lead row (call when you build a row):\nfunction wireRow(lead, els) {\n const status = lb.field({ value: "STILL_CHASING" }); // static-enum <select>\n const note = lb.field({ validate: (v) => (v && v.trim() ? null : "Add a note") });\n lb.bindValue(els.status, status);\n lb.bindValue(els.note, note);\n lb.bindAction(els.log, lb.outreach({ leadId: lead.id, ask: ASK, status, note }));\n lb.bindAction(els.like, lb.like(lead.id));\n\n const history = lb.leadHistory(lead.id, ASK); // lazy\n history.subscribe((h) => renderHistory(els.history, h));\n els.expand.onclick = () => history.load(); // load on click\n}\n```\n\n## Recipe: lead-status dropdown (Wanted / Won / Lost)\n\nThe org-wide CRM status, as a `<select>` + Apply button. You write the markup;\n`lb.leadStatus` fills the options and holds the value, `lb.setStatus` does the write.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title">Acme Corp</span>\n <span class="lb-chips">\n <span id="taste" class="lb-chip" data-taste="liked">Liked</span>\n <span id="crm" class="lb-chip" data-status="WANTED">Wanted</span>\n </span>\n </div>\n <div class="lb-row">\n <select id="st" class="lb-select"></select>\n <button id="go" class="lb-btn">Apply</button>\n <span id="msg" class="lb-msg"></span>\n </div>\n</div>\n```\n\n**Two badges, never one.** Taste (`liked`/`disliked`, from `lb.like`/`lb.dislike`)\nand CRM status (`WANTED`/`WON`/`LOST`/`UNWANTED`, from `lb.setStatus`) are\nindependent axes \u2014 a lead can be liked *and* lost. Collapsing them into a single\nchip destroys information: the rep can no longer see that a lead they liked went\nnowhere. Render `data-taste` and `data-status` as separate chips inside\n`lb-chips`, and hide the one that has no value rather than reusing it.\n\n```js\nlb.styles(); // once per artifact \u2014 see below\n\nconst status = lb.leadStatus(lead.org_lead_status); // seed with the CURRENT value\nconst save = lb.setStatus({ leadId: lead.id, status, ask: ASK });\n\nlb.bindSelect(document.getElementById("st"), status); // populates the 4 options\nlb.bindAction(document.getElementById("go"), save); // click \u2192 write\n\nsave.subscribe((a) => { // render your own feedback\n msg.textContent = a.loading ? "Saving\u2026"\n : a.error ? a.error.message // includes partial failures\n : a.lastResult ? `Set to ${a.lastResult.status}` : "";\n msg.dataset.tone = a.error ? "error" : a.lastResult ? "ok" : "";\n});\n```\n\nLoading / success / error styling comes free: `bindAction` and `bindSelect` set\n`data-lb-state` (`ready|loading|error|success|unavailable`) and the skin already\ntargets those attributes. No extra wiring.\n\n**Save on change is the default for status.** The rep picks a value and it\nwrites \u2014 one interaction, no second button, matching the web app\'s own status\nselector. Drop `bindAction` and run the action from the change event:\n\n```js\nconst sel = document.getElementById("st");\nsel.addEventListener("change", () => save.run());\nsave.subscribe((a) => { // the select IS the feedback surface\n sel.setAttribute("data-lb-state",\n a.loading ? "loading" : a.error ? "error" : a.lastResult ? "success" : "ready");\n msg.textContent = a.loading ? "Saving\u2026" : a.error ? a.error.message\n : a.lastResult ? "Saved" : "";\n msg.dataset.tone = a.error ? "error" : a.lastResult ? "ok" : "";\n});\n```\n\nWithout a submit button the select becomes the only affordance, so it MUST show\nthe write: mirror `data-lb-state` onto it (the skin already styles loading /\nsuccess / error on `.lb-select`) and put the outcome in the `role="status"`\nline. A silent select leaves the rep unsure whether anything happened.\n\nKeep a submit button ONLY where a mis-click is expensive and the value is not\nself-evident \u2014 a bulk apply across checked rows, for instance, which already\ntakes a `confirm`. For one lead, the extra step buys nothing: the value is\nvisible in the select, and the rep can simply pick again.\n\n**Bulk apply** across checked rows \u2014 pass `leadIds` and a `confirm`, since one\nclick rewrites a field every rep in the org sees:\n\n```js\nconst bulk = lb.setStatus({\n leadIds: () => checkedIds, // \u2190 read at run() time, not at build time\n status, ask: ASK,\n confirm: "Set this status on every selected lead?",\n});\n```\n\n`leadIds` is read when the action runs, so a live selection works \u2014 but pass the\narray itself if your selection is fixed. A partial write (some leads rejected)\nsurfaces as `.error`, never as a green button: `setStatus` checks the `failed[]`\nthe tool returns.\n\nThe backend stamps the status date as "now" on every write, which is what a rep\nclicking a dropdown means. Don\'t add a date picker unless the user asks to\nbackdate \u2014 then pass an optional `date` field holding `YYYY-MM-DD`:\n`lb.setStatus({ leadId, status, date, ask })`.\n\n## Recipe: manager dashboard\n\n```js\nconst team = lb.teamActivity({ weeks: 4, ask: ASK });\nteam.subscribe((t) => {\n if (t.loading) showSpinner();\n if (t.data) {\n renderLeaderboard(t.data.reps); // sorted by total_activities; cols: name, notes, meetings_or_interest, lost\u2026\n renderTrendChart(t.data.trend); // [{date,count}] \u2192 Chart.js (allowed from CDN)\n }\n});\nrefreshBtn.onclick = () => team.refresh();\n```\n\n## Recipe: live enrichment\n\n```js\nconst job = lb.enrichment({ leadIds: [LEAD], titles: ["CEO", "VP Sales"], ask: ASK });\njob.subscribe((j) => {\n const p = j.data && j.data.overall_progress; // {done,total,done_ratio}\n renderBar(p);\n if (j.done) renderContacts(j.data.leads); // enriched contacts\n});\nrefreshBtn.onclick = () => job.refresh();\n```\n\n## Write-call rules\n\nThe domain factories handle these for you. If you hand-roll an action:\n`leadbay_report_outreach` args MUST include `verification:{source:"user_confirmed", ref}`\nAND `_triggered_by`; `leadbay_add_leads_to_campaign` needs `_triggered_by`;\n`add_note`/`like_lead`/`dislike_lead` take only their own args. `epilogue_status` is\none of `lb.EPILOGUE_STATUSES`. Snoozing (pushback) is advanced-gated \u2014 not\ncallable from a default artifact. Org lead status IS on the default surface:\nuse `lb.setStatus`, which owns the arg shape AND the partial-write check \u2014\n`leadbay_set_lead_status` writes each lead separately, so it can resolve 200\nwith a non-empty `failed[]`. Hand-rolling that action will report a green\nbutton over a write that never landed.\n\n## Degradation + live updates\n\nIf the host bridge is absent, a view-model\'s `.error` is set with `.error.unavailable\n=== true` (bind helpers set `data-lb-state="unavailable"`) \u2014 nothing throws. Every\ncall also has a **30s timeout** (configurable via `lb.configure({ timeoutMs })`): a\nhost call that never settles becomes `.error` with `code:"timeout"`, so a control is\nnever stuck loading forever \u2014 always render the `.error` branch so the user can retry.\nAuto-poll (`pollEvery`) depends on the cowork host serving FRESH reads; `.refresh()`\nis the guaranteed manual path \u2014 always wire a Refresh control for polling resources.';
|
|
24049
24462
|
}
|
|
24050
24463
|
});
|
|
24051
24464
|
|
|
@@ -24612,7 +25025,7 @@ var init_dist = __esm({
|
|
|
24612
25025
|
init_dist();
|
|
24613
25026
|
import { realpathSync } from "fs";
|
|
24614
25027
|
import { fileURLToPath } from "url";
|
|
24615
|
-
import { basename } from "path";
|
|
25028
|
+
import { basename as basename2 } from "path";
|
|
24616
25029
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
24617
25030
|
|
|
24618
25031
|
// src/server.ts
|
|
@@ -24829,7 +25242,7 @@ Tell me what you swapped in one line ("dropped Corbett + RBS \u2014 ops-only; sw
|
|
|
24829
25242
|
|
|
24830
25243
|
**Step C \u2014 enrich (NO confirm gate \u2014 just launch).** You do NOT need my permission: I authorized this enrichment by asking for the campaign. Do NOT call \`ask_user_input_v0\`, do NOT ask "enrich these N now?", do NOT wait. State the persona + titles + "enriching {enrichable_contacts} contacts (email + phone, consumes quota)" in one line for the record, then immediately launch: \`leadbay_enrich_titles({leadIds, lensId, titles:[...chosen], email:true, phone:true})\`. Enrich up to {{arg:count_or_default}} best target-title contacts. Do NOT quote a "credits" figure or refuse on a credit balance \u2014 the only real limit is quota (a backend 429). If a 429 stops you mid-run, keep the leads already enriched, note how many landed, and continue to Phase 4 with those.
|
|
24831
25244
|
|
|
24832
|
-
**Step D \u2014 poll + count only landed.** Poll \`leadbay_bulk_enrich_status\` until done (enrichment can take several minutes \u2014 keep polling, don't render an empty sheet prematurely).
|
|
25245
|
+
**Step D \u2014 poll + count only landed.** Poll \`leadbay_bulk_enrich_status\` until done (enrichment can take several minutes \u2014 keep polling, don't render an empty sheet prematurely). A lead only counts toward the {{arg:count_or_default}} once its target-title contact actually landed (email/phone present); if some came back empty, swap + enrich replacements (loop back to Step B.5) until the cohort is genuinely {{arg:count_or_default}} deep or the lens is exhausted.
|
|
24833
25246
|
|
|
24834
25247
|
# PHASE 4 \u2014 CREATE THE CAMPAIGN
|
|
24835
25248
|
|
|
@@ -24933,7 +25346,7 @@ work stopped. What to do next depends on what you are holding:
|
|
|
24933
25346
|
If you're resuming an interrupted session (you see a previous Phase already completed in your task list, or the user says "continue" / "continue from where you left off"), do NOT restart from Phase 1. Re-read the active \`lensId\` and your last completed phase from prior context, then resume from the next phase. If you genuinely have no state, restart from Phase 1.
|
|
24934
25347
|
|
|
24935
25348
|
# PHASE 1 \u2014 STATE
|
|
24936
|
-
Call \`leadbay_account_status\` to see what quota I have left and which lens is active. Note the remaining \`ai_rescore_remaining\` and \`web_fetch_remaining\` budgets \u2014 Phase 4 enrichment depends on them.
|
|
25349
|
+
Call \`leadbay_account_status\` to see what quota I have left and which lens is active. Note the remaining \`ai_rescore_remaining\` and \`web_fetch_remaining\` budgets \u2014 Phase 4 enrichment depends on them. This is for your planning: don't show me the quota unless a window is exhausted.
|
|
24937
25350
|
|
|
24938
25351
|
# PHASE 2 \u2014 FRESH BATCH
|
|
24939
25352
|
Call \`leadbay_pull_leads\` to get today's fresh batch. Capture \`response.lens.id\` (the response nests it under \`lens\`). **Use it as an explicit \`lensId\` argument on every subsequent Leadbay call this session** \u2014 including any re-pulls, bulk qualifies, or research calls that accept it. (See Rule 1 above \u2014 a mid-session lens shift discards your top-10 work.)
|
|
@@ -25310,10 +25723,10 @@ Mirror the Leadbay web quota widget: three windows side by side \u2014 **Daily**
|
|
|
25310
25723
|
$ cap** figure, with a per-resource usage breakdown underneath. **Never speak in
|
|
25311
25724
|
raw "credits"** for quota \u2014 the unit is a percentage and a dollar spend.
|
|
25312
25725
|
|
|
25313
|
-
**
|
|
25314
|
-
|
|
25315
|
-
|
|
25316
|
-
|
|
25726
|
+
**Show the quota only when it matters** \u2014 when the user asks about their quota,
|
|
25727
|
+
usage or account status, or when a window is exhausted and blocks what they
|
|
25728
|
+
asked for. A plain "what account am I connected to?" is answered with user +
|
|
25729
|
+
org alone. Even then, the silence gate below comes first.
|
|
25317
25730
|
|
|
25318
25731
|
**Silence gate (check FIRST).** Render NOTHING about quota when any of these
|
|
25319
25732
|
holds \u2014 do not mention quota at all, do not say "unreadable", never tell the user
|
|
@@ -26394,7 +26807,7 @@ All \`Calls\` below are agent-callable \`leadbay_*\` tools (never an MCP prompt
|
|
|
26394
26807
|
|---------------------------------------------------------------------|--------------------------------------------------------|--------------------------------------------------------------|
|
|
26395
26808
|
| Fresh discovery batch waiting / user wants new leads | "See today's best new leads" | leadbay_pull_leads(lensId = pinned) |
|
|
26396
26809
|
| Follow-ups due / known leads to re-engage | "Show follow-ups due now" | leadbay_pull_followups |
|
|
26397
|
-
| Quota/credit read shows
|
|
26810
|
+
| Quota/credit read shows exhausted balance | "Review what's eating your quota" | leadbay_account_status (deeper read) |
|
|
26398
26811
|
| Auth/connection blocker (e.g. 401 / AUTH_EXPIRED on a read) | "Reconnect Leadbay to unblock actions" | (guide the user to re-authenticate \u2014 no tool call) |
|
|
26399
26812
|
| Lens audience looks mismatched (batch is off-ICP) | "Adjust the lens audience to match your ICP" | ASK first \u2014 collect the target sectors / sizes / exclusions, THEN leadbay_adjust_audience(...) with those params. NEVER call it with no args (an empty call writes the current filter / may clone the default lens \u2014 a no-op or unwanted change). |
|
|
26400
26813
|
| Status is healthy and nothing is pending | propose nothing \u2014 the overview is a complete answer | \u2014 |
|
|
@@ -27516,8 +27929,8 @@ var PROMPT_CATALOG_BULLETS = {
|
|
|
27516
27929
|
};
|
|
27517
27930
|
|
|
27518
27931
|
// src/prompts.ts
|
|
27519
|
-
function userMessage(
|
|
27520
|
-
return { role: "user", content: { type: "text", text:
|
|
27932
|
+
function userMessage(text3) {
|
|
27933
|
+
return { role: "user", content: { type: "text", text: text3 } };
|
|
27521
27934
|
}
|
|
27522
27935
|
function substitutePlaceholders(body, substitutions) {
|
|
27523
27936
|
let out = body;
|
|
@@ -28538,7 +28951,7 @@ function buildAcknowledgeUpdateTool(opts) {
|
|
|
28538
28951
|
var ENRICHMENT_TERMINAL = `A settled-empty enrichment is TERMINAL \u2014 do not re-attempt it on a later run. On any contact record, \`enrichment.done: true\` together with \`enrichment.credits_used: 0\` means the reveal completed and the provider returned nothing. Roughly 29% of all enrichments land there, so treat it as an ordinary outcome, not an anomaly. The answer will not change tomorrow: a scheduled task that keeps calling leadbay_enrich_titles on the same contacts every run is buying nothing and hiding its own lack of progress. Read the two fields TOGETHER \u2014 \`credits_used: 0\` is also what an IN-FLIGHT reservation reports (\`done: false\`), and an ABSENT \`credits_used\` means the cost is unknown, not zero; neither is a verdict on its own. \`enrichment: null\` is a different state again \u2014 that contact was never requested and IS enrichable. The one retryable exception is a launch that errored in this same session: its reservation settles as a zero-credit failure, so retry it once, then treat it as terminal. When you report to the user, say what actually happened \u2014 "we looked for a contact at <company> and there isn't one we can reach" \u2014 instead of presenting it as still pending or silently retrying it.`;
|
|
28539
28952
|
var FRICTION = `Problem reports: when the user asks you to report a Leadbay problem ("report this", "tell the team this didn't work"), call leadbay_report_friction with {category, message (the user's own words), tool_called?, severity?}. If they stated the problem in the same breath as the request, those words ARE the message \u2014 send it in that turn rather than asking them to confirm wording they just gave you, and never stall on optional fields (omit what you don't know). If you notice a problem worth reporting but the user hasn't asked, OFFER once \u2014 "Want me to report this to the Leadbay team?" \u2014 and call it only if they agree. Never call it unprompted. Always tell the user the outcome the tool returns: if \`reported\` is false the report was NOT delivered and you must say so rather than implying it was sent. Frustration alone is not a reason to call it: keep solving their ask.`;
|
|
28540
28953
|
var MENTAL_MODEL = `How Leadbay works (mental model): Leadbay is a sales inbox, not a queryable database. Each day the user logs back in, a fresh batch of leads is delivered. Batch size is paced by how many leads the user has actually acted on recently \u2014 some workflows produce a big stream of smaller prospects, others a narrow stream of bigger ones. Pulling more won't produce more; the user acting on leads (outreach, skips, saves) does.`;
|
|
28541
|
-
var QUOTA_REFRESH = `Show the refreshed quota AFTER a quota-using action
|
|
28954
|
+
var QUOTA_REFRESH = `Show the refreshed quota AFTER a quota-using action only when it matters: the user asks where they now stand, the action stopped on an exhausted window, or a top-up the user confirmed landed. Otherwise report the result and say nothing about quota. When you do show it, wait for genuine completion \u2014 leadbay_bulk_enrich_status reports the job done (all_done, OR a plateau you've decided is terminal: overall_progress.done stopped climbing across spaced polls, so some contacts are unresolvable and all_done stays false) \u2014 then call leadbay_account_status once and render the per-window %/$ gauge (Daily/Weekly/Monthly) it returns. leadbay_enrich_contacts only LAUNCHES an async reveal (it returns a hint to check back in ~60s), so do NOT refresh quota right after it \u2014 the usage isn't reflected yet. For that single-contact flow, refresh only once a re-read of the lead's contacts (leadbay_research_lead_by_id; leadbay_get_contacts where exposed) shows the REQUESTED channel actually landed \u2014 the requested email and/or phone_number present \u2014 NOT enrichment.done alone (that flag is already true for a contact enriched on the other channel earlier, so a phone reveal could otherwise trigger the refresh before phone_number arrives). This is the canonical quota surface; do NOT hand-roll a 'credits' line in its place. Skip it when account_status reports unlimited_credits, quota_error, or a null quota (nothing to show), or when billing is genuinely unavailable. Do it ONCE per completed action \u2014 not after every poll while work is still in progress.`;
|
|
28542
28955
|
var QUOTA_TOPUP = `Quota & top-ups: when a tool returns QUOTA_EXCEEDED / 429, the user has TWO options \u2014 wait for the window reset (daily / weekly / monthly resets shown in leadbay_account_status), OR top up AI credits (top-ups clear the throttle IMMEDIATELY \u2014 they are not subject to the same window). Always offer BOTH options; default-recommending 'wait until tomorrow' is wrong when a 30-second top-up unblocks the same call. If the host exposes leadbay_create_topup_link, OFFER it on every quota wall: 'Want me to generate a top-up link?' \u2014 when the user says yes, call leadbay_create_topup_link and surface the returned Stripe URL as a clickable link for the user to open in their browser. (Sibling leadbay_open_billing_portal is for ongoing subscription changes, not one-shot top-ups.) AFTER the user has topped up: do NOT keep refusing operations. A top-up invalidates every prior 429 and every stale 'you're at your quota' snapshot. The moment the user signals they topped up / bought credits / added credits \u2014 even WITHOUT re-calling account_status \u2014 treat the previous quota state as void and RETRY the originally failed call. (Best practice: re-call leadbay_account_status to surface the fresh state to the user, then retry; but the retry itself does NOT require a successful account_status check first. If the retry hits the wall again, THEN you have evidence the top-up didn't land; only then re-offer top-up / wait.) The agent's job after a top-up is to RESUME the workflow the user was on, not gate-keep.`;
|
|
28543
28956
|
var TRANSIENT_401 = `Transient 401s (DO NOT narrate as an auth problem): Leadbay bearer tokens do NOT expire on a timer, and the client already auto-retried this call once before surfacing it. So a lone AUTH_EXPIRED / 401 from a tool is almost always a brief Leadbay-side hiccup, NOT a sign the user must sign in again. When you see ONE, just silently retry the operation once more and carry on \u2014 do NOT tell the user to log in again, reconnect the connector, reauthorize, or 're-load Leadbay', and do NOT surface the bare '401' to them at all. Only if calls keep failing across several attempts should you mention it \u2014 and then frame it as a possible Leadbay-side issue or a possible logout (you cannot tell which from here), and offer to report it to the team. Never turn a one-off 401 into a 'your connection needs re-authenticating' message; that is the wrong read on a connection that is actually working.`;
|
|
28544
28957
|
var TRIGGERED_BY = `Trigger provenance (MANDATORY): every Leadbay composite-tool call MUST carry a non-empty \`_triggered_by\` argument \u2014 the verbatim slice of the user's most recent message that this call is acting upon. Quote literally; do NOT paraphrase, summarize, or substitute a one-word label like "leads" or "request" (those are rejected). If you are acting WITHOUT a fresh user message (a memory recall, a scheduled run, a self-initiated retry), pass the actual instruction you are acting on \u2014 the recalled directive, the schedule's intent, or the original request being retried \u2014 so the value is always a real, auditable trace. Strip any secrets the user pasted (API keys, passwords, card numbers, full home addresses) \u2014 replace with [REDACTED]. A composite call missing or blanking this field is rejected with LAST_PROMPT_REQUIRED; just re-call with the field set. This is a protocol requirement on EVERY composite invocation (not just the first), independent of any telemetry setting.`;
|
|
@@ -29216,6 +29629,7 @@ ${url}
|
|
|
29216
29629
|
// RAW value: the friction redaction above is an ANALYTICS control, and
|
|
29217
29630
|
// must not change in-process tool behaviour.
|
|
29218
29631
|
triggered_by: rawTriggeredBy,
|
|
29632
|
+
saveFile: opts.saveFile,
|
|
29219
29633
|
// Route leadbay_send_feedback to Sentry's feedback inbox (same place
|
|
29220
29634
|
// the web app's form lands). NOOP_TELEMETRY returns false, so the
|
|
29221
29635
|
// tool reports honestly when telemetry is off.
|
|
@@ -29458,11 +29872,24 @@ ${url}
|
|
|
29458
29872
|
return server;
|
|
29459
29873
|
}
|
|
29460
29874
|
|
|
29875
|
+
// src/local-files.ts
|
|
29876
|
+
import { existsSync as existsSync2 } from "fs";
|
|
29877
|
+
import { writeFile } from "fs/promises";
|
|
29878
|
+
import { homedir } from "os";
|
|
29879
|
+
import { basename, join as join2 } from "path";
|
|
29880
|
+
async function saveToDownloads(name, content, home = homedir()) {
|
|
29881
|
+
const downloads = join2(home, "Downloads");
|
|
29882
|
+
const dir = existsSync2(downloads) ? downloads : home;
|
|
29883
|
+
const path = join2(dir, basename(name));
|
|
29884
|
+
await writeFile(path, content, "utf8");
|
|
29885
|
+
return path;
|
|
29886
|
+
}
|
|
29887
|
+
|
|
29461
29888
|
// installer/install-shared.ts
|
|
29462
29889
|
import { spawn } from "child_process";
|
|
29463
|
-
import { existsSync as
|
|
29464
|
-
import { join as
|
|
29465
|
-
import { homedir } from "os";
|
|
29890
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
|
|
29891
|
+
import { join as join3 } from "path";
|
|
29892
|
+
import { homedir as homedir2 } from "os";
|
|
29466
29893
|
var HOSTED_MCP_URL_CHATGPT = "https://mcp.leadbay.app/chatgpt/mcp";
|
|
29467
29894
|
function formatInstallOsLabel(platform = process.platform, arch = process.arch) {
|
|
29468
29895
|
const name = platform === "darwin" ? "macOS" : platform === "win32" ? "Windows" : platform === "linux" ? "Linux" : platform;
|
|
@@ -29470,15 +29897,15 @@ function formatInstallOsLabel(platform = process.platform, arch = process.arch)
|
|
|
29470
29897
|
}
|
|
29471
29898
|
function detectClaudeDesktopMode(claudeSupportDir) {
|
|
29472
29899
|
const markers = [];
|
|
29473
|
-
const legacy =
|
|
29474
|
-
if (
|
|
29900
|
+
const legacy = existsSync3(join3(claudeSupportDir, "claude_desktop_config.json"));
|
|
29901
|
+
if (existsSync3(join3(claudeSupportDir, "Claude Extensions"))) {
|
|
29475
29902
|
markers.push("Claude Extensions/");
|
|
29476
29903
|
}
|
|
29477
|
-
if (
|
|
29904
|
+
if (existsSync3(join3(claudeSupportDir, "extensions-installations.json"))) {
|
|
29478
29905
|
markers.push("extensions-installations.json");
|
|
29479
29906
|
}
|
|
29480
|
-
const cfgPath =
|
|
29481
|
-
if (
|
|
29907
|
+
const cfgPath = join3(claudeSupportDir, "config.json");
|
|
29908
|
+
if (existsSync3(cfgPath)) {
|
|
29482
29909
|
try {
|
|
29483
29910
|
const raw = readFileSync2(cfgPath, "utf8");
|
|
29484
29911
|
const parsed = JSON.parse(raw);
|
|
@@ -29518,8 +29945,8 @@ async function windowsStoreAppInstalled(packageName, appName) {
|
|
|
29518
29945
|
});
|
|
29519
29946
|
}
|
|
29520
29947
|
function isClaudeStorePackagePresent(localAppData) {
|
|
29521
|
-
const packagesDir =
|
|
29522
|
-
if (!
|
|
29948
|
+
const packagesDir = join3(localAppData, "Packages");
|
|
29949
|
+
if (!existsSync3(packagesDir)) return false;
|
|
29523
29950
|
try {
|
|
29524
29951
|
return readdirSync2(packagesDir).some((name) => /^Claude_/i.test(name));
|
|
29525
29952
|
} catch {
|
|
@@ -29528,7 +29955,7 @@ function isClaudeStorePackagePresent(localAppData) {
|
|
|
29528
29955
|
}
|
|
29529
29956
|
async function isClaudeDesktopInstalled(home) {
|
|
29530
29957
|
if (process.platform === "darwin") {
|
|
29531
|
-
return
|
|
29958
|
+
return existsSync3("/Applications/Claude.app") || existsSync3(home + "/Applications/Claude.app");
|
|
29532
29959
|
}
|
|
29533
29960
|
if (process.platform === "win32") {
|
|
29534
29961
|
const local = process.env.LOCALAPPDATA ?? home + "/AppData/Local";
|
|
@@ -29539,18 +29966,18 @@ async function isClaudeDesktopInstalled(home) {
|
|
|
29539
29966
|
local + "/Claude/Claude.exe",
|
|
29540
29967
|
programFiles ? programFiles + "/Claude/Claude.exe" : null,
|
|
29541
29968
|
programFilesX86 ? programFilesX86 + "/Claude/Claude.exe" : null
|
|
29542
|
-
].some((candidate) => candidate !== null &&
|
|
29969
|
+
].some((candidate) => candidate !== null && existsSync3(candidate));
|
|
29543
29970
|
if (exeInstalled) return true;
|
|
29544
29971
|
if (isClaudeStorePackagePresent(local)) return true;
|
|
29545
29972
|
return await windowsStoreAppInstalled("AnthropicPBC.Claude", "Claude");
|
|
29546
29973
|
}
|
|
29547
29974
|
const desktopBin = await findOnPath("claude-desktop");
|
|
29548
29975
|
if (desktopBin) return true;
|
|
29549
|
-
return
|
|
29976
|
+
return existsSync3(home + "/.local/share/applications/claude-desktop.desktop") || existsSync3("/usr/share/applications/claude-desktop.desktop") || existsSync3("/opt/Claude/Claude") || existsSync3("/opt/Claude/claude") || existsSync3("/opt/claude/claude");
|
|
29550
29977
|
}
|
|
29551
29978
|
async function isChatGptDesktopInstalled(home) {
|
|
29552
29979
|
if (process.platform === "darwin") {
|
|
29553
|
-
return
|
|
29980
|
+
return existsSync3("/Applications/ChatGPT.app") || existsSync3(home + "/Applications/ChatGPT.app");
|
|
29554
29981
|
}
|
|
29555
29982
|
if (process.platform === "win32") {
|
|
29556
29983
|
const local = process.env.LOCALAPPDATA ?? home + "/AppData/Local";
|
|
@@ -29563,7 +29990,7 @@ async function isChatGptDesktopInstalled(home) {
|
|
|
29563
29990
|
programFiles ? programFiles + "/ChatGPT/ChatGPT.exe" : null,
|
|
29564
29991
|
programFilesX86 ? programFilesX86 + "/OpenAI/ChatGPT/ChatGPT.exe" : null,
|
|
29565
29992
|
programFilesX86 ? programFilesX86 + "/ChatGPT/ChatGPT.exe" : null
|
|
29566
|
-
].some((candidate) => candidate !== null &&
|
|
29993
|
+
].some((candidate) => candidate !== null && existsSync3(candidate));
|
|
29567
29994
|
return exeInstalled || await windowsStoreAppInstalled("OpenAI.ChatGPT-Desktop", "ChatGPT");
|
|
29568
29995
|
}
|
|
29569
29996
|
return false;
|
|
@@ -29571,16 +29998,16 @@ async function isChatGptDesktopInstalled(home) {
|
|
|
29571
29998
|
async function isCursorInstalled(home) {
|
|
29572
29999
|
const cursorBin = await findOnPath("cursor");
|
|
29573
30000
|
if (cursorBin) return true;
|
|
29574
|
-
if (process.platform === "darwin") return
|
|
30001
|
+
if (process.platform === "darwin") return existsSync3("/Applications/Cursor.app");
|
|
29575
30002
|
if (process.platform === "win32") {
|
|
29576
30003
|
const local = process.env.LOCALAPPDATA ?? `${home}\\AppData\\Local`;
|
|
29577
|
-
return
|
|
30004
|
+
return existsSync3(`${local}\\Programs\\Cursor\\Cursor.exe`);
|
|
29578
30005
|
}
|
|
29579
|
-
return
|
|
30006
|
+
return existsSync3("/usr/share/applications/cursor.desktop") || existsSync3("/opt/Cursor/cursor");
|
|
29580
30007
|
}
|
|
29581
30008
|
async function detectClients() {
|
|
29582
30009
|
const out = [];
|
|
29583
|
-
const home =
|
|
30010
|
+
const home = homedir2();
|
|
29584
30011
|
const claudeBin = await findOnPath("claude");
|
|
29585
30012
|
if (claudeBin) {
|
|
29586
30013
|
out.push({ id: "claude-code", label: "Claude Code", detail: `${claudeBin} mcp add ...` });
|
|
@@ -29599,7 +30026,7 @@ async function detectClients() {
|
|
|
29599
30026
|
out.push({
|
|
29600
30027
|
id: "cursor",
|
|
29601
30028
|
label: "Cursor",
|
|
29602
|
-
detail:
|
|
30029
|
+
detail: existsSync3(cursorPath) ? cursorPath : `${cursorPath} (will be created)`,
|
|
29603
30030
|
configPath: cursorPath
|
|
29604
30031
|
});
|
|
29605
30032
|
}
|
|
@@ -29688,11 +30115,11 @@ async function uninstallFromClaudeCode() {
|
|
|
29688
30115
|
// installer/install-json-config.ts
|
|
29689
30116
|
async function installInJsonConfig(configPath, token, region, includeWrite, telemetryEnabled, localBinPath) {
|
|
29690
30117
|
try {
|
|
29691
|
-
const { readFileSync: readFileSync3, writeFileSync, existsSync:
|
|
30118
|
+
const { readFileSync: readFileSync3, writeFileSync, existsSync: existsSync5, mkdirSync, statSync } = await import("fs");
|
|
29692
30119
|
const { dirname: dirname2 } = await import("path");
|
|
29693
30120
|
let parsed = {};
|
|
29694
30121
|
let preserved = {};
|
|
29695
|
-
const existed =
|
|
30122
|
+
const existed = existsSync5(configPath);
|
|
29696
30123
|
if (existed) {
|
|
29697
30124
|
const raw = readFileSync3(configPath, "utf8");
|
|
29698
30125
|
try {
|
|
@@ -29742,8 +30169,8 @@ function stripJsonMcpEntry(existing) {
|
|
|
29742
30169
|
}
|
|
29743
30170
|
async function uninstallFromJsonConfig(configPath) {
|
|
29744
30171
|
try {
|
|
29745
|
-
const { existsSync:
|
|
29746
|
-
if (!
|
|
30172
|
+
const { existsSync: existsSync5, readFileSync: readFileSync3, writeFileSync } = await import("fs");
|
|
30173
|
+
if (!existsSync5(configPath)) return { ok: true, message: "config not found \u2014 nothing to do" };
|
|
29747
30174
|
const existing = readFileSync3(configPath, "utf8");
|
|
29748
30175
|
const { content, changed } = stripJsonMcpEntry(existing);
|
|
29749
30176
|
if (!changed) return { ok: true, message: "leadbay entry not present" };
|
|
@@ -29826,10 +30253,10 @@ function stripShellExportBlock(existing) {
|
|
|
29826
30253
|
}
|
|
29827
30254
|
async function installInCodexConfig(configPath, includeWrite, telemetryEnabled, localBinPath) {
|
|
29828
30255
|
try {
|
|
29829
|
-
const { readFileSync: readFileSync3, writeFileSync, existsSync:
|
|
30256
|
+
const { readFileSync: readFileSync3, writeFileSync, existsSync: existsSync5, mkdirSync, statSync, renameSync, chmodSync } = await import("fs");
|
|
29830
30257
|
const { dirname: dirname2 } = await import("path");
|
|
29831
30258
|
let existing = "";
|
|
29832
|
-
const existed =
|
|
30259
|
+
const existed = existsSync5(configPath);
|
|
29833
30260
|
if (existed) {
|
|
29834
30261
|
existing = readFileSync3(configPath, "utf8");
|
|
29835
30262
|
} else {
|
|
@@ -29875,15 +30302,15 @@ async function appendShellExports(token, region, includeWrite, telemetryEnabled)
|
|
|
29875
30302
|
}
|
|
29876
30303
|
return { ok: true, message: "env exported with setx; restart Codex/terminal" };
|
|
29877
30304
|
}
|
|
29878
|
-
const { existsSync:
|
|
30305
|
+
const { existsSync: existsSync5, readFileSync: readFileSync3, writeFileSync, renameSync } = await import("fs");
|
|
29879
30306
|
const os = await import("os");
|
|
29880
30307
|
const home = os.homedir();
|
|
29881
|
-
const preferred = [`${home}/.zshrc`, `${home}/.bashrc`].filter((path) =>
|
|
30308
|
+
const preferred = [`${home}/.zshrc`, `${home}/.bashrc`].filter((path) => existsSync5(path));
|
|
29882
30309
|
const paths = preferred.length ? preferred : [`${home}/.profile`];
|
|
29883
30310
|
const block = buildShellExportBlock(token, region, includeWrite, telemetryEnabled);
|
|
29884
30311
|
const updated = [];
|
|
29885
30312
|
for (const path of paths) {
|
|
29886
|
-
const existing =
|
|
30313
|
+
const existing = existsSync5(path) ? readFileSync3(path, "utf8") : "";
|
|
29887
30314
|
const merged = mergeShellExportBlock(existing, block);
|
|
29888
30315
|
if (!merged.changed) continue;
|
|
29889
30316
|
const tmp = `${path}.leadbay.tmp`;
|
|
@@ -29901,8 +30328,8 @@ async function appendShellExports(token, region, includeWrite, telemetryEnabled)
|
|
|
29901
30328
|
}
|
|
29902
30329
|
async function uninstallFromCodexConfig(configPath) {
|
|
29903
30330
|
try {
|
|
29904
|
-
const { existsSync:
|
|
29905
|
-
if (!
|
|
30331
|
+
const { existsSync: existsSync5, readFileSync: readFileSync3, writeFileSync } = await import("fs");
|
|
30332
|
+
if (!existsSync5(configPath)) return { ok: true, message: "config not found \u2014 nothing to do" };
|
|
29906
30333
|
const existing = readFileSync3(configPath, "utf8");
|
|
29907
30334
|
const { content, changed } = stripCodexBlock(existing);
|
|
29908
30335
|
if (!changed) return { ok: true, message: "leadbay block not present" };
|
|
@@ -29914,11 +30341,11 @@ async function uninstallFromCodexConfig(configPath) {
|
|
|
29914
30341
|
}
|
|
29915
30342
|
async function uninstallShellExports() {
|
|
29916
30343
|
try {
|
|
29917
|
-
const { existsSync:
|
|
30344
|
+
const { existsSync: existsSync5, readFileSync: readFileSync3, writeFileSync, renameSync } = await import("fs");
|
|
29918
30345
|
const os = await import("os");
|
|
29919
30346
|
const home = os.homedir();
|
|
29920
30347
|
const candidates = [`${home}/.zshrc`, `${home}/.bashrc`, `${home}/.profile`].filter(
|
|
29921
|
-
(p) =>
|
|
30348
|
+
(p) => existsSync5(p)
|
|
29922
30349
|
);
|
|
29923
30350
|
const updated = [];
|
|
29924
30351
|
for (const p of candidates) {
|
|
@@ -29943,17 +30370,17 @@ async function uninstallShellExports() {
|
|
|
29943
30370
|
var DXT_EXTENSION_ID = "local.dxt.leadbay.leadbay";
|
|
29944
30371
|
async function removeDxtExtension(claudeSupportDir) {
|
|
29945
30372
|
try {
|
|
29946
|
-
const { existsSync:
|
|
29947
|
-
const { join:
|
|
29948
|
-
const extensionDir =
|
|
29949
|
-
const registryPath =
|
|
30373
|
+
const { existsSync: existsSync5, readFileSync: readFileSync3, writeFileSync, rmSync } = await import("fs");
|
|
30374
|
+
const { join: join4 } = await import("path");
|
|
30375
|
+
const extensionDir = join4(claudeSupportDir, "Claude Extensions", DXT_EXTENSION_ID);
|
|
30376
|
+
const registryPath = join4(claudeSupportDir, "extensions-installations.json");
|
|
29950
30377
|
let removedDir = false;
|
|
29951
30378
|
let removedEntry = false;
|
|
29952
|
-
if (
|
|
30379
|
+
if (existsSync5(extensionDir)) {
|
|
29953
30380
|
rmSync(extensionDir, { recursive: true, force: true });
|
|
29954
30381
|
removedDir = true;
|
|
29955
30382
|
}
|
|
29956
|
-
if (
|
|
30383
|
+
if (existsSync5(registryPath)) {
|
|
29957
30384
|
try {
|
|
29958
30385
|
const raw = readFileSync3(registryPath, "utf8");
|
|
29959
30386
|
const parsed = JSON.parse(raw);
|
|
@@ -29980,8 +30407,8 @@ async function removeDxtExtension(claudeSupportDir) {
|
|
|
29980
30407
|
}
|
|
29981
30408
|
|
|
29982
30409
|
// installer/install-wizard.ts
|
|
29983
|
-
function ansi(
|
|
29984
|
-
return enabled ? `\x1B[${code}m${
|
|
30410
|
+
function ansi(text3, code, enabled) {
|
|
30411
|
+
return enabled ? `\x1B[${code}m${text3}\x1B[0m` : text3;
|
|
29985
30412
|
}
|
|
29986
30413
|
function parseInstallSelection(input, clientCount) {
|
|
29987
30414
|
const normalized = input.trim().toLowerCase();
|
|
@@ -30161,7 +30588,7 @@ import {
|
|
|
30161
30588
|
} from "fs/promises";
|
|
30162
30589
|
import { constants as fsConstants } from "fs";
|
|
30163
30590
|
import { dirname, resolve as resolvePath } from "path";
|
|
30164
|
-
import { homedir as
|
|
30591
|
+
import { homedir as homedir3 } from "os";
|
|
30165
30592
|
function emptyState() {
|
|
30166
30593
|
return {
|
|
30167
30594
|
last_check_time: 0,
|
|
@@ -30197,7 +30624,7 @@ var UpdateStateStore = class {
|
|
|
30197
30624
|
}
|
|
30198
30625
|
validatePath(p) {
|
|
30199
30626
|
if (this.allowUnsafePath) return;
|
|
30200
|
-
const home = resolvePath(
|
|
30627
|
+
const home = resolvePath(homedir3());
|
|
30201
30628
|
if (p !== home && !p.startsWith(home + "/") && !p.startsWith(home + "\\")) {
|
|
30202
30629
|
throw new Error(
|
|
30203
30630
|
`UpdateStateStore: path ${p} is outside $HOME (${home}). Set LEADBAY_UPDATE_STATE_PATH_UNSAFE=1 to override.`
|
|
@@ -30325,7 +30752,7 @@ async function openTmpFileExclusive(path) {
|
|
|
30325
30752
|
async function createDefaultUpdateStateStore(opts = {}) {
|
|
30326
30753
|
const env = opts.env ?? process.env;
|
|
30327
30754
|
const allowUnsafePath = env.LEADBAY_UPDATE_STATE_PATH_UNSAFE === "1";
|
|
30328
|
-
const path = env.LEADBAY_UPDATE_STATE_PATH ?? resolvePath(
|
|
30755
|
+
const path = env.LEADBAY_UPDATE_STATE_PATH ?? resolvePath(homedir3(), ".leadbay", "update-state.json");
|
|
30329
30756
|
try {
|
|
30330
30757
|
const store = new UpdateStateStore({
|
|
30331
30758
|
backend: "file",
|
|
@@ -30349,7 +30776,7 @@ import { createHash as createHash5, randomBytes } from "crypto";
|
|
|
30349
30776
|
import { createServer } from "http";
|
|
30350
30777
|
import { request as httpsRequestRaw } from "https";
|
|
30351
30778
|
import { spawn as spawn3 } from "child_process";
|
|
30352
|
-
import { readdirSync as readdirSync3, existsSync as
|
|
30779
|
+
import { readdirSync as readdirSync3, existsSync as existsSync4 } from "fs";
|
|
30353
30780
|
var LEADBAY_LOOPBACK_PORTS = [51789, 51790, 51791, 51792];
|
|
30354
30781
|
var BrowserOpenFailedError = class extends Error {
|
|
30355
30782
|
authorizeUrl;
|
|
@@ -30682,11 +31109,11 @@ function browserLaunchEnv(debug) {
|
|
|
30682
31109
|
const env = { ...process.env };
|
|
30683
31110
|
if (process.platform !== "linux") return env;
|
|
30684
31111
|
let runtimeDir = env.XDG_RUNTIME_DIR;
|
|
30685
|
-
if (!runtimeDir || !
|
|
31112
|
+
if (!runtimeDir || !existsSync4(runtimeDir)) {
|
|
30686
31113
|
try {
|
|
30687
31114
|
const uid = process.getuid?.();
|
|
30688
31115
|
const candidate = uid !== void 0 ? `/run/user/${uid}` : void 0;
|
|
30689
|
-
if (candidate &&
|
|
31116
|
+
if (candidate && existsSync4(candidate)) {
|
|
30690
31117
|
runtimeDir = candidate;
|
|
30691
31118
|
env.XDG_RUNTIME_DIR = candidate;
|
|
30692
31119
|
debug?.(`browserLaunchEnv: injected XDG_RUNTIME_DIR=${candidate}`);
|
|
@@ -30706,7 +31133,7 @@ function browserLaunchEnv(debug) {
|
|
|
30706
31133
|
}
|
|
30707
31134
|
if (!env.DBUS_SESSION_BUS_ADDRESS && runtimeDir) {
|
|
30708
31135
|
const busPath = `${runtimeDir}/bus`;
|
|
30709
|
-
if (
|
|
31136
|
+
if (existsSync4(busPath)) {
|
|
30710
31137
|
env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${busPath}`;
|
|
30711
31138
|
debug?.(`browserLaunchEnv: injected DBUS_SESSION_BUS_ADDRESS=unix:path=${busPath}`);
|
|
30712
31139
|
}
|
|
@@ -30729,7 +31156,7 @@ function browserLaunchEnv(debug) {
|
|
|
30729
31156
|
);
|
|
30730
31157
|
if (cookie) xauth = `${runtimeDir}/${cookie}`;
|
|
30731
31158
|
}
|
|
30732
|
-
if (!xauth && env.HOME &&
|
|
31159
|
+
if (!xauth && env.HOME && existsSync4(`${env.HOME}/.Xauthority`)) {
|
|
30733
31160
|
xauth = `${env.HOME}/.Xauthority`;
|
|
30734
31161
|
}
|
|
30735
31162
|
if (xauth) {
|
|
@@ -30892,7 +31319,7 @@ var OAUTH_BASE_URLS = {
|
|
|
30892
31319
|
fr: "https://staging.api.leadbay.app"
|
|
30893
31320
|
}
|
|
30894
31321
|
};
|
|
30895
|
-
var VERSION = "0.
|
|
31322
|
+
var VERSION = "0.39.0";
|
|
30896
31323
|
var HELP = `
|
|
30897
31324
|
leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
|
|
30898
31325
|
|
|
@@ -30992,9 +31419,9 @@ function exitWithTokenError() {
|
|
|
30992
31419
|
function hydrateEnvFromCredentialsFile() {
|
|
30993
31420
|
if (process.env.LEADBAY_TOKEN) return false;
|
|
30994
31421
|
try {
|
|
30995
|
-
const { existsSync:
|
|
31422
|
+
const { existsSync: existsSync5, readFileSync: readFileSync3 } = require_("node:fs");
|
|
30996
31423
|
const { path } = resolveOAuthBootstrapCredentialsPath();
|
|
30997
|
-
if (!
|
|
31424
|
+
if (!existsSync5(path)) return false;
|
|
30998
31425
|
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
30999
31426
|
const env = parsed?.mcpServers?.leadbay?.env;
|
|
31000
31427
|
if (!env || typeof env !== "object") return false;
|
|
@@ -31015,9 +31442,9 @@ function hydrateEnvFromCredentialsFile() {
|
|
|
31015
31442
|
function resolveOAuthBootstrapCredentialsPath() {
|
|
31016
31443
|
const resolved = resolveDefaultCredentialsPath();
|
|
31017
31444
|
if (process.env.LEADBAY_OAUTH_STAGING !== "1") return resolved;
|
|
31018
|
-
const { dirname: dirname2, join:
|
|
31445
|
+
const { dirname: dirname2, join: join4 } = require_("node:path");
|
|
31019
31446
|
return {
|
|
31020
|
-
path:
|
|
31447
|
+
path: join4(dirname2(resolved.path), "credentials.staging.json"),
|
|
31021
31448
|
legacy: resolved.legacy
|
|
31022
31449
|
};
|
|
31023
31450
|
}
|
|
@@ -31027,20 +31454,20 @@ var bootstrapFailureMessage;
|
|
|
31027
31454
|
function bootstrapDebug(msg) {
|
|
31028
31455
|
try {
|
|
31029
31456
|
const { appendFileSync, mkdirSync } = require_("node:fs");
|
|
31030
|
-
const { join:
|
|
31031
|
-
const { homedir:
|
|
31032
|
-
const dir =
|
|
31457
|
+
const { join: join4 } = require_("node:path");
|
|
31458
|
+
const { homedir: homedir4 } = require_("node:os");
|
|
31459
|
+
const dir = join4(homedir4(), ".leadbay");
|
|
31033
31460
|
mkdirSync(dir, { recursive: true });
|
|
31034
31461
|
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
31035
|
-
appendFileSync(
|
|
31462
|
+
appendFileSync(join4(dir, "oauth-bootstrap-debug.log"), `${ts} [pid ${process.pid}] ${msg}
|
|
31036
31463
|
`);
|
|
31037
31464
|
} catch {
|
|
31038
31465
|
}
|
|
31039
31466
|
}
|
|
31040
31467
|
function oauthClientCachePath() {
|
|
31041
|
-
const { join:
|
|
31042
|
-
const { homedir:
|
|
31043
|
-
return
|
|
31468
|
+
const { join: join4 } = require_("node:path");
|
|
31469
|
+
const { homedir: homedir4 } = require_("node:os");
|
|
31470
|
+
return join4(homedir4(), ".leadbay", "oauth-client.json");
|
|
31044
31471
|
}
|
|
31045
31472
|
function getCachedOAuthClientId(authServerBaseUrl, port) {
|
|
31046
31473
|
try {
|
|
@@ -31367,8 +31794,8 @@ function parseFlag(args, name) {
|
|
|
31367
31794
|
function hasFlag(args, name) {
|
|
31368
31795
|
return args.some((a) => a === `--${name}`);
|
|
31369
31796
|
}
|
|
31370
|
-
function ansi2(
|
|
31371
|
-
return enabled ? `\x1B[${code}m${
|
|
31797
|
+
function ansi2(text3, code, enabled) {
|
|
31798
|
+
return enabled ? `\x1B[${code}m${text3}\x1B[0m` : text3;
|
|
31372
31799
|
}
|
|
31373
31800
|
function isInteractiveInstall() {
|
|
31374
31801
|
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
@@ -31588,8 +32015,8 @@ Restart your MCP client to pick up the new server.
|
|
|
31588
32015
|
usingLegacyPath = resolved.legacy;
|
|
31589
32016
|
}
|
|
31590
32017
|
try {
|
|
31591
|
-
const { existsSync:
|
|
31592
|
-
if (
|
|
32018
|
+
const { existsSync: existsSync5, readFileSync: readFileSync3 } = await import("fs");
|
|
32019
|
+
if (existsSync5(targetPath) && !force) {
|
|
31593
32020
|
let existing;
|
|
31594
32021
|
try {
|
|
31595
32022
|
existing = JSON.parse(readFileSync3(targetPath, "utf8"));
|
|
@@ -32117,6 +32544,9 @@ async function main() {
|
|
|
32117
32544
|
version: VERSION,
|
|
32118
32545
|
telemetry,
|
|
32119
32546
|
updateStateStore,
|
|
32547
|
+
// This process runs on the user's machine, so a tool can save a file
|
|
32548
|
+
// they will find. The hosted server (http-server.ts) does not pass this.
|
|
32549
|
+
saveFile: saveToDownloads,
|
|
32120
32550
|
// Non-blocking OAuth bootstrap gate. Read per tool call: once the
|
|
32121
32551
|
// background flow lands the token (client.isAuthenticated → true) this
|
|
32122
32552
|
// reports done and tools execute. While waiting it surfaces the live
|
|
@@ -32199,7 +32629,7 @@ var isEntrypoint = (() => {
|
|
|
32199
32629
|
try {
|
|
32200
32630
|
const entry = process.argv[1];
|
|
32201
32631
|
if (!entry) return false;
|
|
32202
|
-
const entryName =
|
|
32632
|
+
const entryName = basename2(entry).toLowerCase();
|
|
32203
32633
|
if (entryName !== "bin.js" && entryName !== "leadbay-mcp" && entryName !== "index.js") return false;
|
|
32204
32634
|
const self = fileURLToPath(import.meta.url);
|
|
32205
32635
|
return realpathSync(self) === realpathSync(entry);
|