@leadbay/mcp 0.17.2 → 0.18.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.
@@ -1756,6 +1756,23 @@ var LeadbayClient = class {
1756
1756
  await this.resolveOrgId();
1757
1757
  await this.resolveTasteProfile();
1758
1758
  }
1759
+ // ─── Notifications helpers ────────────────────────────────────────────
1760
+ // Backend exposes `GET /notifications`, `POST /notifications/{id}/seen`,
1761
+ // `POST /notifications/{id}/archive`, plus `GET /ws/ticket?v=1.0` to mint
1762
+ // a one-shot WS URL. See backend/docs/adr/notifications.md for shape.
1763
+ async listNotifications(args = {}) {
1764
+ const params = new URLSearchParams();
1765
+ params.set("archived", String(args.archived ?? false));
1766
+ params.set("page", String(args.page ?? 0));
1767
+ params.set("count", String(args.count ?? 50));
1768
+ return this.request("GET", `/notifications?${params.toString()}`);
1769
+ }
1770
+ async acknowledgeNotification(notificationId, action = "seen") {
1771
+ await this.requestVoid("POST", `/notifications/${notificationId}/${action}`);
1772
+ }
1773
+ async getWsTicket() {
1774
+ return this.request("GET", "/auth/ws?v=1.0");
1775
+ }
1759
1776
  makeError(code, message, hint, endpoint, retry_after, http_status) {
1760
1777
  const out = { error: true, code, message, hint };
1761
1778
  if (endpoint || this._region) {
@@ -6187,6 +6204,8 @@ var COMPOSITE_FILE_TOOL_NAMES = /* @__PURE__ */ new Set([
6187
6204
  "leadbay_import_leads",
6188
6205
  "leadbay_import_status",
6189
6206
  "leadbay_list_campaigns",
6207
+ "leadbay_my_lenses",
6208
+ "leadbay_new_lens",
6190
6209
  "leadbay_prepare_outreach",
6191
6210
  "leadbay_pull_followups",
6192
6211
  "leadbay_pull_leads",
@@ -6203,6 +6222,9 @@ var COMPOSITE_FILE_TOOL_NAMES = /* @__PURE__ */ new Set([
6203
6222
  "leadbay_tour_plan"
6204
6223
  ]);
6205
6224
 
6225
+ // ../core/dist/notifications/inbox.js
6226
+ var DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
6227
+
6206
6228
  // ../core/dist/tool-descriptions.generated.js
6207
6229
  var leadbay_account_status = `## WHEN TO USE
6208
6230
 
@@ -6239,10 +6261,53 @@ Show the user's account state \u2014 admin rights, language, last-active lens, c
6239
6261
 
6240
6262
  **After a user tops up, do NOT keep refusing \u2014 RETRY.** If the user signals they topped up / bought credits / added credits, the previous QUOTA_EXCEEDED is invalidated the moment the Stripe webhook lands. RE-CALL \`leadbay_account_status\` to pick up the new state AND retry the originally failed call. The retry itself does not require a successful account_status check first \u2014 a topped-up user has cleared the throttle whether or not your cached snapshot reflects it yet. If the retry hits the wall again, only then re-offer top-up / wait. **A stale quota snapshot is never a reason to gate-keep a topped-up user.**
6241
6263
 
6264
+ **\`notifications\` block.** The response now includes a top-level \`notifications\` array listing background work the user (or agent) initiated that has since completed (\`bulk_enrich\`, \`bulk_qualify\`, \`import\`). These are signals to revise prior agent outputs the just-finished work might have made stale \u2014 they're NOT a pending-task list for the user. After revising (or confirming nothing is affected), call \`leadbay_acknowledge_notification(notification_id)\`. Full handling protocol below.
6265
+
6266
+ ## GATE \u2014 INSPECT \`_meta.notifications\` ON EVERY RESPONSE
6267
+
6268
+ Some Leadbay tool responses include a \`_meta.notifications\` array listing **background work the user (or you) initiated that has since completed**. The three relevant kinds:
6269
+
6270
+ | Kind | What just finished |
6271
+ |---|---|
6272
+ | \`bulk_enrich\` | A bulk contact-enrichment job (emails / phone numbers attached to leads' contacts). |
6273
+ | \`bulk_qualify\` | A bulk lead-qualification job (web-fetch + AI rescore, producing \`ai_agent_lead_score\` and qualification answers). |
6274
+ | \`import\` | A CSV / CRM file import (rows resolved to leads in the user's pipeline). |
6275
+
6276
+ **Your job when you see an entry: revise prior outputs the just-finished work might have made stale.** This is NOT a pending-task list. It's a "your earlier answer used data that has now changed" signal.
6277
+
6278
+ | Kind | Outputs you've produced that may now be stale \u2014 refresh them |
6279
+ |---|---|
6280
+ | \`bulk_enrich\` | Outreach drafts mentioning these leads' contacts; contact lists; recommended-lead lists citing \`contact_count\`; NEXT STEPS that asked the user to wait for emails / phones. Re-fetch via \`leadbay_get_contacts(leadId)\` for the affected leads. |
6281
+ | \`bulk_qualify\` | Lead rankings / shortlists you produced without \`ai_agent_lead_score\`; "today's leads"; followup maps; prepare-outreach picks. Re-pull via \`leadbay_pull_leads\` / \`leadbay_research_lead_by_id\`. |
6282
+ | \`import\` | "Available leads" claims; pulls from the affected lens that ran before the import landed; followup planning that needed the imported set. Re-pull via \`leadbay_pull_leads\` / \`leadbay_pull_followups\`. |
6283
+
6284
+ **After revising (or after confirming no prior output is affected):** call \`leadbay_acknowledge_notification(notification_id)\` so the entry stops resurfacing on every tool response. Ack-and-move-on is correct even when nothing was stale \u2014 that's how the inbox stays focused on what's actually pending.
6285
+
6286
+ **Do NOT** interpret these entries as "things waiting for the user." The user expects you to handle them silently. They are signals to YOU \u2014 agent \u2014 that prior outputs need a refresh.
6287
+
6288
+ **Don't poll.** When you launch a long operation (enrichment / qualification / import), simply continue the conversation \u2014 the next time you call any tool, the completed-work entry will appear in \`_meta.notifications\`. Status tools (\`leadbay_bulk_enrich_status\`, \`leadbay_qualify_status\`, \`leadbay_import_status\`) exist for the case where you want the answer NOW (mid-conversation), not for ambient polling.
6289
+
6290
+ Also surfaced as a top-level \`notifications\` array on \`leadbay_account_status\` \u2014 same shape, same handling.
6291
+
6292
+
6242
6293
  WHEN TO USE: at the start of a session to know what the agent can/can't do, after a 429 to explain to the user which resource window was exhausted and when it resets (and to offer the top-up alternative), and after the user signals a top-up so the agent can resume the interrupted workflow.
6243
6294
 
6244
6295
  WHEN NOT TO USE: as a pre-flight gate before bulk ops \u2014 operations themselves return 429; this tool is for context, not gating. And: a recent quota snapshot showing "exhausted" is NOT a reason to refuse a write call when the user has just topped up \u2014 re-call this tool first, then proceed.
6245
6296
  `;
6297
+ var leadbay_acknowledge_notification = `Acknowledge a Leadbay notification \u2014 i.e. tell the MCP and the backend "I've seen this and acted on it." Wraps \`POST /1.5/notifications/{id}/seen\` (default) or \`/archive\` (when \`archive:true\`) and drops the entry from the local inbox so subsequent \`_meta.notifications\` payloads stop carrying it.
6298
+
6299
+ **When to call.** After you read an entry from \`_meta.notifications\` or \`account_status.notifications\` and have revised whatever prior output the just-finished background work might have made stale (outreach drafts, lead lists, "available leads" claims, followup plans). Mark-seen tells the human team's pipeline you handled this and prevents the notification from re-surfacing on every subsequent tool response.
6300
+
6301
+ If nothing you produced for the user is affected, ack anyway with \`archive:false\` \u2014 the entry should still clear so the inbox stays focused on what's actually pending.
6302
+
6303
+ Use \`archive:true\` only when you want the row gone from the FE notification dropdown too (e.g. a non-actionable system notification that's already handled). Default behaviour is \`seen\` \u2014 same as the FE dropdown's "click to read" semantics.
6304
+
6305
+ WHEN TO USE: immediately after you finish reviewing / revising in response to a \`_meta.notifications[]\` entry. Idempotent \u2014 calling twice with the same id is safe.
6306
+
6307
+ WHEN NOT TO USE: before doing the revision work; for general "mark all read" sweeps (call once per notification id you've actually consumed).
6308
+
6309
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
6310
+ `;
6246
6311
  var leadbay_add_leads_to_campaign = `## WHEN TO USE
6247
6312
 
6248
6313
  Trigger phrases: "add leads to <name> campaign", "attach these to <campaign>", "put these in Q2 Push", "add to existing campaign".
@@ -6297,7 +6362,37 @@ WHEN NOT TO USE: to log an outreach action \u2014 use leadbay_report_outreach, w
6297
6362
 
6298
6363
  This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
6299
6364
  `;
6300
- var leadbay_adjust_audience = `Restrict (or expand) the lens audience by sector / size. Free-text sectors are auto-resolved against the sector taxonomy; ambiguous matches are surfaced to the agent rather than guessed silently. Permission routing is hidden: the default lens auto-clones to a new user lens; an org-level lens defaults to a per-user draft (admins can override with \`save_for_org:true\`). Filter MERGES with existing criteria (unrelated criteria are not dropped).
6365
+ var leadbay_adjust_audience = `## WHEN TO USE
6366
+
6367
+ Trigger phrases: "narrow the audience to <sector>", "add <sector> to my <name> lens", "remove <sector> from this lens", "only show me companies of <size>", "stop including <sector>", "broaden this lens to also include <sector>".
6368
+
6369
+ **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
6370
+
6371
+ Do NOT use for: "create a new lens called X" \u2192 \`leadbay_new_lens\`; "make a new audience for Y" \u2192 \`leadbay_new_lens\`; "show me / list / switch my lenses" \u2192 \`leadbay_my_lenses\`; "focus on a kind of company beyond sector/size (e.g. 'hospitals running their own IT')" \u2192 \`leadbay_refine_prompt\`.
6372
+
6373
+ Prefer when: user wants to change an EXISTING lens's sectors/sizes. If the user NAMES a lens ('my Joinery lens'), you MUST pass lensName with that name \u2014 do NOT edit the active lens. To create a brand-new lens use leadbay_new_lens instead.
6374
+
6375
+ Examples that SHOULD invoke this tool:
6376
+ - "Add fintech to my Joinery lens."
6377
+ - "Narrow my audience to manufacturing companies, 50\u2013500 people."
6378
+ - "Stop including retail in this lens."
6379
+
6380
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
6381
+ - "Create a lens called Joinery for fintech."
6382
+ - "Show me my lenses."
6383
+ - "Focus on hospitals that run their own IT."
6384
+
6385
+ ## RENDER (quick)
6386
+
6387
+ On \`applied\`: confirm the lens edited (name) + the sectors/sizes added as
6388
+ chips. On \`ambiguous_sectors\` / \`ambiguous_lens\` / \`lens_not_found\`: surface
6389
+ the candidates and ask the user to pick, then re-call with the id/exact name.
6390
+
6391
+ ---
6392
+
6393
+ Restrict (or expand) the lens audience by sector / size. Free-text sectors are auto-resolved against the sector taxonomy; ambiguous matches are surfaced to the agent rather than guessed silently. Permission routing is hidden: the default lens auto-clones to a new user lens; an org-level lens defaults to a per-user draft (admins can override with \`save_for_org:true\`). Filter MERGES with existing criteria (unrelated criteria are not dropped).
6394
+
6395
+ **Targeting a lens \u2014 READ THIS.** By default this edits the user's ACTIVE lens. **If the user names a lens** ("add fintech to my **Joinery** lens", "in my Nordics lens, exclude retail"), you MUST pass \`lensName\` with that name (\`lensName:"Joinery"\`). Do NOT silently edit the active lens when a different one was named \u2014 that corrupts the wrong audience and is a top friction source. The name resolves against the user's lenses (case-insensitive, exact then unique-substring); it is edit-only and does NOT change which lens is active. An unmatched name returns \`status:"lens_not_found"\` with the lens list, and a name matching several returns \`status:"ambiguous_lens"\` with the candidates \u2014 surface them and re-call with the exact \`lensName\` or a \`lensId\`. Use \`leadbay_my_lenses\` if the user first wants to SEE or SWITCH lenses. To CREATE a brand-new lens, use \`leadbay_new_lens\` \u2014 not this tool.
6301
6396
 
6302
6397
  WHEN TO USE: when the user wants to see different kinds of leads (sector / size / etc.).
6303
6398
 
@@ -7511,6 +7606,258 @@ WHEN NOT TO USE: if a token is already preconfigured \u2014 you'll just overwrit
7511
7606
 
7512
7607
  This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
7513
7608
  `;
7609
+ var leadbay_my_lenses = `## WHEN TO USE
7610
+
7611
+ Trigger phrases: "show me my lenses", "list my lenses", "which audiences do I have", "switch to my <name> lens", "change lens", "rename my <name> lens to <X>", "set the description of my <name> lens", "delete my <name> lens", "remove this lens".
7612
+
7613
+ **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
7614
+
7615
+ Do NOT use for: "narrow the audience" \u2192 \`leadbay_adjust_audience\`; "stop showing me <sector>" \u2192 \`leadbay_refine_prompt\`; "more leads on this lens" \u2192 \`leadbay_extend_lens\`; "show me today's leads" \u2192 \`leadbay_pull_leads\`.
7616
+
7617
+ Prefer when: user wants to SEE lenses, CHANGE which is active, RENAME one, or DELETE one \u2014 not edit a lens's sector/size criteria
7618
+
7619
+ Examples that SHOULD invoke this tool:
7620
+ - "Show me my lenses."
7621
+ - "Rename my Auto lens to Automotive and add a description."
7622
+ - "Delete my old Auto lens."
7623
+
7624
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
7625
+ - "Narrow the audience to fintech only."
7626
+ - "I want more leads on this lens."
7627
+ - "Show me today's leads."
7628
+
7629
+ ## RENDER (quick)
7630
+
7631
+ Small markdown table, active lens first: col 1 = \u2B50 prefix when active +
7632
+ lens name; col 2 = description (or \`\u2014\`). After a switch lead with
7633
+ "Now showing **<name>**."; after a rename lead with the rename confirmation.
7634
+ Full algorithm below.
7635
+
7636
+ ---
7637
+
7638
+ List the user's lenses (saved audiences) and, when asked, switch which one is active. A lens shapes the kind of leads delivered each day; this tool is how the user sees their audiences and moves between them \u2014 it does NOT edit a lens's criteria (that's \`leadbay_adjust_audience\`).
7639
+
7640
+ **Three modes, one tool:**
7641
+
7642
+ - **List (no args)** \u2014 pure read. Returns \`{status:"listed", lenses:[{id, name, description, is_active}], active_lens_id}\`. The active lens is resolved from the user's last-requested lens, so \`is_active\` is authoritative even if a row's flag is stale.
7643
+ - **Switch (\`switchToLensId\`)** \u2014 changes the active lens to that id and returns the REFRESHED list. The id MUST be one of the user's lenses; an unknown id returns \`{status:"not_found"}\` with the current list \u2014 surface it and ask the user to pick, do NOT invent an id. Switching to the already-active lens is a harmless no-op.
7644
+ - **Edit (\`editLensId\` + \`newName\` and/or \`newDescription\`)** \u2014 rename and/or set the description of a lens in one call, returns the REFRESHED list. Provide either or both; pass \`newDescription:""\` to clear a description. Same not_found handling. Use the \`id\` from the list for the lens the user named.
7645
+ - **Delete (\`deleteLensId\`)** \u2014 DESTRUCTIVE and confirm-gated. Without \`confirm:true\` it returns \`status:"delete_preview"\` with \`will_delete\` and removes NOTHING \u2014 show it, get the user's explicit yes, then re-call with \`confirm:true\`. The DEFAULT lens cannot be deleted (\`status:"cannot_delete_default"\`). Deleting the active lens leaves no active lens until the next switch/pull resolves one.
7646
+
7647
+ **Lens ids are strings** (e.g. \`"40005"\`) \u2014 pass the \`id\` value straight from the list when switching/renaming/deleting; it is fine to pass it as the string it came as.
7648
+
7649
+ **When the user is vague** ("switch lens" with no target), list first, then offer the lenses as a quick choice via \`ask_user_input_v0\` rather than guessing.
7650
+
7651
+ WHEN TO USE: when the user wants to see their lenses or switch the active one. Canonical phrasings: "show me my lenses", "which audiences do I have", "switch to my <name> lens".
7652
+
7653
+ WHEN NOT TO USE: to change a lens's audience criteria \u2014 that's \`leadbay_adjust_audience\`. Not for refining beyond firmographics (\`leadbay_refine_prompt\`), not for topping up the same lens (\`leadbay_extend_lens\`), not for the daily pull (\`leadbay_pull_leads\`).
7654
+
7655
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
7656
+
7657
+
7658
+ ## GATE \u2014 PREFER BUILT-IN HOST WIDGETS
7659
+
7660
+ Modern chat hosts (Claude, ChatGPT) expose first-party widgets the agent can route into. These ALWAYS produce a better UX than markdown tables / inline prose for the data shapes they support \u2014 they're tappable on mobile, persistent across turns, and integrate with the host's quick-actions.
7661
+
7662
+ **The Big Three** \u2014 when a tool result fits, route there:
7663
+
7664
+ | Host widget | Use when | Field map (from Leadbay payload) |
7665
+ |---|---|---|
7666
+ | \`places_map_display_v0\` (Claude) | Result has \u22652 leads with \`location.city\` set, and the user's intent is geographic / "in person" / travel | \`{name: lead.company_name, address: "<city>, <country>", place_id: lead.location.place_id ?? omit, notes: <one-sentence pitch>}\` per location |
7667
+ | \`message_compose_v1\` (Claude) | You're about to draft outreach (email / message / call opener) | \`{kind: "email", summary_title, variants: [{label, body, subject}]}\` \u2014 2\u20133 variants, labels describe STRATEGY ("Push for alignment", "Reference the M&A signal"), not tone ("Friendly", "Formal") |
7668
+ | \`ask_user_input_v0\` (Claude) | The tool's NEXT STEPS block has 2\u20134 mutually-exclusive next moves and the user hasn't already chosen | \`{questions: [{question: "What next?", type: "single_select", options: [<2-4 short button labels>]}]}\`; max 3 questions per call |
7669
+
7670
+ ChatGPT exposes the same routing pattern via \`_meta.openai/outputTemplate\`. We don't ship any custom widgets ourselves \u2014 this gate is exclusively about routing into the host's first-party widgets when the data shape fits.
7671
+
7672
+ **Rules:**
7673
+ - The widget IS the visual. Do NOT emit a markdown table or prose list of the same data alongside \u2014 that produces two competing UIs.
7674
+ - Pass identifiers (place_id, lead.id, contact_id) verbatim. Don't rewrite.
7675
+ - When the host doesn't expose the named widget, the agent falls back to the prose/table rendering the per-tool description already specifies. The directive is host-conditional; the fallback is automatic.
7676
+ - One short intro sentence in chat is enough \u2014 "Here are your 5 NYC follow-ups." Then route into the widget.
7677
+
7678
+
7679
+ ---
7680
+
7681
+ ## RENDERING \u2014 lenses table, active-first
7682
+
7683
+ Markdown table with TWO columns. Sort **active lens first**, then by \`name\`
7684
+ ascending. **No score bar** \u2014 the \`\u25B0\u2756\u25B1\` glyph identity belongs to lead
7685
+ discovery, not lenses.
7686
+
7687
+ **Column 1 \u2014 Lens**
7688
+ - Prefix \`\u2B50 \` when \`is_active\` is true; otherwise no prefix.
7689
+ - The lens name in **bold**. (Lenses have no public URL \u2014 do not fabricate a link.)
7690
+
7691
+ **Column 2 \u2014 Description**
7692
+ - \`description\` verbatim, clipped to \u2264 18 words.
7693
+ - When null/empty: render \`\u2014\`.
7694
+
7695
+ **After a \`switched: true\` response**, open with a single confirmation line
7696
+ ABOVE the table: \`Now showing **<name>**.\` For \`status: "not_found"\`, lead with
7697
+ the \`message\` (the bad id) and render the list so the user can pick a real one.
7698
+
7699
+ **Empty list** (\`lenses: []\`): render \`*You don't have any lenses yet.*\` \u2014 do not
7700
+ render an empty table.
7701
+
7702
+ **Legend:** \u2B50 active lens.
7703
+
7704
+
7705
+ ## NEXT STEPS \u2014 after \`leadbay_my_lenses\`
7706
+
7707
+ **RENDER NEXT STEPS via \`ask_user_input_v0\` when the host exposes it.**
7708
+
7709
+ The (Observation, Suggest, Calls) table below is the source of truth for which moves are valid. Pick the 2\u20134 most relevant rows based on what the response actually contains, then surface them as a \`single_select\` quick-select widget:
7710
+
7711
+ \`\`\`
7712
+ ask_user_input_v0({
7713
+ questions: [{
7714
+ question: "What next?",
7715
+ type: "single_select",
7716
+ options: [
7717
+ "<Suggest column from row 1>",
7718
+ "<Suggest column from row 2>",
7719
+ "<Suggest column from row 3>"
7720
+ ]
7721
+ }]
7722
+ })
7723
+ \`\`\`
7724
+
7725
+ When the user picks an option, you call the matching tool from the \`Calls\` column. Constraints carried over from the widget contract: 2\u20134 mutually-exclusive options per question, button-sized labels (\u22646 words), max 3 questions per call.
7726
+
7727
+ **Fallback prose mode** \u2014 when the host doesn't expose \`ask_user_input_v0\` (or it returned an error): surface the same 2\u20133 picks as a short bulleted list of "Suggest" phrasings. The table itself stays internal; never recite the whole table to the user.
7728
+
7729
+ ---
7730
+
7731
+
7732
+
7733
+ Pick the 2\u20133 rows that fit what the user is likely to want next. When the user
7734
+ named no target but wants to switch, offer the lenses themselves as the
7735
+ quick-select options (each option = a lens name \u2192 \`leadbay_my_lenses(switchToLensId=<id>)\`).
7736
+
7737
+ | Observation | Suggest | Calls |
7738
+ |--------------------------------------|------------------------------------------|------------------------------------------------------|
7739
+ | User wants a different lens | "Switch to <lens name>" | \`leadbay_my_lenses(switchToLensId=<id>)\` |
7740
+ | User wants to rename / describe a lens| "Rename or describe <lens>" | \`leadbay_my_lenses(editLensId=<id>, newName?=<X>, newDescription?=<Y>)\` |
7741
+ | User wants to delete a lens | "Delete <lens>" | \`leadbay_my_lenses(deleteLensId=<id>)\` \u2192 confirm \u2192 \`confirm=true\` |
7742
+ | \`delete_preview\` (not yet deleted) | "Yes, delete it" | \`leadbay_my_lenses(deleteLensId=<id>, confirm=true)\` |
7743
+ | User wants leads on the active lens | "Pull today's leads" | \`leadbay_pull_leads()\` |
7744
+ | User wants to change the audience | "Adjust this lens's audience" | \`leadbay_adjust_audience(...)\` |
7745
+ | User wants more of the same | "Get a bigger batch on this lens" | \`leadbay_extend_lens(...)\` |
7746
+
7747
+ If nothing fits, default to "pull today's leads on the active lens" \u2014 never
7748
+ invent a tool that doesn't exist.
7749
+ `;
7750
+ var leadbay_new_lens = `## WHEN TO USE
7751
+
7752
+ Trigger phrases: "create a lens", "create a new lens called <name>", "create a lens specialized in/into <X>", "make me a new audience for <X>", "set up a lens for <sector>", "new lens named <name>", "I want a lens just for <X>".
7753
+
7754
+ **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
7755
+
7756
+ Do NOT use for: "narrow the audience / add or remove a sector on an EXISTING lens" \u2192 \`leadbay_adjust_audience\`; "add <sector> to my <name> lens" \u2192 \`leadbay_adjust_audience\`; "focus on a qualitative trait beyond sector/size" \u2192 \`leadbay_refine_prompt\`; "show me / list / switch my lenses" \u2192 \`leadbay_my_lenses\`; "more leads on this lens" \u2192 \`leadbay_extend_lens\`.
7757
+
7758
+ Prefer when: user wants a brand-new lens (create/make/set up, often 'specialized in <X>'). Editing an existing lens \u2192 leadbay_adjust_audience (use lensName). Qualitative refinement \u2192 refine_prompt (admin-only).
7759
+
7760
+ Examples that SHOULD invoke this tool:
7761
+ - "Create a lens called Joinery for the fintech sector."
7762
+ - "Make me a new audience for healthcare companies, 30\u2013300 people."
7763
+ - "Set up a new lens named Nordics SaaS."
7764
+
7765
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
7766
+ - "Add fintech to my Joinery lens."
7767
+ - "Show me my lenses."
7768
+ - "I want more leads on this lens."
7769
+
7770
+ ## RENDER (quick)
7771
+
7772
+ On \`preview\` (default \u2014 NOTHING created yet): show the lens that WILL be
7773
+ created (name + resolved sectors/sizes as chips) and ASK the user to confirm
7774
+ via ask_user_input_v0 ("Create this lens?" / "Change something"). Only on
7775
+ "yes" re-call with confirm:true. On \`created\`: confirm "Created **<name>**."
7776
+ On \`ambiguous_sectors\`: surface the candidate sectors to pick from.
7777
+
7778
+ ---
7779
+
7780
+ Create a brand-new lens (saved audience) and apply its sector/size criteria. Clones a base lens (the user's active/default lens unless \`base\` is given), names it, and applies the filter.
7781
+
7782
+ **Confirm before creating \u2014 two-step by default.** A call WITHOUT \`confirm:true\` is a dry run: it resolves the sectors/sizes and returns \`status:"preview"\` with \`will_create\` (what it WOULD build) \u2014 **nothing is created**. Show that to the user, get an explicit yes (ask via \`ask_user_input_v0\`), then re-call the SAME args with \`confirm:true\` to actually create. Never pass \`confirm:true\` on the first call \u2014 the user must see the preview first. (Sector ambiguity is still surfaced in the preview step, so they pick before confirming.)
7783
+
7784
+ **Sectors resolve first.** Free-text \`sectors\`/\`exclude_sectors\` are auto-resolved against the taxonomy. If any don't resolve, the tool returns \`status:"ambiguous_sectors"\` with the candidates and **does NOT create the lens** \u2014 so re-calling after picking the right sector won't leave orphan half-built lenses. To discover valid sector labels up front, use \`leadbay_list_sectors\`.
7785
+
7786
+ **Does not switch the active lens.** The new lens is created but the user stays on their current one. Offer \`leadbay_my_lenses(switchToLensId=<new id>)\` as a next step if they want to start pulling from it.
7787
+
7788
+ WHEN TO USE: when the user wants a NEW lens. Canonical phrasings: "create a lens called X", "make a new audience for Y", "set up a lens for <sector>".
7789
+
7790
+ WHEN NOT TO USE: to EDIT an existing lens \u2014 use \`leadbay_adjust_audience\` (pass \`lensName\` to target one by name). Not for listing/switching (\`leadbay_my_lenses\`) or topping up (\`leadbay_extend_lens\`).
7791
+
7792
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
7793
+
7794
+
7795
+ ## GATE \u2014 PREFER BUILT-IN HOST WIDGETS
7796
+
7797
+ Modern chat hosts (Claude, ChatGPT) expose first-party widgets the agent can route into. These ALWAYS produce a better UX than markdown tables / inline prose for the data shapes they support \u2014 they're tappable on mobile, persistent across turns, and integrate with the host's quick-actions.
7798
+
7799
+ **The Big Three** \u2014 when a tool result fits, route there:
7800
+
7801
+ | Host widget | Use when | Field map (from Leadbay payload) |
7802
+ |---|---|---|
7803
+ | \`places_map_display_v0\` (Claude) | Result has \u22652 leads with \`location.city\` set, and the user's intent is geographic / "in person" / travel | \`{name: lead.company_name, address: "<city>, <country>", place_id: lead.location.place_id ?? omit, notes: <one-sentence pitch>}\` per location |
7804
+ | \`message_compose_v1\` (Claude) | You're about to draft outreach (email / message / call opener) | \`{kind: "email", summary_title, variants: [{label, body, subject}]}\` \u2014 2\u20133 variants, labels describe STRATEGY ("Push for alignment", "Reference the M&A signal"), not tone ("Friendly", "Formal") |
7805
+ | \`ask_user_input_v0\` (Claude) | The tool's NEXT STEPS block has 2\u20134 mutually-exclusive next moves and the user hasn't already chosen | \`{questions: [{question: "What next?", type: "single_select", options: [<2-4 short button labels>]}]}\`; max 3 questions per call |
7806
+
7807
+ ChatGPT exposes the same routing pattern via \`_meta.openai/outputTemplate\`. We don't ship any custom widgets ourselves \u2014 this gate is exclusively about routing into the host's first-party widgets when the data shape fits.
7808
+
7809
+ **Rules:**
7810
+ - The widget IS the visual. Do NOT emit a markdown table or prose list of the same data alongside \u2014 that produces two competing UIs.
7811
+ - Pass identifiers (place_id, lead.id, contact_id) verbatim. Don't rewrite.
7812
+ - When the host doesn't expose the named widget, the agent falls back to the prose/table rendering the per-tool description already specifies. The directive is host-conditional; the fallback is automatic.
7813
+ - One short intro sentence in chat is enough \u2014 "Here are your 5 NYC follow-ups." Then route into the widget.
7814
+
7815
+
7816
+ ---
7817
+
7818
+ ## NEXT STEPS \u2014 after \`leadbay_new_lens\`
7819
+
7820
+ **RENDER NEXT STEPS via \`ask_user_input_v0\` when the host exposes it.**
7821
+
7822
+ The (Observation, Suggest, Calls) table below is the source of truth for which moves are valid. Pick the 2\u20134 most relevant rows based on what the response actually contains, then surface them as a \`single_select\` quick-select widget:
7823
+
7824
+ \`\`\`
7825
+ ask_user_input_v0({
7826
+ questions: [{
7827
+ question: "What next?",
7828
+ type: "single_select",
7829
+ options: [
7830
+ "<Suggest column from row 1>",
7831
+ "<Suggest column from row 2>",
7832
+ "<Suggest column from row 3>"
7833
+ ]
7834
+ }]
7835
+ })
7836
+ \`\`\`
7837
+
7838
+ When the user picks an option, you call the matching tool from the \`Calls\` column. Constraints carried over from the widget contract: 2\u20134 mutually-exclusive options per question, button-sized labels (\u22646 words), max 3 questions per call.
7839
+
7840
+ **Fallback prose mode** \u2014 when the host doesn't expose \`ask_user_input_v0\` (or it returned an error): surface the same 2\u20133 picks as a short bulleted list of "Suggest" phrasings. The table itself stays internal; never recite the whole table to the user.
7841
+
7842
+ ---
7843
+
7844
+
7845
+
7846
+ Pick the rows that fit. On \`created\`, the switch + pull rows are the natural
7847
+ follow-ups. On \`ambiguous_sectors\`, the only move is to pick a sector and re-call.
7848
+
7849
+ | Observation | Suggest | Calls |
7850
+ |-----------------------------------|------------------------------------------|--------------------------------------------------------|
7851
+ | \`preview\` (not yet created) | "Yes, create this lens" | \`leadbay_new_lens(...same args..., confirm=true)\` |
7852
+ | \`preview\` (not yet created) | "Change the sectors/size first" | (re-ask the user, then \`leadbay_new_lens\` with new args) |
7853
+ | Lens created | "Switch to it and pull leads" | \`leadbay_my_lenses(switchToLensId=<new id>)\` then \`leadbay_pull_leads()\` |
7854
+ | Lens created | "Refine the audience further" | \`leadbay_adjust_audience(lensName=<new name>, ...)\` |
7855
+ | Lens created | "Leave it; keep my current lens active" | (no call) |
7856
+ | \`ambiguous_sectors\` | "Pick the right sector and create" | \`leadbay_new_lens(name=..., sectors=[<chosen id>])\` |
7857
+
7858
+ If nothing fits, default to "switch to the new lens and pull leads" \u2014 never
7859
+ invent a tool that doesn't exist.
7860
+ `;
7514
7861
  var leadbay_open_billing_portal = `Generate a one-shot Stripe customer-portal URL. Wraps \`GET /1.5/stripe/portal\` \u2192 \`{url}\`. Returns a fresh Stripe-hosted URL the user can open to manage their existing Leadbay subscription: change plan tier, swap payment method, view invoices. The agent does NOT make subscription changes itself \u2014 it surfaces the URL and lets the user act.
7515
7862
 
7516
7863
  Sibling of \`leadbay_create_topup_link\`. Use cases differ:
@@ -8143,7 +8490,33 @@ WHEN TO USE: before leadbay_enrich_titles, to plan which titles to order.
8143
8490
 
8144
8491
  WHEN NOT TO USE: when you already know the exact titles you want to enrich.
8145
8492
  `;
8146
- var leadbay_refine_prompt = `Refine the kind of leads Leadbay surfaces, beyond firmographics. Free-text instruction (e.g. "focus on hospitals running their own IT"). Sets the org's \`user_prompt\`; if the new prompt produces ambiguous criteria, Leadbay raises a clarification question, which this composite polls for and surfaces. Admin-only on the backend (will return 403 for non-admins).
8493
+ var leadbay_refine_prompt = `## WHEN TO USE
8494
+
8495
+ Trigger phrases: "focus on companies that <qualitative trait>", "I prefer leads that <behavior/characteristic>", "prioritize companies running their own IT", "deprioritize companies that just raised".
8496
+
8497
+ **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
8498
+
8499
+ Do NOT use for: "create a new lens / a lens specialized into <X>" \u2192 \`leadbay_new_lens\`; "add/remove <sector> to/from my <name> lens" \u2192 \`leadbay_adjust_audience\`; "narrow the audience to <sector> / <size>" \u2192 \`leadbay_adjust_audience\`; "show me / list / switch my lenses" \u2192 \`leadbay_my_lenses\`.
8500
+
8501
+ Prefer when: ADMIN-ONLY. Qualitative refinement of the active lens that sector/size can't express. Creating/naming/listing/switching/sector-editing a lens routes elsewhere. Non-admin user \u2192 do NOT pick this.
8502
+
8503
+ Examples that SHOULD invoke this tool:
8504
+ - "Focus on hospitals that run their own IT in-house."
8505
+ - "Prioritize companies that have recently expanded headcount."
8506
+
8507
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
8508
+ - "Create a lens specialized in automobile."
8509
+ - "Add fintech to my Joinery lens."
8510
+ - "Show me my lenses."
8511
+
8512
+ ## RENDER (quick)
8513
+
8514
+ On success: confirm the refinement applied to the active lens. If a
8515
+ clarification was raised, surface its question (route via ask_user_input_v0).
8516
+
8517
+ ---
8518
+
8519
+ Refine the kind of leads Leadbay surfaces, beyond firmographics. Free-text instruction (e.g. "focus on hospitals running their own IT"). Sets the org's \`user_prompt\`; if the new prompt produces ambiguous criteria, Leadbay raises a clarification question, which this composite polls for and surfaces. Admin-only on the backend (will return 403 for non-admins).
8147
8520
 
8148
8521
  WHEN TO USE: when audience filters (leadbay_adjust_audience) aren't enough.
8149
8522
 
@@ -10838,9 +11211,23 @@ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseB
10838
11211
  await pollPreprocess(client, importId, phaseBudget, ctx, signal);
10839
11212
  ctx?.logger?.info?.(`import-leads: preprocess done for importId=${importId}`);
10840
11213
  if (dryRun) {
10841
- return { importId, records: [] };
11214
+ return { importId, records: [], notification_id: null };
11215
+ }
11216
+ let updateMappingsResp = null;
11217
+ try {
11218
+ updateMappingsResp = await client.request("POST", `/imports/${importId}/update_mappings`, mappings);
11219
+ } catch (err) {
11220
+ if (err?.code === "API_ERROR" || err?.code === "NOT_FOUND") {
11221
+ ctx?.logger?.warn?.(`import-leads: update_mappings raw error (${err?.code}); retrying void`);
11222
+ await client.requestVoid("POST", `/imports/${importId}/update_mappings`, mappings);
11223
+ } else {
11224
+ throw err;
11225
+ }
11226
+ }
11227
+ const importNotificationId = updateMappingsResp?.notification_id ?? null;
11228
+ if (importNotificationId) {
11229
+ ctx?.logger?.info?.(`import-leads: notification_id=${importNotificationId} importId=${importId}`);
10842
11230
  }
10843
- await client.requestVoid("POST", `/imports/${importId}/update_mappings`, mappings);
10844
11231
  ctx?.logger?.info?.(`import-leads: mappings committed for importId=${importId}`);
10845
11232
  const phaseBudget2 = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
10846
11233
  await pollProcess(client, importId, phaseBudget2, ctx, signal);
@@ -10848,7 +11235,7 @@ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseB
10848
11235
  const phaseBudget3 = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
10849
11236
  const records = await pollRecordsToTerminal(client, importId, phaseBudget3, chunk.length, ctx, signal);
10850
11237
  ctx?.logger?.info?.(`import-leads: ${records.length} records terminal for importId=${importId}`);
10851
- return { importId, records };
11238
+ return { importId, records, notification_id: importNotificationId };
10852
11239
  }
10853
11240
  function reconcileOneChunk(prep, chunk, matched, notImported) {
10854
11241
  const seenInputIndex = /* @__PURE__ */ new Set();
@@ -10904,7 +11291,7 @@ function reconcileOneChunk(prep, chunk, matched, notImported) {
10904
11291
  }
10905
11292
  }
10906
11293
  }
10907
- function buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled) {
11294
+ function buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled, notificationIds) {
10908
11295
  const leads = [];
10909
11296
  const not_imported = [];
10910
11297
  if (dryRun) {
@@ -10969,6 +11356,7 @@ function buildImportLeadsResult(client, prep, importIds, matched, notImported, d
10969
11356
  leads,
10970
11357
  not_imported,
10971
11358
  importIds,
11359
+ notification_ids: notificationIds,
10972
11360
  region: client.region,
10973
11361
  cancelled: cancelled || void 0,
10974
11362
  dry_run: dryRun || void 0,
@@ -11156,6 +11544,7 @@ var importLeads = {
11156
11544
  leads: [],
11157
11545
  not_imported,
11158
11546
  importIds: [],
11547
+ notification_ids: [],
11159
11548
  region: client.region,
11160
11549
  dry_run: dryRun || void 0,
11161
11550
  _meta: client.lastMeta ?? {
@@ -11210,6 +11599,10 @@ var importLeads = {
11210
11599
  status: "running",
11211
11600
  handle_id: reservation.record.bulk_id,
11212
11601
  importIds: importIds2,
11602
+ // Notifications fire from update_mappings, which the background
11603
+ // task hasn't called yet at this point. They surface via the WS
11604
+ // listener / catch-up REST on subsequent agent turns.
11605
+ notification_ids: [],
11213
11606
  progress: {
11214
11607
  phase: reservation.record.status === "complete" ? "complete" : importIds2.length > 0 ? "preprocess" : "queued",
11215
11608
  records_processed: reservation.record.status === "complete" ? reservation.record.records_total : 0,
@@ -11230,6 +11623,7 @@ var importLeads = {
11230
11623
  }
11231
11624
  ctx?.logger?.info?.(`import-leads(${prep.mode}): ${prep.validInputs.length} rows \u2192 ${chunks.length} chunk(s); dry_run=${dryRun}, totalBudgetMs=${totalBudget}`);
11232
11625
  const importIds = [];
11626
+ const notificationIds = [];
11233
11627
  const matched = /* @__PURE__ */ new Map();
11234
11628
  const notImported = /* @__PURE__ */ new Map();
11235
11629
  let cancelled = false;
@@ -11241,6 +11635,9 @@ var importLeads = {
11241
11635
  for (let i = 0; i < chunks.length; i++) {
11242
11636
  const chunk = chunks[i];
11243
11637
  const out = await runOneChunk(client, chunk, i, chunks.length, prep.header, prep.mappings, dryRun, perPhaseBudget, totalDeadline, ctx, signal, recordImportId);
11638
+ if (out.notification_id && !notificationIds.includes(out.notification_id)) {
11639
+ notificationIds.push(out.notification_id);
11640
+ }
11244
11641
  if (!dryRun) {
11245
11642
  reconcileOneChunk(prep, out, matched, notImported);
11246
11643
  }
@@ -11261,7 +11658,7 @@ var importLeads = {
11261
11658
  throw err;
11262
11659
  }
11263
11660
  }
11264
- return buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled);
11661
+ return buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled, notificationIds);
11265
11662
  }
11266
11663
  };
11267
11664
  async function runImportInBackground(client, prep, uploadedChunks, opts, ctx, handleId) {
@@ -11278,17 +11675,21 @@ async function runImportInBackground(client, prep, uploadedChunks, opts, ctx, ha
11278
11675
  void (async () => {
11279
11676
  const bgCtx = { logger: ctx.logger, bulkTracker: tracker };
11280
11677
  const importIds = uploadedChunks.map((chunk) => chunk.importId);
11678
+ const notificationIds = [];
11281
11679
  const matched = /* @__PURE__ */ new Map();
11282
11680
  const notImported = /* @__PURE__ */ new Map();
11283
11681
  try {
11284
11682
  const totalDeadline = Date.now() + opts.totalBudget;
11285
11683
  for (const upload of uploadedChunks) {
11286
11684
  const out = await completeUploadedChunk(client, upload, prep.mappings, opts.dryRun, opts.perPhaseBudget, totalDeadline, bgCtx, void 0);
11685
+ if (out.notification_id && !notificationIds.includes(out.notification_id)) {
11686
+ notificationIds.push(out.notification_id);
11687
+ }
11287
11688
  if (!opts.dryRun) {
11288
11689
  reconcileOneChunk(prep, out, matched, notImported);
11289
11690
  }
11290
11691
  }
11291
- const result = buildImportLeadsResult(client, prep, importIds, matched, notImported, opts.dryRun, false);
11692
+ const result = buildImportLeadsResult(client, prep, importIds, matched, notImported, opts.dryRun, false, notificationIds);
11292
11693
  await tracker.markImportComplete(handleId, {
11293
11694
  leads: result.leads,
11294
11695
  not_imported: result.not_imported,
@@ -11956,50 +12357,107 @@ var agentMemoryReview = {
11956
12357
  }
11957
12358
  };
11958
12359
 
11959
- // ../core/dist/tools/select-leads.js
11960
- var selectLeads = {
11961
- name: "leadbay_select_leads",
12360
+ // ../core/dist/tools/acknowledge-notification.js
12361
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12362
+ var acknowledgeNotification = {
12363
+ name: "leadbay_acknowledge_notification",
11962
12364
  annotations: {
11963
- title: "Select leads",
12365
+ title: "Acknowledge a Leadbay notification",
11964
12366
  readOnlyHint: false,
11965
- destructiveHint: true,
12367
+ destructiveHint: false,
11966
12368
  idempotentHint: true,
11967
12369
  openWorldHint: true
11968
12370
  },
11969
- description: leadbay_select_leads,
11970
- optional: true,
12371
+ description: leadbay_acknowledge_notification,
11971
12372
  write: true,
11972
12373
  inputSchema: {
11973
12374
  type: "object",
11974
12375
  properties: {
11975
- leadIds: {
11976
- type: "array",
11977
- items: { type: "string" },
11978
- description: "Lead UUIDs to add to selection (1-1000)",
11979
- minItems: 1,
11980
- maxItems: 1e3
12376
+ notification_id: {
12377
+ type: "string",
12378
+ description: "UUID of the notification to acknowledge. Use the notification_id from `_meta.notifications[]` or `account_status.notifications[]`."
12379
+ },
12380
+ archive: {
12381
+ type: "boolean",
12382
+ description: "If true, archive the notification (won't appear in `archived=false` listings). If false / omitted, mark seen (resets firstSeenAt)."
11981
12383
  }
11982
12384
  },
11983
- required: ["leadIds"],
12385
+ required: ["notification_id"],
11984
12386
  additionalProperties: false
11985
12387
  },
11986
12388
  outputSchema: {
11987
12389
  type: "object",
11988
12390
  properties: {
11989
- selected: {
11990
- type: "number",
11991
- description: "How many leadIds the call added to the selection (echoes input length)."
11992
- }
12391
+ acknowledged: { type: "boolean" },
12392
+ notification_id: { type: "string" },
12393
+ action: { type: "string", enum: ["seen", "archive"] }
11993
12394
  },
11994
- required: ["selected"]
12395
+ required: ["acknowledged", "notification_id", "action"]
11995
12396
  },
11996
- execute: async (client, params) => {
11997
- const qs = params.leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
11998
- await client.requestVoid("POST", `/leads/selection/select?${qs}`);
11999
- return { selected: params.leadIds.length };
12000
- }
12001
- };
12002
-
12397
+ execute: async (client, params, ctx) => {
12398
+ if (!UUID_RE.test(params.notification_id)) {
12399
+ return {
12400
+ error: true,
12401
+ code: "BAD_INPUT",
12402
+ message: "notification_id must be a UUID",
12403
+ hint: "Pass the notification_id verbatim from _meta.notifications[].notification_id or account_status.notifications[].notification_id."
12404
+ };
12405
+ }
12406
+ const action = params.archive ? "archive" : "seen";
12407
+ await client.acknowledgeNotification(params.notification_id, action);
12408
+ ctx?.notificationsInbox?.markSeen(params.notification_id);
12409
+ return {
12410
+ acknowledged: true,
12411
+ notification_id: params.notification_id,
12412
+ action
12413
+ };
12414
+ }
12415
+ };
12416
+
12417
+ // ../core/dist/tools/select-leads.js
12418
+ var selectLeads = {
12419
+ name: "leadbay_select_leads",
12420
+ annotations: {
12421
+ title: "Select leads",
12422
+ readOnlyHint: false,
12423
+ destructiveHint: true,
12424
+ idempotentHint: true,
12425
+ openWorldHint: true
12426
+ },
12427
+ description: leadbay_select_leads,
12428
+ optional: true,
12429
+ write: true,
12430
+ inputSchema: {
12431
+ type: "object",
12432
+ properties: {
12433
+ leadIds: {
12434
+ type: "array",
12435
+ items: { type: "string" },
12436
+ description: "Lead UUIDs to add to selection (1-1000)",
12437
+ minItems: 1,
12438
+ maxItems: 1e3
12439
+ }
12440
+ },
12441
+ required: ["leadIds"],
12442
+ additionalProperties: false
12443
+ },
12444
+ outputSchema: {
12445
+ type: "object",
12446
+ properties: {
12447
+ selected: {
12448
+ type: "number",
12449
+ description: "How many leadIds the call added to the selection (echoes input length)."
12450
+ }
12451
+ },
12452
+ required: ["selected"]
12453
+ },
12454
+ execute: async (client, params) => {
12455
+ const qs = params.leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
12456
+ await client.requestVoid("POST", `/leads/selection/select?${qs}`);
12457
+ return { selected: params.leadIds.length };
12458
+ }
12459
+ };
12460
+
12003
12461
  // ../core/dist/tools/deselect-leads.js
12004
12462
  var deselectLeads = {
12005
12463
  name: "leadbay_deselect_leads",
@@ -12118,7 +12576,7 @@ var createLens = {
12118
12576
  },
12119
12577
  execute: async (client, params) => {
12120
12578
  const lens = await client.request("POST", "/lenses", {
12121
- base: params.base,
12579
+ base: String(params.base),
12122
12580
  name: params.name,
12123
12581
  description: params.description
12124
12582
  });
@@ -15028,6 +15486,11 @@ var accountStatus = {
15028
15486
  type: ["object", "null"],
15029
15487
  description: "Per-resource quota state (llm_completion, ai_rescore, web_fetch, LENS_EXTRA_REFILL) across daily/weekly/monthly windows. Null if /quota_status failed (logged in stderr). Pre-check the LENS_EXTRA_REFILL entry before calling leadbay_extend_lens."
15030
15488
  },
15489
+ notifications: {
15490
+ type: "array",
15491
+ description: "Terminal bulk-progress notifications the MCP knows about (background work the user or agent started that has since completed). Each entry carries notification_id, kind (bulk_enrich | bulk_qualify | import | other), bulk_progress counters, and a revise_hint pointing at prior agent outputs the just-finished work might have made stale. After revising affected outputs, call leadbay_acknowledge_notification(notification_id) to clear the entry. Empty array when nothing has completed.",
15492
+ items: { type: "object" }
15493
+ },
15031
15494
  _meta: {
15032
15495
  type: "object",
15033
15496
  properties: {
@@ -15087,6 +15550,13 @@ var accountStatus = {
15087
15550
  // on /me are intentionally NOT surfaced — they're defunct (see
15088
15551
  // SHAPE-DRIFT.md probe round 4).
15089
15552
  quota,
15553
+ // Inbox of terminal bulk-progress notifications. Same shape the MCP
15554
+ // server attaches to `_meta.notifications` on every tool response —
15555
+ // duplicated here as a top-level field so the agent's daily-rhythm
15556
+ // check-in (this composite) sees them without having to read _meta.
15557
+ // Empty array when the WS listener isn't wired (OpenClaw, tests) OR
15558
+ // when nothing has completed since the last ack.
15559
+ notifications: ctx?.notificationsInbox?.list() ?? [],
15090
15560
  _meta: {
15091
15561
  region: client.region
15092
15562
  }
@@ -15100,6 +15570,33 @@ var DEFAULT_COUNT = 10;
15100
15570
  var MAX_COUNT = 25;
15101
15571
  var DEFAULT_PER_LEAD_BUDGET_MS = 9e4;
15102
15572
  var DEFAULT_TOTAL_BUDGET_MS2 = 5 * 6e4;
15573
+ async function launchBulkQualify(client, leadIds, ctx) {
15574
+ await client.acquireSelectionLock();
15575
+ try {
15576
+ try {
15577
+ const qs = leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
15578
+ await client.requestVoid("POST", `/leads/selection/select?${qs}`);
15579
+ try {
15580
+ const resp = await client.request("POST", "/leads/selection/web_fetch?force_fetch=false", {});
15581
+ return { resp, quotaExceeded: false };
15582
+ } catch (err) {
15583
+ if (err?.code === "QUOTA_EXCEEDED") {
15584
+ ctx?.logger?.warn?.("bulk_qualify_leads: 429 on bulk /leads/selection/web_fetch \u2014 no leads queued");
15585
+ return { resp: null, quotaExceeded: true };
15586
+ }
15587
+ throw err;
15588
+ }
15589
+ } finally {
15590
+ try {
15591
+ await client.requestVoid("POST", "/leads/selection/clear");
15592
+ } catch (e) {
15593
+ ctx?.logger?.warn?.(`bulk_qualify_leads: selection.clear failed: ${e?.message ?? e?.code}`);
15594
+ }
15595
+ }
15596
+ } finally {
15597
+ client.releaseSelectionLock();
15598
+ }
15599
+ }
15103
15600
  var bulkQualifyLeads = {
15104
15601
  name: "leadbay_bulk_qualify_leads",
15105
15602
  annotations: {
@@ -15267,69 +15764,50 @@ var bulkQualifyLeads = {
15267
15764
  per_lead_budget_ms: perLeadBudget,
15268
15765
  total_budget_ms: totalBudget
15269
15766
  });
15270
- const launched2 = [];
15271
- const failed2 = [];
15767
+ let launchedCount = 0;
15768
+ let notificationId = null;
15272
15769
  let quotaExceeded2 = false;
15770
+ let failed2 = [];
15273
15771
  if (!reservation.reused) {
15274
- for (const leadId of candidates) {
15275
- if (quotaExceeded2)
15276
- break;
15277
- try {
15278
- await client.requestVoid("POST", `/leads/${leadId}/web_fetch?force_fetch=false`);
15279
- launched2.push(leadId);
15280
- } catch (err) {
15281
- if (err?.code === "QUOTA_EXCEEDED") {
15282
- quotaExceeded2 = true;
15283
- } else if (err?.code === "NOT_FOUND") {
15284
- failed2.push({ lead_id: leadId, error: "lead not found" });
15285
- } else {
15286
- failed2.push({
15287
- lead_id: leadId,
15288
- error: err?.message ?? err?.code ?? "unknown"
15289
- });
15290
- }
15291
- }
15292
- }
15293
- if (failed2.length === candidates.length || launched2.length > 0 || quotaExceeded2) {
15294
- await ctx.bulkTracker.markLaunched(reservation.record.bulk_id);
15772
+ const launch = await launchBulkQualify(client, candidates, ctx);
15773
+ quotaExceeded2 = launch.quotaExceeded;
15774
+ notificationId = launch.resp?.notification_id ?? null;
15775
+ const queuedIds = launch.resp?.queued_ids ?? [];
15776
+ const skippedIds = launch.resp?.skipped_ids ?? [];
15777
+ launchedCount = queuedIds.length;
15778
+ const seen = /* @__PURE__ */ new Set([...queuedIds, ...skippedIds]);
15779
+ failed2 = candidates.filter((id) => !seen.has(id)).map((id) => ({ lead_id: id, error: "not_queued" }));
15780
+ if (queuedIds.length > 0 || quotaExceeded2 || skippedIds.length > 0 || failed2.length === candidates.length) {
15781
+ await ctx.bulkTracker.markLaunched(reservation.record.bulk_id, notificationId);
15295
15782
  }
15783
+ } else {
15784
+ notificationId = reservation.record.notification_id ?? null;
15785
+ launchedCount = reservation.record.lead_ids.length;
15296
15786
  }
15297
15787
  const out = {
15298
15788
  status: "running",
15299
15789
  handle_id: reservation.record.bulk_id,
15300
15790
  qualify_id: reservation.record.bulk_id,
15301
15791
  lead_ids: candidates,
15302
- launched_count: reservation.reused ? reservation.record.lead_ids.length : launched2.length,
15792
+ launched_count: launchedCount,
15303
15793
  failed: failed2,
15304
15794
  quota_exceeded: quotaExceeded2,
15305
15795
  lens_id: lensId,
15796
+ notification_id: notificationId,
15306
15797
  _meta: { region: client.region }
15307
15798
  };
15308
15799
  return out;
15309
15800
  }
15310
- const launched = [];
15311
- const failed = [];
15312
- let quotaExceeded = false;
15313
- for (const leadId of candidates) {
15314
- if (quotaExceeded)
15315
- break;
15316
- try {
15317
- await client.requestVoid("POST", `/leads/${leadId}/web_fetch?force_fetch=false`);
15318
- launched.push(leadId);
15319
- } catch (err) {
15320
- if (err?.code === "QUOTA_EXCEEDED") {
15321
- quotaExceeded = true;
15322
- ctx?.logger?.warn?.(`bulk_qualify_leads: 429 mid-fanout after launching ${launched.length}/${candidates.length} \u2014 stopping further launches but polling those already in flight`);
15323
- } else if (err?.code === "NOT_FOUND") {
15324
- failed.push({ lead_id: leadId, error: "lead not found" });
15325
- } else {
15326
- failed.push({
15327
- lead_id: leadId,
15328
- error: err?.message ?? err?.code ?? "unknown"
15329
- });
15330
- }
15331
- }
15801
+ const inlineLaunch = await launchBulkQualify(client, candidates, ctx);
15802
+ const quotaExceeded = inlineLaunch.quotaExceeded;
15803
+ const launched = inlineLaunch.resp?.queued_ids ?? [];
15804
+ const inlineSkipped = inlineLaunch.resp?.skipped_ids ?? [];
15805
+ const inlineNotificationId = inlineLaunch.resp?.notification_id ?? null;
15806
+ if (inlineNotificationId) {
15807
+ ctx?.logger?.info?.(`bulk_qualify_leads: launched bulk progress_notification_id=${inlineNotificationId} queued=${launched.length} skipped=${inlineSkipped.length}`);
15332
15808
  }
15809
+ const inlineFailedSeen = /* @__PURE__ */ new Set([...launched, ...inlineSkipped]);
15810
+ const failed = candidates.filter((id) => !inlineFailedSeen.has(id)).map((id) => ({ lead_id: id, error: "not_queued" }));
15333
15811
  let progressDone = 0;
15334
15812
  const progressTotal = launched.length;
15335
15813
  if (progressTotal > 0) {
@@ -16042,6 +16520,7 @@ var importAndQualify = {
16042
16520
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
16043
16521
  qualify_id: null,
16044
16522
  import_ids: queued.importIds,
16523
+ notification_ids: queued.notification_ids ?? [],
16045
16524
  imported: queued.leads.map((l) => ({
16046
16525
  leadId: l.leadId,
16047
16526
  ...l.domain ? { domain: l.domain } : {},
@@ -16066,6 +16545,7 @@ var importAndQualify = {
16066
16545
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
16067
16546
  qualify_id: null,
16068
16547
  import_ids: queued.importIds,
16548
+ notification_ids: queued.notification_ids ?? [],
16069
16549
  imported: [],
16070
16550
  not_imported: [],
16071
16551
  qualified: [],
@@ -16103,6 +16583,7 @@ var importAndQualify = {
16103
16583
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
16104
16584
  qualify_id: null,
16105
16585
  import_ids: importResult.importIds,
16586
+ notification_ids: importResult.notification_ids ?? [],
16106
16587
  imported: [],
16107
16588
  not_imported: importResult.not_imported.map(toNotImportedEntry),
16108
16589
  qualified: [],
@@ -16140,6 +16621,7 @@ var importAndQualify = {
16140
16621
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
16141
16622
  qualify_id: null,
16142
16623
  import_ids: importResult.importIds,
16624
+ notification_ids: importResult.notification_ids ?? [],
16143
16625
  imported,
16144
16626
  not_imported,
16145
16627
  qualified: [],
@@ -16250,6 +16732,7 @@ var importAndQualify = {
16250
16732
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
16251
16733
  qualify_id: reservation.record.bulk_id,
16252
16734
  import_ids: importResult.importIds,
16735
+ notification_ids: importResult.notification_ids ?? [],
16253
16736
  imported,
16254
16737
  not_imported,
16255
16738
  qualified,
@@ -16591,6 +17074,14 @@ var importStatus = {
16591
17074
  };
16592
17075
 
16593
17076
  // ../core/dist/composite/qualify-status.js
17077
+ async function readNotification(client, notificationId) {
17078
+ try {
17079
+ const page = await client.listNotifications({ archived: false, count: 50 });
17080
+ return page.items.find((n) => n.id === notificationId) ?? null;
17081
+ } catch {
17082
+ return null;
17083
+ }
17084
+ }
16594
17085
  var qualifyStatus = {
16595
17086
  name: "leadbay_qualify_status",
16596
17087
  annotations: {
@@ -16745,6 +17236,16 @@ var qualifyStatus = {
16745
17236
  const { _stillRunning, _failedCode, ...rest } = r;
16746
17237
  qualified.push(rest);
16747
17238
  }
17239
+ let bulkProgress = null;
17240
+ let inProgressFlag = null;
17241
+ const notifId = record.notification_id ?? null;
17242
+ if (notifId) {
17243
+ const n = await readNotification(client, notifId);
17244
+ if (n) {
17245
+ bulkProgress = n.bulk_progress;
17246
+ inProgressFlag = n.in_progress;
17247
+ }
17248
+ }
16748
17249
  const out = {
16749
17250
  qualify_id: record.bulk_id,
16750
17251
  launched_at: record.launched_at,
@@ -16756,6 +17257,9 @@ var qualifyStatus = {
16756
17257
  still_running,
16757
17258
  failed,
16758
17259
  not_in_lens: [...notInLensSet],
17260
+ notification_id: notifId,
17261
+ bulk_progress: bulkProgress,
17262
+ in_progress: inProgressFlag,
16759
17263
  region: client.region,
16760
17264
  _meta: client.lastMeta ?? {
16761
17265
  region: client.region,
@@ -16768,6 +17272,9 @@ var qualifyStatus = {
16768
17272
  out.per_lead_budget_ms = record.per_lead_budget_ms;
16769
17273
  if (record.total_budget_ms !== void 0)
16770
17274
  out.total_budget_ms = record.total_budget_ms;
17275
+ if (bulkProgress && bulkProgress.quota_hit_count > 0) {
17276
+ out.quota_hit_hint = "Some leads hit the AI-credits quota during qualification. Top up via leadbay_create_topup_link to clear the throttle immediately, or wait until the daily/weekly window resets.";
17277
+ }
16771
17278
  return out;
16772
17279
  }
16773
17280
  };
@@ -17038,6 +17545,7 @@ var enrichTitles = {
17038
17545
  bulk_id: res.record.bulk_id,
17039
17546
  launched_at: res.record.launched_at,
17040
17547
  durability: res.record.durability,
17548
+ notification_id: res.record.notification_id ?? null,
17041
17549
  seconds_since_original_launch: bulkSecondsSinceOriginal ?? 0,
17042
17550
  titles: params.titles,
17043
17551
  email,
@@ -17053,8 +17561,9 @@ var enrichTitles = {
17053
17561
  total: 3,
17054
17562
  message: `Launching enrichment for ${params.titles.length} title${params.titles.length === 1 ? "" : "s"}\u2026`
17055
17563
  });
17564
+ let launchResp = null;
17056
17565
  try {
17057
- await client.requestVoid("POST", "/leads/selection/enrichment/launch", { titles: params.titles, email, phone });
17566
+ launchResp = await client.request("POST", "/leads/selection/enrichment/launch", { titles: params.titles, email, phone });
17058
17567
  } catch (err) {
17059
17568
  const aborted = err?.name === "AbortError" || ctx?.signal?.aborted === true;
17060
17569
  if (bulkRecord && tracker) {
@@ -17078,9 +17587,10 @@ var enrichTitles = {
17078
17587
  }
17079
17588
  throw err;
17080
17589
  }
17590
+ const notificationId = launchResp?.notification_id ?? null;
17081
17591
  if (bulkRecord && tracker) {
17082
17592
  try {
17083
- await tracker.markLaunched(bulkRecord.bulk_id);
17593
+ await tracker.markLaunched(bulkRecord.bulk_id, notificationId);
17084
17594
  } catch (e) {
17085
17595
  ctx?.logger?.warn?.(`enrich_titles: tracker.markLaunched failed: ${e?.message ?? e}`);
17086
17596
  return {
@@ -17108,8 +17618,9 @@ var enrichTitles = {
17108
17618
  bulk_id: bulkRecord?.bulk_id,
17109
17619
  launched_at: bulkRecord?.launched_at,
17110
17620
  durability: bulkRecord?.durability,
17111
- message: bulkRecord ? "Enrichment job launched. Backend has no server-side bulk_id yet; MCP minted a client-side bulk_id (persisted to disk by default) so you can poll via leadbay_bulk_enrich_status." : "Enrichment job launched. No bulk_id tracker configured \u2014 poll leadbay_get_contacts per lead after ~60s; contact.enrichment.done flips to true.",
17112
- next_action: bulkRecord ? "Call leadbay_bulk_enrich_status({bulk_id}) after ~60s; pass include_contacts=true for the final read." : "Wait ~60s, then call leadbay_research_lead_by_id or leadbay_get_contacts on the leads you care about."
17621
+ notification_id: notificationId,
17622
+ message: notificationId ? "Enrichment job launched. The MCP is now listening for the backend notification \u2014 when enrichment finishes, a `_meta.notifications` entry will surface on your next tool response (also visible in `leadbay_account_status.notifications`)." : bulkRecord ? "Enrichment job launched. Backend did not return a notification id this time; poll via leadbay_bulk_enrich_status with the bulk_id." : "Enrichment job launched. No bulk_id tracker configured \u2014 poll leadbay_get_contacts per lead after ~60s; contact.enrichment.done flips to true.",
17623
+ next_action: notificationId ? "Wait for the next `_meta.notifications` entry (typically <2 min for a small batch). If you want progress sooner, call leadbay_bulk_enrich_status({bulk_id})." : bulkRecord ? "Call leadbay_bulk_enrich_status({bulk_id}) after ~60s; pass include_contacts=true for the final read." : "Wait ~60s, then call leadbay_research_lead_by_id or leadbay_get_contacts on the leads you care about."
17113
17624
  };
17114
17625
  } finally {
17115
17626
  try {
@@ -17125,6 +17636,14 @@ var enrichTitles = {
17125
17636
  };
17126
17637
 
17127
17638
  // ../core/dist/composite/bulk-enrich-status.js
17639
+ async function readNotification2(client, notificationId) {
17640
+ try {
17641
+ const page = await client.listNotifications({ archived: false, count: 50 });
17642
+ return page.items.find((n) => n.id === notificationId) ?? null;
17643
+ } catch {
17644
+ return null;
17645
+ }
17646
+ }
17128
17647
  var STATUS_FETCH_CONCURRENCY = 5;
17129
17648
  async function pMap(items, fn, concurrency) {
17130
17649
  const out = new Array(items.length);
@@ -17289,6 +17808,53 @@ var bulkEnrichStatus = {
17289
17808
  launched_at: record.launched_at
17290
17809
  };
17291
17810
  }
17811
+ const notifId = record.notification_id ?? null;
17812
+ if (notifId) {
17813
+ const n = await readNotification2(client, notifId);
17814
+ if (n && n.bulk_progress) {
17815
+ const bp = n.bulk_progress;
17816
+ const inProgress = n.in_progress;
17817
+ let leads2 = [];
17818
+ if (!inProgress && includeContacts) {
17819
+ leads2 = await pMap(record.lead_ids, async (leadId) => {
17820
+ try {
17821
+ const out = await getContacts.execute(client, { leadId });
17822
+ const contacts = Array.isArray(out?.contacts) ? out.contacts : [];
17823
+ return { lead_id: leadId, contacts };
17824
+ } catch {
17825
+ return { lead_id: leadId };
17826
+ }
17827
+ }, STATUS_FETCH_CONCURRENCY);
17828
+ } else {
17829
+ leads2 = record.lead_ids.map((id) => ({ lead_id: id }));
17830
+ }
17831
+ ctx?.logger?.info?.(`bulk.status_checked_via_notification bulk_id=${record.bulk_id} notification_id=${notifId} done=${bp.success_count}/${bp.total_count} in_progress=${inProgress} wall_ms=${Date.now() - startMs}`);
17832
+ return {
17833
+ bulk_id: record.bulk_id,
17834
+ notification_id: notifId,
17835
+ launched_at: record.launched_at,
17836
+ status: record.status,
17837
+ durability: record.durability,
17838
+ titles: record.titles,
17839
+ email: record.email,
17840
+ phone: record.phone,
17841
+ lens_id: record.lens_id,
17842
+ leads: leads2,
17843
+ overall_progress: {
17844
+ done: bp.success_count + bp.failure_count + bp.quota_hit_count,
17845
+ total: bp.total_count,
17846
+ done_ratio: bp.total_count === 0 ? 0 : (bp.success_count + bp.failure_count + bp.quota_hit_count) / bp.total_count
17847
+ },
17848
+ bulk_progress: bp,
17849
+ in_progress: inProgress,
17850
+ all_done: !inProgress,
17851
+ ...bp.quota_hit_count > 0 ? {
17852
+ quota_hit_hint: "Some contacts could not be enriched because the AI-credits quota was hit. Top up via leadbay_create_topup_link or wait for the window reset."
17853
+ } : {}
17854
+ };
17855
+ }
17856
+ ctx?.logger?.info?.(`bulk_enrich_status: notification ${notifId} not yet visible; falling back to per-lead fan-out`);
17857
+ }
17292
17858
  let doneSoFar = 0;
17293
17859
  const totalLeads = record.lead_ids.length;
17294
17860
  const results = await pMap(record.lead_ids, async (leadId) => {
@@ -17373,6 +17939,8 @@ var bulkEnrichStatus = {
17373
17939
 
17374
17940
  // ../core/dist/composite/adjust-audience.js
17375
17941
  function tokens(s) {
17942
+ if (!s)
17943
+ return [];
17376
17944
  return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
17377
17945
  }
17378
17946
  function bestMatches(text, taxonomy) {
@@ -17386,11 +17954,11 @@ function bestMatches(text, taxonomy) {
17386
17954
  if (have.has(t))
17387
17955
  overlap += 1;
17388
17956
  const score = overlap / Math.max(want.size, 1);
17389
- return { id: s.id, name: s.name, score };
17957
+ return { id: s.id, name: s.name ?? "", score };
17390
17958
  }).filter((m) => m.score > 0).sort((a, b) => b.score - a.score);
17391
17959
  return ranked.slice(0, 5);
17392
17960
  }
17393
- async function resolveSectors(client, texts) {
17961
+ async function resolveSectors(client, texts, ctx) {
17394
17962
  const looksLikeId2 = (s) => /^\d+$/.test(s);
17395
17963
  const direct = texts.filter(looksLikeId2);
17396
17964
  const free = texts.filter((s) => !looksLikeId2(s));
@@ -17399,6 +17967,10 @@ async function resolveSectors(client, texts) {
17399
17967
  const me = await client.resolveMe().catch(() => null);
17400
17968
  const lang = me?.language ?? "en";
17401
17969
  const taxonomy = await client.request("GET", `/sectors/all?lang=${encodeURIComponent(lang)}&includeInvisible=false`);
17970
+ const nullNames = taxonomy.filter((s) => !s.name).length;
17971
+ if (nullNames > 0) {
17972
+ ctx?.logger?.warn?.(`adjust_audience: /sectors/all returned ${nullNames}/${taxonomy.length} sector(s) with a null/missing name`);
17973
+ }
17402
17974
  const resolved = [...direct];
17403
17975
  const ambiguities = [];
17404
17976
  for (const text of free) {
@@ -17411,6 +17983,22 @@ async function resolveSectors(client, texts) {
17411
17983
  }
17412
17984
  return { resolved, ambiguities };
17413
17985
  }
17986
+ async function resolveLensByName(client, name) {
17987
+ const lenses = await client.request("GET", "/lenses");
17988
+ const all = lenses.map((l) => ({ id: l.id, name: l.name }));
17989
+ const needle = name.trim().toLowerCase();
17990
+ const exact = all.filter((l) => (l.name ?? "").trim().toLowerCase() === needle);
17991
+ if (exact.length === 1)
17992
+ return { ok: true, id: exact[0].id };
17993
+ if (exact.length > 1)
17994
+ return { ok: false, reason: "ambiguous", matches: exact };
17995
+ const partial = all.filter((l) => (l.name ?? "").toLowerCase().includes(needle));
17996
+ if (partial.length === 1)
17997
+ return { ok: true, id: partial[0].id };
17998
+ if (partial.length > 1)
17999
+ return { ok: false, reason: "ambiguous", matches: partial };
18000
+ return { ok: false, reason: "not_found", lenses: all };
18001
+ }
17414
18002
  function mergeFilter(current, toAddSectors, toExcludeSectors, sizes) {
17415
18003
  const items = current?.lens_filter?.items ?? [];
17416
18004
  const item = items[0] ?? { criteria: [] };
@@ -17444,11 +18032,15 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes) {
17444
18032
  }
17445
18033
  }
17446
18034
  if (sizes && sizes.length > 0) {
18035
+ const normalizedSizes = sizes.map((s) => ({
18036
+ min: s.min ?? 0,
18037
+ max: s.max ?? 1e6
18038
+ }));
17447
18039
  const idx = criteria.findIndex((c) => c.type === "size");
17448
18040
  if (idx >= 0) {
17449
- criteria[idx] = { type: "size", is_excluded: false, sizes };
18041
+ criteria[idx] = { type: "size", is_excluded: false, sizes: normalizedSizes };
17450
18042
  } else {
17451
- criteria.push({ type: "size", is_excluded: false, sizes });
18043
+ criteria.push({ type: "size", is_excluded: false, sizes: normalizedSizes });
17452
18044
  }
17453
18045
  }
17454
18046
  return {
@@ -17456,6 +18048,9 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes) {
17456
18048
  locations: current.locations ?? { results: [], parents: [] }
17457
18049
  };
17458
18050
  }
18051
+ function filterWriteBody(filter) {
18052
+ return { items: filter.lens_filter.items };
18053
+ }
17459
18054
  var adjustAudience = {
17460
18055
  name: "leadbay_adjust_audience",
17461
18056
  annotations: {
@@ -17497,6 +18092,10 @@ var adjustAudience = {
17497
18092
  description: "Company size buckets, e.g. [{min:30,max:300}]"
17498
18093
  },
17499
18094
  lensId: { type: "number", description: "Lens id (escape hatch)" },
18095
+ lensName: {
18096
+ type: "string",
18097
+ description: "Target a lens BY NAME (e.g. 'Joinery') instead of the active one. Resolved against your lenses \u2014 edit-only, does NOT switch your active lens. Unknown/ambiguous names are surfaced to pick from. Takes effect only when lensId is not given."
18098
+ },
17500
18099
  save_for_org: {
17501
18100
  type: "boolean",
17502
18101
  description: "Admin only \u2014 propagate the change to the org-level lens for everyone (default false: per-user draft)"
@@ -17510,21 +18109,35 @@ var adjustAudience = {
17510
18109
  },
17511
18110
  outputSchema: {
17512
18111
  type: "object",
17513
- description: "Two return shapes: 'ambiguous_sectors' when free-text sectors matched multiple candidates (agent re-calls with sector_ids), 'applied' on success.",
18112
+ description: "Return shapes: 'applied' on success; 'ambiguous_sectors' when free-text sectors matched multiple candidates (re-call with sector_ids); 'lens_not_found' / 'ambiguous_lens' when a lensName didn't resolve to exactly one lens (re-call with lensId or an exact lensName).",
17514
18113
  properties: {
17515
18114
  status: {
17516
18115
  type: "string",
17517
- description: "'ambiguous_sectors' or 'applied'."
18116
+ description: "'applied', 'ambiguous_sectors', 'lens_not_found', or 'ambiguous_lens'."
17518
18117
  },
17519
18118
  sector_ambiguities: {
17520
18119
  type: "array",
17521
18120
  description: "Per ambiguous text: {sector_text, matches:[{id, name, score}]}. Agent picks an id and re-calls.",
17522
18121
  items: { type: "object" }
17523
18122
  },
18123
+ lenses: {
18124
+ type: "array",
18125
+ description: "On 'lens_not_found': the user's lenses [{id, name}] to pick from.",
18126
+ items: { type: "object" }
18127
+ },
18128
+ matches: {
18129
+ type: "array",
18130
+ description: "On 'ambiguous_lens': the lenses [{id, name}] the name matched.",
18131
+ items: { type: "object" }
18132
+ },
18133
+ lens_query: {
18134
+ type: "string",
18135
+ description: "On 'lens_not_found' / 'ambiguous_lens': the lensName the user asked for."
18136
+ },
17524
18137
  message: { type: "string" },
17525
18138
  lens_used: {
17526
18139
  type: "object",
17527
- description: "Resolved lens metadata: {id, name, was_draft, was_new, save_for_org}."
18140
+ description: "Resolved lens metadata: {id, name, was_draft, was_new, active_lens_changed, save_for_org}."
17528
18141
  },
17529
18142
  filter_applied: {
17530
18143
  type: "object",
@@ -17537,28 +18150,63 @@ var adjustAudience = {
17537
18150
  execute: async (client, params, ctx) => {
17538
18151
  const me = await client.resolveMe();
17539
18152
  const isAdmin = me.admin === true;
17540
- const startingLensId = params.lensId ?? me.last_requested_lens ?? await client.resolveDefaultLens();
18153
+ let namedLensId;
18154
+ if (params.lensId == null && params.lensName != null && params.lensName.trim() !== "") {
18155
+ const res = await resolveLensByName(client, params.lensName);
18156
+ if (!res.ok && res.reason === "not_found") {
18157
+ return {
18158
+ status: "lens_not_found",
18159
+ lens_query: params.lensName,
18160
+ lenses: res.lenses,
18161
+ message: `No lens named "${params.lensName}". Pick one of the listed lenses (pass lensId or an exact lensName), or create it first.`
18162
+ };
18163
+ }
18164
+ if (!res.ok && res.reason === "ambiguous") {
18165
+ return {
18166
+ status: "ambiguous_lens",
18167
+ lens_query: params.lensName,
18168
+ matches: res.matches,
18169
+ message: `"${params.lensName}" matched multiple lenses. Re-call with the exact lensName or the lensId of the one you mean.`
18170
+ };
18171
+ }
18172
+ if (res.ok)
18173
+ namedLensId = res.id;
18174
+ }
18175
+ const startingLensId = params.lensId ?? namedLensId ?? me.last_requested_lens ?? await client.resolveDefaultLens();
18176
+ const isNamedEdit = namedLensId != null && params.lensId == null;
17541
18177
  const includeTexts = [
17542
18178
  ...params.sectors ?? [],
17543
18179
  ...params.sector_ids ?? []
17544
18180
  ];
17545
18181
  const excludeTexts = params.exclude_sectors ?? [];
17546
- const includeRes = await resolveSectors(client, includeTexts);
17547
- const excludeRes = await resolveSectors(client, excludeTexts);
18182
+ const includeRes = await resolveSectors(client, includeTexts, ctx);
18183
+ const excludeRes = await resolveSectors(client, excludeTexts, ctx);
17548
18184
  const ambiguities = [
17549
18185
  ...includeRes.ambiguities,
17550
18186
  ...excludeRes.ambiguities
17551
18187
  ];
17552
18188
  if (ambiguities.length > 0) {
18189
+ const noMatch = ambiguities.filter((a) => a.matches.length === 0);
18190
+ const multi = ambiguities.filter((a) => a.matches.length > 0);
18191
+ const parts = [];
18192
+ if (noMatch.length > 0) {
18193
+ const names = noMatch.map((a) => `"${a.sector_text}"`).join(", ");
18194
+ parts.push(`Couldn't find a sector matching ${names}. Ask the user to rephrase or pick a known sector, then re-call with sector_ids=...`);
18195
+ }
18196
+ if (multi.length > 0) {
18197
+ const names = multi.map((a) => `"${a.sector_text}"`).join(", ");
18198
+ parts.push(`${names} matched multiple sectors. Pick from the matches and re-call with sector_ids=...`);
18199
+ }
17553
18200
  return {
17554
18201
  status: "ambiguous_sectors",
17555
18202
  sector_ambiguities: ambiguities,
17556
- message: "One or more sector names matched multiple sectors. Pick from the matches and re-call with sector_ids=..."
18203
+ message: parts.join(" ")
17557
18204
  };
17558
18205
  }
17559
18206
  const lens = await client.request("GET", `/lenses/${startingLensId}`);
17560
18207
  const currentFilter = await client.request("GET", `/lenses/${startingLensId}/filter`);
17561
18208
  const merged = mergeFilter(currentFilter, includeRes.resolved, excludeRes.resolved, params.sizes);
18209
+ const mergedBody = filterWriteBody(merged);
17562
18210
  const isDefault = lens.is_default || lens.default;
17563
18211
  const isUserLevel = lens.user_id != null;
17564
18212
  const isOrgLevel = !isUserLevel && !isDefault;
@@ -17567,24 +18215,28 @@ var adjustAudience = {
17567
18215
  let wasNew = false;
17568
18216
  if (isDefault) {
17569
18217
  const name = params.newLensName ?? `Custom audience \u2014 ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
17570
- const newLens = await client.request("POST", "/lenses", {
17571
- base: startingLensId,
18218
+ const newLens2 = await client.request("POST", "/lenses", {
18219
+ base: String(startingLensId),
17572
18220
  name
17573
18221
  });
17574
- targetLensId = newLens.id;
18222
+ targetLensId = newLens2.id;
17575
18223
  wasNew = true;
17576
- await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, merged);
17577
- await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
18224
+ await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, mergedBody);
18225
+ if (!isNamedEdit) {
18226
+ await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
18227
+ }
17578
18228
  } else if (isUserLevel) {
17579
18229
  try {
17580
- await client.requestVoid("POST", `/lenses/${startingLensId}/filter`, merged);
18230
+ await client.requestVoid("POST", `/lenses/${startingLensId}/filter`, mergedBody);
17581
18231
  } catch (err) {
17582
18232
  if (err?.code === "FORBIDDEN") {
17583
18233
  wasDraft = true;
17584
18234
  const draft = await client.request("POST", `/lenses/${startingLensId}/draft`);
17585
18235
  targetLensId = draft.id;
17586
- await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, merged);
17587
- await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
18236
+ await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, mergedBody);
18237
+ if (!isNamedEdit) {
18238
+ await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
18239
+ }
17588
18240
  } else {
17589
18241
  throw err;
17590
18242
  }
@@ -17596,7 +18248,7 @@ var adjustAudience = {
17596
18248
  const draft = await client.request("POST", `/lenses/${startingLensId}/draft`);
17597
18249
  targetLensId = draft.id;
17598
18250
  try {
17599
- await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, merged);
18251
+ await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, mergedBody);
17600
18252
  } catch (err) {
17601
18253
  ctx?.logger?.warn?.(`adjust_audience: filter on draft ${targetLensId} failed: ${err?.message}`);
17602
18254
  try {
@@ -17612,17 +18264,21 @@ var adjustAudience = {
17612
18264
  }
17613
18265
  throw err;
17614
18266
  }
17615
- await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
18267
+ if (!isNamedEdit) {
18268
+ await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
18269
+ }
17616
18270
  } else {
17617
18271
  try {
17618
- await client.requestVoid("POST", `/lenses/${startingLensId}/filter`, merged);
18272
+ await client.requestVoid("POST", `/lenses/${startingLensId}/filter`, mergedBody);
17619
18273
  } catch (err) {
17620
18274
  throw err;
17621
18275
  }
17622
18276
  }
17623
18277
  }
17624
- client.invalidateMe();
18278
+ if (!isNamedEdit)
18279
+ client.invalidateMe();
17625
18280
  client.invalidateDefaultLens();
18281
+ const namedEditForkedMessage = isNamedEdit && (wasNew || wasDraft) ? ` Note: "${lens.name}" can't be edited in place, so the change was applied to a ${wasDraft ? "personal draft" : "new copy"} (id ${targetLensId}); your active lens is unchanged.` : "";
17626
18282
  return {
17627
18283
  status: "applied",
17628
18284
  lens_used: {
@@ -17630,10 +18286,11 @@ var adjustAudience = {
17630
18286
  name: lens.name,
17631
18287
  was_draft: wasDraft,
17632
18288
  was_new: wasNew,
18289
+ active_lens_changed: !isNamedEdit && (wasNew || wasDraft),
17633
18290
  save_for_org: params.save_for_org === true && isAdmin && isOrgLevel
17634
18291
  },
17635
18292
  filter_applied: merged,
17636
- message: wasDraft ? "Applied to your personal draft of the org lens (your view only)." : wasNew ? `Created a new user-level lens "${lens.name}" with the filter (you can rename via leadbay_update_lens).` : "Applied directly to the lens.",
18293
+ message: (wasDraft ? "Applied to your personal draft of the org lens (your view only)." : wasNew ? `Created a new user-level lens "${lens.name}" with the filter (you can rename via leadbay_update_lens).` : "Applied directly to the lens.") + namedEditForkedMessage,
17637
18294
  _meta: { region: client.region }
17638
18295
  };
17639
18296
  }
@@ -18005,6 +18662,384 @@ var extendLens = {
18005
18662
  }
18006
18663
  };
18007
18664
 
18665
+ // ../core/dist/composite/my-lenses.js
18666
+ var sid = (v) => v == null ? null : String(v);
18667
+ async function listWithActive(client) {
18668
+ const lenses = await client.request("GET", "/lenses");
18669
+ const me = await client.resolveMe().catch(() => null);
18670
+ const activeFromMe = sid(me?.last_requested_lens);
18671
+ const active_lens_id = activeFromMe ?? sid(lenses.find((l) => l.is_last_active)?.id) ?? null;
18672
+ return {
18673
+ active_lens_id,
18674
+ lenses: lenses.map((l) => ({
18675
+ id: sid(l.id),
18676
+ name: l.name,
18677
+ description: l.description ?? null,
18678
+ is_active: sid(l.id) === active_lens_id,
18679
+ is_default: l.is_default === true || l.default === true
18680
+ }))
18681
+ };
18682
+ }
18683
+ var myLenses = {
18684
+ name: "leadbay_my_lenses",
18685
+ annotations: {
18686
+ title: "List, switch, edit, or delete your lenses",
18687
+ // No args → pure read. The delete mode issues DELETE /lenses/:id (an
18688
+ // irreversible side effect), so the tool is destructive — clients must
18689
+ // treat it as approval-required, not auto-run. The delete path is itself
18690
+ // confirm-gated (preview unless confirm:true). switch/edit are not
18691
+ // idempotent across modes either, so don't claim idempotency.
18692
+ readOnlyHint: false,
18693
+ destructiveHint: true,
18694
+ idempotentHint: false,
18695
+ openWorldHint: true
18696
+ },
18697
+ description: leadbay_my_lenses,
18698
+ inputSchema: {
18699
+ type: "object",
18700
+ properties: {
18701
+ switchToLensId: {
18702
+ type: ["string", "number"],
18703
+ description: "When set, switch the active lens to this id (must be one of the user's lenses), then return the refreshed list."
18704
+ },
18705
+ editLensId: {
18706
+ type: ["string", "number"],
18707
+ description: "When set, edit this lens's metadata \u2014 provide newName and/or newDescription. Must be one of the user's lenses."
18708
+ },
18709
+ newName: {
18710
+ type: "string",
18711
+ description: "New lens name (used with editLensId)."
18712
+ },
18713
+ newDescription: {
18714
+ type: "string",
18715
+ description: "New lens description (used with editLensId). Pass an empty string to clear it."
18716
+ },
18717
+ deleteLensId: {
18718
+ type: ["string", "number"],
18719
+ description: "When set, delete this lens. DESTRUCTIVE \u2014 returns a delete_preview unless confirm:true. Cannot delete the default lens."
18720
+ },
18721
+ confirm: {
18722
+ type: "boolean",
18723
+ description: "Required (=true) to actually delete. Without it, deleteLensId returns a preview to confirm with the user first."
18724
+ }
18725
+ },
18726
+ additionalProperties: false
18727
+ },
18728
+ outputSchema: {
18729
+ type: "object",
18730
+ properties: {
18731
+ status: {
18732
+ type: "string",
18733
+ description: "'listed', 'switched', 'already_active', 'edited', 'deleted', 'delete_preview' (confirm to proceed), 'cannot_delete_default', or 'not_found'."
18734
+ },
18735
+ switched: { type: "boolean", description: "True when this call changed the active lens." },
18736
+ edited: { type: "boolean", description: "True when this call renamed/re-described a lens." },
18737
+ deleted: { type: "boolean", description: "True when this call deleted a lens." },
18738
+ will_delete: {
18739
+ type: "object",
18740
+ description: "On 'delete_preview': the lens that WILL be deleted {id, name}. Nothing removed yet."
18741
+ },
18742
+ active_lens_id: { type: ["string", "null"] },
18743
+ lenses: {
18744
+ type: "array",
18745
+ description: "The user's lenses. Each: {id, name, description, is_active}.",
18746
+ items: { type: "object" }
18747
+ },
18748
+ message: { type: "string" }
18749
+ },
18750
+ required: ["status", "lenses", "active_lens_id"]
18751
+ },
18752
+ execute: async (client, params) => {
18753
+ if (params.deleteLensId != null) {
18754
+ const targetId = sid(params.deleteLensId);
18755
+ const before = await listWithActive(client);
18756
+ const target = before.lenses.find((l) => l.id === targetId);
18757
+ if (!target) {
18758
+ return {
18759
+ status: "not_found",
18760
+ switched: false,
18761
+ edited: false,
18762
+ deleted: false,
18763
+ active_lens_id: before.active_lens_id,
18764
+ lenses: before.lenses,
18765
+ message: `No lens with id ${targetId}. Pick one from the list.`
18766
+ };
18767
+ }
18768
+ if (target.is_default) {
18769
+ return {
18770
+ status: "cannot_delete_default",
18771
+ switched: false,
18772
+ edited: false,
18773
+ deleted: false,
18774
+ active_lens_id: before.active_lens_id,
18775
+ lenses: before.lenses,
18776
+ message: `"${target.name}" is the default lens and can't be deleted.`
18777
+ };
18778
+ }
18779
+ if (params.confirm !== true) {
18780
+ return {
18781
+ status: "delete_preview",
18782
+ switched: false,
18783
+ edited: false,
18784
+ deleted: false,
18785
+ active_lens_id: before.active_lens_id,
18786
+ lenses: before.lenses,
18787
+ will_delete: { id: target.id, name: target.name },
18788
+ message: `About to delete "${target.name}". This can't be undone. Confirm with the user, then re-call with confirm:true.`
18789
+ };
18790
+ }
18791
+ await client.requestVoid("DELETE", `/lenses/${targetId}`);
18792
+ client.invalidateMe();
18793
+ client.invalidateDefaultLens();
18794
+ const after = await listWithActive(client);
18795
+ return {
18796
+ status: "deleted",
18797
+ switched: false,
18798
+ edited: false,
18799
+ deleted: true,
18800
+ active_lens_id: after.active_lens_id,
18801
+ lenses: after.lenses,
18802
+ message: `Deleted "${target.name}".`
18803
+ };
18804
+ }
18805
+ if (params.editLensId != null) {
18806
+ const targetId = sid(params.editLensId);
18807
+ const before = await listWithActive(client);
18808
+ const target = before.lenses.find((l) => l.id === targetId);
18809
+ if (!target) {
18810
+ return {
18811
+ status: "not_found",
18812
+ switched: false,
18813
+ edited: false,
18814
+ active_lens_id: before.active_lens_id,
18815
+ lenses: before.lenses,
18816
+ message: `No lens with id ${targetId}. Pick one from the list.`
18817
+ };
18818
+ }
18819
+ const body = {};
18820
+ const newName = params.newName?.trim();
18821
+ if (newName)
18822
+ body.name = newName;
18823
+ if (params.newDescription !== void 0)
18824
+ body.description = params.newDescription;
18825
+ if (Object.keys(body).length === 0) {
18826
+ return {
18827
+ status: "not_found",
18828
+ switched: false,
18829
+ edited: false,
18830
+ active_lens_id: before.active_lens_id,
18831
+ lenses: before.lenses,
18832
+ message: `Nothing to change on "${target.name}" \u2014 provide newName and/or newDescription.`
18833
+ };
18834
+ }
18835
+ await client.requestVoid("POST", `/lenses/${targetId}`, body);
18836
+ client.invalidateDefaultLens();
18837
+ const changed = [
18838
+ body.name != null ? `renamed to "${body.name}"` : null,
18839
+ body.description !== void 0 ? "description updated" : null
18840
+ ].filter(Boolean).join(", ");
18841
+ const after = await listWithActive(client);
18842
+ return {
18843
+ status: "edited",
18844
+ switched: false,
18845
+ edited: true,
18846
+ active_lens_id: after.active_lens_id,
18847
+ lenses: after.lenses,
18848
+ message: `"${target.name}" \u2014 ${changed}.`
18849
+ };
18850
+ }
18851
+ if (params.switchToLensId != null) {
18852
+ const targetId = sid(params.switchToLensId);
18853
+ const before = await listWithActive(client);
18854
+ const target = before.lenses.find((l) => l.id === targetId);
18855
+ if (!target) {
18856
+ return {
18857
+ status: "not_found",
18858
+ switched: false,
18859
+ edited: false,
18860
+ active_lens_id: before.active_lens_id,
18861
+ lenses: before.lenses,
18862
+ message: `No lens with id ${targetId}. Pick an id from the list.`
18863
+ };
18864
+ }
18865
+ if (target.is_active) {
18866
+ return {
18867
+ status: "already_active",
18868
+ switched: false,
18869
+ edited: false,
18870
+ active_lens_id: before.active_lens_id,
18871
+ lenses: before.lenses,
18872
+ message: `"${target.name}" is already your active lens.`
18873
+ };
18874
+ }
18875
+ await client.requestVoid("POST", `/lenses/${targetId}/update_last_requested`);
18876
+ client.invalidateMe();
18877
+ client.invalidateDefaultLens();
18878
+ const after = await listWithActive(client);
18879
+ return {
18880
+ status: "switched",
18881
+ switched: true,
18882
+ edited: false,
18883
+ active_lens_id: after.active_lens_id,
18884
+ lenses: after.lenses,
18885
+ message: `Now showing "${target.name}".`
18886
+ };
18887
+ }
18888
+ const { lenses, active_lens_id } = await listWithActive(client);
18889
+ return { status: "listed", switched: false, edited: false, active_lens_id, lenses };
18890
+ }
18891
+ };
18892
+
18893
+ // ../core/dist/composite/new-lens.js
18894
+ var EMPTY_FILTER = {
18895
+ lens_filter: { items: [{ criteria: [] }] },
18896
+ locations: { results: [], parents: [] }
18897
+ };
18898
+ var newLens = {
18899
+ name: "leadbay_new_lens",
18900
+ annotations: {
18901
+ title: "Create a new named lens",
18902
+ readOnlyHint: false,
18903
+ destructiveHint: false,
18904
+ idempotentHint: false,
18905
+ // each call creates a distinct lens
18906
+ openWorldHint: true
18907
+ },
18908
+ description: leadbay_new_lens,
18909
+ inputSchema: {
18910
+ type: "object",
18911
+ properties: {
18912
+ name: { type: "string", description: "Display name for the new lens (required)." },
18913
+ sectors: {
18914
+ type: "array",
18915
+ items: { type: "string" },
18916
+ description: "Sectors to include \u2014 free text (auto-resolved) or ids."
18917
+ },
18918
+ exclude_sectors: {
18919
+ type: "array",
18920
+ items: { type: "string" },
18921
+ description: "Sectors to exclude \u2014 free text or ids."
18922
+ },
18923
+ sizes: {
18924
+ type: "array",
18925
+ items: {
18926
+ type: "object",
18927
+ properties: { min: { type: "number" }, max: { type: "number" } }
18928
+ },
18929
+ description: "Company size buckets, e.g. [{min:30,max:300}]."
18930
+ },
18931
+ base: {
18932
+ type: "number",
18933
+ description: "Lens id to clone from. Defaults to the active/default lens."
18934
+ },
18935
+ description: { type: "string", description: "Optional lens description." },
18936
+ confirm: {
18937
+ type: "boolean",
18938
+ description: "Safety gate. Defaults to false \u2192 the tool returns a PREVIEW and creates nothing. Show the preview to the user, get their explicit go-ahead, then re-call the SAME args with confirm:true to actually create the lens."
18939
+ }
18940
+ },
18941
+ required: ["name"],
18942
+ additionalProperties: false
18943
+ },
18944
+ outputSchema: {
18945
+ type: "object",
18946
+ description: "'preview' (default, NOTHING created \u2014 confirm with the user then re-call with confirm:true); 'created' on success; 'ambiguous_sectors' when free-text sectors didn't resolve (re-call with sector ids \u2014 the lens was NOT created).",
18947
+ properties: {
18948
+ status: { type: "string", description: "'preview', 'created', 'ambiguous_sectors', or 'orphan_created' (filter write failed + cleanup failed)." },
18949
+ will_create: {
18950
+ type: "object",
18951
+ description: "On 'preview': what WILL be created \u2014 {name, description, sectors, exclude_sectors, sizes}. Nothing has been written yet."
18952
+ },
18953
+ filter_preview: { type: "object", description: "On 'preview': the FilterPayload that would be applied." },
18954
+ lens: {
18955
+ type: "object",
18956
+ description: "On 'created': the created lens {id, name}."
18957
+ },
18958
+ sector_ambiguities: {
18959
+ type: "array",
18960
+ description: "On 'ambiguous_sectors': per text {sector_text, matches:[{id,name,score}]}.",
18961
+ items: { type: "object" }
18962
+ },
18963
+ filter_applied: { type: "object", description: "On 'created': the FilterPayload POSTed to the new lens." },
18964
+ message: { type: "string" },
18965
+ _meta: { type: "object" }
18966
+ },
18967
+ required: ["status"]
18968
+ },
18969
+ execute: async (client, params, ctx) => {
18970
+ const includeRes = await resolveSectors(client, params.sectors ?? [], ctx);
18971
+ const excludeRes = await resolveSectors(client, params.exclude_sectors ?? [], ctx);
18972
+ const ambiguities = [...includeRes.ambiguities, ...excludeRes.ambiguities];
18973
+ if (ambiguities.length > 0) {
18974
+ const noMatch = ambiguities.filter((a) => a.matches.length === 0);
18975
+ const multi = ambiguities.filter((a) => a.matches.length > 0);
18976
+ const parts = [];
18977
+ if (noMatch.length > 0) {
18978
+ parts.push(`Couldn't find a sector matching ${noMatch.map((a) => `"${a.sector_text}"`).join(", ")}. Pick a known sector and re-call (lens not yet created).`);
18979
+ }
18980
+ if (multi.length > 0) {
18981
+ parts.push(`${multi.map((a) => `"${a.sector_text}"`).join(", ")} matched multiple sectors. Pick from the matches and re-call with the sector id.`);
18982
+ }
18983
+ return {
18984
+ status: "ambiguous_sectors",
18985
+ sector_ambiguities: ambiguities,
18986
+ message: parts.join(" ")
18987
+ };
18988
+ }
18989
+ const merged = mergeFilter(EMPTY_FILTER, includeRes.resolved, excludeRes.resolved, params.sizes);
18990
+ if (params.confirm !== true) {
18991
+ return {
18992
+ status: "preview",
18993
+ will_create: {
18994
+ name: params.name,
18995
+ description: params.description ?? null,
18996
+ sectors: includeRes.resolved,
18997
+ exclude_sectors: excludeRes.resolved,
18998
+ sizes: merged.lens_filter.items[0].criteria.find((c) => c.type === "size") ?? null
18999
+ },
19000
+ filter_preview: merged,
19001
+ message: `About to create "${params.name}". Confirm with the user, then re-call with confirm:true.`,
19002
+ _meta: { region: client.region }
19003
+ };
19004
+ }
19005
+ const base = params.base ?? await client.resolveDefaultLens();
19006
+ const created = await client.request("POST", "/lenses", {
19007
+ base: String(base),
19008
+ name: params.name,
19009
+ description: params.description
19010
+ });
19011
+ const hasCriteria = merged.lens_filter.items[0].criteria.length > 0;
19012
+ if (hasCriteria) {
19013
+ try {
19014
+ await client.requestVoid("POST", `/lenses/${created.id}/filter`, filterWriteBody(merged));
19015
+ } catch (err) {
19016
+ ctx?.logger?.warn?.(`new_lens: filter write on new lens ${created.id} failed: ${err?.message} \u2014 rolling back`);
19017
+ try {
19018
+ await client.requestVoid("DELETE", `/lenses/${created.id}`);
19019
+ } catch {
19020
+ client.invalidateDefaultLens();
19021
+ return {
19022
+ status: "orphan_created",
19023
+ lens: { id: created.id, name: created.name },
19024
+ message: `Created "${created.name}" but applying its filter failed, and cleanup also failed. The lens exists with no criteria \u2014 delete it via leadbay_my_lenses(deleteLensId:"${created.id}", confirm:true) or set its audience with leadbay_adjust_audience.`,
19025
+ _meta: { region: client.region }
19026
+ };
19027
+ }
19028
+ client.invalidateDefaultLens();
19029
+ throw err;
19030
+ }
19031
+ }
19032
+ client.invalidateDefaultLens();
19033
+ return {
19034
+ status: "created",
19035
+ lens: { id: created.id, name: created.name },
19036
+ filter_applied: merged,
19037
+ message: `Created "${created.name}".`,
19038
+ _meta: { region: client.region }
19039
+ };
19040
+ }
19041
+ };
19042
+
18008
19043
  // ../core/dist/composite/answer-clarification.js
18009
19044
  var answerClarification = {
18010
19045
  name: "leadbay_answer_clarification",
@@ -18524,7 +19559,6 @@ var granularReadTools = [
18524
19559
  getQuota,
18525
19560
  getLensFilter,
18526
19561
  getLensScoring,
18527
- listSectors,
18528
19562
  listLocations,
18529
19563
  getUserPrompt,
18530
19564
  getClarification,
@@ -18595,6 +19629,11 @@ var compositeReadTools = [
18595
19629
  // it for discoverability; expose it always-on so agents can find custom fields
18596
19630
  // without needing LEADBAY_MCP_ADVANCED=1.
18597
19631
  listMappableFields,
19632
+ // listSectors is granular-shaped but ALWAYS exposed: it's the sector taxonomy
19633
+ // lookup the agent needs to STOP guessing sector names (and to feed
19634
+ // leadbay_new_lens / leadbay_adjust_audience). Without it the agent can only
19635
+ // probe sectors by trial-and-error or ask the user to read the web UI.
19636
+ listSectors,
18598
19637
  // Billing / top-up tools — granular-shaped but ALWAYS exposed because
18599
19638
  // they're the canonical recovery path from a QUOTA_EXCEEDED wall. If
18600
19639
  // they were gated behind LEADBAY_MCP_ADVANCED=1 the agent would
@@ -18609,7 +19648,13 @@ var compositeReadTools = [
18609
19648
  // didn't deliver"). Does not mutate Leadbay state; emits a PostHog
18610
19649
  // event only. Companion to leadbay_report_outreach (which DOES write
18611
19650
  // to the backend and stays gated behind LEADBAY_MCP_WRITE).
18612
- reportFriction
19651
+ reportFriction,
19652
+ // Notification ack — ALWAYS exposed even though it POSTs to /seen.
19653
+ // _meta.notifications surfaces terminal bulk-progress notifications on
19654
+ // every tool response regardless of write gating; without ack the agent
19655
+ // sees the same entries on every call forever. Pairing the surfacing
19656
+ // channel with the clearing tool is non-optional.
19657
+ acknowledgeNotification
18613
19658
  ];
18614
19659
  var compositeWriteTools = [
18615
19660
  bulkQualifyLeads,
@@ -18637,7 +19682,16 @@ var compositeWriteTools = [
18637
19682
  removeLeadsFromCampaign,
18638
19683
  // Lens extend — agent-driven on-demand fill (additive). Gated behind
18639
19684
  // LEADBAY_MCP_WRITE=1. Subject to per-org daily LENS_EXTRA_REFILL quota.
18640
- extendLens
19685
+ extendLens,
19686
+ // Lens list/switch — read-first (no args = pure list); a switchToLensId
19687
+ // changes the active lens. In compositeWriteTools because the switch path
19688
+ // mutates last_requested_lens, but it stays on the default surface
19689
+ // (write is on by default since 0.3.0).
19690
+ myLenses,
19691
+ // Lens creation — make a brand-new named lens with sectors/sizes in one
19692
+ // call. Default-surface so "create a lens called X for Y" works without
19693
+ // the advanced gate.
19694
+ newLens
18641
19695
  ];
18642
19696
  var compositeTools = [
18643
19697
  ...compositeReadTools,
@@ -19419,6 +20473,22 @@ function buildServer(client, opts = {}) {
19419
20473
  });
19420
20474
  }
19421
20475
  };
20476
+ const maybeAttachNotifications = (result) => {
20477
+ const inbox = opts.notificationsInbox;
20478
+ if (!inbox) return;
20479
+ if (result === null || typeof result !== "object" || Array.isArray(result)) {
20480
+ return;
20481
+ }
20482
+ const entries = inbox.list();
20483
+ if (entries.length === 0) return;
20484
+ const envelope = result;
20485
+ const target = envelope.__markdown_envelope === true && envelope.structured !== null && typeof envelope.structured === "object" && !Array.isArray(envelope.structured) ? envelope.structured : envelope;
20486
+ const existingMeta = target._meta && typeof target._meta === "object" && !Array.isArray(target._meta) ? target._meta : {};
20487
+ target._meta = {
20488
+ ...existingMeta,
20489
+ notifications: entries
20490
+ };
20491
+ };
19422
20492
  const isLeadbayBusinessError = (err) => err != null && typeof err === "object" && err.error === true && typeof err.code === "string";
19423
20493
  const buildBusinessCtx = (toolName, envelope, triggered_by) => {
19424
20494
  const meta = envelope._meta ?? {};
@@ -19538,11 +20608,13 @@ function buildServer(client, opts = {}) {
19538
20608
  const result = await tool.execute(client, args, {
19539
20609
  logger: opts.logger,
19540
20610
  bulkTracker: opts.bulkTracker,
20611
+ notificationsInbox: opts.notificationsInbox,
19541
20612
  signal: extra.signal,
19542
20613
  progress,
19543
20614
  elicit
19544
20615
  });
19545
20616
  maybeAttachUpdate(name, result);
20617
+ maybeAttachNotifications(result);
19546
20618
  if (result && typeof result === "object" && result.error === true) {
19547
20619
  const envText = formatErrorForLLM(result);
19548
20620
  const envDur = Date.now() - callStart;
@@ -19837,7 +20909,7 @@ function parseWriteEnv(env = process.env) {
19837
20909
  }
19838
20910
 
19839
20911
  // src/http-server.ts
19840
- var VERSION = true ? "0.17.2" : "0.0.0-dev";
20912
+ var VERSION = true ? "0.18.0" : "0.0.0-dev";
19841
20913
  var PORT = Number(process.env.PORT ?? 8080);
19842
20914
  var HOST = process.env.HOST ?? "0.0.0.0";
19843
20915
  var sseSessions = /* @__PURE__ */ new Map();