@leadbay/mcp 0.17.3 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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) {
@@ -5086,6 +5103,7 @@ var init_composite_file_names = __esm({
5086
5103
  "../core/dist/composite/_composite-file-names.js"() {
5087
5104
  "use strict";
5088
5105
  COMPOSITE_FILE_TOOL_NAMES = /* @__PURE__ */ new Set([
5106
+ "leadbay_account_history",
5089
5107
  "leadbay_account_status",
5090
5108
  "leadbay_add_leads_to_campaign",
5091
5109
  "leadbay_adjust_audience",
@@ -5122,11 +5140,455 @@ var init_composite_file_names = __esm({
5122
5140
  }
5123
5141
  });
5124
5142
 
5143
+ // ../core/dist/notifications/revise-hint.js
5144
+ function reviseHintFor(kind) {
5145
+ switch (kind) {
5146
+ case "bulk_enrich":
5147
+ return HINT_BULK_ENRICH;
5148
+ case "bulk_qualify":
5149
+ return HINT_BULK_QUALIFY;
5150
+ case "import":
5151
+ return HINT_IMPORT;
5152
+ default:
5153
+ return HINT_OTHER;
5154
+ }
5155
+ }
5156
+ function inferKind(n) {
5157
+ if (n.links.some((l) => l.type === "bulk_enrichment"))
5158
+ return "bulk_enrich";
5159
+ if (n.file_import_id)
5160
+ return "import";
5161
+ if (n.bulk_progress)
5162
+ return "bulk_qualify";
5163
+ return "other";
5164
+ }
5165
+ function anchorIdFor(n, kind) {
5166
+ if (kind === "bulk_enrich") {
5167
+ const link = n.links.find((l) => l.type === "bulk_enrichment");
5168
+ return link ? String(link.id) : null;
5169
+ }
5170
+ if (kind === "import")
5171
+ return n.file_import_id;
5172
+ return null;
5173
+ }
5174
+ function toInboxEntry(n) {
5175
+ const kind = inferKind(n);
5176
+ return {
5177
+ notification_id: n.id,
5178
+ kind,
5179
+ anchor_id: anchorIdFor(n, kind),
5180
+ title: n.title,
5181
+ bulk_progress: n.bulk_progress,
5182
+ completed_at: n.updated_at,
5183
+ revise_hint: reviseHintFor(kind)
5184
+ };
5185
+ }
5186
+ var HINT_BULK_ENRICH, HINT_BULK_QUALIFY, HINT_IMPORT, HINT_OTHER;
5187
+ var init_revise_hint = __esm({
5188
+ "../core/dist/notifications/revise-hint.js"() {
5189
+ "use strict";
5190
+ 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.";
5191
+ 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.";
5192
+ 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.";
5193
+ HINT_OTHER = "Background work just completed. If you referenced its subject in prior output, re-fetch the affected data and revise.";
5194
+ }
5195
+ });
5196
+
5197
+ // ../core/dist/notifications/inbox.js
5198
+ var DEFAULT_TTL_MS, NotificationsInbox;
5199
+ var init_inbox = __esm({
5200
+ "../core/dist/notifications/inbox.js"() {
5201
+ "use strict";
5202
+ init_revise_hint();
5203
+ DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
5204
+ NotificationsInbox = class {
5205
+ entries = /* @__PURE__ */ new Map();
5206
+ ttl_ms;
5207
+ now;
5208
+ constructor(opts = {}) {
5209
+ this.ttl_ms = opts.ttl_ms ?? DEFAULT_TTL_MS;
5210
+ this.now = opts.now ?? Date.now;
5211
+ }
5212
+ // Upsert by notification id. Latest write wins so duplicate arrivals
5213
+ // (WS event + REST catch-up landing the same row) collapse cleanly.
5214
+ record(n) {
5215
+ if (!n.bulk_progress)
5216
+ return;
5217
+ if (n.in_progress)
5218
+ return;
5219
+ const entry = toInboxEntry(n);
5220
+ this.entries.set(entry.notification_id, {
5221
+ entry,
5222
+ recordedAt: this.now()
5223
+ });
5224
+ }
5225
+ list() {
5226
+ this.expireStale();
5227
+ return [...this.entries.values()].map((e) => e.entry);
5228
+ }
5229
+ markSeen(notification_id) {
5230
+ this.entries.delete(notification_id);
5231
+ }
5232
+ size() {
5233
+ this.expireStale();
5234
+ return this.entries.size;
5235
+ }
5236
+ expireStale() {
5237
+ const cutoff = this.now() - this.ttl_ms;
5238
+ for (const [id, e] of this.entries) {
5239
+ if (e.recordedAt < cutoff)
5240
+ this.entries.delete(id);
5241
+ }
5242
+ }
5243
+ };
5244
+ }
5245
+ });
5246
+
5247
+ // ../core/dist/notifications/catch-up.js
5248
+ async function catchUpNotifications(client, inbox, opts = {}) {
5249
+ const count = opts.count ?? DEFAULT_COUNT;
5250
+ let added = 0;
5251
+ try {
5252
+ const page = await client.listNotifications({
5253
+ archived: false,
5254
+ page: 0,
5255
+ count
5256
+ });
5257
+ for (const n of page.items) {
5258
+ if (!n.bulk_progress)
5259
+ continue;
5260
+ if (n.in_progress)
5261
+ continue;
5262
+ if (n.first_seen_at)
5263
+ continue;
5264
+ const sizeBefore = inbox.size();
5265
+ inbox.record(n);
5266
+ if (inbox.size() > sizeBefore)
5267
+ added += 1;
5268
+ }
5269
+ opts.logger?.info?.(`notifications.catch_up scanned=${page.items.length} seeded=${added}`);
5270
+ } catch (err) {
5271
+ opts.logger?.warn?.(`notifications.catch_up failed: ${err?.message ?? err?.code ?? err}`);
5272
+ }
5273
+ return added;
5274
+ }
5275
+ var DEFAULT_COUNT;
5276
+ var init_catch_up = __esm({
5277
+ "../core/dist/notifications/catch-up.js"() {
5278
+ "use strict";
5279
+ DEFAULT_COUNT = 50;
5280
+ }
5281
+ });
5282
+
5283
+ // ../core/dist/notifications/ws-client.js
5284
+ var PING_INTERVAL_MS, RECONNECT_INITIAL_MS, RECONNECT_MAX_MS, NotificationsWsClient;
5285
+ var init_ws_client = __esm({
5286
+ "../core/dist/notifications/ws-client.js"() {
5287
+ "use strict";
5288
+ init_catch_up();
5289
+ PING_INTERVAL_MS = 3e4;
5290
+ RECONNECT_INITIAL_MS = 1e3;
5291
+ RECONNECT_MAX_MS = 3e4;
5292
+ NotificationsWsClient = class {
5293
+ client;
5294
+ inbox;
5295
+ logger;
5296
+ ws = null;
5297
+ pingTimer = null;
5298
+ reconnectTimer = null;
5299
+ reconnectDelay = RECONNECT_INITIAL_MS;
5300
+ stopped = false;
5301
+ constructor(opts) {
5302
+ this.client = opts.client;
5303
+ this.inbox = opts.inbox;
5304
+ this.logger = opts.logger;
5305
+ }
5306
+ async start() {
5307
+ this.stopped = false;
5308
+ await catchUpNotifications(this.client, this.inbox, { logger: this.logger });
5309
+ void this.connect();
5310
+ }
5311
+ stop() {
5312
+ this.stopped = true;
5313
+ if (this.pingTimer)
5314
+ clearInterval(this.pingTimer);
5315
+ if (this.reconnectTimer)
5316
+ clearTimeout(this.reconnectTimer);
5317
+ this.pingTimer = null;
5318
+ this.reconnectTimer = null;
5319
+ if (this.ws) {
5320
+ try {
5321
+ this.ws.close(1e3, "shutdown");
5322
+ } catch {
5323
+ }
5324
+ this.ws = null;
5325
+ }
5326
+ }
5327
+ async connect() {
5328
+ if (this.stopped)
5329
+ return;
5330
+ let url;
5331
+ try {
5332
+ const ticket = await this.client.getWsTicket();
5333
+ url = ticket.url;
5334
+ } catch (err) {
5335
+ this.logger?.warn?.(`notifications.ws ticket_fetch_failed: ${err?.message ?? err?.code ?? err}`);
5336
+ this.scheduleReconnect();
5337
+ return;
5338
+ }
5339
+ let ws;
5340
+ try {
5341
+ ws = new WebSocket(url);
5342
+ } catch (err) {
5343
+ this.logger?.warn?.(`notifications.ws construct_failed: ${err?.message ?? err}`);
5344
+ this.scheduleReconnect();
5345
+ return;
5346
+ }
5347
+ this.ws = ws;
5348
+ ws.addEventListener("open", () => {
5349
+ this.logger?.info?.("notifications.ws connected");
5350
+ this.reconnectDelay = RECONNECT_INITIAL_MS;
5351
+ void catchUpNotifications(this.client, this.inbox, {
5352
+ logger: this.logger
5353
+ });
5354
+ this.pingTimer = setInterval(() => this.sendPing(), PING_INTERVAL_MS);
5355
+ });
5356
+ ws.addEventListener("message", (ev) => {
5357
+ const text = typeof ev.data === "string" ? ev.data : String(ev.data ?? "");
5358
+ if (!text)
5359
+ return;
5360
+ let msg;
5361
+ try {
5362
+ msg = JSON.parse(text);
5363
+ } catch {
5364
+ this.logger?.warn?.("notifications.ws non_json_frame");
5365
+ return;
5366
+ }
5367
+ this.handleMessage(msg);
5368
+ });
5369
+ ws.addEventListener("error", (ev) => {
5370
+ this.logger?.warn?.(`notifications.ws error: ${ev?.message ?? "(no detail)"}`);
5371
+ });
5372
+ ws.addEventListener("close", (ev) => {
5373
+ this.logger?.info?.(`notifications.ws closed code=${ev.code} reason=${ev.reason || "(none)"}`);
5374
+ if (this.pingTimer)
5375
+ clearInterval(this.pingTimer);
5376
+ this.pingTimer = null;
5377
+ this.ws = null;
5378
+ this.scheduleReconnect();
5379
+ });
5380
+ }
5381
+ handleMessage(msg) {
5382
+ if (msg.type === "pong")
5383
+ return;
5384
+ if (msg.type === "ping") {
5385
+ this.sendRaw({ type: "pong" });
5386
+ return;
5387
+ }
5388
+ if (msg.type !== "notification")
5389
+ return;
5390
+ const { type: _t, ...rest } = msg;
5391
+ void _t;
5392
+ const n = rest;
5393
+ if (n.bulk_progress == null || n.in_progress) {
5394
+ return;
5395
+ }
5396
+ this.inbox.record(n);
5397
+ 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"}`);
5398
+ }
5399
+ sendPing() {
5400
+ this.sendRaw({ type: "ping" });
5401
+ }
5402
+ sendRaw(obj) {
5403
+ if (!this.ws)
5404
+ return;
5405
+ if (this.ws.readyState !== WebSocket.OPEN)
5406
+ return;
5407
+ try {
5408
+ this.ws.send(JSON.stringify(obj));
5409
+ } catch (err) {
5410
+ this.logger?.warn?.(`notifications.ws send_failed: ${err?.message ?? err}`);
5411
+ }
5412
+ }
5413
+ scheduleReconnect() {
5414
+ if (this.stopped)
5415
+ return;
5416
+ if (this.reconnectTimer)
5417
+ return;
5418
+ const delay = this.reconnectDelay;
5419
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
5420
+ this.reconnectTimer = setTimeout(() => {
5421
+ this.reconnectTimer = null;
5422
+ void this.connect();
5423
+ }, delay);
5424
+ this.logger?.info?.(`notifications.ws reconnect_in_${delay}ms`);
5425
+ }
5426
+ };
5427
+ }
5428
+ });
5429
+
5430
+ // ../core/dist/notifications/index.js
5431
+ var init_notifications = __esm({
5432
+ "../core/dist/notifications/index.js"() {
5433
+ "use strict";
5434
+ init_inbox();
5435
+ init_ws_client();
5436
+ init_catch_up();
5437
+ init_revise_hint();
5438
+ }
5439
+ });
5440
+
5125
5441
  // ../core/dist/tool-descriptions.generated.js
5126
- 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_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;
5442
+ var leadbay_account_history, 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;
5127
5443
  var init_tool_descriptions_generated = __esm({
5128
5444
  "../core/dist/tool-descriptions.generated.js"() {
5129
5445
  "use strict";
5446
+ leadbay_account_history = `## WHEN TO USE
5447
+
5448
+ Trigger phrases: "what's the history on this account", "why should I revisit this account", "summarize everything we've done with <Company>", "has this account gone cold", "give me the back-story on lead <UUID>".
5449
+
5450
+ **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
5451
+
5452
+ Do NOT use for: "live signals only, no history" \u2192 \`leadbay_research_lead_by_id\`; "which accounts should I follow up with" \u2192 \`leadbay_pull_followups\`.
5453
+
5454
+ Prefer when: user wants ONE account's full back-story \u2014 notes + past activity + current signals together; pass \`leadId\`
5455
+
5456
+ Examples that SHOULD invoke this tool:
5457
+ - "What's the full history on this account \u2014 why did it resurface?"
5458
+ - "Summarize everything we've logged on that lead and whether it's worth another visit."
5459
+
5460
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
5461
+ - "Tell me the current AI take on this lead."
5462
+ - "Which accounts should I follow up with this week?"
5463
+
5464
+ ## RENDER (quick)
5465
+
5466
+ Single resurfaced-account card. Lead the card with the current signal/trigger
5467
+ line (from \`signals\`), then a "History" section: notes digest
5468
+ (chronological) + the activity timeline. Close with a one-line "why revisit
5469
+ now" synthesis and a suggested outreach angle tied to the freshest signal.
5470
+
5471
+ ---
5472
+
5473
+ Give me one account's full back-story in a single call. This is the tool for
5474
+ the **reprioritize-a-neglected-account** workflow: the user has an account
5475
+ that was contacted or quoted long ago and wants to know, in one shot, whether
5476
+ a fresh signal makes it worth another visit.
5477
+
5478
+ It bundles three reads on one \`leadId\`:
5479
+
5480
+ 1. **Current state** \u2014 passed through verbatim from \`leadbay_research_lead_by_id\`:
5481
+ \`signals\` (web-research signals with hot flags + sources), \`firmographics\`,
5482
+ \`qualification\` answers, \`contacts\`, and \`engagement\` counts. This is the
5483
+ "why is this account hot NOW" layer.
5484
+ 2. **\`notes\`** \u2014 the FULL list of notes logged on the record (note body +
5485
+ \`created_at\`), chronological. \`leadbay_research_lead_by_id\` only returns a
5486
+ \`notes_count\`; this tool returns the bodies so you can summarize the
5487
+ historical context.
5488
+ 3. **\`activities\`** \u2014 the interaction timeline (\`{type, date}\` entries, newest
5489
+ first) plus the total count. Drives the "no contact in N months" judgement.
5490
+
5491
+ \`notes\` and \`activities\` **degrade gracefully**: if either read fails the card
5492
+ still returns with that section empty (\`notes: []\` /
5493
+ \`activities.total: 0\`). The current-state block is load-bearing \u2014 if research
5494
+ itself errors the whole call fails, because there is nothing to narrate.
5495
+
5496
+ Params: \`leadId\` (required UUID) and \`activityCount\` (optional, default 50,
5497
+ max 100).
5498
+
5499
+ Companion tools: **leadbay_research_lead_by_id** when the user only wants the
5500
+ live AI take with no history; **leadbay_pull_followups** when the user wants a
5501
+ LIST of accounts to act on rather than one account's deep history.
5502
+
5503
+ ## RENDERING \u2014 single-record research card, mode-adaptive
5504
+
5505
+ Present as a single-record card, not a table. This tool gets invoked in two distinct user contexts \u2014 detect which and adapt the body density accordingly.
5506
+
5507
+ **MODE A \u2014 Discovery.** The user is evaluating whether to pursue this company as a target. Signals: "tell me about", "what do they do", "is this a fit", "research [company]", arrival via a click-through from \`leadbay_pull_leads\`, no prior outreach context in the conversation. Next step is usually qualify, deep-dive via \`leadbay_research_lead_by_id\`, or decide whether to start outreach.
5508
+
5509
+ **MODE B \u2014 Contact preparation.** The user is about to call or email someone at this company and needs the talking points. Signals: "I'm calling them", "draft an email", "before my call", "outreach prep", "what should I say", or the conversation has already touched on a specific contact. Next step is usually \`leadbay_prepare_outreach\`.
5510
+
5511
+ Default to MODE A when uncertain. Always offer the cross-mode pivot at the end so the user can redirect if you guessed wrong.
5512
+
5513
+ ### Common structure (both modes)
5514
+
5515
+ - **Header** (H4 or H5): \`<10-segment score bar>\` \`[Company name](website)\`. Use the score-bar algorithm; the bar lives in a single inline-code span. Prefix \`https://\` to website if it's a bare hostname.
5516
+ - **Pill row** (immediately below the header): short location \xB7 compact size \xB7 social pill chips iterated over \`social_urls\` (each non-null platform becomes \`[<platform-label>](<url>)\`) \xB7 \`[website-domain](website)\` \xB7 \`\u260E phone\` when \`phone_numbers[]\` is non-empty (use the first number). All \` \xB7 \`-separated.
5517
+ - **Blurb**: render \`description\` (preferred) or \`short_description\` as a single blockquoted paragraph.
5518
+ - **Staleness line**: italic, \`"Researched <relative time>"\` from \`web_insights_fetched_at\`. Use \`"today"\` / \`"yesterday"\` / \`"N days ago"\` up to 30 days, then absolute date. Prefix with \`\u26A0\` if older than 30 days.
5519
+ - **Contacts table** (always at the bottom):
5520
+ \`\`\`
5521
+ | | Name | Title | LinkedIn |
5522
+ \`\`\`
5523
+ Markers in column 1:
5524
+ - \`\u2605\` \u2014 \`recommended_contact\` match.
5525
+ - \`\u{1F48E}\` \u2014 name fuzzy-matches a \`hot: true\` entry in \`web_insights\` key_people. (Use \`\u{1F48E}\`, not \`\u{1F525}\`, to avoid glyph collision with the follow-up status badge.)
5526
+ Sort \`\u2605\` first, then \`\u{1F48E}\`-only rows, then API order. Link the name via \`linkedin_page\` first; fall back to LinkedIn people-search with \`<First>+<Last>+<Company>\`. Append \`\xB0\` only when the fallback is in use AND \`social_presence.linkedin == false\`. Cap to 6 rows; if \`contacts_count > shown\`, end with \`"+N more \u2014 ask to see the full list"\`.
5527
+
5528
+ ### MODE A body (Discovery, fuller, scannable)
5529
+
5530
+ Render each non-empty \`web_insights\` section as H5 with the emoji + label intact. Section order: \`\u{1F3E2} company profile\` \u2192 \`\u{1F4C8} business signals\` \u2192 \`\u{1F4A1} prospecting clues\` \u2192 \`\u{1F9E9} strategic positioning\` \u2192 \`\u{1F50E} technologies & innovation\`. Inside each, bullet 3\u20135 items. Sort \`hot: true\` items first. **Bold** the description text of hot items; leave cold items plain. Render \`source\` as \`[source](url)\` at the end; include \`date\` when present. Omit empty sections. Skip \`\u{1F517} social links\` (already in the pill row) and \`\u{1F464} key people\` (already in the contacts table).
5531
+
5532
+ ### MODE B body (Contact preparation, tighter)
5533
+
5534
+ Render exactly two H5 sections:
5535
+
5536
+ ##### \u{1F3AF} Conversation hooks
5537
+
5538
+ Distill the 3 most recent / most hot signals from \`\u{1F4C8} business signals\` and \`\u{1F4A1} prospecting clues\` into one-sentence talking points in salesperson voice. Strip the academic framing. Cite the source inline.
5539
+
5540
+ ##### \u{1F464} About the person *(only when recommended_contact is non-empty)*
5541
+
5542
+ 2-line summary: their title + any context from \`web_insights\` key_people. If they appear in a hot signal ("X appointed CEO"), surface that prominently.
5543
+
5544
+ Skip \u{1F3E2} profile, \u{1F9E9} strategic positioning, \u{1F50E} technologies in MODE B \u2014 context the user doesn't need for the next 30 seconds.
5545
+
5546
+ If \`qualification[]\` is non-empty, append one collapsed line: \`"Qualification: N questions answered, avg boost X"\` and offer to expand in NEXT STEPS.
5547
+
5548
+ **Hide:** \`id\`, \`lead.id\`, \`contact.id\`, \`lead.location.pos\`, \`web_fetch_in_progress\`, \`enrichment_in_progress\`, \`recommended_contact_title\` (duplicates \`recommended_contact.job_title\`), empty arrays, fields whose value is the string \`"null"\`, \`contact.source\` (internal), insights whose \`source\` is empty.
5549
+
5550
+ **Legend (print once below the card):** \`\` \`\u25B0\` firmographic \xB7 \`\u2756\` AI booster \xB7 \`\u25B1\` unfilled \xB7 \u2605 recommended \xB7 \u{1F48E} hot in web_insights \xB7 \xB0 = no company LinkedIn (fallback link only) \`\`
5551
+
5552
+ ## Linking a contact's name
5553
+
5554
+ **MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
5555
+
5556
+ URL priority (first applicable wins):
5557
+
5558
+ 1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
5559
+ 2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
5560
+
5561
+ Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
5562
+
5563
+ ## Linking the company
5564
+
5565
+ Use the lead's \`website\` as the company-name link target \u2014 prefix \`https://\` if the value is a bare hostname. (The MCP does NOT synthesize a Leadbay-app deep-link URL; the team has not standardized one. Linking to \`website\` is always real data.)
5566
+
5567
+ When the response carries \`social_urls\` (the post-fix multi-platform URL block on rich-lead responses), render every non-null platform as a pill chip in the company-info row. Iterate over \`social_urls\`'s keys \u2014 never hardcode a fixed list \u2014 and emit each as \`[<platform-label>](<url>)\`. Skip platforms whose URL is null.
5568
+
5569
+ \`social_presence\` carries booleans for the same 6 platforms (crunchbase, facebook, instagram, linkedin, tiktok, twitter) \u2014 useful when you only care that the company has a profile somewhere. Use it as the \xB0-flag signal in the contact people-search fallback (see linking/contact-linkedin).
5570
+
5571
+
5572
+
5573
+ ### RENDERING \u2014 the history layer (on top of the card above)
5574
+
5575
+ After the research card, add a **History** section so the user sees why this
5576
+ account resurfaced:
5577
+
5578
+ - **##### \u{1F5D2} Notes** \u2014 render \`notes\` chronologically (oldest \u2192 newest). Each
5579
+ as a bullet: \`**<relative date from created_at>** \u2014 <note body>\`. Cap at 8;
5580
+ if \`_meta.notes_count > shown\`, end with \`"+N more notes"\`. Omit the section
5581
+ entirely when \`notes\` is empty.
5582
+ - **##### \u{1F553} Timeline** \u2014 render \`activities.activities\` newest-first as a
5583
+ compact bullet list: \`<relative date> \xB7 <type>\`. Cap at 10; if
5584
+ \`activities.total > shown\`, end with \`"+N earlier"\`. Omit when empty.
5585
+ - **##### \u21BB Why revisit now** \u2014 one or two sentences synthesizing the freshest
5586
+ HOT signal from \`signals\` against the gap in \`activities\` (e.g. "Won a public
5587
+ tender last month; no logged contact since the 2024 quote \u2014 strong re-open
5588
+ angle"). Then one suggested outreach angle tied to that signal. This
5589
+ synthesis is the payload of the whole tool \u2014 always include it when there is
5590
+ at least one hot signal.
5591
+ `;
5130
5592
  leadbay_account_status = `## WHEN TO USE
5131
5593
 
5132
5594
  Trigger phrases: "what's my account status", "how much quota do I have", "what lens am I on", "I topped up / I bought credits / I added credits".
@@ -5147,10 +5609,13 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
5147
5609
 
5148
5610
  ## RENDER (quick)
5149
5611
 
5150
- Compact markdown: org name + admin badge on line 1; active lens on
5151
- line 2; per-window quota usage as \`(used / cap)\` chips for
5152
- llm_completion \xB7 ai_rescore \xB7 web_fetch. Surface regeneration flag
5153
- prominently if mid-regen.
5612
+ If \`quota_error\` is set the call FAILED \u2014 quota unreadable; on 401/403 tell
5613
+ the user to reconnect. NEVER report zero usage or "no limits". Else render
5614
+ \`quota.org.resources\` (usage lives there, NOT at quota.resources) as a
5615
+ table, never prose: rows = resources (llm_completion \xB7 ai_rescore \xB7
5616
+ web_fetch + others), cols = Daily/Weekly/Monthly used \`count\` (= amount
5617
+ USED; no cap field, \`plan\` may be null \u2014 never invent a denominator).
5618
+ Empty = a 0 table, not "unlimited". Above: org + admin, lens.
5154
5619
 
5155
5620
  ---
5156
5621
 
@@ -5162,9 +5627,52 @@ Show the user's account state \u2014 admin rights, language, last-active lens, c
5162
5627
 
5163
5628
  **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.**
5164
5629
 
5630
+ **\`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.
5631
+
5632
+ ## GATE \u2014 INSPECT \`_meta.notifications\` ON EVERY RESPONSE
5633
+
5634
+ 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:
5635
+
5636
+ | Kind | What just finished |
5637
+ |---|---|
5638
+ | \`bulk_enrich\` | A bulk contact-enrichment job (emails / phone numbers attached to leads' contacts). |
5639
+ | \`bulk_qualify\` | A bulk lead-qualification job (web-fetch + AI rescore, producing \`ai_agent_lead_score\` and qualification answers). |
5640
+ | \`import\` | A CSV / CRM file import (rows resolved to leads in the user's pipeline). |
5641
+
5642
+ **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.
5643
+
5644
+ | Kind | Outputs you've produced that may now be stale \u2014 refresh them |
5645
+ |---|---|
5646
+ | \`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. |
5647
+ | \`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\`. |
5648
+ | \`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\`. |
5649
+
5650
+ **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.
5651
+
5652
+ **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.
5653
+
5654
+ **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.
5655
+
5656
+ Also surfaced as a top-level \`notifications\` array on \`leadbay_account_status\` \u2014 same shape, same handling.
5657
+
5658
+
5165
5659
  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.
5166
5660
 
5167
5661
  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.
5662
+ `;
5663
+ 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.
5664
+
5665
+ **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.
5666
+
5667
+ 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.
5668
+
5669
+ 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.
5670
+
5671
+ 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.
5672
+
5673
+ WHEN NOT TO USE: before doing the revision work; for general "mark all read" sweeps (call once per notification id you've actually consumed).
5674
+
5675
+ 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\`.
5168
5676
  `;
5169
5677
  leadbay_add_leads_to_campaign = `## WHEN TO USE
5170
5678
 
@@ -5294,6 +5802,10 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
5294
5802
  WHEN TO USE: poll this after leadbay_enrich_titles returns a \`bulk_id\`. Default \`include_contacts=false\` for cheap status polls; set \`include_contacts=true\` once \`all_done\` flips for the final read.
5295
5803
 
5296
5804
  WHEN NOT TO USE: as a substitute for leadbay_research_lead_by_id \u2014 that already includes enriched contacts for a single lead.
5805
+
5806
+ ## CREDIT COST \u2014 show the balance, discreetly
5807
+
5808
+ Once \`all_done\` is true the result carries \`credits_remaining\` (the post-spend AI-credit balance). Don't make a fuss \u2014 no sentence, no callout. Just append ONE small italic line in parentheses at the very END of your reply: \`_(N credits remaining)_\`. If \`credits_remaining\` is null (billing unavailable), omit the line \u2014 don't print 0. Do NOT report a "credits used" figure for this run: the per-contact cost can't be scoped to this specific enrichment (a lead's contact list mixes in earlier runs), so any "X used" number would be misleading.
5297
5809
  `;
5298
5810
  leadbay_bulk_qualify_leads = `Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch). Pass \`wait_for_completion:false\` to return quickly with \`{status:'running', qualify_id}\`; poll leadbay_qualify_status with that id. With \`wait_for_completion\` omitted/true, the legacy behavior polls until the answers are populated or a budget is exhausted. Already-qualified leads (those with a non-null \`ai_agent_lead_score\`) are silently no-ops on the backend, so this composite paginates past them to find fresh candidates. On 429 mid-fanout, stops launching but keeps polling already-launched leads.
5299
5811
 
@@ -5758,6 +6270,10 @@ WHEN TO USE: when you have a specific \`contact_id\` (from leadbay_get_contacts)
5758
6270
 
5759
6271
  WHEN NOT TO USE: for bulk enrichment by job title across many leads \u2014 use leadbay_enrich_titles, which handles the selection lifecycle and returns a clean preview/launch flow.
5760
6272
 
6273
+ ## CREDIT COST \u2014 discreet
6274
+
6275
+ This is a paid call. The result returns \`credits_remaining\` (billing.ai_credits, read before the spend). Don't make a fuss about credits: only flag the balance if it's low (e.g. \u2264 a few credits) so the user can decide. Otherwise append it quietly as a small italic parenthetical at the END of your reply \u2014 \`_(N credits remaining)_\`. Don't quote an exact per-contact cost (the rate is backend-only). The actual per-contact cost (enrichment.credits_used) appears on the contact via leadbay_get_contacts after enrichment. If \`credits_remaining\` is null, omit the line \u2014 don't assume zero.
6276
+
5761
6277
  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\`.
5762
6278
  `;
5763
6279
  leadbay_enrich_titles = `Order contact enrichments by job title across many leads. Contacts are NOT returned by default with a lead (Leadbay keeps enrichment out-of-band to control cost); the agent requests them on demand via this tool when it's ready to actually reach out. Two modes: (A) NO \`titles\` param \u2014 returns the available titles + Leadbay's \`title_suggestions\` + \`auto_included_titles\` + a count of enrichable contacts, so the agent can ask the user which titles to enrich. (B) \`titles\` given \u2014 calls preview, then launches if there's anything enrichable. On 429 returns \`{status:'quota_exceeded'}\` cleanly. Selection lifecycle is wrapped in a try/finally so the user's selection is left clean even on error.
@@ -5766,6 +6282,35 @@ WHEN TO USE: as the agent's go-to enrichment entry point, immediately before pro
5766
6282
 
5767
6283
  WHEN NOT TO USE: to enrich a single contact \u2014 that's leadbay_enrich_contacts (granular). Speculatively, before the user has committed to outreaching \u2014 enrichment spends credits.
5768
6284
 
6285
+ ## CREDIT COST \u2014 make spend visible
6286
+
6287
+ Enrichment is the main PAID operation. Surface cost both before and after.
6288
+
6289
+ **BEFORE (confirm before launching).** The discover / preview_only / dry_run modes return \`credits_remaining\` (the balance) and \`enrichable_contacts\` (the volume that would be enriched). Tell the user plainly: **"You have {credits_remaining} credits. This will enrich {enrichable_contacts} contacts."** then ask them to confirm before you launch the paid run. Route that confirmation through \`ask_user_input_v0\` ("Enrich {enrichable_contacts} contacts now?" \u2192 ["Yes, enrich", "No, cancel"]). Do NOT state an exact estimated cost \u2014 the per-contact credit rate lives backend-side and is not in the preview; show the balance and the count, never a fabricated "will cost N credits". If \`credits_remaining\` is null, billing is unavailable \u2014 say the balance is unknown, don't assume zero or unlimited.
6290
+
6291
+ **AFTER (show the balance, discreetly).** Once the job finishes \u2014 poll \`leadbay_bulk_enrich_status\`, which returns \`credits_remaining\` (the post-spend balance). Don't make a fuss: append ONE small italic line in parentheses at the very END of your reply \u2014 \`_(N credits remaining)_\`. Omit it if \`credits_remaining\` is null. Do NOT report a "credits used" figure: per-run cost can't be scoped reliably (a lead's contacts mix earlier enrichments), so only the balance is shown.
6292
+
6293
+ ## GATE \u2014 PREFER BUILT-IN HOST WIDGETS
6294
+
6295
+ 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.
6296
+
6297
+ **The Big Three** \u2014 when a tool result fits, route there:
6298
+
6299
+ | Host widget | Use when | Field map (from Leadbay payload) |
6300
+ |---|---|---|
6301
+ | \`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 |
6302
+ | \`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") |
6303
+ | \`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 |
6304
+
6305
+ 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.
6306
+
6307
+ **Rules:**
6308
+ - 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.
6309
+ - Pass identifiers (place_id, lead.id, contact_id) verbatim. Don't rewrite.
6310
+ - 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.
6311
+ - One short intro sentence in chat is enough \u2014 "Here are your 5 NYC follow-ups." Then route into the widget.
6312
+
6313
+
5769
6314
  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\`.
5770
6315
  `;
5771
6316
  leadbay_extend_lens = `## WHEN TO USE
@@ -8576,10 +9121,36 @@ var init_get_quota = __esm({
8576
9121
  outputSchema: {
8577
9122
  type: "object",
8578
9123
  properties: {
8579
- plan: { type: ["string", "null"], description: "Org plan tier (e.g., FREE, PRO)." },
9124
+ plan: {
9125
+ type: ["string", "null"],
9126
+ description: "Org plan tier (e.g., FREE, TIER1, TIER2). May be null."
9127
+ },
9128
+ org: {
9129
+ type: "object",
9130
+ description: "Org-level quota state.",
9131
+ properties: {
9132
+ spend: { type: "array", description: "Reserved; empty in practice.", items: { type: "object" } },
9133
+ resources: {
9134
+ type: "array",
9135
+ description: "Per-resource per-window USAGE. Each: {resource_type, count, window_type, resets_at}. `count` is the amount USED in that window (not remaining, not a cap). No cap field is returned by the API.",
9136
+ items: { type: "object" }
9137
+ }
9138
+ }
9139
+ },
9140
+ user: {
9141
+ type: "object",
9142
+ description: "User-level quota state, same shape as `org`. May be absent.",
9143
+ properties: {
9144
+ spend: { type: "array", items: { type: "object" } },
9145
+ resources: { type: "array", items: { type: "object" } }
9146
+ }
9147
+ },
9148
+ // Legacy/compat: the live API does NOT return a top-level `windows`
9149
+ // array — usage lives in org/user.resources[]. Declared only so older
9150
+ // recorded fixtures still conform; do not rely on it.
8580
9151
  windows: {
8581
9152
  type: "array",
8582
- description: "Per-resource per-window limits. Each: {resource, window, current_units, max_units, resets_at}.",
9153
+ description: "Deprecated \u2014 not returned by the live API. Use org/user.resources[].",
8583
9154
  items: { type: "object" }
8584
9155
  }
8585
9156
  }
@@ -10199,9 +10770,23 @@ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseB
10199
10770
  await pollPreprocess(client, importId, phaseBudget, ctx, signal);
10200
10771
  ctx?.logger?.info?.(`import-leads: preprocess done for importId=${importId}`);
10201
10772
  if (dryRun) {
10202
- return { importId, records: [] };
10773
+ return { importId, records: [], notification_id: null };
10774
+ }
10775
+ let updateMappingsResp = null;
10776
+ try {
10777
+ updateMappingsResp = await client.request("POST", `/imports/${importId}/update_mappings`, mappings);
10778
+ } catch (err) {
10779
+ if (err?.code === "API_ERROR" || err?.code === "NOT_FOUND") {
10780
+ ctx?.logger?.warn?.(`import-leads: update_mappings raw error (${err?.code}); retrying void`);
10781
+ await client.requestVoid("POST", `/imports/${importId}/update_mappings`, mappings);
10782
+ } else {
10783
+ throw err;
10784
+ }
10785
+ }
10786
+ const importNotificationId = updateMappingsResp?.notification_id ?? null;
10787
+ if (importNotificationId) {
10788
+ ctx?.logger?.info?.(`import-leads: notification_id=${importNotificationId} importId=${importId}`);
10203
10789
  }
10204
- await client.requestVoid("POST", `/imports/${importId}/update_mappings`, mappings);
10205
10790
  ctx?.logger?.info?.(`import-leads: mappings committed for importId=${importId}`);
10206
10791
  const phaseBudget2 = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
10207
10792
  await pollProcess(client, importId, phaseBudget2, ctx, signal);
@@ -10209,7 +10794,7 @@ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseB
10209
10794
  const phaseBudget3 = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
10210
10795
  const records = await pollRecordsToTerminal(client, importId, phaseBudget3, chunk.length, ctx, signal);
10211
10796
  ctx?.logger?.info?.(`import-leads: ${records.length} records terminal for importId=${importId}`);
10212
- return { importId, records };
10797
+ return { importId, records, notification_id: importNotificationId };
10213
10798
  }
10214
10799
  function reconcileOneChunk(prep, chunk, matched, notImported) {
10215
10800
  const seenInputIndex = /* @__PURE__ */ new Set();
@@ -10265,7 +10850,7 @@ function reconcileOneChunk(prep, chunk, matched, notImported) {
10265
10850
  }
10266
10851
  }
10267
10852
  }
10268
- function buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled) {
10853
+ function buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled, notificationIds) {
10269
10854
  const leads = [];
10270
10855
  const not_imported = [];
10271
10856
  if (dryRun) {
@@ -10330,6 +10915,7 @@ function buildImportLeadsResult(client, prep, importIds, matched, notImported, d
10330
10915
  leads,
10331
10916
  not_imported,
10332
10917
  importIds,
10918
+ notification_ids: notificationIds,
10333
10919
  region: client.region,
10334
10920
  cancelled: cancelled || void 0,
10335
10921
  dry_run: dryRun || void 0,
@@ -10355,17 +10941,21 @@ async function runImportInBackground(client, prep, uploadedChunks, opts, ctx, ha
10355
10941
  void (async () => {
10356
10942
  const bgCtx = { logger: ctx.logger, bulkTracker: tracker };
10357
10943
  const importIds = uploadedChunks.map((chunk) => chunk.importId);
10944
+ const notificationIds = [];
10358
10945
  const matched = /* @__PURE__ */ new Map();
10359
10946
  const notImported = /* @__PURE__ */ new Map();
10360
10947
  try {
10361
10948
  const totalDeadline = Date.now() + opts.totalBudget;
10362
10949
  for (const upload of uploadedChunks) {
10363
10950
  const out = await completeUploadedChunk(client, upload, prep.mappings, opts.dryRun, opts.perPhaseBudget, totalDeadline, bgCtx, void 0);
10951
+ if (out.notification_id && !notificationIds.includes(out.notification_id)) {
10952
+ notificationIds.push(out.notification_id);
10953
+ }
10364
10954
  if (!opts.dryRun) {
10365
10955
  reconcileOneChunk(prep, out, matched, notImported);
10366
10956
  }
10367
10957
  }
10368
- const result = buildImportLeadsResult(client, prep, importIds, matched, notImported, opts.dryRun, false);
10958
+ const result = buildImportLeadsResult(client, prep, importIds, matched, notImported, opts.dryRun, false, notificationIds);
10369
10959
  await tracker.markImportComplete(handleId, {
10370
10960
  leads: result.leads,
10371
10961
  not_imported: result.not_imported,
@@ -10598,6 +11188,7 @@ var init_import_leads = __esm({
10598
11188
  leads: [],
10599
11189
  not_imported,
10600
11190
  importIds: [],
11191
+ notification_ids: [],
10601
11192
  region: client.region,
10602
11193
  dry_run: dryRun || void 0,
10603
11194
  _meta: client.lastMeta ?? {
@@ -10652,6 +11243,10 @@ var init_import_leads = __esm({
10652
11243
  status: "running",
10653
11244
  handle_id: reservation.record.bulk_id,
10654
11245
  importIds: importIds2,
11246
+ // Notifications fire from update_mappings, which the background
11247
+ // task hasn't called yet at this point. They surface via the WS
11248
+ // listener / catch-up REST on subsequent agent turns.
11249
+ notification_ids: [],
10655
11250
  progress: {
10656
11251
  phase: reservation.record.status === "complete" ? "complete" : importIds2.length > 0 ? "preprocess" : "queued",
10657
11252
  records_processed: reservation.record.status === "complete" ? reservation.record.records_total : 0,
@@ -10672,6 +11267,7 @@ var init_import_leads = __esm({
10672
11267
  }
10673
11268
  ctx?.logger?.info?.(`import-leads(${prep.mode}): ${prep.validInputs.length} rows \u2192 ${chunks.length} chunk(s); dry_run=${dryRun}, totalBudgetMs=${totalBudget}`);
10674
11269
  const importIds = [];
11270
+ const notificationIds = [];
10675
11271
  const matched = /* @__PURE__ */ new Map();
10676
11272
  const notImported = /* @__PURE__ */ new Map();
10677
11273
  let cancelled = false;
@@ -10683,6 +11279,9 @@ var init_import_leads = __esm({
10683
11279
  for (let i = 0; i < chunks.length; i++) {
10684
11280
  const chunk = chunks[i];
10685
11281
  const out = await runOneChunk(client, chunk, i, chunks.length, prep.header, prep.mappings, dryRun, perPhaseBudget, totalDeadline, ctx, signal, recordImportId);
11282
+ if (out.notification_id && !notificationIds.includes(out.notification_id)) {
11283
+ notificationIds.push(out.notification_id);
11284
+ }
10686
11285
  if (!dryRun) {
10687
11286
  reconcileOneChunk(prep, out, matched, notImported);
10688
11287
  }
@@ -10703,7 +11302,7 @@ var init_import_leads = __esm({
10703
11302
  throw err;
10704
11303
  }
10705
11304
  }
10706
- return buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled);
11305
+ return buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled, notificationIds);
10707
11306
  }
10708
11307
  };
10709
11308
  }
@@ -11411,6 +12010,70 @@ var init_agent_memory_review = __esm({
11411
12010
  }
11412
12011
  });
11413
12012
 
12013
+ // ../core/dist/tools/acknowledge-notification.js
12014
+ var UUID_RE, acknowledgeNotification;
12015
+ var init_acknowledge_notification = __esm({
12016
+ "../core/dist/tools/acknowledge-notification.js"() {
12017
+ "use strict";
12018
+ init_tool_descriptions_generated();
12019
+ UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12020
+ acknowledgeNotification = {
12021
+ name: "leadbay_acknowledge_notification",
12022
+ annotations: {
12023
+ title: "Acknowledge a Leadbay notification",
12024
+ readOnlyHint: false,
12025
+ destructiveHint: false,
12026
+ idempotentHint: true,
12027
+ openWorldHint: true
12028
+ },
12029
+ description: leadbay_acknowledge_notification,
12030
+ write: true,
12031
+ inputSchema: {
12032
+ type: "object",
12033
+ properties: {
12034
+ notification_id: {
12035
+ type: "string",
12036
+ description: "UUID of the notification to acknowledge. Use the notification_id from `_meta.notifications[]` or `account_status.notifications[]`."
12037
+ },
12038
+ archive: {
12039
+ type: "boolean",
12040
+ description: "If true, archive the notification (won't appear in `archived=false` listings). If false / omitted, mark seen (resets firstSeenAt)."
12041
+ }
12042
+ },
12043
+ required: ["notification_id"],
12044
+ additionalProperties: false
12045
+ },
12046
+ outputSchema: {
12047
+ type: "object",
12048
+ properties: {
12049
+ acknowledged: { type: "boolean" },
12050
+ notification_id: { type: "string" },
12051
+ action: { type: "string", enum: ["seen", "archive"] }
12052
+ },
12053
+ required: ["acknowledged", "notification_id", "action"]
12054
+ },
12055
+ execute: async (client, params, ctx) => {
12056
+ if (!UUID_RE.test(params.notification_id)) {
12057
+ return {
12058
+ error: true,
12059
+ code: "BAD_INPUT",
12060
+ message: "notification_id must be a UUID",
12061
+ hint: "Pass the notification_id verbatim from _meta.notifications[].notification_id or account_status.notifications[].notification_id."
12062
+ };
12063
+ }
12064
+ const action = params.archive ? "archive" : "seen";
12065
+ await client.acknowledgeNotification(params.notification_id, action);
12066
+ ctx?.notificationsInbox?.markSeen(params.notification_id);
12067
+ return {
12068
+ acknowledged: true,
12069
+ notification_id: params.notification_id,
12070
+ action
12071
+ };
12072
+ }
12073
+ };
12074
+ }
12075
+ });
12076
+
11414
12077
  // ../core/dist/tools/select-leads.js
11415
12078
  var selectLeads;
11416
12079
  var init_select_leads = __esm({
@@ -14578,6 +15241,86 @@ var init_research_lead_by_name_fuzzy = __esm({
14578
15241
  }
14579
15242
  });
14580
15243
 
15244
+ // ../core/dist/composite/account-history.js
15245
+ var accountHistory;
15246
+ var init_account_history = __esm({
15247
+ "../core/dist/composite/account-history.js"() {
15248
+ "use strict";
15249
+ init_research_lead_by_id();
15250
+ init_tool_descriptions_generated();
15251
+ accountHistory = {
15252
+ name: "leadbay_account_history",
15253
+ annotations: {
15254
+ title: "One account's full back-story",
15255
+ readOnlyHint: true,
15256
+ destructiveHint: false,
15257
+ idempotentHint: true,
15258
+ openWorldHint: true
15259
+ },
15260
+ description: leadbay_account_history,
15261
+ inputSchema: {
15262
+ type: "object",
15263
+ properties: {
15264
+ leadId: { type: "string", description: "Lead UUID (required)" },
15265
+ activityCount: {
15266
+ type: "number",
15267
+ description: "Number of activity-timeline entries to return, max 100 (default: 50)."
15268
+ },
15269
+ lensId: {
15270
+ type: "number",
15271
+ description: "Lens id the lead came from (escape hatch \u2014 normally omit; defaults to the active lens). Pass it when researching a lead from a lens other than the current default, so the underlying /lenses/{lensId}/leads/{leadId} fetch doesn't 404 after the active lens changed."
15272
+ }
15273
+ },
15274
+ required: ["leadId"],
15275
+ additionalProperties: false
15276
+ },
15277
+ execute: async (client, params, ctx) => {
15278
+ const leadId = params.leadId;
15279
+ const count = Math.max(1, Math.min(Math.floor(params.activityCount ?? 50), 100));
15280
+ const [research, notes, activities] = await Promise.all([
15281
+ researchLeadById.execute(client, { leadId, lensId: params.lensId, response_format: "json" }, ctx),
15282
+ client.request("GET", `/leads/${leadId}/notes`).catch(() => []),
15283
+ client.request("GET", `/leads/${leadId}/activities?count=${count}`).catch(() => ({ items: [], pagination: { total: 0 } }))
15284
+ ]);
15285
+ const r = research;
15286
+ const noteList = Array.isArray(notes) ? notes : [];
15287
+ const activityItems = Array.isArray(activities?.items) ? activities.items : [];
15288
+ return {
15289
+ lead: {
15290
+ id: r.firmographics?.id ?? leadId,
15291
+ name: r.firmographics?.name ?? null
15292
+ },
15293
+ // Current state — signals, firmographics, qualification, contacts,
15294
+ // engagement: passed through verbatim from research_lead_by_id so the
15295
+ // agent gets the live "why is this account hot NOW" picture.
15296
+ signals: r.signals ?? null,
15297
+ firmographics: r.firmographics ?? null,
15298
+ qualification: r.qualification ?? [],
15299
+ contacts: r.contacts ?? null,
15300
+ engagement: r.engagement ?? null,
15301
+ // Historical context — the part research only counts/summarizes.
15302
+ notes: noteList,
15303
+ activities: {
15304
+ activities: activityItems.map((a) => ({ type: a.type, date: a.date })),
15305
+ total: activities?.pagination?.total ?? 0
15306
+ },
15307
+ // Preserve research's pass-through metadata (agent_memory summary,
15308
+ // lens_id, web_fetch_in_progress, has_reachable_contact, …) — the
15309
+ // generated description advertises the memory protocol, so dropping
15310
+ // _meta.agent_memory would make history narratives miss stored
15311
+ // preferences. Spread research _meta first, then layer our counts.
15312
+ _meta: {
15313
+ ...r._meta ?? {},
15314
+ region: client.region,
15315
+ notes_count: noteList.length,
15316
+ activities_returned: activityItems.length
15317
+ }
15318
+ };
15319
+ }
15320
+ };
15321
+ }
15322
+ });
15323
+
14581
15324
  // ../core/dist/composite/recall-ordered-titles.js
14582
15325
  var recallOrderedTitles;
14583
15326
  var init_recall_ordered_titles = __esm({
@@ -14758,7 +15501,21 @@ var init_account_status = __esm({
14758
15501
  },
14759
15502
  quota: {
14760
15503
  type: ["object", "null"],
14761
- 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."
15504
+ description: "Per-resource quota state (llm_completion, ai_rescore, web_fetch, LENS_EXTRA_REFILL) across daily/weekly/monthly windows. Null if /quota_status failed (see quota_error) or genuinely returned nothing. Pre-check the LENS_EXTRA_REFILL entry before calling leadbay_extend_lens."
15505
+ },
15506
+ quota_error: {
15507
+ type: ["object", "null"],
15508
+ description: "Non-null ONLY when the quota_status call FAILED \u2014 {code, http_status, message}. A 401/403 means the token lacks quota scope: tell the user to reconnect / re-run OAuth. Treat as 'quota unreadable', NEVER as zero usage or 'no limits'.",
15509
+ properties: {
15510
+ code: { type: "string" },
15511
+ http_status: { type: ["number", "null"] },
15512
+ message: { type: "string" }
15513
+ }
15514
+ },
15515
+ notifications: {
15516
+ type: "array",
15517
+ 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.",
15518
+ items: { type: "object" }
14762
15519
  },
14763
15520
  _meta: {
14764
15521
  type: "object",
@@ -14794,9 +15551,15 @@ var init_account_status = __esm({
14794
15551
  execute: async (client, _params, ctx) => {
14795
15552
  const me = await client.resolveMe();
14796
15553
  let quota = null;
15554
+ let quota_error = null;
14797
15555
  try {
14798
15556
  quota = await client.request("GET", `/organizations/${me.organization.id}/quota_status`);
14799
15557
  } catch (err) {
15558
+ quota_error = {
15559
+ code: err?.code ?? "QUOTA_STATUS_FAILED",
15560
+ http_status: err?._meta?.http_status ?? null,
15561
+ message: err?.message ?? "quota_status request failed"
15562
+ };
14800
15563
  ctx?.logger?.warn?.(`account_status: quota_status failed: ${err?.message ?? err?.code ?? err}`);
14801
15564
  }
14802
15565
  return withAgentMemoryMeta(client, {
@@ -14819,6 +15582,16 @@ var init_account_status = __esm({
14819
15582
  // on /me are intentionally NOT surfaced — they're defunct (see
14820
15583
  // SHAPE-DRIFT.md probe round 4).
14821
15584
  quota,
15585
+ // Inbox of terminal bulk-progress notifications. Same shape the MCP
15586
+ // server attaches to `_meta.notifications` on every tool response —
15587
+ // duplicated here as a top-level field so the agent's daily-rhythm
15588
+ // check-in (this composite) sees them without having to read _meta.
15589
+ // Empty array when the WS listener isn't wired (OpenClaw, tests) OR
15590
+ // when nothing has completed since the last ack.
15591
+ notifications: ctx?.notificationsInbox?.list() ?? [],
15592
+ // Non-null ONLY when the quota_status call failed. The agent must treat
15593
+ // this as "could not read quota" (reauth on 401/403) — NOT as zero usage.
15594
+ quota_error,
14822
15595
  _meta: {
14823
15596
  region: client.region
14824
15597
  }
@@ -14829,13 +15602,40 @@ var init_account_status = __esm({
14829
15602
  });
14830
15603
 
14831
15604
  // ../core/dist/composite/bulk-qualify-leads.js
14832
- var PAGE_SIZE, DEFAULT_COUNT, MAX_COUNT, DEFAULT_PER_LEAD_BUDGET_MS, DEFAULT_TOTAL_BUDGET_MS2, bulkQualifyLeads;
15605
+ async function launchBulkQualify(client, leadIds, ctx) {
15606
+ await client.acquireSelectionLock();
15607
+ try {
15608
+ try {
15609
+ const qs = leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
15610
+ await client.requestVoid("POST", `/leads/selection/select?${qs}`);
15611
+ try {
15612
+ const resp = await client.request("POST", "/leads/selection/web_fetch?force_fetch=false", {});
15613
+ return { resp, quotaExceeded: false };
15614
+ } catch (err) {
15615
+ if (err?.code === "QUOTA_EXCEEDED") {
15616
+ ctx?.logger?.warn?.("bulk_qualify_leads: 429 on bulk /leads/selection/web_fetch \u2014 no leads queued");
15617
+ return { resp: null, quotaExceeded: true };
15618
+ }
15619
+ throw err;
15620
+ }
15621
+ } finally {
15622
+ try {
15623
+ await client.requestVoid("POST", "/leads/selection/clear");
15624
+ } catch (e) {
15625
+ ctx?.logger?.warn?.(`bulk_qualify_leads: selection.clear failed: ${e?.message ?? e?.code}`);
15626
+ }
15627
+ }
15628
+ } finally {
15629
+ client.releaseSelectionLock();
15630
+ }
15631
+ }
15632
+ var PAGE_SIZE, DEFAULT_COUNT2, MAX_COUNT, DEFAULT_PER_LEAD_BUDGET_MS, DEFAULT_TOTAL_BUDGET_MS2, bulkQualifyLeads;
14833
15633
  var init_bulk_qualify_leads = __esm({
14834
15634
  "../core/dist/composite/bulk-qualify-leads.js"() {
14835
15635
  "use strict";
14836
15636
  init_tool_descriptions_generated();
14837
15637
  PAGE_SIZE = 50;
14838
- DEFAULT_COUNT = 10;
15638
+ DEFAULT_COUNT2 = 10;
14839
15639
  MAX_COUNT = 25;
14840
15640
  DEFAULT_PER_LEAD_BUDGET_MS = 9e4;
14841
15641
  DEFAULT_TOTAL_BUDGET_MS2 = 5 * 6e4;
@@ -14856,7 +15656,7 @@ var init_bulk_qualify_leads = __esm({
14856
15656
  properties: {
14857
15657
  count: {
14858
15658
  type: "number",
14859
- description: `How many fresh leads to qualify (default ${DEFAULT_COUNT}, max ${MAX_COUNT})`
15659
+ description: `How many fresh leads to qualify (default ${DEFAULT_COUNT2}, max ${MAX_COUNT})`
14860
15660
  },
14861
15661
  leadIds: {
14862
15662
  type: "array",
@@ -14947,7 +15747,7 @@ var init_bulk_qualify_leads = __esm({
14947
15747
  ]
14948
15748
  },
14949
15749
  execute: async (client, params, ctx) => {
14950
- const wantCount = Math.min(params.count ?? DEFAULT_COUNT, MAX_COUNT);
15750
+ const wantCount = Math.min(params.count ?? DEFAULT_COUNT2, MAX_COUNT);
14951
15751
  const perLeadBudget = params.per_lead_budget_ms ?? DEFAULT_PER_LEAD_BUDGET_MS;
14952
15752
  const totalBudget = params.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS2;
14953
15753
  const totalDeadline = Date.now() + totalBudget;
@@ -15006,69 +15806,50 @@ var init_bulk_qualify_leads = __esm({
15006
15806
  per_lead_budget_ms: perLeadBudget,
15007
15807
  total_budget_ms: totalBudget
15008
15808
  });
15009
- const launched2 = [];
15010
- const failed2 = [];
15809
+ let launchedCount = 0;
15810
+ let notificationId = null;
15011
15811
  let quotaExceeded2 = false;
15812
+ let failed2 = [];
15012
15813
  if (!reservation.reused) {
15013
- for (const leadId of candidates) {
15014
- if (quotaExceeded2)
15015
- break;
15016
- try {
15017
- await client.requestVoid("POST", `/leads/${leadId}/web_fetch?force_fetch=false`);
15018
- launched2.push(leadId);
15019
- } catch (err) {
15020
- if (err?.code === "QUOTA_EXCEEDED") {
15021
- quotaExceeded2 = true;
15022
- } else if (err?.code === "NOT_FOUND") {
15023
- failed2.push({ lead_id: leadId, error: "lead not found" });
15024
- } else {
15025
- failed2.push({
15026
- lead_id: leadId,
15027
- error: err?.message ?? err?.code ?? "unknown"
15028
- });
15029
- }
15030
- }
15031
- }
15032
- if (failed2.length === candidates.length || launched2.length > 0 || quotaExceeded2) {
15033
- await ctx.bulkTracker.markLaunched(reservation.record.bulk_id);
15814
+ const launch = await launchBulkQualify(client, candidates, ctx);
15815
+ quotaExceeded2 = launch.quotaExceeded;
15816
+ notificationId = launch.resp?.notification_id ?? null;
15817
+ const queuedIds = launch.resp?.queued_ids ?? [];
15818
+ const skippedIds = launch.resp?.skipped_ids ?? [];
15819
+ launchedCount = queuedIds.length;
15820
+ const seen = /* @__PURE__ */ new Set([...queuedIds, ...skippedIds]);
15821
+ failed2 = candidates.filter((id) => !seen.has(id)).map((id) => ({ lead_id: id, error: "not_queued" }));
15822
+ if (queuedIds.length > 0 || quotaExceeded2 || skippedIds.length > 0 || failed2.length === candidates.length) {
15823
+ await ctx.bulkTracker.markLaunched(reservation.record.bulk_id, notificationId);
15034
15824
  }
15825
+ } else {
15826
+ notificationId = reservation.record.notification_id ?? null;
15827
+ launchedCount = reservation.record.lead_ids.length;
15035
15828
  }
15036
15829
  const out = {
15037
15830
  status: "running",
15038
15831
  handle_id: reservation.record.bulk_id,
15039
15832
  qualify_id: reservation.record.bulk_id,
15040
15833
  lead_ids: candidates,
15041
- launched_count: reservation.reused ? reservation.record.lead_ids.length : launched2.length,
15834
+ launched_count: launchedCount,
15042
15835
  failed: failed2,
15043
15836
  quota_exceeded: quotaExceeded2,
15044
15837
  lens_id: lensId,
15838
+ notification_id: notificationId,
15045
15839
  _meta: { region: client.region }
15046
15840
  };
15047
15841
  return out;
15048
15842
  }
15049
- const launched = [];
15050
- const failed = [];
15051
- let quotaExceeded = false;
15052
- for (const leadId of candidates) {
15053
- if (quotaExceeded)
15054
- break;
15055
- try {
15056
- await client.requestVoid("POST", `/leads/${leadId}/web_fetch?force_fetch=false`);
15057
- launched.push(leadId);
15058
- } catch (err) {
15059
- if (err?.code === "QUOTA_EXCEEDED") {
15060
- quotaExceeded = true;
15061
- 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`);
15062
- } else if (err?.code === "NOT_FOUND") {
15063
- failed.push({ lead_id: leadId, error: "lead not found" });
15064
- } else {
15065
- failed.push({
15066
- lead_id: leadId,
15067
- error: err?.message ?? err?.code ?? "unknown"
15068
- });
15069
- }
15070
- }
15843
+ const inlineLaunch = await launchBulkQualify(client, candidates, ctx);
15844
+ const quotaExceeded = inlineLaunch.quotaExceeded;
15845
+ const launched = inlineLaunch.resp?.queued_ids ?? [];
15846
+ const inlineSkipped = inlineLaunch.resp?.skipped_ids ?? [];
15847
+ const inlineNotificationId = inlineLaunch.resp?.notification_id ?? null;
15848
+ if (inlineNotificationId) {
15849
+ ctx?.logger?.info?.(`bulk_qualify_leads: launched bulk progress_notification_id=${inlineNotificationId} queued=${launched.length} skipped=${inlineSkipped.length}`);
15071
15850
  }
15851
+ const inlineFailedSeen = /* @__PURE__ */ new Set([...launched, ...inlineSkipped]);
15852
+ const failed = candidates.filter((id) => !inlineFailedSeen.has(id)).map((id) => ({ lead_id: id, error: "not_queued" }));
15072
15853
  let progressDone = 0;
15073
15854
  const progressTotal = launched.length;
15074
15855
  if (progressTotal > 0) {
@@ -15904,6 +16685,7 @@ var init_import_and_qualify = __esm({
15904
16685
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
15905
16686
  qualify_id: null,
15906
16687
  import_ids: queued.importIds,
16688
+ notification_ids: queued.notification_ids ?? [],
15907
16689
  imported: queued.leads.map((l) => ({
15908
16690
  leadId: l.leadId,
15909
16691
  ...l.domain ? { domain: l.domain } : {},
@@ -15928,6 +16710,7 @@ var init_import_and_qualify = __esm({
15928
16710
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
15929
16711
  qualify_id: null,
15930
16712
  import_ids: queued.importIds,
16713
+ notification_ids: queued.notification_ids ?? [],
15931
16714
  imported: [],
15932
16715
  not_imported: [],
15933
16716
  qualified: [],
@@ -15965,6 +16748,7 @@ var init_import_and_qualify = __esm({
15965
16748
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
15966
16749
  qualify_id: null,
15967
16750
  import_ids: importResult.importIds,
16751
+ notification_ids: importResult.notification_ids ?? [],
15968
16752
  imported: [],
15969
16753
  not_imported: importResult.not_imported.map(toNotImportedEntry),
15970
16754
  qualified: [],
@@ -16002,6 +16786,7 @@ var init_import_and_qualify = __esm({
16002
16786
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
16003
16787
  qualify_id: null,
16004
16788
  import_ids: importResult.importIds,
16789
+ notification_ids: importResult.notification_ids ?? [],
16005
16790
  imported,
16006
16791
  not_imported,
16007
16792
  qualified: [],
@@ -16112,6 +16897,7 @@ var init_import_and_qualify = __esm({
16112
16897
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
16113
16898
  qualify_id: reservation.record.bulk_id,
16114
16899
  import_ids: importResult.importIds,
16900
+ notification_ids: importResult.notification_ids ?? [],
16115
16901
  imported,
16116
16902
  not_imported,
16117
16903
  qualified,
@@ -16707,16 +17493,20 @@ var init_bulk_store = __esm({
16707
17493
  this.logger?.info?.(`bulk.import_failed bulk_id=${bulk_id}`);
16708
17494
  });
16709
17495
  }
16710
- async markLaunched(bulk_id) {
17496
+ async markLaunched(bulk_id, notification_id) {
16711
17497
  return this.mutex.run(async () => {
16712
17498
  const all = this.prune(await this.readAll());
16713
17499
  const idx = all.findIndex((r) => r.bulk_id === bulk_id);
16714
17500
  if (idx < 0) {
16715
17501
  throw new Error(`bulk_id not found: ${bulk_id}`);
16716
17502
  }
16717
- all[idx] = { ...all[idx], status: "launched" };
17503
+ all[idx] = {
17504
+ ...all[idx],
17505
+ status: "launched",
17506
+ ...notification_id ? { notification_id } : {}
17507
+ };
16718
17508
  await this.writeAll(all);
16719
- this.logger?.info?.(`bulk.launched bulk_id=${bulk_id}`);
17509
+ this.logger?.info?.(`bulk.launched bulk_id=${bulk_id}${notification_id ? ` notification_id=${notification_id}` : ""}`);
16720
17510
  return all[idx];
16721
17511
  });
16722
17512
  }
@@ -16968,6 +17758,14 @@ var init_import_status = __esm({
16968
17758
  });
16969
17759
 
16970
17760
  // ../core/dist/composite/qualify-status.js
17761
+ async function readNotification(client, notificationId) {
17762
+ try {
17763
+ const page = await client.listNotifications({ archived: false, count: 50 });
17764
+ return page.items.find((n) => n.id === notificationId) ?? null;
17765
+ } catch {
17766
+ return null;
17767
+ }
17768
+ }
16971
17769
  var qualifyStatus;
16972
17770
  var init_qualify_status = __esm({
16973
17771
  "../core/dist/composite/qualify-status.js"() {
@@ -17129,6 +17927,16 @@ var init_qualify_status = __esm({
17129
17927
  const { _stillRunning, _failedCode, ...rest } = r;
17130
17928
  qualified.push(rest);
17131
17929
  }
17930
+ let bulkProgress = null;
17931
+ let inProgressFlag = null;
17932
+ const notifId = record.notification_id ?? null;
17933
+ if (notifId) {
17934
+ const n = await readNotification(client, notifId);
17935
+ if (n) {
17936
+ bulkProgress = n.bulk_progress;
17937
+ inProgressFlag = n.in_progress;
17938
+ }
17939
+ }
17132
17940
  const out = {
17133
17941
  qualify_id: record.bulk_id,
17134
17942
  launched_at: record.launched_at,
@@ -17140,6 +17948,9 @@ var init_qualify_status = __esm({
17140
17948
  still_running,
17141
17949
  failed,
17142
17950
  not_in_lens: [...notInLensSet],
17951
+ notification_id: notifId,
17952
+ bulk_progress: bulkProgress,
17953
+ in_progress: inProgressFlag,
17143
17954
  region: client.region,
17144
17955
  _meta: client.lastMeta ?? {
17145
17956
  region: client.region,
@@ -17152,17 +17963,36 @@ var init_qualify_status = __esm({
17152
17963
  out.per_lead_budget_ms = record.per_lead_budget_ms;
17153
17964
  if (record.total_budget_ms !== void 0)
17154
17965
  out.total_budget_ms = record.total_budget_ms;
17966
+ if (bulkProgress && bulkProgress.quota_hit_count > 0) {
17967
+ 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.";
17968
+ }
17155
17969
  return out;
17156
17970
  }
17157
17971
  };
17158
17972
  }
17159
17973
  });
17160
17974
 
17975
+ // ../core/dist/composite/_credits-helpers.js
17976
+ async function readCreditsRemaining(client, force = false) {
17977
+ try {
17978
+ const me = await client.resolveMe(force);
17979
+ return me.organization.billing?.ai_credits ?? null;
17980
+ } catch {
17981
+ return null;
17982
+ }
17983
+ }
17984
+ var init_credits_helpers = __esm({
17985
+ "../core/dist/composite/_credits-helpers.js"() {
17986
+ "use strict";
17987
+ }
17988
+ });
17989
+
17161
17990
  // ../core/dist/composite/enrich-titles.js
17162
17991
  var DEFAULT_CANDIDATE_COUNT, enrichTitles;
17163
17992
  var init_enrich_titles = __esm({
17164
17993
  "../core/dist/composite/enrich-titles.js"() {
17165
17994
  "use strict";
17995
+ init_credits_helpers();
17166
17996
  init_tool_descriptions_generated();
17167
17997
  DEFAULT_CANDIDATE_COUNT = 25;
17168
17998
  enrichTitles = {
@@ -17246,6 +18076,10 @@ var init_enrich_titles = __esm({
17246
18076
  type: "number",
17247
18077
  description: "Count of enrichable contacts at preview time."
17248
18078
  },
18079
+ credits_remaining: {
18080
+ type: ["number", "null"],
18081
+ description: "AI-credit balance BEFORE launching (billing.ai_credits). Present in discover / preview_only / dry_run modes. Pair with enrichable_contacts to tell the user 'you have N credits, this will enrich M contacts' \u2014 do NOT estimate an exact cost (the per-contact rate is backend-only). Null = billing unavailable."
18082
+ },
17249
18083
  selected_lead_count: {
17250
18084
  type: "number",
17251
18085
  description: "How many leads the selection covers."
@@ -17369,6 +18203,10 @@ var init_enrich_titles = __esm({
17369
18203
  previously_enriched: previouslyEnriched,
17370
18204
  enrichable_contacts: enrichableContacts,
17371
18205
  selected_lead_count: leadIds.length,
18206
+ // BEFORE: show balance + volume. We can't estimate exact cost
18207
+ // (the per-contact rate is backend-only), so surface the balance
18208
+ // and the count, not a fabricated "will cost N".
18209
+ credits_remaining: await readCreditsRemaining(client),
17372
18210
  next_action: "Pick titles to enrich and call leadbay_enrich_titles again with titles=[...]"
17373
18211
  };
17374
18212
  }
@@ -17391,7 +18229,8 @@ var init_enrich_titles = __esm({
17391
18229
  preview,
17392
18230
  launched: false,
17393
18231
  message: "No enrichable contacts for the chosen titles. Try other titles from available_titles or recommendations.",
17394
- available_titles: availableTitles
18232
+ available_titles: availableTitles,
18233
+ credits_remaining: await readCreditsRemaining(client)
17395
18234
  };
17396
18235
  }
17397
18236
  if (params.dry_run) {
@@ -17399,7 +18238,12 @@ var init_enrich_titles = __esm({
17399
18238
  mode: "dry_run",
17400
18239
  preview,
17401
18240
  launched: false,
17402
- would_launch: { titles: params.titles, email, phone }
18241
+ would_launch: { titles: params.titles, email, phone },
18242
+ // BEFORE confirmation gate: balance + how many contacts WOULD be
18243
+ // enriched. enrichable_contacts is the volume; credits_remaining
18244
+ // the balance. No estimated cost — that rate is backend-only.
18245
+ enrichable_contacts: preview.enrichable_contacts,
18246
+ credits_remaining: await readCreditsRemaining(client)
17403
18247
  };
17404
18248
  }
17405
18249
  const tracker = ctx?.bulkTracker;
@@ -17429,6 +18273,7 @@ var init_enrich_titles = __esm({
17429
18273
  bulk_id: res.record.bulk_id,
17430
18274
  launched_at: res.record.launched_at,
17431
18275
  durability: res.record.durability,
18276
+ notification_id: res.record.notification_id ?? null,
17432
18277
  seconds_since_original_launch: bulkSecondsSinceOriginal ?? 0,
17433
18278
  titles: params.titles,
17434
18279
  email,
@@ -17444,8 +18289,9 @@ var init_enrich_titles = __esm({
17444
18289
  total: 3,
17445
18290
  message: `Launching enrichment for ${params.titles.length} title${params.titles.length === 1 ? "" : "s"}\u2026`
17446
18291
  });
18292
+ let launchResp = null;
17447
18293
  try {
17448
- await client.requestVoid("POST", "/leads/selection/enrichment/launch", { titles: params.titles, email, phone });
18294
+ launchResp = await client.request("POST", "/leads/selection/enrichment/launch", { titles: params.titles, email, phone });
17449
18295
  } catch (err) {
17450
18296
  const aborted = err?.name === "AbortError" || ctx?.signal?.aborted === true;
17451
18297
  if (bulkRecord && tracker) {
@@ -17469,9 +18315,10 @@ var init_enrich_titles = __esm({
17469
18315
  }
17470
18316
  throw err;
17471
18317
  }
18318
+ const notificationId = launchResp?.notification_id ?? null;
17472
18319
  if (bulkRecord && tracker) {
17473
18320
  try {
17474
- await tracker.markLaunched(bulkRecord.bulk_id);
18321
+ await tracker.markLaunched(bulkRecord.bulk_id, notificationId);
17475
18322
  } catch (e) {
17476
18323
  ctx?.logger?.warn?.(`enrich_titles: tracker.markLaunched failed: ${e?.message ?? e}`);
17477
18324
  return {
@@ -17499,8 +18346,9 @@ var init_enrich_titles = __esm({
17499
18346
  bulk_id: bulkRecord?.bulk_id,
17500
18347
  launched_at: bulkRecord?.launched_at,
17501
18348
  durability: bulkRecord?.durability,
17502
- 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.",
17503
- 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."
18349
+ notification_id: notificationId,
18350
+ 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.",
18351
+ 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."
17504
18352
  };
17505
18353
  } finally {
17506
18354
  try {
@@ -17518,6 +18366,14 @@ var init_enrich_titles = __esm({
17518
18366
  });
17519
18367
 
17520
18368
  // ../core/dist/composite/bulk-enrich-status.js
18369
+ async function readNotification2(client, notificationId) {
18370
+ try {
18371
+ const page = await client.listNotifications({ archived: false, count: 50 });
18372
+ return page.items.find((n) => n.id === notificationId) ?? null;
18373
+ } catch {
18374
+ return null;
18375
+ }
18376
+ }
17521
18377
  async function pMap(items, fn, concurrency) {
17522
18378
  const out = new Array(items.length);
17523
18379
  let next = 0;
@@ -17538,6 +18394,7 @@ var init_bulk_enrich_status = __esm({
17538
18394
  "use strict";
17539
18395
  init_get_contacts();
17540
18396
  init_bulk_store();
18397
+ init_credits_helpers();
17541
18398
  init_tool_descriptions_generated();
17542
18399
  STATUS_FETCH_CONCURRENCY = 5;
17543
18400
  bulkEnrichStatus = {
@@ -17604,6 +18461,10 @@ var init_bulk_enrich_status = __esm({
17604
18461
  type: "boolean",
17605
18462
  description: "True when overall_progress.done === total AND no partial_failures."
17606
18463
  },
18464
+ credits_remaining: {
18465
+ type: ["number", "null"],
18466
+ description: "AI-credit balance re-read after the spend (force-refreshed /users/me \u2192 billing.ai_credits). Present only when all_done. Null = billing unavailable (don't read as zero). NOTE: a per-run 'credits used' figure is intentionally NOT returned \u2014 getContacts can't scope cost to this bulk, so any sum would conflate historical enrichments."
18467
+ },
17607
18468
  partial_failures: {
17608
18469
  type: "array",
17609
18470
  description: "Per-lead errors observed during contacts fan-out (omitted when no failures).",
@@ -17689,6 +18550,55 @@ var init_bulk_enrich_status = __esm({
17689
18550
  launched_at: record.launched_at
17690
18551
  };
17691
18552
  }
18553
+ const notifId = record.notification_id ?? null;
18554
+ if (notifId) {
18555
+ const n = await readNotification2(client, notifId);
18556
+ if (n && n.bulk_progress) {
18557
+ const bp = n.bulk_progress;
18558
+ const inProgress = n.in_progress;
18559
+ let leads2 = [];
18560
+ if (!inProgress && includeContacts) {
18561
+ leads2 = await pMap(record.lead_ids, async (leadId) => {
18562
+ try {
18563
+ const out = await getContacts.execute(client, { leadId });
18564
+ const contacts = Array.isArray(out?.contacts) ? out.contacts : [];
18565
+ return { lead_id: leadId, contacts };
18566
+ } catch {
18567
+ return { lead_id: leadId };
18568
+ }
18569
+ }, STATUS_FETCH_CONCURRENCY);
18570
+ } else {
18571
+ leads2 = record.lead_ids.map((id) => ({ lead_id: id }));
18572
+ }
18573
+ 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}`);
18574
+ const creditsRemaining2 = !inProgress ? await readCreditsRemaining(client, true) : null;
18575
+ return {
18576
+ bulk_id: record.bulk_id,
18577
+ notification_id: notifId,
18578
+ launched_at: record.launched_at,
18579
+ status: record.status,
18580
+ durability: record.durability,
18581
+ titles: record.titles,
18582
+ email: record.email,
18583
+ phone: record.phone,
18584
+ lens_id: record.lens_id,
18585
+ leads: leads2,
18586
+ overall_progress: {
18587
+ done: bp.success_count + bp.failure_count + bp.quota_hit_count,
18588
+ total: bp.total_count,
18589
+ done_ratio: bp.total_count === 0 ? 0 : (bp.success_count + bp.failure_count + bp.quota_hit_count) / bp.total_count
18590
+ },
18591
+ bulk_progress: bp,
18592
+ in_progress: inProgress,
18593
+ all_done: !inProgress,
18594
+ ...!inProgress ? { credits_remaining: creditsRemaining2 } : {},
18595
+ ...bp.quota_hit_count > 0 ? {
18596
+ 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."
18597
+ } : {}
18598
+ };
18599
+ }
18600
+ ctx?.logger?.info?.(`bulk_enrich_status: notification ${notifId} not yet visible; falling back to per-lead fan-out`);
18601
+ }
17692
18602
  let doneSoFar = 0;
17693
18603
  const totalLeads = record.lead_ids.length;
17694
18604
  const results = await pMap(record.lead_ids, async (leadId) => {
@@ -17754,6 +18664,10 @@ var init_bulk_enrich_status = __esm({
17754
18664
  };
17755
18665
  const allDone = totalAll > 0 && totalDone === totalAll && partialFailures.length === 0;
17756
18666
  ctx?.logger?.info?.(`bulk.status_checked bulk_id=${record.bulk_id} done=${totalDone} total=${totalAll} wall_ms=${Date.now() - startMs}`);
18667
+ let creditsRemaining = null;
18668
+ if (allDone) {
18669
+ creditsRemaining = await readCreditsRemaining(client, true);
18670
+ }
17757
18671
  return {
17758
18672
  bulk_id: record.bulk_id,
17759
18673
  launched_at: record.launched_at,
@@ -17766,6 +18680,7 @@ var init_bulk_enrich_status = __esm({
17766
18680
  leads,
17767
18681
  overall_progress: overallProgress,
17768
18682
  all_done: allDone,
18683
+ ...allDone ? { credits_remaining: creditsRemaining } : {},
17769
18684
  ...partialFailures.length > 0 ? { partial_failures: partialFailures } : {}
17770
18685
  };
17771
18686
  }
@@ -19457,8 +20372,12 @@ __export(dist_exports, {
19457
20372
  InMemoryBulkStore: () => InMemoryBulkStore,
19458
20373
  LeadbayClient: () => LeadbayClient,
19459
20374
  LocalBulkStore: () => LocalBulkStore,
20375
+ NotificationsInbox: () => NotificationsInbox,
20376
+ NotificationsWsClient: () => NotificationsWsClient,
19460
20377
  REGIONS: () => REGIONS,
20378
+ accountHistory: () => accountHistory,
19461
20379
  accountStatus: () => accountStatus,
20380
+ acknowledgeNotification: () => acknowledgeNotification,
19462
20381
  addLeadsToCampaign: () => addLeadsToCampaign,
19463
20382
  addNote: () => addNote,
19464
20383
  adjustAudience: () => adjustAudience,
@@ -19466,6 +20385,7 @@ __export(dist_exports, {
19466
20385
  agentMemoryRecall: () => agentMemoryRecall,
19467
20386
  agentMemoryReview: () => agentMemoryReview,
19468
20387
  agentMemoryTools: () => agentMemoryTools,
20388
+ anchorIdFor: () => anchorIdFor,
19469
20389
  answerClarification: () => answerClarification,
19470
20390
  appendEntry: () => appendEntry,
19471
20391
  appendTombstone: () => appendTombstone,
@@ -19474,6 +20394,7 @@ __export(dist_exports, {
19474
20394
  bulkQualifyLeads: () => bulkQualifyLeads,
19475
20395
  campaignCallSheet: () => campaignCallSheet,
19476
20396
  campaignProgression: () => campaignProgression,
20397
+ catchUpNotifications: () => catchUpNotifications,
19477
20398
  clearAgentMemoryCache: () => clearAgentMemoryCache,
19478
20399
  clearMockJournal: () => clearMockJournal,
19479
20400
  clearSelection: () => clearSelection,
@@ -19523,6 +20444,7 @@ __export(dist_exports, {
19523
20444
  importAndQualify: () => importAndQualify,
19524
20445
  importLeads: () => importLeads,
19525
20446
  importStatus: () => importStatus,
20447
+ inferKind: () => inferKind,
19526
20448
  invalidateAgentMemoryCache: () => invalidateAgentMemoryCache,
19527
20449
  isAgentMemoryEnabled: () => isAgentMemoryEnabled,
19528
20450
  isValidBulkId: () => isValidBulkId,
@@ -19563,12 +20485,14 @@ __export(dist_exports, {
19563
20485
  resolveAgentMemorySummary: () => resolveAgentMemorySummary,
19564
20486
  resolveImportRows: () => resolveImportRows,
19565
20487
  resolveRegion: () => resolveRegion,
20488
+ reviseHintFor: () => reviseHintFor,
19566
20489
  seedCandidates: () => seedCandidates,
19567
20490
  selectLeads: () => selectLeads,
19568
20491
  setActiveLens: () => setActiveLens,
19569
20492
  setEpilogueStatus: () => setEpilogueStatus,
19570
20493
  setPushback: () => setPushback,
19571
20494
  setUserPrompt: () => setUserPrompt,
20495
+ toInboxEntry: () => toInboxEntry,
19572
20496
  tools: () => tools,
19573
20497
  tourPlan: () => tourPlan,
19574
20498
  updateLens: () => updateLens,
@@ -19583,6 +20507,7 @@ var init_dist = __esm({
19583
20507
  init_types();
19584
20508
  init_agent_memory();
19585
20509
  init_composite_file_names();
20510
+ init_notifications();
19586
20511
  init_login();
19587
20512
  init_list_lenses();
19588
20513
  init_discover_leads();
@@ -19612,6 +20537,7 @@ var init_dist = __esm({
19612
20537
  init_agent_memory_recall();
19613
20538
  init_agent_memory_capture();
19614
20539
  init_agent_memory_review();
20540
+ init_acknowledge_notification();
19615
20541
  init_select_leads();
19616
20542
  init_deselect_leads();
19617
20543
  init_clear_selection();
@@ -19647,6 +20573,7 @@ var init_dist = __esm({
19647
20573
  init_campaign_call_sheet();
19648
20574
  init_research_lead_by_id();
19649
20575
  init_research_lead_by_name_fuzzy();
20576
+ init_account_history();
19650
20577
  init_recall_ordered_titles();
19651
20578
  init_account_status();
19652
20579
  init_bulk_qualify_leads();
@@ -19739,6 +20666,13 @@ var init_dist = __esm({
19739
20666
  campaignCallSheet,
19740
20667
  researchLeadById,
19741
20668
  researchLeadByNameFuzzy,
20669
+ // accountHistory layers FULL notes + activity timeline on top of research
20670
+ // so the agent can write the US4 "why has this dormant account resurfaced"
20671
+ // narrative in ONE call. ALWAYS exposed (compositeReadTools) — the underlying
20672
+ // get_lead_notes / get_lead_activities are ADVANCED-gated, but the
20673
+ // reprioritize-a-neglected-account workflow (#3630 GAP C) must work in a
20674
+ // default deployment without LEADBAY_MCP_ADVANCED=1.
20675
+ accountHistory,
19742
20676
  recallOrderedTitles,
19743
20677
  accountStatus,
19744
20678
  bulkEnrichStatus,
@@ -19771,7 +20705,13 @@ var init_dist = __esm({
19771
20705
  // didn't deliver"). Does not mutate Leadbay state; emits a PostHog
19772
20706
  // event only. Companion to leadbay_report_outreach (which DOES write
19773
20707
  // to the backend and stays gated behind LEADBAY_MCP_WRITE).
19774
- reportFriction
20708
+ reportFriction,
20709
+ // Notification ack — ALWAYS exposed even though it POSTs to /seen.
20710
+ // _meta.notifications surfaces terminal bulk-progress notifications on
20711
+ // every tool response regardless of write gating; without ack the agent
20712
+ // sees the same entries on every call forever. Pairing the surfacing
20713
+ // channel with the clearing tool is non-optional.
20714
+ acknowledgeNotification
19775
20715
  ];
19776
20716
  compositeWriteTools = [
19777
20717
  bulkQualifyLeads,
@@ -22119,6 +23059,22 @@ function buildServer(client, opts = {}) {
22119
23059
  });
22120
23060
  }
22121
23061
  };
23062
+ const maybeAttachNotifications = (result) => {
23063
+ const inbox = opts.notificationsInbox;
23064
+ if (!inbox) return;
23065
+ if (result === null || typeof result !== "object" || Array.isArray(result)) {
23066
+ return;
23067
+ }
23068
+ const entries = inbox.list();
23069
+ if (entries.length === 0) return;
23070
+ const envelope = result;
23071
+ const target = envelope.__markdown_envelope === true && envelope.structured !== null && typeof envelope.structured === "object" && !Array.isArray(envelope.structured) ? envelope.structured : envelope;
23072
+ const existingMeta = target._meta && typeof target._meta === "object" && !Array.isArray(target._meta) ? target._meta : {};
23073
+ target._meta = {
23074
+ ...existingMeta,
23075
+ notifications: entries
23076
+ };
23077
+ };
22122
23078
  const isLeadbayBusinessError = (err) => err != null && typeof err === "object" && err.error === true && typeof err.code === "string";
22123
23079
  const buildBusinessCtx = (toolName, envelope, triggered_by) => {
22124
23080
  const meta = envelope._meta ?? {};
@@ -22238,11 +23194,13 @@ function buildServer(client, opts = {}) {
22238
23194
  const result = await tool.execute(client, args, {
22239
23195
  logger: opts.logger,
22240
23196
  bulkTracker: opts.bulkTracker,
23197
+ notificationsInbox: opts.notificationsInbox,
22241
23198
  signal: extra.signal,
22242
23199
  progress,
22243
23200
  elicit
22244
23201
  });
22245
23202
  maybeAttachUpdate(name, result);
23203
+ maybeAttachNotifications(result);
22246
23204
  if (result && typeof result === "object" && result.error === true) {
22247
23205
  const envText = formatErrorForLLM(result);
22248
23206
  const envDur = Date.now() - callStart;
@@ -23680,7 +24638,7 @@ var OAUTH_BASE_URLS = {
23680
24638
  fr: "https://staging.api.leadbay.app"
23681
24639
  }
23682
24640
  };
23683
- var VERSION = "0.17.3";
24641
+ var VERSION = "0.18.1";
23684
24642
  var HELP = `
23685
24643
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
23686
24644
 
@@ -24777,21 +25735,41 @@ async function main() {
24777
25735
  logger.warn?.(`update_check.unexpected ${err?.message ?? err}`);
24778
25736
  });
24779
25737
  }
25738
+ const notificationsInbox = new NotificationsInbox();
25739
+ let notificationsWs = null;
25740
+ const WS_DISABLED = process.env.LEADBAY_NOTIFICATIONS_WS_DISABLED === "1" || authState !== "ok";
25741
+ if (!WS_DISABLED) {
25742
+ notificationsWs = new NotificationsWsClient({
25743
+ client,
25744
+ inbox: notificationsInbox,
25745
+ logger
25746
+ });
25747
+ void notificationsWs.start().catch((err) => {
25748
+ logger.warn?.(
25749
+ `notifications.ws start_failed: ${err?.message ?? err}`
25750
+ );
25751
+ });
25752
+ }
24780
25753
  const server = buildServer(client, {
24781
25754
  includeAdvanced,
24782
25755
  includeWrite,
24783
25756
  logger,
24784
25757
  bulkTracker,
25758
+ notificationsInbox,
24785
25759
  version: VERSION,
24786
25760
  telemetry,
24787
25761
  updateStateStore
24788
25762
  });
24789
25763
  const transport = new StdioServerTransport();
24790
25764
  logger.info?.(
24791
- `Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl}, bulk_store=${bulkTracker.durability}, auth_state=${authState})`
25765
+ `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})`
24792
25766
  );
24793
25767
  await server.connect(transport);
24794
25768
  const shutdown = async (code) => {
25769
+ try {
25770
+ notificationsWs?.stop();
25771
+ } catch {
25772
+ }
24795
25773
  try {
24796
25774
  await telemetry.shutdown();
24797
25775
  } finally {