@leadbay/mcp 0.10.1 → 0.12.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 +19 -0
- package/README.md +11 -11
- package/dist/bin.js +1035 -58
- package/dist/{chunk-F3EWCHME.js → chunk-J2Y4LCFM.js} +1132 -57
- package/dist/{dist-BHLIJAIH.js → dist-YYVFSDMH.js} +1 -1
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
LeadbayClient,
|
|
3
4
|
compositeReadTools,
|
|
4
5
|
compositeWriteTools,
|
|
5
6
|
createClient,
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
granularReadTools,
|
|
9
10
|
granularWriteTools,
|
|
10
11
|
resolveRegion
|
|
11
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-J2Y4LCFM.js";
|
|
12
13
|
|
|
13
14
|
// src/bin.ts
|
|
14
15
|
import { realpathSync } from "fs";
|
|
@@ -128,25 +129,20 @@ If \`qualification_summary.answered == 0\` or \`avg_qualification_boost\` is nul
|
|
|
128
129
|
|
|
129
130
|
**Column 3 \u2014 Contact**
|
|
130
131
|
|
|
131
|
-
\`[Contact name](LINK) \xB7 short job title\`. See linking/contact-linkedin for
|
|
132
|
+
\`[Contact name](LINK) \xB7 short job title\`. The \`[Contact name](LINK)\` markdown link wrapping is mandatory \u2014 never render the name as plain text. See linking/contact-linkedin for the URL priority (real profile \u2192 constructed people-search) and the \xB0-flag fallback.
|
|
132
133
|
|
|
133
134
|
**Hide from the user (never include in any cell):** \`id\`, \`location.pos\`, \`location.country\` (unless city/state both missing), \`sector_id\`, \`is_hq\`, \`web_fetch_in_progress\`, \`enrichment_in_progress\`, \`highlighted_fields\`, \`custom_fields\`, \`contacts_count\` when 0, \`notes_count\` / \`epilogue_actions_count\` / \`prospecting_actions_count\` when 0, \`stale_at\`, \`deal_insights\`, \`social_presence\` booleans (except as the \xB0-flag signal), \`need_attention\` flags, any field whose value is the string \`"null"\`.
|
|
134
135
|
|
|
135
136
|
## Linking a contact's name
|
|
136
137
|
|
|
137
|
-
|
|
138
|
+
**MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
|
|
138
139
|
|
|
139
|
-
|
|
140
|
+
URL priority (first applicable wins):
|
|
140
141
|
|
|
141
|
-
|
|
142
|
+
1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
|
|
143
|
+
2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
|
|
142
144
|
|
|
143
|
-
|
|
144
|
-
https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
|
|
145
|
-
\`\`\`
|
|
146
|
-
|
|
147
|
-
URL-encode the params. Strip Inc / LLC / Corp / Ltd / GmbH suffixes from the company name. Append a trailing \` \xB0\` to the rendered name ONLY when the fallback is in use AND \`social_presence.linkedin == false\` (no company LinkedIn \u2192 search may not resolve). Never append \`\xB0\` when a real \`linkedin_page\` was used.
|
|
148
|
-
|
|
149
|
-
Never link a person's name to the company's LinkedIn page (and vice versa). The two surfaces are different \u2014 conflating them quietly degrades the workflow.
|
|
145
|
+
Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
|
|
150
146
|
|
|
151
147
|
## Linking the company
|
|
152
148
|
|
|
@@ -365,6 +361,55 @@ After I answer, call \`leadbay_report_outreach({lead_id: '{{arg:lead_id}}', note
|
|
|
365
361
|
# PHASE 3 \u2014 CONFIRM
|
|
366
362
|
Tell me the outreach was logged, name the verification.source used, and surface the response's \`outreach_id\` if present so I can refer back to it.
|
|
367
363
|
`;
|
|
364
|
+
var leadbay_plan_tour_in_city = `
|
|
365
|
+
Plan a field sales tour for me in **{{arg:city}}**{{arg:date_paren}}.
|
|
366
|
+
|
|
367
|
+
GATE \u2014 DEFER TO TOOL RENDERING. When you call a Leadbay composite that ships its own RENDERING block (every composite in 0.9.0+ does), render the response using that block's recipe verbatim \u2014 score bars, glyph palette, column order, hide-list, link priorities, all of it. Do NOT substitute prose, a numbered list, or a different column structure even when an orchestrating prompt's body suggests alternate framing. Prompt-specific commentary (motivational nudges, summaries, next-action recommendations) belongs ABOVE or BELOW the canonical table, never in place of it.
|
|
368
|
+
|
|
369
|
+
If the prompt's body and the tool's RENDERING appear to conflict, the tool's RENDERING wins for the structural layout; the prompt's voice wins for the commentary that surrounds it.
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# PHASE 1 \u2014 BUILD THE ITINERARY
|
|
373
|
+
|
|
374
|
+
Call \`leadbay_tour_plan({city: "{{arg:city}}"})\` with the default counts (6 follow-ups + 6 discover). If the response is \`status: "ambiguous_locations"\`, surface the candidates and ask me to pick one, then re-call with \`city_id\`.
|
|
375
|
+
|
|
376
|
+
Split the returned \`monitor_leads\` into two buckets client-side using \`last_monitor_action\`:
|
|
377
|
+
|
|
378
|
+
- **Customers** \u2014 leads with any \`last_monitor_action\` history (CONTACTED, MEETING_BOOKED, etc.). Treat as known accounts with prior engagement.
|
|
379
|
+
- **Qualified prospects** \u2014 leads with high \`ai_agent_lead_score\` (or \`score\`) but no recent action.
|
|
380
|
+
|
|
381
|
+
\`discover_leads\` are the **New** bucket.
|
|
382
|
+
|
|
383
|
+
Aim for a 3+3+3 split if possible. If the customers bucket has fewer than 3, fill from qualified. If discover_filter_note indicates a low match ratio for the city, mention it: "Only N/30 fresh leads matched your city" \u2014 better honest than padded.
|
|
384
|
+
|
|
385
|
+
# PHASE 2 \u2014 RENDER THE MAP
|
|
386
|
+
|
|
387
|
+
Route the union of \`monitor_leads + discover_leads\` into \`places_map_display_v0\` (when the host exposes it). Per-lead \`notes\` string:
|
|
388
|
+
|
|
389
|
+
- \`\u2605 Customer \u2014 <one-sentence sector + why-now>. Reach <name>, <role>: <bare phone>, <bare email>.\`
|
|
390
|
+
- \`\u2605 Qualified \u2014 <one-sentence>. Reach <name>...\`
|
|
391
|
+
- \`\u2726 New \u2014 <one-sentence>. Reach <name>...\`
|
|
392
|
+
|
|
393
|
+
Skip leads with \`location.pos === null\` (no coordinates \u2192 no pin) \u2014 list them as "+ N leads without coordinates" below the widget.
|
|
394
|
+
|
|
395
|
+
Below the widget, emit a chat-prose summary grouped by mode (Customers / Qualified / New), with LinkedIn-linked contact name + bare phone/email pills per lead. Use the canonical \`linking/contact-linkedin\` rules.
|
|
396
|
+
|
|
397
|
+
# PHASE 3 \u2014 DRAFT IN-AREA OUTREACH (optional, ask first)
|
|
398
|
+
|
|
399
|
+
After the map, ask me ONCE: "Want me to draft 'I'll be in {{arg:city}}{{arg:date_paren}}' outreach for the top accounts?" If I say yes, for each of the top 3 leads (1 Customer / 1 Qualified / 1 New), call \`leadbay_prepare_outreach(leadId)\` and route the draft through \`message_compose_v1\` with a single variant labeled "In-area visit" \u2014 body opens with the visit context, references the AI-summary angle, ends with a clear ask (15-min coffee / on-site stopover).
|
|
400
|
+
|
|
401
|
+
Serialize the prepare_outreach calls (max 3 in parallel \u2014 see the long-running-tools rule).
|
|
402
|
+
|
|
403
|
+
# PHASE 4 \u2014 PERSIST AS A CAMPAIGN (optional, ask first)
|
|
404
|
+
|
|
405
|
+
After drafts, ask me ONCE: "Save these 9 accounts as a campaign called '**{{arg:city}} Tour{{arg:date_dash}}**'?" If I say yes, call \`leadbay_create_campaign({lead_ids: [...all_nine_lead_ids], name: "{{arg:city}} Tour{{arg:date_dash}}"})\`. Surface the returned \`id\` + \`name\` as a confirmation line, and offer the NEXT STEPS chip "View progression" (which routes to \`leadbay_campaign_progression\`).
|
|
406
|
+
|
|
407
|
+
If I declined the campaign step, end the turn \u2014 the map + drafts are enough for an ad-hoc trip.
|
|
408
|
+
|
|
409
|
+
# PHASE 5 \u2014 STOP
|
|
410
|
+
|
|
411
|
+
Done. The map is the surface; the drafts are the action; the campaign is the persistence layer for managerial follow-up after the trip.
|
|
412
|
+
`;
|
|
368
413
|
var leadbay_prospecting_overview = `
|
|
369
414
|
# Leadbay Prospecting \u2014 Orientation
|
|
370
415
|
|
|
@@ -585,25 +630,20 @@ If \`qualification_summary.answered == 0\` or \`avg_qualification_boost\` is nul
|
|
|
585
630
|
|
|
586
631
|
**Column 3 \u2014 Contact**
|
|
587
632
|
|
|
588
|
-
\`[Contact name](LINK) \xB7 short job title\`. See linking/contact-linkedin for
|
|
633
|
+
\`[Contact name](LINK) \xB7 short job title\`. The \`[Contact name](LINK)\` markdown link wrapping is mandatory \u2014 never render the name as plain text. See linking/contact-linkedin for the URL priority (real profile \u2192 constructed people-search) and the \xB0-flag fallback.
|
|
589
634
|
|
|
590
635
|
**Hide from the user (never include in any cell):** \`id\`, \`location.pos\`, \`location.country\` (unless city/state both missing), \`sector_id\`, \`is_hq\`, \`web_fetch_in_progress\`, \`enrichment_in_progress\`, \`highlighted_fields\`, \`custom_fields\`, \`contacts_count\` when 0, \`notes_count\` / \`epilogue_actions_count\` / \`prospecting_actions_count\` when 0, \`stale_at\`, \`deal_insights\`, \`social_presence\` booleans (except as the \xB0-flag signal), \`need_attention\` flags, any field whose value is the string \`"null"\`.
|
|
591
636
|
|
|
592
637
|
## Linking a contact's name
|
|
593
638
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
When the response carries a real contact LinkedIn URL \u2014 \`contact.linkedin_page\` is a string that starts with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it) \u2014 link the contact's name to that URL.
|
|
597
|
-
|
|
598
|
-
Otherwise fall back to a LinkedIn people-search URL:
|
|
639
|
+
**MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
|
|
599
640
|
|
|
600
|
-
|
|
601
|
-
https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
|
|
602
|
-
\`\`\`
|
|
641
|
+
URL priority (first applicable wins):
|
|
603
642
|
|
|
604
|
-
|
|
643
|
+
1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
|
|
644
|
+
2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
|
|
605
645
|
|
|
606
|
-
Never link a person's name to the company's LinkedIn page (and vice versa)
|
|
646
|
+
Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
|
|
607
647
|
|
|
608
648
|
## Linking the company
|
|
609
649
|
|
|
@@ -718,19 +758,14 @@ If \`qualification[]\` is non-empty, append one collapsed line: \`"Qualification
|
|
|
718
758
|
|
|
719
759
|
## Linking a contact's name
|
|
720
760
|
|
|
721
|
-
|
|
761
|
+
**MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
|
|
722
762
|
|
|
723
|
-
|
|
763
|
+
URL priority (first applicable wins):
|
|
724
764
|
|
|
725
|
-
|
|
765
|
+
1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
|
|
766
|
+
2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
|
|
726
767
|
|
|
727
|
-
|
|
728
|
-
https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
|
|
729
|
-
\`\`\`
|
|
730
|
-
|
|
731
|
-
URL-encode the params. Strip Inc / LLC / Corp / Ltd / GmbH suffixes from the company name. Append a trailing \` \xB0\` to the rendered name ONLY when the fallback is in use AND \`social_presence.linkedin == false\` (no company LinkedIn \u2192 search may not resolve). Never append \`\xB0\` when a real \`linkedin_page\` was used.
|
|
732
|
-
|
|
733
|
-
Never link a person's name to the company's LinkedIn page (and vice versa). The two surfaces are different \u2014 conflating them quietly degrades the workflow.
|
|
768
|
+
Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
|
|
734
769
|
|
|
735
770
|
## Linking the company
|
|
736
771
|
|
|
@@ -750,6 +785,160 @@ Place a 2\u20133 sentence summary ABOVE the card with:
|
|
|
750
785
|
|
|
751
786
|
The card itself handles the signal callouts (\`\u{1F4C8} business signals\`, \`\u{1F4A1} prospecting clues\`). Do NOT re-narrate signals in prose above the card \u2014 that's what the card sections are for. Be honest about uncertainty: if any field is missing from tool responses, say "not surfaced by qualification" rather than guessing.
|
|
752
787
|
`;
|
|
788
|
+
var leadbay_setup_team_prospecting = `
|
|
789
|
+
Set up manager-led prospecting for me: turn the audience into a lens, validate candidates, then persist as named campaigns.
|
|
790
|
+
|
|
791
|
+
Audience: **{{arg:audience}}**
|
|
792
|
+
{{arg:rep_split_block}}
|
|
793
|
+
|
|
794
|
+
GATE \u2014 DEFER TO TOOL RENDERING. When you call a Leadbay composite that ships its own RENDERING block (every composite in 0.9.0+ does), render the response using that block's recipe verbatim \u2014 score bars, glyph palette, column order, hide-list, link priorities, all of it. Do NOT substitute prose, a numbered list, or a different column structure even when an orchestrating prompt's body suggests alternate framing. Prompt-specific commentary (motivational nudges, summaries, next-action recommendations) belongs ABOVE or BELOW the canonical table, never in place of it.
|
|
795
|
+
|
|
796
|
+
If the prompt's body and the tool's RENDERING appear to conflict, the tool's RENDERING wins for the structural layout; the prompt's voice wins for the commentary that surrounds it.
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
# PHASE 1 \u2014 INTERPRET INTENT INTO A LENS
|
|
800
|
+
|
|
801
|
+
Call \`leadbay_refine_prompt({user_prompt: "{{arg:audience}}"})\`. This handles the clarification protocol natively \u2014 if the system needs more info (e.g. industry disambiguation, geography precision), it returns \`status: "clarification_needed"\` with options. Surface those to me; on my answer, re-call \`leadbay_refine_prompt\` until the prompt converges.
|
|
802
|
+
|
|
803
|
+
When the prompt has converged, call \`leadbay_create_lens({user_prompt: <refined>, name: "<short descriptive name>"})\` to create a draft lens, then \`leadbay_promote_lens({lensId})\` to make it the active lens.
|
|
804
|
+
|
|
805
|
+
# PHASE 2 \u2014 PULL + VALIDATE CANDIDATES
|
|
806
|
+
|
|
807
|
+
Call \`leadbay_pull_leads({count: 20, lensId: <the new lens id>})\` to surface the top 20 candidates from the freshly-created lens. Render with the canonical \`pull_leads\` table layout.
|
|
808
|
+
|
|
809
|
+
Ask me ONCE: "Want me to deep-research the top N for validation?" If yes, call \`leadbay_research_lead_by_id\` serialized over the top 3-5 (one at a time, max 3 in parallel per the long-running-tools rule). Surface a research summary per lead.
|
|
810
|
+
|
|
811
|
+
Then ask me ONCE: "Which of these should we drop?" If I name leads to drop, exclude them from the working set. The remaining is the validated set.
|
|
812
|
+
|
|
813
|
+
# PHASE 3 \u2014 DECIDE THE CAMPAIGN SHAPE
|
|
814
|
+
|
|
815
|
+
If I provided a \`rep_split\` ("one campaign per rep: John gets Tulsa, Sarah gets OKC"), partition the validated leads accordingly. If I didn't, ask ONCE: "Create one campaign for the whole batch, or split per rep / region / sector?" \u2014 surface 2-4 options via \`ask_user_input_v0\` when available, else as a bulleted list.
|
|
816
|
+
|
|
817
|
+
For each campaign-shape decision, derive a name. Templates:
|
|
818
|
+
- Whole batch: \`"<lens-name> \u2013 <YYYY-MM-DD>"\`
|
|
819
|
+
- Per rep: \`"<lens-name> \u2013 <RepName>"\`
|
|
820
|
+
- Per region: \`"<lens-name> \u2013 <RegionName>"\`
|
|
821
|
+
|
|
822
|
+
# PHASE 4 \u2014 PERSIST
|
|
823
|
+
|
|
824
|
+
For each campaign-shape partition, call \`leadbay_create_campaign({lead_ids: [...partition], name: "<derived>"})\`. Surface the returned \`id\` + \`name\` per campaign as a confirmation line.
|
|
825
|
+
|
|
826
|
+
# PHASE 5 \u2014 BE HONEST ABOUT SCOPE
|
|
827
|
+
|
|
828
|
+
Once the campaigns are created, surface this caveat in plain prose:
|
|
829
|
+
|
|
830
|
+
> Campaign visibility is currently scoped to the user who CREATED the campaign \u2014 the reps won't see these in their own MCP \`leadbay_list_campaigns\` calls. They CAN see them in the web UI at app.leadbay.ai \u2192 Campaigns. Cross-user MCP visibility would need backend work; flag this as a #3630 US3 product gap if your reps work primarily through MCP.
|
|
831
|
+
|
|
832
|
+
End with a NEXT STEPS chip via \`ask_user_input_v0\`: "View progression on one of these now?" \u2192 routes to \`leadbay_campaign_progression\`.
|
|
833
|
+
|
|
834
|
+
# PHASE 6 \u2014 STOP
|
|
835
|
+
|
|
836
|
+
Done. The lens is live, the validated cohort is persisted as named campaigns, and the manager knows where the cross-user-visibility gap is.
|
|
837
|
+
`;
|
|
838
|
+
var leadbay_work_campaign = `
|
|
839
|
+
Work my **{{arg:campaign_or_default}}** campaign as an outreach session{{arg:mode_paren}}.
|
|
840
|
+
|
|
841
|
+
GATE \u2014 DEFER TO TOOL RENDERING. When you call a Leadbay composite that ships its own RENDERING block (every composite in 0.9.0+ does), render the response using that block's recipe verbatim \u2014 score bars, glyph palette, column order, hide-list, link priorities, all of it. Do NOT substitute prose, a numbered list, or a different column structure even when an orchestrating prompt's body suggests alternate framing. Prompt-specific commentary (motivational nudges, summaries, next-action recommendations) belongs ABOVE or BELOW the canonical table, never in place of it.
|
|
842
|
+
|
|
843
|
+
If the prompt's body and the tool's RENDERING appear to conflict, the tool's RENDERING wins for the structural layout; the prompt's voice wins for the commentary that surrounds it.
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
# PHASE 0 \u2014 PICK THE CAMPAIGN
|
|
847
|
+
|
|
848
|
+
If I gave you a name or id, resolve it. Otherwise call \`leadbay_list_campaigns()\` and surface the active campaigns as a \`single_select\` via \`ask_user_input_v0\` (cap at 4 \u2014 sort by \`updated_at\` desc, archived hidden):
|
|
849
|
+
|
|
850
|
+
> Which campaign do you want to work?
|
|
851
|
+
> - <Name 1> \xB7 <N leads> \xB7 last touched <date>
|
|
852
|
+
> - <Name 2> \xB7 <N leads> \xB7 last touched <date>
|
|
853
|
+
> - \u2026
|
|
854
|
+
|
|
855
|
+
When the user picks, capture the \`campaign_id\`. If \`{{arg:campaign}}\` is a name, fuzzy-match against \`campaigns[].campaign.name\`. On ambiguous matches, surface a \`single_select\` instead of guessing.
|
|
856
|
+
|
|
857
|
+
# PHASE 1 \u2014 FETCH + ASSESS READINESS (the load-bearing phase)
|
|
858
|
+
|
|
859
|
+
Call \`leadbay_campaign_call_sheet({campaign_id})\`. The response carries \`summary\` + \`readiness\` \u2014 use them to figure out what the user CAN actually do today, then PROPOSE the right session mode rather than auto-rendering.
|
|
860
|
+
|
|
861
|
+
**Read the summary numbers**:
|
|
862
|
+
- \`total_leads\`, \`total_contacts\`
|
|
863
|
+
- \`leads_with_phone\` \u2014 can call from this many leads
|
|
864
|
+
- \`leads_with_email\` \u2014 can email this many
|
|
865
|
+
- \`leads_with_coords\` \u2014 can map this many
|
|
866
|
+
- \`leads_without_contacts\` \u2014 these need enrichment before any outreach is possible
|
|
867
|
+
- \`leads_already_contacted\` \u2014 these have prior touches; the rep may want to skip them for cold work
|
|
868
|
+
|
|
869
|
+
**Read the \`readiness\` booleans** (pre-computed thresholds):
|
|
870
|
+
- \`ready_for_calling\` (phone coverage \u226560%) \u2014 call session viable
|
|
871
|
+
- \`ready_for_emailing\` (email coverage \u226560%) \u2014 email session viable
|
|
872
|
+
- \`needs_enrichment\` (\u226530% no-contacts OR both phone+email coverage <40%) \u2014 enrichment recommended first
|
|
873
|
+
- \`travel_friendly\` (\u22655 geocoded leads AND coord coverage \u226560%) \u2014 map mode worth proposing
|
|
874
|
+
|
|
875
|
+
**One-line situation report** (always emit BEFORE the proposal):
|
|
876
|
+
|
|
877
|
+
\`\`\`
|
|
878
|
+
\u{1F4CB} <total_leads> leads \xB7 \u{1F4DE} <leads_with_phone> with a phone \xB7 \u2709 <leads_with_email> with an email \xB7 \u{1F5FA} <leads_with_coords> with coords \xB7 \u{1F534} <leads_without_contacts> need enrichment \xB7 \u2705 <leads_already_contacted> already touched
|
|
879
|
+
\`\`\`
|
|
880
|
+
|
|
881
|
+
**Then PROPOSE the right modes via \`ask_user_input_v0\`** (2-4 options, sorted by what makes the most sense for THIS campaign's data):
|
|
882
|
+
|
|
883
|
+
- "\u{1F4DE} Start calling now" \u2014 IF \`ready_for_calling\`. Top option when phones are there.
|
|
884
|
+
- "\u2709 Email session instead" \u2014 IF \`ready_for_emailing\` AND \`email_ratio > phone_ratio\`. Don't surface this when calling is more obvious.
|
|
885
|
+
- "\u{1F527} Enrich titles first" \u2014 IF \`needs_enrichment\`. Top option when most leads have no contacts. Phrase as "<N> leads have no reachable contact yet \u2014 enrich titles before we start?" so the user understands the cost.
|
|
886
|
+
- "\u{1F5FA} View on a map" \u2014 IF \`travel_friendly\` **AND** the user hasn't previously signaled disinterest in maps (check your conversation memory; if you've seen the user dismiss map renders before in this session or saved a "no maps" preference, drop this option).
|
|
887
|
+
|
|
888
|
+
If the MCP prompt argument \`mode\` was actually supplied, skip the proposal and jump to the matching mode below. If \`mode\` was omitted, do not treat \`call_sheet\` as implicit user consent \u2014 propose first.
|
|
889
|
+
|
|
890
|
+
# PHASE 2A \u2014 CALL-SHEET MODE (default after "\u{1F4DE} Start calling now")
|
|
891
|
+
|
|
892
|
+
Render per the \`leadbay_campaign_call_sheet\` RENDERING block \u2014 one CARD per lead with the 4-col contact table (Contact / Phone / Role / Recent). The phone in column 2 MUST be \`[bare](tel:URL)\` (use \`contact.phone_tel_url\` verbatim \u2014 the composite has already canonicalized it). The contact name in column 1 MUST be \`[Name](linkedin_url)\`. Email stacks under the name when present (\`\u2709 [email](mailto_url)\`). Recent stacks \`\u{1F4DD} last note\` + \`\u{1F4DE} last_action_headline\`.
|
|
893
|
+
|
|
894
|
+
End the turn with the standby line:
|
|
895
|
+
|
|
896
|
+
> Ready to start calling. Tell me what happened after each call \u2014 I'll record the note + outcome.
|
|
897
|
+
|
|
898
|
+
# PHASE 2B \u2014 EMAIL-SHEET MODE (after "\u2709 Email session instead")
|
|
899
|
+
|
|
900
|
+
Same data, slightly different render emphasis: drop the Phone column, put \`\u2709 [email](mailto_url)\` as column 2. Below each lead's table, generate a SUGGESTED short email draft per the next-step \u2014 but DON'T send. Drafts are for the user to copy-paste / send themselves.
|
|
901
|
+
|
|
902
|
+
# PHASE 2C \u2014 ENRICH-FIRST MODE (after "\u{1F527} Enrich titles first")
|
|
903
|
+
|
|
904
|
+
Extract \`leadIds\` from \`sheet.leads[].lead_id\`, then call \`leadbay_enrich_titles({leadIds, \u2026})\` (consult its description for titles / email / phone selection; do not pass \`campaign_id\`, because that is not part of the tool schema). Surface progress to the user. When complete, automatically loop back to Phase 1 (re-fetch the call sheet, re-assess readiness, re-propose).
|
|
905
|
+
|
|
906
|
+
# PHASE 2D \u2014 MAP MODE (after "\u{1F5FA} View on a map")
|
|
907
|
+
|
|
908
|
+
Pass \`response.map_locations\` directly to \`places_map_display_v0\` \u2014 the composite has already built the per-pin notes string with the top contact's phone inline. After the widget, emit the standard 4-col card list anyway so the rich detail is still scannable.
|
|
909
|
+
|
|
910
|
+
# PHASE 3 \u2014 RECORD OUTCOMES, ONE AT A TIME (after the user starts dictating)
|
|
911
|
+
|
|
912
|
+
When the user says something like *"Called Bree, voicemail, trying again Tuesday"* or *"Talked to John, wants pricing sent next week"*, parse:
|
|
913
|
+
|
|
914
|
+
1. **Which lead** \u2014 by company name OR contact name (cross-reference with the cards you just rendered).
|
|
915
|
+
2. **The note** \u2014 the user's exact words about what happened (the SDR's voice \u2014 don't paraphrase).
|
|
916
|
+
3. **The outcome** \u2014 pick ONE of these four epilogue values based on what the user said:
|
|
917
|
+
- \`STILL_CHASING\` \u2014 pursuing, no decision yet ("trying again", "they'll get back to me")
|
|
918
|
+
- \`COULD_NOT_REACH_STILL_TRYING\` \u2014 voicemail, no answer, wrong number, gatekeeper blocked
|
|
919
|
+
- \`INTEREST_VALIDATED_OR_MEETING_PLANED\` \u2014 meeting booked, quote requested, "send me more info"
|
|
920
|
+
- \`NOT_INTERESTED_LOST\` \u2014 declined, "not now", "not a fit", "remove from list"
|
|
921
|
+
|
|
922
|
+
Call \`leadbay_report_outreach({lead_id, note: <user's words>, epilogue_status: <picked>, verification: {source: "user_confirmed", ref: <user's exact words verbatim>}})\`. Confirm in ONE line: *"\u2705 Logged: <Company> \u2192 <epilogue>. Next?"*
|
|
923
|
+
|
|
924
|
+
Then wait for the next dictation. Don't ask "anything else?" \u2014 just acknowledge and wait.
|
|
925
|
+
|
|
926
|
+
# PHASE 4 \u2014 STOP
|
|
927
|
+
|
|
928
|
+
When the user says "done" / "that's it" / "wrapping up" / similar, surface a session summary chip:
|
|
929
|
+
|
|
930
|
+
> Session complete \u2014 N calls logged: X meetings booked \xB7 Y still chasing \xB7 Z couldn't reach \xB7 W declined.
|
|
931
|
+
|
|
932
|
+
Optional: offer to review the \`leadbay_campaign_progression\` for the same campaign to see the updated counts.
|
|
933
|
+
|
|
934
|
+
# Iron laws
|
|
935
|
+
|
|
936
|
+
- The \`verification\` field on \`leadbay_report_outreach\` is REQUIRED. For calls (no message id), always use \`{source: "user_confirmed", ref: <user's verbatim words>}\`. Skipping it is forbidden; fabricating a gmail_message_id for a call is forbidden.
|
|
937
|
+
- ONE call \u2192 ONE \`leadbay_report_outreach\` invocation. Don't batch; each call has its own note + outcome.
|
|
938
|
+
- Map mode is OPT-IN, never automatic. The user invokes it via the proposal options or by passing \`mode=map\`.
|
|
939
|
+
- If you've seen the user dismiss / dislike map renders earlier in the session, don't propose map mode again.
|
|
940
|
+
- If the user dictates an outcome that doesn't cleanly map to one of the four epilogue values, ASK ONCE before guessing.
|
|
941
|
+
`;
|
|
753
942
|
var PROMPT_META = {
|
|
754
943
|
leadbay_daily_check_in: { "name": "leadbay_daily_check_in", "short_description": `Run the canonical daily check-in: account state, fresh batch, triage
|
|
755
944
|
top 10, deep-dive every promising one, offer contact enrichment. The
|
|
@@ -769,6 +958,7 @@ anything that implies pre-existing pipeline context.
|
|
|
769
958
|
`, "arguments": [], "expected_calls": ["leadbay_pull_followups", "leadbay_research_lead_by_id", "leadbay_prepare_outreach"], "failure_modes": ["Calls leadbay_pull_leads (the Discover entry point) instead of leadbay_pull_followups \u2014 these are different data sources; the Discover queue does NOT contain Monitor's known-but-cold pipeline", 'Iterates pages of leadbay_pull_leads filtering by engagement_count to "fake" a follow-up view (a real bug observed in 0.9.0 \u2014 the right move is to call pull_followups directly)', "Replaces the canonical pull_followups table layout with prose per row (the per-tool RENDERING block is the structural contract; commentary belongs above or below)", 'Skips the cross-mode pivot offer at the end ("Want to see NEW leads from your wishlist instead?" routes to leadbay_pull_leads)'] },
|
|
770
959
|
leadbay_import_file: { "name": "leadbay_import_file", "short_description": "Import a user-supplied CSV/file into Leadbay through five phases with\nevidence gates \u2014 scan, derive, resolve identities, preserve & commit,\nthen optionally qualify and report. The job is to maximize how many\nrows the Leadbay system actually ingests and matches.\n", "arguments": [{ "name": "file", "description": "Path or user-visible name of the CSV/file to import. If omitted, use the file the user attached or referenced.", "required": false }, { "name": "instruction", "description": 'Additional user goal, e.g. "then qualify the leads", "preserve owner phone as a custom field", or "only import restaurants in Manhattan".', "required": false }], "expected_calls": ["leadbay_resolve_import_rows", "leadbay_list_mappable_fields", "leadbay_create_custom_field", "leadbay_import_leads", "leadbay_import_and_qualify", "leadbay_add_note", "leadbay_import_status"], "failure_modes": ["Picks LEADBAY_ID from score alone, name-only, fuzzy-name-only, root-domain-only, brand-only, postcode-only, or city-only evidence", "Drops meaningful business notes or CRM record links instead of preserving them as custom fields or lead notes", "Treats a consumer mailbox domain (gmail.com, hotmail.com, ...) as the company domain", "Skips deriving company_domain from a business email when no website column exists (this kills match rate)", "Skips the COLUMN PRESERVATION PLAN byproduct before importing", "Skips the DECISION LOG byproduct before writing LEADBAY_ID", "Returns the imported records WITHOUT writing LEADBAY_ID values back into the user's file (leaves the user no audit trail of what matched)", "Fabricates leadIds, contact emails, or mapping IDs not present in the file or a tool response"] },
|
|
771
960
|
leadbay_log_outreach: { "name": "leadbay_log_outreach", "short_description": "Log outreach (an email I sent, a call I made, a meeting I had) on a\nspecific lead. Captures verification so the SDR pipeline trusts the entry.\n", "arguments": [{ "name": "lead_id", "description": "The lead UUID. Get it from leadbay_pull_leads or leadbay_research_lead_by_id.", "required": true }, { "name": "summary", "description": "1-2 sentences describing what I did (e.g. 'Sent intro email to CTO citing recent Hornsea contract').", "required": true }], "expected_calls": ["leadbay_report_outreach"], "failure_modes": ["Calls leadbay_report_outreach without first collecting a verification source", "Fabricates a gmail_message_id or calendar_event_id (the human team treats verification as canonical)", "Records outreach to a different lead_id than the one the user supplied", "Skips the dry_run step when the user is unsure what would be sent"] },
|
|
961
|
+
leadbay_plan_tour_in_city: { "name": "leadbay_plan_tour_in_city", "short_description": "Plan a field sales tour: in one flow, surface follow-ups + fresh\nDiscover leads in the target city via `leadbay_tour_plan`, render\nto a map, draft in-area outreach via `leadbay_prepare_outreach`,\nand optionally persist the selected accounts as a named campaign\nvia `leadbay_create_campaign`. Closes #3630 US1 end-to-end.\n", "arguments": [{ "name": "city", "description": "City or region the user is visiting (e.g. 'Limoges', 'Bay Area'). Used as the geo filter for both Monitor and Discover lookups.", "required": true }, { "name": "date", "description": "When the visit is (e.g. 'May 24', 'next Thursday'). Surfaced in the outreach drafts as 'I'll be in <city> on <date>'.", "required": false }], "expected_calls": ["leadbay_tour_plan", "leadbay_research_lead_by_id", "leadbay_prepare_outreach", "leadbay_create_campaign"], "failure_modes": ["Calls leadbay_followups_map (Monitor-only) instead of leadbay_tour_plan \u2014 loses the Discover (fresh-lead) half that the user explicitly asked for", "Calls leadbay_pull_leads then drops the geo filter \u2014 returns the lens-wide wishlist instead of city-relevant fresh leads", 'Skips the campaign-persist step ("would you like to save these as a tour?") \u2014 leaves the rep with a one-shot map but no follow-up artifact', "Creates a campaign WITHOUT asking the user first \u2014 the persist step is high-intent; offer it, don't assume", "Fabricates lead_ids when seeding the campaign instead of using the ids returned by tour_plan"] },
|
|
772
962
|
leadbay_prospecting_overview: { "name": "leadbay_prospecting_overview", "short_description": `Orientation for working with Leadbay from any host \u2014 discovery vs.
|
|
773
963
|
follow-up, the outreach loop, outcome recording, imports, pushback /
|
|
774
964
|
snooze, and the connected-outreach-tool registry. Trigger when the
|
|
@@ -778,7 +968,9 @@ should I follow up on" to "I'll send via lemlist".
|
|
|
778
968
|
`, "arguments": [], "expected_calls": ["leadbay_account_status", "leadbay_pull_leads", "leadbay_pull_followups", "leadbay_research_lead_by_id", "leadbay_research_lead_by_name_fuzzy", "leadbay_prepare_outreach", "leadbay_report_outreach", "leadbay_set_pushback", "leadbay_remove_pushback", "leadbay_bulk_qualify_leads", "leadbay_enrich_titles", "leadbay_import_leads", "leadbay_add_note", "leadbay_adjust_audience"], "failure_modes": ['Drives outreach without asking the user "how did it go?" afterwards \u2014 leaving prospecting_actions and epilogue_status stale', 'Says "epilogue" in user-facing dialogue instead of "outcome"', 'Says "Monitor" in user-facing dialogue instead of "follow-ups"', 'Treats a "not now / next quarter" reply as a note instead of routing through the pushback mechanism', "Drafts outreach in a generic format when the user has a connected sequencer (lemlist, Outreach.io, etc.) that has its own idiom", "Re-pulls leads without passing the captured lensId, allowing a backend lens shift to discard prior work", "Skips the STOP byproduct in any multi-step workflow it triggers", "Calls leadbay_pull_leads (Discover wishlist) for a follow-up query, or leadbay_pull_followups (Monitor view) for a discovery query \u2014 the two entry points read from different backend tables; the right orchestrators are leadbay_daily_check_in (discovery) and leadbay_followup_check_in (follow-up)"] },
|
|
779
969
|
leadbay_qualify_top_n: { "name": "leadbay_qualify_top_n", "short_description": "Bulk-qualify the top N un-qualified leads in the active lens. Uses\nleadbay_bulk_qualify_leads with a sensible default budget.\n", "arguments": [{ "name": "count", "description": "How many leads to qualify (default 10, max 25). Higher counts may take 5+ minutes.", "required": false }], "expected_calls": ["leadbay_bulk_qualify_leads", "leadbay_qualify_status", "leadbay_pull_leads", "leadbay_research_lead_by_id"], "failure_modes": ["Picks a count larger than the user asked for (or larger than the max 25)", "Glosses over still-running leads in the summary instead of naming them", "Recommends a lead from the existing qualified pool instead of one from this batch's actual results", 'Replaces the canonical pull_leads table with prose when rendering the newly-qualified batch (the per-tool RENDERING block is the structural contract; "standouts" commentary sits above it)', "Expands the qualify-status sentence into a card or table instead of the one-line status-inline render"] },
|
|
780
970
|
leadbay_refine_audience: { "name": "leadbay_refine_audience", "short_description": "Refine the kind of leads Leadbay surfaces beyond firmographics, with a\nfree-text instruction. Handles the clarification round-trip if the new\nprompt is ambiguous.\n", "arguments": [{ "name": "instruction", "description": "The refinement (e.g. 'focus on hospitals running their own IT'). Set to plain English.", "required": true }], "expected_calls": ["leadbay_refine_prompt", "leadbay_account_status"], "failure_modes": ["Calls leadbay_answer_clarification on the user's behalf instead of surfacing the clarification verbatim", "Glosses over the clarification options instead of presenting them as offered", "Promises immediate effect when status='applied' actually triggers an async intelligence recompute"] },
|
|
781
|
-
leadbay_research_a_domain: { "name": "leadbay_research_a_domain", "short_description": "Import a company by domain and run deep qualification + research in one\npass. Use when a colleague mentions a name and you want everything Leadbay\nknows about it.\n", "arguments": [{ "name": "domain", "description": "The company's primary domain (e.g. 'acme.com'). Protocol/path are stripped.", "required": true }], "expected_calls": ["leadbay_import_and_qualify", "leadbay_research_lead_by_id"], "failure_modes": ["Fabricates qualification answers not present in any tool response", "Reports certainty about fit when qualification didn't actually run (e.g. quota_blocked)", "Skips the research step after import completes", "Renders the research_lead_by_id result as a freeform narrative instead of the canonical research-company-card layout (the card with header score bar, pill row, signal sections, contacts table is the structural contract; commentary belongs ABOVE or BELOW it)", "Enumerates every imported lead in prose instead of the terse single-record summary from the import-result rendering snippet"] }
|
|
971
|
+
leadbay_research_a_domain: { "name": "leadbay_research_a_domain", "short_description": "Import a company by domain and run deep qualification + research in one\npass. Use when a colleague mentions a name and you want everything Leadbay\nknows about it.\n", "arguments": [{ "name": "domain", "description": "The company's primary domain (e.g. 'acme.com'). Protocol/path are stripped.", "required": true }], "expected_calls": ["leadbay_import_and_qualify", "leadbay_research_lead_by_id"], "failure_modes": ["Fabricates qualification answers not present in any tool response", "Reports certainty about fit when qualification didn't actually run (e.g. quota_blocked)", "Skips the research step after import completes", "Renders the research_lead_by_id result as a freeform narrative instead of the canonical research-company-card layout (the card with header score bar, pill row, signal sections, contacts table is the structural contract; commentary belongs ABOVE or BELOW it)", "Enumerates every imported lead in prose instead of the terse single-record summary from the import-result rendering snippet"] },
|
|
972
|
+
leadbay_setup_team_prospecting: { "name": "leadbay_setup_team_prospecting", "short_description": "Manager-led prospecting setup: conversationally turn a natural-language\naudience ask into a Leadbay lens, validate the candidate leads, and\npersist them as one or more named campaigns the rep(s) can work\nthrough. Closes #3630 US3 end-to-end (within the current\ncreator-scoped campaign visibility model).\n", "arguments": [{ "name": "audience", "description": "Natural-language audience description (e.g. 'plumbing companies with 10-50 employees in Seine-Maritime'). The lens-creation step (`leadbay_refine_prompt` \u2192 `leadbay_create_lens`) interprets it.", "required": true }, { "name": "rep_split", "description": "Optional: how to split the validated leads into per-rep campaigns. Free text \u2014 e.g. 'split by city' or 'one campaign per rep: John gets Tulsa, Sarah gets OKC'.", "required": false }], "expected_calls": ["leadbay_refine_prompt", "leadbay_create_lens", "leadbay_promote_lens", "leadbay_pull_leads", "leadbay_research_lead_by_id", "leadbay_create_campaign", "leadbay_add_leads_to_campaign"], "failure_modes": ["Skips the validation step \u2014 creates a campaign of unvetted leads from a freshly-created lens without giving the manager a chance to drop weak fits", "Creates ONE campaign for all reps without asking about the split \u2014 the user explicitly mentioned per-rep distribution and the prompt should honor it", "Pretends the backend supports cross-user assignment \u2014 campaigns are owned by the caller (creator-scoped). Surface this honestly instead of fabricating an assignment model", "Asks ALL clarifying questions inline before tool calls \u2014 instead, run the lens refinement loop with `leadbay_refine_prompt` which handles the clarification protocol natively"] },
|
|
973
|
+
leadbay_work_campaign: { "name": "leadbay_work_campaign", "short_description": "Work a campaign as a real outreach session: pick the campaign,\nassess what the user has (phones / emails / coords), then PROPOSE\nthe right session mode (call sheet, email sheet, enrich titles\nfirst, map). After they pick, render \u2014 and as they dictate\noutcomes per lead, record both note + epilogue via\n`leadbay_report_outreach` in one round trip.\n", "arguments": [{ "name": "campaign", "description": "Campaign name (fuzzy match against your own campaigns) or campaign UUID. Omit to list and pick interactively.", "required": false }, { "name": "mode", "description": "Optional: skip the readiness-assessment proposal and jump directly into 'call_sheet' / 'email_sheet' / 'map' / 'enrich_first'. Omit (recommended) and let the prompt propose based on the data.", "required": false }], "expected_calls": ["leadbay_list_campaigns", "leadbay_campaign_call_sheet", "leadbay_enrich_titles", "leadbay_report_outreach"], "failure_modes": ["Renders the call sheet immediately without proposing the right mode \u2014 if 60% of leads have no contacts, calling is futile; enrich first. Always assess `readiness` first.", "Auto-renders the map widget without asking \u2014 maps are intrusive when the user just wants to scroll a list. Map mode is a proposed option, not a default.", "Proposes map mode after the user has previously said they don't like maps \u2014 check conversation memory before adding 'View on a map' to the options list.", "Calls `leadbay_campaign_progression` instead of `leadbay_campaign_call_sheet` \u2014 progression has counts but no phones / LinkedIn / call-ready data; the user can't actually dial from progression rows.", "Renders contacts WITHOUT making the phone number a `[bare](tel:URL)` link \u2014 on mobile that breaks one-tap calling, which is the whole point of the cheat sheet.", "Records outreach WITHOUT epilogue_status \u2014 leaves the lead's pipeline state unchanged; the rep then sees the same lead surfaced again next session.", "Records outreach WITHOUT verification \u2014 verification.source/ref is REQUIRED. For calls, pass `{source: 'user_confirmed', ref: <user's exact words>}`.", "Loops through ALL leads in a 50-lead campaign before recording any outreach \u2014 the call-then-record loop must be per-lead, not batched."] }
|
|
782
974
|
};
|
|
783
975
|
var PROMPT_CATALOG_HEADER = `This server exposes the following workflow prompts via \`prompts/list\` and \`prompts/get\`. Some MCP clients render them as slash commands; if your client does not, you (the agent) should invoke them directly via \`prompts/get\` when the user's request matches one of the triggers described below.`;
|
|
784
976
|
var PROMPT_CATALOG_BULLETS = {
|
|
@@ -786,10 +978,13 @@ var PROMPT_CATALOG_BULLETS = {
|
|
|
786
978
|
leadbay_followup_check_in: `- \`leadbay_followup_check_in\`: Run the canonical follow-up check-in: surface KNOWN leads from the Monitor view that need re-engagement today, ranked by AI urgency, with the canonical pull_followups table layout. Trigger when the user asks "follow up", "already known leads", "leads I haven't contacted", "leads in [city]", "before my trip", "this week", "this month", "what's overdue", "who should I re-engage", or anything that implies pre-existing pipeline context.`,
|
|
787
979
|
leadbay_import_file: `- \`leadbay_import_file\` (optional args: file, instruction): Import a user-supplied CSV/file into Leadbay through five phases with evidence gates \u2014 scan, derive, resolve identities, preserve & commit, then optionally qualify and report. The job is to maximize how many rows the Leadbay system actually ingests and matches.`,
|
|
788
980
|
leadbay_log_outreach: `- \`leadbay_log_outreach\` (required args: lead_id, summary): Log outreach (an email I sent, a call I made, a meeting I had) on a specific lead. Captures verification so the SDR pipeline trusts the entry.`,
|
|
981
|
+
leadbay_plan_tour_in_city: `- \`leadbay_plan_tour_in_city\` (required args: city; optional args: date): Plan a field sales tour: in one flow, surface follow-ups + fresh Discover leads in the target city via \`leadbay_tour_plan\`, render to a map, draft in-area outreach via \`leadbay_prepare_outreach\`, and optionally persist the selected accounts as a named campaign via \`leadbay_create_campaign\`. Closes #3630 US1 end-to-end.`,
|
|
789
982
|
leadbay_prospecting_overview: `- \`leadbay_prospecting_overview\`: Orientation for working with Leadbay from any host \u2014 discovery vs. follow-up, the outreach loop, outcome recording, imports, pushback / snooze, and the connected-outreach-tool registry. Trigger when the conversation involves Leadbay leads, prospecting, pipeline, follow-up, outreach, or lens / ICP \u2014 anything from "show me my leads" to "what should I follow up on" to "I'll send via lemlist".`,
|
|
790
983
|
leadbay_qualify_top_n: `- \`leadbay_qualify_top_n\` (optional args: count): Bulk-qualify the top N un-qualified leads in the active lens. Uses leadbay_bulk_qualify_leads with a sensible default budget.`,
|
|
791
984
|
leadbay_refine_audience: `- \`leadbay_refine_audience\` (required args: instruction): Refine the kind of leads Leadbay surfaces beyond firmographics, with a free-text instruction. Handles the clarification round-trip if the new prompt is ambiguous.`,
|
|
792
|
-
leadbay_research_a_domain: `- \`leadbay_research_a_domain\` (required args: domain): Import a company by domain and run deep qualification + research in one pass. Use when a colleague mentions a name and you want everything Leadbay knows about it
|
|
985
|
+
leadbay_research_a_domain: `- \`leadbay_research_a_domain\` (required args: domain): Import a company by domain and run deep qualification + research in one pass. Use when a colleague mentions a name and you want everything Leadbay knows about it.`,
|
|
986
|
+
leadbay_setup_team_prospecting: `- \`leadbay_setup_team_prospecting\` (required args: audience; optional args: rep_split): Manager-led prospecting setup: conversationally turn a natural-language audience ask into a Leadbay lens, validate the candidate leads, and persist them as one or more named campaigns the rep(s) can work through. Closes #3630 US3 end-to-end (within the current creator-scoped campaign visibility model).`,
|
|
987
|
+
leadbay_work_campaign: `- \`leadbay_work_campaign\` (optional args: campaign, mode): Work a campaign as a real outreach session: pick the campaign, assess what the user has (phones / emails / coords), then PROPOSE the right session mode (call sheet, email sheet, enrich titles first, map). After they pick, render \u2014 and as they dictate outcomes per lead, record both note + epilogue via \`leadbay_report_outreach\` in one round trip.`
|
|
793
988
|
};
|
|
794
989
|
|
|
795
990
|
// src/prompts.ts
|
|
@@ -900,6 +1095,80 @@ var CATALOG = [
|
|
|
900
1095
|
)
|
|
901
1096
|
]
|
|
902
1097
|
},
|
|
1098
|
+
{
|
|
1099
|
+
name: "leadbay_plan_tour_in_city",
|
|
1100
|
+
description: PROMPT_META.leadbay_plan_tour_in_city.short_description,
|
|
1101
|
+
arguments: [
|
|
1102
|
+
{
|
|
1103
|
+
name: "city",
|
|
1104
|
+
description: "City or region the user is visiting (e.g. 'Limoges', 'Bay Area'). Used as the geo filter for both Monitor and Discover lookups.",
|
|
1105
|
+
required: true
|
|
1106
|
+
},
|
|
1107
|
+
{
|
|
1108
|
+
name: "date",
|
|
1109
|
+
description: "When the visit is (e.g. 'May 24', 'next Thursday'). Surfaced in the outreach drafts as 'I'll be in <city> on <date>'.",
|
|
1110
|
+
required: false
|
|
1111
|
+
}
|
|
1112
|
+
],
|
|
1113
|
+
render: (args) => [
|
|
1114
|
+
userMessage(
|
|
1115
|
+
substitutePlaceholders(leadbay_plan_tour_in_city, {
|
|
1116
|
+
city: args.city ?? "<missing>",
|
|
1117
|
+
date_paren: args.date ? ` on ${args.date}` : "",
|
|
1118
|
+
date_dash: args.date ? ` \u2013 ${args.date}` : ""
|
|
1119
|
+
})
|
|
1120
|
+
)
|
|
1121
|
+
]
|
|
1122
|
+
},
|
|
1123
|
+
{
|
|
1124
|
+
name: "leadbay_setup_team_prospecting",
|
|
1125
|
+
description: PROMPT_META.leadbay_setup_team_prospecting.short_description,
|
|
1126
|
+
arguments: [
|
|
1127
|
+
{
|
|
1128
|
+
name: "audience",
|
|
1129
|
+
description: "Natural-language audience description (e.g. 'plumbing companies with 10-50 employees in Seine-Maritime').",
|
|
1130
|
+
required: true
|
|
1131
|
+
},
|
|
1132
|
+
{
|
|
1133
|
+
name: "rep_split",
|
|
1134
|
+
description: "Optional: how to split validated leads into per-rep campaigns. Free text (e.g. 'split by city', 'one campaign per rep').",
|
|
1135
|
+
required: false
|
|
1136
|
+
}
|
|
1137
|
+
],
|
|
1138
|
+
render: (args) => [
|
|
1139
|
+
userMessage(
|
|
1140
|
+
substitutePlaceholders(leadbay_setup_team_prospecting, {
|
|
1141
|
+
audience: args.audience ?? "<missing>",
|
|
1142
|
+
rep_split_block: args.rep_split ? `Rep split preference: **${args.rep_split}**
|
|
1143
|
+
` : ""
|
|
1144
|
+
})
|
|
1145
|
+
)
|
|
1146
|
+
]
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
name: "leadbay_work_campaign",
|
|
1150
|
+
description: PROMPT_META.leadbay_work_campaign.short_description,
|
|
1151
|
+
arguments: [
|
|
1152
|
+
{
|
|
1153
|
+
name: "campaign",
|
|
1154
|
+
description: "Campaign name (fuzzy match) or campaign UUID. Omit to list and pick interactively.",
|
|
1155
|
+
required: false
|
|
1156
|
+
},
|
|
1157
|
+
{
|
|
1158
|
+
name: "mode",
|
|
1159
|
+
description: "Optional: skip readiness proposal and jump to 'call_sheet', 'email_sheet', 'map', or 'enrich_first'. Omit to let the prompt propose based on campaign data.",
|
|
1160
|
+
required: false
|
|
1161
|
+
}
|
|
1162
|
+
],
|
|
1163
|
+
render: (args) => [
|
|
1164
|
+
userMessage(
|
|
1165
|
+
substitutePlaceholders(leadbay_work_campaign, {
|
|
1166
|
+
campaign_or_default: args.campaign ?? "<pick from the list>",
|
|
1167
|
+
mode_paren: args.mode ? ` (mode: ${args.mode})` : ""
|
|
1168
|
+
})
|
|
1169
|
+
)
|
|
1170
|
+
]
|
|
1171
|
+
},
|
|
903
1172
|
{
|
|
904
1173
|
name: "leadbay_qualify_top_n",
|
|
905
1174
|
description: PROMPT_META.leadbay_qualify_top_n.short_description,
|
|
@@ -1032,6 +1301,12 @@ var EMBEDDED_SENTRY_DSN = "https://301f1c433433b76132956ed5415bea19@o45058744368
|
|
|
1032
1301
|
var EV_TOOL_CALL = "mcp tool called";
|
|
1033
1302
|
var EV_QUOTA_HIT = "mcp quota hit";
|
|
1034
1303
|
var EV_TOPUP_LINK = "mcp topup link created";
|
|
1304
|
+
var EV_STARTUP = "mcp startup";
|
|
1305
|
+
var EV_MCP_UPDATE_CHECK = "mcp update check";
|
|
1306
|
+
var EV_MCP_UPDATE_PROMPTED = "mcp update prompted";
|
|
1307
|
+
var EV_MCP_UPDATE_INSTALL_CLICKED = "mcp update install_clicked";
|
|
1308
|
+
var EV_MCP_UPDATE_DISMISSED = "mcp update dismissed";
|
|
1309
|
+
var EV_MCP_VERSION_UPDATED = "mcp version updated";
|
|
1035
1310
|
|
|
1036
1311
|
// src/telemetry.ts
|
|
1037
1312
|
var NOOP_TELEMETRY = {
|
|
@@ -1043,8 +1318,20 @@ var NOOP_TELEMETRY = {
|
|
|
1043
1318
|
},
|
|
1044
1319
|
captureTopupLink: () => {
|
|
1045
1320
|
},
|
|
1321
|
+
captureStartup: () => {
|
|
1322
|
+
},
|
|
1046
1323
|
captureException: () => {
|
|
1047
1324
|
},
|
|
1325
|
+
captureUpdateCheck: () => {
|
|
1326
|
+
},
|
|
1327
|
+
captureUpdatePrompted: () => {
|
|
1328
|
+
},
|
|
1329
|
+
captureUpdateInstallClicked: () => {
|
|
1330
|
+
},
|
|
1331
|
+
captureUpdateDismissed: () => {
|
|
1332
|
+
},
|
|
1333
|
+
captureVersionUpdated: () => {
|
|
1334
|
+
},
|
|
1048
1335
|
shutdown: async () => {
|
|
1049
1336
|
}
|
|
1050
1337
|
};
|
|
@@ -1088,7 +1375,12 @@ function initTelemetry(opts) {
|
|
|
1088
1375
|
profilesSampleRate: 0,
|
|
1089
1376
|
defaultIntegrations: false,
|
|
1090
1377
|
integrations: [Sentry.httpIntegration()],
|
|
1091
|
-
sendDefaultPii: false
|
|
1378
|
+
sendDefaultPii: false,
|
|
1379
|
+
// Tag every captured event with the surface so Sentry views can
|
|
1380
|
+
// split MCP issues from web-app issues without per-call work.
|
|
1381
|
+
initialScope: {
|
|
1382
|
+
tags: { source: "mcp" }
|
|
1383
|
+
}
|
|
1092
1384
|
});
|
|
1093
1385
|
sentryReady = true;
|
|
1094
1386
|
}
|
|
@@ -1105,6 +1397,11 @@ function initTelemetry(opts) {
|
|
|
1105
1397
|
const pendingEvents = [];
|
|
1106
1398
|
let region = "unknown";
|
|
1107
1399
|
const baseProps = () => ({
|
|
1400
|
+
// Always tag MCP-originated events so PostHog dashboards can split
|
|
1401
|
+
// MCP usage from the web app and any future surfaces. The value
|
|
1402
|
+
// ("mcp") is the canonical source identifier — match it in any
|
|
1403
|
+
// PostHog filter or insight that should isolate the MCP surface.
|
|
1404
|
+
source: "mcp",
|
|
1108
1405
|
mcp_version: version,
|
|
1109
1406
|
node_version: process.versions.node,
|
|
1110
1407
|
platform: process.platform,
|
|
@@ -1204,6 +1501,24 @@ function initTelemetry(opts) {
|
|
|
1204
1501
|
captureTopupLink(props) {
|
|
1205
1502
|
emit(EV_TOPUP_LINK, { ...props });
|
|
1206
1503
|
},
|
|
1504
|
+
captureStartup(props) {
|
|
1505
|
+
emit(EV_STARTUP, { ...props });
|
|
1506
|
+
},
|
|
1507
|
+
captureUpdateCheck(props) {
|
|
1508
|
+
emit(EV_MCP_UPDATE_CHECK, { ...props });
|
|
1509
|
+
},
|
|
1510
|
+
captureUpdatePrompted(props) {
|
|
1511
|
+
emit(EV_MCP_UPDATE_PROMPTED, { ...props });
|
|
1512
|
+
},
|
|
1513
|
+
captureUpdateInstallClicked(props) {
|
|
1514
|
+
emit(EV_MCP_UPDATE_INSTALL_CLICKED, { ...props });
|
|
1515
|
+
},
|
|
1516
|
+
captureUpdateDismissed(props) {
|
|
1517
|
+
emit(EV_MCP_UPDATE_DISMISSED, { ...props });
|
|
1518
|
+
},
|
|
1519
|
+
captureVersionUpdated(props) {
|
|
1520
|
+
emit(EV_MCP_VERSION_UPDATED, { ...props });
|
|
1521
|
+
},
|
|
1207
1522
|
captureException(err, ctx) {
|
|
1208
1523
|
if (!sentryReady) return;
|
|
1209
1524
|
try {
|
|
@@ -1227,6 +1542,327 @@ function initTelemetry(opts) {
|
|
|
1227
1542
|
};
|
|
1228
1543
|
}
|
|
1229
1544
|
|
|
1545
|
+
// src/update-check.ts
|
|
1546
|
+
var cachedInfo = null;
|
|
1547
|
+
var checkInFlight = false;
|
|
1548
|
+
function getCachedUpdateInfo() {
|
|
1549
|
+
return cachedInfo;
|
|
1550
|
+
}
|
|
1551
|
+
var RELEASES_LATEST_URL = "https://api.github.com/repos/leadbay/leadclaw/releases/latest";
|
|
1552
|
+
var CHECK_THROTTLE_MS = 24 * 60 * 60 * 1e3;
|
|
1553
|
+
var FETCH_TIMEOUT_MS = 5e3;
|
|
1554
|
+
var USER_AGENT = "leadbay-mcp-update-check";
|
|
1555
|
+
function parseTagName(tag) {
|
|
1556
|
+
const stripped = tag.replace(/^mcp-v?/, "").replace(/^v/, "");
|
|
1557
|
+
if (!/^\d+\.\d+\.\d+/.test(stripped)) return null;
|
|
1558
|
+
return stripped;
|
|
1559
|
+
}
|
|
1560
|
+
function compareSemver(a, b) {
|
|
1561
|
+
const [aCore, aPre] = a.split("-", 2);
|
|
1562
|
+
const [bCore, bPre] = b.split("-", 2);
|
|
1563
|
+
const aParts = aCore.split(".").map((n) => parseInt(n, 10));
|
|
1564
|
+
const bParts = bCore.split(".").map((n) => parseInt(n, 10));
|
|
1565
|
+
for (let i = 0; i < 3; i++) {
|
|
1566
|
+
const av = aParts[i] ?? 0;
|
|
1567
|
+
const bv = bParts[i] ?? 0;
|
|
1568
|
+
if (av > bv) return 1;
|
|
1569
|
+
if (av < bv) return -1;
|
|
1570
|
+
}
|
|
1571
|
+
if (!aPre && !bPre) return 0;
|
|
1572
|
+
if (!aPre && bPre) return 1;
|
|
1573
|
+
if (aPre && !bPre) return -1;
|
|
1574
|
+
const aIds = aPre.split(".");
|
|
1575
|
+
const bIds = bPre.split(".");
|
|
1576
|
+
const len = Math.max(aIds.length, bIds.length);
|
|
1577
|
+
for (let i = 0; i < len; i++) {
|
|
1578
|
+
const ai = aIds[i];
|
|
1579
|
+
const bi = bIds[i];
|
|
1580
|
+
if (ai === void 0) return -1;
|
|
1581
|
+
if (bi === void 0) return 1;
|
|
1582
|
+
const aNum = /^\d+$/.test(ai);
|
|
1583
|
+
const bNum = /^\d+$/.test(bi);
|
|
1584
|
+
if (aNum && bNum) {
|
|
1585
|
+
const d = parseInt(ai, 10) - parseInt(bi, 10);
|
|
1586
|
+
if (d !== 0) return d > 0 ? 1 : -1;
|
|
1587
|
+
} else if (aNum !== bNum) {
|
|
1588
|
+
return aNum ? -1 : 1;
|
|
1589
|
+
} else if (ai !== bi) {
|
|
1590
|
+
return ai > bi ? 1 : -1;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
return 0;
|
|
1594
|
+
}
|
|
1595
|
+
function pickMcpbAsset(rel) {
|
|
1596
|
+
if (!Array.isArray(rel.assets)) return void 0;
|
|
1597
|
+
const mcpb = rel.assets.find(
|
|
1598
|
+
(a) => typeof a.name === "string" && a.name.endsWith(".mcpb")
|
|
1599
|
+
);
|
|
1600
|
+
if (mcpb?.browser_download_url) return mcpb.browser_download_url;
|
|
1601
|
+
const dxt = rel.assets.find(
|
|
1602
|
+
(a) => typeof a.name === "string" && a.name.endsWith(".dxt")
|
|
1603
|
+
);
|
|
1604
|
+
return dxt?.browser_download_url;
|
|
1605
|
+
}
|
|
1606
|
+
async function checkForUpdate(opts) {
|
|
1607
|
+
if (checkInFlight) return cachedInfo;
|
|
1608
|
+
checkInFlight = true;
|
|
1609
|
+
try {
|
|
1610
|
+
return await doCheck(opts);
|
|
1611
|
+
} finally {
|
|
1612
|
+
checkInFlight = false;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
async function doCheck(opts) {
|
|
1616
|
+
const now = opts.now ?? Date.now;
|
|
1617
|
+
const url = opts.releasesUrl ?? RELEASES_LATEST_URL;
|
|
1618
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
1619
|
+
const currentVersion = opts.currentVersion;
|
|
1620
|
+
const state = await opts.stateStore.read();
|
|
1621
|
+
const within = now() - state.last_check_time < CHECK_THROTTLE_MS;
|
|
1622
|
+
if (within && state.latest_known_version && state.latest_known_mcpb_url && state.latest_known_release_url) {
|
|
1623
|
+
const cached = buildInfoIfUpgrade(
|
|
1624
|
+
currentVersion,
|
|
1625
|
+
state.latest_known_version,
|
|
1626
|
+
state.latest_known_mcpb_url,
|
|
1627
|
+
state.latest_known_release_url,
|
|
1628
|
+
state.suppressed_versions,
|
|
1629
|
+
state.remind_until,
|
|
1630
|
+
now()
|
|
1631
|
+
);
|
|
1632
|
+
cachedInfo = cached;
|
|
1633
|
+
return cached;
|
|
1634
|
+
}
|
|
1635
|
+
let status;
|
|
1636
|
+
let body = null;
|
|
1637
|
+
let nextEtag;
|
|
1638
|
+
try {
|
|
1639
|
+
const ctrl = new AbortController();
|
|
1640
|
+
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
1641
|
+
let resp;
|
|
1642
|
+
try {
|
|
1643
|
+
resp = await fetchImpl(url, {
|
|
1644
|
+
method: "GET",
|
|
1645
|
+
headers: {
|
|
1646
|
+
Accept: "application/vnd.github+json",
|
|
1647
|
+
"User-Agent": USER_AGENT,
|
|
1648
|
+
...state.etag ? { "If-None-Match": state.etag } : {}
|
|
1649
|
+
},
|
|
1650
|
+
signal: ctrl.signal
|
|
1651
|
+
});
|
|
1652
|
+
} finally {
|
|
1653
|
+
clearTimeout(timer);
|
|
1654
|
+
}
|
|
1655
|
+
status = resp.status;
|
|
1656
|
+
nextEtag = resp.headers.get("etag") ?? state.etag;
|
|
1657
|
+
if (status === 200) {
|
|
1658
|
+
body = await resp.json();
|
|
1659
|
+
}
|
|
1660
|
+
} catch (err) {
|
|
1661
|
+
opts.logger?.warn?.(
|
|
1662
|
+
`update_check.fetch_failed ${err?.message ?? err}`
|
|
1663
|
+
);
|
|
1664
|
+
opts.telemetry.captureUpdateCheck?.({
|
|
1665
|
+
current_version: currentVersion,
|
|
1666
|
+
check_error: String(err?.message ?? err)
|
|
1667
|
+
});
|
|
1668
|
+
await opts.stateStore.update((cur) => ({ ...cur, last_check_time: now() }));
|
|
1669
|
+
return null;
|
|
1670
|
+
}
|
|
1671
|
+
if (status !== 200 && status !== 304) {
|
|
1672
|
+
opts.telemetry.captureUpdateCheck?.({
|
|
1673
|
+
current_version: currentVersion,
|
|
1674
|
+
check_error: `http_${status}`
|
|
1675
|
+
});
|
|
1676
|
+
await opts.stateStore.update((cur) => ({ ...cur, last_check_time: now() }));
|
|
1677
|
+
return null;
|
|
1678
|
+
}
|
|
1679
|
+
let latestVersion;
|
|
1680
|
+
let mcpbUrl;
|
|
1681
|
+
let releaseUrl;
|
|
1682
|
+
if (status === 200 && body) {
|
|
1683
|
+
const parsed = body.tag_name ? parseTagName(body.tag_name) : null;
|
|
1684
|
+
if (parsed) {
|
|
1685
|
+
latestVersion = parsed;
|
|
1686
|
+
mcpbUrl = pickMcpbAsset(body);
|
|
1687
|
+
releaseUrl = body.html_url;
|
|
1688
|
+
}
|
|
1689
|
+
} else {
|
|
1690
|
+
latestVersion = state.latest_known_version;
|
|
1691
|
+
mcpbUrl = state.latest_known_mcpb_url;
|
|
1692
|
+
releaseUrl = state.latest_known_release_url;
|
|
1693
|
+
}
|
|
1694
|
+
const persisted = await opts.stateStore.update((cur) => ({
|
|
1695
|
+
...cur,
|
|
1696
|
+
last_check_time: now(),
|
|
1697
|
+
etag: nextEtag,
|
|
1698
|
+
latest_known_version: latestVersion ?? cur.latest_known_version,
|
|
1699
|
+
latest_known_mcpb_url: mcpbUrl ?? cur.latest_known_mcpb_url,
|
|
1700
|
+
latest_known_release_url: releaseUrl ?? cur.latest_known_release_url
|
|
1701
|
+
}));
|
|
1702
|
+
opts.telemetry.captureUpdateCheck?.({
|
|
1703
|
+
current_version: currentVersion,
|
|
1704
|
+
latest_version: persisted.latest_known_version
|
|
1705
|
+
});
|
|
1706
|
+
const info = buildInfoIfUpgrade(
|
|
1707
|
+
currentVersion,
|
|
1708
|
+
persisted.latest_known_version,
|
|
1709
|
+
persisted.latest_known_mcpb_url,
|
|
1710
|
+
persisted.latest_known_release_url,
|
|
1711
|
+
persisted.suppressed_versions,
|
|
1712
|
+
persisted.remind_until,
|
|
1713
|
+
now()
|
|
1714
|
+
);
|
|
1715
|
+
cachedInfo = info;
|
|
1716
|
+
return info;
|
|
1717
|
+
}
|
|
1718
|
+
function buildInfoIfUpgrade(currentVersion, latestVersion, mcpbUrl, releaseUrl, suppressed, remindUntil, nowMs) {
|
|
1719
|
+
if (!latestVersion || !mcpbUrl || !releaseUrl) return null;
|
|
1720
|
+
if (compareSemver(latestVersion, currentVersion) <= 0) return null;
|
|
1721
|
+
if (suppressed.includes(latestVersion)) return null;
|
|
1722
|
+
if (remindUntil && remindUntil > nowMs) return null;
|
|
1723
|
+
return {
|
|
1724
|
+
current_version: currentVersion,
|
|
1725
|
+
latest_version: latestVersion,
|
|
1726
|
+
mcpb_url: mcpbUrl,
|
|
1727
|
+
release_url: releaseUrl
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
async function recordRunningVersion(currentVersion, stateStore, telemetry) {
|
|
1731
|
+
const cur = await stateStore.read();
|
|
1732
|
+
const prev = cur.previous_running_version;
|
|
1733
|
+
if (prev === currentVersion) return;
|
|
1734
|
+
if (prev && compareSemver(currentVersion, prev) > 0) {
|
|
1735
|
+
telemetry.captureVersionUpdated?.({
|
|
1736
|
+
from_version: prev,
|
|
1737
|
+
to_version: currentVersion
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
await stateStore.update((s) => ({
|
|
1741
|
+
...s,
|
|
1742
|
+
previous_running_version: currentVersion,
|
|
1743
|
+
// Clear any prior "remind me tomorrow" for the version we just landed on —
|
|
1744
|
+
// the user has effectively answered the prompt by upgrading.
|
|
1745
|
+
remind_until: void 0,
|
|
1746
|
+
// Drop suppression of versions we've now surpassed.
|
|
1747
|
+
suppressed_versions: s.suppressed_versions.filter(
|
|
1748
|
+
(v) => compareSemver(v, currentVersion) > 0
|
|
1749
|
+
)
|
|
1750
|
+
}));
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// src/update-tool.ts
|
|
1754
|
+
var TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1e3;
|
|
1755
|
+
var DESCRIPTION = "Record the user's choice on an update prompt surfaced via `update_available` on leadbay_account_status. Pass `action: 'install' | 'remind_tomorrow' | 'skip'` and `version` (the `latest_version` from the prompt). On 'install', the server returns `{ mcpb_url, release_url }` \u2014 show the user a clickable link to mcpb_url so Claude Desktop's native installer opens it. On 'remind_tomorrow' the server suppresses the prompt for 24 hours. On 'skip' the version is suppressed permanently. Call this tool EXACTLY ONCE per prompt \u2014 do not loop, and do not call it speculatively when no update_available block is present.";
|
|
1756
|
+
function buildAcknowledgeUpdateTool(opts) {
|
|
1757
|
+
const now = opts.now ?? Date.now;
|
|
1758
|
+
return {
|
|
1759
|
+
name: "leadbay_acknowledge_update",
|
|
1760
|
+
annotations: {
|
|
1761
|
+
title: "Acknowledge a Leadbay MCP update prompt",
|
|
1762
|
+
readOnlyHint: false,
|
|
1763
|
+
destructiveHint: false,
|
|
1764
|
+
idempotentHint: false,
|
|
1765
|
+
openWorldHint: false
|
|
1766
|
+
},
|
|
1767
|
+
description: DESCRIPTION,
|
|
1768
|
+
inputSchema: {
|
|
1769
|
+
type: "object",
|
|
1770
|
+
properties: {
|
|
1771
|
+
action: {
|
|
1772
|
+
type: "string",
|
|
1773
|
+
enum: ["install", "remind_tomorrow", "skip"],
|
|
1774
|
+
description: "What the user chose: 'install' (they'll click the link), 'remind_tomorrow' (suppress for 24h), or 'skip' (suppress this version permanently)."
|
|
1775
|
+
},
|
|
1776
|
+
version: {
|
|
1777
|
+
type: "string",
|
|
1778
|
+
description: "The latest_version string from the update_available block. Used for suppression and event correlation."
|
|
1779
|
+
}
|
|
1780
|
+
},
|
|
1781
|
+
required: ["action", "version"],
|
|
1782
|
+
additionalProperties: false
|
|
1783
|
+
},
|
|
1784
|
+
outputSchema: {
|
|
1785
|
+
type: "object",
|
|
1786
|
+
properties: {
|
|
1787
|
+
ok: { type: "boolean" },
|
|
1788
|
+
action: { type: "string" },
|
|
1789
|
+
version: { type: "string" },
|
|
1790
|
+
message: { type: "string" },
|
|
1791
|
+
mcpb_url: { type: ["string", "null"] },
|
|
1792
|
+
release_url: { type: ["string", "null"] }
|
|
1793
|
+
},
|
|
1794
|
+
required: ["ok", "action", "version", "message"]
|
|
1795
|
+
},
|
|
1796
|
+
execute: async (_client, args, _ctx) => {
|
|
1797
|
+
const action = String(args?.action ?? "");
|
|
1798
|
+
const version = String(args?.version ?? "");
|
|
1799
|
+
if (action !== "install" && action !== "remind_tomorrow" && action !== "skip") {
|
|
1800
|
+
return {
|
|
1801
|
+
error: true,
|
|
1802
|
+
code: "INVALID_ARGUMENT",
|
|
1803
|
+
message: `Unknown action '${action}' for leadbay_acknowledge_update.`,
|
|
1804
|
+
hint: "Pass one of: 'install', 'remind_tomorrow', 'skip'."
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
if (!version) {
|
|
1808
|
+
return {
|
|
1809
|
+
error: true,
|
|
1810
|
+
code: "INVALID_ARGUMENT",
|
|
1811
|
+
message: "Missing required `version` argument.",
|
|
1812
|
+
hint: "Pass the latest_version string from the update_available block."
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
if (action === "install") {
|
|
1816
|
+
const state = await opts.stateStore.read();
|
|
1817
|
+
opts.telemetry.captureUpdateInstallClicked?.({
|
|
1818
|
+
current_version: opts.currentVersion,
|
|
1819
|
+
latest_version: version
|
|
1820
|
+
});
|
|
1821
|
+
return {
|
|
1822
|
+
ok: true,
|
|
1823
|
+
action,
|
|
1824
|
+
version,
|
|
1825
|
+
mcpb_url: state.latest_known_mcpb_url ?? null,
|
|
1826
|
+
release_url: state.latest_known_release_url ?? null,
|
|
1827
|
+
message: state.latest_known_mcpb_url ? "Show the user the mcpb_url as a clickable link \u2014 opening it in Claude Desktop runs the native installer." : "No .mcpb URL is cached. Direct the user to the release_url to download manually."
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
if (action === "remind_tomorrow") {
|
|
1831
|
+
await opts.stateStore.update((cur) => ({
|
|
1832
|
+
...cur,
|
|
1833
|
+
remind_until: now() + TWENTY_FOUR_HOURS_MS
|
|
1834
|
+
}));
|
|
1835
|
+
opts.telemetry.captureUpdateDismissed?.({
|
|
1836
|
+
current_version: opts.currentVersion,
|
|
1837
|
+
latest_version: version,
|
|
1838
|
+
action: "remind_tomorrow"
|
|
1839
|
+
});
|
|
1840
|
+
return {
|
|
1841
|
+
ok: true,
|
|
1842
|
+
action,
|
|
1843
|
+
version,
|
|
1844
|
+
message: "Reminder snoozed for 24 hours. No further prompts will appear in that window."
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
await opts.stateStore.update((cur) => ({
|
|
1848
|
+
...cur,
|
|
1849
|
+
suppressed_versions: cur.suppressed_versions.includes(version) ? cur.suppressed_versions : [...cur.suppressed_versions, version]
|
|
1850
|
+
}));
|
|
1851
|
+
opts.telemetry.captureUpdateDismissed?.({
|
|
1852
|
+
current_version: opts.currentVersion,
|
|
1853
|
+
latest_version: version,
|
|
1854
|
+
action: "skip"
|
|
1855
|
+
});
|
|
1856
|
+
return {
|
|
1857
|
+
ok: true,
|
|
1858
|
+
action,
|
|
1859
|
+
version,
|
|
1860
|
+
message: `Version ${version} suppressed. Future releases will still prompt.`
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1230
1866
|
// src/server.ts
|
|
1231
1867
|
var VERIFICATION_MANDATE = "After every email, call, message, or meeting with a lead's contact, you MUST call leadbay_report_outreach with verification={source, ref} (gmail_message_id from the Gmail send, calendar_event_id from a booking, or user_confirmed='<the user's literal confirmation>'). Skipping or fabricating verification poisons the human team's pipeline.";
|
|
1232
1868
|
var MENTAL_MODEL_PARAGRAPH = "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.";
|
|
@@ -1249,6 +1885,10 @@ function buildStartHereParagraph(has) {
|
|
|
1249
1885
|
}
|
|
1250
1886
|
return base + " When the user asks for refinement, contact enrichment, audience changes, or outreach reporting, tell them: those actions require write tools, currently disabled. Re-enable by removing `LEADBAY_MCP_WRITE=0` from your MCP client config and restarting the client. Also: do not promise to log outreach \u2014 the report_outreach tool is not available in this configuration.";
|
|
1251
1887
|
}
|
|
1888
|
+
function buildUpdateAvailableParagraph(has) {
|
|
1889
|
+
if (!has("leadbay_acknowledge_update")) return null;
|
|
1890
|
+
return "MCP auto-update: when `leadbay_account_status` returns an `update_available` field (`{ current_version, latest_version, mcpb_url, release_url }`), a newer MCP server release is published and the user has NOT suppressed it. Surface a prompt via `ask_user_input_v0` with EXACTLY these three options: \"Install now\", \"Remind me tomorrow\", \"Skip this version\". Map the user's choice to `leadbay_acknowledge_update({ action: 'install' | 'remind_tomorrow' | 'skip', version: latest_version })`. On 'install', the tool returns `mcpb_url` \u2014 render it as a clickable markdown link the user can open in Claude Desktop (the .mcpb extension triggers the native installer). The user does NOT need to restart anything before clicking \u2014 the new server takes effect on the next MCP session. Prompt the user ONCE per session per version \u2014 don't re-prompt within the same chat after they've acknowledged.";
|
|
1891
|
+
}
|
|
1252
1892
|
function buildRhythmParagraph(has) {
|
|
1253
1893
|
if (has("leadbay_report_outreach")) {
|
|
1254
1894
|
return "Suggested rhythm: a healthy agent pattern is a daily check-in \u2014 pull fresh leads, skim the auto-qualified top, deepen 1-3 promising ones, propose outreach to the user, then leadbay_report_outreach on what actually got sent. If your host supports scheduling, offer to set up a daily run.";
|
|
@@ -1329,6 +1969,8 @@ function buildServerInstructions(exposed) {
|
|
|
1329
1969
|
parts.push(buildScoringParagraph(has));
|
|
1330
1970
|
parts.push(buildStartHereParagraph(has));
|
|
1331
1971
|
parts.push(buildRhythmParagraph(has));
|
|
1972
|
+
const updateParagraph = buildUpdateAvailableParagraph(has);
|
|
1973
|
+
if (updateParagraph) parts.push(updateParagraph);
|
|
1332
1974
|
const promptsCatalog = buildPromptsCatalogParagraph(has);
|
|
1333
1975
|
if (promptsCatalog) parts.push(promptsCatalog);
|
|
1334
1976
|
parts.push(RESOURCES_PARAGRAPH);
|
|
@@ -1376,6 +2018,16 @@ function buildServer(client, opts = {}) {
|
|
|
1376
2018
|
exposedTools.push(...granularWriteTools);
|
|
1377
2019
|
}
|
|
1378
2020
|
}
|
|
2021
|
+
if (opts.updateStateStore) {
|
|
2022
|
+
exposedTools.push(
|
|
2023
|
+
buildAcknowledgeUpdateTool({
|
|
2024
|
+
stateStore: opts.updateStateStore,
|
|
2025
|
+
telemetry: opts.telemetry ?? NOOP_TELEMETRY,
|
|
2026
|
+
currentVersion: opts.version ?? "0.0.0-dev",
|
|
2027
|
+
logger: opts.logger
|
|
2028
|
+
})
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
1379
2031
|
if (opts.extraTools) {
|
|
1380
2032
|
exposedTools.push(...opts.extraTools);
|
|
1381
2033
|
}
|
|
@@ -1465,10 +2117,45 @@ function buildServer(client, opts = {}) {
|
|
|
1465
2117
|
const DEBUG_RAW = process.env.LEADBAY_DEBUG ?? "";
|
|
1466
2118
|
const DEBUG_ON = DEBUG_RAW === "1" || DEBUG_RAW.toLowerCase() === "true";
|
|
1467
2119
|
const telemetry = opts.telemetry ?? NOOP_TELEMETRY;
|
|
2120
|
+
const promptedVersionsThisSession = /* @__PURE__ */ new Set();
|
|
2121
|
+
const serverVersion = opts.version ?? "0.0.0-dev";
|
|
2122
|
+
const UPDATE_CHECK_DISABLED = process.env.LEADBAY_UPDATE_CHECK_DISABLED === "1";
|
|
2123
|
+
const maybeRefreshUpdate = () => {
|
|
2124
|
+
if (UPDATE_CHECK_DISABLED) return;
|
|
2125
|
+
if (!opts.updateStateStore) return;
|
|
2126
|
+
void checkForUpdate({
|
|
2127
|
+
currentVersion: serverVersion,
|
|
2128
|
+
stateStore: opts.updateStateStore,
|
|
2129
|
+
telemetry,
|
|
2130
|
+
logger: opts.logger
|
|
2131
|
+
}).catch((err) => {
|
|
2132
|
+
opts.logger?.warn?.(
|
|
2133
|
+
`update_check.unexpected ${err?.message ?? err}`
|
|
2134
|
+
);
|
|
2135
|
+
});
|
|
2136
|
+
};
|
|
2137
|
+
const maybeAttachUpdate = (toolName, result) => {
|
|
2138
|
+
if (toolName !== "leadbay_account_status") return;
|
|
2139
|
+
if (!opts.updateStateStore) return;
|
|
2140
|
+
if (result === null || typeof result !== "object" || Array.isArray(result)) {
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
const info = getCachedUpdateInfo();
|
|
2144
|
+
if (!info) return;
|
|
2145
|
+
result.update_available = info;
|
|
2146
|
+
if (!promptedVersionsThisSession.has(info.latest_version)) {
|
|
2147
|
+
promptedVersionsThisSession.add(info.latest_version);
|
|
2148
|
+
telemetry.captureUpdatePrompted?.({
|
|
2149
|
+
current_version: serverVersion,
|
|
2150
|
+
latest_version: info.latest_version
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
};
|
|
1468
2154
|
const isLeadbayBusinessError = (err) => err != null && typeof err === "object" && err.error === true && typeof err.code === "string";
|
|
1469
2155
|
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
1470
2156
|
const callStart = Date.now();
|
|
1471
2157
|
const name = req.params.name;
|
|
2158
|
+
maybeRefreshUpdate();
|
|
1472
2159
|
const tool = toolByName.get(name);
|
|
1473
2160
|
if (!tool) {
|
|
1474
2161
|
return {
|
|
@@ -1522,6 +2209,7 @@ function buildServer(client, opts = {}) {
|
|
|
1522
2209
|
progress,
|
|
1523
2210
|
elicit
|
|
1524
2211
|
});
|
|
2212
|
+
maybeAttachUpdate(name, result);
|
|
1525
2213
|
if (result && typeof result === "object" && result.error === true) {
|
|
1526
2214
|
const envText = formatErrorForLLM(result);
|
|
1527
2215
|
const envDur = Date.now() - callStart;
|
|
@@ -1659,9 +2347,202 @@ function buildServer(client, opts = {}) {
|
|
|
1659
2347
|
return server;
|
|
1660
2348
|
}
|
|
1661
2349
|
|
|
2350
|
+
// src/update-state.ts
|
|
2351
|
+
import {
|
|
2352
|
+
mkdir as mkdirAsync,
|
|
2353
|
+
lstat,
|
|
2354
|
+
open as fsOpen,
|
|
2355
|
+
readFile,
|
|
2356
|
+
rename,
|
|
2357
|
+
stat,
|
|
2358
|
+
unlink
|
|
2359
|
+
} from "fs/promises";
|
|
2360
|
+
import { constants as fsConstants } from "fs";
|
|
2361
|
+
import { dirname, resolve as resolvePath } from "path";
|
|
2362
|
+
import { homedir } from "os";
|
|
2363
|
+
function emptyState() {
|
|
2364
|
+
return {
|
|
2365
|
+
last_check_time: 0,
|
|
2366
|
+
suppressed_versions: []
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
var UpdateStateStore = class {
|
|
2370
|
+
backend;
|
|
2371
|
+
path;
|
|
2372
|
+
logger;
|
|
2373
|
+
allowUnsafePath;
|
|
2374
|
+
now;
|
|
2375
|
+
memory = emptyState();
|
|
2376
|
+
initialized = false;
|
|
2377
|
+
constructor(opts) {
|
|
2378
|
+
this.backend = opts.backend;
|
|
2379
|
+
this.logger = opts.logger;
|
|
2380
|
+
this.allowUnsafePath = !!opts.allowUnsafePath;
|
|
2381
|
+
this.now = opts.now ?? Date.now;
|
|
2382
|
+
if (this.backend === "file") {
|
|
2383
|
+
if (!opts.path) {
|
|
2384
|
+
throw new Error("UpdateStateStore: path is required when backend=file");
|
|
2385
|
+
}
|
|
2386
|
+
this.path = resolvePath(opts.path);
|
|
2387
|
+
this.validatePath(this.path);
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
get durability() {
|
|
2391
|
+
return this.backend;
|
|
2392
|
+
}
|
|
2393
|
+
get resolvedPath() {
|
|
2394
|
+
return this.path;
|
|
2395
|
+
}
|
|
2396
|
+
validatePath(p) {
|
|
2397
|
+
if (this.allowUnsafePath) return;
|
|
2398
|
+
const home = resolvePath(homedir());
|
|
2399
|
+
if (p !== home && !p.startsWith(home + "/") && !p.startsWith(home + "\\")) {
|
|
2400
|
+
throw new Error(
|
|
2401
|
+
`UpdateStateStore: path ${p} is outside $HOME (${home}). Set LEADBAY_UPDATE_STATE_PATH_UNSAFE=1 to override.`
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
async ensureInitialized() {
|
|
2406
|
+
if (this.initialized || this.backend !== "file") {
|
|
2407
|
+
this.initialized = true;
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
const dir = dirname(this.path);
|
|
2411
|
+
await mkdirAsync(dir, { recursive: true, mode: 448 });
|
|
2412
|
+
try {
|
|
2413
|
+
const st = await lstat(this.path);
|
|
2414
|
+
if (st.isSymbolicLink()) {
|
|
2415
|
+
throw new Error(
|
|
2416
|
+
`UpdateStateStore: refusing to use ${this.path} \u2014 path is a symlink. Set LEADBAY_UPDATE_STATE_PATH_UNSAFE=1 to override.`
|
|
2417
|
+
);
|
|
2418
|
+
}
|
|
2419
|
+
} catch (err) {
|
|
2420
|
+
if (err?.code !== "ENOENT") throw err;
|
|
2421
|
+
}
|
|
2422
|
+
this.initialized = true;
|
|
2423
|
+
}
|
|
2424
|
+
async read() {
|
|
2425
|
+
if (this.backend === "memory") return { ...this.memory, suppressed_versions: [...this.memory.suppressed_versions] };
|
|
2426
|
+
await this.ensureInitialized();
|
|
2427
|
+
let raw;
|
|
2428
|
+
try {
|
|
2429
|
+
raw = await readFile(this.path, "utf8");
|
|
2430
|
+
} catch (err) {
|
|
2431
|
+
if (err?.code === "ENOENT") return emptyState();
|
|
2432
|
+
throw err;
|
|
2433
|
+
}
|
|
2434
|
+
try {
|
|
2435
|
+
const parsed = JSON.parse(raw);
|
|
2436
|
+
return this.validate(parsed);
|
|
2437
|
+
} catch (err) {
|
|
2438
|
+
this.logger?.warn?.(
|
|
2439
|
+
`update_state.parse_failed ${err?.message ?? err}; resetting to empty`
|
|
2440
|
+
);
|
|
2441
|
+
return emptyState();
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
async write(state) {
|
|
2445
|
+
if (this.backend === "memory") {
|
|
2446
|
+
this.memory = { ...state, suppressed_versions: [...state.suppressed_versions] };
|
|
2447
|
+
return;
|
|
2448
|
+
}
|
|
2449
|
+
await this.ensureInitialized();
|
|
2450
|
+
const tmp = `${this.path}.tmp.${process.pid}.${this.now()}`;
|
|
2451
|
+
const handle = await openTmpFileExclusive(tmp);
|
|
2452
|
+
try {
|
|
2453
|
+
await handle.writeFile(JSON.stringify(state, null, 2));
|
|
2454
|
+
} finally {
|
|
2455
|
+
await handle.close();
|
|
2456
|
+
}
|
|
2457
|
+
await rename(tmp, this.path);
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* Apply a partial mutation atomically (read → merge → write). Caller
|
|
2461
|
+
* passes a function so concurrent mutators (the startup check + a
|
|
2462
|
+
* tool call landing during it) compose without dropping fields.
|
|
2463
|
+
*/
|
|
2464
|
+
async update(mutator) {
|
|
2465
|
+
const cur = await this.read();
|
|
2466
|
+
const next = mutator(cur);
|
|
2467
|
+
await this.write(next);
|
|
2468
|
+
return next;
|
|
2469
|
+
}
|
|
2470
|
+
validate(raw) {
|
|
2471
|
+
if (!raw || typeof raw !== "object") return emptyState();
|
|
2472
|
+
const r = raw;
|
|
2473
|
+
const out = emptyState();
|
|
2474
|
+
if (typeof r.last_check_time === "number" && Number.isFinite(r.last_check_time)) {
|
|
2475
|
+
out.last_check_time = r.last_check_time;
|
|
2476
|
+
}
|
|
2477
|
+
if (typeof r.latest_known_version === "string") {
|
|
2478
|
+
out.latest_known_version = r.latest_known_version;
|
|
2479
|
+
}
|
|
2480
|
+
if (typeof r.latest_known_mcpb_url === "string") {
|
|
2481
|
+
out.latest_known_mcpb_url = r.latest_known_mcpb_url;
|
|
2482
|
+
}
|
|
2483
|
+
if (typeof r.latest_known_release_url === "string") {
|
|
2484
|
+
out.latest_known_release_url = r.latest_known_release_url;
|
|
2485
|
+
}
|
|
2486
|
+
if (typeof r.etag === "string") out.etag = r.etag;
|
|
2487
|
+
if (Array.isArray(r.suppressed_versions)) {
|
|
2488
|
+
out.suppressed_versions = r.suppressed_versions.filter(
|
|
2489
|
+
(v) => typeof v === "string"
|
|
2490
|
+
);
|
|
2491
|
+
}
|
|
2492
|
+
if (typeof r.remind_until === "number" && Number.isFinite(r.remind_until)) {
|
|
2493
|
+
out.remind_until = r.remind_until;
|
|
2494
|
+
}
|
|
2495
|
+
if (typeof r.previous_running_version === "string") {
|
|
2496
|
+
out.previous_running_version = r.previous_running_version;
|
|
2497
|
+
}
|
|
2498
|
+
return out;
|
|
2499
|
+
}
|
|
2500
|
+
};
|
|
2501
|
+
async function openTmpFileExclusive(path) {
|
|
2502
|
+
try {
|
|
2503
|
+
return await fsOpen(
|
|
2504
|
+
path,
|
|
2505
|
+
fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL,
|
|
2506
|
+
384
|
|
2507
|
+
);
|
|
2508
|
+
} catch (err) {
|
|
2509
|
+
if (err?.code === "EEXIST") {
|
|
2510
|
+
await unlink(path).catch(() => {
|
|
2511
|
+
});
|
|
2512
|
+
return fsOpen(
|
|
2513
|
+
path,
|
|
2514
|
+
fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL,
|
|
2515
|
+
384
|
|
2516
|
+
);
|
|
2517
|
+
}
|
|
2518
|
+
throw err;
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
async function createDefaultUpdateStateStore(opts = {}) {
|
|
2522
|
+
const env = opts.env ?? process.env;
|
|
2523
|
+
const allowUnsafePath = env.LEADBAY_UPDATE_STATE_PATH_UNSAFE === "1";
|
|
2524
|
+
const path = env.LEADBAY_UPDATE_STATE_PATH ?? resolvePath(homedir(), ".leadbay", "update-state.json");
|
|
2525
|
+
try {
|
|
2526
|
+
const store = new UpdateStateStore({
|
|
2527
|
+
backend: "file",
|
|
2528
|
+
path,
|
|
2529
|
+
logger: opts.logger,
|
|
2530
|
+
allowUnsafePath
|
|
2531
|
+
});
|
|
2532
|
+
await store.ensureInitialized();
|
|
2533
|
+
await stat(dirname(path));
|
|
2534
|
+
return store;
|
|
2535
|
+
} catch (err) {
|
|
2536
|
+
opts.logger?.warn?.(
|
|
2537
|
+
`update_state.fallback_memory path=${path} reason=${err?.message ?? err}`
|
|
2538
|
+
);
|
|
2539
|
+
return new UpdateStateStore({ backend: "memory", logger: opts.logger });
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
1662
2543
|
// src/bin.ts
|
|
1663
2544
|
import { createRequire } from "module";
|
|
1664
|
-
var VERSION = "0.
|
|
2545
|
+
var VERSION = "0.12.0";
|
|
1665
2546
|
var HELP = `
|
|
1666
2547
|
leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
|
|
1667
2548
|
|
|
@@ -1710,7 +2591,7 @@ EXAMPLE Claude Desktop config (~/Library/Application Support/Claude/claude_deskt
|
|
|
1710
2591
|
"mcpServers": {
|
|
1711
2592
|
"leadbay": {
|
|
1712
2593
|
"command": "npx",
|
|
1713
|
-
"args": ["-y", "@leadbay/mcp@0.
|
|
2594
|
+
"args": ["-y", "@leadbay/mcp@0.12"],
|
|
1714
2595
|
"env": {
|
|
1715
2596
|
"LEADBAY_TOKEN": "lb_...",
|
|
1716
2597
|
"LEADBAY_REGION": "us",
|
|
@@ -1763,9 +2644,47 @@ function exitWithTokenError() {
|
|
|
1763
2644
|
);
|
|
1764
2645
|
process.exit(1);
|
|
1765
2646
|
}
|
|
2647
|
+
var BrokenLeadbayClient = class extends LeadbayClient {
|
|
2648
|
+
stubError;
|
|
2649
|
+
constructor(stubError, baseUrl, region) {
|
|
2650
|
+
super(baseUrl, "broken-token-startup-auth-failure", region);
|
|
2651
|
+
this.stubError = stubError;
|
|
2652
|
+
}
|
|
2653
|
+
async request() {
|
|
2654
|
+
throw this.stubError;
|
|
2655
|
+
}
|
|
2656
|
+
async requestVoid() {
|
|
2657
|
+
throw this.stubError;
|
|
2658
|
+
}
|
|
2659
|
+
async requestRawBinary() {
|
|
2660
|
+
throw this.stubError;
|
|
2661
|
+
}
|
|
2662
|
+
};
|
|
2663
|
+
function makeBrokenClient(stubError, region) {
|
|
2664
|
+
const baseUrl = region === "fr" ? "https://api-fr.leadbay.app" : "https://api-us.leadbay.app";
|
|
2665
|
+
return new BrokenLeadbayClient(stubError, baseUrl, region);
|
|
2666
|
+
}
|
|
1766
2667
|
async function resolveClientFromEnv(logger) {
|
|
1767
2668
|
const token = process.env.LEADBAY_TOKEN;
|
|
1768
|
-
if (!token)
|
|
2669
|
+
if (!token) {
|
|
2670
|
+
process.stderr.write(
|
|
2671
|
+
"leadbay-mcp: LEADBAY_TOKEN environment variable is required.\n 1. Run: npx -y @leadbay/mcp install --email <you> --region <us|fr>\n 2. Set it in your MCP client config (e.g. claude_desktop_config.json).\n\nRun `leadbay-mcp --help` for the full config template.\n"
|
|
2672
|
+
);
|
|
2673
|
+
const regionEnv2 = process.env.LEADBAY_REGION;
|
|
2674
|
+
const region = regionEnv2 === "fr" ? "fr" : "us";
|
|
2675
|
+
return {
|
|
2676
|
+
client: makeBrokenClient(
|
|
2677
|
+
{
|
|
2678
|
+
error: true,
|
|
2679
|
+
code: "AUTH_MISSING",
|
|
2680
|
+
message: "LEADBAY_TOKEN environment variable is not set.",
|
|
2681
|
+
hint: "Run `npx -y @leadbay/mcp install --email <you> --region <us|fr>` to mint a token, then set LEADBAY_TOKEN in your MCP client config."
|
|
2682
|
+
},
|
|
2683
|
+
region
|
|
2684
|
+
),
|
|
2685
|
+
authState: "missing"
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
1769
2688
|
const regionEnv = process.env.LEADBAY_REGION;
|
|
1770
2689
|
const explicitRegion = regionEnv === "us" || regionEnv === "fr" ? regionEnv : void 0;
|
|
1771
2690
|
const baseUrl = process.env.LEADBAY_BASE_URL;
|
|
@@ -1773,7 +2692,7 @@ async function resolveClientFromEnv(logger) {
|
|
|
1773
2692
|
const config = { token };
|
|
1774
2693
|
if (baseUrl) config.baseUrl = baseUrl;
|
|
1775
2694
|
if (explicitRegion) config.region = explicitRegion;
|
|
1776
|
-
return createClient(config);
|
|
2695
|
+
return { client: createClient(config), authState: "ok" };
|
|
1777
2696
|
}
|
|
1778
2697
|
process.stderr.write(
|
|
1779
2698
|
"[leadbay-mcp warn] LEADBAY_REGION is unset; probing api-us and api-fr in parallel.\n Your bearer token will be sent to BOTH backends. Set LEADBAY_REGION=us|fr in your\n MCP client config to avoid this.\n"
|
|
@@ -1785,7 +2704,8 @@ async function resolveClientFromEnv(logger) {
|
|
|
1785
2704
|
return c;
|
|
1786
2705
|
};
|
|
1787
2706
|
try {
|
|
1788
|
-
|
|
2707
|
+
const client = await Promise.any([probe("us"), probe("fr")]);
|
|
2708
|
+
return { client, authState: "ok" };
|
|
1789
2709
|
} catch (err) {
|
|
1790
2710
|
const errors = err?.errors ?? [];
|
|
1791
2711
|
const firstAuth = errors.find(
|
|
@@ -1797,14 +2717,28 @@ async function resolveClientFromEnv(logger) {
|
|
|
1797
2717
|
Tip: verify your LEADBAY_TOKEN is valid and, if you know your region, set LEADBAY_REGION=us or LEADBAY_REGION=fr.
|
|
1798
2718
|
`
|
|
1799
2719
|
);
|
|
1800
|
-
|
|
2720
|
+
return {
|
|
2721
|
+
client: makeBrokenClient(
|
|
2722
|
+
{
|
|
2723
|
+
error: true,
|
|
2724
|
+
code: firstAuth.code,
|
|
2725
|
+
message: firstAuth.message,
|
|
2726
|
+
hint: "Verify your LEADBAY_TOKEN is valid. If you know your region, set LEADBAY_REGION=us or LEADBAY_REGION=fr to skip auto-probing. Mint a fresh token with `leadbay-mcp login --email <you> --region <us|fr>`."
|
|
2727
|
+
},
|
|
2728
|
+
"us"
|
|
2729
|
+
),
|
|
2730
|
+
authState: "expired"
|
|
2731
|
+
};
|
|
1801
2732
|
}
|
|
1802
2733
|
const firstMsg = errors[0]?.message ?? String(err);
|
|
1803
2734
|
process.stderr.write(
|
|
1804
2735
|
`leadbay-mcp: region auto-detection failed (${firstMsg}). Defaulting to us; set LEADBAY_REGION to skip probing.
|
|
1805
2736
|
`
|
|
1806
2737
|
);
|
|
1807
|
-
return
|
|
2738
|
+
return {
|
|
2739
|
+
client: createClient({ token, region: "us" }),
|
|
2740
|
+
authState: "probe_failed"
|
|
2741
|
+
};
|
|
1808
2742
|
}
|
|
1809
2743
|
}
|
|
1810
2744
|
async function readPassword() {
|
|
@@ -1958,7 +2892,7 @@ async function runLogin(args) {
|
|
|
1958
2892
|
let result;
|
|
1959
2893
|
try {
|
|
1960
2894
|
if (pinnedRegion && !allowFallback) {
|
|
1961
|
-
const { REGIONS } = await import("./dist-
|
|
2895
|
+
const { REGIONS } = await import("./dist-YYVFSDMH.js");
|
|
1962
2896
|
const baseUrl = REGIONS[pinnedRegion];
|
|
1963
2897
|
const c = createClient({ region: pinnedRegion });
|
|
1964
2898
|
const token = await loginAt(baseUrl, email, password);
|
|
@@ -1977,7 +2911,7 @@ async function runLogin(args) {
|
|
|
1977
2911
|
mcpServers: {
|
|
1978
2912
|
leadbay: {
|
|
1979
2913
|
command: "npx",
|
|
1980
|
-
args: ["-y", "@leadbay/mcp@0.
|
|
2914
|
+
args: ["-y", "@leadbay/mcp@0.12"],
|
|
1981
2915
|
env: {
|
|
1982
2916
|
LEADBAY_TOKEN: result.token,
|
|
1983
2917
|
LEADBAY_REGION: result.region
|
|
@@ -2017,7 +2951,7 @@ Or for Claude Code (token included \u2014 same warning applies):
|
|
|
2017
2951
|
claude mcp add leadbay --scope user \\
|
|
2018
2952
|
--env LEADBAY_TOKEN=${result.token} \\
|
|
2019
2953
|
--env LEADBAY_REGION=${result.region} \\
|
|
2020
|
-
-- npx -y @leadbay/mcp@0.
|
|
2954
|
+
-- npx -y @leadbay/mcp@0.12
|
|
2021
2955
|
|
|
2022
2956
|
Restart your MCP client to pick up the new server.
|
|
2023
2957
|
`
|
|
@@ -2064,8 +2998,8 @@ Restart your MCP client to pick up the new server.
|
|
|
2064
2998
|
let actualMode;
|
|
2065
2999
|
try {
|
|
2066
3000
|
const { writeFileSync, chmodSync, mkdirSync, renameSync, statSync, unlinkSync } = await import("fs");
|
|
2067
|
-
const { dirname } = await import("path");
|
|
2068
|
-
mkdirSync(
|
|
3001
|
+
const { dirname: dirname2 } = await import("path");
|
|
3002
|
+
mkdirSync(dirname2(targetPath), { recursive: true });
|
|
2069
3003
|
const tmp = targetPath + ".tmp." + process.pid;
|
|
2070
3004
|
writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", {
|
|
2071
3005
|
encoding: "utf8",
|
|
@@ -2123,7 +3057,7 @@ For Claude Code, run:
|
|
|
2123
3057
|
claude mcp add leadbay --scope user \\
|
|
2124
3058
|
--env LEADBAY_TOKEN=$(jq -r .mcpServers.leadbay.env.LEADBAY_TOKEN ${quotedPath}) \\
|
|
2125
3059
|
--env LEADBAY_REGION=${result.region} \\
|
|
2126
|
-
-- npx -y @leadbay/mcp@0.
|
|
3060
|
+
-- npx -y @leadbay/mcp@0.12
|
|
2127
3061
|
`
|
|
2128
3062
|
);
|
|
2129
3063
|
}
|
|
@@ -2303,7 +3237,7 @@ function buildClaudeCodeAddArgs(token, region, includeWrite, telemetryEnabled) {
|
|
|
2303
3237
|
`LEADBAY_TELEMETRY_ENABLED=${telemetryEnabled ? "true" : "false"}`
|
|
2304
3238
|
];
|
|
2305
3239
|
if (!includeWrite) args.push("--env", `LEADBAY_MCP_WRITE=0`);
|
|
2306
|
-
args.push("--", "npx", "-y", "@leadbay/mcp@0.
|
|
3240
|
+
args.push("--", "npx", "-y", "@leadbay/mcp@0.12");
|
|
2307
3241
|
return args;
|
|
2308
3242
|
}
|
|
2309
3243
|
async function installInClaudeCode(token, region, includeWrite, telemetryEnabled) {
|
|
@@ -2329,7 +3263,7 @@ async function installInClaudeCode(token, region, includeWrite, telemetryEnabled
|
|
|
2329
3263
|
async function installInJsonConfig(configPath, token, region, includeWrite, telemetryEnabled) {
|
|
2330
3264
|
try {
|
|
2331
3265
|
const { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } = await import("fs");
|
|
2332
|
-
const { dirname } = await import("path");
|
|
3266
|
+
const { dirname: dirname2 } = await import("path");
|
|
2333
3267
|
let parsed = {};
|
|
2334
3268
|
let preserved = {};
|
|
2335
3269
|
if (existsSync(configPath)) {
|
|
@@ -2341,7 +3275,7 @@ async function installInJsonConfig(configPath, token, region, includeWrite, tele
|
|
|
2341
3275
|
return { ok: false, message: `existing ${configPath} is not valid JSON; refusing to overwrite` };
|
|
2342
3276
|
}
|
|
2343
3277
|
} else {
|
|
2344
|
-
mkdirSync(
|
|
3278
|
+
mkdirSync(dirname2(configPath), { recursive: true });
|
|
2345
3279
|
}
|
|
2346
3280
|
parsed.mcpServers = parsed.mcpServers ?? {};
|
|
2347
3281
|
const env = {
|
|
@@ -2353,7 +3287,7 @@ async function installInJsonConfig(configPath, token, region, includeWrite, tele
|
|
|
2353
3287
|
if (!includeWrite) env.LEADBAY_MCP_WRITE = "0";
|
|
2354
3288
|
parsed.mcpServers.leadbay = {
|
|
2355
3289
|
command: "npx",
|
|
2356
|
-
args: ["-y", "@leadbay/mcp@0.
|
|
3290
|
+
args: ["-y", "@leadbay/mcp@0.12"],
|
|
2357
3291
|
env
|
|
2358
3292
|
};
|
|
2359
3293
|
const tmp = configPath + ".tmp";
|
|
@@ -2457,7 +3391,7 @@ leadbay-mcp install \u2014 detected MCP clients on this machine:
|
|
|
2457
3391
|
let region;
|
|
2458
3392
|
try {
|
|
2459
3393
|
if (pinnedRegion && !allowFallback) {
|
|
2460
|
-
const { REGIONS } = await import("./dist-
|
|
3394
|
+
const { REGIONS } = await import("./dist-YYVFSDMH.js");
|
|
2461
3395
|
const baseUrl = REGIONS[pinnedRegion];
|
|
2462
3396
|
token = await loginAt(baseUrl, email, password);
|
|
2463
3397
|
region = pinnedRegion;
|
|
@@ -2530,7 +3464,7 @@ leadbay-mcp install \u2014 detected MCP clients on this machine:
|
|
|
2530
3464
|
process.stderr.write(
|
|
2531
3465
|
`
|
|
2532
3466
|
The token was written into client config files but never printed to your terminal.
|
|
2533
|
-
Verify with: LEADBAY_TOKEN=$(...) npx -y @leadbay/mcp@0.
|
|
3467
|
+
Verify with: LEADBAY_TOKEN=$(...) npx -y @leadbay/mcp@0.12 doctor
|
|
2534
3468
|
Restart your MCP client(s) to pick up the new server.
|
|
2535
3469
|
If you ever leak the token, run \`leadbay-mcp login --email <you> --region <us|fr>\` to mint a fresh one (which invalidates the prior session).
|
|
2536
3470
|
`
|
|
@@ -2581,6 +3515,26 @@ async function runDoctor() {
|
|
|
2581
3515
|
);
|
|
2582
3516
|
return 1;
|
|
2583
3517
|
}
|
|
3518
|
+
var startupSafetyNetsInstalled = false;
|
|
3519
|
+
function installStartupSafetyNets(logger) {
|
|
3520
|
+
if (startupSafetyNetsInstalled) return;
|
|
3521
|
+
startupSafetyNetsInstalled = true;
|
|
3522
|
+
const reportAndExit = (label, err) => {
|
|
3523
|
+
const msg = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
3524
|
+
process.stderr.write(`leadbay-mcp: ${label}: ${msg}
|
|
3525
|
+
`);
|
|
3526
|
+
logger.error?.(`${label}: ${msg}`);
|
|
3527
|
+
try {
|
|
3528
|
+
const bootTelemetry = initTelemetry({ version: VERSION });
|
|
3529
|
+
bootTelemetry.captureException(err, { tool: "__startup__" });
|
|
3530
|
+
void bootTelemetry.shutdown();
|
|
3531
|
+
} catch {
|
|
3532
|
+
}
|
|
3533
|
+
process.exit(1);
|
|
3534
|
+
};
|
|
3535
|
+
process.on("uncaughtException", (err) => reportAndExit("uncaughtException", err));
|
|
3536
|
+
process.on("unhandledRejection", (err) => reportAndExit("unhandledRejection", err));
|
|
3537
|
+
}
|
|
2584
3538
|
async function main() {
|
|
2585
3539
|
const arg = process.argv[2];
|
|
2586
3540
|
if (arg === "--version" || arg === "-v") {
|
|
@@ -2603,23 +3557,45 @@ async function main() {
|
|
|
2603
3557
|
process.exit(await runDoctor());
|
|
2604
3558
|
}
|
|
2605
3559
|
const logger = makeStderrLogger(parseLogLevel(process.env.LEADBAY_LOG_LEVEL));
|
|
3560
|
+
installStartupSafetyNets(logger);
|
|
2606
3561
|
const telemetry = initTelemetry({ version: VERSION, logger });
|
|
2607
|
-
const client = await resolveClientFromEnv(logger);
|
|
3562
|
+
const { client, authState } = await resolveClientFromEnv(logger);
|
|
2608
3563
|
telemetry.identify(client);
|
|
3564
|
+
telemetry.captureStartup({
|
|
3565
|
+
auth_state: authState,
|
|
3566
|
+
region: client.region
|
|
3567
|
+
});
|
|
2609
3568
|
const includeAdvanced = process.env.LEADBAY_MCP_ADVANCED === "1";
|
|
2610
3569
|
const includeWrite = parseWriteEnv();
|
|
2611
3570
|
const bulkTracker = await createDefaultBulkStore({ logger });
|
|
3571
|
+
const updateStateStore = await createDefaultUpdateStateStore({ logger });
|
|
3572
|
+
void recordRunningVersion(VERSION, updateStateStore, telemetry).catch((err) => {
|
|
3573
|
+
logger.warn?.(
|
|
3574
|
+
`update_state.record_version_failed ${err?.message ?? err}`
|
|
3575
|
+
);
|
|
3576
|
+
});
|
|
3577
|
+
if (process.env.LEADBAY_UPDATE_CHECK_DISABLED !== "1") {
|
|
3578
|
+
void checkForUpdate({
|
|
3579
|
+
currentVersion: VERSION,
|
|
3580
|
+
stateStore: updateStateStore,
|
|
3581
|
+
telemetry,
|
|
3582
|
+
logger
|
|
3583
|
+
}).catch((err) => {
|
|
3584
|
+
logger.warn?.(`update_check.unexpected ${err?.message ?? err}`);
|
|
3585
|
+
});
|
|
3586
|
+
}
|
|
2612
3587
|
const server = buildServer(client, {
|
|
2613
3588
|
includeAdvanced,
|
|
2614
3589
|
includeWrite,
|
|
2615
3590
|
logger,
|
|
2616
3591
|
bulkTracker,
|
|
2617
3592
|
version: VERSION,
|
|
2618
|
-
telemetry
|
|
3593
|
+
telemetry,
|
|
3594
|
+
updateStateStore
|
|
2619
3595
|
});
|
|
2620
3596
|
const transport = new StdioServerTransport();
|
|
2621
3597
|
logger.info?.(
|
|
2622
|
-
`Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl}, bulk_store=${bulkTracker.durability})`
|
|
3598
|
+
`Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl}, bulk_store=${bulkTracker.durability}, auth_state=${authState})`
|
|
2623
3599
|
);
|
|
2624
3600
|
await server.connect(transport);
|
|
2625
3601
|
const shutdown = async (code) => {
|
|
@@ -2661,6 +3637,7 @@ export {
|
|
|
2661
3637
|
checkLoginCollision,
|
|
2662
3638
|
computeFreshDefaultPath,
|
|
2663
3639
|
detectClaudeDesktopMode,
|
|
3640
|
+
makeBrokenClient,
|
|
2664
3641
|
parseWriteEnv,
|
|
2665
3642
|
resolveClientFromEnv,
|
|
2666
3643
|
resolveDefaultCredentialsPath
|