@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.
package/dist/bin.js CHANGED
@@ -553,6 +553,23 @@ var init_client = __esm({
553
553
  await this.resolveOrgId();
554
554
  await this.resolveTasteProfile();
555
555
  }
556
+ // ─── Notifications helpers ────────────────────────────────────────────
557
+ // Backend exposes `GET /notifications`, `POST /notifications/{id}/seen`,
558
+ // `POST /notifications/{id}/archive`, plus `GET /ws/ticket?v=1.0` to mint
559
+ // a one-shot WS URL. See backend/docs/adr/notifications.md for shape.
560
+ async listNotifications(args = {}) {
561
+ const params = new URLSearchParams();
562
+ params.set("archived", String(args.archived ?? false));
563
+ params.set("page", String(args.page ?? 0));
564
+ params.set("count", String(args.count ?? 50));
565
+ return this.request("GET", `/notifications?${params.toString()}`);
566
+ }
567
+ async acknowledgeNotification(notificationId, action = "seen") {
568
+ await this.requestVoid("POST", `/notifications/${notificationId}/${action}`);
569
+ }
570
+ async getWsTicket() {
571
+ return this.request("GET", "/auth/ws?v=1.0");
572
+ }
556
573
  makeError(code, message, hint, endpoint, retry_after, http_status) {
557
574
  const out = { error: true, code, message, hint };
558
575
  if (endpoint || this._region) {
@@ -5102,6 +5119,8 @@ var init_composite_file_names = __esm({
5102
5119
  "leadbay_import_leads",
5103
5120
  "leadbay_import_status",
5104
5121
  "leadbay_list_campaigns",
5122
+ "leadbay_my_lenses",
5123
+ "leadbay_new_lens",
5105
5124
  "leadbay_prepare_outreach",
5106
5125
  "leadbay_pull_followups",
5107
5126
  "leadbay_pull_leads",
@@ -5120,8 +5139,306 @@ var init_composite_file_names = __esm({
5120
5139
  }
5121
5140
  });
5122
5141
 
5142
+ // ../core/dist/notifications/revise-hint.js
5143
+ function reviseHintFor(kind) {
5144
+ switch (kind) {
5145
+ case "bulk_enrich":
5146
+ return HINT_BULK_ENRICH;
5147
+ case "bulk_qualify":
5148
+ return HINT_BULK_QUALIFY;
5149
+ case "import":
5150
+ return HINT_IMPORT;
5151
+ default:
5152
+ return HINT_OTHER;
5153
+ }
5154
+ }
5155
+ function inferKind(n) {
5156
+ if (n.links.some((l) => l.type === "bulk_enrichment"))
5157
+ return "bulk_enrich";
5158
+ if (n.file_import_id)
5159
+ return "import";
5160
+ if (n.bulk_progress)
5161
+ return "bulk_qualify";
5162
+ return "other";
5163
+ }
5164
+ function anchorIdFor(n, kind) {
5165
+ if (kind === "bulk_enrich") {
5166
+ const link = n.links.find((l) => l.type === "bulk_enrichment");
5167
+ return link ? String(link.id) : null;
5168
+ }
5169
+ if (kind === "import")
5170
+ return n.file_import_id;
5171
+ return null;
5172
+ }
5173
+ function toInboxEntry(n) {
5174
+ const kind = inferKind(n);
5175
+ return {
5176
+ notification_id: n.id,
5177
+ kind,
5178
+ anchor_id: anchorIdFor(n, kind),
5179
+ title: n.title,
5180
+ bulk_progress: n.bulk_progress,
5181
+ completed_at: n.updated_at,
5182
+ revise_hint: reviseHintFor(kind)
5183
+ };
5184
+ }
5185
+ var HINT_BULK_ENRICH, HINT_BULK_QUALIFY, HINT_IMPORT, HINT_OTHER;
5186
+ var init_revise_hint = __esm({
5187
+ "../core/dist/notifications/revise-hint.js"() {
5188
+ "use strict";
5189
+ HINT_BULK_ENRICH = "Contact enrichment just finished. Revise any prior output that named these leads' contacts (outreach drafts, contact lists, recommended-lead lists with contact_count, NEXT STEPS asking the user to wait for emails / phones). Re-fetch contacts via leadbay_get_contacts for the affected leads.";
5190
+ HINT_BULK_QUALIFY = "Lead qualification just finished. Revise any prior lead list / ranking / outreach shortlist that depended on ai_agent_lead_score for these leads \u2014 today's leads, top-of-inbox, followups maps, prepare-outreach shortlists. Re-pull qualification answers via leadbay_research_lead_by_id or re-rank via leadbay_pull_leads.";
5191
+ HINT_IMPORT = "CSV / CRM import just finished. Revise any prior output that referenced 'leads available' before the import landed \u2014 lead lists pulled from the affected lens, 'what's new today', followup planning. Re-pull the affected lens via leadbay_pull_leads / leadbay_pull_followups.";
5192
+ HINT_OTHER = "Background work just completed. If you referenced its subject in prior output, re-fetch the affected data and revise.";
5193
+ }
5194
+ });
5195
+
5196
+ // ../core/dist/notifications/inbox.js
5197
+ var DEFAULT_TTL_MS, NotificationsInbox;
5198
+ var init_inbox = __esm({
5199
+ "../core/dist/notifications/inbox.js"() {
5200
+ "use strict";
5201
+ init_revise_hint();
5202
+ DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
5203
+ NotificationsInbox = class {
5204
+ entries = /* @__PURE__ */ new Map();
5205
+ ttl_ms;
5206
+ now;
5207
+ constructor(opts = {}) {
5208
+ this.ttl_ms = opts.ttl_ms ?? DEFAULT_TTL_MS;
5209
+ this.now = opts.now ?? Date.now;
5210
+ }
5211
+ // Upsert by notification id. Latest write wins so duplicate arrivals
5212
+ // (WS event + REST catch-up landing the same row) collapse cleanly.
5213
+ record(n) {
5214
+ if (!n.bulk_progress)
5215
+ return;
5216
+ if (n.in_progress)
5217
+ return;
5218
+ const entry = toInboxEntry(n);
5219
+ this.entries.set(entry.notification_id, {
5220
+ entry,
5221
+ recordedAt: this.now()
5222
+ });
5223
+ }
5224
+ list() {
5225
+ this.expireStale();
5226
+ return [...this.entries.values()].map((e) => e.entry);
5227
+ }
5228
+ markSeen(notification_id) {
5229
+ this.entries.delete(notification_id);
5230
+ }
5231
+ size() {
5232
+ this.expireStale();
5233
+ return this.entries.size;
5234
+ }
5235
+ expireStale() {
5236
+ const cutoff = this.now() - this.ttl_ms;
5237
+ for (const [id, e] of this.entries) {
5238
+ if (e.recordedAt < cutoff)
5239
+ this.entries.delete(id);
5240
+ }
5241
+ }
5242
+ };
5243
+ }
5244
+ });
5245
+
5246
+ // ../core/dist/notifications/catch-up.js
5247
+ async function catchUpNotifications(client, inbox, opts = {}) {
5248
+ const count = opts.count ?? DEFAULT_COUNT;
5249
+ let added = 0;
5250
+ try {
5251
+ const page = await client.listNotifications({
5252
+ archived: false,
5253
+ page: 0,
5254
+ count
5255
+ });
5256
+ for (const n of page.items) {
5257
+ if (!n.bulk_progress)
5258
+ continue;
5259
+ if (n.in_progress)
5260
+ continue;
5261
+ if (n.first_seen_at)
5262
+ continue;
5263
+ const sizeBefore = inbox.size();
5264
+ inbox.record(n);
5265
+ if (inbox.size() > sizeBefore)
5266
+ added += 1;
5267
+ }
5268
+ opts.logger?.info?.(`notifications.catch_up scanned=${page.items.length} seeded=${added}`);
5269
+ } catch (err) {
5270
+ opts.logger?.warn?.(`notifications.catch_up failed: ${err?.message ?? err?.code ?? err}`);
5271
+ }
5272
+ return added;
5273
+ }
5274
+ var DEFAULT_COUNT;
5275
+ var init_catch_up = __esm({
5276
+ "../core/dist/notifications/catch-up.js"() {
5277
+ "use strict";
5278
+ DEFAULT_COUNT = 50;
5279
+ }
5280
+ });
5281
+
5282
+ // ../core/dist/notifications/ws-client.js
5283
+ var PING_INTERVAL_MS, RECONNECT_INITIAL_MS, RECONNECT_MAX_MS, NotificationsWsClient;
5284
+ var init_ws_client = __esm({
5285
+ "../core/dist/notifications/ws-client.js"() {
5286
+ "use strict";
5287
+ init_catch_up();
5288
+ PING_INTERVAL_MS = 3e4;
5289
+ RECONNECT_INITIAL_MS = 1e3;
5290
+ RECONNECT_MAX_MS = 3e4;
5291
+ NotificationsWsClient = class {
5292
+ client;
5293
+ inbox;
5294
+ logger;
5295
+ ws = null;
5296
+ pingTimer = null;
5297
+ reconnectTimer = null;
5298
+ reconnectDelay = RECONNECT_INITIAL_MS;
5299
+ stopped = false;
5300
+ constructor(opts) {
5301
+ this.client = opts.client;
5302
+ this.inbox = opts.inbox;
5303
+ this.logger = opts.logger;
5304
+ }
5305
+ async start() {
5306
+ this.stopped = false;
5307
+ await catchUpNotifications(this.client, this.inbox, { logger: this.logger });
5308
+ void this.connect();
5309
+ }
5310
+ stop() {
5311
+ this.stopped = true;
5312
+ if (this.pingTimer)
5313
+ clearInterval(this.pingTimer);
5314
+ if (this.reconnectTimer)
5315
+ clearTimeout(this.reconnectTimer);
5316
+ this.pingTimer = null;
5317
+ this.reconnectTimer = null;
5318
+ if (this.ws) {
5319
+ try {
5320
+ this.ws.close(1e3, "shutdown");
5321
+ } catch {
5322
+ }
5323
+ this.ws = null;
5324
+ }
5325
+ }
5326
+ async connect() {
5327
+ if (this.stopped)
5328
+ return;
5329
+ let url;
5330
+ try {
5331
+ const ticket = await this.client.getWsTicket();
5332
+ url = ticket.url;
5333
+ } catch (err) {
5334
+ this.logger?.warn?.(`notifications.ws ticket_fetch_failed: ${err?.message ?? err?.code ?? err}`);
5335
+ this.scheduleReconnect();
5336
+ return;
5337
+ }
5338
+ let ws;
5339
+ try {
5340
+ ws = new WebSocket(url);
5341
+ } catch (err) {
5342
+ this.logger?.warn?.(`notifications.ws construct_failed: ${err?.message ?? err}`);
5343
+ this.scheduleReconnect();
5344
+ return;
5345
+ }
5346
+ this.ws = ws;
5347
+ ws.addEventListener("open", () => {
5348
+ this.logger?.info?.("notifications.ws connected");
5349
+ this.reconnectDelay = RECONNECT_INITIAL_MS;
5350
+ void catchUpNotifications(this.client, this.inbox, {
5351
+ logger: this.logger
5352
+ });
5353
+ this.pingTimer = setInterval(() => this.sendPing(), PING_INTERVAL_MS);
5354
+ });
5355
+ ws.addEventListener("message", (ev) => {
5356
+ const text = typeof ev.data === "string" ? ev.data : String(ev.data ?? "");
5357
+ if (!text)
5358
+ return;
5359
+ let msg;
5360
+ try {
5361
+ msg = JSON.parse(text);
5362
+ } catch {
5363
+ this.logger?.warn?.("notifications.ws non_json_frame");
5364
+ return;
5365
+ }
5366
+ this.handleMessage(msg);
5367
+ });
5368
+ ws.addEventListener("error", (ev) => {
5369
+ this.logger?.warn?.(`notifications.ws error: ${ev?.message ?? "(no detail)"}`);
5370
+ });
5371
+ ws.addEventListener("close", (ev) => {
5372
+ this.logger?.info?.(`notifications.ws closed code=${ev.code} reason=${ev.reason || "(none)"}`);
5373
+ if (this.pingTimer)
5374
+ clearInterval(this.pingTimer);
5375
+ this.pingTimer = null;
5376
+ this.ws = null;
5377
+ this.scheduleReconnect();
5378
+ });
5379
+ }
5380
+ handleMessage(msg) {
5381
+ if (msg.type === "pong")
5382
+ return;
5383
+ if (msg.type === "ping") {
5384
+ this.sendRaw({ type: "pong" });
5385
+ return;
5386
+ }
5387
+ if (msg.type !== "notification")
5388
+ return;
5389
+ const { type: _t, ...rest } = msg;
5390
+ void _t;
5391
+ const n = rest;
5392
+ if (n.bulk_progress == null || n.in_progress) {
5393
+ return;
5394
+ }
5395
+ this.inbox.record(n);
5396
+ this.logger?.info?.(`notifications.ws terminal id=${n.id} kind=${n.file_import_id ? "import" : n.links.some((l) => l.type === "bulk_enrichment") ? "bulk_enrich" : "bulk_qualify"}`);
5397
+ }
5398
+ sendPing() {
5399
+ this.sendRaw({ type: "ping" });
5400
+ }
5401
+ sendRaw(obj) {
5402
+ if (!this.ws)
5403
+ return;
5404
+ if (this.ws.readyState !== WebSocket.OPEN)
5405
+ return;
5406
+ try {
5407
+ this.ws.send(JSON.stringify(obj));
5408
+ } catch (err) {
5409
+ this.logger?.warn?.(`notifications.ws send_failed: ${err?.message ?? err}`);
5410
+ }
5411
+ }
5412
+ scheduleReconnect() {
5413
+ if (this.stopped)
5414
+ return;
5415
+ if (this.reconnectTimer)
5416
+ return;
5417
+ const delay = this.reconnectDelay;
5418
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
5419
+ this.reconnectTimer = setTimeout(() => {
5420
+ this.reconnectTimer = null;
5421
+ void this.connect();
5422
+ }, delay);
5423
+ this.logger?.info?.(`notifications.ws reconnect_in_${delay}ms`);
5424
+ }
5425
+ };
5426
+ }
5427
+ });
5428
+
5429
+ // ../core/dist/notifications/index.js
5430
+ var init_notifications = __esm({
5431
+ "../core/dist/notifications/index.js"() {
5432
+ "use strict";
5433
+ init_inbox();
5434
+ init_ws_client();
5435
+ init_catch_up();
5436
+ init_revise_hint();
5437
+ }
5438
+ });
5439
+
5123
5440
  // ../core/dist/tool-descriptions.generated.js
5124
- var leadbay_account_status, leadbay_add_leads_to_campaign, leadbay_add_note, leadbay_adjust_audience, leadbay_agent_memory_capture, leadbay_agent_memory_recall, leadbay_agent_memory_review, leadbay_answer_clarification, leadbay_bulk_enrich_status, leadbay_bulk_qualify_leads, leadbay_campaign_call_sheet, leadbay_campaign_progression, leadbay_clear_selection, leadbay_clear_user_prompt, leadbay_create_campaign, leadbay_create_custom_field, leadbay_create_lens, leadbay_create_lens_draft, leadbay_create_topup_link, leadbay_deselect_leads, leadbay_discover_leads, leadbay_dislike_lead, leadbay_dismiss_clarification, leadbay_enrich_contacts, leadbay_enrich_titles, leadbay_extend_lens, leadbay_followups_map, leadbay_get_clarification, leadbay_get_contacts, leadbay_get_enrichment_job_titles, leadbay_get_epilogue_responses, leadbay_get_lead_activities, leadbay_get_lead_notes, leadbay_get_lead_profile, leadbay_get_lens_filter, leadbay_get_lens_scoring, leadbay_get_prospecting_actions, leadbay_get_quota, leadbay_get_selection_ids, leadbay_get_taste_profile, leadbay_get_user_prompt, leadbay_get_web_fetch, leadbay_import_and_qualify, leadbay_import_leads, leadbay_import_status, leadbay_launch_bulk_enrichment, leadbay_like_lead, leadbay_list_campaigns, leadbay_list_lenses, leadbay_list_locations, leadbay_list_mappable_fields, leadbay_list_sectors, leadbay_login, leadbay_open_billing_portal, leadbay_pick_clarification, leadbay_prepare_outreach, leadbay_preview_bulk_enrichment, leadbay_promote_lens, leadbay_pull_followups, leadbay_pull_leads, leadbay_qualify_lead, leadbay_qualify_status, leadbay_recall_ordered_titles, leadbay_refine_prompt, leadbay_remove_epilogue, leadbay_remove_leads_from_campaign, leadbay_remove_pushback, leadbay_report_friction, leadbay_report_outreach, leadbay_research_lead_by_id, leadbay_research_lead_by_name_fuzzy, leadbay_resolve_import_rows, leadbay_seed_candidates, leadbay_select_leads, leadbay_set_active_lens, leadbay_set_epilogue_status, leadbay_set_pushback, leadbay_set_user_prompt, leadbay_tour_plan, leadbay_update_lens, leadbay_update_lens_filter;
5441
+ var leadbay_account_status, leadbay_acknowledge_notification, leadbay_add_leads_to_campaign, leadbay_add_note, leadbay_adjust_audience, leadbay_agent_memory_capture, leadbay_agent_memory_recall, leadbay_agent_memory_review, leadbay_answer_clarification, leadbay_bulk_enrich_status, leadbay_bulk_qualify_leads, leadbay_campaign_call_sheet, leadbay_campaign_progression, leadbay_clear_selection, leadbay_clear_user_prompt, leadbay_create_campaign, leadbay_create_custom_field, leadbay_create_lens, leadbay_create_lens_draft, leadbay_create_topup_link, leadbay_deselect_leads, leadbay_discover_leads, leadbay_dislike_lead, leadbay_dismiss_clarification, leadbay_enrich_contacts, leadbay_enrich_titles, leadbay_extend_lens, leadbay_followups_map, leadbay_get_clarification, leadbay_get_contacts, leadbay_get_enrichment_job_titles, leadbay_get_epilogue_responses, leadbay_get_lead_activities, leadbay_get_lead_notes, leadbay_get_lead_profile, leadbay_get_lens_filter, leadbay_get_lens_scoring, leadbay_get_prospecting_actions, leadbay_get_quota, leadbay_get_selection_ids, leadbay_get_taste_profile, leadbay_get_user_prompt, leadbay_get_web_fetch, leadbay_import_and_qualify, leadbay_import_leads, leadbay_import_status, leadbay_launch_bulk_enrichment, leadbay_like_lead, leadbay_list_campaigns, leadbay_list_lenses, leadbay_list_locations, leadbay_list_mappable_fields, leadbay_list_sectors, leadbay_login, leadbay_my_lenses, leadbay_new_lens, leadbay_open_billing_portal, leadbay_pick_clarification, leadbay_prepare_outreach, leadbay_preview_bulk_enrichment, leadbay_promote_lens, leadbay_pull_followups, leadbay_pull_leads, leadbay_qualify_lead, leadbay_qualify_status, leadbay_recall_ordered_titles, leadbay_refine_prompt, leadbay_remove_epilogue, leadbay_remove_leads_from_campaign, leadbay_remove_pushback, leadbay_report_friction, leadbay_report_outreach, leadbay_research_lead_by_id, leadbay_research_lead_by_name_fuzzy, leadbay_resolve_import_rows, leadbay_seed_candidates, leadbay_select_leads, leadbay_set_active_lens, leadbay_set_epilogue_status, leadbay_set_pushback, leadbay_set_user_prompt, leadbay_tour_plan, leadbay_update_lens, leadbay_update_lens_filter;
5125
5442
  var init_tool_descriptions_generated = __esm({
5126
5443
  "../core/dist/tool-descriptions.generated.js"() {
5127
5444
  "use strict";
@@ -5160,9 +5477,52 @@ Show the user's account state \u2014 admin rights, language, last-active lens, c
5160
5477
 
5161
5478
  **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.**
5162
5479
 
5480
+ **\`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.
5481
+
5482
+ ## GATE \u2014 INSPECT \`_meta.notifications\` ON EVERY RESPONSE
5483
+
5484
+ 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:
5485
+
5486
+ | Kind | What just finished |
5487
+ |---|---|
5488
+ | \`bulk_enrich\` | A bulk contact-enrichment job (emails / phone numbers attached to leads' contacts). |
5489
+ | \`bulk_qualify\` | A bulk lead-qualification job (web-fetch + AI rescore, producing \`ai_agent_lead_score\` and qualification answers). |
5490
+ | \`import\` | A CSV / CRM file import (rows resolved to leads in the user's pipeline). |
5491
+
5492
+ **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.
5493
+
5494
+ | Kind | Outputs you've produced that may now be stale \u2014 refresh them |
5495
+ |---|---|
5496
+ | \`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. |
5497
+ | \`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\`. |
5498
+ | \`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\`. |
5499
+
5500
+ **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.
5501
+
5502
+ **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.
5503
+
5504
+ **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.
5505
+
5506
+ Also surfaced as a top-level \`notifications\` array on \`leadbay_account_status\` \u2014 same shape, same handling.
5507
+
5508
+
5163
5509
  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.
5164
5510
 
5165
5511
  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.
5512
+ `;
5513
+ 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.
5514
+
5515
+ **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.
5516
+
5517
+ 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.
5518
+
5519
+ 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.
5520
+
5521
+ 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.
5522
+
5523
+ WHEN NOT TO USE: before doing the revision work; for general "mark all read" sweeps (call once per notification id you've actually consumed).
5524
+
5525
+ 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\`.
5166
5526
  `;
5167
5527
  leadbay_add_leads_to_campaign = `## WHEN TO USE
5168
5528
 
@@ -5218,7 +5578,37 @@ WHEN NOT TO USE: to log an outreach action \u2014 use leadbay_report_outreach, w
5218
5578
 
5219
5579
  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\`.
5220
5580
  `;
5221
- 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).
5581
+ leadbay_adjust_audience = `## WHEN TO USE
5582
+
5583
+ 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>".
5584
+
5585
+ **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
5586
+
5587
+ 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\`.
5588
+
5589
+ 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.
5590
+
5591
+ Examples that SHOULD invoke this tool:
5592
+ - "Add fintech to my Joinery lens."
5593
+ - "Narrow my audience to manufacturing companies, 50\u2013500 people."
5594
+ - "Stop including retail in this lens."
5595
+
5596
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
5597
+ - "Create a lens called Joinery for fintech."
5598
+ - "Show me my lenses."
5599
+ - "Focus on hospitals that run their own IT."
5600
+
5601
+ ## RENDER (quick)
5602
+
5603
+ On \`applied\`: confirm the lens edited (name) + the sectors/sizes added as
5604
+ chips. On \`ambiguous_sectors\` / \`ambiguous_lens\` / \`lens_not_found\`: surface
5605
+ the candidates and ask the user to pick, then re-call with the id/exact name.
5606
+
5607
+ ---
5608
+
5609
+ 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).
5610
+
5611
+ **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.
5222
5612
 
5223
5613
  WHEN TO USE: when the user wants to see different kinds of leads (sector / size / etc.).
5224
5614
 
@@ -6431,6 +6821,258 @@ WHEN TO USE: at the start of a session if no token is preconfigured (cfg.token /
6431
6821
  WHEN NOT TO USE: if a token is already preconfigured \u2014 you'll just overwrite it. The user needs a Leadbay account first; they can register at https://wow.leadbay.ai/?register=true.
6432
6822
 
6433
6823
  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\`.
6824
+ `;
6825
+ leadbay_my_lenses = `## WHEN TO USE
6826
+
6827
+ 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".
6828
+
6829
+ **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
6830
+
6831
+ 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\`.
6832
+
6833
+ 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
6834
+
6835
+ Examples that SHOULD invoke this tool:
6836
+ - "Show me my lenses."
6837
+ - "Rename my Auto lens to Automotive and add a description."
6838
+ - "Delete my old Auto lens."
6839
+
6840
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
6841
+ - "Narrow the audience to fintech only."
6842
+ - "I want more leads on this lens."
6843
+ - "Show me today's leads."
6844
+
6845
+ ## RENDER (quick)
6846
+
6847
+ Small markdown table, active lens first: col 1 = \u2B50 prefix when active +
6848
+ lens name; col 2 = description (or \`\u2014\`). After a switch lead with
6849
+ "Now showing **<name>**."; after a rename lead with the rename confirmation.
6850
+ Full algorithm below.
6851
+
6852
+ ---
6853
+
6854
+ 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\`).
6855
+
6856
+ **Three modes, one tool:**
6857
+
6858
+ - **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.
6859
+ - **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.
6860
+ - **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.
6861
+ - **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.
6862
+
6863
+ **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.
6864
+
6865
+ **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.
6866
+
6867
+ 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".
6868
+
6869
+ 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\`).
6870
+
6871
+ 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\`.
6872
+
6873
+
6874
+ ## GATE \u2014 PREFER BUILT-IN HOST WIDGETS
6875
+
6876
+ 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.
6877
+
6878
+ **The Big Three** \u2014 when a tool result fits, route there:
6879
+
6880
+ | Host widget | Use when | Field map (from Leadbay payload) |
6881
+ |---|---|---|
6882
+ | \`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 |
6883
+ | \`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") |
6884
+ | \`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 |
6885
+
6886
+ 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.
6887
+
6888
+ **Rules:**
6889
+ - 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.
6890
+ - Pass identifiers (place_id, lead.id, contact_id) verbatim. Don't rewrite.
6891
+ - 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.
6892
+ - One short intro sentence in chat is enough \u2014 "Here are your 5 NYC follow-ups." Then route into the widget.
6893
+
6894
+
6895
+ ---
6896
+
6897
+ ## RENDERING \u2014 lenses table, active-first
6898
+
6899
+ Markdown table with TWO columns. Sort **active lens first**, then by \`name\`
6900
+ ascending. **No score bar** \u2014 the \`\u25B0\u2756\u25B1\` glyph identity belongs to lead
6901
+ discovery, not lenses.
6902
+
6903
+ **Column 1 \u2014 Lens**
6904
+ - Prefix \`\u2B50 \` when \`is_active\` is true; otherwise no prefix.
6905
+ - The lens name in **bold**. (Lenses have no public URL \u2014 do not fabricate a link.)
6906
+
6907
+ **Column 2 \u2014 Description**
6908
+ - \`description\` verbatim, clipped to \u2264 18 words.
6909
+ - When null/empty: render \`\u2014\`.
6910
+
6911
+ **After a \`switched: true\` response**, open with a single confirmation line
6912
+ ABOVE the table: \`Now showing **<name>**.\` For \`status: "not_found"\`, lead with
6913
+ the \`message\` (the bad id) and render the list so the user can pick a real one.
6914
+
6915
+ **Empty list** (\`lenses: []\`): render \`*You don't have any lenses yet.*\` \u2014 do not
6916
+ render an empty table.
6917
+
6918
+ **Legend:** \u2B50 active lens.
6919
+
6920
+
6921
+ ## NEXT STEPS \u2014 after \`leadbay_my_lenses\`
6922
+
6923
+ **RENDER NEXT STEPS via \`ask_user_input_v0\` when the host exposes it.**
6924
+
6925
+ 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:
6926
+
6927
+ \`\`\`
6928
+ ask_user_input_v0({
6929
+ questions: [{
6930
+ question: "What next?",
6931
+ type: "single_select",
6932
+ options: [
6933
+ "<Suggest column from row 1>",
6934
+ "<Suggest column from row 2>",
6935
+ "<Suggest column from row 3>"
6936
+ ]
6937
+ }]
6938
+ })
6939
+ \`\`\`
6940
+
6941
+ 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.
6942
+
6943
+ **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.
6944
+
6945
+ ---
6946
+
6947
+
6948
+
6949
+ Pick the 2\u20133 rows that fit what the user is likely to want next. When the user
6950
+ named no target but wants to switch, offer the lenses themselves as the
6951
+ quick-select options (each option = a lens name \u2192 \`leadbay_my_lenses(switchToLensId=<id>)\`).
6952
+
6953
+ | Observation | Suggest | Calls |
6954
+ |--------------------------------------|------------------------------------------|------------------------------------------------------|
6955
+ | User wants a different lens | "Switch to <lens name>" | \`leadbay_my_lenses(switchToLensId=<id>)\` |
6956
+ | User wants to rename / describe a lens| "Rename or describe <lens>" | \`leadbay_my_lenses(editLensId=<id>, newName?=<X>, newDescription?=<Y>)\` |
6957
+ | User wants to delete a lens | "Delete <lens>" | \`leadbay_my_lenses(deleteLensId=<id>)\` \u2192 confirm \u2192 \`confirm=true\` |
6958
+ | \`delete_preview\` (not yet deleted) | "Yes, delete it" | \`leadbay_my_lenses(deleteLensId=<id>, confirm=true)\` |
6959
+ | User wants leads on the active lens | "Pull today's leads" | \`leadbay_pull_leads()\` |
6960
+ | User wants to change the audience | "Adjust this lens's audience" | \`leadbay_adjust_audience(...)\` |
6961
+ | User wants more of the same | "Get a bigger batch on this lens" | \`leadbay_extend_lens(...)\` |
6962
+
6963
+ If nothing fits, default to "pull today's leads on the active lens" \u2014 never
6964
+ invent a tool that doesn't exist.
6965
+ `;
6966
+ leadbay_new_lens = `## WHEN TO USE
6967
+
6968
+ 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>".
6969
+
6970
+ **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
6971
+
6972
+ 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\`.
6973
+
6974
+ 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).
6975
+
6976
+ Examples that SHOULD invoke this tool:
6977
+ - "Create a lens called Joinery for the fintech sector."
6978
+ - "Make me a new audience for healthcare companies, 30\u2013300 people."
6979
+ - "Set up a new lens named Nordics SaaS."
6980
+
6981
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
6982
+ - "Add fintech to my Joinery lens."
6983
+ - "Show me my lenses."
6984
+ - "I want more leads on this lens."
6985
+
6986
+ ## RENDER (quick)
6987
+
6988
+ On \`preview\` (default \u2014 NOTHING created yet): show the lens that WILL be
6989
+ created (name + resolved sectors/sizes as chips) and ASK the user to confirm
6990
+ via ask_user_input_v0 ("Create this lens?" / "Change something"). Only on
6991
+ "yes" re-call with confirm:true. On \`created\`: confirm "Created **<name>**."
6992
+ On \`ambiguous_sectors\`: surface the candidate sectors to pick from.
6993
+
6994
+ ---
6995
+
6996
+ 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.
6997
+
6998
+ **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.)
6999
+
7000
+ **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\`.
7001
+
7002
+ **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.
7003
+
7004
+ 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>".
7005
+
7006
+ 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\`).
7007
+
7008
+ 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\`.
7009
+
7010
+
7011
+ ## GATE \u2014 PREFER BUILT-IN HOST WIDGETS
7012
+
7013
+ 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.
7014
+
7015
+ **The Big Three** \u2014 when a tool result fits, route there:
7016
+
7017
+ | Host widget | Use when | Field map (from Leadbay payload) |
7018
+ |---|---|---|
7019
+ | \`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 |
7020
+ | \`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") |
7021
+ | \`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 |
7022
+
7023
+ 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.
7024
+
7025
+ **Rules:**
7026
+ - 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.
7027
+ - Pass identifiers (place_id, lead.id, contact_id) verbatim. Don't rewrite.
7028
+ - 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.
7029
+ - One short intro sentence in chat is enough \u2014 "Here are your 5 NYC follow-ups." Then route into the widget.
7030
+
7031
+
7032
+ ---
7033
+
7034
+ ## NEXT STEPS \u2014 after \`leadbay_new_lens\`
7035
+
7036
+ **RENDER NEXT STEPS via \`ask_user_input_v0\` when the host exposes it.**
7037
+
7038
+ 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:
7039
+
7040
+ \`\`\`
7041
+ ask_user_input_v0({
7042
+ questions: [{
7043
+ question: "What next?",
7044
+ type: "single_select",
7045
+ options: [
7046
+ "<Suggest column from row 1>",
7047
+ "<Suggest column from row 2>",
7048
+ "<Suggest column from row 3>"
7049
+ ]
7050
+ }]
7051
+ })
7052
+ \`\`\`
7053
+
7054
+ 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.
7055
+
7056
+ **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.
7057
+
7058
+ ---
7059
+
7060
+
7061
+
7062
+ Pick the rows that fit. On \`created\`, the switch + pull rows are the natural
7063
+ follow-ups. On \`ambiguous_sectors\`, the only move is to pick a sector and re-call.
7064
+
7065
+ | Observation | Suggest | Calls |
7066
+ |-----------------------------------|------------------------------------------|--------------------------------------------------------|
7067
+ | \`preview\` (not yet created) | "Yes, create this lens" | \`leadbay_new_lens(...same args..., confirm=true)\` |
7068
+ | \`preview\` (not yet created) | "Change the sectors/size first" | (re-ask the user, then \`leadbay_new_lens\` with new args) |
7069
+ | Lens created | "Switch to it and pull leads" | \`leadbay_my_lenses(switchToLensId=<new id>)\` then \`leadbay_pull_leads()\` |
7070
+ | Lens created | "Refine the audience further" | \`leadbay_adjust_audience(lensName=<new name>, ...)\` |
7071
+ | Lens created | "Leave it; keep my current lens active" | (no call) |
7072
+ | \`ambiguous_sectors\` | "Pick the right sector and create" | \`leadbay_new_lens(name=..., sectors=[<chosen id>])\` |
7073
+
7074
+ If nothing fits, default to "switch to the new lens and pull leads" \u2014 never
7075
+ invent a tool that doesn't exist.
6434
7076
  `;
6435
7077
  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.
6436
7078
 
@@ -7064,7 +7706,33 @@ WHEN TO USE: before leadbay_enrich_titles, to plan which titles to order.
7064
7706
 
7065
7707
  WHEN NOT TO USE: when you already know the exact titles you want to enrich.
7066
7708
  `;
7067
- 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).
7709
+ leadbay_refine_prompt = `## WHEN TO USE
7710
+
7711
+ 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".
7712
+
7713
+ **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
7714
+
7715
+ 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\`.
7716
+
7717
+ 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.
7718
+
7719
+ Examples that SHOULD invoke this tool:
7720
+ - "Focus on hospitals that run their own IT in-house."
7721
+ - "Prioritize companies that have recently expanded headcount."
7722
+
7723
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
7724
+ - "Create a lens specialized in automobile."
7725
+ - "Add fintech to my Joinery lens."
7726
+ - "Show me my lenses."
7727
+
7728
+ ## RENDER (quick)
7729
+
7730
+ On success: confirm the refinement applied to the active lens. If a
7731
+ clarification was raised, surface its question (route via ask_user_input_v0).
7732
+
7733
+ ---
7734
+
7735
+ 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).
7068
7736
 
7069
7737
  WHEN TO USE: when audience filters (leadbay_adjust_audience) aren't enough.
7070
7738
 
@@ -9889,9 +10557,23 @@ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseB
9889
10557
  await pollPreprocess(client, importId, phaseBudget, ctx, signal);
9890
10558
  ctx?.logger?.info?.(`import-leads: preprocess done for importId=${importId}`);
9891
10559
  if (dryRun) {
9892
- return { importId, records: [] };
10560
+ return { importId, records: [], notification_id: null };
10561
+ }
10562
+ let updateMappingsResp = null;
10563
+ try {
10564
+ updateMappingsResp = await client.request("POST", `/imports/${importId}/update_mappings`, mappings);
10565
+ } catch (err) {
10566
+ if (err?.code === "API_ERROR" || err?.code === "NOT_FOUND") {
10567
+ ctx?.logger?.warn?.(`import-leads: update_mappings raw error (${err?.code}); retrying void`);
10568
+ await client.requestVoid("POST", `/imports/${importId}/update_mappings`, mappings);
10569
+ } else {
10570
+ throw err;
10571
+ }
10572
+ }
10573
+ const importNotificationId = updateMappingsResp?.notification_id ?? null;
10574
+ if (importNotificationId) {
10575
+ ctx?.logger?.info?.(`import-leads: notification_id=${importNotificationId} importId=${importId}`);
9893
10576
  }
9894
- await client.requestVoid("POST", `/imports/${importId}/update_mappings`, mappings);
9895
10577
  ctx?.logger?.info?.(`import-leads: mappings committed for importId=${importId}`);
9896
10578
  const phaseBudget2 = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
9897
10579
  await pollProcess(client, importId, phaseBudget2, ctx, signal);
@@ -9899,7 +10581,7 @@ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseB
9899
10581
  const phaseBudget3 = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
9900
10582
  const records = await pollRecordsToTerminal(client, importId, phaseBudget3, chunk.length, ctx, signal);
9901
10583
  ctx?.logger?.info?.(`import-leads: ${records.length} records terminal for importId=${importId}`);
9902
- return { importId, records };
10584
+ return { importId, records, notification_id: importNotificationId };
9903
10585
  }
9904
10586
  function reconcileOneChunk(prep, chunk, matched, notImported) {
9905
10587
  const seenInputIndex = /* @__PURE__ */ new Set();
@@ -9955,7 +10637,7 @@ function reconcileOneChunk(prep, chunk, matched, notImported) {
9955
10637
  }
9956
10638
  }
9957
10639
  }
9958
- function buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled) {
10640
+ function buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled, notificationIds) {
9959
10641
  const leads = [];
9960
10642
  const not_imported = [];
9961
10643
  if (dryRun) {
@@ -10020,6 +10702,7 @@ function buildImportLeadsResult(client, prep, importIds, matched, notImported, d
10020
10702
  leads,
10021
10703
  not_imported,
10022
10704
  importIds,
10705
+ notification_ids: notificationIds,
10023
10706
  region: client.region,
10024
10707
  cancelled: cancelled || void 0,
10025
10708
  dry_run: dryRun || void 0,
@@ -10045,17 +10728,21 @@ async function runImportInBackground(client, prep, uploadedChunks, opts, ctx, ha
10045
10728
  void (async () => {
10046
10729
  const bgCtx = { logger: ctx.logger, bulkTracker: tracker };
10047
10730
  const importIds = uploadedChunks.map((chunk) => chunk.importId);
10731
+ const notificationIds = [];
10048
10732
  const matched = /* @__PURE__ */ new Map();
10049
10733
  const notImported = /* @__PURE__ */ new Map();
10050
10734
  try {
10051
10735
  const totalDeadline = Date.now() + opts.totalBudget;
10052
10736
  for (const upload of uploadedChunks) {
10053
10737
  const out = await completeUploadedChunk(client, upload, prep.mappings, opts.dryRun, opts.perPhaseBudget, totalDeadline, bgCtx, void 0);
10738
+ if (out.notification_id && !notificationIds.includes(out.notification_id)) {
10739
+ notificationIds.push(out.notification_id);
10740
+ }
10054
10741
  if (!opts.dryRun) {
10055
10742
  reconcileOneChunk(prep, out, matched, notImported);
10056
10743
  }
10057
10744
  }
10058
- const result = buildImportLeadsResult(client, prep, importIds, matched, notImported, opts.dryRun, false);
10745
+ const result = buildImportLeadsResult(client, prep, importIds, matched, notImported, opts.dryRun, false, notificationIds);
10059
10746
  await tracker.markImportComplete(handleId, {
10060
10747
  leads: result.leads,
10061
10748
  not_imported: result.not_imported,
@@ -10288,6 +10975,7 @@ var init_import_leads = __esm({
10288
10975
  leads: [],
10289
10976
  not_imported,
10290
10977
  importIds: [],
10978
+ notification_ids: [],
10291
10979
  region: client.region,
10292
10980
  dry_run: dryRun || void 0,
10293
10981
  _meta: client.lastMeta ?? {
@@ -10342,6 +11030,10 @@ var init_import_leads = __esm({
10342
11030
  status: "running",
10343
11031
  handle_id: reservation.record.bulk_id,
10344
11032
  importIds: importIds2,
11033
+ // Notifications fire from update_mappings, which the background
11034
+ // task hasn't called yet at this point. They surface via the WS
11035
+ // listener / catch-up REST on subsequent agent turns.
11036
+ notification_ids: [],
10345
11037
  progress: {
10346
11038
  phase: reservation.record.status === "complete" ? "complete" : importIds2.length > 0 ? "preprocess" : "queued",
10347
11039
  records_processed: reservation.record.status === "complete" ? reservation.record.records_total : 0,
@@ -10362,6 +11054,7 @@ var init_import_leads = __esm({
10362
11054
  }
10363
11055
  ctx?.logger?.info?.(`import-leads(${prep.mode}): ${prep.validInputs.length} rows \u2192 ${chunks.length} chunk(s); dry_run=${dryRun}, totalBudgetMs=${totalBudget}`);
10364
11056
  const importIds = [];
11057
+ const notificationIds = [];
10365
11058
  const matched = /* @__PURE__ */ new Map();
10366
11059
  const notImported = /* @__PURE__ */ new Map();
10367
11060
  let cancelled = false;
@@ -10373,6 +11066,9 @@ var init_import_leads = __esm({
10373
11066
  for (let i = 0; i < chunks.length; i++) {
10374
11067
  const chunk = chunks[i];
10375
11068
  const out = await runOneChunk(client, chunk, i, chunks.length, prep.header, prep.mappings, dryRun, perPhaseBudget, totalDeadline, ctx, signal, recordImportId);
11069
+ if (out.notification_id && !notificationIds.includes(out.notification_id)) {
11070
+ notificationIds.push(out.notification_id);
11071
+ }
10376
11072
  if (!dryRun) {
10377
11073
  reconcileOneChunk(prep, out, matched, notImported);
10378
11074
  }
@@ -10393,7 +11089,7 @@ var init_import_leads = __esm({
10393
11089
  throw err;
10394
11090
  }
10395
11091
  }
10396
- return buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled);
11092
+ return buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled, notificationIds);
10397
11093
  }
10398
11094
  };
10399
11095
  }
@@ -11101,6 +11797,70 @@ var init_agent_memory_review = __esm({
11101
11797
  }
11102
11798
  });
11103
11799
 
11800
+ // ../core/dist/tools/acknowledge-notification.js
11801
+ var UUID_RE, acknowledgeNotification;
11802
+ var init_acknowledge_notification = __esm({
11803
+ "../core/dist/tools/acknowledge-notification.js"() {
11804
+ "use strict";
11805
+ init_tool_descriptions_generated();
11806
+ UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11807
+ acknowledgeNotification = {
11808
+ name: "leadbay_acknowledge_notification",
11809
+ annotations: {
11810
+ title: "Acknowledge a Leadbay notification",
11811
+ readOnlyHint: false,
11812
+ destructiveHint: false,
11813
+ idempotentHint: true,
11814
+ openWorldHint: true
11815
+ },
11816
+ description: leadbay_acknowledge_notification,
11817
+ write: true,
11818
+ inputSchema: {
11819
+ type: "object",
11820
+ properties: {
11821
+ notification_id: {
11822
+ type: "string",
11823
+ description: "UUID of the notification to acknowledge. Use the notification_id from `_meta.notifications[]` or `account_status.notifications[]`."
11824
+ },
11825
+ archive: {
11826
+ type: "boolean",
11827
+ description: "If true, archive the notification (won't appear in `archived=false` listings). If false / omitted, mark seen (resets firstSeenAt)."
11828
+ }
11829
+ },
11830
+ required: ["notification_id"],
11831
+ additionalProperties: false
11832
+ },
11833
+ outputSchema: {
11834
+ type: "object",
11835
+ properties: {
11836
+ acknowledged: { type: "boolean" },
11837
+ notification_id: { type: "string" },
11838
+ action: { type: "string", enum: ["seen", "archive"] }
11839
+ },
11840
+ required: ["acknowledged", "notification_id", "action"]
11841
+ },
11842
+ execute: async (client, params, ctx) => {
11843
+ if (!UUID_RE.test(params.notification_id)) {
11844
+ return {
11845
+ error: true,
11846
+ code: "BAD_INPUT",
11847
+ message: "notification_id must be a UUID",
11848
+ hint: "Pass the notification_id verbatim from _meta.notifications[].notification_id or account_status.notifications[].notification_id."
11849
+ };
11850
+ }
11851
+ const action = params.archive ? "archive" : "seen";
11852
+ await client.acknowledgeNotification(params.notification_id, action);
11853
+ ctx?.notificationsInbox?.markSeen(params.notification_id);
11854
+ return {
11855
+ acknowledged: true,
11856
+ notification_id: params.notification_id,
11857
+ action
11858
+ };
11859
+ }
11860
+ };
11861
+ }
11862
+ });
11863
+
11104
11864
  // ../core/dist/tools/select-leads.js
11105
11865
  var selectLeads;
11106
11866
  var init_select_leads = __esm({
@@ -11296,7 +12056,7 @@ var init_create_lens = __esm({
11296
12056
  },
11297
12057
  execute: async (client, params) => {
11298
12058
  const lens = await client.request("POST", "/lenses", {
11299
- base: params.base,
12059
+ base: String(params.base),
11300
12060
  name: params.name,
11301
12061
  description: params.description
11302
12062
  });
@@ -14450,6 +15210,11 @@ var init_account_status = __esm({
14450
15210
  type: ["object", "null"],
14451
15211
  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."
14452
15212
  },
15213
+ notifications: {
15214
+ type: "array",
15215
+ 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.",
15216
+ items: { type: "object" }
15217
+ },
14453
15218
  _meta: {
14454
15219
  type: "object",
14455
15220
  properties: {
@@ -14509,6 +15274,13 @@ var init_account_status = __esm({
14509
15274
  // on /me are intentionally NOT surfaced — they're defunct (see
14510
15275
  // SHAPE-DRIFT.md probe round 4).
14511
15276
  quota,
15277
+ // Inbox of terminal bulk-progress notifications. Same shape the MCP
15278
+ // server attaches to `_meta.notifications` on every tool response —
15279
+ // duplicated here as a top-level field so the agent's daily-rhythm
15280
+ // check-in (this composite) sees them without having to read _meta.
15281
+ // Empty array when the WS listener isn't wired (OpenClaw, tests) OR
15282
+ // when nothing has completed since the last ack.
15283
+ notifications: ctx?.notificationsInbox?.list() ?? [],
14512
15284
  _meta: {
14513
15285
  region: client.region
14514
15286
  }
@@ -14516,16 +15288,43 @@ var init_account_status = __esm({
14516
15288
  }
14517
15289
  };
14518
15290
  }
14519
- });
14520
-
14521
- // ../core/dist/composite/bulk-qualify-leads.js
14522
- var PAGE_SIZE, DEFAULT_COUNT, MAX_COUNT, DEFAULT_PER_LEAD_BUDGET_MS, DEFAULT_TOTAL_BUDGET_MS2, bulkQualifyLeads;
15291
+ });
15292
+
15293
+ // ../core/dist/composite/bulk-qualify-leads.js
15294
+ async function launchBulkQualify(client, leadIds, ctx) {
15295
+ await client.acquireSelectionLock();
15296
+ try {
15297
+ try {
15298
+ const qs = leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
15299
+ await client.requestVoid("POST", `/leads/selection/select?${qs}`);
15300
+ try {
15301
+ const resp = await client.request("POST", "/leads/selection/web_fetch?force_fetch=false", {});
15302
+ return { resp, quotaExceeded: false };
15303
+ } catch (err) {
15304
+ if (err?.code === "QUOTA_EXCEEDED") {
15305
+ ctx?.logger?.warn?.("bulk_qualify_leads: 429 on bulk /leads/selection/web_fetch \u2014 no leads queued");
15306
+ return { resp: null, quotaExceeded: true };
15307
+ }
15308
+ throw err;
15309
+ }
15310
+ } finally {
15311
+ try {
15312
+ await client.requestVoid("POST", "/leads/selection/clear");
15313
+ } catch (e) {
15314
+ ctx?.logger?.warn?.(`bulk_qualify_leads: selection.clear failed: ${e?.message ?? e?.code}`);
15315
+ }
15316
+ }
15317
+ } finally {
15318
+ client.releaseSelectionLock();
15319
+ }
15320
+ }
15321
+ var PAGE_SIZE, DEFAULT_COUNT2, MAX_COUNT, DEFAULT_PER_LEAD_BUDGET_MS, DEFAULT_TOTAL_BUDGET_MS2, bulkQualifyLeads;
14523
15322
  var init_bulk_qualify_leads = __esm({
14524
15323
  "../core/dist/composite/bulk-qualify-leads.js"() {
14525
15324
  "use strict";
14526
15325
  init_tool_descriptions_generated();
14527
15326
  PAGE_SIZE = 50;
14528
- DEFAULT_COUNT = 10;
15327
+ DEFAULT_COUNT2 = 10;
14529
15328
  MAX_COUNT = 25;
14530
15329
  DEFAULT_PER_LEAD_BUDGET_MS = 9e4;
14531
15330
  DEFAULT_TOTAL_BUDGET_MS2 = 5 * 6e4;
@@ -14546,7 +15345,7 @@ var init_bulk_qualify_leads = __esm({
14546
15345
  properties: {
14547
15346
  count: {
14548
15347
  type: "number",
14549
- description: `How many fresh leads to qualify (default ${DEFAULT_COUNT}, max ${MAX_COUNT})`
15348
+ description: `How many fresh leads to qualify (default ${DEFAULT_COUNT2}, max ${MAX_COUNT})`
14550
15349
  },
14551
15350
  leadIds: {
14552
15351
  type: "array",
@@ -14637,7 +15436,7 @@ var init_bulk_qualify_leads = __esm({
14637
15436
  ]
14638
15437
  },
14639
15438
  execute: async (client, params, ctx) => {
14640
- const wantCount = Math.min(params.count ?? DEFAULT_COUNT, MAX_COUNT);
15439
+ const wantCount = Math.min(params.count ?? DEFAULT_COUNT2, MAX_COUNT);
14641
15440
  const perLeadBudget = params.per_lead_budget_ms ?? DEFAULT_PER_LEAD_BUDGET_MS;
14642
15441
  const totalBudget = params.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS2;
14643
15442
  const totalDeadline = Date.now() + totalBudget;
@@ -14696,69 +15495,50 @@ var init_bulk_qualify_leads = __esm({
14696
15495
  per_lead_budget_ms: perLeadBudget,
14697
15496
  total_budget_ms: totalBudget
14698
15497
  });
14699
- const launched2 = [];
14700
- const failed2 = [];
15498
+ let launchedCount = 0;
15499
+ let notificationId = null;
14701
15500
  let quotaExceeded2 = false;
15501
+ let failed2 = [];
14702
15502
  if (!reservation.reused) {
14703
- for (const leadId of candidates) {
14704
- if (quotaExceeded2)
14705
- break;
14706
- try {
14707
- await client.requestVoid("POST", `/leads/${leadId}/web_fetch?force_fetch=false`);
14708
- launched2.push(leadId);
14709
- } catch (err) {
14710
- if (err?.code === "QUOTA_EXCEEDED") {
14711
- quotaExceeded2 = true;
14712
- } else if (err?.code === "NOT_FOUND") {
14713
- failed2.push({ lead_id: leadId, error: "lead not found" });
14714
- } else {
14715
- failed2.push({
14716
- lead_id: leadId,
14717
- error: err?.message ?? err?.code ?? "unknown"
14718
- });
14719
- }
14720
- }
14721
- }
14722
- if (failed2.length === candidates.length || launched2.length > 0 || quotaExceeded2) {
14723
- await ctx.bulkTracker.markLaunched(reservation.record.bulk_id);
15503
+ const launch = await launchBulkQualify(client, candidates, ctx);
15504
+ quotaExceeded2 = launch.quotaExceeded;
15505
+ notificationId = launch.resp?.notification_id ?? null;
15506
+ const queuedIds = launch.resp?.queued_ids ?? [];
15507
+ const skippedIds = launch.resp?.skipped_ids ?? [];
15508
+ launchedCount = queuedIds.length;
15509
+ const seen = /* @__PURE__ */ new Set([...queuedIds, ...skippedIds]);
15510
+ failed2 = candidates.filter((id) => !seen.has(id)).map((id) => ({ lead_id: id, error: "not_queued" }));
15511
+ if (queuedIds.length > 0 || quotaExceeded2 || skippedIds.length > 0 || failed2.length === candidates.length) {
15512
+ await ctx.bulkTracker.markLaunched(reservation.record.bulk_id, notificationId);
14724
15513
  }
15514
+ } else {
15515
+ notificationId = reservation.record.notification_id ?? null;
15516
+ launchedCount = reservation.record.lead_ids.length;
14725
15517
  }
14726
15518
  const out = {
14727
15519
  status: "running",
14728
15520
  handle_id: reservation.record.bulk_id,
14729
15521
  qualify_id: reservation.record.bulk_id,
14730
15522
  lead_ids: candidates,
14731
- launched_count: reservation.reused ? reservation.record.lead_ids.length : launched2.length,
15523
+ launched_count: launchedCount,
14732
15524
  failed: failed2,
14733
15525
  quota_exceeded: quotaExceeded2,
14734
15526
  lens_id: lensId,
15527
+ notification_id: notificationId,
14735
15528
  _meta: { region: client.region }
14736
15529
  };
14737
15530
  return out;
14738
15531
  }
14739
- const launched = [];
14740
- const failed = [];
14741
- let quotaExceeded = false;
14742
- for (const leadId of candidates) {
14743
- if (quotaExceeded)
14744
- break;
14745
- try {
14746
- await client.requestVoid("POST", `/leads/${leadId}/web_fetch?force_fetch=false`);
14747
- launched.push(leadId);
14748
- } catch (err) {
14749
- if (err?.code === "QUOTA_EXCEEDED") {
14750
- quotaExceeded = true;
14751
- 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`);
14752
- } else if (err?.code === "NOT_FOUND") {
14753
- failed.push({ lead_id: leadId, error: "lead not found" });
14754
- } else {
14755
- failed.push({
14756
- lead_id: leadId,
14757
- error: err?.message ?? err?.code ?? "unknown"
14758
- });
14759
- }
14760
- }
15532
+ const inlineLaunch = await launchBulkQualify(client, candidates, ctx);
15533
+ const quotaExceeded = inlineLaunch.quotaExceeded;
15534
+ const launched = inlineLaunch.resp?.queued_ids ?? [];
15535
+ const inlineSkipped = inlineLaunch.resp?.skipped_ids ?? [];
15536
+ const inlineNotificationId = inlineLaunch.resp?.notification_id ?? null;
15537
+ if (inlineNotificationId) {
15538
+ ctx?.logger?.info?.(`bulk_qualify_leads: launched bulk progress_notification_id=${inlineNotificationId} queued=${launched.length} skipped=${inlineSkipped.length}`);
14761
15539
  }
15540
+ const inlineFailedSeen = /* @__PURE__ */ new Set([...launched, ...inlineSkipped]);
15541
+ const failed = candidates.filter((id) => !inlineFailedSeen.has(id)).map((id) => ({ lead_id: id, error: "not_queued" }));
14762
15542
  let progressDone = 0;
14763
15543
  const progressTotal = launched.length;
14764
15544
  if (progressTotal > 0) {
@@ -15594,6 +16374,7 @@ var init_import_and_qualify = __esm({
15594
16374
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
15595
16375
  qualify_id: null,
15596
16376
  import_ids: queued.importIds,
16377
+ notification_ids: queued.notification_ids ?? [],
15597
16378
  imported: queued.leads.map((l) => ({
15598
16379
  leadId: l.leadId,
15599
16380
  ...l.domain ? { domain: l.domain } : {},
@@ -15618,6 +16399,7 @@ var init_import_and_qualify = __esm({
15618
16399
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
15619
16400
  qualify_id: null,
15620
16401
  import_ids: queued.importIds,
16402
+ notification_ids: queued.notification_ids ?? [],
15621
16403
  imported: [],
15622
16404
  not_imported: [],
15623
16405
  qualified: [],
@@ -15655,6 +16437,7 @@ var init_import_and_qualify = __esm({
15655
16437
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
15656
16438
  qualify_id: null,
15657
16439
  import_ids: importResult.importIds,
16440
+ notification_ids: importResult.notification_ids ?? [],
15658
16441
  imported: [],
15659
16442
  not_imported: importResult.not_imported.map(toNotImportedEntry),
15660
16443
  qualified: [],
@@ -15692,6 +16475,7 @@ var init_import_and_qualify = __esm({
15692
16475
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
15693
16476
  qualify_id: null,
15694
16477
  import_ids: importResult.importIds,
16478
+ notification_ids: importResult.notification_ids ?? [],
15695
16479
  imported,
15696
16480
  not_imported,
15697
16481
  qualified: [],
@@ -15802,6 +16586,7 @@ var init_import_and_qualify = __esm({
15802
16586
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
15803
16587
  qualify_id: reservation.record.bulk_id,
15804
16588
  import_ids: importResult.importIds,
16589
+ notification_ids: importResult.notification_ids ?? [],
15805
16590
  imported,
15806
16591
  not_imported,
15807
16592
  qualified,
@@ -16397,16 +17182,20 @@ var init_bulk_store = __esm({
16397
17182
  this.logger?.info?.(`bulk.import_failed bulk_id=${bulk_id}`);
16398
17183
  });
16399
17184
  }
16400
- async markLaunched(bulk_id) {
17185
+ async markLaunched(bulk_id, notification_id) {
16401
17186
  return this.mutex.run(async () => {
16402
17187
  const all = this.prune(await this.readAll());
16403
17188
  const idx = all.findIndex((r) => r.bulk_id === bulk_id);
16404
17189
  if (idx < 0) {
16405
17190
  throw new Error(`bulk_id not found: ${bulk_id}`);
16406
17191
  }
16407
- all[idx] = { ...all[idx], status: "launched" };
17192
+ all[idx] = {
17193
+ ...all[idx],
17194
+ status: "launched",
17195
+ ...notification_id ? { notification_id } : {}
17196
+ };
16408
17197
  await this.writeAll(all);
16409
- this.logger?.info?.(`bulk.launched bulk_id=${bulk_id}`);
17198
+ this.logger?.info?.(`bulk.launched bulk_id=${bulk_id}${notification_id ? ` notification_id=${notification_id}` : ""}`);
16410
17199
  return all[idx];
16411
17200
  });
16412
17201
  }
@@ -16658,6 +17447,14 @@ var init_import_status = __esm({
16658
17447
  });
16659
17448
 
16660
17449
  // ../core/dist/composite/qualify-status.js
17450
+ async function readNotification(client, notificationId) {
17451
+ try {
17452
+ const page = await client.listNotifications({ archived: false, count: 50 });
17453
+ return page.items.find((n) => n.id === notificationId) ?? null;
17454
+ } catch {
17455
+ return null;
17456
+ }
17457
+ }
16661
17458
  var qualifyStatus;
16662
17459
  var init_qualify_status = __esm({
16663
17460
  "../core/dist/composite/qualify-status.js"() {
@@ -16819,6 +17616,16 @@ var init_qualify_status = __esm({
16819
17616
  const { _stillRunning, _failedCode, ...rest } = r;
16820
17617
  qualified.push(rest);
16821
17618
  }
17619
+ let bulkProgress = null;
17620
+ let inProgressFlag = null;
17621
+ const notifId = record.notification_id ?? null;
17622
+ if (notifId) {
17623
+ const n = await readNotification(client, notifId);
17624
+ if (n) {
17625
+ bulkProgress = n.bulk_progress;
17626
+ inProgressFlag = n.in_progress;
17627
+ }
17628
+ }
16822
17629
  const out = {
16823
17630
  qualify_id: record.bulk_id,
16824
17631
  launched_at: record.launched_at,
@@ -16830,6 +17637,9 @@ var init_qualify_status = __esm({
16830
17637
  still_running,
16831
17638
  failed,
16832
17639
  not_in_lens: [...notInLensSet],
17640
+ notification_id: notifId,
17641
+ bulk_progress: bulkProgress,
17642
+ in_progress: inProgressFlag,
16833
17643
  region: client.region,
16834
17644
  _meta: client.lastMeta ?? {
16835
17645
  region: client.region,
@@ -16842,6 +17652,9 @@ var init_qualify_status = __esm({
16842
17652
  out.per_lead_budget_ms = record.per_lead_budget_ms;
16843
17653
  if (record.total_budget_ms !== void 0)
16844
17654
  out.total_budget_ms = record.total_budget_ms;
17655
+ if (bulkProgress && bulkProgress.quota_hit_count > 0) {
17656
+ 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.";
17657
+ }
16845
17658
  return out;
16846
17659
  }
16847
17660
  };
@@ -17119,6 +17932,7 @@ var init_enrich_titles = __esm({
17119
17932
  bulk_id: res.record.bulk_id,
17120
17933
  launched_at: res.record.launched_at,
17121
17934
  durability: res.record.durability,
17935
+ notification_id: res.record.notification_id ?? null,
17122
17936
  seconds_since_original_launch: bulkSecondsSinceOriginal ?? 0,
17123
17937
  titles: params.titles,
17124
17938
  email,
@@ -17134,8 +17948,9 @@ var init_enrich_titles = __esm({
17134
17948
  total: 3,
17135
17949
  message: `Launching enrichment for ${params.titles.length} title${params.titles.length === 1 ? "" : "s"}\u2026`
17136
17950
  });
17951
+ let launchResp = null;
17137
17952
  try {
17138
- await client.requestVoid("POST", "/leads/selection/enrichment/launch", { titles: params.titles, email, phone });
17953
+ launchResp = await client.request("POST", "/leads/selection/enrichment/launch", { titles: params.titles, email, phone });
17139
17954
  } catch (err) {
17140
17955
  const aborted = err?.name === "AbortError" || ctx?.signal?.aborted === true;
17141
17956
  if (bulkRecord && tracker) {
@@ -17159,9 +17974,10 @@ var init_enrich_titles = __esm({
17159
17974
  }
17160
17975
  throw err;
17161
17976
  }
17977
+ const notificationId = launchResp?.notification_id ?? null;
17162
17978
  if (bulkRecord && tracker) {
17163
17979
  try {
17164
- await tracker.markLaunched(bulkRecord.bulk_id);
17980
+ await tracker.markLaunched(bulkRecord.bulk_id, notificationId);
17165
17981
  } catch (e) {
17166
17982
  ctx?.logger?.warn?.(`enrich_titles: tracker.markLaunched failed: ${e?.message ?? e}`);
17167
17983
  return {
@@ -17189,8 +18005,9 @@ var init_enrich_titles = __esm({
17189
18005
  bulk_id: bulkRecord?.bulk_id,
17190
18006
  launched_at: bulkRecord?.launched_at,
17191
18007
  durability: bulkRecord?.durability,
17192
- 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.",
17193
- 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."
18008
+ notification_id: notificationId,
18009
+ 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.",
18010
+ 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."
17194
18011
  };
17195
18012
  } finally {
17196
18013
  try {
@@ -17208,6 +18025,14 @@ var init_enrich_titles = __esm({
17208
18025
  });
17209
18026
 
17210
18027
  // ../core/dist/composite/bulk-enrich-status.js
18028
+ async function readNotification2(client, notificationId) {
18029
+ try {
18030
+ const page = await client.listNotifications({ archived: false, count: 50 });
18031
+ return page.items.find((n) => n.id === notificationId) ?? null;
18032
+ } catch {
18033
+ return null;
18034
+ }
18035
+ }
17211
18036
  async function pMap(items, fn, concurrency) {
17212
18037
  const out = new Array(items.length);
17213
18038
  let next = 0;
@@ -17379,6 +18204,53 @@ var init_bulk_enrich_status = __esm({
17379
18204
  launched_at: record.launched_at
17380
18205
  };
17381
18206
  }
18207
+ const notifId = record.notification_id ?? null;
18208
+ if (notifId) {
18209
+ const n = await readNotification2(client, notifId);
18210
+ if (n && n.bulk_progress) {
18211
+ const bp = n.bulk_progress;
18212
+ const inProgress = n.in_progress;
18213
+ let leads2 = [];
18214
+ if (!inProgress && includeContacts) {
18215
+ leads2 = await pMap(record.lead_ids, async (leadId) => {
18216
+ try {
18217
+ const out = await getContacts.execute(client, { leadId });
18218
+ const contacts = Array.isArray(out?.contacts) ? out.contacts : [];
18219
+ return { lead_id: leadId, contacts };
18220
+ } catch {
18221
+ return { lead_id: leadId };
18222
+ }
18223
+ }, STATUS_FETCH_CONCURRENCY);
18224
+ } else {
18225
+ leads2 = record.lead_ids.map((id) => ({ lead_id: id }));
18226
+ }
18227
+ 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}`);
18228
+ return {
18229
+ bulk_id: record.bulk_id,
18230
+ notification_id: notifId,
18231
+ launched_at: record.launched_at,
18232
+ status: record.status,
18233
+ durability: record.durability,
18234
+ titles: record.titles,
18235
+ email: record.email,
18236
+ phone: record.phone,
18237
+ lens_id: record.lens_id,
18238
+ leads: leads2,
18239
+ overall_progress: {
18240
+ done: bp.success_count + bp.failure_count + bp.quota_hit_count,
18241
+ total: bp.total_count,
18242
+ done_ratio: bp.total_count === 0 ? 0 : (bp.success_count + bp.failure_count + bp.quota_hit_count) / bp.total_count
18243
+ },
18244
+ bulk_progress: bp,
18245
+ in_progress: inProgress,
18246
+ all_done: !inProgress,
18247
+ ...bp.quota_hit_count > 0 ? {
18248
+ 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."
18249
+ } : {}
18250
+ };
18251
+ }
18252
+ ctx?.logger?.info?.(`bulk_enrich_status: notification ${notifId} not yet visible; falling back to per-lead fan-out`);
18253
+ }
17382
18254
  let doneSoFar = 0;
17383
18255
  const totalLeads = record.lead_ids.length;
17384
18256
  const results = await pMap(record.lead_ids, async (leadId) => {
@@ -17465,6 +18337,8 @@ var init_bulk_enrich_status = __esm({
17465
18337
 
17466
18338
  // ../core/dist/composite/adjust-audience.js
17467
18339
  function tokens(s) {
18340
+ if (!s)
18341
+ return [];
17468
18342
  return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
17469
18343
  }
17470
18344
  function bestMatches(text, taxonomy) {
@@ -17478,11 +18352,11 @@ function bestMatches(text, taxonomy) {
17478
18352
  if (have.has(t))
17479
18353
  overlap += 1;
17480
18354
  const score = overlap / Math.max(want.size, 1);
17481
- return { id: s.id, name: s.name, score };
18355
+ return { id: s.id, name: s.name ?? "", score };
17482
18356
  }).filter((m) => m.score > 0).sort((a, b) => b.score - a.score);
17483
18357
  return ranked.slice(0, 5);
17484
18358
  }
17485
- async function resolveSectors(client, texts) {
18359
+ async function resolveSectors(client, texts, ctx) {
17486
18360
  const looksLikeId2 = (s) => /^\d+$/.test(s);
17487
18361
  const direct = texts.filter(looksLikeId2);
17488
18362
  const free = texts.filter((s) => !looksLikeId2(s));
@@ -17491,6 +18365,10 @@ async function resolveSectors(client, texts) {
17491
18365
  const me = await client.resolveMe().catch(() => null);
17492
18366
  const lang = me?.language ?? "en";
17493
18367
  const taxonomy = await client.request("GET", `/sectors/all?lang=${encodeURIComponent(lang)}&includeInvisible=false`);
18368
+ const nullNames = taxonomy.filter((s) => !s.name).length;
18369
+ if (nullNames > 0) {
18370
+ ctx?.logger?.warn?.(`adjust_audience: /sectors/all returned ${nullNames}/${taxonomy.length} sector(s) with a null/missing name`);
18371
+ }
17494
18372
  const resolved = [...direct];
17495
18373
  const ambiguities = [];
17496
18374
  for (const text of free) {
@@ -17503,6 +18381,22 @@ async function resolveSectors(client, texts) {
17503
18381
  }
17504
18382
  return { resolved, ambiguities };
17505
18383
  }
18384
+ async function resolveLensByName(client, name) {
18385
+ const lenses = await client.request("GET", "/lenses");
18386
+ const all = lenses.map((l) => ({ id: l.id, name: l.name }));
18387
+ const needle = name.trim().toLowerCase();
18388
+ const exact = all.filter((l) => (l.name ?? "").trim().toLowerCase() === needle);
18389
+ if (exact.length === 1)
18390
+ return { ok: true, id: exact[0].id };
18391
+ if (exact.length > 1)
18392
+ return { ok: false, reason: "ambiguous", matches: exact };
18393
+ const partial = all.filter((l) => (l.name ?? "").toLowerCase().includes(needle));
18394
+ if (partial.length === 1)
18395
+ return { ok: true, id: partial[0].id };
18396
+ if (partial.length > 1)
18397
+ return { ok: false, reason: "ambiguous", matches: partial };
18398
+ return { ok: false, reason: "not_found", lenses: all };
18399
+ }
17506
18400
  function mergeFilter(current, toAddSectors, toExcludeSectors, sizes) {
17507
18401
  const items = current?.lens_filter?.items ?? [];
17508
18402
  const item = items[0] ?? { criteria: [] };
@@ -17536,11 +18430,15 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes) {
17536
18430
  }
17537
18431
  }
17538
18432
  if (sizes && sizes.length > 0) {
18433
+ const normalizedSizes = sizes.map((s) => ({
18434
+ min: s.min ?? 0,
18435
+ max: s.max ?? 1e6
18436
+ }));
17539
18437
  const idx = criteria.findIndex((c) => c.type === "size");
17540
18438
  if (idx >= 0) {
17541
- criteria[idx] = { type: "size", is_excluded: false, sizes };
18439
+ criteria[idx] = { type: "size", is_excluded: false, sizes: normalizedSizes };
17542
18440
  } else {
17543
- criteria.push({ type: "size", is_excluded: false, sizes });
18441
+ criteria.push({ type: "size", is_excluded: false, sizes: normalizedSizes });
17544
18442
  }
17545
18443
  }
17546
18444
  return {
@@ -17548,6 +18446,9 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes) {
17548
18446
  locations: current.locations ?? { results: [], parents: [] }
17549
18447
  };
17550
18448
  }
18449
+ function filterWriteBody(filter) {
18450
+ return { items: filter.lens_filter.items };
18451
+ }
17551
18452
  var adjustAudience;
17552
18453
  var init_adjust_audience = __esm({
17553
18454
  "../core/dist/composite/adjust-audience.js"() {
@@ -17594,6 +18495,10 @@ var init_adjust_audience = __esm({
17594
18495
  description: "Company size buckets, e.g. [{min:30,max:300}]"
17595
18496
  },
17596
18497
  lensId: { type: "number", description: "Lens id (escape hatch)" },
18498
+ lensName: {
18499
+ type: "string",
18500
+ 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."
18501
+ },
17597
18502
  save_for_org: {
17598
18503
  type: "boolean",
17599
18504
  description: "Admin only \u2014 propagate the change to the org-level lens for everyone (default false: per-user draft)"
@@ -17607,21 +18512,35 @@ var init_adjust_audience = __esm({
17607
18512
  },
17608
18513
  outputSchema: {
17609
18514
  type: "object",
17610
- description: "Two return shapes: 'ambiguous_sectors' when free-text sectors matched multiple candidates (agent re-calls with sector_ids), 'applied' on success.",
18515
+ 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).",
17611
18516
  properties: {
17612
18517
  status: {
17613
18518
  type: "string",
17614
- description: "'ambiguous_sectors' or 'applied'."
18519
+ description: "'applied', 'ambiguous_sectors', 'lens_not_found', or 'ambiguous_lens'."
17615
18520
  },
17616
18521
  sector_ambiguities: {
17617
18522
  type: "array",
17618
18523
  description: "Per ambiguous text: {sector_text, matches:[{id, name, score}]}. Agent picks an id and re-calls.",
17619
18524
  items: { type: "object" }
17620
18525
  },
18526
+ lenses: {
18527
+ type: "array",
18528
+ description: "On 'lens_not_found': the user's lenses [{id, name}] to pick from.",
18529
+ items: { type: "object" }
18530
+ },
18531
+ matches: {
18532
+ type: "array",
18533
+ description: "On 'ambiguous_lens': the lenses [{id, name}] the name matched.",
18534
+ items: { type: "object" }
18535
+ },
18536
+ lens_query: {
18537
+ type: "string",
18538
+ description: "On 'lens_not_found' / 'ambiguous_lens': the lensName the user asked for."
18539
+ },
17621
18540
  message: { type: "string" },
17622
18541
  lens_used: {
17623
18542
  type: "object",
17624
- description: "Resolved lens metadata: {id, name, was_draft, was_new, save_for_org}."
18543
+ description: "Resolved lens metadata: {id, name, was_draft, was_new, active_lens_changed, save_for_org}."
17625
18544
  },
17626
18545
  filter_applied: {
17627
18546
  type: "object",
@@ -17634,28 +18553,63 @@ var init_adjust_audience = __esm({
17634
18553
  execute: async (client, params, ctx) => {
17635
18554
  const me = await client.resolveMe();
17636
18555
  const isAdmin = me.admin === true;
17637
- const startingLensId = params.lensId ?? me.last_requested_lens ?? await client.resolveDefaultLens();
18556
+ let namedLensId;
18557
+ if (params.lensId == null && params.lensName != null && params.lensName.trim() !== "") {
18558
+ const res = await resolveLensByName(client, params.lensName);
18559
+ if (!res.ok && res.reason === "not_found") {
18560
+ return {
18561
+ status: "lens_not_found",
18562
+ lens_query: params.lensName,
18563
+ lenses: res.lenses,
18564
+ message: `No lens named "${params.lensName}". Pick one of the listed lenses (pass lensId or an exact lensName), or create it first.`
18565
+ };
18566
+ }
18567
+ if (!res.ok && res.reason === "ambiguous") {
18568
+ return {
18569
+ status: "ambiguous_lens",
18570
+ lens_query: params.lensName,
18571
+ matches: res.matches,
18572
+ message: `"${params.lensName}" matched multiple lenses. Re-call with the exact lensName or the lensId of the one you mean.`
18573
+ };
18574
+ }
18575
+ if (res.ok)
18576
+ namedLensId = res.id;
18577
+ }
18578
+ const startingLensId = params.lensId ?? namedLensId ?? me.last_requested_lens ?? await client.resolveDefaultLens();
18579
+ const isNamedEdit = namedLensId != null && params.lensId == null;
17638
18580
  const includeTexts = [
17639
18581
  ...params.sectors ?? [],
17640
18582
  ...params.sector_ids ?? []
17641
18583
  ];
17642
18584
  const excludeTexts = params.exclude_sectors ?? [];
17643
- const includeRes = await resolveSectors(client, includeTexts);
17644
- const excludeRes = await resolveSectors(client, excludeTexts);
18585
+ const includeRes = await resolveSectors(client, includeTexts, ctx);
18586
+ const excludeRes = await resolveSectors(client, excludeTexts, ctx);
17645
18587
  const ambiguities = [
17646
18588
  ...includeRes.ambiguities,
17647
18589
  ...excludeRes.ambiguities
17648
18590
  ];
17649
18591
  if (ambiguities.length > 0) {
18592
+ const noMatch = ambiguities.filter((a) => a.matches.length === 0);
18593
+ const multi = ambiguities.filter((a) => a.matches.length > 0);
18594
+ const parts = [];
18595
+ if (noMatch.length > 0) {
18596
+ const names = noMatch.map((a) => `"${a.sector_text}"`).join(", ");
18597
+ 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=...`);
18598
+ }
18599
+ if (multi.length > 0) {
18600
+ const names = multi.map((a) => `"${a.sector_text}"`).join(", ");
18601
+ parts.push(`${names} matched multiple sectors. Pick from the matches and re-call with sector_ids=...`);
18602
+ }
17650
18603
  return {
17651
18604
  status: "ambiguous_sectors",
17652
18605
  sector_ambiguities: ambiguities,
17653
- message: "One or more sector names matched multiple sectors. Pick from the matches and re-call with sector_ids=..."
18606
+ message: parts.join(" ")
17654
18607
  };
17655
18608
  }
17656
18609
  const lens = await client.request("GET", `/lenses/${startingLensId}`);
17657
18610
  const currentFilter = await client.request("GET", `/lenses/${startingLensId}/filter`);
17658
18611
  const merged = mergeFilter(currentFilter, includeRes.resolved, excludeRes.resolved, params.sizes);
18612
+ const mergedBody = filterWriteBody(merged);
17659
18613
  const isDefault = lens.is_default || lens.default;
17660
18614
  const isUserLevel = lens.user_id != null;
17661
18615
  const isOrgLevel = !isUserLevel && !isDefault;
@@ -17664,24 +18618,28 @@ var init_adjust_audience = __esm({
17664
18618
  let wasNew = false;
17665
18619
  if (isDefault) {
17666
18620
  const name = params.newLensName ?? `Custom audience \u2014 ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
17667
- const newLens = await client.request("POST", "/lenses", {
17668
- base: startingLensId,
18621
+ const newLens2 = await client.request("POST", "/lenses", {
18622
+ base: String(startingLensId),
17669
18623
  name
17670
18624
  });
17671
- targetLensId = newLens.id;
18625
+ targetLensId = newLens2.id;
17672
18626
  wasNew = true;
17673
- await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, merged);
17674
- await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
18627
+ await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, mergedBody);
18628
+ if (!isNamedEdit) {
18629
+ await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
18630
+ }
17675
18631
  } else if (isUserLevel) {
17676
18632
  try {
17677
- await client.requestVoid("POST", `/lenses/${startingLensId}/filter`, merged);
18633
+ await client.requestVoid("POST", `/lenses/${startingLensId}/filter`, mergedBody);
17678
18634
  } catch (err) {
17679
18635
  if (err?.code === "FORBIDDEN") {
17680
18636
  wasDraft = true;
17681
18637
  const draft = await client.request("POST", `/lenses/${startingLensId}/draft`);
17682
18638
  targetLensId = draft.id;
17683
- await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, merged);
17684
- await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
18639
+ await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, mergedBody);
18640
+ if (!isNamedEdit) {
18641
+ await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
18642
+ }
17685
18643
  } else {
17686
18644
  throw err;
17687
18645
  }
@@ -17693,7 +18651,7 @@ var init_adjust_audience = __esm({
17693
18651
  const draft = await client.request("POST", `/lenses/${startingLensId}/draft`);
17694
18652
  targetLensId = draft.id;
17695
18653
  try {
17696
- await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, merged);
18654
+ await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, mergedBody);
17697
18655
  } catch (err) {
17698
18656
  ctx?.logger?.warn?.(`adjust_audience: filter on draft ${targetLensId} failed: ${err?.message}`);
17699
18657
  try {
@@ -17709,17 +18667,21 @@ var init_adjust_audience = __esm({
17709
18667
  }
17710
18668
  throw err;
17711
18669
  }
17712
- await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
18670
+ if (!isNamedEdit) {
18671
+ await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
18672
+ }
17713
18673
  } else {
17714
18674
  try {
17715
- await client.requestVoid("POST", `/lenses/${startingLensId}/filter`, merged);
18675
+ await client.requestVoid("POST", `/lenses/${startingLensId}/filter`, mergedBody);
17716
18676
  } catch (err) {
17717
18677
  throw err;
17718
18678
  }
17719
18679
  }
17720
18680
  }
17721
- client.invalidateMe();
18681
+ if (!isNamedEdit)
18682
+ client.invalidateMe();
17722
18683
  client.invalidateDefaultLens();
18684
+ 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.` : "";
17723
18685
  return {
17724
18686
  status: "applied",
17725
18687
  lens_used: {
@@ -17727,10 +18689,11 @@ var init_adjust_audience = __esm({
17727
18689
  name: lens.name,
17728
18690
  was_draft: wasDraft,
17729
18691
  was_new: wasNew,
18692
+ active_lens_changed: !isNamedEdit && (wasNew || wasDraft),
17730
18693
  save_for_org: params.save_for_org === true && isAdmin && isOrgLevel
17731
18694
  },
17732
18695
  filter_applied: merged,
17733
- 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.",
18696
+ 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,
17734
18697
  _meta: { region: client.region }
17735
18698
  };
17736
18699
  }
@@ -18125,6 +19088,399 @@ var init_extend_lens = __esm({
18125
19088
  }
18126
19089
  });
18127
19090
 
19091
+ // ../core/dist/composite/my-lenses.js
19092
+ async function listWithActive(client) {
19093
+ const lenses = await client.request("GET", "/lenses");
19094
+ const me = await client.resolveMe().catch(() => null);
19095
+ const activeFromMe = sid(me?.last_requested_lens);
19096
+ const active_lens_id = activeFromMe ?? sid(lenses.find((l) => l.is_last_active)?.id) ?? null;
19097
+ return {
19098
+ active_lens_id,
19099
+ lenses: lenses.map((l) => ({
19100
+ id: sid(l.id),
19101
+ name: l.name,
19102
+ description: l.description ?? null,
19103
+ is_active: sid(l.id) === active_lens_id,
19104
+ is_default: l.is_default === true || l.default === true
19105
+ }))
19106
+ };
19107
+ }
19108
+ var sid, myLenses;
19109
+ var init_my_lenses = __esm({
19110
+ "../core/dist/composite/my-lenses.js"() {
19111
+ "use strict";
19112
+ init_tool_descriptions_generated();
19113
+ sid = (v) => v == null ? null : String(v);
19114
+ myLenses = {
19115
+ name: "leadbay_my_lenses",
19116
+ annotations: {
19117
+ title: "List, switch, edit, or delete your lenses",
19118
+ // No args → pure read. The delete mode issues DELETE /lenses/:id (an
19119
+ // irreversible side effect), so the tool is destructive — clients must
19120
+ // treat it as approval-required, not auto-run. The delete path is itself
19121
+ // confirm-gated (preview unless confirm:true). switch/edit are not
19122
+ // idempotent across modes either, so don't claim idempotency.
19123
+ readOnlyHint: false,
19124
+ destructiveHint: true,
19125
+ idempotentHint: false,
19126
+ openWorldHint: true
19127
+ },
19128
+ description: leadbay_my_lenses,
19129
+ inputSchema: {
19130
+ type: "object",
19131
+ properties: {
19132
+ switchToLensId: {
19133
+ type: ["string", "number"],
19134
+ description: "When set, switch the active lens to this id (must be one of the user's lenses), then return the refreshed list."
19135
+ },
19136
+ editLensId: {
19137
+ type: ["string", "number"],
19138
+ description: "When set, edit this lens's metadata \u2014 provide newName and/or newDescription. Must be one of the user's lenses."
19139
+ },
19140
+ newName: {
19141
+ type: "string",
19142
+ description: "New lens name (used with editLensId)."
19143
+ },
19144
+ newDescription: {
19145
+ type: "string",
19146
+ description: "New lens description (used with editLensId). Pass an empty string to clear it."
19147
+ },
19148
+ deleteLensId: {
19149
+ type: ["string", "number"],
19150
+ description: "When set, delete this lens. DESTRUCTIVE \u2014 returns a delete_preview unless confirm:true. Cannot delete the default lens."
19151
+ },
19152
+ confirm: {
19153
+ type: "boolean",
19154
+ description: "Required (=true) to actually delete. Without it, deleteLensId returns a preview to confirm with the user first."
19155
+ }
19156
+ },
19157
+ additionalProperties: false
19158
+ },
19159
+ outputSchema: {
19160
+ type: "object",
19161
+ properties: {
19162
+ status: {
19163
+ type: "string",
19164
+ description: "'listed', 'switched', 'already_active', 'edited', 'deleted', 'delete_preview' (confirm to proceed), 'cannot_delete_default', or 'not_found'."
19165
+ },
19166
+ switched: { type: "boolean", description: "True when this call changed the active lens." },
19167
+ edited: { type: "boolean", description: "True when this call renamed/re-described a lens." },
19168
+ deleted: { type: "boolean", description: "True when this call deleted a lens." },
19169
+ will_delete: {
19170
+ type: "object",
19171
+ description: "On 'delete_preview': the lens that WILL be deleted {id, name}. Nothing removed yet."
19172
+ },
19173
+ active_lens_id: { type: ["string", "null"] },
19174
+ lenses: {
19175
+ type: "array",
19176
+ description: "The user's lenses. Each: {id, name, description, is_active}.",
19177
+ items: { type: "object" }
19178
+ },
19179
+ message: { type: "string" }
19180
+ },
19181
+ required: ["status", "lenses", "active_lens_id"]
19182
+ },
19183
+ execute: async (client, params) => {
19184
+ if (params.deleteLensId != null) {
19185
+ const targetId = sid(params.deleteLensId);
19186
+ const before = await listWithActive(client);
19187
+ const target = before.lenses.find((l) => l.id === targetId);
19188
+ if (!target) {
19189
+ return {
19190
+ status: "not_found",
19191
+ switched: false,
19192
+ edited: false,
19193
+ deleted: false,
19194
+ active_lens_id: before.active_lens_id,
19195
+ lenses: before.lenses,
19196
+ message: `No lens with id ${targetId}. Pick one from the list.`
19197
+ };
19198
+ }
19199
+ if (target.is_default) {
19200
+ return {
19201
+ status: "cannot_delete_default",
19202
+ switched: false,
19203
+ edited: false,
19204
+ deleted: false,
19205
+ active_lens_id: before.active_lens_id,
19206
+ lenses: before.lenses,
19207
+ message: `"${target.name}" is the default lens and can't be deleted.`
19208
+ };
19209
+ }
19210
+ if (params.confirm !== true) {
19211
+ return {
19212
+ status: "delete_preview",
19213
+ switched: false,
19214
+ edited: false,
19215
+ deleted: false,
19216
+ active_lens_id: before.active_lens_id,
19217
+ lenses: before.lenses,
19218
+ will_delete: { id: target.id, name: target.name },
19219
+ message: `About to delete "${target.name}". This can't be undone. Confirm with the user, then re-call with confirm:true.`
19220
+ };
19221
+ }
19222
+ await client.requestVoid("DELETE", `/lenses/${targetId}`);
19223
+ client.invalidateMe();
19224
+ client.invalidateDefaultLens();
19225
+ const after = await listWithActive(client);
19226
+ return {
19227
+ status: "deleted",
19228
+ switched: false,
19229
+ edited: false,
19230
+ deleted: true,
19231
+ active_lens_id: after.active_lens_id,
19232
+ lenses: after.lenses,
19233
+ message: `Deleted "${target.name}".`
19234
+ };
19235
+ }
19236
+ if (params.editLensId != null) {
19237
+ const targetId = sid(params.editLensId);
19238
+ const before = await listWithActive(client);
19239
+ const target = before.lenses.find((l) => l.id === targetId);
19240
+ if (!target) {
19241
+ return {
19242
+ status: "not_found",
19243
+ switched: false,
19244
+ edited: false,
19245
+ active_lens_id: before.active_lens_id,
19246
+ lenses: before.lenses,
19247
+ message: `No lens with id ${targetId}. Pick one from the list.`
19248
+ };
19249
+ }
19250
+ const body = {};
19251
+ const newName = params.newName?.trim();
19252
+ if (newName)
19253
+ body.name = newName;
19254
+ if (params.newDescription !== void 0)
19255
+ body.description = params.newDescription;
19256
+ if (Object.keys(body).length === 0) {
19257
+ return {
19258
+ status: "not_found",
19259
+ switched: false,
19260
+ edited: false,
19261
+ active_lens_id: before.active_lens_id,
19262
+ lenses: before.lenses,
19263
+ message: `Nothing to change on "${target.name}" \u2014 provide newName and/or newDescription.`
19264
+ };
19265
+ }
19266
+ await client.requestVoid("POST", `/lenses/${targetId}`, body);
19267
+ client.invalidateDefaultLens();
19268
+ const changed = [
19269
+ body.name != null ? `renamed to "${body.name}"` : null,
19270
+ body.description !== void 0 ? "description updated" : null
19271
+ ].filter(Boolean).join(", ");
19272
+ const after = await listWithActive(client);
19273
+ return {
19274
+ status: "edited",
19275
+ switched: false,
19276
+ edited: true,
19277
+ active_lens_id: after.active_lens_id,
19278
+ lenses: after.lenses,
19279
+ message: `"${target.name}" \u2014 ${changed}.`
19280
+ };
19281
+ }
19282
+ if (params.switchToLensId != null) {
19283
+ const targetId = sid(params.switchToLensId);
19284
+ const before = await listWithActive(client);
19285
+ const target = before.lenses.find((l) => l.id === targetId);
19286
+ if (!target) {
19287
+ return {
19288
+ status: "not_found",
19289
+ switched: false,
19290
+ edited: false,
19291
+ active_lens_id: before.active_lens_id,
19292
+ lenses: before.lenses,
19293
+ message: `No lens with id ${targetId}. Pick an id from the list.`
19294
+ };
19295
+ }
19296
+ if (target.is_active) {
19297
+ return {
19298
+ status: "already_active",
19299
+ switched: false,
19300
+ edited: false,
19301
+ active_lens_id: before.active_lens_id,
19302
+ lenses: before.lenses,
19303
+ message: `"${target.name}" is already your active lens.`
19304
+ };
19305
+ }
19306
+ await client.requestVoid("POST", `/lenses/${targetId}/update_last_requested`);
19307
+ client.invalidateMe();
19308
+ client.invalidateDefaultLens();
19309
+ const after = await listWithActive(client);
19310
+ return {
19311
+ status: "switched",
19312
+ switched: true,
19313
+ edited: false,
19314
+ active_lens_id: after.active_lens_id,
19315
+ lenses: after.lenses,
19316
+ message: `Now showing "${target.name}".`
19317
+ };
19318
+ }
19319
+ const { lenses, active_lens_id } = await listWithActive(client);
19320
+ return { status: "listed", switched: false, edited: false, active_lens_id, lenses };
19321
+ }
19322
+ };
19323
+ }
19324
+ });
19325
+
19326
+ // ../core/dist/composite/new-lens.js
19327
+ var EMPTY_FILTER, newLens;
19328
+ var init_new_lens = __esm({
19329
+ "../core/dist/composite/new-lens.js"() {
19330
+ "use strict";
19331
+ init_adjust_audience();
19332
+ init_tool_descriptions_generated();
19333
+ EMPTY_FILTER = {
19334
+ lens_filter: { items: [{ criteria: [] }] },
19335
+ locations: { results: [], parents: [] }
19336
+ };
19337
+ newLens = {
19338
+ name: "leadbay_new_lens",
19339
+ annotations: {
19340
+ title: "Create a new named lens",
19341
+ readOnlyHint: false,
19342
+ destructiveHint: false,
19343
+ idempotentHint: false,
19344
+ // each call creates a distinct lens
19345
+ openWorldHint: true
19346
+ },
19347
+ description: leadbay_new_lens,
19348
+ inputSchema: {
19349
+ type: "object",
19350
+ properties: {
19351
+ name: { type: "string", description: "Display name for the new lens (required)." },
19352
+ sectors: {
19353
+ type: "array",
19354
+ items: { type: "string" },
19355
+ description: "Sectors to include \u2014 free text (auto-resolved) or ids."
19356
+ },
19357
+ exclude_sectors: {
19358
+ type: "array",
19359
+ items: { type: "string" },
19360
+ description: "Sectors to exclude \u2014 free text or ids."
19361
+ },
19362
+ sizes: {
19363
+ type: "array",
19364
+ items: {
19365
+ type: "object",
19366
+ properties: { min: { type: "number" }, max: { type: "number" } }
19367
+ },
19368
+ description: "Company size buckets, e.g. [{min:30,max:300}]."
19369
+ },
19370
+ base: {
19371
+ type: "number",
19372
+ description: "Lens id to clone from. Defaults to the active/default lens."
19373
+ },
19374
+ description: { type: "string", description: "Optional lens description." },
19375
+ confirm: {
19376
+ type: "boolean",
19377
+ 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."
19378
+ }
19379
+ },
19380
+ required: ["name"],
19381
+ additionalProperties: false
19382
+ },
19383
+ outputSchema: {
19384
+ type: "object",
19385
+ 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).",
19386
+ properties: {
19387
+ status: { type: "string", description: "'preview', 'created', 'ambiguous_sectors', or 'orphan_created' (filter write failed + cleanup failed)." },
19388
+ will_create: {
19389
+ type: "object",
19390
+ description: "On 'preview': what WILL be created \u2014 {name, description, sectors, exclude_sectors, sizes}. Nothing has been written yet."
19391
+ },
19392
+ filter_preview: { type: "object", description: "On 'preview': the FilterPayload that would be applied." },
19393
+ lens: {
19394
+ type: "object",
19395
+ description: "On 'created': the created lens {id, name}."
19396
+ },
19397
+ sector_ambiguities: {
19398
+ type: "array",
19399
+ description: "On 'ambiguous_sectors': per text {sector_text, matches:[{id,name,score}]}.",
19400
+ items: { type: "object" }
19401
+ },
19402
+ filter_applied: { type: "object", description: "On 'created': the FilterPayload POSTed to the new lens." },
19403
+ message: { type: "string" },
19404
+ _meta: { type: "object" }
19405
+ },
19406
+ required: ["status"]
19407
+ },
19408
+ execute: async (client, params, ctx) => {
19409
+ const includeRes = await resolveSectors(client, params.sectors ?? [], ctx);
19410
+ const excludeRes = await resolveSectors(client, params.exclude_sectors ?? [], ctx);
19411
+ const ambiguities = [...includeRes.ambiguities, ...excludeRes.ambiguities];
19412
+ if (ambiguities.length > 0) {
19413
+ const noMatch = ambiguities.filter((a) => a.matches.length === 0);
19414
+ const multi = ambiguities.filter((a) => a.matches.length > 0);
19415
+ const parts = [];
19416
+ if (noMatch.length > 0) {
19417
+ 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).`);
19418
+ }
19419
+ if (multi.length > 0) {
19420
+ parts.push(`${multi.map((a) => `"${a.sector_text}"`).join(", ")} matched multiple sectors. Pick from the matches and re-call with the sector id.`);
19421
+ }
19422
+ return {
19423
+ status: "ambiguous_sectors",
19424
+ sector_ambiguities: ambiguities,
19425
+ message: parts.join(" ")
19426
+ };
19427
+ }
19428
+ const merged = mergeFilter(EMPTY_FILTER, includeRes.resolved, excludeRes.resolved, params.sizes);
19429
+ if (params.confirm !== true) {
19430
+ return {
19431
+ status: "preview",
19432
+ will_create: {
19433
+ name: params.name,
19434
+ description: params.description ?? null,
19435
+ sectors: includeRes.resolved,
19436
+ exclude_sectors: excludeRes.resolved,
19437
+ sizes: merged.lens_filter.items[0].criteria.find((c) => c.type === "size") ?? null
19438
+ },
19439
+ filter_preview: merged,
19440
+ message: `About to create "${params.name}". Confirm with the user, then re-call with confirm:true.`,
19441
+ _meta: { region: client.region }
19442
+ };
19443
+ }
19444
+ const base = params.base ?? await client.resolveDefaultLens();
19445
+ const created = await client.request("POST", "/lenses", {
19446
+ base: String(base),
19447
+ name: params.name,
19448
+ description: params.description
19449
+ });
19450
+ const hasCriteria = merged.lens_filter.items[0].criteria.length > 0;
19451
+ if (hasCriteria) {
19452
+ try {
19453
+ await client.requestVoid("POST", `/lenses/${created.id}/filter`, filterWriteBody(merged));
19454
+ } catch (err) {
19455
+ ctx?.logger?.warn?.(`new_lens: filter write on new lens ${created.id} failed: ${err?.message} \u2014 rolling back`);
19456
+ try {
19457
+ await client.requestVoid("DELETE", `/lenses/${created.id}`);
19458
+ } catch {
19459
+ client.invalidateDefaultLens();
19460
+ return {
19461
+ status: "orphan_created",
19462
+ lens: { id: created.id, name: created.name },
19463
+ 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.`,
19464
+ _meta: { region: client.region }
19465
+ };
19466
+ }
19467
+ client.invalidateDefaultLens();
19468
+ throw err;
19469
+ }
19470
+ }
19471
+ client.invalidateDefaultLens();
19472
+ return {
19473
+ status: "created",
19474
+ lens: { id: created.id, name: created.name },
19475
+ filter_applied: merged,
19476
+ message: `Created "${created.name}".`,
19477
+ _meta: { region: client.region }
19478
+ };
19479
+ }
19480
+ };
19481
+ }
19482
+ });
19483
+
18128
19484
  // ../core/dist/composite/answer-clarification.js
18129
19485
  var answerClarification;
18130
19486
  var init_answer_clarification = __esm({
@@ -18663,8 +20019,11 @@ __export(dist_exports, {
18663
20019
  InMemoryBulkStore: () => InMemoryBulkStore,
18664
20020
  LeadbayClient: () => LeadbayClient,
18665
20021
  LocalBulkStore: () => LocalBulkStore,
20022
+ NotificationsInbox: () => NotificationsInbox,
20023
+ NotificationsWsClient: () => NotificationsWsClient,
18666
20024
  REGIONS: () => REGIONS,
18667
20025
  accountStatus: () => accountStatus,
20026
+ acknowledgeNotification: () => acknowledgeNotification,
18668
20027
  addLeadsToCampaign: () => addLeadsToCampaign,
18669
20028
  addNote: () => addNote,
18670
20029
  adjustAudience: () => adjustAudience,
@@ -18672,6 +20031,7 @@ __export(dist_exports, {
18672
20031
  agentMemoryRecall: () => agentMemoryRecall,
18673
20032
  agentMemoryReview: () => agentMemoryReview,
18674
20033
  agentMemoryTools: () => agentMemoryTools,
20034
+ anchorIdFor: () => anchorIdFor,
18675
20035
  answerClarification: () => answerClarification,
18676
20036
  appendEntry: () => appendEntry,
18677
20037
  appendTombstone: () => appendTombstone,
@@ -18680,6 +20040,7 @@ __export(dist_exports, {
18680
20040
  bulkQualifyLeads: () => bulkQualifyLeads,
18681
20041
  campaignCallSheet: () => campaignCallSheet,
18682
20042
  campaignProgression: () => campaignProgression,
20043
+ catchUpNotifications: () => catchUpNotifications,
18683
20044
  clearAgentMemoryCache: () => clearAgentMemoryCache,
18684
20045
  clearMockJournal: () => clearMockJournal,
18685
20046
  clearSelection: () => clearSelection,
@@ -18729,6 +20090,7 @@ __export(dist_exports, {
18729
20090
  importAndQualify: () => importAndQualify,
18730
20091
  importLeads: () => importLeads,
18731
20092
  importStatus: () => importStatus,
20093
+ inferKind: () => inferKind,
18732
20094
  invalidateAgentMemoryCache: () => invalidateAgentMemoryCache,
18733
20095
  isAgentMemoryEnabled: () => isAgentMemoryEnabled,
18734
20096
  isValidBulkId: () => isValidBulkId,
@@ -18769,12 +20131,14 @@ __export(dist_exports, {
18769
20131
  resolveAgentMemorySummary: () => resolveAgentMemorySummary,
18770
20132
  resolveImportRows: () => resolveImportRows,
18771
20133
  resolveRegion: () => resolveRegion,
20134
+ reviseHintFor: () => reviseHintFor,
18772
20135
  seedCandidates: () => seedCandidates,
18773
20136
  selectLeads: () => selectLeads,
18774
20137
  setActiveLens: () => setActiveLens,
18775
20138
  setEpilogueStatus: () => setEpilogueStatus,
18776
20139
  setPushback: () => setPushback,
18777
20140
  setUserPrompt: () => setUserPrompt,
20141
+ toInboxEntry: () => toInboxEntry,
18778
20142
  tools: () => tools,
18779
20143
  tourPlan: () => tourPlan,
18780
20144
  updateLens: () => updateLens,
@@ -18789,6 +20153,7 @@ var init_dist = __esm({
18789
20153
  init_types();
18790
20154
  init_agent_memory();
18791
20155
  init_composite_file_names();
20156
+ init_notifications();
18792
20157
  init_login();
18793
20158
  init_list_lenses();
18794
20159
  init_discover_leads();
@@ -18818,6 +20183,7 @@ var init_dist = __esm({
18818
20183
  init_agent_memory_recall();
18819
20184
  init_agent_memory_capture();
18820
20185
  init_agent_memory_review();
20186
+ init_acknowledge_notification();
18821
20187
  init_select_leads();
18822
20188
  init_deselect_leads();
18823
20189
  init_clear_selection();
@@ -18867,6 +20233,8 @@ var init_dist = __esm({
18867
20233
  init_refine_prompt();
18868
20234
  init_seed_candidates();
18869
20235
  init_extend_lens();
20236
+ init_my_lenses();
20237
+ init_new_lens();
18870
20238
  init_answer_clarification();
18871
20239
  init_report_outreach();
18872
20240
  init_report_friction();
@@ -18886,7 +20254,6 @@ var init_dist = __esm({
18886
20254
  getQuota,
18887
20255
  getLensFilter,
18888
20256
  getLensScoring,
18889
- listSectors,
18890
20257
  listLocations,
18891
20258
  getUserPrompt,
18892
20259
  getClarification,
@@ -18957,6 +20324,11 @@ var init_dist = __esm({
18957
20324
  // it for discoverability; expose it always-on so agents can find custom fields
18958
20325
  // without needing LEADBAY_MCP_ADVANCED=1.
18959
20326
  listMappableFields,
20327
+ // listSectors is granular-shaped but ALWAYS exposed: it's the sector taxonomy
20328
+ // lookup the agent needs to STOP guessing sector names (and to feed
20329
+ // leadbay_new_lens / leadbay_adjust_audience). Without it the agent can only
20330
+ // probe sectors by trial-and-error or ask the user to read the web UI.
20331
+ listSectors,
18960
20332
  // Billing / top-up tools — granular-shaped but ALWAYS exposed because
18961
20333
  // they're the canonical recovery path from a QUOTA_EXCEEDED wall. If
18962
20334
  // they were gated behind LEADBAY_MCP_ADVANCED=1 the agent would
@@ -18971,7 +20343,13 @@ var init_dist = __esm({
18971
20343
  // didn't deliver"). Does not mutate Leadbay state; emits a PostHog
18972
20344
  // event only. Companion to leadbay_report_outreach (which DOES write
18973
20345
  // to the backend and stays gated behind LEADBAY_MCP_WRITE).
18974
- reportFriction
20346
+ reportFriction,
20347
+ // Notification ack — ALWAYS exposed even though it POSTs to /seen.
20348
+ // _meta.notifications surfaces terminal bulk-progress notifications on
20349
+ // every tool response regardless of write gating; without ack the agent
20350
+ // sees the same entries on every call forever. Pairing the surfacing
20351
+ // channel with the clearing tool is non-optional.
20352
+ acknowledgeNotification
18975
20353
  ];
18976
20354
  compositeWriteTools = [
18977
20355
  bulkQualifyLeads,
@@ -18999,7 +20377,16 @@ var init_dist = __esm({
18999
20377
  removeLeadsFromCampaign,
19000
20378
  // Lens extend — agent-driven on-demand fill (additive). Gated behind
19001
20379
  // LEADBAY_MCP_WRITE=1. Subject to per-org daily LENS_EXTRA_REFILL quota.
19002
- extendLens
20380
+ extendLens,
20381
+ // Lens list/switch — read-first (no args = pure list); a switchToLensId
20382
+ // changes the active lens. In compositeWriteTools because the switch path
20383
+ // mutates last_requested_lens, but it stays on the default surface
20384
+ // (write is on by default since 0.3.0).
20385
+ myLenses,
20386
+ // Lens creation — make a brand-new named lens with sectors/sizes in one
20387
+ // call. Default-surface so "create a lens called X for Y" works without
20388
+ // the advanced gate.
20389
+ newLens
19003
20390
  ];
19004
20391
  compositeTools = [
19005
20392
  ...compositeReadTools,
@@ -21310,6 +22697,22 @@ function buildServer(client, opts = {}) {
21310
22697
  });
21311
22698
  }
21312
22699
  };
22700
+ const maybeAttachNotifications = (result) => {
22701
+ const inbox = opts.notificationsInbox;
22702
+ if (!inbox) return;
22703
+ if (result === null || typeof result !== "object" || Array.isArray(result)) {
22704
+ return;
22705
+ }
22706
+ const entries = inbox.list();
22707
+ if (entries.length === 0) return;
22708
+ const envelope = result;
22709
+ const target = envelope.__markdown_envelope === true && envelope.structured !== null && typeof envelope.structured === "object" && !Array.isArray(envelope.structured) ? envelope.structured : envelope;
22710
+ const existingMeta = target._meta && typeof target._meta === "object" && !Array.isArray(target._meta) ? target._meta : {};
22711
+ target._meta = {
22712
+ ...existingMeta,
22713
+ notifications: entries
22714
+ };
22715
+ };
21313
22716
  const isLeadbayBusinessError = (err) => err != null && typeof err === "object" && err.error === true && typeof err.code === "string";
21314
22717
  const buildBusinessCtx = (toolName, envelope, triggered_by) => {
21315
22718
  const meta = envelope._meta ?? {};
@@ -21429,11 +22832,13 @@ function buildServer(client, opts = {}) {
21429
22832
  const result = await tool.execute(client, args, {
21430
22833
  logger: opts.logger,
21431
22834
  bulkTracker: opts.bulkTracker,
22835
+ notificationsInbox: opts.notificationsInbox,
21432
22836
  signal: extra.signal,
21433
22837
  progress,
21434
22838
  elicit
21435
22839
  });
21436
22840
  maybeAttachUpdate(name, result);
22841
+ maybeAttachNotifications(result);
21437
22842
  if (result && typeof result === "object" && result.error === true) {
21438
22843
  const envText = formatErrorForLLM(result);
21439
22844
  const envDur = Date.now() - callStart;
@@ -22871,7 +24276,7 @@ var OAUTH_BASE_URLS = {
22871
24276
  fr: "https://staging.api.leadbay.app"
22872
24277
  }
22873
24278
  };
22874
- var VERSION = "0.17.2";
24279
+ var VERSION = "0.18.0";
22875
24280
  var HELP = `
22876
24281
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
22877
24282
 
@@ -23968,21 +25373,41 @@ async function main() {
23968
25373
  logger.warn?.(`update_check.unexpected ${err?.message ?? err}`);
23969
25374
  });
23970
25375
  }
25376
+ const notificationsInbox = new NotificationsInbox();
25377
+ let notificationsWs = null;
25378
+ const WS_DISABLED = process.env.LEADBAY_NOTIFICATIONS_WS_DISABLED === "1" || authState !== "ok";
25379
+ if (!WS_DISABLED) {
25380
+ notificationsWs = new NotificationsWsClient({
25381
+ client,
25382
+ inbox: notificationsInbox,
25383
+ logger
25384
+ });
25385
+ void notificationsWs.start().catch((err) => {
25386
+ logger.warn?.(
25387
+ `notifications.ws start_failed: ${err?.message ?? err}`
25388
+ );
25389
+ });
25390
+ }
23971
25391
  const server = buildServer(client, {
23972
25392
  includeAdvanced,
23973
25393
  includeWrite,
23974
25394
  logger,
23975
25395
  bulkTracker,
25396
+ notificationsInbox,
23976
25397
  version: VERSION,
23977
25398
  telemetry,
23978
25399
  updateStateStore
23979
25400
  });
23980
25401
  const transport = new StdioServerTransport();
23981
25402
  logger.info?.(
23982
- `Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl}, bulk_store=${bulkTracker.durability}, auth_state=${authState})`
25403
+ `Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl}, bulk_store=${bulkTracker.durability}, notifications_ws=${WS_DISABLED ? "disabled" : "enabled"}, auth_state=${authState})`
23983
25404
  );
23984
25405
  await server.connect(transport);
23985
25406
  const shutdown = async (code) => {
25407
+ try {
25408
+ notificationsWs?.stop();
25409
+ } catch {
25410
+ }
23986
25411
  try {
23987
25412
  await telemetry.shutdown();
23988
25413
  } finally {