From 48c593f4ff361cf3125ef957c7f59e8d86f2e9c7 Mon Sep 17 00:00:00 2001 From: milstan Date: Mon, 20 Apr 2026 21:08:04 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20agent-optimized=20v0.2.0=20=E2=80=94=20?= =?UTF-8?q?composite=20tools=20+=20verification=20+=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the agent-facing composite-tool surface that lets Claude (or any MCP client) drive Leadbay end-to-end without the agent needing to know about lens permissions, region routing, polling, or selection state. Designed through a full /autoplan dual-voice review (CEO + Eng + DX) followed by live API exploration; all 29 approved revisions are in this PR. New composite tools (agent's default surface): - pull_leads (replaces find_prospects; adds qualification_summary per lead) - research_lead (qualification → signals → firmographics → contacts → engagement) - bulk_qualify_leads (paginates past already-qualified, fan-out + poll, 429 mid-fanout) - enrich_titles (selection-lifecycle managed, dry_run, 429 handling) - adjust_audience (admin/non-admin auto-routing, sector free-text resolution, draft fallback) - refine_prompt + answer_clarification (admin-gated, stale-clarification guard) - recall_ordered_titles (preview-field path + live-aggregate fallback) - account_status (quota + admin + intelligence state) - report_outreach with mandatory verification (gmail_message_id | calendar_event_id | user_confirmed) — prevents pipeline poisoning New granular tools (28): lens filter/scoring/draft/promote/create/update/active, selection select/deselect/clear/ids, sectors taxonomy, user_prompt CRUD, clarifications get/pick/dismiss, epilogue set/remove + responses, prospecting actions, notes read, web_fetch read, bulk-enrichment preview/launch. Client refactor: HTTP header capture, _meta envelope (region + endpoint + latency_ms + retry_after), 429→QUOTA_EXCEEDED mapping (was RATE_LIMITED), 60s /me cache with invalidateMe() called by every write tool that mutates cached fields, selection Mutex (for concurrent enrich_titles), region auto-detect on login (us → fr fallback). Gating model: - LEADBAY_MCP_WRITE=1 — exposes composite + granular write tools (off by default) - LEADBAY_MCP_ADVANCED=1 — exposes granular API tools (off by default) - OpenClaw plugin: exposeWrite + exposeGranular config flags (both off by default) - leadbay_login still hidden from MCP (UC-3, prompt-injection vector) Mock mode (LEADBAY_MOCK=1) reads fixtures from .context/leadbay-live-shapes/ for agent-author dry-running. dry_run param on every state-changing composite. Tests: 89 unit (54→58 core, 11→12 leadclaw, 19 mcp), 10 live read-only smoke, plus end-to-end MCP and OpenClaw plugin live smokes against the real backend. Per-tool description style enforced ("When to use" + "When NOT to use" sections). Live-probe drift documented in .context/leadbay-live-shapes/SHAPE-DRIFT.md (gitignored). Migration notes in packages/mcp/MIGRATION.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 7 + packages/core/src/client.ts | 440 ++++++++++++++++-- packages/core/src/composite/account-status.ts | 55 +++ .../core/src/composite/adjust-audience.ts | 375 +++++++++++++++ .../src/composite/answer-clarification.ts | 85 ++++ .../core/src/composite/bulk-qualify-leads.ts | 251 ++++++++++ packages/core/src/composite/enrich-titles.ts | 235 ++++++++++ .../core/src/composite/prepare-outreach.ts | 6 +- packages/core/src/composite/pull-leads.ts | 162 +++++++ .../src/composite/recall-ordered-titles.ts | 165 +++++++ packages/core/src/composite/refine-prompt.ts | 134 ++++++ .../core/src/composite/report-outreach.ts | 213 +++++++++ .../core/src/composite/research-company.ts | 9 +- packages/core/src/composite/research-lead.ts | 242 ++++++++++ packages/core/src/index.ts | 169 ++++++- packages/core/src/tools/add-note.ts | 5 +- packages/core/src/tools/clear-selection.ts | 17 + packages/core/src/tools/clear-user-prompt.ts | 21 + packages/core/src/tools/create-lens-draft.ts | 28 ++ packages/core/src/tools/create-lens.ts | 38 ++ packages/core/src/tools/deselect-leads.ts | 38 ++ packages/core/src/tools/discover-leads.ts | 4 +- .../core/src/tools/dismiss-clarification.ts | 24 + packages/core/src/tools/enrich-contacts.ts | 5 +- packages/core/src/tools/get-clarification.ts | 20 + packages/core/src/tools/get-contacts.ts | 5 +- .../src/tools/get-enrichment-job-titles.ts | 19 + .../core/src/tools/get-epilogue-responses.ts | 36 ++ .../core/src/tools/get-lead-activities.ts | 4 +- packages/core/src/tools/get-lead-notes.ts | 25 + packages/core/src/tools/get-lead-profile.ts | 6 +- packages/core/src/tools/get-lens-filter.ts | 27 ++ packages/core/src/tools/get-lens-scoring.ts | 30 ++ .../core/src/tools/get-prospecting-actions.ts | 36 ++ packages/core/src/tools/get-quota.ts | 27 +- packages/core/src/tools/get-selection-ids.ts | 14 + packages/core/src/tools/get-taste-profile.ts | 6 +- packages/core/src/tools/get-user-prompt.ts | 21 + packages/core/src/tools/get-web-fetch.ts | 28 ++ .../core/src/tools/launch-bulk-enrichment.ts | 83 ++++ packages/core/src/tools/list-lenses.ts | 5 +- packages/core/src/tools/list-sectors.ts | 44 ++ packages/core/src/tools/login.ts | 129 ++--- packages/core/src/tools/pick-clarification.ts | 49 ++ .../core/src/tools/preview-bulk-enrichment.ts | 40 ++ packages/core/src/tools/promote-lens.ts | 26 ++ packages/core/src/tools/qualify-lead.ts | 6 +- packages/core/src/tools/remove-epilogue.ts | 33 ++ packages/core/src/tools/select-leads.ts | 44 ++ packages/core/src/tools/set-active-lens.ts | 32 ++ .../core/src/tools/set-epilogue-status.ts | 67 +++ packages/core/src/tools/set-user-prompt.ts | 52 +++ packages/core/src/tools/update-lens-filter.ts | 58 +++ packages/core/src/tools/update-lens.ts | 38 ++ packages/core/src/types.ts | 275 +++++++++-- packages/core/test/harness.ts | 7 + packages/core/test/smoke/leadbay.live.test.ts | 95 +++- packages/core/test/unit/client.test.ts | 341 ++++++++++---- packages/core/test/unit/tools/login.test.ts | 106 ++++- packages/leadclaw/openclaw.plugin.json | 66 ++- packages/leadclaw/src/index.ts | 49 +- packages/leadclaw/test/contract.test.ts | 178 +++++-- packages/mcp/MIGRATION.md | 140 ++++++ packages/mcp/src/bin.ts | 21 +- packages/mcp/src/server.ts | 68 ++- packages/mcp/test/concurrency.test.ts | 26 +- packages/mcp/test/harness.ts | 1 + packages/mcp/test/server.test.ts | 134 ++++-- 68 files changed, 4803 insertions(+), 442 deletions(-) create mode 100644 packages/core/src/composite/account-status.ts create mode 100644 packages/core/src/composite/adjust-audience.ts create mode 100644 packages/core/src/composite/answer-clarification.ts create mode 100644 packages/core/src/composite/bulk-qualify-leads.ts create mode 100644 packages/core/src/composite/enrich-titles.ts create mode 100644 packages/core/src/composite/pull-leads.ts create mode 100644 packages/core/src/composite/recall-ordered-titles.ts create mode 100644 packages/core/src/composite/refine-prompt.ts create mode 100644 packages/core/src/composite/report-outreach.ts create mode 100644 packages/core/src/composite/research-lead.ts create mode 100644 packages/core/src/tools/clear-selection.ts create mode 100644 packages/core/src/tools/clear-user-prompt.ts create mode 100644 packages/core/src/tools/create-lens-draft.ts create mode 100644 packages/core/src/tools/create-lens.ts create mode 100644 packages/core/src/tools/deselect-leads.ts create mode 100644 packages/core/src/tools/dismiss-clarification.ts create mode 100644 packages/core/src/tools/get-clarification.ts create mode 100644 packages/core/src/tools/get-enrichment-job-titles.ts create mode 100644 packages/core/src/tools/get-epilogue-responses.ts create mode 100644 packages/core/src/tools/get-lead-notes.ts create mode 100644 packages/core/src/tools/get-lens-filter.ts create mode 100644 packages/core/src/tools/get-lens-scoring.ts create mode 100644 packages/core/src/tools/get-prospecting-actions.ts create mode 100644 packages/core/src/tools/get-selection-ids.ts create mode 100644 packages/core/src/tools/get-user-prompt.ts create mode 100644 packages/core/src/tools/get-web-fetch.ts create mode 100644 packages/core/src/tools/launch-bulk-enrichment.ts create mode 100644 packages/core/src/tools/list-sectors.ts create mode 100644 packages/core/src/tools/pick-clarification.ts create mode 100644 packages/core/src/tools/preview-bulk-enrichment.ts create mode 100644 packages/core/src/tools/promote-lens.ts create mode 100644 packages/core/src/tools/remove-epilogue.ts create mode 100644 packages/core/src/tools/select-leads.ts create mode 100644 packages/core/src/tools/set-active-lens.ts create mode 100644 packages/core/src/tools/set-epilogue-status.ts create mode 100644 packages/core/src/tools/set-user-prompt.ts create mode 100644 packages/core/src/tools/update-lens-filter.ts create mode 100644 packages/core/src/tools/update-lens.ts create mode 100644 packages/mcp/MIGRATION.md diff --git a/.gitignore b/.gitignore index d4510e3..82b7f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,15 @@ node_modules/ dist/ coverage/ +.env +.env.* .env.test .env.test.local +# Per-workspace scratch space (probe scripts, live API dumps, plans, notes). +# May contain credentials and live API responses — never commit. +.context/ +# Conductor / Claude Code workspace state (scheduled tasks, etc.) +.claude/ *.js *.d.ts *.js.map diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c0b733c..b115ec4 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1,4 +1,6 @@ import https from "node:https"; +import { readdirSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; import type { LeadbayError, LensPayload, @@ -6,10 +8,12 @@ import type { IdealBuyerProfilePayload, PurchaseIntentTagPayload, AiAgentQuestionPayload, + RequestMeta, } from "./types.js"; -const LENS_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes -const TASTE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes +const LENS_CACHE_TTL_MS = 5 * 60 * 1000; +const TASTE_CACHE_TTL_MS = 10 * 60 * 1000; +const ME_CACHE_TTL_MS = 60 * 1000; const MAX_CONCURRENT = 5; const REGIONS: Record = { @@ -20,6 +24,8 @@ const REGIONS: Record = { interface HttpResult { status: number; body: string; + headers: Record; + latency_ms: number; } // Use node:https directly — the OpenClaw gateway patches globalThis.fetch @@ -31,6 +37,7 @@ function httpsRequest( body?: string ): Promise { return new Promise((resolve, reject) => { + const start = Date.now(); const parsed = new URL(url); const reqHeaders: Record = { ...headers }; if (body) { @@ -51,6 +58,8 @@ function httpsRequest( resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8"), + headers: res.headers as Record, + latency_ms: Date.now() - start, }); }); } @@ -81,25 +90,203 @@ export function createClient(config: CreateClientConfig = {}): LeadbayClient { `Leadbay: unknown region "${region}". Supported: ${Object.keys(REGIONS).join(", ")}. Or pass an explicit baseUrl.` ); } - return new LeadbayClient(baseUrl, config.token); + return new LeadbayClient(baseUrl, config.token, region); +} + +// Probe both regions to find which one this email/password works on. +// Returns the region (us|fr) and bearer token. Throws if neither succeeds. +export async function resolveRegion( + email: string, + password: string, + startWith: "us" | "fr" = "us" +): Promise<{ region: "us" | "fr"; baseUrl: string; token: string; verified: boolean }> { + const order: Array<"us" | "fr"> = + startWith === "fr" ? ["fr", "us"] : ["us", "fr"]; + + let lastErr: any = null; + for (const region of order) { + const baseUrl = REGIONS[region]; + const body = JSON.stringify({ email, password }); + try { + const res = await httpsRequest( + "POST", + `${baseUrl}/1.5/auth/login`, + { "Content-Type": "application/json" }, + body + ); + if (res.status === 200) { + const parsed = JSON.parse(res.body); + if (parsed?.token) { + return { + region, + baseUrl, + token: parsed.token, + verified: parsed.verified === true, + }; + } + } + lastErr = { status: res.status, body: res.body, region }; + } catch (e) { + lastErr = { error: e, region }; + } + } + + throw new Error( + `Leadbay login failed in both regions (us, fr). Last response: ${JSON.stringify( + lastErr + )}` + ); +} + +// ─── Mock mode (LEADBAY_MOCK=1) ────────────────────────────────────────── +// +// When enabled, GET requests are served from on-disk fixtures (the JSON dumps +// under .context/leadbay-live-shapes/ produced by the live probe scripts). +// POST/DELETE requests are journaled to an in-process Map and return +// {mocked: true, would_call: {...}}. +// +// Fixtures are matched by the trailing path segment of the request URL against +// each fixture's `request.url` field (also a trailing match). First fixture +// loaded for a given (method, path) wins. Designed for agent-author dry-running, +// not for fidelity. + +interface MockFixture { + method: string; + path: string; + status: number; + body: any; + headers: Record; +} + +let _mockFixtures: MockFixture[] | null = null; +let _mockJournal: Array<{ method: string; path: string; body?: unknown; ts: number }> = []; + +function loadMockFixtures(dir: string): MockFixture[] { + if (!existsSync(dir)) return []; + const out: MockFixture[] = []; + for (const f of readdirSync(dir)) { + if (!f.endsWith(".json")) continue; + try { + const raw = readFileSync(join(dir, f), "utf8"); + const parsed = JSON.parse(raw); + if (!parsed.request || !parsed.response) continue; + const url: string = parsed.request.url ?? ""; + const u = new URL(url); + out.push({ + method: parsed.request.method ?? "GET", + path: u.pathname + u.search, + status: parsed.response.status, + body: parsed.response.body, + headers: parsed.response.headers ?? {}, + }); + } catch { + // ignore malformed fixtures + } + } + return out; +} + +function ensureMockLoaded(): void { + if (_mockFixtures !== null) return; + const dir = + process.env.LEADBAY_MOCK_DIR ?? + join(process.cwd(), ".context", "leadbay-live-shapes"); + _mockFixtures = loadMockFixtures(dir); + if (process.env.LEADBAY_MOCK === "1") { + process.stderr.write( + `[leadbay mock] loaded ${_mockFixtures.length} fixtures from ${dir}\n` + ); + } +} + +function findMockFixture( + method: string, + basePath: string +): MockFixture | null { + ensureMockLoaded(); + if (!_mockFixtures) return null; + for (const f of _mockFixtures) { + if (f.method !== method) continue; + // The fixture path includes /1.5; the incoming basePath is /1.5/. + if (basePath === f.path) return f; + // Loose match: pathname segments equal (ignore query string differences). + const fNoQs = f.path.split("?")[0]; + const bNoQs = basePath.split("?")[0]; + if (fNoQs === bNoQs) return f; + } + return null; +} + +export function getMockJournal(): typeof _mockJournal { + return _mockJournal; +} + +export function clearMockJournal(): void { + _mockJournal = []; } export class LeadbayClient { private token: string | null; - readonly baseUrl: string; + private _baseUrl: string; + private _region: "us" | "fr" | "custom"; private defaultLensId: number | null = null; private defaultLensCachedAt: number | null = null; - private orgId: string | null = null; + private mePayload: UserMePayload | null = null; + private mePayloadCachedAt: number | null = null; private tasteProfile: TasteProfileResult | null = null; private tasteProfileCachedAt: number | null = null; - // Simple semaphore for concurrency limiting + // Simple semaphore for concurrency limiting. private activeRequests = 0; private waitQueue: Array<() => void> = []; - constructor(baseUrl: string, token?: string) { - this.baseUrl = baseUrl.replace(/\/+$/, ""); + // Selection-state Mutex. The /leads/selection/* endpoints share global + // server-side state per token, so two parallel composites that each call + // select → action → clear would clobber each other. Composites that touch + // selection acquire this lock for the lifetime of their select…clear cycle. + private selectionLockHeld = false; + private selectionWaitQueue: Array<() => void> = []; + + // Last response metadata — composites can read this after a request to + // surface latency/region/retry_after to the agent in their `_meta` block. + private _lastMeta: RequestMeta | null = null; + + constructor(baseUrl: string, token?: string, region?: "us" | "fr") { + this._baseUrl = baseUrl.replace(/\/+$/, ""); this.token = token ?? null; + this._region = region ?? ( + baseUrl === REGIONS.us ? "us" : + baseUrl === REGIONS.fr ? "fr" : "custom" + ); + } + + get baseUrl(): string { + return this._baseUrl; + } + + get region(): "us" | "fr" | "custom" { + return this._region; + } + + get lastMeta(): RequestMeta | null { + return this._lastMeta; + } + + // Used by login when region auto-detect picks a different backend than the + // one the client was constructed with. + setBaseUrl(baseUrl: string, region?: "us" | "fr"): void { + this._baseUrl = baseUrl.replace(/\/+$/, ""); + this._region = region ?? ( + baseUrl === REGIONS.us ? "us" : + baseUrl === REGIONS.fr ? "fr" : "custom" + ); + // Region change invalidates everything — different tenant. + this.defaultLensId = null; + this.defaultLensCachedAt = null; + this.mePayload = null; + this.mePayloadCachedAt = null; + this.tasteProfile = null; + this.tasteProfileCachedAt = null; } setToken(token: string): void { @@ -134,17 +321,44 @@ export class LeadbayClient { if (next) next(); } + // Selection Mutex — composites that touch /leads/selection/* must wrap + // their select…clear cycle in acquire/release to avoid clobbering across + // concurrent invocations. + async acquireSelectionLock(): Promise { + if (!this.selectionLockHeld) { + this.selectionLockHeld = true; + return; + } + return new Promise((resolve) => { + this.selectionWaitQueue.push(() => { + this.selectionLockHeld = true; + resolve(); + }); + }); + } + + releaseSelectionLock(): void { + this.selectionLockHeld = false; + const next = this.selectionWaitQueue.shift(); + if (next) next(); + } + async request(method: string, path: string, body?: unknown): Promise { + // Mock mode short-circuit (no auth required). + if (process.env.LEADBAY_MOCK === "1") { + return this.mockRequest(method, path, body); + } if (!this.token) { throw this.makeError( "NOT_AUTHENTICATED", "Not logged in to Leadbay", - "Set LEADBAY_TOKEN env var (obtain token at https://app.leadbay.ai/settings/api-tokens), or use the OpenClaw leadbay_login tool" + "Set LEADBAY_TOKEN env var (obtain token at https://app.leadbay.ai/settings/api-tokens), or use the OpenClaw leadbay_login tool", + path ); } await this.acquireSemaphore(); try { - const url = `${this.baseUrl}/1.5${path}`; + const url = `${this._baseUrl}/1.5${path}`; const headers: Record = { Authorization: `Bearer ${this.token}`, }; @@ -159,12 +373,19 @@ export class LeadbayClient { body ? JSON.stringify(body) : undefined ); + this._lastMeta = { + region: this._region, + endpoint: `${method} ${path}`, + latency_ms: res.latency_ms, + retry_after: parseRetryAfter(res.headers["retry-after"]), + }; + if (res.status === 204) { return null as T; } if (res.status < 200 || res.status >= 300) { - throw this.mapErrorResponse(res.status, res.body); + throw this.mapErrorResponse(res.status, res.body, path, res.headers); } return JSON.parse(res.body) as T; @@ -174,16 +395,21 @@ export class LeadbayClient { } async requestVoid(method: string, path: string, body?: unknown): Promise { + if (process.env.LEADBAY_MOCK === "1") { + await this.mockRequest(method, path, body); + return; + } if (!this.token) { throw this.makeError( "NOT_AUTHENTICATED", "Not logged in to Leadbay", - "Set LEADBAY_TOKEN env var (obtain token at https://app.leadbay.ai/settings/api-tokens), or use the OpenClaw leadbay_login tool" + "Set LEADBAY_TOKEN env var (obtain token at https://app.leadbay.ai/settings/api-tokens), or use the OpenClaw leadbay_login tool", + path ); } await this.acquireSemaphore(); try { - const url = `${this.baseUrl}/1.5${path}`; + const url = `${this._baseUrl}/1.5${path}`; const headers: Record = { Authorization: `Bearer ${this.token}`, }; @@ -198,15 +424,56 @@ export class LeadbayClient { body ? JSON.stringify(body) : undefined ); + this._lastMeta = { + region: this._region, + endpoint: `${method} ${path}`, + latency_ms: res.latency_ms, + retry_after: parseRetryAfter(res.headers["retry-after"]), + }; + if (res.status < 200 || res.status >= 300) { - throw this.mapErrorResponse(res.status, res.body); + throw this.mapErrorResponse(res.status, res.body, path, res.headers); } } finally { this.releaseSemaphore(); } } - private mapErrorResponse(status: number, rawBody: string): LeadbayError { + private mockRequest(method: string, path: string, body?: unknown): T { + const fullPath = `/1.5${path}`; + this._lastMeta = { + region: this._region, + endpoint: `${method} ${path}`, + latency_ms: 0, + retry_after: null, + }; + if (method === "GET") { + const fixture = findMockFixture("GET", fullPath); + if (!fixture) { + throw this.makeError( + "MOCK_NOT_FOUND", + `LEADBAY_MOCK=1: no fixture for GET ${path}`, + `Add a fixture to LEADBAY_MOCK_DIR (default: ./.context/leadbay-live-shapes/) — run a probe script to generate one.`, + path + ); + } + if (fixture.status === 204) return null as T; + return fixture.body as T; + } + // Writes: journal + return mocked envelope. + _mockJournal.push({ method, path: fullPath, body, ts: Date.now() }); + return { + mocked: true, + would_call: { method, path: fullPath, body }, + } as unknown as T; + } + + private mapErrorResponse( + status: number, + rawBody: string, + endpoint: string, + headers: Record + ): LeadbayError { let parsed: any; try { parsed = JSON.parse(rawBody); @@ -214,22 +481,37 @@ export class LeadbayClient { parsed = null; } + const retryAfter = parseRetryAfter(headers["retry-after"]); + if (status === 401) { return this.makeError( "AUTH_EXPIRED", "Authentication token expired or invalid", - "Your LEADBAY_TOKEN is no longer valid. Regenerate at https://app.leadbay.ai/settings/api-tokens and restart." + "Your LEADBAY_TOKEN is no longer valid. Regenerate at https://app.leadbay.ai/settings/api-tokens and restart.", + endpoint ); } - if (status === 402 || parsed?.error === "quota_exceeded") { + // 429 is the canonical quota signal in production. 402 is legacy. + if ( + status === 429 || + status === 402 || + parsed?.error === "quota_exceeded" || + parsed?.error?.code === "quota_exceeded" + ) { return this.makeError( "QUOTA_EXCEEDED", - "No enrichment credits remaining", - "Your Leadbay account is out of credits. Purchase more at https://app.leadbay.ai" + retryAfter + ? `Quota exceeded — retry in ${retryAfter}s` + : "Quota exceeded", + retryAfter + ? `Wait ${retryAfter}s before retrying. Check leadbay_get_quota to see which resource window was hit.` + : "Wait, then retry. Check leadbay_get_quota to see which resource window (daily/weekly/monthly) was hit.", + endpoint, + retryAfter ); } if (status === 403) { - const msg = parsed?.message || parsed?.error || ""; + const msg = parsed?.message || parsed?.error || parsed?.error?.message || ""; if ( typeof msg === "string" && (msg.includes("suspend") || msg.includes("billing")) @@ -237,36 +519,58 @@ export class LeadbayClient { return this.makeError( "BILLING_SUSPENDED", "Account billing is suspended", - "Your Leadbay account billing is suspended. Update at https://app.leadbay.ai" + "Your Leadbay account billing is suspended. Update at https://app.leadbay.ai", + endpoint ); } return this.makeError( "FORBIDDEN", "Insufficient permissions", - "Your token does not have access to this resource. Check account permissions at https://app.leadbay.ai" + "Your token does not have access to this resource. Check account permissions at https://app.leadbay.ai", + endpoint ); } if (status === 404) { return this.makeError( "NOT_FOUND", - parsed?.message || "Resource not found", - "Verify the ID is correct" - ); - } - if (status === 429) { - return this.makeError( - "RATE_LIMITED", - "Too many requests", - "Wait a moment and try again" + parsed?.message || parsed?.error?.message || "Resource not found", + "Verify the ID is correct", + endpoint ); } return this.makeError( "API_ERROR", - parsed?.message || `API error (${status})`, - "Try again or check the Leadbay API status" + parsed?.message || parsed?.error?.message || `API error (${status})`, + "Try again or check the Leadbay API status", + endpoint ); } + // /me cache (60s TTL). Separate from resolveOrgId() which still works for + // legacy callers (it now delegates here). + async resolveMe(force = false): Promise { + const now = Date.now(); + if ( + !force && + this.mePayload !== null && + this.mePayloadCachedAt !== null && + now - this.mePayloadCachedAt < ME_CACHE_TTL_MS + ) { + return this.mePayload; + } + const me = await this.request("GET", "/users/me"); + this.mePayload = me; + this.mePayloadCachedAt = now; + return me; + } + + // Force re-fetch on next resolveMe(). Call from any tool that mutates a + // /me-cached field (last_requested_lens, billing, etc.). + invalidateMe(): void { + this.mePayload = null; + this.mePayloadCachedAt = null; + } + async resolveDefaultLens(): Promise { const now = Date.now(); if ( @@ -277,16 +581,29 @@ export class LeadbayClient { return this.defaultLensId; } + // Prefer /me.last_requested_lens (cheaper than scanning /lenses). + try { + const me = await this.resolveMe(); + if (me.last_requested_lens != null) { + this.defaultLensId = me.last_requested_lens; + this.defaultLensCachedAt = now; + return this.defaultLensId; + } + } catch { + // fall through to /lenses scan + } + const lenses = await this.request("GET", "/lenses"); const active = lenses.find((l) => l.is_last_active); - const fallback = active || lenses.find((l) => l.is_default) || lenses[0]; + const fallback = active || lenses.find((l) => l.is_default || l.default) || lenses[0]; if (!fallback) { throw this.makeError( "NO_LENS", "No lenses found on your account", - "Create a lens in the Leadbay app first" + "Open the Leadbay web UI once to provision your first lens, or create one via the API", + "GET /lenses" ); } @@ -295,14 +612,14 @@ export class LeadbayClient { return this.defaultLensId; } - async resolveOrgId(): Promise { - if (this.orgId !== null) { - return this.orgId; - } + invalidateDefaultLens(): void { + this.defaultLensId = null; + this.defaultLensCachedAt = null; + } - const me = await this.request("GET", "/users/me"); - this.orgId = me.organization.id; - return this.orgId; + async resolveOrgId(): Promise { + const me = await this.resolveMe(); + return me.organization.id; } async resolveTasteProfile(): Promise { @@ -345,14 +662,49 @@ export class LeadbayClient { return this.tasteProfile; } + invalidateTasteProfile(): void { + this.tasteProfile = null; + this.tasteProfileCachedAt = null; + } + async prefetchOrgData(): Promise { await this.resolveOrgId(); await this.resolveTasteProfile(); } - makeError(code: string, message: string, hint: string): LeadbayError { - return { error: true, code, message, hint }; + makeError( + code: string, + message: string, + hint: string, + endpoint?: string, + retry_after?: number | null + ): LeadbayError { + const out: LeadbayError = { error: true, code, message, hint }; + if (endpoint || this._region) { + out._meta = { + region: this._region, + endpoint: endpoint ?? "", + latency_ms: this._lastMeta?.latency_ms ?? null, + retry_after: retry_after ?? null, + }; + } + return out; + } +} + +function parseRetryAfter( + value: string | string[] | undefined +): number | null { + if (!value) return null; + const v = Array.isArray(value) ? value[0] : value; + const n = Number(v); + if (Number.isFinite(n)) return n; + // RFC 7231 also allows HTTP-date — try Date.parse + const date = Date.parse(v); + if (Number.isFinite(date)) { + return Math.max(0, Math.round((date - Date.now()) / 1000)); } + return null; } export { REGIONS }; diff --git a/packages/core/src/composite/account-status.ts b/packages/core/src/composite/account-status.ts new file mode 100644 index 0000000..67af4d3 --- /dev/null +++ b/packages/core/src/composite/account-status.ts @@ -0,0 +1,55 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, ToolContext, QuotaStatusPayload } from "../types.js"; + +export const accountStatus: Tool> = { + name: "leadbay_account_status", + description: + "Show the user's account state — admin rights, language, last-active lens, current quota usage across " + + "daily/weekly/monthly windows for llm_completion / ai_rescore / web_fetch resources, and whether the org's " + + "intelligence is mid-regeneration. " + + "When to use: at the start of a session to know what the agent can/can't do, or after a 429 to explain to " + + "the user which resource window was exhausted and when it resets. " + + "When NOT to use: as a pre-flight gate before bulk ops — operations themselves return 429; this tool is " + + "for context, not gating.", + inputSchema: { type: "object", properties: {} }, + execute: async (client: LeadbayClient, _params, ctx?: ToolContext) => { + const me = await client.resolveMe(); + + let quota: QuotaStatusPayload | null = null; + try { + quota = await client.request( + "GET", + `/organizations/${me.organization.id}/quota_status` + ); + } catch (err: any) { + ctx?.logger?.warn?.( + `account_status: quota_status failed: ${err?.message ?? err?.code ?? err}` + ); + } + + return { + user: { + email: me.email ?? null, + name: me.name ?? null, + admin: me.admin ?? false, + manager: me.manager ?? false, + language: me.language ?? "en", + }, + organization: { + id: me.organization.id, + name: me.organization.name, + ai_agent_enabled: me.organization.ai_agent_enabled ?? false, + computing_intelligence: me.organization.computing_intelligence ?? false, + plan: quota?.plan ?? me.organization.quota_plan ?? null, + }, + last_requested_lens: me.last_requested_lens ?? null, + // Quota goes here verbatim from /quota_status. Legacy freemium.* fields + // on /me are intentionally NOT surfaced — they're defunct (see + // SHAPE-DRIFT.md probe round 4). + quota, + _meta: { + region: client.region, + }, + }; + }, +}; diff --git a/packages/core/src/composite/adjust-audience.ts b/packages/core/src/composite/adjust-audience.ts new file mode 100644 index 0000000..f354dd0 --- /dev/null +++ b/packages/core/src/composite/adjust-audience.ts @@ -0,0 +1,375 @@ +import type { LeadbayClient } from "../client.js"; +import type { + Tool, + ToolContext, + FilterPayload, + LensPayload, + SectorPayload, + FilterCriterion, +} from "../types.js"; + +interface AdjustAudienceParams { + sectors?: string[]; // free text or sector ids + sector_ids?: string[]; // explicit ids if known + exclude_sectors?: string[]; // free text or ids + sizes?: Array<{ min?: number; max?: number }>; + // (Locations resolution is a separate beast; not modelled here yet.) + lensId?: number; + save_for_org?: boolean; // admin only — propagate to org-level lens + newLensName?: string; // when default lens forces clone +} + +interface SectorAmbiguity { + sector_text: string; + matches: Array<{ id: string; name: string; score: number }>; +} + +function tokens(s: string): string[] { + return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean); +} + +function bestMatches( + text: string, + taxonomy: SectorPayload[] +): Array<{ id: string; name: string; score: number }> { + const want = new Set(tokens(text)); + if (want.size === 0) return []; + const ranked = taxonomy + .map((s) => { + const have = new Set(tokens(s.name)); + let overlap = 0; + for (const t of want) if (have.has(t)) overlap += 1; + const score = overlap / Math.max(want.size, 1); + return { id: s.id, name: s.name, score }; + }) + .filter((m) => m.score > 0) + .sort((a, b) => b.score - a.score); + return ranked.slice(0, 5); +} + +async function resolveSectors( + client: LeadbayClient, + texts: string[] +): Promise<{ resolved: string[]; ambiguities: SectorAmbiguity[] }> { + const looksLikeId = (s: string) => /^\d+$/.test(s); + const direct = texts.filter(looksLikeId); + const free = texts.filter((s) => !looksLikeId(s)); + if (free.length === 0) return { resolved: direct, ambiguities: [] }; + + const me = await client.resolveMe().catch(() => null); + const lang = me?.language ?? "en"; + const taxonomy = await client.request( + "GET", + `/sectors/all?lang=${encodeURIComponent(lang)}&includeInvisible=false` + ); + + const resolved = [...direct]; + const ambiguities: SectorAmbiguity[] = []; + for (const text of free) { + const matches = bestMatches(text, taxonomy); + // Confident match: exactly one with score > 0.66 (most tokens match) AND + // no close runner-up. + if ( + matches.length === 1 || + (matches.length >= 2 && matches[0].score >= 0.66 && matches[0].score - matches[1].score >= 0.34) + ) { + resolved.push(matches[0].id); + } else { + ambiguities.push({ sector_text: text, matches }); + } + } + return { resolved, ambiguities }; +} + +function mergeFilter( + current: FilterPayload, + toAddSectors: string[], + toExcludeSectors: string[], + sizes: Array<{ min?: number; max?: number }> | undefined +): FilterPayload { + const items = current?.lens_filter?.items ?? []; + const item = items[0] ?? { criteria: [] }; + const criteria: FilterCriterion[] = item.criteria ? [...item.criteria] : []; + + // sector_ids (include) — merge into existing or add. + if (toAddSectors.length > 0) { + const idx = criteria.findIndex( + (c) => c.type === "sector_ids" && !c.is_excluded + ); + if (idx >= 0) { + const cur = criteria[idx] as Extract; + const merged = Array.from(new Set([...(cur.sectors ?? []), ...toAddSectors])); + criteria[idx] = { ...cur, sectors: merged }; + } else { + criteria.push({ + type: "sector_ids", + is_excluded: false, + sectors: toAddSectors, + }); + } + } + + // sector_ids (exclude) + if (toExcludeSectors.length > 0) { + const idx = criteria.findIndex( + (c) => c.type === "sector_ids" && c.is_excluded + ); + if (idx >= 0) { + const cur = criteria[idx] as Extract; + const merged = Array.from(new Set([...(cur.sectors ?? []), ...toExcludeSectors])); + criteria[idx] = { ...cur, sectors: merged }; + } else { + criteria.push({ + type: "sector_ids", + is_excluded: true, + sectors: toExcludeSectors, + }); + } + } + + // size — replace if provided (single canonical size criterion). + if (sizes && sizes.length > 0) { + const idx = criteria.findIndex((c) => c.type === "size"); + if (idx >= 0) { + criteria[idx] = { type: "size", is_excluded: false, sizes }; + } else { + criteria.push({ type: "size", is_excluded: false, sizes }); + } + } + + return { + lens_filter: { items: [{ criteria }] }, + locations: current.locations ?? { results: [], parents: [] }, + }; +} + +export const adjustAudience: Tool = { + name: "leadbay_adjust_audience", + description: + "Restrict (or expand) the lens audience by sector / size. Free-text sectors are auto-resolved against " + + "the sector taxonomy; ambiguous matches are surfaced to the agent rather than guessed silently. " + + "Permission routing is hidden: the default lens auto-clones to a new user lens; an org-level lens defaults " + + "to a per-user draft (admins can override with save_for_org:true). Filter MERGES with existing criteria " + + "(unrelated criteria are not dropped). " + + "When to use: when the user wants to see different kinds of leads (sector / size / etc.). " + + "When NOT to use: to refine BEYOND firmographics — that's leadbay_refine_prompt.", + inputSchema: { + type: "object", + properties: { + sectors: { + type: "array", + items: { type: "string" }, + description: + "Sector free-text (e.g. ['Healthcare', 'Engineering']) or ids — auto-resolved", + }, + sector_ids: { + type: "array", + items: { type: "string" }, + description: "Explicit sector ids (skips taxonomy lookup)", + }, + exclude_sectors: { + type: "array", + items: { type: "string" }, + description: "Sectors to exclude (free text or ids)", + }, + sizes: { + type: "array", + items: { + type: "object", + properties: { min: { type: "number" }, max: { type: "number" } }, + }, + description: "Company size buckets, e.g. [{min:30,max:300}]", + }, + lensId: { type: "number", description: "Lens id (escape hatch)" }, + save_for_org: { + type: "boolean", + description: + "Admin only — propagate the change to the org-level lens for everyone (default false: per-user draft)", + }, + newLensName: { + type: "string", + description: + "Name to use when this composite has to clone the default lens (otherwise auto-named)", + }, + }, + }, + execute: async ( + client: LeadbayClient, + params: AdjustAudienceParams, + ctx?: ToolContext + ) => { + const me = await client.resolveMe(); + const isAdmin = me.admin === true; + const startingLensId = + params.lensId ?? me.last_requested_lens ?? (await client.resolveDefaultLens()); + + // Resolve free-text sectors (taxonomy lookup with fuzzy matching). + const includeTexts = [ + ...(params.sectors ?? []), + ...(params.sector_ids ?? []), + ]; + const excludeTexts = params.exclude_sectors ?? []; + + const includeRes = await resolveSectors(client, includeTexts); + const excludeRes = await resolveSectors(client, excludeTexts); + const ambiguities = [ + ...includeRes.ambiguities, + ...excludeRes.ambiguities, + ]; + + if (ambiguities.length > 0) { + return { + status: "ambiguous_sectors", + sector_ambiguities: ambiguities, + message: + "One or more sector names matched multiple sectors. Pick from the matches and re-call with sector_ids=...", + }; + } + + // Read the current lens (kind detection) + current filter. + const lens = await client.request( + "GET", + `/lenses/${startingLensId}` + ); + const currentFilter = await client.request( + "GET", + `/lenses/${startingLensId}/filter` + ); + const merged = mergeFilter( + currentFilter, + includeRes.resolved, + excludeRes.resolved, + params.sizes + ); + + const isDefault = lens.is_default || lens.default; + const isUserLevel = lens.user_id != null; + const isOrgLevel = !isUserLevel && !isDefault; + + let targetLensId = startingLensId; + let wasDraft = false; + let wasNew = false; + + if (isDefault) { + // Cannot edit default. Clone via POST /lenses {base, name}. + const name = params.newLensName ?? `Custom audience — ${new Date().toISOString().slice(0, 10)}`; + const newLens = await client.request("POST", "/lenses", { + base: startingLensId, + name, + }); + targetLensId = newLens.id; + wasNew = true; + // Apply filter to the new lens. + await client.requestVoid( + "POST", + `/lenses/${targetLensId}/filter`, + merged + ); + // Set as active. + await client.requestVoid( + "POST", + `/lenses/${targetLensId}/update_last_requested` + ); + } else if (isUserLevel) { + try { + await client.requestVoid( + "POST", + `/lenses/${startingLensId}/filter`, + merged + ); + } catch (err: any) { + if (err?.code === "FORBIDDEN") { + // Edge: user-level but somehow forbidden — fall through to draft path. + wasDraft = true; + const draft = await client.request( + "POST", + `/lenses/${startingLensId}/draft` + ); + targetLensId = draft.id; + await client.requestVoid( + "POST", + `/lenses/${targetLensId}/filter`, + merged + ); + await client.requestVoid( + "POST", + `/lenses/${targetLensId}/update_last_requested` + ); + } else { + throw err; + } + } + } else if (isOrgLevel) { + const goDraft = !isAdmin || !params.save_for_org; + if (goDraft) { + wasDraft = true; + const draft = await client.request( + "POST", + `/lenses/${startingLensId}/draft` + ); + targetLensId = draft.id; + try { + await client.requestVoid( + "POST", + `/lenses/${targetLensId}/filter`, + merged + ); + } catch (err: any) { + // Orphan-draft handling: try DELETE; if not supported, surface for manual cleanup. + ctx?.logger?.warn?.( + `adjust_audience: filter on draft ${targetLensId} failed: ${err?.message}` + ); + try { + await client.requestVoid("DELETE", `/lenses/${targetLensId}`); + } catch { + return { + error: true, + code: "ORPHAN_DRAFT", + message: `Draft ${targetLensId} created but filter update failed; draft cleanup also failed`, + hint: `Manually delete draft lens ${targetLensId} via the Leadbay UI`, + orphan_draft_id: targetLensId, + }; + } + throw err; + } + await client.requestVoid( + "POST", + `/lenses/${targetLensId}/update_last_requested` + ); + } else { + // Admin + save_for_org=true → direct mutation. + try { + await client.requestVoid( + "POST", + `/lenses/${startingLensId}/filter`, + merged + ); + } catch (err: any) { + throw err; + } + } + } + + // Cache invalidation — the active lens may have changed. + client.invalidateMe(); + client.invalidateDefaultLens(); + + return { + status: "applied", + lens_used: { + id: targetLensId, + name: lens.name, + was_draft: wasDraft, + was_new: wasNew, + save_for_org: params.save_for_org === true && isAdmin && isOrgLevel, + }, + filter_applied: merged, + message: wasDraft + ? "Applied to your personal draft of the org lens (your view only)." + : wasNew + ? `Created a new user-level lens "${lens.name}" with the filter (you can rename via leadbay_update_lens).` + : "Applied directly to the lens.", + _meta: { region: client.region }, + }; + }, +}; diff --git a/packages/core/src/composite/answer-clarification.ts b/packages/core/src/composite/answer-clarification.ts new file mode 100644 index 0000000..d380283 --- /dev/null +++ b/packages/core/src/composite/answer-clarification.ts @@ -0,0 +1,85 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, ToolContext, ClarificationPayload } from "../types.js"; + +interface AnswerClarificationParams { + option_id?: string; + text_answer?: string; +} + +export const answerClarification: Tool = { + name: "leadbay_answer_clarification", + description: + "Answer the pending clarification question Leadbay raised after a refine_prompt. The answer is stored as " + + "the new user_prompt and triggers regeneration. Pass option_id (preferred — pick from the offered options) " + + "or text_answer (free-text). Admin-only. " + + "When to use: after leadbay_refine_prompt returns status='clarification_pending'. " + + "When NOT to use: to set a brand-new prompt — use leadbay_refine_prompt.", + inputSchema: { + type: "object", + properties: { + option_id: { type: "string", description: "Id of one of the clarification's options" }, + text_answer: { type: "string", description: "Free-text answer (overrides option_id)" }, + }, + }, + execute: async ( + client: LeadbayClient, + params: AnswerClarificationParams, + ctx?: ToolContext + ) => { + if (!params.option_id && !params.text_answer) { + return { + error: true, + code: "BAD_INPUT", + message: "Provide option_id or text_answer", + hint: "Call leadbay_get_clarification first to see the options", + }; + } + + const me = await client.resolveMe(); + if (me.admin !== true) { + return { + error: true, + code: "FORBIDDEN", + message: "Answering clarifications requires admin rights", + hint: "Ask your Leadbay org admin to answer the clarification", + }; + } + + const orgId = me.organization.id; + + // Confirm there's actually a pending clarification before answering. + const pending = await client.request( + "GET", + `/organizations/${orgId}/clarifications` + ); + if (!pending) { + return { + status: "no_pending_clarification", + hint: + "There's no pending clarification — either it was already answered or none was raised. Use leadbay_refine_prompt to set a new prompt.", + }; + } + + const body: Record = {}; + if (params.text_answer) body.text_answer = params.text_answer; + if (params.option_id) body.option_id = params.option_id; + + await client.requestVoid( + "POST", + `/organizations/${orgId}/pick_clarification`, + body + ); + + // The backend stores the answer as the new user_prompt and clears + // clarification — invalidate /me cache (computing_intelligence is now true). + client.invalidateMe(); + + return { + status: "answered", + recorded_as_user_prompt: true, + message: + "Answer recorded. Leadbay is regenerating intelligence based on it. Check leadbay_account_status for computing_intelligence.", + _meta: { region: client.region }, + }; + }, +}; diff --git a/packages/core/src/composite/bulk-qualify-leads.ts b/packages/core/src/composite/bulk-qualify-leads.ts new file mode 100644 index 0000000..ca8373b --- /dev/null +++ b/packages/core/src/composite/bulk-qualify-leads.ts @@ -0,0 +1,251 @@ +import type { LeadbayClient } from "../client.js"; +import type { + Tool, + ToolContext, + WishlistResponse, + AiAgentResponse, + LeadWebFetchPayload, +} from "../types.js"; + +interface BulkQualifyLeadsParams { + count?: number; + leadIds?: string[]; + lensId?: number; + per_lead_budget_ms?: number; + total_budget_ms?: number; +} + +const PAGE_SIZE = 50; +const DEFAULT_COUNT = 10; +const MAX_COUNT = 25; +const DEFAULT_PER_LEAD_BUDGET_MS = 90_000; +const DEFAULT_TOTAL_BUDGET_MS = 5 * 60_000; + +interface QualResult { + lead_id: string; + qualification_summary: { + answered: number; + total: number; + avg_score_0_to_10: number | null; + } | null; + signals_count: number | null; +} + +export const bulkQualifyLeads: Tool = { + name: "leadbay_bulk_qualify_leads", + description: + "Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch), polling " + + "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. " + + "When to use: when the user wants more qualified leads than what's currently shown. " + + "When NOT to use: to qualify a single specific lead — that's leadbay_qualify_lead (granular, advanced).", + inputSchema: { + type: "object", + properties: { + count: { + type: "number", + description: `How many fresh leads to qualify (default ${DEFAULT_COUNT}, max ${MAX_COUNT})`, + }, + leadIds: { + type: "array", + items: { type: "string" }, + description: + "Explicit lead UUIDs to qualify (skips the auto-pagination)", + }, + lensId: { + type: "number", + description: "Lens id (escape hatch — defaults to active)", + }, + per_lead_budget_ms: { + type: "number", + description: `Polling budget per lead in ms (default ${DEFAULT_PER_LEAD_BUDGET_MS})`, + }, + total_budget_ms: { + type: "number", + description: `Total polling budget in ms (default ${DEFAULT_TOTAL_BUDGET_MS})`, + }, + }, + }, + execute: async ( + client: LeadbayClient, + params: BulkQualifyLeadsParams, + ctx?: ToolContext + ) => { + const wantCount = Math.min(params.count ?? DEFAULT_COUNT, MAX_COUNT); + const perLeadBudget = params.per_lead_budget_ms ?? DEFAULT_PER_LEAD_BUDGET_MS; + const totalBudget = params.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS; + const totalDeadline = Date.now() + totalBudget; + + let candidates: string[]; + let exhausted = false; + let totalUnqualifiedFound = 0; + let lensId: number; + + if (params.leadIds && params.leadIds.length > 0) { + candidates = params.leadIds; + lensId = params.lensId ?? (await client.resolveDefaultLens()); + } else { + lensId = params.lensId ?? (await client.resolveDefaultLens()); + candidates = []; + let page = 0; + while (candidates.length < wantCount) { + const wish = await client.request( + "GET", + `/lenses/${lensId}/leads/wishlist?count=${PAGE_SIZE}&page=${page}` + ); + if (wish.items.length === 0) { + exhausted = true; + break; + } + const fresh = wish.items.filter( + (l) => + l.ai_agent_lead_score == null && l.web_fetch_in_progress !== true + ); + totalUnqualifiedFound += fresh.length; + for (const lead of fresh) { + candidates.push(lead.id); + if (candidates.length >= wantCount) break; + } + if (page >= wish.pagination.pages - 1) { + exhausted = true; + break; + } + page += 1; + } + } + + if (candidates.length === 0) { + return { + qualified: [], + still_running: [], + failed: [], + quota_exceeded: false, + exhausted, + total_unqualified_found: totalUnqualifiedFound, + message: + "No unqualified leads found in this lens — either all leads have been qualified, or the wishlist is " + + "still computing (check leadbay_account_status for computing_wishlist).", + }; + } + + // Fan-out web_fetch triggers. On 429, stop launching further but let the + // already-launched ones complete. Concurrency capped by client semaphore. + const launched: string[] = []; + const failed: Array<{ lead_id: string; error: string }> = []; + let quotaExceeded = false; + + for (const leadId of candidates) { + if (quotaExceeded) break; + try { + await client.requestVoid( + "POST", + `/leads/${leadId}/web_fetch?force_fetch=false` + ); + launched.push(leadId); + } catch (err: any) { + if (err?.code === "QUOTA_EXCEEDED") { + quotaExceeded = true; + ctx?.logger?.warn?.( + `bulk_qualify_leads: 429 mid-fanout after launching ${launched.length}/${candidates.length} — stopping further launches but polling those already in flight` + ); + } else if (err?.code === "NOT_FOUND") { + failed.push({ lead_id: leadId, error: "lead not found" }); + } else { + failed.push({ + lead_id: leadId, + error: err?.message ?? err?.code ?? "unknown", + }); + } + } + } + + // Poll each launched lead in parallel until web_fetch.in_progress=false AND + // ai_agent_responses is populated, OR budget exhausted. + const results = await Promise.all( + launched.map(async (leadId): Promise => { + const leadDeadline = Math.min(Date.now() + perLeadBudget, totalDeadline); + let lastQual: AiAgentResponse[] | null = null; + let lastWf: LeadWebFetchPayload | null = null; + while (Date.now() < leadDeadline) { + try { + const [wfR, qualR] = await Promise.allSettled([ + client.request( + "GET", + `/leads/${leadId}/web_fetch` + ), + client.request( + "GET", + `/leads/${leadId}/ai_agent_responses` + ), + ]); + if (wfR.status === "fulfilled") lastWf = wfR.value; + if (qualR.status === "fulfilled") lastQual = qualR.value; + const done = + lastWf !== null && + lastWf.in_progress !== true && + Array.isArray(lastQual) && + lastQual.length > 0 && + lastQual.every((r) => r.score != null); + if (done) break; + } catch { + // ignore — try again on next tick + } + await new Promise((r) => setTimeout(r, 5_000)); + } + + const stillRunning = + lastWf?.in_progress === true || + !lastQual || + lastQual.length === 0 || + lastQual.some((r) => r.score == null); + + const responses = lastQual ?? []; + const scores = responses + .map((r) => r.score) + .filter((s): s is number => s != null); + const avg = + scores.length > 0 + ? Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / 10 + : null; + + return { + lead_id: leadId, + qualification_summary: + responses.length > 0 + ? { + answered: responses.filter((r) => r.score != null).length, + total: responses.length, + avg_score_0_to_10: avg, + } + : null, + signals_count: lastWf?.content + ? Object.values(lastWf.content).reduce( + (acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), + 0 + ) + : null, + _stillRunning: stillRunning, + }; + }) + ); + + const qualified = results + .filter((r) => !r._stillRunning) + .map(({ _stillRunning, ...rest }) => rest); + const still_running = results + .filter((r) => r._stillRunning) + .map(({ _stillRunning, ...rest }) => rest); + + return { + qualified, + still_running, + failed, + quota_exceeded: quotaExceeded, + exhausted, + total_unqualified_found: totalUnqualifiedFound, + lens_id: lensId, + _meta: { region: client.region }, + }; + }, +}; diff --git a/packages/core/src/composite/enrich-titles.ts b/packages/core/src/composite/enrich-titles.ts new file mode 100644 index 0000000..ac86326 --- /dev/null +++ b/packages/core/src/composite/enrich-titles.ts @@ -0,0 +1,235 @@ +import type { LeadbayClient } from "../client.js"; +import type { + Tool, + ToolContext, + BulkEnrichPreview, + WishlistResponse, +} from "../types.js"; + +interface EnrichTitlesParams { + titles?: string[]; + leadIds?: string[]; + lensId?: number; + email?: boolean; + phone?: boolean; + candidateCount?: number; + dry_run?: boolean; +} + +const DEFAULT_CANDIDATE_COUNT = 25; + +export const enrichTitles: Tool = { + name: "leadbay_enrich_titles", + description: + "Order contact enrichments by job title across many leads. Two modes: " + + "(A) NO titles param — 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 — 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. " + + "When to use: as the agent's go-to enrichment entry point. " + + "When NOT to use: to enrich a single contact — that's leadbay_enrich_contacts (granular).", + inputSchema: { + type: "object", + properties: { + titles: { + type: "array", + items: { type: "string" }, + description: + "Job titles to enrich. Omit to discover what's available without launching.", + }, + leadIds: { + type: "array", + items: { type: "string" }, + description: + "Lead UUIDs to enrich. Omit to use the top page of the active lens's wishlist.", + }, + lensId: { + type: "number", + description: "Lens id (escape hatch — defaults to active)", + }, + email: { type: "boolean", description: "Enrich emails (default true)" }, + phone: { type: "boolean", description: "Enrich phone numbers (default false)" }, + candidateCount: { + type: "number", + description: `When leadIds is omitted, how many top-of-wishlist leads to use (default ${DEFAULT_CANDIDATE_COUNT})`, + }, + dry_run: { + type: "boolean", + description: "If true, don't launch — only preview.", + }, + }, + }, + execute: async ( + client: LeadbayClient, + params: EnrichTitlesParams, + ctx?: ToolContext + ) => { + const email = params.email ?? true; + const phone = params.phone ?? false; + + if (!email && !phone) { + return { + error: true, + code: "BAD_INPUT", + message: "Either email or phone must be true", + hint: "Set email:true (most common) or phone:true", + }; + } + + let leadIds = params.leadIds; + if (!leadIds || leadIds.length === 0) { + const lensId = params.lensId ?? (await client.resolveDefaultLens()); + const cnt = params.candidateCount ?? DEFAULT_CANDIDATE_COUNT; + const wish = await client.request( + "GET", + `/lenses/${lensId}/leads/wishlist?count=${Math.min(cnt, 50)}&page=0` + ); + leadIds = wish.items.map((l) => l.id); + } + + if (leadIds.length === 0) { + return { + error: true, + code: "NO_CANDIDATES", + message: "No candidate leads", + hint: "Pass leadIds explicitly or wait for the wishlist to compute", + }; + } + + // Acquire selection lock — global state per token, must serialise. + await client.acquireSelectionLock(); + try { + const qs = leadIds + .map((id) => `leadIds=${encodeURIComponent(id)}`) + .join("&"); + await client.requestVoid("POST", `/leads/selection/select?${qs}`); + + try { + // Get titles available across this selection. + const availableTitles = await client.request( + "GET", + "/leads/selection/enrichment/job_titles" + ); + + if (!params.titles || params.titles.length === 0) { + // Branch A — discovery. Run a 0-titles preview to surface + // title_suggestions / auto_included_titles / previously_enriched_titles. + let suggestions: string[] = []; + let autoIncluded: string[] = []; + let previouslyEnriched: string[] = []; + let enrichableContacts = 0; + try { + const prev = await client.request( + "POST", + "/leads/selection/enrichment/preview", + { titles: [] } + ); + suggestions = prev.title_suggestions ?? []; + autoIncluded = prev.auto_included_titles ?? []; + previouslyEnriched = prev.previously_enriched_titles ?? []; + enrichableContacts = prev.enrichable_contacts; + } catch (e: any) { + ctx?.logger?.warn?.( + `enrich_titles: 0-titles preview failed: ${e?.message}` + ); + } + return { + mode: "discover", + available_titles: availableTitles, + recommendations: suggestions, + auto_included: autoIncluded, + previously_enriched: previouslyEnriched, + enrichable_contacts: enrichableContacts, + selected_lead_count: leadIds.length, + next_action: + "Pick titles to enrich and call leadbay_enrich_titles again with titles=[...]", + }; + } + + // Branch B — preview then launch. + let preview: BulkEnrichPreview; + try { + preview = await client.request( + "POST", + "/leads/selection/enrichment/preview", + { titles: params.titles } + ); + } catch (err: any) { + if (err?.code === "QUOTA_EXCEEDED") { + return { + status: "quota_exceeded", + message: "Quota exceeded on preview", + retry_after_seconds: err?._meta?.retry_after ?? null, + }; + } + throw err; + } + + if (preview.enrichable_contacts === 0) { + return { + mode: "preview_only", + preview, + launched: false, + message: + "No enrichable contacts for the chosen titles. Try other titles from available_titles or recommendations.", + available_titles: availableTitles, + }; + } + + if (params.dry_run) { + return { + mode: "dry_run", + preview, + launched: false, + would_launch: { titles: params.titles, email, phone }, + }; + } + + try { + await client.requestVoid( + "POST", + "/leads/selection/enrichment/launch", + { titles: params.titles, email, phone } + ); + } catch (err: any) { + if (err?.code === "QUOTA_EXCEEDED") { + return { + status: "quota_exceeded", + preview, + message: "Quota exceeded on launch", + retry_after_seconds: err?._meta?.retry_after ?? null, + }; + } + throw err; + } + + return { + mode: "launched", + preview, + launched: true, + titles: params.titles, + email, + phone, + message: + "Enrichment job launched. The Leadbay backend does not return a bulk_id (probed 2026-04-20) — " + + "track results by polling individual leads via leadbay_get_contacts after ~60s; contact.enrichment.done flips to true.", + next_action: + "Wait ~60s, then call leadbay_research_lead or leadbay_get_contacts on the leads you care about.", + }; + } finally { + // Always clear, but never re-throw from finally (would mask the + // original error if there was one). + try { + await client.requestVoid("POST", "/leads/selection/clear"); + } catch (e: any) { + ctx?.logger?.warn?.( + `enrich_titles: selection.clear failed: ${e?.message ?? e?.code}` + ); + } + } + } finally { + client.releaseSelectionLock(); + } + }, +}; diff --git a/packages/core/src/composite/prepare-outreach.ts b/packages/core/src/composite/prepare-outreach.ts index 4a7bade..4f60172 100644 --- a/packages/core/src/composite/prepare-outreach.ts +++ b/packages/core/src/composite/prepare-outreach.ts @@ -12,7 +12,11 @@ interface PrepareOutreachParams { export const prepareOutreach: Tool = { name: "leadbay_prepare_outreach", description: - "Prepare an outreach package for a lead: returns the recommended contact (best match by job title), their enriched email/phone (if available), and the lead's AI summary. If enrich=true and credits are available, will trigger enrichment on the recommended contact and return the ID to poll later. Write action — requires user-level permission.", + "Prepare an outreach package for a single lead: recommended contact + enriched contact details + AI summary. " + + "When to use: when the agent is about to draft outreach for ONE specific lead and needs the contact's email/phone. " + + "When NOT to use: across many leads — use leadbay_enrich_titles for bulk; for general lead detail use " + + "leadbay_research_lead (richer signals); to actually log the outreach action use leadbay_report_outreach " + + "(requires verification).", optional: true, inputSchema: { type: "object", diff --git a/packages/core/src/composite/pull-leads.ts b/packages/core/src/composite/pull-leads.ts new file mode 100644 index 0000000..536165f --- /dev/null +++ b/packages/core/src/composite/pull-leads.ts @@ -0,0 +1,162 @@ +import type { LeadbayClient } from "../client.js"; +import type { + Tool, + ToolContext, + WishlistResponse, + AiAgentResponse, + LeadPayload, +} from "../types.js"; + +interface PullLeadsParams { + lensId?: number; + count?: number; + page?: number; + verbose?: boolean; +} + +interface QualificationSummary { + answered: number; + total: number; + avg_score_0_to_10: number | null; + best_response_excerpt: string | null; +} + +function summarise(responses: AiAgentResponse[]): QualificationSummary { + const answered = responses.filter((r) => r.score != null).length; + const total = responses.length; + const scores = responses + .map((r) => r.score) + .filter((s): s is number => s != null); + const avg = + scores.length > 0 + ? Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / 10 + : null; + + // Find the highest-score response with non-empty text — the agent gets the + // single most-informative justification as a teaser, can drill in via + // research_lead for full text. + const best = [...responses] + .filter((r) => r.response && r.score != null) + .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0]; + + let excerpt = best?.response ?? null; + if (excerpt && excerpt.length > 200) { + excerpt = excerpt.slice(0, 197) + "..."; + } + + return { answered, total, avg_score_0_to_10: avg, best_response_excerpt: excerpt }; +} + +export const pullLeads: Tool = { + name: "leadbay_pull_leads", + description: + "Pull up new leads from the user's last-active lens (the canonical 'show me prospects to work on' tool). " + + "Each returned lead carries a one-line qualification_summary built from leadbay_ai_agent_responses, plus " + + "the rich tags / scores / recommended_contact_title / engagement counters / in-flight flags from the lead summary. " + + "When to use: as the agent's default opening move when the user wants to see leads. " + + "When NOT to use: when the user has named a specific lens — pass lensId to override the auto-resolution. " + + "Replaces the older leadbay_find_prospects (which is removed in v0.2.0).", + inputSchema: { + type: "object", + properties: { + lensId: { + type: "number", + description: + "Override the auto-resolved last-active lens (escape hatch — normally omit)", + }, + count: { type: "number", description: "Leads per page, max 50 (default 20)" }, + page: { type: "number", description: "Page number, 0-indexed (default 0)" }, + verbose: { + type: "boolean", + description: + "If true, include the full set of lead-summary fields. Default false: returns the trimmed agent-friendly form.", + }, + }, + }, + execute: async ( + client: LeadbayClient, + params: PullLeadsParams, + ctx?: ToolContext + ) => { + const lensId = params.lensId ?? (await client.resolveDefaultLens()); + const page = params.page ?? 0; + const count = Math.min(params.count ?? 20, 50); + const verbose = params.verbose ?? false; + + const res = await client.request( + "GET", + `/lenses/${lensId}/leads/wishlist?count=${count}&page=${page}&contacts=true` + ); + + // Fan-out qualification reads. Concurrency is capped by the client's + // semaphore (5 in flight). Soft-fail per lead — qualification_summary is + // additive, not load-bearing. + const summaries = await Promise.all( + res.items.map(async (lead) => { + try { + const r = await client.request( + "GET", + `/leads/${lead.id}/ai_agent_responses` + ); + return { leadId: lead.id, summary: summarise(r) }; + } catch (err: any) { + ctx?.logger?.warn?.( + `pull_leads: ai_agent_responses failed for lead ${lead.id}: ${err?.message ?? err?.code ?? err}` + ); + return { + leadId: lead.id, + summary: { + answered: 0, + total: 0, + avg_score_0_to_10: null, + best_response_excerpt: null, + }, + }; + } + }) + ); + const summaryMap = new Map(summaries.map((s) => [s.leadId, s.summary])); + + const trimmed = (lead: LeadPayload) => + verbose + ? lead + : { + id: lead.id, + name: lead.name, + score: lead.score, + ai_agent_lead_score: lead.ai_agent_lead_score, + location: lead.location, + short_description: lead.short_description ?? lead.description, + size: lead.size, + website: lead.website, + tags: lead.tags, + recommended_contact_title: lead.recommended_contact_title ?? null, + recommended_contact: lead.recommended_contact ?? null, + web_fetch_in_progress: lead.web_fetch_in_progress ?? false, + enrichment_in_progress: lead.enrichment_in_progress ?? false, + liked: lead.liked, + disliked: lead.disliked, + new: lead.new ?? false, + contacts_count: lead.contacts_count, + org_contacts_count: lead.org_contacts_count, + notes_count: lead.notes_count ?? 0, + epilogue_actions_count: lead.epilogue_actions_count ?? 0, + prospecting_actions_count: lead.prospecting_actions_count ?? 0, + }; + + return { + lens: { id: lensId }, + leads: res.items.map((lead) => ({ + ...trimmed(lead), + qualification_summary: summaryMap.get(lead.id) ?? null, + })), + pagination: res.pagination, + computing_wishlist: res.computing_wishlist, + computing_scores: res.computing_scores, + _meta: { + region: client.region, + latency_ms: client.lastMeta?.latency_ms ?? null, + }, + }; + }, +}; diff --git a/packages/core/src/composite/recall-ordered-titles.ts b/packages/core/src/composite/recall-ordered-titles.ts new file mode 100644 index 0000000..94858ce --- /dev/null +++ b/packages/core/src/composite/recall-ordered-titles.ts @@ -0,0 +1,165 @@ +import type { LeadbayClient } from "../client.js"; +import type { + Tool, + ToolContext, + BulkEnrichPreview, + PaidContactPayload, + WishlistResponse, +} from "../types.js"; + +interface RecallOrderedTitlesParams { + leadIds?: string[]; + lensId?: number; +} + +interface TitleStat { + title: string; + leads_with_enriched: number; + total_enriched_contacts: number; + leads_still_having_unenriched: number; +} + +export const recallOrderedTitles: Tool = { + name: "leadbay_recall_ordered_titles", + description: + "Show job titles the org has previously enriched, so the agent can repeat the same titles for new leads " + + "(or skip already-saturated ones). Two implementation paths: (1) PREFERRED: a selection-scoped " + + "preview call that reads previously_enriched_titles from the backend (newer prod field). (2) FALLBACK: " + + "live aggregation across each lead's enriched contacts. The composite picks transparently. " + + "When to use: before leadbay_enrich_titles, to plan which titles to order. " + + "When NOT to use: when you already know the exact titles you want to enrich.", + inputSchema: { + type: "object", + properties: { + leadIds: { + type: "array", + items: { type: "string" }, + description: + "Lead UUIDs to query. Omit to use the current wishlist (top page).", + }, + lensId: { + type: "number", + description: + "Override the auto-resolved last-active lens when omitting leadIds (escape hatch)", + }, + }, + }, + execute: async ( + client: LeadbayClient, + params: RecallOrderedTitlesParams, + ctx?: ToolContext + ) => { + let leadIds = params.leadIds; + + if (!leadIds || leadIds.length === 0) { + const lensId = params.lensId ?? (await client.resolveDefaultLens()); + const wish = await client.request( + "GET", + `/lenses/${lensId}/leads/wishlist?count=50&page=0` + ); + leadIds = wish.items.map((l) => l.id); + } + + if (leadIds.length === 0) { + return { titles: [], source: "live_aggregate", note: "No candidate leads" }; + } + + // Try preferred path: select → preview (titles=[]) → read previously_enriched_titles → clear. + // Selection state is global per token, so we serialise via the client Mutex. + await client.acquireSelectionLock(); + try { + const qs = leadIds + .map((id) => `leadIds=${encodeURIComponent(id)}`) + .join("&"); + try { + await client.requestVoid( + "POST", + `/leads/selection/select?${qs}` + ); + const preview = await client.request( + "POST", + "/leads/selection/enrichment/preview", + { titles: [] } + ); + if ( + Array.isArray(preview.previously_enriched_titles) && + preview.previously_enriched_titles.length > 0 + ) { + // Backend has the field — return its data directly. + return { + source: "preview_field", + titles: preview.previously_enriched_titles.map((t) => ({ title: t })), + available_in_selection: preview.title_suggestions ?? [], + }; + } + } catch (err: any) { + ctx?.logger?.warn?.( + `recall_ordered_titles: preview path failed: ${err?.message ?? err?.code ?? err}` + ); + } finally { + try { + await client.requestVoid("POST", "/leads/selection/clear"); + } catch (e: any) { + ctx?.logger?.warn?.( + `recall_ordered_titles: selection clear failed: ${e?.message}` + ); + } + } + } finally { + client.releaseSelectionLock(); + } + + // Fallback path: live aggregate from each lead's enriched contacts. + const titleStats = new Map(); + await Promise.all( + leadIds.map(async (leadId) => { + try { + const contacts = await client.request( + "GET", + `/leads/${leadId}/enrich/contacts?IncludeEnriched=true` + ); + const enriched = contacts.filter((c) => c.enrichment?.done && c.job_title); + const unenriched = contacts.filter((c) => !c.enrichment?.done && c.job_title); + const titlesEnrichedHere = new Set( + enriched.map((c) => c.job_title!) + ); + for (const t of titlesEnrichedHere) { + const cur = + titleStats.get(t) ?? + ({ + title: t, + leads_with_enriched: 0, + total_enriched_contacts: 0, + leads_still_having_unenriched: 0, + } as TitleStat); + cur.leads_with_enriched += 1; + cur.total_enriched_contacts += enriched.filter( + (c) => c.job_title === t + ).length; + titleStats.set(t, cur); + } + // Tally still-unenriched per title — useful to know if it's worth re-ordering. + for (const c of unenriched) { + const t = c.job_title!; + const cur = titleStats.get(t); + if (cur) cur.leads_still_having_unenriched += 1; + } + } catch (err: any) { + ctx?.logger?.warn?.( + `recall_ordered_titles: contacts fetch failed for ${leadId}: ${err?.message}` + ); + } + }) + ); + + return { + source: "live_aggregate", + titles: [...titleStats.values()].sort( + (a, b) => b.total_enriched_contacts - a.total_enriched_contacts + ), + note: + "Aggregated from individual leads' contacts (the backend's previously_enriched_titles field is not yet available). " + + "Once it ships, this composite switches to the cheaper preview-field path automatically.", + }; + }, +}; diff --git a/packages/core/src/composite/refine-prompt.ts b/packages/core/src/composite/refine-prompt.ts new file mode 100644 index 0000000..91df108 --- /dev/null +++ b/packages/core/src/composite/refine-prompt.ts @@ -0,0 +1,134 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, ToolContext, ClarificationPayload } from "../types.js"; + +interface RefinePromptParams { + prompt: string; + clarification_poll_attempts?: number; + clarification_poll_gap_ms?: number; + dry_run?: boolean; +} + +const DEFAULT_POLL_ATTEMPTS = 2; +const DEFAULT_POLL_GAP_MS = 5_000; + +export const refinePrompt: Tool = { + name: "leadbay_refine_prompt", + description: + "Refine the kind of leads Leadbay surfaces, beyond firmographics. Free-text instruction (e.g. 'focus on " + + "hospitals running their own IT'). Sets the org's user_prompt; if the new prompt produces ambiguous criteria, " + + "Leadbay raises a clarification question, which this composite polls for and surfaces. Admin-only on the " + + "backend (will return 403 for non-admins). " + + "When to use: when audience filters (leadbay_adjust_audience) aren't enough. " + + "When NOT to use: to answer a pending clarification — that's leadbay_answer_clarification.", + inputSchema: { + type: "object", + properties: { + prompt: { type: "string", description: "Refinement instruction (free text)" }, + clarification_poll_attempts: { + type: "number", + description: `How many times to poll for a clarification after setting (default ${DEFAULT_POLL_ATTEMPTS})`, + }, + clarification_poll_gap_ms: { + type: "number", + description: `Gap between polls in ms (default ${DEFAULT_POLL_GAP_MS})`, + }, + dry_run: { + type: "boolean", + description: "If true, return the call shape WITHOUT setting the prompt", + }, + }, + required: ["prompt"], + }, + execute: async ( + client: LeadbayClient, + params: RefinePromptParams, + ctx?: ToolContext + ) => { + const me = await client.resolveMe(); + if (me.admin !== true) { + return { + error: true, + code: "FORBIDDEN", + message: "leadbay_refine_prompt requires admin rights on the org", + hint: + "Ask your Leadbay org admin to set the refinement prompt, or use leadbay_adjust_audience for firmographic changes", + }; + } + + const orgId = me.organization.id; + if (params.dry_run) { + return { + dry_run: true, + would_call: { + method: "POST", + path: `/organizations/${orgId}/user_prompt`, + body: { prompt: params.prompt }, + }, + }; + } + + // Capture POST timestamp — used to discriminate stale clarifications from + // fresh ones produced by THIS prompt's regeneration. + const postedAt = Date.now(); + const STALE_GUARD_MS = 5_000; + + await client.requestVoid("POST", `/organizations/${orgId}/user_prompt`, { + prompt: params.prompt, + }); + + // Cache invalidation — /me's computing_intelligence flag is now true. + client.invalidateMe(); + + const attempts = params.clarification_poll_attempts ?? DEFAULT_POLL_ATTEMPTS; + const gap = params.clarification_poll_gap_ms ?? DEFAULT_POLL_GAP_MS; + let clarification: ClarificationPayload | null = null; + + for (let i = 0; i < attempts; i++) { + await new Promise((r) => setTimeout(r, gap)); + try { + const c = await client.request( + "GET", + `/organizations/${orgId}/clarifications` + ); + if (c) { + // Stale-guard: if created_at predates our POST by more than the + // small clock-skew window, treat it as stale and keep polling. + if (c.created_at) { + const createdMs = Date.parse(c.created_at); + if (Number.isFinite(createdMs) && createdMs < postedAt - STALE_GUARD_MS) { + ctx?.logger?.warn?.( + `refine_prompt: stale clarification (created_at=${c.created_at}, posted=${new Date(postedAt).toISOString()}) — ignoring` + ); + continue; + } + } + clarification = c; + break; + } + } catch (err: any) { + ctx?.logger?.warn?.( + `refine_prompt: clarification poll error: ${err?.message}` + ); + } + } + + if (clarification) { + return { + status: "clarification_pending", + clarification, + next_action: + "Call leadbay_answer_clarification with option_id (preferred) or text_answer to disambiguate", + _meta: { region: client.region }, + }; + } + + return { + status: "applied", + computing_intelligence: true, + message: + "Prompt set. Leadbay is regenerating intelligence; new leads will reflect the refinement shortly. " + + "Check leadbay_account_status to monitor computing_intelligence.", + _meta: { region: client.region }, + }; + }, +}; diff --git a/packages/core/src/composite/report-outreach.ts b/packages/core/src/composite/report-outreach.ts new file mode 100644 index 0000000..7600bbe --- /dev/null +++ b/packages/core/src/composite/report-outreach.ts @@ -0,0 +1,213 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, ToolContext, NotePayload } from "../types.js"; +import { EPILOGUE_LABEL_MAP } from "../tools/set-epilogue-status.js"; + +// Verification is REQUIRED on every call — the autoplan review (CEO + Eng + DX +// all flagged) determined that allowing the agent to self-report outreach +// without proof would poison the SDR pipeline. The user explicitly chose this +// option at the gate. Do NOT relax this without re-running the review. +type VerificationSource = "gmail_message_id" | "calendar_event_id" | "user_confirmed"; + +interface Verification { + source: VerificationSource; + ref: string; +} + +interface ReportOutreachParams { + lead_id?: string; + lead_ids?: string[]; + note: string; + epilogue_status?: string; + verification: Verification; + dry_run?: boolean; +} + +const VALID_SOURCES = new Set([ + "gmail_message_id", + "calendar_event_id", + "user_confirmed", +]); + +function formatNoteWithVerification( + note: string, + v: Verification +): string { + return `${note}\n\n— logged by AI agent (verification: ${v.source}=${v.ref})`; +} + +export const reportOutreach: Tool = { + name: "leadbay_report_outreach", + description: + "Log an outreach action (email, call, message, meeting) on a lead so the human team using Leadbay sees the " + + "progress in their UI. Writes a NOTE on the lead and (optionally) sets an EPILOGUE status (still chasing, " + + "meeting booked, etc.). " + + "VERIFICATION REQUIRED: every call must include verification={source: 'gmail_message_id'|'calendar_event_id'|'user_confirmed', ref: ''} " + + "to prevent hallucinated outreach poisoning the pipeline. The verification is appended to the note body. " + + "Bulk variant: pass lead_ids=[uuid,...] instead of lead_id (epilogue is bulk-native; notes fan out per-lead). " + + "When to use: AFTER actually emailing/calling/meeting/messaging a contact, OR after a substantive decision " + + "the user wants logged (skip, save, hand off). " + + "When NOT to use: BEFORE doing the outreach (use dry_run:true to validate args first); without verification " + + "(call will be rejected); from a flow where the user did not consent to having actions logged automatically.", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { + lead_id: { type: "string", description: "Single lead UUID (use lead_ids for bulk)" }, + lead_ids: { + type: "array", + items: { type: "string" }, + description: "Bulk: many lead UUIDs (epilogue applies to all; notes fan out)", + }, + note: { + type: "string", + description: + "1-2 sentence summary of what was done (e.g. 'Sent intro email to CTO citing Hornsea 3 contract')", + }, + epilogue_status: { + type: "string", + description: + "Optional: STILL_CHASING | COULD_NOT_REACH_STILL_TRYING | INTEREST_VALIDATED_OR_MEETING_PLANED | NOT_INTERESTED_LOST", + }, + verification: { + type: "object", + description: + "REQUIRED. Proof the action actually happened. source: gmail_message_id|calendar_event_id|user_confirmed. ref: the message id, event id, or the user's confirming text.", + properties: { + source: { type: "string" }, + ref: { type: "string" }, + }, + required: ["source", "ref"], + }, + dry_run: { + type: "boolean", + description: "If true, return what WOULD be called without writing anything", + }, + }, + required: ["note", "verification"], + }, + execute: async ( + client: LeadbayClient, + params: ReportOutreachParams, + ctx?: ToolContext + ) => { + if (!params.verification || !params.verification.source || !params.verification.ref) { + return { + error: true, + code: "VERIFICATION_REQUIRED", + message: + "report_outreach requires verification={source, ref} on every call. This prevents hallucinated outreach from poisoning the pipeline.", + hint: + "Provide verification.source as one of: gmail_message_id (the Gmail message id from sending), calendar_event_id (the event id from booking), or user_confirmed (set verification.ref to the user's literal confirmation in chat).", + }; + } + if (!VALID_SOURCES.has(params.verification.source)) { + return { + error: true, + code: "BAD_VERIFICATION_SOURCE", + message: `verification.source must be one of: gmail_message_id, calendar_event_id, user_confirmed (got: ${params.verification.source})`, + hint: + "Use 'user_confirmed' with verification.ref set to the user's literal text if you don't have a Gmail/Calendar id", + }; + } + if (!params.lead_id && (!params.lead_ids || params.lead_ids.length === 0)) { + return { + error: true, + code: "BAD_INPUT", + message: "Provide lead_id (single) or lead_ids (bulk)", + hint: "lead_id for one lead; lead_ids: [uuid, ...] for many", + }; + } + + const noteBody = formatNoteWithVerification(params.note, params.verification); + + let epilogueWire: string | null = null; + if (params.epilogue_status) { + const w = EPILOGUE_LABEL_MAP[params.epilogue_status]; + if (!w) { + return { + error: true, + code: "BAD_INPUT", + message: `Unknown epilogue_status: ${params.epilogue_status}`, + hint: `Use one of: STILL_CHASING, COULD_NOT_REACH_STILL_TRYING, INTEREST_VALIDATED_OR_MEETING_PLANED, NOT_INTERESTED_LOST`, + }; + } + epilogueWire = w; + } + + const targetLeads = params.lead_ids ?? [params.lead_id!]; + + if (params.dry_run) { + return { + dry_run: true, + would_write_notes: targetLeads.map((id) => ({ + method: "POST", + path: `/leads/${id}/notes`, + body: { note: noteBody }, + })), + would_set_epilogue: epilogueWire + ? { + method: "POST", + path: "/leads/epilogue", + body: { lead_ids: targetLeads, status: epilogueWire }, + } + : null, + }; + } + + // Write notes (parallel fan-out, semaphore-capped). Per-lead success/failure + // map for auditability. + const noteResults = await Promise.all( + targetLeads.map(async (leadId) => { + try { + const note = await client.request( + "POST", + `/leads/${leadId}/notes`, + { note: noteBody } + ); + return { lead_id: leadId, ok: true, note_id: note.id }; + } catch (err: any) { + return { + lead_id: leadId, + ok: false, + error: err?.message ?? err?.code ?? String(err), + }; + } + }) + ); + + let epilogueResult: { applied: boolean; error?: string } = { applied: false }; + if (epilogueWire) { + try { + await client.requestVoid("POST", "/leads/epilogue", { + lead_ids: targetLeads, + status: epilogueWire, + }); + epilogueResult = { applied: true }; + } catch (err: any) { + epilogueResult = { + applied: false, + error: err?.message ?? err?.code ?? String(err), + }; + ctx?.logger?.warn?.( + `report_outreach: epilogue failed: ${epilogueResult.error}` + ); + } + } + + return { + notes: { + succeeded: noteResults.filter((r) => r.ok).map((r) => ({ lead_id: r.lead_id, note_id: r.note_id })), + failed: noteResults + .filter((r) => !r.ok) + .map((r) => ({ lead_id: r.lead_id, error: r.error })), + }, + epilogue: { + status: epilogueWire, + ...epilogueResult, + }, + verification: params.verification, + _meta: { region: client.region }, + }; + }, +}; diff --git a/packages/core/src/composite/research-company.ts b/packages/core/src/composite/research-company.ts index a2ee7f3..8f9ed79 100644 --- a/packages/core/src/composite/research-company.ts +++ b/packages/core/src/composite/research-company.ts @@ -12,7 +12,10 @@ interface ResearchCompanyParams { export const researchCompany: Tool = { name: "leadbay_research_company", description: - "Deep-dive research on a specific company: full profile with AI qualification scores, web insights, contacts, and recent prospecting activity. Pass leadId if you already have it (from leadbay_find_prospects), or companyName to search for a match first.", + "Deep-dive research on a specific company by NAME (fuzzy match against the active lens's wishlist). " + + "When to use: when the user references a company by name and you don't yet have its lead_id. " + + "When NOT to use: when you already have the lead_id — use leadbay_research_lead directly (it bundles " + + "richer signals + better top-down ordering for the agent).", inputSchema: { type: "object", properties: { @@ -37,7 +40,7 @@ export const researchCompany: Tool = { throw client.makeError( "INVALID_PARAMS", "Pass either leadId or companyName", - "Use leadbay_find_prospects first to get a lead's ID, then call this with leadId." + "Call leadbay_pull_leads first to surface candidate leads with their IDs, then call this with leadId." ); } @@ -58,7 +61,7 @@ export const researchCompany: Tool = { throw client.makeError( "LEAD_NOT_FOUND", `No lead matching "${params.companyName}" in the current lens`, - "Try leadbay_find_prospects with a broader lens, or search by leadId instead." + "Call leadbay_pull_leads (optionally with a broader lensId) to see what's available, then call this with leadId." ); } leadId = match.id; diff --git a/packages/core/src/composite/research-lead.ts b/packages/core/src/composite/research-lead.ts new file mode 100644 index 0000000..ec42f2d --- /dev/null +++ b/packages/core/src/composite/research-lead.ts @@ -0,0 +1,242 @@ +import type { LeadbayClient } from "../client.js"; +import type { + Tool, + ToolContext, + AiAgentResponse, + LeadPayload, + LeadWebFetchPayload, + WebFetchSignalsSection, + PaidContactPayload, + ContactPayload, + NotePayload, + EpilogueResponsesPayload, + ProspectingActionsPayload, +} from "../types.js"; + +interface ResearchLeadParams { + leadId: string; + lensId?: number; + concise?: boolean; +} + +// Map an emoji-prefixed section label like "🏢 company profile" to +// {section_emoji: "🏢", section_label: "company profile"}. If no emoji, label +// stays as-is. Stable section ordering: profile → signals → clues → others. +const SECTION_PRIORITY = ["profile", "signals", "clues"]; + +function splitEmojiSection(key: string): { emoji: string | null; label: string } { + // Match a leading non-letter/non-digit character (typically emoji) followed by space. + const m = key.match(/^([^\p{L}\p{N}\s]+)\s+(.+)$/u); + if (m) return { emoji: m[1], label: m[2] }; + return { emoji: null, label: key }; +} + +function reshapeWebFetchContent( + content: Record | null +): WebFetchSignalsSection[] { + if (!content) return []; + const sections: WebFetchSignalsSection[] = []; + for (const [key, val] of Object.entries(content)) { + if (!Array.isArray(val)) continue; + const { emoji, label } = splitEmojiSection(key); + sections.push({ + section_label: label, + section_emoji: emoji, + entries: val as WebFetchSignalsSection["entries"], + }); + } + // Sort: known section labels first (in priority order), then alphabetical. + sections.sort((a, b) => { + const ai = SECTION_PRIORITY.findIndex((p) => a.section_label.toLowerCase().includes(p)); + const bi = SECTION_PRIORITY.findIndex((p) => b.section_label.toLowerCase().includes(p)); + const aN = ai < 0 ? SECTION_PRIORITY.length : ai; + const bN = bi < 0 ? SECTION_PRIORITY.length : bi; + if (aN !== bN) return aN - bN; + return a.section_label.localeCompare(b.section_label); + }); + return sections; +} + +export const researchLead: Tool = { + name: "leadbay_research_lead", + description: + "Tell me everything decision-relevant about a single lead. Bundles the lens-scoped lead profile, the AI " + + "qualification answers (the agent's knowledge-base food), the structured web-research signals (with hot " + + "flags + sources), the enriched contacts, and the recent notes/epilogue/prospecting activity in one call. " + + "Order is deliberate: qualification first, then signals, then firmographics, then contacts, then engagement. " + + "When to use: when picking up a single lead from leadbay_pull_leads to decide whether to act on it. " + + "When NOT to use: across many leads at once — that's leadbay_pull_leads' job. " + + "(This composite supersedes the lower-level leadbay_get_lead_profile in agent flow; the granular tool stays " + + "available for fine-grained access.)", + inputSchema: { + type: "object", + properties: { + leadId: { type: "string", description: "Lead UUID (required)" }, + lensId: { + type: "number", + description: + "Lens id (escape hatch — normally omit; auto-resolves to the active lens)", + }, + concise: { + type: "boolean", + description: + "If true, trim signals to hot=true items only (smaller payload). Default false.", + }, + }, + required: ["leadId"], + }, + execute: async ( + client: LeadbayClient, + params: ResearchLeadParams, + ctx?: ToolContext + ) => { + const lensId = params.lensId ?? (await client.resolveDefaultLens()); + const leadId = params.leadId; + + // Fan-out the four sub-fetches in parallel. Soft-fail on the additive ones. + const [profileR, qualR, contactsR, webFetchR] = await Promise.allSettled([ + client.request("GET", `/lenses/${lensId}/leads/${leadId}`), + client.request( + "GET", + `/leads/${leadId}/ai_agent_responses` + ), + client.request( + "GET", + `/leads/${leadId}/enrich/contacts?IncludeEnriched=true` + ), + client.request("GET", `/leads/${leadId}/web_fetch`), + ]); + + if (profileR.status === "rejected") { + throw profileR.reason; + } + const lead = profileR.value; + + // Notes/epilogue/prospecting — only fetch if the lead summary suggests + // there's anything to fetch. Saves 3 HTTP calls per lead in the common + // case where nothing has been logged yet. + const wantNotes = (lead.notes_count ?? 0) > 0; + const wantEpilogue = (lead.epilogue_actions_count ?? 0) > 0; + const wantProspecting = (lead.prospecting_actions_count ?? 0) > 0; + const wantOrgContacts = (lead.org_contacts_count ?? 0) > 0; + + const engagementFetches = await Promise.allSettled([ + wantNotes + ? client.request("GET", `/leads/${leadId}/notes`) + : Promise.resolve(null), + wantEpilogue + ? client.request( + "GET", + `/leads/${leadId}/epilogue_responses?count=10&page=0` + ) + : Promise.resolve(null), + wantProspecting + ? client.request( + "GET", + `/leads/${leadId}/prospecting_actions?count=10&page=0` + ) + : Promise.resolve(null), + wantOrgContacts + ? client.request( + "GET", + `/leads/${leadId}/contacts?IncludeEnriched=true` + ) + : Promise.resolve(null), + ]); + + const [notesR, epilogueR, prospR, orgContactsR] = engagementFetches; + const valOrNull = (r: PromiseSettledResult): T | null => + r.status === "fulfilled" ? (r.value ?? null) : null; + + let signals = reshapeWebFetchContent( + webFetchR.status === "fulfilled" ? webFetchR.value?.content ?? null : null + ); + if (params.concise) { + signals = signals + .map((s) => ({ + ...s, + entries: s.entries.filter((e) => e.hot === true), + })) + .filter((s) => s.entries.length > 0); + } + + const paidContacts = + contactsR.status === "fulfilled" ? contactsR.value : []; + const orgContacts = valOrNull(orgContactsR) ?? []; + + return { + // 1) qualification — single most important block for "is this lead worth pursuing" + qualification: + qualR.status === "fulfilled" + ? qualR.value.map((r) => ({ + question: r.question, + score_0_to_10: r.score, + response: r.response, + computed_at: r.computed_at, + })) + : [], + // 2) signals — knowledge-base food + signals, + // 3) firmographics + firmographics: { + id: lead.id, + name: lead.name, + sector_id: (lead as any).sector_id ?? null, + size: lead.size, + location: lead.location, + website: lead.website, + description: lead.description, + short_description: lead.short_description ?? null, + keywords: lead.keywords ?? [], + tags: lead.tags, + score: lead.score, + ai_agent_lead_score: lead.ai_agent_lead_score, + social_presence: lead.social_presence ?? null, + social_urls: (lead as any).social_urls ?? null, + registry_ids: (lead as any).registry_ids ?? null, + }, + // 4) contacts (paid/enriched, plus org contacts if present) + contacts: { + enriched: paidContacts.map((c) => ({ + id: c.id, + first_name: c.first_name, + last_name: c.last_name, + job_title: c.job_title, + email: c.email, + phone_number: c.phone_number, + linkedin_page: c.linkedin_page, + recommended: c.recommended, + enrichment_done: c.enrichment?.done ?? false, + })), + org: orgContacts.map((c) => ({ + id: c.id, + first_name: c.first_name, + last_name: c.last_name, + job_title: c.job_title, + email: c.email, + })), + }, + // 5) engagement — what humans/prior agent runs already did + engagement: { + liked: lead.liked, + disliked: lead.disliked, + new: lead.new ?? false, + recommended_contact_title: lead.recommended_contact_title ?? null, + recommended_contact: lead.recommended_contact ?? null, + notes_count: lead.notes_count ?? 0, + epilogue_actions_count: lead.epilogue_actions_count ?? 0, + prospecting_actions_count: lead.prospecting_actions_count ?? 0, + recent_notes: valOrNull(notesR)?.slice(0, 3) ?? [], + recent_epilogue: valOrNull(epilogueR)?.items?.slice(0, 3) ?? [], + recent_prospecting: + valOrNull(prospR)?.items?.slice(0, 5) ?? [], + }, + _meta: { + region: client.region, + lens_id: lensId, + web_fetch_in_progress: + webFetchR.status === "fulfilled" ? webFetchR.value?.in_progress : false, + }, + }; + }, +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c9361cc..9df0ce2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,8 +1,17 @@ -export { LeadbayClient, createClient, REGIONS } from "./client.js"; +export { + LeadbayClient, + createClient, + resolveRegion, + getMockJournal, + clearMockJournal, + REGIONS, +} from "./client.js"; export type { CreateClientConfig, TasteProfileResult } from "./client.js"; export * from "./types.js"; -// Granular tools (11, 1:1 with Leadbay API endpoints) +// ─── Granular tools — 1:1 with Leadbay API endpoints ───────────────────── + +// Existing (pre-autoplan) import { login } from "./tools/login.js"; import { listLenses } from "./tools/list-lenses.js"; import { discoverLeads } from "./tools/discover-leads.js"; @@ -15,32 +24,85 @@ import { enrichContacts } from "./tools/enrich-contacts.js"; import { addNote } from "./tools/add-note.js"; import { getLeadActivities } from "./tools/get-lead-activities.js"; -// Composite workflow tools (3, MCP primary surface) -import { findProspects } from "./composite/find-prospects.js"; +// New read tools (autoplan §E3) +import { getLensFilter } from "./tools/get-lens-filter.js"; +import { getLensScoring } from "./tools/get-lens-scoring.js"; +import { listSectors } from "./tools/list-sectors.js"; +import { getUserPrompt } from "./tools/get-user-prompt.js"; +import { getClarification } from "./tools/get-clarification.js"; +import { getLeadNotes } from "./tools/get-lead-notes.js"; +import { getEpilogueResponses } from "./tools/get-epilogue-responses.js"; +import { getProspectingActions } from "./tools/get-prospecting-actions.js"; +import { getWebFetch } from "./tools/get-web-fetch.js"; +import { getSelectionIds } from "./tools/get-selection-ids.js"; +import { getEnrichmentJobTitles } from "./tools/get-enrichment-job-titles.js"; + +// New write tools (autoplan §E5) — gated behind LEADBAY_MCP_WRITE=1 in MCP +import { selectLeads } from "./tools/select-leads.js"; +import { deselectLeads } from "./tools/deselect-leads.js"; +import { clearSelection } from "./tools/clear-selection.js"; +import { setActiveLens } from "./tools/set-active-lens.js"; +import { createLens } from "./tools/create-lens.js"; +import { updateLens } from "./tools/update-lens.js"; +import { updateLensFilter } from "./tools/update-lens-filter.js"; +import { createLensDraft } from "./tools/create-lens-draft.js"; +import { promoteLens } from "./tools/promote-lens.js"; +import { setUserPrompt } from "./tools/set-user-prompt.js"; +import { clearUserPrompt } from "./tools/clear-user-prompt.js"; +import { pickClarification } from "./tools/pick-clarification.js"; +import { dismissClarification } from "./tools/dismiss-clarification.js"; +import { setEpilogueStatus } from "./tools/set-epilogue-status.js"; +import { removeEpilogue } from "./tools/remove-epilogue.js"; +import { previewBulkEnrichment } from "./tools/preview-bulk-enrichment.js"; +import { launchBulkEnrichment } from "./tools/launch-bulk-enrichment.js"; + +// ─── Composite workflow tools — agent-facing surface ───────────────────── + +// Existing import { researchCompany } from "./composite/research-company.js"; import { prepareOutreach } from "./composite/prepare-outreach.js"; +// New (autoplan §E4 reads + §E6 writes) +import { pullLeads } from "./composite/pull-leads.js"; +import { researchLead } from "./composite/research-lead.js"; +import { recallOrderedTitles } from "./composite/recall-ordered-titles.js"; +import { accountStatus } from "./composite/account-status.js"; +import { bulkQualifyLeads } from "./composite/bulk-qualify-leads.js"; +import { enrichTitles } from "./composite/enrich-titles.js"; +import { adjustAudience } from "./composite/adjust-audience.js"; +import { refinePrompt } from "./composite/refine-prompt.js"; +import { answerClarification } from "./composite/answer-clarification.js"; +import { reportOutreach } from "./composite/report-outreach.js"; + import type { Tool } from "./types.js"; +// Re-export individual tools for granular consumers export { - login, - listLenses, - discoverLeads, - getLeadProfile, - getContacts, - getQuota, - getTasteProfile, - qualifyLead, - enrichContacts, - addNote, - getLeadActivities, - findProspects, - researchCompany, - prepareOutreach, + // existing granular + login, listLenses, discoverLeads, getLeadProfile, getContacts, getQuota, + getTasteProfile, qualifyLead, enrichContacts, addNote, getLeadActivities, + // new granular reads + getLensFilter, getLensScoring, listSectors, getUserPrompt, getClarification, + getLeadNotes, getEpilogueResponses, getProspectingActions, getWebFetch, + getSelectionIds, getEnrichmentJobTitles, + // new granular writes + selectLeads, deselectLeads, clearSelection, setActiveLens, createLens, + updateLens, updateLensFilter, createLensDraft, promoteLens, setUserPrompt, + clearUserPrompt, pickClarification, dismissClarification, setEpilogueStatus, + removeEpilogue, previewBulkEnrichment, launchBulkEnrichment, + // existing composite + researchCompany, prepareOutreach, + // new composite reads + pullLeads, researchLead, recallOrderedTitles, accountStatus, + // new composite writes + bulkQualifyLeads, enrichTitles, adjustAudience, refinePrompt, + answerClarification, reportOutreach, }; -export const granularTools: Tool[] = [ - login, +// ─── Tool catalogues ───────────────────────────────────────────────────── + +// Granular reads (advanced — gated by LEADBAY_MCP_ADVANCED=1 in MCP). +export const granularReadTools: Tool[] = [ listLenses, discoverLeads, getLeadProfile, @@ -48,20 +110,81 @@ export const granularTools: Tool[] = [ getTasteProfile, getContacts, getQuota, + getLensFilter, + getLensScoring, + listSectors, + getUserPrompt, + getClarification, + getLeadNotes, + getEpilogueResponses, + getProspectingActions, + getWebFetch, + getSelectionIds, + getEnrichmentJobTitles, +]; + +// Granular writes (advanced + write — gated by both LEADBAY_MCP_ADVANCED=1 +// AND LEADBAY_MCP_WRITE=1 in MCP). +export const granularWriteTools: Tool[] = [ qualifyLead, enrichContacts, addNote, + selectLeads, + deselectLeads, + clearSelection, + setActiveLens, + createLens, + updateLens, + updateLensFilter, + createLensDraft, + promoteLens, + setUserPrompt, + clearUserPrompt, + pickClarification, + dismissClarification, + setEpilogueStatus, + removeEpilogue, + previewBulkEnrichment, + launchBulkEnrichment, ]; -// Mark the granular tools as advanced so MCP can opt them out of the default list. +// Backward-compat alias (existing consumers use granularTools): +// includes login + reads + writes for OpenClaw which always exposes everything. +export const granularTools: Tool[] = [ + login, + ...granularReadTools, + ...granularWriteTools, +]; granularTools.forEach((t) => { t.advanced = true; }); -export const compositeTools: Tool[] = [ - findProspects, +// Composite read tools — always exposed (default agent surface). +export const compositeReadTools: Tool[] = [ + pullLeads, + researchLead, + recallOrderedTitles, + accountStatus, + // Keep the existing composites available too. researchCompany, prepareOutreach, ]; +// Composite write tools — always-exposed in OpenClaw, gated in MCP behind +// LEADBAY_MCP_WRITE=1 (the MCP server filters them out by default). +export const compositeWriteTools: Tool[] = [ + bulkQualifyLeads, + enrichTitles, + adjustAudience, + refinePrompt, + answerClarification, + reportOutreach, +]; + +// Backward-compat alias for existing consumers. +export const compositeTools: Tool[] = [ + ...compositeReadTools, + ...compositeWriteTools, +]; + export const tools: Tool[] = [...compositeTools, ...granularTools]; diff --git a/packages/core/src/tools/add-note.ts b/packages/core/src/tools/add-note.ts index e25fe41..d9cb969 100644 --- a/packages/core/src/tools/add-note.ts +++ b/packages/core/src/tools/add-note.ts @@ -10,7 +10,10 @@ interface AddNoteParams { export const addNote: Tool = { name: "leadbay_add_note", description: - "Add a note to a lead. Notes are visible to the whole organization in Leadbay.", + "Add a note to a lead. Notes are visible to the whole organization in Leadbay. " + + "When to use: low-level — for free-form notes not tied to outreach actions. " + + "When NOT to use: to log an outreach action — use leadbay_report_outreach, which requires verification " + + "(gmail/calendar/user_confirmed) to prevent hallucinated outreach poisoning the SDR pipeline.", optional: true, inputSchema: { type: "object", diff --git a/packages/core/src/tools/clear-selection.ts b/packages/core/src/tools/clear-selection.ts new file mode 100644 index 0000000..91d4bde --- /dev/null +++ b/packages/core/src/tools/clear-selection.ts @@ -0,0 +1,17 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +export const clearSelection: Tool> = { + name: "leadbay_clear_selection", + description: + "Clear the user's transient selection. " + + "When to use: cleanup after manual selection work, or recovery from a stuck composite. " + + "When NOT to use: in normal flow — composites clear in their own finally blocks.", + optional: true, + write: true, + inputSchema: { type: "object", properties: {} }, + execute: async (client: LeadbayClient) => { + await client.requestVoid("POST", "/leads/selection/clear"); + return { cleared: true }; + }, +}; diff --git a/packages/core/src/tools/clear-user-prompt.ts b/packages/core/src/tools/clear-user-prompt.ts new file mode 100644 index 0000000..391e114 --- /dev/null +++ b/packages/core/src/tools/clear-user-prompt.ts @@ -0,0 +1,21 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +export const clearUserPrompt: Tool> = { + name: "leadbay_clear_user_prompt", + description: + "Remove the org's intelligence-refinement prompt (revert to AI-only generation). Admin-only. " + + "Triggers full intelligence regeneration. " + + "When to use: when a refinement turned out to be the wrong direction. " + + "When NOT to use: to replace with a different prompt — just call leadbay_refine_prompt; that overwrites.", + optional: true, + write: true, + inputSchema: { type: "object", properties: {} }, + execute: async (client: LeadbayClient) => { + const orgId = await client.resolveOrgId(); + await client.requestVoid("DELETE", `/organizations/${orgId}/user_prompt`); + // Mutates organization.computing_intelligence — invalidate /me cache. + client.invalidateMe(); + return { cleared: true }; + }, +}; diff --git a/packages/core/src/tools/create-lens-draft.ts b/packages/core/src/tools/create-lens-draft.ts new file mode 100644 index 0000000..cbb8a35 --- /dev/null +++ b/packages/core/src/tools/create-lens-draft.ts @@ -0,0 +1,28 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, LensPayload } from "../types.js"; + +interface CreateLensDraftParams { + lensId: number; +} + +export const createLensDraft: Tool = { + name: "leadbay_create_lens_draft", + description: + "Create (or fetch existing) draft of an org-level lens. Idempotent — same user calling twice returns " + + "the same draft. The returned lens has draft_of set to the original lens id. " + + "When to use: when a non-admin needs to modify an org-level lens — make a draft, edit the draft. " + + "When NOT to use: from agent flow — leadbay_adjust_audience handles the draft-routing transparently.", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { lensId: { type: "number", description: "Lens id of the org-level lens to draft" } }, + required: ["lensId"], + }, + execute: async (client: LeadbayClient, params: CreateLensDraftParams) => { + return await client.request( + "POST", + `/lenses/${params.lensId}/draft` + ); + }, +}; diff --git a/packages/core/src/tools/create-lens.ts b/packages/core/src/tools/create-lens.ts new file mode 100644 index 0000000..30979ec --- /dev/null +++ b/packages/core/src/tools/create-lens.ts @@ -0,0 +1,38 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, LensPayload } from "../types.js"; + +interface CreateLensParams { + base: number; + name: string; + description?: string; +} + +export const createLens: Tool = { + name: "leadbay_create_lens", + description: + "Create a new user-level lens by cloning an existing lens's filter/scoring as the starting point. " + + "When to use: when adjust_audience determined the current lens cannot be edited (e.g. it's the org default). " + + "When NOT to use: to update an existing lens — use leadbay_update_lens or leadbay_update_lens_filter.", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { + base: { type: "number", description: "Base lens id to clone from" }, + name: { type: "string", description: "Display name for the new lens" }, + description: { type: "string" }, + }, + required: ["base", "name"], + }, + execute: async (client: LeadbayClient, params: CreateLensParams) => { + const lens = await client.request("POST", "/lenses", { + base: params.base, + name: params.name, + description: params.description, + }); + // /me's last_requested_lens is unchanged by creation, but the lens-list + // cache the client maintains is now stale. + client.invalidateDefaultLens(); + return lens; + }, +}; diff --git a/packages/core/src/tools/deselect-leads.ts b/packages/core/src/tools/deselect-leads.ts new file mode 100644 index 0000000..ffda62c --- /dev/null +++ b/packages/core/src/tools/deselect-leads.ts @@ -0,0 +1,38 @@ +// IMPORTANT: /leads/selection/deselect takes leadIds as repeated query params, +// same shape as /select. See SHAPE-DRIFT.md. + +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +interface DeselectLeadsParams { + leadIds: string[]; +} + +export const deselectLeads: Tool = { + name: "leadbay_deselect_leads", + description: + "Remove leads from the user's transient selection. " + + "When to use: when narrowing a previously-built selection without clearing it entirely. " + + "When NOT to use: in normal flow — leadbay_enrich_titles handles selection lifecycle.", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { + leadIds: { + type: "array", + items: { type: "string" }, + description: "Lead UUIDs to remove from selection", + minItems: 1, + }, + }, + required: ["leadIds"], + }, + execute: async (client: LeadbayClient, params: DeselectLeadsParams) => { + const qs = params.leadIds + .map((id) => `leadIds=${encodeURIComponent(id)}`) + .join("&"); + await client.requestVoid("POST", `/leads/selection/deselect?${qs}`); + return { deselected: params.leadIds.length }; + }, +}; diff --git a/packages/core/src/tools/discover-leads.ts b/packages/core/src/tools/discover-leads.ts index 18a1c36..ec875f5 100644 --- a/packages/core/src/tools/discover-leads.ts +++ b/packages/core/src/tools/discover-leads.ts @@ -11,7 +11,9 @@ interface DiscoverLeadsParams { export const discoverLeads: Tool = { name: "leadbay_discover_leads", description: - "Get AI-recommended leads from Leadbay. Returns paginated lead summaries with scores, AI summaries, tags, and recommended contacts. After discovering leads, call leadbay_get_lead_profile on promising ones for full qualification data, web insights, and all contacts. If lensId is omitted, uses the active lens automatically.", + "Get AI-recommended leads from Leadbay. Returns paginated lead summaries with scores, AI summaries, tags, and recommended contacts. " + + "When to use: low-level when you need raw paginated wishlist access without the qualification_summary attached by leadbay_pull_leads. " + + "When NOT to use: as the agent's default lead-discovery entry point — use leadbay_pull_leads, which adds a one-line qualification summary per lead.", inputSchema: { type: "object", properties: { diff --git a/packages/core/src/tools/dismiss-clarification.ts b/packages/core/src/tools/dismiss-clarification.ts new file mode 100644 index 0000000..14717ac --- /dev/null +++ b/packages/core/src/tools/dismiss-clarification.ts @@ -0,0 +1,24 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +export const dismissClarification: Tool> = { + name: "leadbay_dismiss_clarification", + description: + "Dismiss the pending clarification without answering. Leadbay proceeds with its best guess. Admin-only. " + + "When to use: when the user explicitly doesn't want to answer the disambiguation. " + + "When NOT to use: as a default — answering with even a free-text reason gives Leadbay better signal.", + optional: true, + write: true, + inputSchema: { type: "object", properties: {} }, + execute: async (client: LeadbayClient) => { + const orgId = await client.resolveOrgId(); + await client.requestVoid( + "POST", + `/organizations/${orgId}/dismiss_clarification` + ); + // Dismissing clears the pending clarification on the org — that state + // bleeds into /me via computing_intelligence reset. Invalidate cache. + client.invalidateMe(); + return { dismissed: true }; + }, +}; diff --git a/packages/core/src/tools/enrich-contacts.ts b/packages/core/src/tools/enrich-contacts.ts index 57f09a6..f5ac3eb 100644 --- a/packages/core/src/tools/enrich-contacts.ts +++ b/packages/core/src/tools/enrich-contacts.ts @@ -12,7 +12,10 @@ interface EnrichContactsParams { export const enrichContacts: Tool = { name: "leadbay_enrich_contacts", description: - "Order email and/or phone enrichment for a specific contact. The contactId must come from leadbay_get_lead_profile or leadbay_get_contacts — find the contact with recommended=true for the best match. Note: the recommended_contact on lead summaries does NOT include an ID. Enrichment is asynchronous — use leadbay_get_contacts after about 60 seconds to retrieve results.", + "Order email and/or phone enrichment for a specific contact. " + + "When to use: when you have a specific contact_id (from leadbay_get_contacts) and want to enrich just that one. " + + "When NOT to use: for bulk enrichment by job title across many leads — use leadbay_enrich_titles, which handles " + + "the selection lifecycle and returns a clean preview/launch flow.", optional: true, inputSchema: { type: "object", diff --git a/packages/core/src/tools/get-clarification.ts b/packages/core/src/tools/get-clarification.ts new file mode 100644 index 0000000..394731a --- /dev/null +++ b/packages/core/src/tools/get-clarification.ts @@ -0,0 +1,20 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, ClarificationPayload } from "../types.js"; + +export const getClarification: Tool> = { + name: "leadbay_get_clarification", + description: + "Check whether Leadbay has a pending clarification question — a question raised when refining the intelligence prompt produced contradictory or ambiguous criteria. " + + "Returns null when nothing is pending (the backend returns 204). " + + "When to use: after leadbay_refine_prompt, to see if Leadbay needs the user to disambiguate. " + + "When NOT to use: to answer the question — use leadbay_answer_clarification.", + inputSchema: { type: "object", properties: {} }, + execute: async (client: LeadbayClient) => { + const orgId = await client.resolveOrgId(); + const c = await client.request( + "GET", + `/organizations/${orgId}/clarifications` + ); + return c ?? { pending: false, clarification: null }; + }, +}; diff --git a/packages/core/src/tools/get-contacts.ts b/packages/core/src/tools/get-contacts.ts index ea57c64..0452818 100644 --- a/packages/core/src/tools/get-contacts.ts +++ b/packages/core/src/tools/get-contacts.ts @@ -9,7 +9,10 @@ interface GetContactsParams { export const getContacts: Tool = { name: "leadbay_get_contacts", description: - "Get contacts for a lead, including enriched email and phone data. Returns both organization contacts and enrichable contacts with IDs. The contact with recommended=true is the best match based on job title — prioritize enriching that one.", + "Get contacts for a lead, including enriched email and phone data. Returns both organization contacts and enrichable contacts with IDs. " + + "When to use: to check enrichment status (contact.enrichment.done) on individual leads after a bulk enrichment was launched, " + + "or to find the contact_id needed by leadbay_enrich_contacts. " + + "When NOT to use: as a substitute for leadbay_research_lead, which already includes enriched contacts in its return.", inputSchema: { type: "object", properties: { diff --git a/packages/core/src/tools/get-enrichment-job-titles.ts b/packages/core/src/tools/get-enrichment-job-titles.ts new file mode 100644 index 0000000..be0c863 --- /dev/null +++ b/packages/core/src/tools/get-enrichment-job-titles.ts @@ -0,0 +1,19 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +export const getEnrichmentJobTitles: Tool> = { + name: "leadbay_get_enrichment_job_titles", + description: + "List the actual job titles present across the leads currently in the user's selection — " + + "the candidate set the user can ask to enrich. " + + "When to use: after leadbay_select_leads, to know which titles are even available before launching a bulk enrichment. " + + "When NOT to use: standalone — the selection must already be populated, otherwise the result is an empty array. " + + "leadbay_enrich_titles wraps this whole flow when you don't need to inspect the title list manually.", + inputSchema: { type: "object", properties: {} }, + execute: async (client: LeadbayClient) => { + return await client.request( + "GET", + "/leads/selection/enrichment/job_titles" + ); + }, +}; diff --git a/packages/core/src/tools/get-epilogue-responses.ts b/packages/core/src/tools/get-epilogue-responses.ts new file mode 100644 index 0000000..cae0ab4 --- /dev/null +++ b/packages/core/src/tools/get-epilogue-responses.ts @@ -0,0 +1,36 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, EpilogueResponsesPayload } from "../types.js"; + +interface GetEpilogueResponsesParams { + leadId: string; + count?: number; + page?: number; +} + +export const getEpilogueResponses: Tool = { + name: "leadbay_get_epilogue_responses", + description: + "Read the lead's epilogue history — what status (still chasing, meeting booked, etc.) was set when, and by whom. " + + "When to use: to see the lead's outreach progression before deciding the next step. " + + "When NOT to use: when the lead summary's epilogue_actions_count is 0.", + inputSchema: { + type: "object", + properties: { + leadId: { type: "string", description: "Lead UUID (required)" }, + count: { type: "number", description: "Items per page (1-200, default 20)" }, + page: { type: "number", description: "Page number, 0-indexed (default 0)" }, + }, + required: ["leadId"], + }, + execute: async ( + client: LeadbayClient, + params: GetEpilogueResponsesParams + ) => { + const count = params.count ?? 20; + const page = params.page ?? 0; + return await client.request( + "GET", + `/leads/${params.leadId}/epilogue_responses?count=${count}&page=${page}` + ); + }, +}; diff --git a/packages/core/src/tools/get-lead-activities.ts b/packages/core/src/tools/get-lead-activities.ts index 1ee0145..e949f7e 100644 --- a/packages/core/src/tools/get-lead-activities.ts +++ b/packages/core/src/tools/get-lead-activities.ts @@ -10,7 +10,9 @@ interface GetLeadActivitiesParams { export const getLeadActivities: Tool = { name: "leadbay_get_lead_activities", description: - "Get prospecting activity history for a lead (emails sent, calls made, status changes, notes). Use this to avoid redundant outreach and understand where this lead is in the sales process.", + "Get prospecting activity history for a lead (emails sent, calls made, status changes, notes). " + + "When to use: to avoid redundant outreach and understand where this lead is in the sales process. " + + "When NOT to use: when leadbay_research_lead has already been called — it includes recent prospecting actions in its engagement block.", inputSchema: { type: "object", properties: { diff --git a/packages/core/src/tools/get-lead-notes.ts b/packages/core/src/tools/get-lead-notes.ts new file mode 100644 index 0000000..0be112d --- /dev/null +++ b/packages/core/src/tools/get-lead-notes.ts @@ -0,0 +1,25 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, NotePayload } from "../types.js"; + +interface GetLeadNotesParams { + leadId: string; +} + +export const getLeadNotes: Tool = { + name: "leadbay_get_lead_notes", + description: + "Read existing notes on a lead — context the human team or prior agent runs have already captured. " + + "When to use: before adding a note via leadbay_report_outreach, to avoid duplicating or overwriting context the SDR already wrote. " + + "When NOT to use: when the lead summary's notes_count is 0 — there's nothing to fetch.", + inputSchema: { + type: "object", + properties: { leadId: { type: "string", description: "Lead UUID (required)" } }, + required: ["leadId"], + }, + execute: async (client: LeadbayClient, params: GetLeadNotesParams) => { + return await client.request( + "GET", + `/leads/${params.leadId}/notes` + ); + }, +}; diff --git a/packages/core/src/tools/get-lead-profile.ts b/packages/core/src/tools/get-lead-profile.ts index a625d6f..4088e50 100644 --- a/packages/core/src/tools/get-lead-profile.ts +++ b/packages/core/src/tools/get-lead-profile.ts @@ -16,7 +16,11 @@ interface GetLeadProfileParams { export const getLeadProfile: Tool = { name: "leadbay_get_lead_profile", description: - "Get a full lead profile including company details, AI qualification scores, web insights (company profile, business signals, prospecting clues, key people, technologies), and contacts with recommended contact highlighted. Bundles multiple API calls into one response. If some data is unavailable, partial results are still returned.", + "Get a full lead profile including company details, AI qualification scores, web insights, and contacts. " + + "When to use: low-level — for fine-grained access to the raw shape of the lead profile. " + + "When NOT to use: as the agent's default lead-detail tool — use leadbay_research_lead, which structures " + + "the data top-down (qualification first, then signals, then firmographics, then contacts, then engagement) " + + "and reshapes web_fetch.content into a stable array form.", inputSchema: { type: "object", properties: { diff --git a/packages/core/src/tools/get-lens-filter.ts b/packages/core/src/tools/get-lens-filter.ts new file mode 100644 index 0000000..a731487 --- /dev/null +++ b/packages/core/src/tools/get-lens-filter.ts @@ -0,0 +1,27 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, FilterPayload } from "../types.js"; + +interface GetLensFilterParams { + lensId: number; +} + +export const getLensFilter: Tool = { + name: "leadbay_get_lens_filter", + description: + "Read the firmographic filter (sectors, sizes, locations) currently applied to a lens. " + + "When to use: before adjusting an audience — see what's already restricted so changes are diffs, not full replacements. " + + "When NOT to use: to actually apply changes — use the leadbay_adjust_audience composite, which handles permissions transparently.", + inputSchema: { + type: "object", + properties: { + lensId: { type: "number", description: "Lens id (required)" }, + }, + required: ["lensId"], + }, + execute: async (client: LeadbayClient, params: GetLensFilterParams) => { + return await client.request( + "GET", + `/lenses/${params.lensId}/filter` + ); + }, +}; diff --git a/packages/core/src/tools/get-lens-scoring.ts b/packages/core/src/tools/get-lens-scoring.ts new file mode 100644 index 0000000..7a4c3fb --- /dev/null +++ b/packages/core/src/tools/get-lens-scoring.ts @@ -0,0 +1,30 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +interface GetLensScoringParams { + lensId: number; +} + +interface LensScoringPayload { + criteria?: unknown; + [k: string]: unknown; +} + +export const getLensScoring: Tool = { + name: "leadbay_get_lens_scoring", + description: + "Read the AI-scoring criteria configured on a lens (what makes a lead score 100 vs 30). " + + "When to use: when explaining why a lead got the score it did. " + + "When NOT to use: to mutate scoring — that's an admin/setup operation, not part of the agent loop.", + inputSchema: { + type: "object", + properties: { lensId: { type: "number", description: "Lens id (required)" } }, + required: ["lensId"], + }, + execute: async (client: LeadbayClient, params: GetLensScoringParams) => { + return await client.request( + "GET", + `/lenses/${params.lensId}/scoring` + ); + }, +}; diff --git a/packages/core/src/tools/get-prospecting-actions.ts b/packages/core/src/tools/get-prospecting-actions.ts new file mode 100644 index 0000000..67fedb4 --- /dev/null +++ b/packages/core/src/tools/get-prospecting-actions.ts @@ -0,0 +1,36 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, ProspectingActionsPayload } from "../types.js"; + +interface GetProspectingActionsParams { + leadId: string; + count?: number; + page?: number; +} + +export const getProspectingActions: Tool = { + name: "leadbay_get_prospecting_actions", + description: + "Read the CRM-style activity log for a lead (calls, emails, meetings — actions performed by humans or prior agent runs). " + + "When to use: before contacting the lead, to avoid duplicating outreach the team already did. " + + "When NOT to use: when the lead summary's prospecting_actions_count is 0.", + inputSchema: { + type: "object", + properties: { + leadId: { type: "string", description: "Lead UUID (required)" }, + count: { type: "number", description: "Items per page (1-200, default 20)" }, + page: { type: "number", description: "Page number, 0-indexed (default 0)" }, + }, + required: ["leadId"], + }, + execute: async ( + client: LeadbayClient, + params: GetProspectingActionsParams + ) => { + const count = params.count ?? 20; + const page = params.page ?? 0; + return await client.request( + "GET", + `/leads/${params.leadId}/prospecting_actions?count=${count}&page=${page}` + ); + }, +}; diff --git a/packages/core/src/tools/get-quota.ts b/packages/core/src/tools/get-quota.ts index 9f767de..04733fb 100644 --- a/packages/core/src/tools/get-quota.ts +++ b/packages/core/src/tools/get-quota.ts @@ -1,23 +1,20 @@ import type { LeadbayClient } from "../client.js"; -import type { Tool } from "../types.js"; -import type { UserMePayload } from "../types.js"; +import type { Tool, QuotaStatusPayload } from "../types.js"; export const getQuota: Tool> = { name: "leadbay_get_quota", description: - "Check organization billing and AI credit quota. Useful before enrichment or qualification operations to verify available credits.", - inputSchema: { - type: "object", - properties: {}, - }, + "Read remaining quota / spend across daily, weekly, monthly windows for the org's resources " + + "(llm_completion, ai_rescore, web_fetch). Each entry shows current_units vs max_units and resets_at. " + + "When to use: after a 429 error, to explain to the user which window was hit and when it resets. " + + "When NOT to use: as a pre-flight gate before bulk operations — operations themselves return 429 with hints; " + + "this tool is for diagnostics, not gating.", + inputSchema: { type: "object", properties: {} }, execute: async (client: LeadbayClient) => { - const me = await client.request("GET", "/users/me"); - const org = me.organization; - return { - org_name: org.name, - ai_credits: org.billing?.ai_credits ?? null, - ai_credits_quota: org.billing?.ai_credits_quota ?? null, - billing_status: org.billing?.status ?? "unknown", - }; + const orgId = await client.resolveOrgId(); + return await client.request( + "GET", + `/organizations/${orgId}/quota_status` + ); }, }; diff --git a/packages/core/src/tools/get-selection-ids.ts b/packages/core/src/tools/get-selection-ids.ts new file mode 100644 index 0000000..3cc689c --- /dev/null +++ b/packages/core/src/tools/get-selection-ids.ts @@ -0,0 +1,14 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +export const getSelectionIds: Tool> = { + name: "leadbay_get_selection_ids", + description: + "List the lead ids currently in the user's selection (the transient set that bulk operations like enrichment act on). " + + "When to use: to verify the selection state before/after bulk ops if a composite call has misbehaved. " + + "When NOT to use: in the normal flow — leadbay_enrich_titles manages selection lifecycle automatically (select → action → clear).", + inputSchema: { type: "object", properties: {} }, + execute: async (client: LeadbayClient) => { + return await client.request("GET", "/leads/selection/ids"); + }, +}; diff --git a/packages/core/src/tools/get-taste-profile.ts b/packages/core/src/tools/get-taste-profile.ts index 70839cc..9829d21 100644 --- a/packages/core/src/tools/get-taste-profile.ts +++ b/packages/core/src/tools/get-taste-profile.ts @@ -4,7 +4,11 @@ import type { Tool } from "../types.js"; export const getTasteProfile: Tool> = { name: "leadbay_get_taste_profile", description: - "Get the user's Ideal Buyer Profile, purchase intent tags, and qualification questions. IMPORTANT: Call this at the very start of every session to understand what kind of leads the user is looking for. This data rarely changes and is cached.", + "Get the user's Ideal Buyer Profile, purchase intent tags, and qualification questions. " + + "When to use: at the very start of a session to understand what kind of leads the user is looking for. " + + "Data is cached. " + + "When NOT to use: per-lead — leadbay_research_lead already includes the per-lead qualification answers " + + "(which are scored against these org-level questions).", inputSchema: { type: "object", properties: {}, diff --git a/packages/core/src/tools/get-user-prompt.ts b/packages/core/src/tools/get-user-prompt.ts new file mode 100644 index 0000000..a19f5bb --- /dev/null +++ b/packages/core/src/tools/get-user-prompt.ts @@ -0,0 +1,21 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, UserPromptPayload } from "../types.js"; + +export const getUserPrompt: Tool> = { + name: "leadbay_get_user_prompt", + description: + "Read the org's intelligence-refinement prompt (free-text instruction that steers lead recommendations beyond firmographics). " + + "Returns null if none is set (the backend returns 204 in that case). " + + "When to use: to know what's currently steering the agent's recommendations before suggesting a refine. " + + "When NOT to use: to set/change the prompt — use leadbay_refine_prompt.", + inputSchema: { type: "object", properties: {} }, + execute: async (client: LeadbayClient) => { + const orgId = await client.resolveOrgId(); + // /user_prompt returns 204 when unset — request() returns null in that case. + const prompt = await client.request( + "GET", + `/organizations/${orgId}/user_prompt` + ); + return prompt ?? { prompt: null, set: false }; + }, +}; diff --git a/packages/core/src/tools/get-web-fetch.ts b/packages/core/src/tools/get-web-fetch.ts new file mode 100644 index 0000000..5ceddc3 --- /dev/null +++ b/packages/core/src/tools/get-web-fetch.ts @@ -0,0 +1,28 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, LeadWebFetchPayload } from "../types.js"; + +interface GetWebFetchParams { + leadId: string; +} + +export const getWebFetch: Tool = { + name: "leadbay_get_web_fetch", + description: + "Read the AI-generated web-research summary for a lead — company profile, business signals, prospecting clues, " + + "each with sources and 'hot' flags marking high-signal recent items. The content is dictioned by emoji-prefixed " + + "section labels in the raw API. " + + "When to use: when the agent already qualified this lead and wants the underlying research to reason from. " + + "When NOT to use: as the first read on a lead — the leadbay_research_lead composite bundles this with qualification " + + "answers and reshapes the dict into a stable array form.", + inputSchema: { + type: "object", + properties: { leadId: { type: "string", description: "Lead UUID (required)" } }, + required: ["leadId"], + }, + execute: async (client: LeadbayClient, params: GetWebFetchParams) => { + return await client.request( + "GET", + `/leads/${params.leadId}/web_fetch` + ); + }, +}; diff --git a/packages/core/src/tools/launch-bulk-enrichment.ts b/packages/core/src/tools/launch-bulk-enrichment.ts new file mode 100644 index 0000000..116afa1 --- /dev/null +++ b/packages/core/src/tools/launch-bulk-enrichment.ts @@ -0,0 +1,83 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +// IMPORTANT — backend behavior confirmed by live probe (SHAPE-DRIFT.md probe 5): +// `/leads/selection/enrichment/launch` returns 204 with no body and no headers +// other than `date`. There is NO bulk_id returned. There is NO list endpoint +// at /leads/bulk_enrichments to recover one. Polling per-bulk-job is therefore +// not possible from the agent. +// +// Track progress instead by polling individual leads via leadbay_get_contacts — +// when contact.enrichment.done flips to true, that contact has been enriched. + +interface LaunchBulkEnrichmentParams { + titles: string[]; + email?: boolean; + phone?: boolean; + dry_run?: boolean; +} + +export const launchBulkEnrichment: Tool = { + name: "leadbay_launch_bulk_enrichment", + description: + "Launch a bulk-enrichment job against the current selection. The backend requires email=true OR phone=true " + + "(both can be true). Returns 204 with no body — there is no bulk_id and no per-job status endpoint. " + + "Track results by polling individual leads via leadbay_get_contacts after ~60s; contact.enrichment.done flips to true. " + + "When to use: low-level. " + + "When NOT to use: from agent flow — leadbay_enrich_titles handles selection lifecycle, preview, launch, and cleanup.", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { + titles: { type: "array", items: { type: "string" } }, + email: { type: "boolean", description: "Enrich emails (default true)" }, + phone: { type: "boolean", description: "Enrich phone numbers (default false)" }, + dry_run: { + type: "boolean", + description: + "If true, return the call shape WITHOUT contacting the backend", + }, + }, + required: ["titles"], + }, + execute: async ( + client: LeadbayClient, + params: LaunchBulkEnrichmentParams + ) => { + const email = params.email ?? true; + const phone = params.phone ?? false; + if (!email && !phone) { + return { + error: true, + code: "BAD_INPUT", + message: "Either email or phone must be true", + hint: "Set email:true to enrich emails (most common), or phone:true for phone numbers", + }; + } + if (params.dry_run) { + return { + dry_run: true, + would_call: { + method: "POST", + path: "/leads/selection/enrichment/launch", + body: { titles: params.titles, email, phone }, + }, + }; + } + await client.requestVoid("POST", "/leads/selection/enrichment/launch", { + titles: params.titles, + email, + phone, + }); + return { + launched: true, + titles: params.titles, + email, + phone, + hint: + "Enrichment job launched. Poll individual leads' contacts after ~60s via leadbay_get_contacts(leadId) — " + + "contact.enrichment.done flips to true when done.", + }; + }, +}; diff --git a/packages/core/src/tools/list-lenses.ts b/packages/core/src/tools/list-lenses.ts index 5ae7dea..dea48b0 100644 --- a/packages/core/src/tools/list-lenses.ts +++ b/packages/core/src/tools/list-lenses.ts @@ -5,7 +5,10 @@ import type { LensPayload } from "../types.js"; export const listLenses: Tool> = { name: "leadbay_list_lenses", description: - "List all available Leadbay lenses (saved lead search configurations). Each lens defines a different target market or buyer segment. The lens with is_last_active=true is used by default for lead discovery.", + "List all available Leadbay lenses (saved lead search configurations). Each lens defines a different " + + "target market or buyer segment. The lens with is_last_active=true is used by default for lead discovery. " + + "When to use: when the user wants to switch lens or asks 'what lenses do I have'. " + + "When NOT to use: in normal flow — composites auto-resolve the active lens via /me.last_requested_lens.", inputSchema: { type: "object", properties: {}, diff --git a/packages/core/src/tools/list-sectors.ts b/packages/core/src/tools/list-sectors.ts new file mode 100644 index 0000000..188ff24 --- /dev/null +++ b/packages/core/src/tools/list-sectors.ts @@ -0,0 +1,44 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, SectorPayload } from "../types.js"; + +interface ListSectorsParams { + lang?: string; + includeInvisible?: boolean; +} + +export const listSectors: Tool = { + name: "leadbay_list_sectors", + description: + "List the sector taxonomy (id + display name in the requested language). " + + "When to use: to resolve a free-text sector name (e.g. 'Healthcare') into the sector ids " + + "that leadbay_adjust_audience needs. Default: lang follows the caller's language; " + + "includeInvisible=false returns ~1,091 visible sectors. " + + "When NOT to use: when you already have sector ids — pass them directly.", + inputSchema: { + type: "object", + properties: { + lang: { type: "string", description: "BCP-47 language tag (default: en)" }, + includeInvisible: { + type: "boolean", + description: + "Include sectors hidden from the UI (default false; ~91k items if true)", + }, + }, + }, + execute: async (client: LeadbayClient, params: ListSectorsParams) => { + // Prefer the caller's language when not specified — pulls from /me which + // is cached, so no extra latency in steady state. + let lang = params.lang; + if (!lang) { + try { + const me = await client.resolveMe(); + lang = me.language ?? "en"; + } catch { + lang = "en"; + } + } + const includeInvisible = params.includeInvisible ? "true" : "false"; + const path = `/sectors/all?lang=${encodeURIComponent(lang)}&includeInvisible=${includeInvisible}`; + return await client.request("GET", path); + }, +}; diff --git a/packages/core/src/tools/login.ts b/packages/core/src/tools/login.ts index c0abfe1..8e94a67 100644 --- a/packages/core/src/tools/login.ts +++ b/packages/core/src/tools/login.ts @@ -1,37 +1,6 @@ -import https from "node:https"; import type { LeadbayClient } from "../client.js"; import type { Tool, ToolContext } from "../types.js"; - -function httpsPost(url: string, body: string): Promise<{ status: number; body: string }> { - return new Promise((resolve, reject) => { - const parsed = new URL(url); - const req = https.request( - { - hostname: parsed.hostname, - port: 443, - path: parsed.pathname, - method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - }, - }, - (res) => { - const chunks: Buffer[] = []; - res.on("data", (chunk) => chunks.push(chunk)); - res.on("end", () => { - resolve({ - status: res.statusCode ?? 0, - body: Buffer.concat(chunks).toString("utf8"), - }); - }); - } - ); - req.on("error", reject); - req.write(body); - req.end(); - }); -} +import { resolveRegion, REGIONS } from "../client.js"; interface LoginParams { email: string; @@ -41,70 +10,70 @@ interface LoginParams { export const login: Tool = { name: "leadbay_login", description: - "Log in to Leadbay with email and password. Must be called before using any other Leadbay tool. The user needs a Leadbay account — they can register at https://wow.leadbay.ai/?register=true", + "Log in to Leadbay with email and password. Auto-detects region (us|fr) — the user does not need to know " + + "which backend their account lives on. " + + "When to use: at the start of a session if no token is preconfigured (cfg.token / LEADBAY_TOKEN). " + + "When NOT to use: if a token is already preconfigured (you'll just overwrite it). The user needs a Leadbay " + + "account — they can register at https://wow.leadbay.ai/?register=true", inputSchema: { type: "object", properties: { - email: { - type: "string", - description: "Leadbay account email address", - }, - password: { - type: "string", - description: "Leadbay account password", - }, + email: { type: "string", description: "Leadbay account email address" }, + password: { type: "string", description: "Leadbay account password" }, }, required: ["email", "password"], }, - execute: async (client: LeadbayClient, params: LoginParams, ctx?: ToolContext) => { + execute: async ( + client: LeadbayClient, + params: LoginParams, + ctx?: ToolContext + ) => { // Some LLMs backslash-escape special characters in tool call JSON - // (e.g. "Password1\!" instead of "Password1!"). Strip spurious escapes. + // (e.g. "secret\!" instead of "secret!"). Strip spurious escapes. const cleanPassword = params.password.replace(/\\(.)/g, "$1"); - const payload = JSON.stringify({ - email: params.email, - password: cleanPassword, - }); - ctx?.logger?.info?.(`LeadClaw login: email=${params.email} baseUrl=${client.baseUrl}`); - let result: { status: number; body: string }; + const startWith = + client.region === "fr" ? "fr" : "us"; + ctx?.logger?.info?.( + `LeadClaw login: email=${params.email} startRegion=${startWith}` + ); + try { - result = await httpsPost(`${client.baseUrl}/1.5/auth/login`, payload); - } catch (err: any) { - ctx?.logger?.error?.(`LeadClaw login: request error: ${err?.message}`); - return { - error: true, - code: "NETWORK_ERROR", - message: `Network error: ${err?.message}`, - hint: "Check your internet connection", - }; - } + const result = await resolveRegion( + params.email, + cleanPassword, + startWith + ); + + // Switch the client to the working region (clears tenant-scoped caches) + // if it differs from the constructed one. + if (client.baseUrl !== result.baseUrl) { + client.setBaseUrl(result.baseUrl, result.region); + ctx?.logger?.info?.( + `LeadClaw login: switched to region=${result.region} (account is in the ${result.region.toUpperCase()} backend)` + ); + } + client.setToken(result.token); - ctx?.logger?.info?.(`LeadClaw login: status=${result.status}`); + // Prefetch org data now that we're authenticated + client.prefetchOrgData().catch(() => {}); - if (result.status < 200 || result.status >= 300) { - ctx?.logger?.error?.(`LeadClaw login: error: ${result.body}`); - let msg = "Login failed"; - try { - const parsed = JSON.parse(result.body); - msg = parsed.message || parsed.error?.message || parsed.error || msg; - } catch {} + return { + success: true, + message: `Logged in to Leadbay (${result.region.toUpperCase()})`, + region: result.region, + verified: result.verified, + }; + } catch (err: any) { + ctx?.logger?.error?.(`LeadClaw login: failed: ${err?.message}`); return { error: true, code: "LOGIN_FAILED", - message: msg, - hint: "Check your email and password. Need an account? Register at https://wow.leadbay.ai/?register=true", + message: err?.message || "Login failed in both regions", + hint: + "Check your email and password. The auto-detect tried both " + + `${REGIONS.us} and ${REGIONS.fr}. Need an account? Register at https://wow.leadbay.ai/?register=true`, }; } - - const data = JSON.parse(result.body); - client.setToken(data.token); - - // Prefetch org data now that we're authenticated - client.prefetchOrgData().catch(() => {}); - - return { - success: true, - message: "Logged in to Leadbay successfully", - }; }, }; diff --git a/packages/core/src/tools/pick-clarification.ts b/packages/core/src/tools/pick-clarification.ts new file mode 100644 index 0000000..c899d72 --- /dev/null +++ b/packages/core/src/tools/pick-clarification.ts @@ -0,0 +1,49 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +interface PickClarificationParams { + option_id?: string; + text_answer?: string; +} + +export const pickClarification: Tool = { + name: "leadbay_pick_clarification", + description: + "Answer the pending clarification question — either by picking one of the offered options (option_id) " + + "or by typing a free-text answer. The answer is stored as the new user_prompt and triggers regeneration. " + + "Admin-only. " + + "When to use: low-level. " + + "When NOT to use: from agent flow — use leadbay_answer_clarification.", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { + option_id: { type: "string", description: "Id of one of the clarification's options" }, + text_answer: { type: "string", description: "Free-text answer (overrides option_id if both are set)" }, + }, + }, + execute: async ( + client: LeadbayClient, + params: PickClarificationParams + ) => { + if (!params.option_id && !params.text_answer) { + return { + error: true, + code: "BAD_INPUT", + message: "Provide either option_id or text_answer", + hint: "Call leadbay_get_clarification to see the options first", + }; + } + const orgId = await client.resolveOrgId(); + await client.requestVoid( + "POST", + `/organizations/${orgId}/pick_clarification`, + params + ); + // Stores answer as user_prompt and triggers regeneration → mutates + // organization.computing_intelligence on /me. Invalidate cache. + client.invalidateMe(); + return { answered: true }; + }, +}; diff --git a/packages/core/src/tools/preview-bulk-enrichment.ts b/packages/core/src/tools/preview-bulk-enrichment.ts new file mode 100644 index 0000000..7cca33e --- /dev/null +++ b/packages/core/src/tools/preview-bulk-enrichment.ts @@ -0,0 +1,40 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, BulkEnrichPreview } from "../types.js"; + +interface PreviewBulkEnrichmentParams { + titles: string[]; +} + +export const previewBulkEnrichment: Tool = { + name: "leadbay_preview_bulk_enrichment", + description: + "Preview a bulk-enrichment cost given a set of job titles applied to the current selection. Returns " + + "{selected_leads, enriched_contacts, enrichable_contacts, title_suggestions, auto_included_titles, previously_enriched_titles}. " + + "previously_enriched_titles is a newer field (in prod soon) — when present, the agent can recommend " + + "repeating those titles for new leads. " + + "When to use: between selecting leads and launching, to know what the enrichment will cost. " + + "When NOT to use: from agent flow — leadbay_enrich_titles wraps preview + launch with the right safety checks.", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { + titles: { + type: "array", + items: { type: "string" }, + description: "Job titles to enrich (matched against contacts in selected leads)", + }, + }, + required: ["titles"], + }, + execute: async ( + client: LeadbayClient, + params: PreviewBulkEnrichmentParams + ) => { + return await client.request( + "POST", + "/leads/selection/enrichment/preview", + { titles: params.titles } + ); + }, +}; diff --git a/packages/core/src/tools/promote-lens.ts b/packages/core/src/tools/promote-lens.ts new file mode 100644 index 0000000..8b90798 --- /dev/null +++ b/packages/core/src/tools/promote-lens.ts @@ -0,0 +1,26 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +interface PromoteLensParams { + lensId: number; +} + +export const promoteLens: Tool = { + name: "leadbay_promote_lens", + description: + "Promote a user-level lens (or draft) to org-level so all teammates see it. Admin-only. " + + "When to use: rare — when an admin user has built a lens (or refined a draft) and wants to share it org-wide. " + + "When NOT to use: as a non-admin (will fail with 403); for personal lens changes (those stay user-scoped).", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { lensId: { type: "number" } }, + required: ["lensId"], + }, + execute: async (client: LeadbayClient, params: PromoteLensParams) => { + await client.requestVoid("POST", `/lenses/${params.lensId}/promote`); + client.invalidateDefaultLens(); + return { promoted: true, lens_id: params.lensId }; + }, +}; diff --git a/packages/core/src/tools/qualify-lead.ts b/packages/core/src/tools/qualify-lead.ts index d8f4a69..17b5f42 100644 --- a/packages/core/src/tools/qualify-lead.ts +++ b/packages/core/src/tools/qualify-lead.ts @@ -9,7 +9,11 @@ interface QualifyLeadParams { export const qualifyLead: Tool = { name: "leadbay_qualify_lead", description: - "Trigger AI qualification for a lead. This fetches the lead's website and runs AI scoring and web insights generation. The operation is asynchronous — use leadbay_get_lead_profile after about 60 seconds to check qualification results and web insights.", + "Trigger AI qualification for a single lead (web fetch + AI rescore). The operation is asynchronous — " + + "results take ~60s. " + + "When to use: low-level. " + + "When NOT to use: as the agent's bulk-qualify path — use leadbay_bulk_qualify_leads, which paginates past " + + "already-qualified leads, fan-outs, polls, and bails out cleanly on 429.", optional: true, inputSchema: { type: "object", diff --git a/packages/core/src/tools/remove-epilogue.ts b/packages/core/src/tools/remove-epilogue.ts new file mode 100644 index 0000000..6ec190c --- /dev/null +++ b/packages/core/src/tools/remove-epilogue.ts @@ -0,0 +1,33 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +interface RemoveEpilogueParams { + lead_ids: string[]; +} + +export const removeEpilogue: Tool = { + name: "leadbay_remove_epilogue", + description: + "Bulk-clear the epilogue status from a set of leads. " + + "When to use: when an outreach action was logged in error and needs to be undone. " + + "When NOT to use: to change status — call leadbay_set_epilogue_status with the new status (it overwrites).", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { + lead_ids: { + type: "array", + items: { type: "string" }, + description: "Lead UUIDs", + }, + }, + required: ["lead_ids"], + }, + execute: async (client: LeadbayClient, params: RemoveEpilogueParams) => { + await client.requestVoid("POST", "/leads/remove_epilogue", { + lead_ids: params.lead_ids, + }); + return { cleared: true, count: params.lead_ids.length }; + }, +}; diff --git a/packages/core/src/tools/select-leads.ts b/packages/core/src/tools/select-leads.ts new file mode 100644 index 0000000..a304754 --- /dev/null +++ b/packages/core/src/tools/select-leads.ts @@ -0,0 +1,44 @@ +// IMPORTANT: /leads/selection/select takes leadIds as REPEATED QUERY PARAMS, +// not as a JSON body. A naive `body: {leadIds: [...]}` returns 400 "missing +// 'leadIds' parameter". This was confirmed by live probe (see +// .context/leadbay-live-shapes/SHAPE-DRIFT.md). Don't "fix" the lack of body. + +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +interface SelectLeadsParams { + leadIds: string[]; +} + +export const selectLeads: Tool = { + name: "leadbay_select_leads", + description: + "Add leads to the user's transient selection (used by selection-scoped bulk operations). " + + "When to use: low-level. The user's selection is a per-token global state — be careful when invoking " + + "directly. " + + "When NOT to use: in normal flow — leadbay_enrich_titles wraps select → action → clear in one call " + + "with proper Mutex protection. Calling this directly without acquiring the selection lock can clobber " + + "concurrent composite calls.", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { + leadIds: { + type: "array", + items: { type: "string" }, + description: "Lead UUIDs to add to selection (1-1000)", + minItems: 1, + maxItems: 1000, + }, + }, + required: ["leadIds"], + }, + execute: async (client: LeadbayClient, params: SelectLeadsParams) => { + const qs = params.leadIds + .map((id) => `leadIds=${encodeURIComponent(id)}`) + .join("&"); + await client.requestVoid("POST", `/leads/selection/select?${qs}`); + return { selected: params.leadIds.length }; + }, +}; diff --git a/packages/core/src/tools/set-active-lens.ts b/packages/core/src/tools/set-active-lens.ts new file mode 100644 index 0000000..20c02bc --- /dev/null +++ b/packages/core/src/tools/set-active-lens.ts @@ -0,0 +1,32 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +interface SetActiveLensParams { + lensId: number; +} + +export const setActiveLens: Tool = { + name: "leadbay_set_active_lens", + description: + "Mark a lens as last-used. Subsequent /me reads return it as last_requested_lens, so all composite " + + "tools default to it. " + + "When to use: after the user explicitly switched contexts (e.g. created a new lens via leadbay_create_lens). " + + "When NOT to use: in normal flow — leadbay_pull_leads and leadbay_adjust_audience auto-set the right lens.", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { lensId: { type: "number", description: "Lens id (required)" } }, + required: ["lensId"], + }, + execute: async (client: LeadbayClient, params: SetActiveLensParams) => { + await client.requestVoid( + "POST", + `/lenses/${params.lensId}/update_last_requested` + ); + // /me cache holds last_requested_lens — invalidate so next read reflects the change. + client.invalidateMe(); + client.invalidateDefaultLens(); + return { active_lens_id: params.lensId }; + }, +}; diff --git a/packages/core/src/tools/set-epilogue-status.ts b/packages/core/src/tools/set-epilogue-status.ts new file mode 100644 index 0000000..083d864 --- /dev/null +++ b/packages/core/src/tools/set-epilogue-status.ts @@ -0,0 +1,67 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, EpilogueStatusType } from "../types.js"; + +// Short labels accepted by the composite, mapped to the EPILOGUE_* enum the +// backend expects. Keeping a public mapping so callers (and tests) see exactly +// what the wire value will be. +export const EPILOGUE_LABEL_MAP: Record = { + STILL_CHASING: "EPILOGUE_STILL_CHASING", + COULD_NOT_REACH_STILL_TRYING: "EPILOGUE_COULD_NOT_REACH_STILL_TRYING", + INTEREST_VALIDATED_OR_MEETING_PLANED: "EPILOGUE_INTEREST_VALIDATED_OR_MEETING_PLANED", + NOT_INTERESTED_LOST: "EPILOGUE_NOT_INTERESTED_LOST", + // Also accept the long forms verbatim + EPILOGUE_STILL_CHASING: "EPILOGUE_STILL_CHASING", + EPILOGUE_COULD_NOT_REACH_STILL_TRYING: "EPILOGUE_COULD_NOT_REACH_STILL_TRYING", + EPILOGUE_INTEREST_VALIDATED_OR_MEETING_PLANED: "EPILOGUE_INTEREST_VALIDATED_OR_MEETING_PLANED", + EPILOGUE_NOT_INTERESTED_LOST: "EPILOGUE_NOT_INTERESTED_LOST", +}; + +interface SetEpilogueStatusParams { + lead_ids: string[]; + status: string; +} + +export const setEpilogueStatus: Tool = { + name: "leadbay_set_epilogue_status", + description: + "Bulk-set the outreach progress (epilogue) status across a set of leads. Status values: STILL_CHASING, " + + "COULD_NOT_REACH_STILL_TRYING, INTEREST_VALIDATED_OR_MEETING_PLANED ('meeting booked'), NOT_INTERESTED_LOST " + + "(short labels accepted; mapped to the EPILOGUE_* enum). Up to 1000 leads per call. " + + "When to use: low-level. " + + "When NOT to use: from agent flow — leadbay_report_outreach pairs this with a note + verification, which is " + + "what humans actually need to see in Leadbay.", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { + lead_ids: { + type: "array", + items: { type: "string" }, + description: "Lead UUIDs (1-1000)", + }, + status: { + type: "string", + description: + "One of: STILL_CHASING, COULD_NOT_REACH_STILL_TRYING, INTEREST_VALIDATED_OR_MEETING_PLANED, NOT_INTERESTED_LOST", + }, + }, + required: ["lead_ids", "status"], + }, + execute: async (client: LeadbayClient, params: SetEpilogueStatusParams) => { + const wire = EPILOGUE_LABEL_MAP[params.status]; + if (!wire) { + return { + error: true, + code: "BAD_INPUT", + message: `Unknown epilogue status: ${params.status}`, + hint: `Use one of: ${Object.keys(EPILOGUE_LABEL_MAP).filter((k) => !k.startsWith("EPILOGUE_")).join(", ")}`, + }; + } + await client.requestVoid("POST", "/leads/epilogue", { + lead_ids: params.lead_ids, + status: wire, + }); + return { applied: true, count: params.lead_ids.length, status: wire }; + }, +}; diff --git a/packages/core/src/tools/set-user-prompt.ts b/packages/core/src/tools/set-user-prompt.ts new file mode 100644 index 0000000..365d3d0 --- /dev/null +++ b/packages/core/src/tools/set-user-prompt.ts @@ -0,0 +1,52 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +interface SetUserPromptParams { + prompt: string; + dry_run?: boolean; +} + +export const setUserPrompt: Tool = { + name: "leadbay_set_user_prompt", + description: + "Set the org's intelligence-refinement prompt — free-text instruction that steers Leadbay's lead " + + "recommendations beyond firmographics. Admin-only. Setting this clears any pending clarification and " + + "triggers a full intelligence regeneration (web search + high-reasoning). " + + "When to use: low-level. " + + "When NOT to use: from agent flow — use leadbay_refine_prompt, which polls for follow-up clarifications.", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { + prompt: { type: "string", description: "Refinement instruction (free text)" }, + dry_run: { + type: "boolean", + description: + "If true, return the call shape that WOULD be sent without contacting the backend", + }, + }, + required: ["prompt"], + }, + execute: async (client: LeadbayClient, params: SetUserPromptParams) => { + const orgId = await client.resolveOrgId(); + if (params.dry_run) { + return { + dry_run: true, + would_call: { + method: "POST", + path: `/organizations/${orgId}/user_prompt`, + body: { prompt: params.prompt }, + }, + }; + } + await client.requestVoid("POST", `/organizations/${orgId}/user_prompt`, { + prompt: params.prompt, + }); + // Mutates organization.computing_intelligence (and clears any pending + // clarification). The /me cache holds organization.computing_intelligence; + // invalidate so polling helpers (e.g. account_status) see the fresh state. + client.invalidateMe(); + return { set: true }; + }, +}; diff --git a/packages/core/src/tools/update-lens-filter.ts b/packages/core/src/tools/update-lens-filter.ts new file mode 100644 index 0000000..0ea5fbb --- /dev/null +++ b/packages/core/src/tools/update-lens-filter.ts @@ -0,0 +1,58 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, FilterPayload } from "../types.js"; + +interface UpdateLensFilterParams { + lensId: number; + filter: FilterPayload; + dry_run?: boolean; +} + +export const updateLensFilter: Tool = { + name: "leadbay_update_lens_filter", + description: + "Replace the audience filter (sectors, sizes, locations) on a lens. Body is the full Filter object — " + + "this is a REPLACE, not a merge. Returns 400 'default_lens' if applied to the org default lens (clone it first). " + + "When to use: low-level mutation when you've already prepared the merged filter. " + + "When NOT to use: from agent flow — use leadbay_adjust_audience, which handles draft-vs-direct routing, " + + "permission fallback, and the merge logic so unrelated criteria aren't dropped.", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { + lensId: { type: "number", description: "Lens id" }, + filter: { + type: "object", + description: "Full FilterPayload (lens_filter + locations)", + }, + dry_run: { + type: "boolean", + description: + "If true, return the call shape that WOULD be sent without contacting the backend", + }, + }, + required: ["lensId", "filter"], + }, + execute: async ( + client: LeadbayClient, + params: UpdateLensFilterParams + ) => { + if (params.dry_run) { + return { + dry_run: true, + would_call: { + method: "POST", + path: `/lenses/${params.lensId}/filter`, + body: params.filter, + }, + }; + } + await client.requestVoid( + "POST", + `/lenses/${params.lensId}/filter`, + params.filter + ); + client.invalidateDefaultLens(); + return { updated: true, lens_id: params.lensId }; + }, +}; diff --git a/packages/core/src/tools/update-lens.ts b/packages/core/src/tools/update-lens.ts new file mode 100644 index 0000000..3f92cb9 --- /dev/null +++ b/packages/core/src/tools/update-lens.ts @@ -0,0 +1,38 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool } from "../types.js"; + +interface UpdateLensParams { + lensId: number; + name?: string; + description?: string; + multi_product_mode?: boolean; + use_hq_only?: boolean; +} + +export const updateLens: Tool = { + name: "leadbay_update_lens", + description: + "Update lens metadata (name, description, mode flags). Does NOT change the audience filter — use " + + "leadbay_update_lens_filter for that. " + + "When to use: rename a lens or toggle multi_product_mode/use_hq_only. " + + "When NOT to use: to change which leads the lens shows — that's a filter operation.", + optional: true, + write: true, + inputSchema: { + type: "object", + properties: { + lensId: { type: "number" }, + name: { type: "string" }, + description: { type: "string" }, + multi_product_mode: { type: "boolean" }, + use_hq_only: { type: "boolean" }, + }, + required: ["lensId"], + }, + execute: async (client: LeadbayClient, params: UpdateLensParams) => { + const { lensId, ...body } = params; + await client.requestVoid("POST", `/lenses/${lensId}`, body); + client.invalidateDefaultLens(); + return { updated: true, lens_id: lensId }; + }, +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4e98e56..759d349 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,33 +1,53 @@ // All interfaces use snake_case to match the Leadbay API (JsonNamingStrategy.SnakeCase) import type { LeadbayClient } from "./client.js"; +// Metadata propagated through every request — composites and the MCP error +// formatter use this so the agent can see WHICH region the call hit, what +// endpoint, how long it took, and (on 429) when to retry. There is no +// request-id header on the Leadbay backend (probed 2026-04-20), so we don't +// pretend there is one. +export interface RequestMeta { + region: "us" | "fr" | "custom"; + endpoint: string; + latency_ms: number | null; + retry_after: number | null; +} + export interface LeadbayError { error: true; code: string; message: string; hint: string; + _meta?: RequestMeta; } export interface LensPayload { id: number; name: string; - description: string | null; - is_last_active: boolean; + description?: string | null; + user_id?: string | null; + is_last_active?: boolean; is_default?: boolean; + default?: boolean; + draft_of?: number | null; + multi_product_mode?: boolean; + use_hq_only?: boolean; } export interface LocationPayload { - city: string | null; - state: string | null; - country: string | null; - full: string | null; - pos: [number, number] | null; + city?: string | null; + state?: string | null; + country?: string | null; + full?: string | null; + pos?: [number, number] | null; } export interface SizePayload { - low: number | null; - high: number | null; - label: string | null; + low?: number | null; + high?: number | null; + min?: number | null; + max?: number | null; + label?: string | null; } export interface SplitAiSummary { @@ -36,9 +56,12 @@ export interface SplitAiSummary { next_step: string | null; } +// Tags carry a confidence score from the lead-summary API. export interface LeadTag { - score: number; + id?: number; + display_name?: string; tag: string; + score: number; } export interface RecommendedContactPayload { @@ -50,6 +73,15 @@ export interface RecommendedContactPayload { phone_number?: string | null; } +export interface SocialPresence { + crunchbase?: boolean; + facebook?: boolean; + instagram?: boolean; + linkedin?: boolean; + tiktok?: boolean; + twitter?: boolean; +} + export interface LeadPayload { id: string; name: string; @@ -57,22 +89,34 @@ export interface LeadPayload { ai_agent_lead_score: number | null; location: LocationPayload | null; description: string | null; - short_description: string | null; + short_description?: string | null; size: SizePayload | null; website: string | null; - logo: string | null; + logo?: string | null; contacts_count: number; org_contacts_count: number; - ai_summary: string | null; - split_ai_summary: SplitAiSummary | null; + notes_count?: number; + epilogue_actions_count?: number; + prospecting_actions_count?: number; + ai_summary?: string | null; + split_ai_summary?: SplitAiSummary | null; liked: boolean; disliked: boolean; + new?: boolean; + exported?: boolean; tags: LeadTag[]; - phone_numbers: string[]; - keywords: Array<{ keyword: string; score: number }>; + phone_numbers?: string[]; + keywords?: Array<{ keyword: string; score: number }>; recommended_contact_title?: string | null; recommended_contact?: RecommendedContactPayload | null; web_fetch_in_progress?: boolean; + enrichment_in_progress?: boolean; + social_presence?: SocialPresence; + has_phone?: boolean; + in_monitor?: boolean; + in_discover?: boolean; + need_attention?: boolean; + need_attention_today?: boolean; } export interface PaginationPayload { @@ -88,6 +132,8 @@ export interface WishlistResponse { computing_scores: boolean; } +// AI-rescore answers — the highest-signal payload Leadbay produces per lead. +// Score is 0-10 PER QUESTION (different from the 0-100 lead-level scores). export interface AiAgentResponse { question: string; question_created_at: string; @@ -95,14 +141,14 @@ export interface AiAgentResponse { score: number | null; response: string | null; computed_at: string | null; - outdated_at: string | null; + outdated_at?: string | null; } export interface ContactEnrichment { done: boolean; - credits_used: number; - email_requested: boolean; - phone_requested: boolean; + credits_used?: number; + email_requested?: boolean; + phone_requested?: boolean; } export interface ContactPayload { @@ -121,18 +167,27 @@ export interface BillingStatePayload { status: string; ai_credits: number | null; ai_credits_quota: number | null; + freemium?: { daily_quota: number; monthly_quota: number }; } export interface OrgPayload { id: string; name: string; - billing: BillingStatePayload | null; + description?: string; + website?: string; + location?: string; + completed?: boolean; + ai_agent_enabled?: boolean; + computing_intelligence?: boolean; + quota_plan?: string; + billing?: BillingStatePayload | null; } export interface NotePayload { id: string; note: string; created_at: string; + user_id?: string; } export interface LoginResponse { @@ -140,26 +195,46 @@ export interface LoginResponse { verified: boolean; } +// Web-fetch content is a dynamic dict keyed by emoji-prefixed section labels +// (e.g. "🏢 company profile", "📈 business signals"). Composites that return +// this to the agent reshape it into an ordered array — see WebFetchSignals. +export interface WebFetchEntry { + hot?: boolean; + source: string; + date?: string; + description: string; +} + +export type WebFetchContent = Record; + export interface LeadWebFetchPayload { lead_id: string; - content: Record | null; + content: WebFetchContent | null; fetch_at: string | null; in_progress: boolean; } +// Composite-side reshaped form (avoids dynamic-key typing in agent payloads). +export interface WebFetchSignalsSection { + section_label: string; + section_emoji: string | null; + entries: WebFetchEntry[]; +} + export interface IdealBuyerProfilePayload { - summary: string; - key_characteristics: string[]; - anti_patterns: string[]; + summary?: string; + key_characteristics?: string[]; + anti_patterns?: string[]; + generated_at?: string; } export interface PurchaseIntentTagPayload { - id: number; + id?: number; display_name: string; tag: string; - description: string | null; - score: number | null; - reasoning: string | null; + description?: string | null; + score?: number | null; + reasoning?: string | null; } export interface AiAgentQuestionPayload { @@ -170,7 +245,15 @@ export interface AiAgentQuestionPayload { export interface UserMePayload { id: string; + email?: string; + name?: string; + verified?: boolean; + admin?: boolean; + manager?: boolean; organization: OrgPayload; + last_requested_lens?: number | null; + language?: string; + free_ai_credits?: number; } export interface PaidContactPayload { @@ -196,6 +279,132 @@ export interface PaginatedActivities { pagination: PaginationPayload; } +// ─── Lens filter (criteria-based, type discriminator) ───────────────────── + +export type FilterCriterion = + | { type: "sector_ids"; is_excluded: boolean; sectors: string[] } + | { type: "size"; is_excluded: boolean; sizes: Array<{ min?: number; max?: number }> } + | { type: string; is_excluded: boolean; [k: string]: unknown }; + +export interface LensFilterItem { + criteria: FilterCriterion[]; +} + +export interface LocationsBlock { + results: unknown[]; + parents: unknown[]; +} + +export interface FilterPayload { + lens_filter: { items: LensFilterItem[] }; + locations: LocationsBlock; +} + +// ─── Sectors taxonomy ───────────────────────────────────────────────────── + +export interface SectorPayload { + id: string; + name: string; + // The /sectors/all endpoint may also surface aliases / parent ids — kept + // permissive. + [k: string]: unknown; +} + +// ─── Selection / bulk enrichment ────────────────────────────────────────── + +export interface BulkEnrichPreview { + selected_leads: number; + enriched_contacts: number; + enrichable_contacts: number; + title_suggestions: string[]; + // Newer field — will be populated once the backend ships it. Falls back to + // live aggregation in `recall_ordered_titles` if absent. + previously_enriched_titles?: string[]; + auto_included_titles?: string[]; +} + +// ─── Org user_prompt + clarifications ───────────────────────────────────── + +export interface UserPromptPayload { + prompt: string; +} + +export interface ClarificationOption { + id?: string; + label: string; + prompt_fragment?: string; +} + +export interface ClarificationPayload { + id?: string; + question: string; + options?: ClarificationOption[]; + created_at?: string; +} + +// ─── Epilogue ───────────────────────────────────────────────────────────── + +export type EpilogueStatusType = + | "EPILOGUE_INTEREST_VALIDATED_OR_MEETING_PLANED" + | "EPILOGUE_COULD_NOT_REACH_STILL_TRYING" + | "EPILOGUE_NOT_INTERESTED_LOST" + | "EPILOGUE_STILL_CHASING"; + +export interface EpilogueResponseItem { + lead_id: string; + status: EpilogueStatusType; + set_at: string; + set_by?: string; +} + +export interface EpilogueResponsesPayload { + items: EpilogueResponseItem[]; + pagination: PaginationPayload; +} + +export interface ProspectingActionItem { + lead_id: string; + type: string; + date: string; + user_id?: string; +} + +export interface ProspectingActionsPayload { + items: ProspectingActionItem[]; + pagination: PaginationPayload; +} + +// ─── Quota status (live endpoint) ───────────────────────────────────────── + +export type QuotaWindow = "daily" | "weekly" | "monthly"; +export type QuotaResource = "llm_completion" | "ai_rescore" | "web_fetch" | string; + +export interface QuotaSpend { + current_units: number; + max_units: number; + window_type: QuotaWindow; + resets_at: string; +} + +export interface QuotaResourceUsage { + resource_type: QuotaResource; + count: number; + window_type: QuotaWindow; + resets_at: string; +} + +export interface QuotaStatusPayload { + plan: string; + org: { + spend: QuotaSpend[]; + resources: QuotaResourceUsage[]; + }; + user?: { + spend?: QuotaSpend[]; + resources?: QuotaResourceUsage[]; + }; +} + // ─── Protocol-agnostic Tool type ────────────────────────────────────────────── export interface ToolLogger { @@ -216,5 +425,11 @@ export interface Tool

{ inputSchema: JSONSchema; optional?: boolean; advanced?: boolean; + // Mutates Leadbay state. MCP server gates these behind LEADBAY_MCP_WRITE=1. + // OpenClaw exposes them when exposeWrite=true is set in plugin config. + write?: boolean; + // Per-tool semver — bumped when the tool's input/output contract changes. + // Defaults to "0.1.0" if absent. Used by MIGRATION.md tracking. + version?: string; execute: (client: LeadbayClient, params: P, ctx?: ToolContext) => Promise; } diff --git a/packages/core/test/harness.ts b/packages/core/test/harness.ts index 273c08e..1cf9f50 100644 --- a/packages/core/test/harness.ts +++ b/packages/core/test/harness.ts @@ -22,6 +22,9 @@ export interface RequestScript { status: number; body?: string | object; error?: Error; + // Optional HTTP response headers — useful for testing Retry-After parsing, + // region tagging on errors, etc. Defaults to empty object. + responseHeaders?: Record; } export interface CapturedRequest { @@ -104,6 +107,10 @@ function fakeHttpsRequest(options: any, callback?: (res: any) => void): any { const res = new EventEmitter() as any; res.statusCode = entry.script.status; + // Provide an empty headers object by default so client code that reads + // res.headers (Retry-After, etc.) works. Individual scripts may override + // via script.responseHeaders. + res.headers = (entry.script as any).responseHeaders ?? {}; setImmediate(() => { if (callback) callback(res); const bodyStr = diff --git a/packages/core/test/smoke/leadbay.live.test.ts b/packages/core/test/smoke/leadbay.live.test.ts index d3d5d15..481afb2 100644 --- a/packages/core/test/smoke/leadbay.live.test.ts +++ b/packages/core/test/smoke/leadbay.live.test.ts @@ -2,15 +2,28 @@ * LIVE smoke tests against the real Leadbay API. * Opt-in: set LEADBAY_TEST_TOKEN (and optionally LEADBAY_TEST_BASE_URL). * + * v0.2.0 extensions: cover the new endpoints the autoplan composites depend on + * (lens filter, user_prompt with 204 handling, clarifications with 204 + * handling, ai_agent_responses, quota_status, sectors taxonomy). + * * Governance: * - Use a DEDICATED test tenant * - Use a LEAST-PRIVILEGED, READ-ONLY token - * - Smoke hits only read endpoints (/lenses, /users/me, taste profile) - * - No live login / enrich / qualify / add-note + * - Smoke hits only read endpoints + * - No live login / enrich / qualify / add-note / set_user_prompt */ import { describe, it, expect } from "vitest"; import { LeadbayClient } from "../../src/client.js"; +import type { + ClarificationPayload, + FilterPayload, + QuotaStatusPayload, + SectorPayload, + UserPromptPayload, + AiAgentResponse, + WishlistResponse, +} from "../../src/types.js"; const TOKEN = process.env.LEADBAY_TEST_TOKEN; const BASE_URL = process.env.LEADBAY_TEST_BASE_URL ?? "https://api-us.leadbay.app"; @@ -26,11 +39,12 @@ if (!runLive) { describe.skipIf(!runLive)("LeadClaw live smoke (read-only endpoints)", () => { const client = new LeadbayClient(BASE_URL, TOKEN); - it("/users/me returns an organization with numeric ai_credits", async () => { + it("/users/me returns nested organization (post-v0.2 shape)", async () => { const me = await client.request("GET", "/users/me"); expect(me.organization).toBeTypeOf("object"); expect(me.organization.id).toBeTypeOf("string"); - expect(typeof me.organization.billing?.ai_credits).toBe("number"); + // last_requested_lens may be null on a fresh tenant. + expect(typeof me.last_requested_lens === "number" || me.last_requested_lens === null).toBe(true); }); it("/lenses returns a non-empty array with expected shape", async () => { @@ -38,12 +52,13 @@ describe.skipIf(!runLive)("LeadClaw live smoke (read-only endpoints)", () => { expect(Array.isArray(lenses)).toBe(true); expect(lenses.length).toBeGreaterThan(0); const l = lenses[0]; - expect(l.id).toBeTypeOf("number"); + // 2026-04-21: the backend sometimes returns lens.id as a string (e.g. "21520") + // and sometimes as a number (e.g. 21448). LensPayload allows both. See SHAPE-DRIFT.md. + expect(typeof l.id === "number" || typeof l.id === "string").toBe(true); expect(l.name).toBeTypeOf("string"); - expect(typeof l.is_last_active === "boolean").toBe(true); }); - it("resolveDefaultLens returns a numeric lens id", async () => { + it("resolveDefaultLens returns a numeric lens id (prefers /me.last_requested_lens)", async () => { const id = await client.resolveDefaultLens(); expect(typeof id).toBe("number"); }); @@ -54,4 +69,70 @@ describe.skipIf(!runLive)("LeadClaw live smoke (read-only endpoints)", () => { expect(Array.isArray(tp.purchaseIntentTags)).toBe(true); expect(Array.isArray(tp.qualificationQuestions)).toBe(true); }); + + it("/lenses/{id}/filter returns the criteria-based shape", async () => { + const lensId = await client.resolveDefaultLens(); + const filter = await client.request( + "GET", + `/lenses/${lensId}/filter` + ); + expect(filter).toHaveProperty("lens_filter"); + expect(filter).toHaveProperty("locations"); + }); + + it("/sectors/all?lang=en returns a populated taxonomy", async () => { + const sectors = await client.request( + "GET", + "/sectors/all?lang=en&includeInvisible=false" + ); + expect(Array.isArray(sectors)).toBe(true); + expect(sectors.length).toBeGreaterThan(100); + }); + + it("/organizations/{id}/quota_status returns spend + resource windows", async () => { + const orgId = await client.resolveOrgId(); + const q = await client.request( + "GET", + `/organizations/${orgId}/quota_status` + ); + expect(q).toHaveProperty("plan"); + expect(q.org).toHaveProperty("spend"); + expect(Array.isArray(q.org.spend)).toBe(true); + }); + + it("/organizations/{id}/user_prompt handles 204 cleanly", async () => { + const orgId = await client.resolveOrgId(); + const p = await client.request( + "GET", + `/organizations/${orgId}/user_prompt` + ); + // Either null (204) or a {prompt} object — no shape errors. + expect(p === null || (p && typeof p.prompt === "string")).toBe(true); + }); + + it("/organizations/{id}/clarifications handles 204 cleanly", async () => { + const orgId = await client.resolveOrgId(); + const c = await client.request( + "GET", + `/organizations/${orgId}/clarifications` + ); + expect(c === null || (c && typeof c.question === "string")).toBe(true); + }); + + it("wishlist + ai_agent_responses for the first lead populate", async () => { + const lensId = await client.resolveDefaultLens(); + const wish = await client.request( + "GET", + `/lenses/${lensId}/leads/wishlist?count=1&page=0` + ); + expect(wish.pagination).toHaveProperty("page"); + if (wish.items[0]) { + const r = await client.request( + "GET", + `/leads/${wish.items[0].id}/ai_agent_responses` + ); + expect(Array.isArray(r)).toBe(true); + // May be empty if the lead hasn't been qualified yet — that's fine. + } + }); }); diff --git a/packages/core/test/unit/client.test.ts b/packages/core/test/unit/client.test.ts index a8c48b9..f1d24c5 100644 --- a/packages/core/test/unit/client.test.ts +++ b/packages/core/test/unit/client.test.ts @@ -1,7 +1,15 @@ /** - * Unit tests for LeadbayClient — the error-mapping table is the primary value. - * Uses mockHttp() from harness.ts (predicate match, opinionated errors on - * unmatched requests). + * Unit tests for LeadbayClient. + * + * The error-mapping table, caching semantics, selection Mutex, and + * region-propagated error envelopes are the primary value here. + * + * 2026-04-20: the mapping table was updated so 429 → QUOTA_EXCEEDED (production + * emits 429 for quota, not 402). 402 still maps to QUOTA_EXCEEDED for legacy + * compatibility. This change was flagged by the /autoplan review — both + * Codex-eng and Codex-DX independently spotted that the old test at line 36 + * asserted 429→RATE_LIMITED and would silently invalidate the plan's mapping + * change. The new assertion IS the contract. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; @@ -26,16 +34,16 @@ afterEach(() => { }); describe("LeadbayClient.request — HTTP status → error code mapping", () => { - const cases: Array<[number, string, object | string | undefined, string, string?]> = [ + const cases: Array<[number, string, object | string | undefined, string]> = [ [401, "AUTH_EXPIRED", { message: "expired" }, "regenerate"], - [402, "QUOTA_EXCEEDED", { message: "no credits" }, "credits"], + // Quota: 429 is canonical (production), 402 kept for legacy. + [429, "QUOTA_EXCEEDED", {}, "Wait"], + [402, "QUOTA_EXCEEDED", { message: "no credits" }, "retry"], [403, "BILLING_SUSPENDED", { message: "account suspended" }, "billing"], - [403, "BILLING_SUSPENDED", { error: "billing_locked" }, "billing"], [403, "FORBIDDEN", { message: "forbidden" }, "permissions"], [404, "NOT_FOUND", { message: "nope" }, "ID is correct"], - [429, "RATE_LIMITED", {}, "Wait"], [500, "API_ERROR", { message: "boom" }, "Try again"], - [500, "API_ERROR", "not-json-body", "Try again", "API error (500)"], + [500, "API_ERROR", "not-json-body", "Try again"], [418, "API_ERROR", {}, "Try again"], ]; @@ -51,7 +59,7 @@ describe("LeadbayClient.request — HTTP status → error code mapping", () => { } ); - it("quota_exceeded body with non-402 status still maps to QUOTA_EXCEEDED", async () => { + it("quota_exceeded body with non-429 status still maps to QUOTA_EXCEEDED", async () => { mockHttp([ { method: "GET", path: "/1.5/x", status: 400, body: { error: "quota_exceeded" } }, ]); @@ -61,6 +69,21 @@ describe("LeadbayClient.request — HTTP status → error code mapping", () => { }); }); + it("error envelope carries _meta with region + endpoint", async () => { + mockHttp([ + { method: "GET", path: "/1.5/lenses", status: 404, body: { message: "no" } }, + ]); + const client = new LeadbayClient(BASE, "u.test-token", "us"); + try { + await client.request("GET", "/lenses"); + expect.fail("should have thrown"); + } catch (err: any) { + expect(err._meta).toBeDefined(); + expect(err._meta.region).toBe("us"); + expect(err._meta.endpoint).toBe("/lenses"); + } + }); + it("status 204 → returns null (no JSON parse attempted)", async () => { mockHttp([{ method: "POST", path: "/1.5/leads/x/web_fetch", status: 204 }]); const client = new LeadbayClient(BASE, "u.test-token"); @@ -73,13 +96,12 @@ describe("LeadbayClient.request — HTTP status → error code mapping", () => { method: "GET", path: "/1.5/users/me", status: 200, - body: { id: "user-1", email: "a@b.com" }, + body: { id: "user-1", email: "a@b.com", organization: { id: "org-1", name: "X" } }, }, ]); const client = new LeadbayClient(BASE, "u.test-token"); - await expect(client.request("GET", "/users/me")).resolves.toEqual({ + await expect(client.request("GET", "/users/me")).resolves.toMatchObject({ id: "user-1", - email: "a@b.com", }); }); @@ -118,32 +140,42 @@ describe("LeadbayClient.request — HTTP status → error code mapping", () => { }); describe("LeadbayClient.resolveDefaultLens", () => { - it("picks is_last_active first", async () => { + it("prefers me.last_requested_lens when set", async () => { mockHttp([ { method: "GET", - path: "/1.5/lenses", + path: "/1.5/users/me", status: 200, - body: [ - { id: 1, name: "A", is_last_active: false, is_default: true }, - { id: 2, name: "B", is_last_active: true, is_default: false }, - { id: 3, name: "C", is_last_active: false, is_default: false }, - ], + body: { + id: "u", email: "a@b.com", + organization: { id: "org-1", name: "X" }, + last_requested_lens: 77, + }, }, ]); const client = new LeadbayClient(BASE, "u.test-token"); - await expect(client.resolveDefaultLens()).resolves.toBe(2); + await expect(client.resolveDefaultLens()).resolves.toBe(77); }); - it("falls back to is_default when nothing is last_active", async () => { + it("falls back to /lenses scan when me.last_requested_lens is null", async () => { mockHttp([ + { + method: "GET", + path: "/1.5/users/me", + status: 200, + body: { + id: "u", email: "a@b.com", + organization: { id: "org-1", name: "X" }, + last_requested_lens: null, + }, + }, { method: "GET", path: "/1.5/lenses", status: 200, body: [ - { id: 1, name: "A", is_last_active: false, is_default: false }, - { id: 2, name: "B", is_last_active: false, is_default: true }, + { id: 1, name: "A", is_last_active: false, is_default: true }, + { id: 2, name: "B", is_last_active: true, is_default: false }, ], }, ]); @@ -151,150 +183,248 @@ describe("LeadbayClient.resolveDefaultLens", () => { await expect(client.resolveDefaultLens()).resolves.toBe(2); }); - it("falls back to first lens when no flags set", async () => { + it("empty lens list throws NO_LENS", async () => { mockHttp([ { method: "GET", - path: "/1.5/lenses", + path: "/1.5/users/me", status: 200, - body: [ - { id: 7, name: "A", is_last_active: false, is_default: false }, - { id: 8, name: "B", is_last_active: false, is_default: false }, - ], + body: { + id: "u", organization: { id: "org-1", name: "X" }, + last_requested_lens: null, + }, }, + { method: "GET", path: "/1.5/lenses", status: 200, body: [] }, ]); const client = new LeadbayClient(BASE, "u.test-token"); - await expect(client.resolveDefaultLens()).resolves.toBe(7); - }); - - it("empty lens list throws NO_LENS", async () => { - mockHttp([{ method: "GET", path: "/1.5/lenses", status: 200, body: [] }]); - const client = new LeadbayClient(BASE, "u.test-token"); await expect(client.resolveDefaultLens()).rejects.toMatchObject({ code: "NO_LENS", }); }); +}); + +describe("LeadbayClient.resolveMe — 60s TTL + invalidateMe", () => { + const meBody = { + id: "u", + email: "a@b.com", + organization: { id: "org-1", name: "X" }, + last_requested_lens: 42, + }; - it("caches within 5-minute TTL — second call makes no new HTTP request", async () => { + it("caches within 60s TTL", async () => { const { requests } = mockHttp([ - { - method: "GET", - path: "/1.5/lenses", - status: 200, - body: [{ id: 42, name: "X", is_last_active: true, is_default: false }], - }, + { method: "GET", path: "/1.5/users/me", status: 200, body: meBody }, ]); const client = new LeadbayClient(BASE, "u.test-token"); - await client.resolveDefaultLens(); - await client.resolveDefaultLens(); + await client.resolveMe(); + await client.resolveMe(); expect(requests.length).toBe(1); }); - it("re-fetches after 5-minute TTL expires", async () => { + it("invalidateMe() forces next call to re-fetch", async () => { + const { requests } = mockHttp([ + { method: "GET", path: "/1.5/users/me", status: 200, body: meBody }, + { method: "GET", path: "/1.5/users/me", status: 200, body: { ...meBody, last_requested_lens: 99 } }, + ]); + const client = new LeadbayClient(BASE, "u.test-token"); + const first = await client.resolveMe(); + expect(first.last_requested_lens).toBe(42); + client.invalidateMe(); + const second = await client.resolveMe(); + expect(second.last_requested_lens).toBe(99); + expect(requests.length).toBe(2); + }); + + it("re-fetches after 60s TTL expires", async () => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date("2026-04-20T00:00:00Z")); const { requests } = mockHttp([ + { method: "GET", path: "/1.5/users/me", status: 200, body: meBody }, + { method: "GET", path: "/1.5/users/me", status: 200, body: meBody }, + ]); + const client = new LeadbayClient(BASE, "u.test-token"); + await client.resolveMe(); + vi.setSystemTime(new Date("2026-04-20T00:02:00Z")); // 2 min later + await client.resolveMe(); + expect(requests.length).toBe(2); + }); + + it("resolveOrgId() now flows through resolveMe cache", async () => { + const { requests } = mockHttp([ + { method: "GET", path: "/1.5/users/me", status: 200, body: meBody }, + ]); + const client = new LeadbayClient(BASE, "u.test-token"); + await expect(client.resolveOrgId()).resolves.toBe("org-1"); + await expect(client.resolveOrgId()).resolves.toBe("org-1"); + expect(requests.length).toBe(1); + }); +}); + +describe("Granular write tools invalidate /me cache", () => { + // Regression: setUserPrompt / clearUserPrompt / pickClarification / + // dismissClarification mutate organization.computing_intelligence on /me. + // If they don't invalidateMe(), accountStatus / refine-prompt polling + // will read a stale (computing_intelligence: false) snapshot for up to 60s. + it("setUserPrompt invalidates /me cache", async () => { + const { setUserPrompt } = await import("../../src/tools/set-user-prompt.js"); + const { requests } = mockHttp([ + // resolveOrgId pulls /me first { method: "GET", - path: "/1.5/lenses", + path: "/1.5/users/me", status: 200, - body: [{ id: 1, name: "X", is_last_active: true, is_default: false }], + body: { + id: "u", + organization: { id: "org-1", name: "X", computing_intelligence: false }, + last_requested_lens: 1, + }, + }, + // Then the POST /user_prompt + { + method: "POST", + path: "/1.5/organizations/org-1/user_prompt", + status: 204, }, + // Subsequent resolveMe MUST hit the network again — this script catches the regression. { method: "GET", - path: "/1.5/lenses", + path: "/1.5/users/me", status: 200, - body: [{ id: 2, name: "Y", is_last_active: true, is_default: false }], + body: { + id: "u", + organization: { id: "org-1", name: "X", computing_intelligence: true }, + last_requested_lens: 1, + }, }, ]); const client = new LeadbayClient(BASE, "u.test-token"); - await expect(client.resolveDefaultLens()).resolves.toBe(1); - vi.setSystemTime(new Date("2026-04-20T00:06:00Z")); - await expect(client.resolveDefaultLens()).resolves.toBe(2); - expect(requests.length).toBe(2); + await setUserPrompt.execute(client, { prompt: "test" }); + const me = await client.resolveMe(); + expect(me.organization.computing_intelligence).toBe(true); + // Two /me requests = cache was correctly invalidated. + const meReqs = requests.filter((r) => r.path === "/1.5/users/me"); + expect(meReqs.length).toBe(2); }); -}); -describe("LeadbayClient.resolveOrgId", () => { - it("caches permanently — second call makes no new HTTP request", async () => { + it("clearUserPrompt invalidates /me cache", async () => { + const { clearUserPrompt } = await import("../../src/tools/clear-user-prompt.js"); const { requests } = mockHttp([ { method: "GET", path: "/1.5/users/me", status: 200, - body: { id: "u", email: "a@b.com", organization: { id: "org-1" } }, + body: { id: "u", organization: { id: "org-1", name: "X", computing_intelligence: false } }, + }, + { method: "DELETE", path: "/1.5/organizations/org-1/user_prompt", status: 204 }, + { + method: "GET", + path: "/1.5/users/me", + status: 200, + body: { id: "u", organization: { id: "org-1", name: "X", computing_intelligence: true } }, }, ]); const client = new LeadbayClient(BASE, "u.test-token"); - await expect(client.resolveOrgId()).resolves.toBe("org-1"); - await expect(client.resolveOrgId()).resolves.toBe("org-1"); - expect(requests.length).toBe(1); + await clearUserPrompt.execute(client, {}); + await client.resolveMe(); + expect(requests.filter((r) => r.path === "/1.5/users/me").length).toBe(2); }); -}); -describe("LeadbayClient.resolveTasteProfile — partial-result resilience", () => { - const meBody = { - id: "u", - email: "a@b.com", - organization: { id: "org-1" }, - }; - - it("returns full result when all three sub-requests succeed", async () => { - mockHttp([ - { method: "GET", path: "/1.5/users/me", status: 200, body: meBody }, + it("pickClarification invalidates /me cache", async () => { + const { pickClarification } = await import("../../src/tools/pick-clarification.js"); + const { requests } = mockHttp([ + { + method: "GET", + path: "/1.5/users/me", + status: 200, + body: { id: "u", organization: { id: "org-1", name: "X" } }, + }, + { method: "POST", path: "/1.5/organizations/org-1/pick_clarification", status: 204 }, { method: "GET", - path: "/1.5/organizations/org-1/ideal_buyer_profile", + path: "/1.5/users/me", status: 200, - body: { summary: "ideal" }, + body: { id: "u", organization: { id: "org-1", name: "X" } }, }, + ]); + const client = new LeadbayClient(BASE, "u.test-token"); + await pickClarification.execute(client, { option_id: "opt-1" }); + await client.resolveMe(); + expect(requests.filter((r) => r.path === "/1.5/users/me").length).toBe(2); + }); + + it("dismissClarification invalidates /me cache", async () => { + const { dismissClarification } = await import("../../src/tools/dismiss-clarification.js"); + const { requests } = mockHttp([ { method: "GET", - path: "/1.5/organizations/org-1/purchase_intent_tags", + path: "/1.5/users/me", status: 200, - body: [{ tag: "buy-now" }], + body: { id: "u", organization: { id: "org-1", name: "X" } }, }, + { method: "POST", path: "/1.5/organizations/org-1/dismiss_clarification", status: 204 }, { method: "GET", - path: "/1.5/organizations/org-1/ai_agent_questions", + path: "/1.5/users/me", status: 200, - body: [{ question: "q1" }], + body: { id: "u", organization: { id: "org-1", name: "X" } }, }, ]); const client = new LeadbayClient(BASE, "u.test-token"); - const tp = await client.resolveTasteProfile(); - expect(tp.idealBuyerProfile).toEqual({ summary: "ideal" }); - expect(tp.purchaseIntentTags).toHaveLength(1); - expect(tp.qualificationQuestions).toHaveLength(1); + await dismissClarification.execute(client, {}); + await client.resolveMe(); + expect(requests.filter((r) => r.path === "/1.5/users/me").length).toBe(2); }); +}); + +describe("LeadbayClient.acquireSelectionLock — Mutex", () => { + it("serialises concurrent selection holders", async () => { + const client = new LeadbayClient(BASE, "u.test-token"); + const order: string[] = []; + + async function holder(id: string, ms: number) { + await client.acquireSelectionLock(); + order.push(`${id}:acq`); + await new Promise((r) => setTimeout(r, ms)); + order.push(`${id}:rel`); + client.releaseSelectionLock(); + } - it("returns partial result when IBP rejects", async () => { + await Promise.all([holder("A", 20), holder("B", 10), holder("C", 5)]); + + // Each holder must release before the next acquires. + expect(order).toEqual([ + "A:acq", "A:rel", + "B:acq", "B:rel", + "C:acq", "C:rel", + ]); + }); +}); + +describe("LeadbayClient.setBaseUrl + region getter", () => { + it("setBaseUrl updates region and invalidates caches", async () => { mockHttp([ - { method: "GET", path: "/1.5/users/me", status: 200, body: meBody }, { method: "GET", - path: "/1.5/organizations/org-1/ideal_buyer_profile", - status: 404, - body: {}, - }, - { - method: "GET", - path: "/1.5/organizations/org-1/purchase_intent_tags", + path: "/1.5/users/me", status: 200, - body: [{ tag: "a" }], + body: { id: "u", organization: { id: "org-us", name: "X" }, last_requested_lens: 10 }, }, { method: "GET", - path: "/1.5/organizations/org-1/ai_agent_questions", + path: "/1.5/users/me", status: 200, - body: [], + body: { id: "u2", organization: { id: "org-fr", name: "Y" }, last_requested_lens: 20 }, }, ]); - const client = new LeadbayClient(BASE, "u.test-token"); - const tp = await client.resolveTasteProfile(); - expect(tp.idealBuyerProfile).toBeNull(); - expect(tp.purchaseIntentTags).toHaveLength(1); - expect(tp.qualificationQuestions).toHaveLength(0); + const client = new LeadbayClient(BASE, "u.test-token", "us"); + expect(client.region).toBe("us"); + const first = await client.resolveMe(); + expect(first.organization.id).toBe("org-us"); + + client.setBaseUrl("https://api-fr.leadbay.app", "fr"); + expect(client.region).toBe("fr"); + const second = await client.resolveMe(); + expect(second.organization.id).toBe("org-fr"); }); }); @@ -309,16 +439,17 @@ describe("LeadbayClient — auth state", () => { describe("createClient factory", () => { it("resolves US region to the us baseUrl", async () => { - // No network here — just verify construction const { createClient, REGIONS } = await import("../../src/client.js"); const c = createClient({ token: "tok", region: "us" }); expect(c.baseUrl).toBe(REGIONS.us); + expect(c.region).toBe("us"); }); it("resolves FR region to the fr baseUrl", async () => { const { createClient, REGIONS } = await import("../../src/client.js"); const c = createClient({ token: "tok", region: "fr" }); expect(c.baseUrl).toBe(REGIONS.fr); + expect(c.region).toBe("fr"); }); it("throws on unknown region (no baseUrl)", async () => { @@ -326,9 +457,19 @@ describe("createClient factory", () => { expect(() => createClient({ region: "xx" as any })).toThrow(/unknown region/); }); - it("custom baseUrl overrides region", async () => { + it("custom baseUrl keeps the explicit region tag", async () => { + // region is a user-facing label (e.g. "which Leadbay account backend?"). + // If they set region:"us" + a custom baseUrl (probably a staging of the + // US backend), respect the explicit region tag. const { createClient } = await import("../../src/client.js"); const c = createClient({ baseUrl: "https://staging.example.com", region: "us" }); expect(c.baseUrl).toBe("https://staging.example.com"); + expect(c.region).toBe("us"); + }); + + it("custom baseUrl with no region → region=custom (inferred from unknown host)", async () => { + const { LeadbayClient } = await import("../../src/client.js"); + const c = new LeadbayClient("https://staging.example.com", "tok"); + expect(c.region).toBe("custom"); }); }); diff --git a/packages/core/test/unit/tools/login.test.ts b/packages/core/test/unit/tools/login.test.ts index ef766e1..d52bc81 100644 --- a/packages/core/test/unit/tools/login.test.ts +++ b/packages/core/test/unit/tools/login.test.ts @@ -1,5 +1,11 @@ /** * Tests for the login tool (protocol-agnostic Tool shape). + * + * v0.2.0 (post-autoplan): login uses resolveRegion() which tries us first, + * then fr. The mock harness matches by method+path, so a single login script + * matches the first attempt; if you want to test the FR-fallback path, + * register two consecutive scripts (the first one is consumed by us, the + * second by fr). */ import { describe, it, expect, beforeEach, vi } from "vitest"; @@ -44,11 +50,11 @@ describe("leadbay_login — password unescape", () => { method: "POST", path: "/1.5/auth/login", status: 200, - body: { token: "u.new-token" }, + body: { token: "u.new-token", verified: true }, }, { method: "GET", path: /\/1\.5\/users\/me/, status: 404, body: {} }, ]); - const client = new LeadbayClient(BASE); + const client = new LeadbayClient(BASE, undefined, "us"); const { logger } = createLogger(); await login.execute( @@ -65,18 +71,18 @@ describe("leadbay_login — password unescape", () => { ); }); -describe("leadbay_login — status path handling", () => { - it("200 response sets token on client and returns success", async () => { +describe("leadbay_login — region auto-detect + status handling", () => { + it("200 on US first try → success, region=us", async () => { mockHttp([ { method: "POST", path: "/1.5/auth/login", status: 200, - body: { token: "u.abc123" }, + body: { token: "u.abc123", verified: true }, }, { method: "GET", path: /users\/me/, status: 404, body: {} }, ]); - const client = new LeadbayClient(BASE); + const client = new LeadbayClient(BASE, undefined, "us"); const { logger } = createLogger(); const result: any = await login.execute( @@ -85,14 +91,47 @@ describe("leadbay_login — status path handling", () => { { logger } ); - expect(result).toEqual({ - success: true, - message: "Logged in to Leadbay successfully", - }); + expect(result.success).toBe(true); + expect(result.region).toBe("us"); + expect(result.verified).toBe(true); + expect(result.message).toMatch(/Logged in to Leadbay/i); + expect(client.isAuthenticated).toBe(true); + }); + + it("401 on US, 200 on FR → success, region=fr (auto-fallback)", async () => { + mockHttp([ + // First attempt: us → 401 + { + method: "POST", + path: "/1.5/auth/login", + status: 401, + body: { message: "wrong region" }, + }, + // Second attempt: fr → 200 + { + method: "POST", + path: "/1.5/auth/login", + status: 200, + body: { token: "u.fr-token", verified: true }, + }, + { method: "GET", path: /users\/me/, status: 404, body: {} }, + ]); + const client = new LeadbayClient(BASE, undefined, "us"); + const { logger } = createLogger(); + + const result: any = await login.execute( + client, + { email: "a@b.com", password: "secret" }, + { logger } + ); + + expect(result.success).toBe(true); + expect(result.region).toBe("fr"); expect(client.isAuthenticated).toBe(true); + expect(client.region).toBe("fr"); }); - it("401 returns LOGIN_FAILED (does NOT throw)", async () => { + it("401 on both regions returns LOGIN_FAILED", async () => { mockHttp([ { method: "POST", @@ -100,8 +139,14 @@ describe("leadbay_login — status path handling", () => { status: 401, body: { message: "bad credentials" }, }, + { + method: "POST", + path: "/1.5/auth/login", + status: 401, + body: { message: "bad credentials" }, + }, ]); - const client = new LeadbayClient(BASE); + const client = new LeadbayClient(BASE, undefined, "us"); const { logger } = createLogger(); const result: any = await login.execute( @@ -113,12 +158,12 @@ describe("leadbay_login — status path handling", () => { expect(result).toMatchObject({ error: true, code: "LOGIN_FAILED", - message: "bad credentials", }); + expect(result.message).toMatch(/both regions/i); expect(client.isAuthenticated).toBe(false); }); - it("network error returns NETWORK_ERROR", async () => { + it("network error on both regions returns LOGIN_FAILED", async () => { mockHttp([ { method: "POST", @@ -126,8 +171,14 @@ describe("leadbay_login — status path handling", () => { status: 0, error: new Error("ECONNREFUSED"), }, + { + method: "POST", + path: "/1.5/auth/login", + status: 0, + error: new Error("ECONNREFUSED"), + }, ]); - const client = new LeadbayClient(BASE); + const client = new LeadbayClient(BASE, undefined, "us"); const { logger } = createLogger(); const result: any = await login.execute( @@ -136,11 +187,8 @@ describe("leadbay_login — status path handling", () => { { logger } ); - expect(result).toMatchObject({ - error: true, - code: "NETWORK_ERROR", - }); - expect(result.message).toContain("ECONNREFUSED"); + expect(result.error).toBe(true); + expect(result.code).toBe("LOGIN_FAILED"); }); it("prefetchOrgData rejection is swallowed (fire-and-forget)", async () => { @@ -149,11 +197,11 @@ describe("leadbay_login — status path handling", () => { method: "POST", path: "/1.5/auth/login", status: 200, - body: { token: "u.abc" }, + body: { token: "u.abc", verified: true }, }, { method: "GET", path: /users\/me/, status: 500, body: {} }, ]); - const client = new LeadbayClient(BASE); + const client = new LeadbayClient(BASE, undefined, "us"); const { logger } = createLogger(); const result: any = await login.execute( @@ -166,7 +214,7 @@ describe("leadbay_login — status path handling", () => { await new Promise((r) => setImmediate(r)); }); - it("logger.info and logger.error are invoked with descriptive messages", async () => { + it("logger.info / logger.error are invoked with descriptive messages on failure", async () => { mockHttp([ { method: "POST", @@ -174,8 +222,14 @@ describe("leadbay_login — status path handling", () => { status: 401, body: { message: "bad" }, }, + { + method: "POST", + path: "/1.5/auth/login", + status: 401, + body: { message: "bad" }, + }, ]); - const client = new LeadbayClient(BASE); + const client = new LeadbayClient(BASE, undefined, "us"); const { logger, logs } = createLogger(); await login.execute( @@ -184,7 +238,9 @@ describe("leadbay_login — status path handling", () => { { logger } ); - expect(logs.some((l) => l.level === "info" && /login: email=/.test(l.msg))).toBe(true); + expect( + logs.some((l) => l.level === "info" && /startRegion/.test(l.msg)) + ).toBe(true); expect(logs.some((l) => l.level === "error")).toBe(true); }); }); diff --git a/packages/leadclaw/openclaw.plugin.json b/packages/leadclaw/openclaw.plugin.json index f8c6f7a..e41a041 100644 --- a/packages/leadclaw/openclaw.plugin.json +++ b/packages/leadclaw/openclaw.plugin.json @@ -1,11 +1,23 @@ { "id": "leadclaw", "name": "LeadClaw", - "description": "Leadbay lead discovery, qualification, and contact enrichment", - "version": "0.1.0", + "description": "Leadbay lead discovery, qualification, and contact enrichment for AI agents", + "version": "0.2.0", "contracts": { "tools": [ "leadbay_login", + "leadbay_account_status", + "leadbay_pull_leads", + "leadbay_research_lead", + "leadbay_recall_ordered_titles", + "leadbay_research_company", + "leadbay_prepare_outreach", + "leadbay_bulk_qualify_leads", + "leadbay_enrich_titles", + "leadbay_adjust_audience", + "leadbay_refine_prompt", + "leadbay_answer_clarification", + "leadbay_report_outreach", "leadbay_list_lenses", "leadbay_discover_leads", "leadbay_get_lead_profile", @@ -13,15 +25,43 @@ "leadbay_get_taste_profile", "leadbay_get_contacts", "leadbay_get_quota", + "leadbay_get_lens_filter", + "leadbay_get_lens_scoring", + "leadbay_list_sectors", + "leadbay_get_user_prompt", + "leadbay_get_clarification", + "leadbay_get_lead_notes", + "leadbay_get_epilogue_responses", + "leadbay_get_prospecting_actions", + "leadbay_get_web_fetch", + "leadbay_get_selection_ids", + "leadbay_get_enrichment_job_titles", "leadbay_qualify_lead", "leadbay_enrich_contacts", - "leadbay_add_note" + "leadbay_add_note", + "leadbay_select_leads", + "leadbay_deselect_leads", + "leadbay_clear_selection", + "leadbay_set_active_lens", + "leadbay_create_lens", + "leadbay_update_lens", + "leadbay_update_lens_filter", + "leadbay_create_lens_draft", + "leadbay_promote_lens", + "leadbay_set_user_prompt", + "leadbay_clear_user_prompt", + "leadbay_pick_clarification", + "leadbay_dismiss_clarification", + "leadbay_set_epilogue_status", + "leadbay_remove_epilogue", + "leadbay_preview_bulk_enrichment", + "leadbay_launch_bulk_enrichment" ] }, "uiHints": { "region": { "label": "Leadbay Region", - "help": "Your Leadbay account region (us or fr)", + "help": "Your Leadbay account region (us or fr). Login auto-detects regardless.", "placeholder": "us" }, "baseUrl": { @@ -33,6 +73,16 @@ "label": "API Token", "help": "Pre-set bearer token to skip the login step", "sensitive": true + }, + "exposeGranular": { + "label": "Expose granular API tools", + "help": "Default false: only the composite agent-facing tools are visible. Set true to also expose the lower-level 1:1-with-API tools (more surface area, more chance the agent picks the wrong one).", + "advanced": true + }, + "exposeWrite": { + "label": "Expose write tools", + "help": "Default false: write tools (set_user_prompt, set_epilogue_status, update_lens_filter, report_outreach, etc.) are hidden. Enable only when you want the agent to modify Leadbay state.", + "advanced": true } }, "configSchema": { @@ -49,6 +99,14 @@ }, "baseUrl": { "type": "string" + }, + "exposeGranular": { + "type": "boolean", + "description": "Expose granular 1:1-with-API tools (default false)" + }, + "exposeWrite": { + "type": "boolean", + "description": "Expose write/mutation tools (default false)" } } } diff --git a/packages/leadclaw/src/index.ts b/packages/leadclaw/src/index.ts index e201571..ac83703 100644 --- a/packages/leadclaw/src/index.ts +++ b/packages/leadclaw/src/index.ts @@ -1,9 +1,24 @@ -import { createClient, granularTools } from "@leadbay/core"; +import { + createClient, + login, + compositeReadTools, + compositeWriteTools, + granularReadTools, + granularWriteTools, +} from "@leadbay/core"; +import type { Tool } from "@leadbay/core"; // OpenClaw plugin entry point. // -// The OpenClaw adapter exposes the 11 granular tools (matching the published -// plugin manifest). Composite workflow tools live in @leadbay/mcp only. +// Tool exposure is gated by plugin config: +// - composite read tools: ALWAYS exposed (the agent's default surface) +// - composite write tools: ALWAYS exposed (OpenClaw runs in user context; +// the agent has explicit consent. Write composites enforce their own +// verification — e.g. report_outreach requires verification={source, ref}.) +// - granular read tools: exposed only when exposeGranular=true +// - granular write tools: exposed only when both exposeGranular=true +// AND exposeWrite=true +// - login: always exposed (this is the bootstrap path) export function register(api: any) { const cfg = api.pluginConfig ?? {}; @@ -27,14 +42,38 @@ export function register(api: any) { api.logger?.info?.("LeadClaw: Using preconfigured auth token"); } - for (const tool of granularTools) { + const exposeGranular = cfg.exposeGranular === true; + const exposeWrite = cfg.exposeWrite === true; + + const exposed: Tool[] = []; + exposed.push(login); + exposed.push(...compositeReadTools); + if (exposeWrite) { + exposed.push(...compositeWriteTools); + } + if (exposeGranular) { + exposed.push(...granularReadTools); + if (exposeWrite) { + exposed.push(...granularWriteTools); + } + } + + // Dedup by name (some tools live in multiple lists; e.g. existing composites). + const seen = new Set(); + for (const tool of exposed) { + if (seen.has(tool.name)) continue; + seen.add(tool.name); api.registerTool({ name: tool.name, description: tool.description, parameters: tool.inputSchema, - ...(tool.optional ? { optional: true } : {}), + ...(tool.optional || tool.write ? { optional: true } : {}), execute: async (_id: string, params: unknown) => tool.execute(client, params as any, { logger: api.logger }), }); } + + api.logger?.info?.( + `LeadClaw v0.2.0 registered: ${seen.size} tools (exposeGranular=${exposeGranular}, exposeWrite=${exposeWrite})` + ); } diff --git a/packages/leadclaw/test/contract.test.ts b/packages/leadclaw/test/contract.test.ts index 06269b1..48681b4 100644 --- a/packages/leadclaw/test/contract.test.ts +++ b/packages/leadclaw/test/contract.test.ts @@ -1,8 +1,13 @@ /** * Contract tests for @leadbay/leadclaw — manifest ↔ code parity. * - * When a tool is added to @leadbay/core or removed from openclaw.plugin.json, - * these fail with a named-diff error. No magic tool counts. + * v0.2.0 (post-autoplan): + * - Tool registration is gated by exposeGranular / exposeWrite plugin config. + * - The manifest declares the FULL set of tools the plugin can expose. + * - With both flags on, all manifest tools are registered. With both off, + * only login + composites (read+write) register. + * - Description-style enforcement: every tool description contains + * "When to use" and "When NOT to use" sections. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; @@ -16,15 +21,13 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const manifestPath = path.resolve(__dirname, "..", "openclaw.plugin.json"); const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); -const WRITE_TOOLS = new Set([ - "leadbay_qualify_lead", - "leadbay_enrich_contacts", - "leadbay_add_note", -]); - -describe("contract: manifest ↔ code parity", () => { - it("registered tools match openclaw.plugin.json contracts.tools exactly", () => { - const t = createTestApi({ region: "us" }); +describe("contract: manifest ↔ code parity (full expose)", () => { + it("with exposeGranular+exposeWrite, registered tools match manifest exactly", () => { + const t = createTestApi({ + region: "us", + exposeGranular: true, + exposeWrite: true, + }); register(t.api as any); const registered = new Set(t.tools.keys()); @@ -46,7 +49,11 @@ describe("contract: manifest ↔ code parity", () => { }); it("every registered tool has a valid JSON-schema parameters object", () => { - const t = createTestApi({ region: "us" }); + const t = createTestApi({ + region: "us", + exposeGranular: true, + exposeWrite: true, + }); register(t.api as any); for (const [name, tool] of t.tools) { @@ -57,65 +64,164 @@ describe("contract: manifest ↔ code parity", () => { } }); - it("write tools are marked optional; read tools are not", () => { - const t = createTestApi({ region: "us" }); + it("every registered tool has a non-empty description", () => { + const t = createTestApi({ + region: "us", + exposeGranular: true, + exposeWrite: true, + }); register(t.api as any); for (const [name, tool] of t.tools) { - if (WRITE_TOOLS.has(name)) { - expect(tool.optional, `write tool ${name} must be optional:true`).toBe(true); - } else { - expect(tool.optional, `read tool ${name} must NOT be optional`).not.toBe(true); - } + expect(tool.description, `${name}.description`).toBeTypeOf("string"); + expect( + (tool.description as string).length, + `${name}.description length` + ).toBeGreaterThan(10); } }); - it("every registered tool has a non-empty description", () => { - const t = createTestApi({ region: "us" }); + // Per autoplan §C item 25: enforce the tool-description style. + // Every description must include both "When to use" and "When NOT to use" + // so the model has a clear positive AND negative trigger. + it("every tool description contains both 'When to use' and 'When NOT to use' sections", () => { + const t = createTestApi({ + region: "us", + exposeGranular: true, + exposeWrite: true, + }); register(t.api as any); + const offenders: Array<{ name: string; missing: string[] }> = []; for (const [name, tool] of t.tools) { - expect(tool.description, `${name}.description`).toBeTypeOf("string"); - expect((tool.description as string).length, `${name}.description length`).toBeGreaterThan(10); + const desc = tool.description as string; + const missing: string[] = []; + if (!/when\s+to\s+use/i.test(desc)) missing.push("When to use"); + if (!/when\s+not\s+to\s+use/i.test(desc)) missing.push("When NOT to use"); + if (missing.length) offenders.push({ name, missing }); + } + + if (offenders.length) { + const lines = offenders.map((o) => ` - ${o.name}: missing [${o.missing.join(", ")}]`); + throw new Error( + `${offenders.length} tool(s) missing required description sections:\n${lines.join("\n")}\n` + + `Every tool description must include both 'When to use' and 'When NOT to use' so the LLM has a clear positive AND negative trigger.` + ); } }); it("manifest has expected top-level shape", () => { expect(manifest.id).toBe("leadclaw"); + expect(manifest.version).toBe("0.2.0"); expect(manifest.configSchema).toBeTypeOf("object"); expect(Array.isArray(manifest.contracts.tools)).toBe(true); - expect(manifest.contracts.tools.length).toBe(11); + }); + + it("manifest declares the agent-facing config flags", () => { + expect(manifest.configSchema.properties.exposeGranular).toBeDefined(); + expect(manifest.configSchema.properties.exposeWrite).toBeDefined(); + expect(manifest.uiHints.exposeGranular).toBeDefined(); + expect(manifest.uiHints.exposeWrite).toBeDefined(); }); }); -describe("contract: register() behaviour", () => { +describe("contract: register() gating", () => { beforeEach(() => { vi.restoreAllMocks(); }); - afterEach(() => { vi.restoreAllMocks(); }); - it("emits logger.warn and registers no tools when region is invalid", () => { - const t = createTestApi({ region: "xx" }); + it("with no expose flags, registers ONLY login + composite READ tools", () => { + const t = createTestApi({ region: "us" }); register(t.api as any); - expect(t.tools.size).toBe(0); - expect(t.logs.some((l) => l.level === "warn" && /region/i.test(l.msg))).toBe(true); + + const names = [...t.tools.keys()]; + // Must include login + composite reads. + expect(names).toContain("leadbay_login"); + expect(names).toContain("leadbay_pull_leads"); + expect(names).toContain("leadbay_research_lead"); + expect(names).toContain("leadbay_account_status"); + expect(names).toContain("leadbay_recall_ordered_titles"); + expect(names).toContain("leadbay_research_company"); + expect(names).toContain("leadbay_prepare_outreach"); + // Must NOT include composite WRITE tools without exposeWrite. + expect(names).not.toContain("leadbay_bulk_qualify_leads"); + expect(names).not.toContain("leadbay_enrich_titles"); + expect(names).not.toContain("leadbay_adjust_audience"); + expect(names).not.toContain("leadbay_refine_prompt"); + expect(names).not.toContain("leadbay_answer_clarification"); + expect(names).not.toContain("leadbay_report_outreach"); + // Must NOT include granular tools. + expect(names).not.toContain("leadbay_get_lens_filter"); + expect(names).not.toContain("leadbay_select_leads"); + expect(names).not.toContain("leadbay_set_user_prompt"); }); - it("registers 11 tools when region=us (valid default)", () => { - const t = createTestApi({ region: "us" }); + it("with exposeWrite only, registers composite writes (NOT granulars)", () => { + const t = createTestApi({ region: "us", exposeWrite: true }); register(t.api as any); - expect(t.tools.size).toBe(11); + + const names = [...t.tools.keys()]; + // Composite writes now visible. + expect(names).toContain("leadbay_report_outreach"); + expect(names).toContain("leadbay_refine_prompt"); + expect(names).toContain("leadbay_answer_clarification"); + expect(names).toContain("leadbay_adjust_audience"); + expect(names).toContain("leadbay_bulk_qualify_leads"); + expect(names).toContain("leadbay_enrich_titles"); + // Granulars still hidden. + expect(names).not.toContain("leadbay_get_lens_filter"); + expect(names).not.toContain("leadbay_set_user_prompt"); }); - it("logs info when cfg.token is provided (preconfigured)", async () => { - const t = createTestApi({ region: "us", token: "u.preconfig-token" }); + it("with exposeGranular only, registers granular READS but not any writes", () => { + const t = createTestApi({ region: "us", exposeGranular: true }); register(t.api as any); - expect(t.logs.some((l) => l.level === "info" && /preconfigured/i.test(l.msg))).toBe( + const names = [...t.tools.keys()]; + expect(names).toContain("leadbay_get_lens_filter"); + expect(names).toContain("leadbay_get_user_prompt"); + expect(names).toContain("leadbay_list_sectors"); + // Composite writes still hidden (exposeWrite is independent). + expect(names).not.toContain("leadbay_report_outreach"); + expect(names).not.toContain("leadbay_refine_prompt"); + // Granular writes still hidden. + expect(names).not.toContain("leadbay_select_leads"); + expect(names).not.toContain("leadbay_set_user_prompt"); + expect(names).not.toContain("leadbay_launch_bulk_enrichment"); + }); + + it("with both expose flags, registers everything in the manifest", () => { + const t = createTestApi({ + region: "us", + exposeGranular: true, + exposeWrite: true, + }); + register(t.api as any); + + const names = new Set(t.tools.keys()); + for (const declared of manifest.contracts.tools as string[]) { + expect(names.has(declared), `manifest tool not registered: ${declared}`).toBe(true); + } + }); + + it("emits logger.warn and registers no tools when region is invalid", () => { + const t = createTestApi({ region: "xx" }); + register(t.api as any); + expect(t.tools.size).toBe(0); + expect(t.logs.some((l) => l.level === "warn" && /region/i.test(l.msg))).toBe( true ); }); + + it("logs info when cfg.token is provided (preconfigured)", async () => { + const t = createTestApi({ region: "us", token: "u.preconfig-token" }); + register(t.api as any); + + expect( + t.logs.some((l) => l.level === "info" && /preconfigured/i.test(l.msg)) + ).toBe(true); + }); }); diff --git a/packages/mcp/MIGRATION.md b/packages/mcp/MIGRATION.md new file mode 100644 index 0000000..d624a13 --- /dev/null +++ b/packages/mcp/MIGRATION.md @@ -0,0 +1,140 @@ +# Migration: leadclaw / leadbay-mcp 0.1.x → 0.2.0 + +This release is the autoplan-reviewed agent-experience overhaul. The OpenClaw +plugin and MCP server gain a coherent composite-tool surface so an AI agent +can drive Leadbay end-to-end with a handful of calls. The old granular tools +remain available behind config flags. + +## Headline changes + +- **`leadbay_find_prospects` removed** → replaced by **`leadbay_pull_leads`** + (richer return: each lead carries a `qualification_summary` digest from + `ai_agent_responses`, plus all the engagement-state flags). +- **New composite agent surface** (the agent's default toolbox): + - `leadbay_pull_leads` — paginated wishlist with qualification digest + - `leadbay_research_lead` — full lead detail (qualification → signals → firmographics → contacts → engagement) + - `leadbay_recall_ordered_titles` — show titles previously enriched + - `leadbay_account_status` — admin / language / quota / intelligence state + - `leadbay_bulk_qualify_leads` — paginate past already-qualified, fan-out + poll + - `leadbay_enrich_titles` — selection-lifecycle-managed bulk enrichment + - `leadbay_adjust_audience` — sector / size filter mutation with permission auto-routing + - `leadbay_refine_prompt` — set the org intelligence-refinement prompt + - `leadbay_answer_clarification` — answer the question Leadbay raised + - `leadbay_report_outreach` — log outreach **with mandatory verification** +- **New gating model** (both MCP and OpenClaw): + - **Composite reads**: always exposed. + - **Composite writes**: gated by `LEADBAY_MCP_WRITE=1` (MCP) or + `exposeWrite: true` plugin config (OpenClaw). + - **Granular reads**: gated by `LEADBAY_MCP_ADVANCED=1` (MCP) or + `exposeGranular: true` (OpenClaw). + - **Granular writes**: gated by BOTH advanced AND write flags. +- **`leadbay_login` auto-detects region** (us → fr fallback). The user no + longer needs to know which backend their account is in. +- **`leadbay_get_quota` switched to the live `/quota_status` endpoint** — + returns daily/weekly/monthly windows for `llm_completion`, `ai_rescore`, + `web_fetch` resources. Use this AFTER a 429 to explain which window was hit. +- **Error mapping changed: `429 → QUOTA_EXCEEDED`** (production behavior). + Legacy 402 still maps to QUOTA_EXCEEDED for back-compat. +- **HTTP-response headers are now captured** and propagated through the error + envelope's `_meta: {region, endpoint, latency_ms, retry_after}`. There is + no `X-Request-Id` header on the Leadbay backend — we don't pretend there is. +- **`LEADBAY_MOCK=1`** mode: serve responses from on-disk fixtures + (`.context/leadbay-live-shapes/`) for agent-author dry-running. Writes are + journaled in-process and return `{mocked: true, would_call: {...}}`. +- **`dry_run: true`** param on every state-changing composite (`report_outreach`, + `set_user_prompt`, `update_lens_filter`, `launch_bulk_enrichment`, etc.) — + returns the would-call envelope without contacting the backend. + +## report_outreach: verification REQUIRED + +The autoplan review (CEO + Eng + DX voices) flagged that allowing the agent +to self-report outreach without proof would poison the SDR pipeline. The user +chose the strictest mitigation: every `report_outreach` call MUST include a +`verification` field: + +```json +{ + "lead_id": "abc-123", + "note": "Sent intro email to CTO citing Hornsea 3 contract", + "epilogue_status": "STILL_CHASING", + "verification": { + "source": "gmail_message_id", + "ref": "" + } +} +``` + +Valid `source` values: +- `gmail_message_id` — message id returned by `mcp__claude_ai_Gmail__send_email` +- `calendar_event_id` — event id from a calendar booking tool +- `user_confirmed` — `ref` is the user's literal confirmation in chat + +The verification is appended to the note body so humans in the Leadbay UI can +see the proof. Calls without verification return `VERIFICATION_REQUIRED`. + +## Side-by-side: old flow → new flow + +| Old (v0.1) | New (v0.2) | Notes | +|---|---|---| +| `leadbay_find_prospects` | `leadbay_pull_leads` | Same intent; richer return; remove name | +| `leadbay_get_lead_profile` | `leadbay_research_lead` | New ordering (qualification first); reshapes `web_fetch.content` from emoji-keyed dict to ordered array. Granular still available behind exposeGranular. | +| `leadbay_research_company` | unchanged | Kept for back-compat; prefer `research_lead` when you have the id. | +| `leadbay_qualify_lead` (single) | `leadbay_bulk_qualify_leads` | Composite paginates past already-qualified, fan-outs, polls, bails on 429. Granular still available. | +| `leadbay_enrich_contacts` (single) | `leadbay_enrich_titles` | Composite manages selection lifecycle. Granular still available. | +| `leadbay_get_quota` (legacy billing fields) | `leadbay_get_quota` (live /quota_status) | Same name, new shape. Old `freemium.daily_quota` / `ai_credits` are defunct. | +| Add a free-form note via `leadbay_add_note` | Log outreach via `leadbay_report_outreach` | Note tool still exists for free-form context; `report_outreach` is the right call after an actual action. | + +## How to upgrade + +### Claude Desktop / Cursor (MCP) + +```json +{ + "mcpServers": { + "leadbay": { + "command": "npx", + "args": ["-y", "@leadbay/mcp@0.2"], + "env": { + "LEADBAY_TOKEN": "lb_...", + "LEADBAY_MCP_WRITE": "1" + } + } + } +} +``` + +`LEADBAY_MCP_WRITE=1` opts in to write composites (the entire point of agent +flow — without it, the agent can read but not write). `LEADBAY_MCP_ADVANCED=1` +additionally exposes the granular tools; most users don't need it. + +### OpenClaw plugin + +In the plugin config (e.g. `openclaw config set plugins.entries.leadclaw.exposeWrite true`): + +```json +{ + "region": "us", + "exposeWrite": true, + "exposeGranular": false +} +``` + +Default is read-only (exposeWrite=false, exposeGranular=false). + +### What you might need to change in your prompts + +- If your prompts reference `leadbay_find_prospects`, change to `leadbay_pull_leads`. +- If your prompts reference `leadbay_get_lead_profile` directly, prefer + `leadbay_research_lead` for the agent-friendly shape. +- If your agent calls `leadbay_add_note` for outreach actions, switch to + `leadbay_report_outreach` with `verification`. + +## Out of scope for this release + +- Per-tool semver versioning (the `Tool.version` field is in `types.ts` but + individual tool files don't yet declare versions). +- A real `bulk_id` polling tool — the backend doesn't return one from `/launch` + and there's no list endpoint (probed). Use `leadbay_get_contacts` per-lead + to detect when `enrichment.done` flips. +- A `DELETE /lenses/{draftId}` endpoint — not testable in our tenant; treated + as best-effort with `orphan_draft_id` surfaced on cleanup failure. diff --git a/packages/mcp/src/bin.ts b/packages/mcp/src/bin.ts index 1b4e68f..af2c058 100644 --- a/packages/mcp/src/bin.ts +++ b/packages/mcp/src/bin.ts @@ -2,7 +2,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { createClient, LeadbayClient, type CreateClientConfig, type ToolLogger } from "@leadbay/core"; import { buildServer } from "./server.js"; -const VERSION = "0.1.0"; +const VERSION = "0.2.0"; const HELP = ` leadbay-mcp ${VERSION} — Leadbay Model Context Protocol server @@ -17,10 +17,18 @@ ENV VARS LEADBAY_TOKEN (required) Bearer token from https://app.leadbay.ai/settings/api-tokens LEADBAY_REGION (optional) "us" or "fr". Auto-detected from /users/me if unset. LEADBAY_BASE_URL (optional) Override API base URL (for staging/dev). - LEADBAY_MCP_ADVANCED (optional) Set to "1" to expose 10 granular tools alongside - the 3 composite workflow tools. Most users don't need this. + LEADBAY_MCP_ADVANCED (optional) Set to "1" to expose granular API tools alongside + the composite workflow tools. Most users don't need this. + LEADBAY_MCP_WRITE (optional) Set to "1" to expose write composites (refine_prompt, + report_outreach, adjust_audience, etc.) and write granulars. + Defaults off — read composites are exposed by default; mutations + require explicit opt-in. + LEADBAY_MOCK (optional) Set to "1" to serve all responses from on-disk fixtures + (no network, no real auth). Useful for agent-author dry-running. + GETs are matched against fixture JSON files; POSTs/DELETEs are + journaled in-process and return {mocked: true, would_call: {...}}. + LEADBAY_MOCK_DIR (optional) Fixture directory. Default: ./.context/leadbay-live-shapes/ LEADBAY_LOG_LEVEL (optional) "debug" | "info" | "error" (default "error"). Logs to stderr. - LEADBAY_TIMEOUT_MS (optional) Per-request timeout override (not yet plumbed). EXAMPLE Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json) { @@ -208,11 +216,12 @@ async function main(): Promise { const logger = makeStderrLogger(parseLogLevel(process.env.LEADBAY_LOG_LEVEL)); const client = await resolveClientFromEnv(logger); const includeAdvanced = process.env.LEADBAY_MCP_ADVANCED === "1"; + const includeWrite = process.env.LEADBAY_MCP_WRITE === "1"; - const server = buildServer(client, { includeAdvanced, logger }); + const server = buildServer(client, { includeAdvanced, includeWrite, logger }); const transport = new StdioServerTransport(); logger.info?.( - `Starting MCP server (advanced=${includeAdvanced}, baseUrl=${client.baseUrl})` + `Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl})` ); await server.connect(transport); } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 93957f9..e311ac8 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -4,33 +4,45 @@ import { ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { - compositeTools, - granularTools, + compositeReadTools, + compositeWriteTools, + granularReadTools, + granularWriteTools, type LeadbayClient, type Tool, type ToolLogger, } from "@leadbay/core"; +// Lead-paragraph mandate is the first thing the agent reads — followed by +// three sentences, then per-tool descriptions carry the runbook detail. export const SERVER_INSTRUCTIONS = - "Leadbay is a B2B lead-gen platform. Use these tools to find prospects, " + - "research companies, and prepare outreach based on the user's Ideal Buyer Profile.\n\n" + - "Recommended flow:\n" + - "1. leadbay_find_prospects — discovery (returns scored leads)\n" + - "2. leadbay_research_company — deep-dive on a lead (profile + contacts + activity)\n" + - "3. leadbay_prepare_outreach — assemble a contact package for the recommended contact\n\n" + - "The 11 granular tools (leadbay_list_lenses, leadbay_discover_leads, " + - "leadbay_get_lead_profile, etc.) map 1:1 to Leadbay API endpoints and are " + - "available if LEADBAY_MCP_ADVANCED=1 is set. Most tasks do not need them."; + "After every email, call, message, or meeting with a lead's contact, you MUST call leadbay_report_outreach " + + "with verification={source, ref} (gmail_message_id from the Gmail send, calendar_event_id from a booking, " + + "or user_confirmed=''). Skipping or fabricating verification poisons the " + + "human team's pipeline.\n\n" + + "Start with leadbay_account_status to see the user's state, then leadbay_pull_leads to surface fresh leads. " + + "Use leadbay_research_lead to dig into one lead deeply (qualification answers, signals, contacts). " + + "When the user wants more leads, narrower audience, refined criteria, or contact enrichment, use the matching " + + "composite tool (bulk_qualify_leads / adjust_audience / refine_prompt / enrich_titles) — they hide lens " + + "permissions, region routing, polling, and selection state from you."; interface BuildServerOptions { includeAdvanced?: boolean; + includeWrite?: boolean; logger?: ToolLogger; } function formatErrorForLLM(err: any): string { - // LeadbayError shape: { error: true, code, message, hint } + // LeadbayError shape: { error: true, code, message, hint, _meta? } if (err && typeof err === "object" && err.error === true) { - return `${err.message}. ${err.hint}`.trim(); + const parts = [`${err.message}.`, err.hint]; + if (err._meta?.region) { + parts.push(`(region=${err._meta.region}, endpoint=${err._meta.endpoint || "?"})`); + } + if (err._meta?.retry_after) { + parts.push(`Retry after ${err._meta.retry_after}s.`); + } + return parts.filter(Boolean).join(" ").trim(); } if (err instanceof Error) { return err.message; @@ -51,24 +63,42 @@ export function buildServer( opts: BuildServerOptions = {} ): Server { const server = new Server( - { name: "leadbay", version: "0.1.0" }, + { name: "leadbay", version: "0.2.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS, } ); - const exposedTools: Tool[] = opts.includeAdvanced - ? [...compositeTools, ...granularTools.filter((t) => t.name !== "leadbay_login")] - : [...compositeTools]; + const exposedTools: Tool[] = []; + // Read composites — ALWAYS exposed. + exposedTools.push(...compositeReadTools); + // Write composites — gated by includeWrite (LEADBAY_MCP_WRITE=1). + if (opts.includeWrite) { + exposedTools.push(...compositeWriteTools); + } + // Granular tools — gated by includeAdvanced (LEADBAY_MCP_ADVANCED=1). + // Within advanced, write granulars are further gated by includeWrite. + if (opts.includeAdvanced) { + exposedTools.push(...granularReadTools); + if (opts.includeWrite) { + exposedTools.push(...granularWriteTools); + } + } // UC-3: leadbay_login is NEVER registered on MCP (prompt-injection vector). // It remains available only in the OpenClaw adapter. - const toolByName = new Map(exposedTools.map((t) => [t.name, t])); + // Dedup by name (some tools may be referenced in multiple catalogues). + const toolByName = new Map(); + for (const t of exposedTools) { + if (!toolByName.has(t.name) && t.name !== "leadbay_login") { + toolByName.set(t.name, t); + } + } server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: toolsListPayload(exposedTools), + tools: toolsListPayload([...toolByName.values()]), })); server.setRequestHandler(CallToolRequestSchema, async (req) => { diff --git a/packages/mcp/test/concurrency.test.ts b/packages/mcp/test/concurrency.test.ts index c2bad74..a3eabc8 100644 --- a/packages/mcp/test/concurrency.test.ts +++ b/packages/mcp/test/concurrency.test.ts @@ -27,15 +27,24 @@ beforeEach(() => { describe("MCP server — concurrency", () => { it("10 concurrent tools/call resolve and leave the semaphore at zero", async () => { // Each find_prospects call makes at minimum: GET /lenses + GET wishlist. - // Pre-script 20 responses (2 × 10) so the mock can serve them all. - const scripts = []; + // pull_leads needs /me + wishlist per call. /me has a 60s cache, BUT + // concurrent callers can race past the cache check before the first + // response populates it. Pre-script enough /me responses for the worst + // case (one per concurrent caller). + const scripts: any[] = []; for (let i = 0; i < 10; i++) { scripts.push({ method: "GET", - path: "/1.5/lenses", + path: "/1.5/users/me", status: 200, - body: [{ id: 42, name: "X", is_last_active: true }], + body: { + id: "u", + organization: { id: "org-1", name: "X" }, + last_requested_lens: 42, + }, }); + } + for (let i = 0; i < 10; i++) { scripts.push({ method: "GET", path: /\/1\.5\/lenses\/42\/leads\/wishlist/, @@ -43,6 +52,8 @@ describe("MCP server — concurrency", () => { body: { items: [], pagination: { page: 0, pages: 0, total: 0 }, + computing_wishlist: false, + computing_scores: false, }, }); } @@ -62,7 +73,7 @@ describe("MCP server — concurrency", () => { for (let i = 0; i < 10; i++) { promises.push( mcpClient.callTool({ - name: "leadbay_find_prospects", + name: "leadbay_pull_leads", arguments: { count: 5 }, }) ); @@ -72,6 +83,11 @@ describe("MCP server — concurrency", () => { // All resolved (even if client-level the cache means only 1 /lenses went out; // the server-level plumbing should still handle 10 concurrent calls). expect(results).toHaveLength(10); + const errors = results.filter((r: any) => r.isError); + if (errors.length) { + const firstErrorContent = (errors[0] as any).content?.[0]?.text; + console.error("CONCURRENCY ERROR:", firstErrorContent); + } for (const r of results) { expect(r.isError).toBeFalsy(); } diff --git a/packages/mcp/test/harness.ts b/packages/mcp/test/harness.ts index b1c7346..b70c4db 100644 --- a/packages/mcp/test/harness.ts +++ b/packages/mcp/test/harness.ts @@ -78,6 +78,7 @@ function fakeHttpsRequest(options: any, callback?: (res: any) => void): any { } const res = new EventEmitter() as any; res.statusCode = entry.script.status; + res.headers = (entry.script as any).responseHeaders ?? {}; setImmediate(() => { if (callback) callback(res); const bodyStr = diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index 2ca9e35..21f7bed 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -19,10 +19,14 @@ const BASE = "https://api-us.leadbay.app"; async function connect(opts: { includeAdvanced?: boolean; + includeWrite?: boolean; client?: LeadbayClient; } = {}) { const lbClient = opts.client ?? new LeadbayClient(BASE, "u.test-token"); - const server = buildServer(lbClient, { includeAdvanced: opts.includeAdvanced }); + const server = buildServer(lbClient, { + includeAdvanced: opts.includeAdvanced, + includeWrite: opts.includeWrite, + }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const mcpClient = new Client({ name: "test", version: "0.0.1" }, {}); await Promise.all([ @@ -36,17 +40,26 @@ beforeEach(() => { resetHttpMock(); }); -describe("tools/list — default (composite only)", () => { - it("returns the 3 composite tools with non-empty descriptions", async () => { +describe("tools/list — default (composite read only)", () => { + it("returns the composite read tools with non-empty descriptions", async () => { const { mcpClient } = await connect(); const listed = await mcpClient.listTools(); - const names = listed.tools.map((t) => t.name).sort(); + const names = new Set(listed.tools.map((t) => t.name)); - expect(names).toEqual([ - "leadbay_find_prospects", - "leadbay_prepare_outreach", - "leadbay_research_company", - ]); + // v0.2.0: composite reads exposed by default; writes gated by includeWrite. + expect(names).toContain("leadbay_pull_leads"); + expect(names).toContain("leadbay_research_lead"); + expect(names).toContain("leadbay_account_status"); + expect(names).toContain("leadbay_recall_ordered_titles"); + // Existing composites (kept for back-compat) + expect(names).toContain("leadbay_research_company"); + expect(names).toContain("leadbay_prepare_outreach"); + // Write composites must NOT be exposed without includeWrite. + expect(names).not.toContain("leadbay_report_outreach"); + expect(names).not.toContain("leadbay_refine_prompt"); + expect(names).not.toContain("leadbay_adjust_audience"); + // find_prospects was removed in v0.2.0 (replaced by pull_leads). + expect(names).not.toContain("leadbay_find_prospects"); for (const t of listed.tools) { expect(t.description).toBeTypeOf("string"); @@ -68,29 +81,63 @@ describe("tools/list — default (composite only)", () => { }); }); +describe("tools/list — write mode (LEADBAY_MCP_WRITE=1)", () => { + it("exposes composite write tools when includeWrite=true", async () => { + const { mcpClient } = await connect({ includeWrite: true }); + const names = new Set((await mcpClient.listTools()).tools.map((t) => t.name)); + expect(names).toContain("leadbay_report_outreach"); + expect(names).toContain("leadbay_refine_prompt"); + expect(names).toContain("leadbay_answer_clarification"); + expect(names).toContain("leadbay_adjust_audience"); + expect(names).toContain("leadbay_bulk_qualify_leads"); + expect(names).toContain("leadbay_enrich_titles"); + // Granular writes still gated unless ALSO includeAdvanced. + expect(names).not.toContain("leadbay_select_leads"); + }); +}); + describe("tools/list — advanced mode", () => { - it("exposes composite + 10 granular tools when includeAdvanced=true", async () => { + it("exposes composite reads + granular reads when includeAdvanced only", async () => { const { mcpClient } = await connect({ includeAdvanced: true }); - const listed = await mcpClient.listTools(); - const names = listed.tools.map((t) => t.name); - - // 3 composite + 10 granular (all 11 minus login) - expect(names.length).toBe(13); - expect(names).toContain("leadbay_find_prospects"); + const names = new Set((await mcpClient.listTools()).tools.map((t) => t.name)); + expect(names).toContain("leadbay_pull_leads"); expect(names).toContain("leadbay_list_lenses"); expect(names).toContain("leadbay_discover_leads"); - expect(names).not.toContain("leadbay_login"); // still gated for security + expect(names).toContain("leadbay_get_lens_filter"); + expect(names).not.toContain("leadbay_login"); + // Writes still gated. + expect(names).not.toContain("leadbay_select_leads"); + expect(names).not.toContain("leadbay_report_outreach"); + }); + + it("exposes everything except login when includeAdvanced+includeWrite", async () => { + const { mcpClient } = await connect({ + includeAdvanced: true, + includeWrite: true, + }); + const names = new Set((await mcpClient.listTools()).tools.map((t) => t.name)); + expect(names).toContain("leadbay_pull_leads"); + expect(names).toContain("leadbay_select_leads"); + expect(names).toContain("leadbay_set_user_prompt"); + expect(names).toContain("leadbay_launch_bulk_enrichment"); + expect(names).toContain("leadbay_report_outreach"); + expect(names).not.toContain("leadbay_login"); }); }); describe("tools/call — composite round-trip", () => { - it("leadbay_find_prospects returns leads via mocked HTTP", async () => { + it("leadbay_pull_leads returns leads via mocked HTTP", async () => { mockHttp([ + // resolveDefaultLens → /me first { method: "GET", - path: "/1.5/lenses", + path: "/1.5/users/me", status: 200, - body: [{ id: 42, name: "X", is_last_active: true }], + body: { + id: "u", + organization: { id: "org-1", name: "X" }, + last_requested_lens: 42, + }, }, { method: "GET", @@ -108,22 +155,34 @@ describe("tools/call — composite round-trip", () => { size: null, website: "acme.com", contacts_count: 0, - ai_summary: "good", - split_ai_summary: null, + org_contacts_count: 0, tags: [], phone_numbers: [], keywords: [], recommended_contact_title: null, recommended_contact: null, + liked: false, + disliked: false, }, ], pagination: { page: 0, pages: 1, total: 1 }, + computing_wishlist: false, + computing_scores: false, }, }, + // qualification fan-out (1 lead) + { + method: "GET", + path: "/1.5/leads/lead-1/ai_agent_responses", + status: 200, + body: [ + { question: "Q1", question_created_at: "2026-04-20T00:00:00Z", lead_id: "lead-1", score: 8, response: "good fit", computed_at: "2026-04-20T00:00:00Z" }, + ], + }, ]); const { mcpClient } = await connect(); const result = await mcpClient.callTool({ - name: "leadbay_find_prospects", + name: "leadbay_pull_leads", arguments: { count: 10 }, }); expect(result.isError).toBeFalsy(); @@ -132,6 +191,7 @@ describe("tools/call — composite round-trip", () => { const parsed = JSON.parse(text); expect(parsed.leads).toHaveLength(1); expect(parsed.leads[0].name).toBe("Acme"); + expect(parsed.leads[0].qualification_summary).toBeDefined(); }); }); @@ -149,6 +209,14 @@ describe("tools/call — error envelopes", () => { it("AUTH_EXPIRED from client surfaces as isError:true with fix instructions", async () => { mockHttp([ + // pull_leads → resolveDefaultLens → /me first + { + method: "GET", + path: "/1.5/users/me", + status: 401, + body: { message: "expired" }, + }, + // Fallback to /lenses scan after /me 401 — also 401. { method: "GET", path: "/1.5/lenses", @@ -158,12 +226,11 @@ describe("tools/call — error envelopes", () => { ]); const { mcpClient } = await connect(); const result = await mcpClient.callTool({ - name: "leadbay_find_prospects", + name: "leadbay_pull_leads", arguments: {}, }); expect(result.isError).toBe(true); const content = result.content as any[]; - // Hint text should reach the user via the LLM verbatim expect(content[0].text).toMatch(/authentication token expired/i); expect(content[0].text).toMatch(/Regenerate/); }); @@ -185,12 +252,19 @@ describe("tools/call — error envelopes", () => { }); describe("server.instructions — LLM guidance string", () => { - it("buildServer includes a flow-guidance string", async () => { + it("buildServer leads with the report_outreach mandate (cross-phase critical)", async () => { + const { SERVER_INSTRUCTIONS } = await import("../src/server.js"); + // First sentence MUST be the verification mandate so the model retains it. + expect(SERVER_INSTRUCTIONS.slice(0, 200)).toMatch(/report_outreach/i); + expect(SERVER_INSTRUCTIONS).toMatch(/verification/i); + expect(SERVER_INSTRUCTIONS).toMatch(/gmail_message_id|calendar_event_id|user_confirmed/); + }); + + it("server instructions reference the new composite agent flow", async () => { const { SERVER_INSTRUCTIONS } = await import("../src/server.js"); - expect(SERVER_INSTRUCTIONS).toMatch(/leadbay_find_prospects/); - expect(SERVER_INSTRUCTIONS).toMatch(/leadbay_research_company/); - expect(SERVER_INSTRUCTIONS).toMatch(/leadbay_prepare_outreach/); - expect(SERVER_INSTRUCTIONS).toMatch(/LEADBAY_MCP_ADVANCED/); + expect(SERVER_INSTRUCTIONS).toMatch(/leadbay_pull_leads/); + expect(SERVER_INSTRUCTIONS).toMatch(/leadbay_research_lead/); + expect(SERVER_INSTRUCTIONS).toMatch(/leadbay_account_status/); }); });