@leadbay/mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +140 -0
- package/README.md +273 -0
- package/dist/bin.js +824 -0
- package/dist/chunk-BGJ6JWIO.js +3010 -0
- package/dist/dist-PIXZN6N4.js +133 -0
- package/package.json +56 -0
|
@@ -0,0 +1,3010 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../core/dist/client.js
|
|
4
|
+
import https from "https";
|
|
5
|
+
import { readdirSync, readFileSync, existsSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
var LENS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
8
|
+
var TASTE_CACHE_TTL_MS = 10 * 60 * 1e3;
|
|
9
|
+
var ME_CACHE_TTL_MS = 60 * 1e3;
|
|
10
|
+
var MAX_CONCURRENT = 5;
|
|
11
|
+
var REGIONS = {
|
|
12
|
+
us: "https://api-us.leadbay.app",
|
|
13
|
+
fr: "https://api-fr.leadbay.app"
|
|
14
|
+
};
|
|
15
|
+
function httpsRequest(method, url, headers, body) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const start = Date.now();
|
|
18
|
+
const parsed = new URL(url);
|
|
19
|
+
const reqHeaders = { ...headers };
|
|
20
|
+
if (body) {
|
|
21
|
+
reqHeaders["Content-Length"] = Buffer.byteLength(body);
|
|
22
|
+
}
|
|
23
|
+
const req = https.request({
|
|
24
|
+
hostname: parsed.hostname,
|
|
25
|
+
port: 443,
|
|
26
|
+
path: parsed.pathname + parsed.search,
|
|
27
|
+
method,
|
|
28
|
+
headers: reqHeaders
|
|
29
|
+
}, (res) => {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
32
|
+
res.on("end", () => {
|
|
33
|
+
resolve({
|
|
34
|
+
status: res.statusCode ?? 0,
|
|
35
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
36
|
+
headers: res.headers,
|
|
37
|
+
latency_ms: Date.now() - start
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
req.on("error", reject);
|
|
42
|
+
if (body)
|
|
43
|
+
req.write(body);
|
|
44
|
+
req.end();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function createClient(config = {}) {
|
|
48
|
+
const region = config.region ?? "us";
|
|
49
|
+
const baseUrl = config.baseUrl ?? REGIONS[region];
|
|
50
|
+
if (!baseUrl) {
|
|
51
|
+
throw new Error(`Leadbay: unknown region "${region}". Supported: ${Object.keys(REGIONS).join(", ")}. Or pass an explicit baseUrl.`);
|
|
52
|
+
}
|
|
53
|
+
return new LeadbayClient(baseUrl, config.token, region);
|
|
54
|
+
}
|
|
55
|
+
async function resolveRegion(email, password, startWith = "us") {
|
|
56
|
+
const order = startWith === "fr" ? ["fr", "us"] : ["us", "fr"];
|
|
57
|
+
let lastErr = null;
|
|
58
|
+
for (const region of order) {
|
|
59
|
+
const baseUrl = REGIONS[region];
|
|
60
|
+
const body = JSON.stringify({ email, password });
|
|
61
|
+
try {
|
|
62
|
+
const res = await httpsRequest("POST", `${baseUrl}/1.5/auth/login`, { "Content-Type": "application/json" }, body);
|
|
63
|
+
if (res.status === 200) {
|
|
64
|
+
const parsed = JSON.parse(res.body);
|
|
65
|
+
if (parsed?.token) {
|
|
66
|
+
return {
|
|
67
|
+
region,
|
|
68
|
+
baseUrl,
|
|
69
|
+
token: parsed.token,
|
|
70
|
+
verified: parsed.verified === true
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
lastErr = { status: res.status, body: res.body, region };
|
|
75
|
+
} catch (e) {
|
|
76
|
+
lastErr = { error: e, region };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`Leadbay login failed in both regions (us, fr). Last response: ${JSON.stringify(lastErr)}`);
|
|
80
|
+
}
|
|
81
|
+
var _mockFixtures = null;
|
|
82
|
+
var _mockJournal = [];
|
|
83
|
+
function loadMockFixtures(dir) {
|
|
84
|
+
if (!existsSync(dir))
|
|
85
|
+
return [];
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const f of readdirSync(dir)) {
|
|
88
|
+
if (!f.endsWith(".json"))
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
const raw = readFileSync(join(dir, f), "utf8");
|
|
92
|
+
const parsed = JSON.parse(raw);
|
|
93
|
+
if (!parsed.request || !parsed.response)
|
|
94
|
+
continue;
|
|
95
|
+
const url = parsed.request.url ?? "";
|
|
96
|
+
const u = new URL(url);
|
|
97
|
+
out.push({
|
|
98
|
+
method: parsed.request.method ?? "GET",
|
|
99
|
+
path: u.pathname + u.search,
|
|
100
|
+
status: parsed.response.status,
|
|
101
|
+
body: parsed.response.body,
|
|
102
|
+
headers: parsed.response.headers ?? {}
|
|
103
|
+
});
|
|
104
|
+
} catch {
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
function ensureMockLoaded() {
|
|
110
|
+
if (_mockFixtures !== null)
|
|
111
|
+
return;
|
|
112
|
+
const dir = process.env.LEADBAY_MOCK_DIR ?? join(process.cwd(), ".context", "leadbay-live-shapes");
|
|
113
|
+
_mockFixtures = loadMockFixtures(dir);
|
|
114
|
+
if (process.env.LEADBAY_MOCK === "1") {
|
|
115
|
+
process.stderr.write(`[leadbay mock] loaded ${_mockFixtures.length} fixtures from ${dir}
|
|
116
|
+
`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function findMockFixture(method, basePath) {
|
|
120
|
+
ensureMockLoaded();
|
|
121
|
+
if (!_mockFixtures)
|
|
122
|
+
return null;
|
|
123
|
+
for (const f of _mockFixtures) {
|
|
124
|
+
if (f.method !== method)
|
|
125
|
+
continue;
|
|
126
|
+
if (basePath === f.path)
|
|
127
|
+
return f;
|
|
128
|
+
const fNoQs = f.path.split("?")[0];
|
|
129
|
+
const bNoQs = basePath.split("?")[0];
|
|
130
|
+
if (fNoQs === bNoQs)
|
|
131
|
+
return f;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
function getMockJournal() {
|
|
136
|
+
return _mockJournal;
|
|
137
|
+
}
|
|
138
|
+
function clearMockJournal() {
|
|
139
|
+
_mockJournal = [];
|
|
140
|
+
}
|
|
141
|
+
var LeadbayClient = class {
|
|
142
|
+
token;
|
|
143
|
+
_baseUrl;
|
|
144
|
+
_region;
|
|
145
|
+
defaultLensId = null;
|
|
146
|
+
defaultLensCachedAt = null;
|
|
147
|
+
mePayload = null;
|
|
148
|
+
mePayloadCachedAt = null;
|
|
149
|
+
tasteProfile = null;
|
|
150
|
+
tasteProfileCachedAt = null;
|
|
151
|
+
// Simple semaphore for concurrency limiting.
|
|
152
|
+
activeRequests = 0;
|
|
153
|
+
waitQueue = [];
|
|
154
|
+
// Selection-state Mutex. The /leads/selection/* endpoints share global
|
|
155
|
+
// server-side state per token, so two parallel composites that each call
|
|
156
|
+
// select → action → clear would clobber each other. Composites that touch
|
|
157
|
+
// selection acquire this lock for the lifetime of their select…clear cycle.
|
|
158
|
+
selectionLockHeld = false;
|
|
159
|
+
selectionWaitQueue = [];
|
|
160
|
+
// Last response metadata — composites can read this after a request to
|
|
161
|
+
// surface latency/region/retry_after to the agent in their `_meta` block.
|
|
162
|
+
_lastMeta = null;
|
|
163
|
+
constructor(baseUrl, token, region) {
|
|
164
|
+
this._baseUrl = baseUrl.replace(/\/+$/, "");
|
|
165
|
+
this.token = token ?? null;
|
|
166
|
+
this._region = region ?? (baseUrl === REGIONS.us ? "us" : baseUrl === REGIONS.fr ? "fr" : "custom");
|
|
167
|
+
}
|
|
168
|
+
get baseUrl() {
|
|
169
|
+
return this._baseUrl;
|
|
170
|
+
}
|
|
171
|
+
get region() {
|
|
172
|
+
return this._region;
|
|
173
|
+
}
|
|
174
|
+
get lastMeta() {
|
|
175
|
+
return this._lastMeta;
|
|
176
|
+
}
|
|
177
|
+
// Used by login when region auto-detect picks a different backend than the
|
|
178
|
+
// one the client was constructed with.
|
|
179
|
+
setBaseUrl(baseUrl, region) {
|
|
180
|
+
this._baseUrl = baseUrl.replace(/\/+$/, "");
|
|
181
|
+
this._region = region ?? (baseUrl === REGIONS.us ? "us" : baseUrl === REGIONS.fr ? "fr" : "custom");
|
|
182
|
+
this.defaultLensId = null;
|
|
183
|
+
this.defaultLensCachedAt = null;
|
|
184
|
+
this.mePayload = null;
|
|
185
|
+
this.mePayloadCachedAt = null;
|
|
186
|
+
this.tasteProfile = null;
|
|
187
|
+
this.tasteProfileCachedAt = null;
|
|
188
|
+
}
|
|
189
|
+
setToken(token) {
|
|
190
|
+
this.token = token;
|
|
191
|
+
}
|
|
192
|
+
get isAuthenticated() {
|
|
193
|
+
return this.token !== null;
|
|
194
|
+
}
|
|
195
|
+
// Test-only getter for concurrency assertions
|
|
196
|
+
get _semaphoreState() {
|
|
197
|
+
return { active: this.activeRequests, queued: this.waitQueue.length };
|
|
198
|
+
}
|
|
199
|
+
async acquireSemaphore() {
|
|
200
|
+
if (this.activeRequests < MAX_CONCURRENT) {
|
|
201
|
+
this.activeRequests++;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
this.waitQueue.push(() => {
|
|
206
|
+
this.activeRequests++;
|
|
207
|
+
resolve();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
releaseSemaphore() {
|
|
212
|
+
this.activeRequests--;
|
|
213
|
+
const next = this.waitQueue.shift();
|
|
214
|
+
if (next)
|
|
215
|
+
next();
|
|
216
|
+
}
|
|
217
|
+
// Selection Mutex — composites that touch /leads/selection/* must wrap
|
|
218
|
+
// their select…clear cycle in acquire/release to avoid clobbering across
|
|
219
|
+
// concurrent invocations.
|
|
220
|
+
async acquireSelectionLock() {
|
|
221
|
+
if (!this.selectionLockHeld) {
|
|
222
|
+
this.selectionLockHeld = true;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
return new Promise((resolve) => {
|
|
226
|
+
this.selectionWaitQueue.push(() => {
|
|
227
|
+
this.selectionLockHeld = true;
|
|
228
|
+
resolve();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
releaseSelectionLock() {
|
|
233
|
+
this.selectionLockHeld = false;
|
|
234
|
+
const next = this.selectionWaitQueue.shift();
|
|
235
|
+
if (next)
|
|
236
|
+
next();
|
|
237
|
+
}
|
|
238
|
+
async request(method, path, body) {
|
|
239
|
+
if (process.env.LEADBAY_MOCK === "1") {
|
|
240
|
+
return this.mockRequest(method, path, body);
|
|
241
|
+
}
|
|
242
|
+
if (!this.token) {
|
|
243
|
+
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", path);
|
|
244
|
+
}
|
|
245
|
+
await this.acquireSemaphore();
|
|
246
|
+
try {
|
|
247
|
+
const url = `${this._baseUrl}/1.5${path}`;
|
|
248
|
+
const headers = {
|
|
249
|
+
Authorization: `Bearer ${this.token}`
|
|
250
|
+
};
|
|
251
|
+
if (body) {
|
|
252
|
+
headers["Content-Type"] = "application/json";
|
|
253
|
+
}
|
|
254
|
+
const res = await httpsRequest(method, url, headers, body ? JSON.stringify(body) : void 0);
|
|
255
|
+
this._lastMeta = {
|
|
256
|
+
region: this._region,
|
|
257
|
+
endpoint: `${method} ${path}`,
|
|
258
|
+
latency_ms: res.latency_ms,
|
|
259
|
+
retry_after: parseRetryAfter(res.headers["retry-after"])
|
|
260
|
+
};
|
|
261
|
+
if (res.status === 204) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
if (res.status < 200 || res.status >= 300) {
|
|
265
|
+
throw this.mapErrorResponse(res.status, res.body, path, res.headers);
|
|
266
|
+
}
|
|
267
|
+
return JSON.parse(res.body);
|
|
268
|
+
} finally {
|
|
269
|
+
this.releaseSemaphore();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async requestVoid(method, path, body) {
|
|
273
|
+
if (process.env.LEADBAY_MOCK === "1") {
|
|
274
|
+
await this.mockRequest(method, path, body);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (!this.token) {
|
|
278
|
+
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", path);
|
|
279
|
+
}
|
|
280
|
+
await this.acquireSemaphore();
|
|
281
|
+
try {
|
|
282
|
+
const url = `${this._baseUrl}/1.5${path}`;
|
|
283
|
+
const headers = {
|
|
284
|
+
Authorization: `Bearer ${this.token}`
|
|
285
|
+
};
|
|
286
|
+
if (body) {
|
|
287
|
+
headers["Content-Type"] = "application/json";
|
|
288
|
+
}
|
|
289
|
+
const res = await httpsRequest(method, url, headers, body ? JSON.stringify(body) : void 0);
|
|
290
|
+
this._lastMeta = {
|
|
291
|
+
region: this._region,
|
|
292
|
+
endpoint: `${method} ${path}`,
|
|
293
|
+
latency_ms: res.latency_ms,
|
|
294
|
+
retry_after: parseRetryAfter(res.headers["retry-after"])
|
|
295
|
+
};
|
|
296
|
+
if (res.status < 200 || res.status >= 300) {
|
|
297
|
+
throw this.mapErrorResponse(res.status, res.body, path, res.headers);
|
|
298
|
+
}
|
|
299
|
+
} finally {
|
|
300
|
+
this.releaseSemaphore();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
mockRequest(method, path, body) {
|
|
304
|
+
const fullPath = `/1.5${path}`;
|
|
305
|
+
this._lastMeta = {
|
|
306
|
+
region: this._region,
|
|
307
|
+
endpoint: `${method} ${path}`,
|
|
308
|
+
latency_ms: 0,
|
|
309
|
+
retry_after: null
|
|
310
|
+
};
|
|
311
|
+
if (method === "GET") {
|
|
312
|
+
const fixture = findMockFixture("GET", fullPath);
|
|
313
|
+
if (!fixture) {
|
|
314
|
+
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/) \u2014 run a probe script to generate one.`, path);
|
|
315
|
+
}
|
|
316
|
+
if (fixture.status === 204)
|
|
317
|
+
return null;
|
|
318
|
+
return fixture.body;
|
|
319
|
+
}
|
|
320
|
+
_mockJournal.push({ method, path: fullPath, body, ts: Date.now() });
|
|
321
|
+
return {
|
|
322
|
+
mocked: true,
|
|
323
|
+
would_call: { method, path: fullPath, body }
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
mapErrorResponse(status, rawBody, endpoint, headers) {
|
|
327
|
+
let parsed;
|
|
328
|
+
try {
|
|
329
|
+
parsed = JSON.parse(rawBody);
|
|
330
|
+
} catch {
|
|
331
|
+
parsed = null;
|
|
332
|
+
}
|
|
333
|
+
const retryAfter = parseRetryAfter(headers["retry-after"]);
|
|
334
|
+
if (status === 401) {
|
|
335
|
+
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.", endpoint);
|
|
336
|
+
}
|
|
337
|
+
if (status === 429 || status === 402 || parsed?.error === "quota_exceeded" || parsed?.error?.code === "quota_exceeded") {
|
|
338
|
+
return this.makeError("QUOTA_EXCEEDED", retryAfter ? `Quota exceeded \u2014 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);
|
|
339
|
+
}
|
|
340
|
+
if (status === 403) {
|
|
341
|
+
const msg = parsed?.message || parsed?.error || parsed?.error?.message || "";
|
|
342
|
+
if (typeof msg === "string" && (msg.includes("suspend") || msg.includes("billing"))) {
|
|
343
|
+
return this.makeError("BILLING_SUSPENDED", "Account billing is suspended", "Your Leadbay account billing is suspended. Update at https://app.leadbay.ai", endpoint);
|
|
344
|
+
}
|
|
345
|
+
return this.makeError("FORBIDDEN", "Insufficient permissions", "Your token does not have access to this resource. Check account permissions at https://app.leadbay.ai", endpoint);
|
|
346
|
+
}
|
|
347
|
+
if (status === 404) {
|
|
348
|
+
return this.makeError("NOT_FOUND", parsed?.message || parsed?.error?.message || "Resource not found", "Verify the ID is correct", endpoint);
|
|
349
|
+
}
|
|
350
|
+
return this.makeError("API_ERROR", parsed?.message || parsed?.error?.message || `API error (${status})`, "Try again or check the Leadbay API status", endpoint);
|
|
351
|
+
}
|
|
352
|
+
// /me cache (60s TTL). Separate from resolveOrgId() which still works for
|
|
353
|
+
// legacy callers (it now delegates here).
|
|
354
|
+
async resolveMe(force = false) {
|
|
355
|
+
const now = Date.now();
|
|
356
|
+
if (!force && this.mePayload !== null && this.mePayloadCachedAt !== null && now - this.mePayloadCachedAt < ME_CACHE_TTL_MS) {
|
|
357
|
+
return this.mePayload;
|
|
358
|
+
}
|
|
359
|
+
const me = await this.request("GET", "/users/me");
|
|
360
|
+
this.mePayload = me;
|
|
361
|
+
this.mePayloadCachedAt = now;
|
|
362
|
+
return me;
|
|
363
|
+
}
|
|
364
|
+
// Force re-fetch on next resolveMe(). Call from any tool that mutates a
|
|
365
|
+
// /me-cached field (last_requested_lens, billing, etc.).
|
|
366
|
+
invalidateMe() {
|
|
367
|
+
this.mePayload = null;
|
|
368
|
+
this.mePayloadCachedAt = null;
|
|
369
|
+
}
|
|
370
|
+
async resolveDefaultLens() {
|
|
371
|
+
const now = Date.now();
|
|
372
|
+
if (this.defaultLensId !== null && this.defaultLensCachedAt !== null && now - this.defaultLensCachedAt < LENS_CACHE_TTL_MS) {
|
|
373
|
+
return this.defaultLensId;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
const me = await this.resolveMe();
|
|
377
|
+
if (me.last_requested_lens != null) {
|
|
378
|
+
this.defaultLensId = me.last_requested_lens;
|
|
379
|
+
this.defaultLensCachedAt = now;
|
|
380
|
+
return this.defaultLensId;
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
}
|
|
384
|
+
const lenses = await this.request("GET", "/lenses");
|
|
385
|
+
const active = lenses.find((l) => l.is_last_active);
|
|
386
|
+
const fallback = active || lenses.find((l) => l.is_default || l.default) || lenses[0];
|
|
387
|
+
if (!fallback) {
|
|
388
|
+
throw this.makeError("NO_LENS", "No lenses found on your account", "Open the Leadbay web UI once to provision your first lens, or create one via the API", "GET /lenses");
|
|
389
|
+
}
|
|
390
|
+
this.defaultLensId = fallback.id;
|
|
391
|
+
this.defaultLensCachedAt = now;
|
|
392
|
+
return this.defaultLensId;
|
|
393
|
+
}
|
|
394
|
+
invalidateDefaultLens() {
|
|
395
|
+
this.defaultLensId = null;
|
|
396
|
+
this.defaultLensCachedAt = null;
|
|
397
|
+
}
|
|
398
|
+
async resolveOrgId() {
|
|
399
|
+
const me = await this.resolveMe();
|
|
400
|
+
return me.organization.id;
|
|
401
|
+
}
|
|
402
|
+
async resolveTasteProfile() {
|
|
403
|
+
const now = Date.now();
|
|
404
|
+
if (this.tasteProfile !== null && this.tasteProfileCachedAt !== null && now - this.tasteProfileCachedAt < TASTE_CACHE_TTL_MS) {
|
|
405
|
+
return this.tasteProfile;
|
|
406
|
+
}
|
|
407
|
+
const orgId = await this.resolveOrgId();
|
|
408
|
+
const [ibpResult, tagsResult, questionsResult] = await Promise.allSettled([
|
|
409
|
+
this.request("GET", `/organizations/${orgId}/ideal_buyer_profile`),
|
|
410
|
+
this.request("GET", `/organizations/${orgId}/purchase_intent_tags`),
|
|
411
|
+
this.request("GET", `/organizations/${orgId}/ai_agent_questions`)
|
|
412
|
+
]);
|
|
413
|
+
this.tasteProfile = {
|
|
414
|
+
idealBuyerProfile: ibpResult.status === "fulfilled" ? ibpResult.value : null,
|
|
415
|
+
purchaseIntentTags: tagsResult.status === "fulfilled" ? tagsResult.value : [],
|
|
416
|
+
qualificationQuestions: questionsResult.status === "fulfilled" ? questionsResult.value : []
|
|
417
|
+
};
|
|
418
|
+
this.tasteProfileCachedAt = now;
|
|
419
|
+
return this.tasteProfile;
|
|
420
|
+
}
|
|
421
|
+
invalidateTasteProfile() {
|
|
422
|
+
this.tasteProfile = null;
|
|
423
|
+
this.tasteProfileCachedAt = null;
|
|
424
|
+
}
|
|
425
|
+
async prefetchOrgData() {
|
|
426
|
+
await this.resolveOrgId();
|
|
427
|
+
await this.resolveTasteProfile();
|
|
428
|
+
}
|
|
429
|
+
makeError(code, message, hint, endpoint, retry_after) {
|
|
430
|
+
const out = { error: true, code, message, hint };
|
|
431
|
+
if (endpoint || this._region) {
|
|
432
|
+
out._meta = {
|
|
433
|
+
region: this._region,
|
|
434
|
+
endpoint: endpoint ?? "",
|
|
435
|
+
latency_ms: this._lastMeta?.latency_ms ?? null,
|
|
436
|
+
retry_after: retry_after ?? null
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
function parseRetryAfter(value) {
|
|
443
|
+
if (!value)
|
|
444
|
+
return null;
|
|
445
|
+
const v = Array.isArray(value) ? value[0] : value;
|
|
446
|
+
const n = Number(v);
|
|
447
|
+
if (Number.isFinite(n))
|
|
448
|
+
return n;
|
|
449
|
+
const date = Date.parse(v);
|
|
450
|
+
if (Number.isFinite(date)) {
|
|
451
|
+
return Math.max(0, Math.round((date - Date.now()) / 1e3));
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ../core/dist/tools/login.js
|
|
457
|
+
var login = {
|
|
458
|
+
name: "leadbay_login",
|
|
459
|
+
description: "Log in to Leadbay with email and password. Auto-detects region (us|fr) \u2014 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 \u2014 they can register at https://wow.leadbay.ai/?register=true",
|
|
460
|
+
inputSchema: {
|
|
461
|
+
type: "object",
|
|
462
|
+
properties: {
|
|
463
|
+
email: { type: "string", description: "Leadbay account email address" },
|
|
464
|
+
password: { type: "string", description: "Leadbay account password" }
|
|
465
|
+
},
|
|
466
|
+
required: ["email", "password"]
|
|
467
|
+
},
|
|
468
|
+
execute: async (client, params, ctx) => {
|
|
469
|
+
const cleanPassword = params.password.replace(/\\(.)/g, "$1");
|
|
470
|
+
const startWith = client.region === "fr" ? "fr" : "us";
|
|
471
|
+
ctx?.logger?.info?.(`LeadClaw login: email=${params.email} startRegion=${startWith}`);
|
|
472
|
+
try {
|
|
473
|
+
const result = await resolveRegion(params.email, cleanPassword, startWith);
|
|
474
|
+
if (client.baseUrl !== result.baseUrl) {
|
|
475
|
+
client.setBaseUrl(result.baseUrl, result.region);
|
|
476
|
+
ctx?.logger?.info?.(`LeadClaw login: switched to region=${result.region} (account is in the ${result.region.toUpperCase()} backend)`);
|
|
477
|
+
}
|
|
478
|
+
client.setToken(result.token);
|
|
479
|
+
client.prefetchOrgData().catch(() => {
|
|
480
|
+
});
|
|
481
|
+
return {
|
|
482
|
+
success: true,
|
|
483
|
+
message: `Logged in to Leadbay (${result.region.toUpperCase()})`,
|
|
484
|
+
region: result.region,
|
|
485
|
+
verified: result.verified
|
|
486
|
+
};
|
|
487
|
+
} catch (err) {
|
|
488
|
+
ctx?.logger?.error?.(`LeadClaw login: failed: ${err?.message}`);
|
|
489
|
+
return {
|
|
490
|
+
error: true,
|
|
491
|
+
code: "LOGIN_FAILED",
|
|
492
|
+
message: err?.message || "Login failed in both regions",
|
|
493
|
+
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`
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
// ../core/dist/tools/list-lenses.js
|
|
500
|
+
var listLenses = {
|
|
501
|
+
name: "leadbay_list_lenses",
|
|
502
|
+
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. When to use: when the user wants to switch lens or asks 'what lenses do I have'. When NOT to use: in normal flow \u2014 composites auto-resolve the active lens via /me.last_requested_lens.",
|
|
503
|
+
inputSchema: {
|
|
504
|
+
type: "object",
|
|
505
|
+
properties: {}
|
|
506
|
+
},
|
|
507
|
+
execute: async (client) => {
|
|
508
|
+
const lenses = await client.request("GET", "/lenses");
|
|
509
|
+
return {
|
|
510
|
+
lenses: lenses.map((l) => ({
|
|
511
|
+
id: l.id,
|
|
512
|
+
name: l.name,
|
|
513
|
+
is_last_active: l.is_last_active,
|
|
514
|
+
description: l.description
|
|
515
|
+
}))
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// ../core/dist/tools/discover-leads.js
|
|
521
|
+
var discoverLeads = {
|
|
522
|
+
name: "leadbay_discover_leads",
|
|
523
|
+
description: "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 \u2014 use leadbay_pull_leads, which adds a one-line qualification summary per lead.",
|
|
524
|
+
inputSchema: {
|
|
525
|
+
type: "object",
|
|
526
|
+
properties: {
|
|
527
|
+
lensId: {
|
|
528
|
+
type: "number",
|
|
529
|
+
description: "Lens ID (optional, auto-resolves to the active lens)"
|
|
530
|
+
},
|
|
531
|
+
page: {
|
|
532
|
+
type: "number",
|
|
533
|
+
description: "Page number, 0-indexed (default: 0)"
|
|
534
|
+
},
|
|
535
|
+
count: {
|
|
536
|
+
type: "number",
|
|
537
|
+
description: "Results per page, max 50 (default: 20)"
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
execute: async (client, params) => {
|
|
542
|
+
const lensId = params.lensId ?? await client.resolveDefaultLens();
|
|
543
|
+
const page = params.page ?? 0;
|
|
544
|
+
const count = Math.min(params.count ?? 20, 50);
|
|
545
|
+
const res = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${count}&page=${page}&contacts=true`);
|
|
546
|
+
return {
|
|
547
|
+
leads: res.items.map((lead) => ({
|
|
548
|
+
id: lead.id,
|
|
549
|
+
name: lead.name,
|
|
550
|
+
score: lead.score,
|
|
551
|
+
ai_agent_lead_score: lead.ai_agent_lead_score,
|
|
552
|
+
location: lead.location,
|
|
553
|
+
description: lead.description,
|
|
554
|
+
size: lead.size,
|
|
555
|
+
website: lead.website,
|
|
556
|
+
contacts_count: lead.contacts_count,
|
|
557
|
+
ai_summary: lead.ai_summary,
|
|
558
|
+
split_ai_summary: lead.split_ai_summary,
|
|
559
|
+
tags: lead.tags,
|
|
560
|
+
phone_numbers: lead.phone_numbers,
|
|
561
|
+
keywords: lead.keywords,
|
|
562
|
+
recommended_contact_title: lead.recommended_contact_title ?? null,
|
|
563
|
+
recommended_contact: lead.recommended_contact ?? null
|
|
564
|
+
})),
|
|
565
|
+
pagination: res.pagination
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// ../core/dist/tools/get-lead-profile.js
|
|
571
|
+
var getLeadProfile = {
|
|
572
|
+
name: "leadbay_get_lead_profile",
|
|
573
|
+
description: "Get a full lead profile including company details, AI qualification scores, web insights, and contacts. When to use: low-level \u2014 for fine-grained access to the raw shape of the lead profile. When NOT to use: as the agent's default lead-detail tool \u2014 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.",
|
|
574
|
+
inputSchema: {
|
|
575
|
+
type: "object",
|
|
576
|
+
properties: {
|
|
577
|
+
leadId: {
|
|
578
|
+
type: "string",
|
|
579
|
+
description: "Lead UUID (required)"
|
|
580
|
+
},
|
|
581
|
+
lensId: {
|
|
582
|
+
type: "number",
|
|
583
|
+
description: "Lens ID (optional, auto-resolves to active lens)"
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
required: ["leadId"]
|
|
587
|
+
},
|
|
588
|
+
execute: async (client, params) => {
|
|
589
|
+
const lensId = params.lensId ?? await client.resolveDefaultLens();
|
|
590
|
+
const [leadResult, qualResult, contactsResult, paidContactsResult, webFetchResult] = await Promise.allSettled([
|
|
591
|
+
client.request("GET", `/lenses/${lensId}/leads/${params.leadId}`),
|
|
592
|
+
client.request("GET", `/leads/${params.leadId}/ai_agent_responses`),
|
|
593
|
+
client.request("GET", `/leads/${params.leadId}/contacts?IncludeEnriched=true`),
|
|
594
|
+
client.request("GET", `/leads/${params.leadId}/enrich/contacts?IncludeEnriched=true`),
|
|
595
|
+
client.request("GET", `/leads/${params.leadId}/web_fetch`)
|
|
596
|
+
]);
|
|
597
|
+
if (leadResult.status === "rejected") {
|
|
598
|
+
throw leadResult.reason;
|
|
599
|
+
}
|
|
600
|
+
const lead = leadResult.value;
|
|
601
|
+
const qualification = qualResult.status === "fulfilled" ? qualResult.value : null;
|
|
602
|
+
const orgContacts = contactsResult.status === "fulfilled" ? contactsResult.value : [];
|
|
603
|
+
const paidContacts = paidContactsResult.status === "fulfilled" ? paidContactsResult.value : [];
|
|
604
|
+
const webFetch = webFetchResult.status === "fulfilled" ? webFetchResult.value : null;
|
|
605
|
+
const allContacts = [
|
|
606
|
+
...orgContacts.map((c) => ({
|
|
607
|
+
id: c.id,
|
|
608
|
+
first_name: c.first_name,
|
|
609
|
+
last_name: c.last_name,
|
|
610
|
+
email: c.email,
|
|
611
|
+
phone_number: c.phone_number,
|
|
612
|
+
linkedin_page: c.linkedin_page,
|
|
613
|
+
job_title: c.job_title,
|
|
614
|
+
recommended: c.recommended,
|
|
615
|
+
enrichment: c.enrichment,
|
|
616
|
+
source: "org"
|
|
617
|
+
})),
|
|
618
|
+
...paidContacts.map((c) => ({
|
|
619
|
+
id: c.id,
|
|
620
|
+
first_name: c.first_name,
|
|
621
|
+
last_name: c.last_name,
|
|
622
|
+
email: c.email,
|
|
623
|
+
phone_number: c.phone_number,
|
|
624
|
+
linkedin_page: c.linkedin_page,
|
|
625
|
+
job_title: c.job_title,
|
|
626
|
+
recommended: c.recommended,
|
|
627
|
+
enrichment: c.enrichment,
|
|
628
|
+
source: "paid"
|
|
629
|
+
}))
|
|
630
|
+
];
|
|
631
|
+
return {
|
|
632
|
+
lead: {
|
|
633
|
+
id: lead.id,
|
|
634
|
+
name: lead.name,
|
|
635
|
+
score: lead.score,
|
|
636
|
+
ai_agent_lead_score: lead.ai_agent_lead_score,
|
|
637
|
+
location: lead.location,
|
|
638
|
+
description: lead.description,
|
|
639
|
+
short_description: lead.short_description,
|
|
640
|
+
size: lead.size,
|
|
641
|
+
website: lead.website,
|
|
642
|
+
logo: lead.logo,
|
|
643
|
+
ai_summary: lead.ai_summary,
|
|
644
|
+
split_ai_summary: lead.split_ai_summary,
|
|
645
|
+
tags: lead.tags,
|
|
646
|
+
phone_numbers: lead.phone_numbers,
|
|
647
|
+
keywords: lead.keywords,
|
|
648
|
+
contacts_count: lead.contacts_count,
|
|
649
|
+
recommended_contact_title: lead.recommended_contact_title ?? null,
|
|
650
|
+
recommended_contact: lead.recommended_contact ?? null,
|
|
651
|
+
web_fetch_in_progress: lead.web_fetch_in_progress ?? false
|
|
652
|
+
},
|
|
653
|
+
qualification: qualification?.map((q) => ({
|
|
654
|
+
question: q.question,
|
|
655
|
+
score: q.score,
|
|
656
|
+
response: q.response,
|
|
657
|
+
computed_at: q.computed_at,
|
|
658
|
+
outdated_at: q.outdated_at
|
|
659
|
+
})) ?? null,
|
|
660
|
+
contacts: allContacts,
|
|
661
|
+
web_insights: webFetch?.content ?? null,
|
|
662
|
+
web_insights_fetched_at: webFetch?.fetch_at ?? null
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// ../core/dist/tools/get-contacts.js
|
|
668
|
+
var getContacts = {
|
|
669
|
+
name: "leadbay_get_contacts",
|
|
670
|
+
description: "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.",
|
|
671
|
+
inputSchema: {
|
|
672
|
+
type: "object",
|
|
673
|
+
properties: {
|
|
674
|
+
leadId: {
|
|
675
|
+
type: "string",
|
|
676
|
+
description: "Lead UUID (required)"
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
required: ["leadId"]
|
|
680
|
+
},
|
|
681
|
+
execute: async (client, params) => {
|
|
682
|
+
const [orgResult, paidResult] = await Promise.allSettled([
|
|
683
|
+
client.request("GET", `/leads/${params.leadId}/contacts?IncludeEnriched=true`),
|
|
684
|
+
client.request("GET", `/leads/${params.leadId}/enrich/contacts?IncludeEnriched=true`)
|
|
685
|
+
]);
|
|
686
|
+
const orgContacts = orgResult.status === "fulfilled" ? orgResult.value : [];
|
|
687
|
+
const paidContacts = paidResult.status === "fulfilled" ? paidResult.value : [];
|
|
688
|
+
return {
|
|
689
|
+
contacts: [
|
|
690
|
+
...orgContacts.map((c) => ({
|
|
691
|
+
id: c.id,
|
|
692
|
+
first_name: c.first_name,
|
|
693
|
+
last_name: c.last_name,
|
|
694
|
+
email: c.email,
|
|
695
|
+
phone_number: c.phone_number,
|
|
696
|
+
linkedin_page: c.linkedin_page,
|
|
697
|
+
job_title: c.job_title,
|
|
698
|
+
recommended: c.recommended,
|
|
699
|
+
enrichment: c.enrichment,
|
|
700
|
+
source: "org"
|
|
701
|
+
})),
|
|
702
|
+
...paidContacts.map((c) => ({
|
|
703
|
+
id: c.id,
|
|
704
|
+
first_name: c.first_name,
|
|
705
|
+
last_name: c.last_name,
|
|
706
|
+
email: c.email,
|
|
707
|
+
phone_number: c.phone_number,
|
|
708
|
+
linkedin_page: c.linkedin_page,
|
|
709
|
+
job_title: c.job_title,
|
|
710
|
+
recommended: c.recommended,
|
|
711
|
+
enrichment: c.enrichment,
|
|
712
|
+
source: "paid"
|
|
713
|
+
}))
|
|
714
|
+
]
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
// ../core/dist/tools/get-quota.js
|
|
720
|
+
var getQuota = {
|
|
721
|
+
name: "leadbay_get_quota",
|
|
722
|
+
description: "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 \u2014 operations themselves return 429 with hints; this tool is for diagnostics, not gating.",
|
|
723
|
+
inputSchema: { type: "object", properties: {} },
|
|
724
|
+
execute: async (client) => {
|
|
725
|
+
const orgId = await client.resolveOrgId();
|
|
726
|
+
return await client.request("GET", `/organizations/${orgId}/quota_status`);
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// ../core/dist/tools/get-taste-profile.js
|
|
731
|
+
var getTasteProfile = {
|
|
732
|
+
name: "leadbay_get_taste_profile",
|
|
733
|
+
description: "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 \u2014 leadbay_research_lead already includes the per-lead qualification answers (which are scored against these org-level questions).",
|
|
734
|
+
inputSchema: {
|
|
735
|
+
type: "object",
|
|
736
|
+
properties: {}
|
|
737
|
+
},
|
|
738
|
+
execute: async (client) => {
|
|
739
|
+
const profile = await client.resolveTasteProfile();
|
|
740
|
+
const isEmpty = !profile.idealBuyerProfile && profile.purchaseIntentTags.length === 0 && profile.qualificationQuestions.length === 0;
|
|
741
|
+
return {
|
|
742
|
+
ideal_buyer_profile: profile.idealBuyerProfile ? {
|
|
743
|
+
summary: profile.idealBuyerProfile.summary,
|
|
744
|
+
key_characteristics: profile.idealBuyerProfile.key_characteristics,
|
|
745
|
+
anti_patterns: profile.idealBuyerProfile.anti_patterns
|
|
746
|
+
} : null,
|
|
747
|
+
purchase_intent_tags: profile.purchaseIntentTags.map((t) => ({
|
|
748
|
+
display_name: t.display_name,
|
|
749
|
+
description: t.description,
|
|
750
|
+
score: t.score,
|
|
751
|
+
reasoning: t.reasoning
|
|
752
|
+
})),
|
|
753
|
+
qualification_questions: profile.qualificationQuestions.map((q) => ({
|
|
754
|
+
question: q.question
|
|
755
|
+
})),
|
|
756
|
+
...isEmpty ? {
|
|
757
|
+
hint: "No taste profile configured yet. Set it up at app.leadbay.ai for better lead matching."
|
|
758
|
+
} : {}
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
// ../core/dist/tools/qualify-lead.js
|
|
764
|
+
var qualifyLead = {
|
|
765
|
+
name: "leadbay_qualify_lead",
|
|
766
|
+
description: "Trigger AI qualification for a single lead (web fetch + AI rescore). The operation is asynchronous \u2014 results take ~60s. When to use: low-level. When NOT to use: as the agent's bulk-qualify path \u2014 use leadbay_bulk_qualify_leads, which paginates past already-qualified leads, fan-outs, polls, and bails out cleanly on 429.",
|
|
767
|
+
optional: true,
|
|
768
|
+
inputSchema: {
|
|
769
|
+
type: "object",
|
|
770
|
+
properties: {
|
|
771
|
+
leadId: {
|
|
772
|
+
type: "string",
|
|
773
|
+
description: "Lead UUID (required)"
|
|
774
|
+
},
|
|
775
|
+
forceFetch: {
|
|
776
|
+
type: "boolean",
|
|
777
|
+
description: "Force re-fetch even if recent data exists (default: false)"
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
required: ["leadId"]
|
|
781
|
+
},
|
|
782
|
+
execute: async (client, params) => {
|
|
783
|
+
const force = params.forceFetch ?? false;
|
|
784
|
+
await client.requestVoid("POST", `/leads/${params.leadId}/web_fetch?force_fetch=${force}`);
|
|
785
|
+
return {
|
|
786
|
+
triggered: true,
|
|
787
|
+
hint: "AI qualification started. Use leadbay_get_lead_profile after ~60 seconds to check qualification results and web insights."
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
// ../core/dist/tools/enrich-contacts.js
|
|
793
|
+
var enrichContacts = {
|
|
794
|
+
name: "leadbay_enrich_contacts",
|
|
795
|
+
description: "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 \u2014 use leadbay_enrich_titles, which handles the selection lifecycle and returns a clean preview/launch flow.",
|
|
796
|
+
optional: true,
|
|
797
|
+
inputSchema: {
|
|
798
|
+
type: "object",
|
|
799
|
+
properties: {
|
|
800
|
+
leadId: {
|
|
801
|
+
type: "string",
|
|
802
|
+
description: "Lead UUID (required)"
|
|
803
|
+
},
|
|
804
|
+
contactId: {
|
|
805
|
+
type: "string",
|
|
806
|
+
description: "Contact UUID (required)"
|
|
807
|
+
},
|
|
808
|
+
email: {
|
|
809
|
+
type: "boolean",
|
|
810
|
+
description: "Enrich email address (default: true)"
|
|
811
|
+
},
|
|
812
|
+
phone: {
|
|
813
|
+
type: "boolean",
|
|
814
|
+
description: "Enrich phone number (default: true)"
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
required: ["leadId", "contactId"]
|
|
818
|
+
},
|
|
819
|
+
execute: async (client, params) => {
|
|
820
|
+
const email = params.email ?? true;
|
|
821
|
+
const phone = params.phone ?? true;
|
|
822
|
+
if (!email && !phone) {
|
|
823
|
+
throw client.makeError("INVALID_PARAMS", "At least one of email or phone must be true", "Set email=true or phone=true");
|
|
824
|
+
}
|
|
825
|
+
let creditsRemaining = null;
|
|
826
|
+
try {
|
|
827
|
+
const me = await client.request("GET", "/users/me");
|
|
828
|
+
creditsRemaining = me.organization.billing?.ai_credits ?? null;
|
|
829
|
+
if (creditsRemaining !== null && creditsRemaining <= 0) {
|
|
830
|
+
throw client.makeError("QUOTA_EXCEEDED", "No enrichment credits remaining", "Purchase more credits at app.leadbay.ai");
|
|
831
|
+
}
|
|
832
|
+
} catch (e) {
|
|
833
|
+
if (e?.code === "QUOTA_EXCEEDED")
|
|
834
|
+
throw e;
|
|
835
|
+
}
|
|
836
|
+
const enrichPath = `/leads/${params.leadId}/enrich/contacts/${params.contactId}/enrich?email=${email}&phone=${phone}`;
|
|
837
|
+
try {
|
|
838
|
+
await client.requestVoid("POST", enrichPath);
|
|
839
|
+
} catch (e) {
|
|
840
|
+
if (e?.code === "NOT_FOUND") {
|
|
841
|
+
const orgPath = `/leads/${params.leadId}/contacts/${params.contactId}/enrich?email=${email}&phone=${phone}`;
|
|
842
|
+
await client.requestVoid("POST", orgPath);
|
|
843
|
+
} else {
|
|
844
|
+
throw e;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return {
|
|
848
|
+
triggered: true,
|
|
849
|
+
contact_id: params.contactId,
|
|
850
|
+
email_requested: email,
|
|
851
|
+
phone_requested: phone,
|
|
852
|
+
credits_remaining: creditsRemaining,
|
|
853
|
+
hint: "Enrichment started. Use leadbay_get_contacts after ~60 seconds to check results."
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
// ../core/dist/tools/add-note.js
|
|
859
|
+
var addNote = {
|
|
860
|
+
name: "leadbay_add_note",
|
|
861
|
+
description: "Add a note to a lead. Notes are visible to the whole organization in Leadbay. When to use: low-level \u2014 for free-form notes not tied to outreach actions. When NOT to use: to log an outreach action \u2014 use leadbay_report_outreach, which requires verification (gmail/calendar/user_confirmed) to prevent hallucinated outreach poisoning the SDR pipeline.",
|
|
862
|
+
optional: true,
|
|
863
|
+
inputSchema: {
|
|
864
|
+
type: "object",
|
|
865
|
+
properties: {
|
|
866
|
+
leadId: {
|
|
867
|
+
type: "string",
|
|
868
|
+
description: "Lead UUID (required)"
|
|
869
|
+
},
|
|
870
|
+
note: {
|
|
871
|
+
type: "string",
|
|
872
|
+
description: "Note text (max 4095 characters)"
|
|
873
|
+
}
|
|
874
|
+
},
|
|
875
|
+
required: ["leadId", "note"]
|
|
876
|
+
},
|
|
877
|
+
execute: async (client, params) => {
|
|
878
|
+
if (!params.note || params.note.trim().length === 0) {
|
|
879
|
+
throw client.makeError("INVALID_PARAMS", "Note cannot be empty", "Provide a non-empty note");
|
|
880
|
+
}
|
|
881
|
+
const note = params.note.slice(0, 4095);
|
|
882
|
+
const result = await client.request("POST", `/leads/${params.leadId}/notes`, { note });
|
|
883
|
+
return {
|
|
884
|
+
id: result.id,
|
|
885
|
+
note: result.note,
|
|
886
|
+
created_at: result.created_at
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
// ../core/dist/tools/get-lead-activities.js
|
|
892
|
+
var getLeadActivities = {
|
|
893
|
+
name: "leadbay_get_lead_activities",
|
|
894
|
+
description: "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 \u2014 it includes recent prospecting actions in its engagement block.",
|
|
895
|
+
inputSchema: {
|
|
896
|
+
type: "object",
|
|
897
|
+
properties: {
|
|
898
|
+
leadId: {
|
|
899
|
+
type: "string",
|
|
900
|
+
description: "Lead UUID (required)"
|
|
901
|
+
},
|
|
902
|
+
count: {
|
|
903
|
+
type: "number",
|
|
904
|
+
description: "Number of activities to return, max 100 (default: 50)"
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
required: ["leadId"]
|
|
908
|
+
},
|
|
909
|
+
execute: async (client, params) => {
|
|
910
|
+
const count = Math.min(params.count ?? 50, 100);
|
|
911
|
+
const res = await client.request("GET", `/leads/${params.leadId}/activities?count=${count}`);
|
|
912
|
+
return {
|
|
913
|
+
activities: res.items.map((a) => ({
|
|
914
|
+
type: a.type,
|
|
915
|
+
date: a.date
|
|
916
|
+
})),
|
|
917
|
+
total: res.pagination.total
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
// ../core/dist/tools/get-lens-filter.js
|
|
923
|
+
var getLensFilter = {
|
|
924
|
+
name: "leadbay_get_lens_filter",
|
|
925
|
+
description: "Read the firmographic filter (sectors, sizes, locations) currently applied to a lens. When to use: before adjusting an audience \u2014 see what's already restricted so changes are diffs, not full replacements. When NOT to use: to actually apply changes \u2014 use the leadbay_adjust_audience composite, which handles permissions transparently.",
|
|
926
|
+
inputSchema: {
|
|
927
|
+
type: "object",
|
|
928
|
+
properties: {
|
|
929
|
+
lensId: { type: "number", description: "Lens id (required)" }
|
|
930
|
+
},
|
|
931
|
+
required: ["lensId"]
|
|
932
|
+
},
|
|
933
|
+
execute: async (client, params) => {
|
|
934
|
+
return await client.request("GET", `/lenses/${params.lensId}/filter`);
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
// ../core/dist/tools/get-lens-scoring.js
|
|
939
|
+
var getLensScoring = {
|
|
940
|
+
name: "leadbay_get_lens_scoring",
|
|
941
|
+
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 \u2014 that's an admin/setup operation, not part of the agent loop.",
|
|
942
|
+
inputSchema: {
|
|
943
|
+
type: "object",
|
|
944
|
+
properties: { lensId: { type: "number", description: "Lens id (required)" } },
|
|
945
|
+
required: ["lensId"]
|
|
946
|
+
},
|
|
947
|
+
execute: async (client, params) => {
|
|
948
|
+
return await client.request("GET", `/lenses/${params.lensId}/scoring`);
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
// ../core/dist/tools/list-sectors.js
|
|
953
|
+
var listSectors = {
|
|
954
|
+
name: "leadbay_list_sectors",
|
|
955
|
+
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 \u2014 pass them directly.",
|
|
956
|
+
inputSchema: {
|
|
957
|
+
type: "object",
|
|
958
|
+
properties: {
|
|
959
|
+
lang: { type: "string", description: "BCP-47 language tag (default: en)" },
|
|
960
|
+
includeInvisible: {
|
|
961
|
+
type: "boolean",
|
|
962
|
+
description: "Include sectors hidden from the UI (default false; ~91k items if true)"
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
},
|
|
966
|
+
execute: async (client, params) => {
|
|
967
|
+
let lang = params.lang;
|
|
968
|
+
if (!lang) {
|
|
969
|
+
try {
|
|
970
|
+
const me = await client.resolveMe();
|
|
971
|
+
lang = me.language ?? "en";
|
|
972
|
+
} catch {
|
|
973
|
+
lang = "en";
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const includeInvisible = params.includeInvisible ? "true" : "false";
|
|
977
|
+
const path = `/sectors/all?lang=${encodeURIComponent(lang)}&includeInvisible=${includeInvisible}`;
|
|
978
|
+
return await client.request("GET", path);
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
// ../core/dist/tools/get-user-prompt.js
|
|
983
|
+
var getUserPrompt = {
|
|
984
|
+
name: "leadbay_get_user_prompt",
|
|
985
|
+
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 \u2014 use leadbay_refine_prompt.",
|
|
986
|
+
inputSchema: { type: "object", properties: {} },
|
|
987
|
+
execute: async (client) => {
|
|
988
|
+
const orgId = await client.resolveOrgId();
|
|
989
|
+
const prompt = await client.request("GET", `/organizations/${orgId}/user_prompt`);
|
|
990
|
+
return prompt ?? { prompt: null, set: false };
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
// ../core/dist/tools/get-clarification.js
|
|
995
|
+
var getClarification = {
|
|
996
|
+
name: "leadbay_get_clarification",
|
|
997
|
+
description: "Check whether Leadbay has a pending clarification question \u2014 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 \u2014 use leadbay_answer_clarification.",
|
|
998
|
+
inputSchema: { type: "object", properties: {} },
|
|
999
|
+
execute: async (client) => {
|
|
1000
|
+
const orgId = await client.resolveOrgId();
|
|
1001
|
+
const c = await client.request("GET", `/organizations/${orgId}/clarifications`);
|
|
1002
|
+
return c ?? { pending: false, clarification: null };
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// ../core/dist/tools/get-lead-notes.js
|
|
1007
|
+
var getLeadNotes = {
|
|
1008
|
+
name: "leadbay_get_lead_notes",
|
|
1009
|
+
description: "Read existing notes on a lead \u2014 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 \u2014 there's nothing to fetch.",
|
|
1010
|
+
inputSchema: {
|
|
1011
|
+
type: "object",
|
|
1012
|
+
properties: { leadId: { type: "string", description: "Lead UUID (required)" } },
|
|
1013
|
+
required: ["leadId"]
|
|
1014
|
+
},
|
|
1015
|
+
execute: async (client, params) => {
|
|
1016
|
+
return await client.request("GET", `/leads/${params.leadId}/notes`);
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
// ../core/dist/tools/get-epilogue-responses.js
|
|
1021
|
+
var getEpilogueResponses = {
|
|
1022
|
+
name: "leadbay_get_epilogue_responses",
|
|
1023
|
+
description: "Read the lead's epilogue history \u2014 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.",
|
|
1024
|
+
inputSchema: {
|
|
1025
|
+
type: "object",
|
|
1026
|
+
properties: {
|
|
1027
|
+
leadId: { type: "string", description: "Lead UUID (required)" },
|
|
1028
|
+
count: { type: "number", description: "Items per page (1-200, default 20)" },
|
|
1029
|
+
page: { type: "number", description: "Page number, 0-indexed (default 0)" }
|
|
1030
|
+
},
|
|
1031
|
+
required: ["leadId"]
|
|
1032
|
+
},
|
|
1033
|
+
execute: async (client, params) => {
|
|
1034
|
+
const count = params.count ?? 20;
|
|
1035
|
+
const page = params.page ?? 0;
|
|
1036
|
+
return await client.request("GET", `/leads/${params.leadId}/epilogue_responses?count=${count}&page=${page}`);
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
// ../core/dist/tools/get-prospecting-actions.js
|
|
1041
|
+
var getProspectingActions = {
|
|
1042
|
+
name: "leadbay_get_prospecting_actions",
|
|
1043
|
+
description: "Read the CRM-style activity log for a lead (calls, emails, meetings \u2014 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.",
|
|
1044
|
+
inputSchema: {
|
|
1045
|
+
type: "object",
|
|
1046
|
+
properties: {
|
|
1047
|
+
leadId: { type: "string", description: "Lead UUID (required)" },
|
|
1048
|
+
count: { type: "number", description: "Items per page (1-200, default 20)" },
|
|
1049
|
+
page: { type: "number", description: "Page number, 0-indexed (default 0)" }
|
|
1050
|
+
},
|
|
1051
|
+
required: ["leadId"]
|
|
1052
|
+
},
|
|
1053
|
+
execute: async (client, params) => {
|
|
1054
|
+
const count = params.count ?? 20;
|
|
1055
|
+
const page = params.page ?? 0;
|
|
1056
|
+
return await client.request("GET", `/leads/${params.leadId}/prospecting_actions?count=${count}&page=${page}`);
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
// ../core/dist/tools/get-web-fetch.js
|
|
1061
|
+
var getWebFetch = {
|
|
1062
|
+
name: "leadbay_get_web_fetch",
|
|
1063
|
+
description: "Read the AI-generated web-research summary for a lead \u2014 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 \u2014 the leadbay_research_lead composite bundles this with qualification answers and reshapes the dict into a stable array form.",
|
|
1064
|
+
inputSchema: {
|
|
1065
|
+
type: "object",
|
|
1066
|
+
properties: { leadId: { type: "string", description: "Lead UUID (required)" } },
|
|
1067
|
+
required: ["leadId"]
|
|
1068
|
+
},
|
|
1069
|
+
execute: async (client, params) => {
|
|
1070
|
+
return await client.request("GET", `/leads/${params.leadId}/web_fetch`);
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
// ../core/dist/tools/get-selection-ids.js
|
|
1075
|
+
var getSelectionIds = {
|
|
1076
|
+
name: "leadbay_get_selection_ids",
|
|
1077
|
+
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 \u2014 leadbay_enrich_titles manages selection lifecycle automatically (select \u2192 action \u2192 clear).",
|
|
1078
|
+
inputSchema: { type: "object", properties: {} },
|
|
1079
|
+
execute: async (client) => {
|
|
1080
|
+
return await client.request("GET", "/leads/selection/ids");
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
// ../core/dist/tools/get-enrichment-job-titles.js
|
|
1085
|
+
var getEnrichmentJobTitles = {
|
|
1086
|
+
name: "leadbay_get_enrichment_job_titles",
|
|
1087
|
+
description: "List the actual job titles present across the leads currently in the user's selection \u2014 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 \u2014 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.",
|
|
1088
|
+
inputSchema: { type: "object", properties: {} },
|
|
1089
|
+
execute: async (client) => {
|
|
1090
|
+
return await client.request("GET", "/leads/selection/enrichment/job_titles");
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
// ../core/dist/tools/select-leads.js
|
|
1095
|
+
var selectLeads = {
|
|
1096
|
+
name: "leadbay_select_leads",
|
|
1097
|
+
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 \u2014 be careful when invoking directly. When NOT to use: in normal flow \u2014 leadbay_enrich_titles wraps select \u2192 action \u2192 clear in one call with proper Mutex protection. Calling this directly without acquiring the selection lock can clobber concurrent composite calls.",
|
|
1098
|
+
optional: true,
|
|
1099
|
+
write: true,
|
|
1100
|
+
inputSchema: {
|
|
1101
|
+
type: "object",
|
|
1102
|
+
properties: {
|
|
1103
|
+
leadIds: {
|
|
1104
|
+
type: "array",
|
|
1105
|
+
items: { type: "string" },
|
|
1106
|
+
description: "Lead UUIDs to add to selection (1-1000)",
|
|
1107
|
+
minItems: 1,
|
|
1108
|
+
maxItems: 1e3
|
|
1109
|
+
}
|
|
1110
|
+
},
|
|
1111
|
+
required: ["leadIds"]
|
|
1112
|
+
},
|
|
1113
|
+
execute: async (client, params) => {
|
|
1114
|
+
const qs = params.leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
|
|
1115
|
+
await client.requestVoid("POST", `/leads/selection/select?${qs}`);
|
|
1116
|
+
return { selected: params.leadIds.length };
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
// ../core/dist/tools/deselect-leads.js
|
|
1121
|
+
var deselectLeads = {
|
|
1122
|
+
name: "leadbay_deselect_leads",
|
|
1123
|
+
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 \u2014 leadbay_enrich_titles handles selection lifecycle.",
|
|
1124
|
+
optional: true,
|
|
1125
|
+
write: true,
|
|
1126
|
+
inputSchema: {
|
|
1127
|
+
type: "object",
|
|
1128
|
+
properties: {
|
|
1129
|
+
leadIds: {
|
|
1130
|
+
type: "array",
|
|
1131
|
+
items: { type: "string" },
|
|
1132
|
+
description: "Lead UUIDs to remove from selection",
|
|
1133
|
+
minItems: 1
|
|
1134
|
+
}
|
|
1135
|
+
},
|
|
1136
|
+
required: ["leadIds"]
|
|
1137
|
+
},
|
|
1138
|
+
execute: async (client, params) => {
|
|
1139
|
+
const qs = params.leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
|
|
1140
|
+
await client.requestVoid("POST", `/leads/selection/deselect?${qs}`);
|
|
1141
|
+
return { deselected: params.leadIds.length };
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
// ../core/dist/tools/clear-selection.js
|
|
1146
|
+
var clearSelection = {
|
|
1147
|
+
name: "leadbay_clear_selection",
|
|
1148
|
+
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 \u2014 composites clear in their own finally blocks.",
|
|
1149
|
+
optional: true,
|
|
1150
|
+
write: true,
|
|
1151
|
+
inputSchema: { type: "object", properties: {} },
|
|
1152
|
+
execute: async (client) => {
|
|
1153
|
+
await client.requestVoid("POST", "/leads/selection/clear");
|
|
1154
|
+
return { cleared: true };
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
// ../core/dist/tools/set-active-lens.js
|
|
1159
|
+
var setActiveLens = {
|
|
1160
|
+
name: "leadbay_set_active_lens",
|
|
1161
|
+
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 \u2014 leadbay_pull_leads and leadbay_adjust_audience auto-set the right lens.",
|
|
1162
|
+
optional: true,
|
|
1163
|
+
write: true,
|
|
1164
|
+
inputSchema: {
|
|
1165
|
+
type: "object",
|
|
1166
|
+
properties: { lensId: { type: "number", description: "Lens id (required)" } },
|
|
1167
|
+
required: ["lensId"]
|
|
1168
|
+
},
|
|
1169
|
+
execute: async (client, params) => {
|
|
1170
|
+
await client.requestVoid("POST", `/lenses/${params.lensId}/update_last_requested`);
|
|
1171
|
+
client.invalidateMe();
|
|
1172
|
+
client.invalidateDefaultLens();
|
|
1173
|
+
return { active_lens_id: params.lensId };
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
// ../core/dist/tools/create-lens.js
|
|
1178
|
+
var createLens = {
|
|
1179
|
+
name: "leadbay_create_lens",
|
|
1180
|
+
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 \u2014 use leadbay_update_lens or leadbay_update_lens_filter.",
|
|
1181
|
+
optional: true,
|
|
1182
|
+
write: true,
|
|
1183
|
+
inputSchema: {
|
|
1184
|
+
type: "object",
|
|
1185
|
+
properties: {
|
|
1186
|
+
base: { type: "number", description: "Base lens id to clone from" },
|
|
1187
|
+
name: { type: "string", description: "Display name for the new lens" },
|
|
1188
|
+
description: { type: "string" }
|
|
1189
|
+
},
|
|
1190
|
+
required: ["base", "name"]
|
|
1191
|
+
},
|
|
1192
|
+
execute: async (client, params) => {
|
|
1193
|
+
const lens = await client.request("POST", "/lenses", {
|
|
1194
|
+
base: params.base,
|
|
1195
|
+
name: params.name,
|
|
1196
|
+
description: params.description
|
|
1197
|
+
});
|
|
1198
|
+
client.invalidateDefaultLens();
|
|
1199
|
+
return lens;
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
// ../core/dist/tools/update-lens.js
|
|
1204
|
+
var updateLens = {
|
|
1205
|
+
name: "leadbay_update_lens",
|
|
1206
|
+
description: "Update lens metadata (name, description, mode flags). Does NOT change the audience filter \u2014 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 \u2014 that's a filter operation.",
|
|
1207
|
+
optional: true,
|
|
1208
|
+
write: true,
|
|
1209
|
+
inputSchema: {
|
|
1210
|
+
type: "object",
|
|
1211
|
+
properties: {
|
|
1212
|
+
lensId: { type: "number" },
|
|
1213
|
+
name: { type: "string" },
|
|
1214
|
+
description: { type: "string" },
|
|
1215
|
+
multi_product_mode: { type: "boolean" },
|
|
1216
|
+
use_hq_only: { type: "boolean" }
|
|
1217
|
+
},
|
|
1218
|
+
required: ["lensId"]
|
|
1219
|
+
},
|
|
1220
|
+
execute: async (client, params) => {
|
|
1221
|
+
const { lensId, ...body } = params;
|
|
1222
|
+
await client.requestVoid("POST", `/lenses/${lensId}`, body);
|
|
1223
|
+
client.invalidateDefaultLens();
|
|
1224
|
+
return { updated: true, lens_id: lensId };
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
// ../core/dist/tools/update-lens-filter.js
|
|
1229
|
+
var updateLensFilter = {
|
|
1230
|
+
name: "leadbay_update_lens_filter",
|
|
1231
|
+
description: "Replace the audience filter (sectors, sizes, locations) on a lens. Body is the full Filter object \u2014 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 \u2014 use leadbay_adjust_audience, which handles draft-vs-direct routing, permission fallback, and the merge logic so unrelated criteria aren't dropped.",
|
|
1232
|
+
optional: true,
|
|
1233
|
+
write: true,
|
|
1234
|
+
inputSchema: {
|
|
1235
|
+
type: "object",
|
|
1236
|
+
properties: {
|
|
1237
|
+
lensId: { type: "number", description: "Lens id" },
|
|
1238
|
+
filter: {
|
|
1239
|
+
type: "object",
|
|
1240
|
+
description: "Full FilterPayload (lens_filter + locations)"
|
|
1241
|
+
},
|
|
1242
|
+
dry_run: {
|
|
1243
|
+
type: "boolean",
|
|
1244
|
+
description: "If true, return the call shape that WOULD be sent without contacting the backend"
|
|
1245
|
+
}
|
|
1246
|
+
},
|
|
1247
|
+
required: ["lensId", "filter"]
|
|
1248
|
+
},
|
|
1249
|
+
execute: async (client, params) => {
|
|
1250
|
+
if (params.dry_run) {
|
|
1251
|
+
return {
|
|
1252
|
+
dry_run: true,
|
|
1253
|
+
would_call: {
|
|
1254
|
+
method: "POST",
|
|
1255
|
+
path: `/lenses/${params.lensId}/filter`,
|
|
1256
|
+
body: params.filter
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
await client.requestVoid("POST", `/lenses/${params.lensId}/filter`, params.filter);
|
|
1261
|
+
client.invalidateDefaultLens();
|
|
1262
|
+
return { updated: true, lens_id: params.lensId };
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
// ../core/dist/tools/create-lens-draft.js
|
|
1267
|
+
var createLensDraft = {
|
|
1268
|
+
name: "leadbay_create_lens_draft",
|
|
1269
|
+
description: "Create (or fetch existing) draft of an org-level lens. Idempotent \u2014 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 \u2014 make a draft, edit the draft. When NOT to use: from agent flow \u2014 leadbay_adjust_audience handles the draft-routing transparently.",
|
|
1270
|
+
optional: true,
|
|
1271
|
+
write: true,
|
|
1272
|
+
inputSchema: {
|
|
1273
|
+
type: "object",
|
|
1274
|
+
properties: { lensId: { type: "number", description: "Lens id of the org-level lens to draft" } },
|
|
1275
|
+
required: ["lensId"]
|
|
1276
|
+
},
|
|
1277
|
+
execute: async (client, params) => {
|
|
1278
|
+
return await client.request("POST", `/lenses/${params.lensId}/draft`);
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
// ../core/dist/tools/promote-lens.js
|
|
1283
|
+
var promoteLens = {
|
|
1284
|
+
name: "leadbay_promote_lens",
|
|
1285
|
+
description: "Promote a user-level lens (or draft) to org-level so all teammates see it. Admin-only. When to use: rare \u2014 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).",
|
|
1286
|
+
optional: true,
|
|
1287
|
+
write: true,
|
|
1288
|
+
inputSchema: {
|
|
1289
|
+
type: "object",
|
|
1290
|
+
properties: { lensId: { type: "number" } },
|
|
1291
|
+
required: ["lensId"]
|
|
1292
|
+
},
|
|
1293
|
+
execute: async (client, params) => {
|
|
1294
|
+
await client.requestVoid("POST", `/lenses/${params.lensId}/promote`);
|
|
1295
|
+
client.invalidateDefaultLens();
|
|
1296
|
+
return { promoted: true, lens_id: params.lensId };
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
|
|
1300
|
+
// ../core/dist/tools/set-user-prompt.js
|
|
1301
|
+
var setUserPrompt = {
|
|
1302
|
+
name: "leadbay_set_user_prompt",
|
|
1303
|
+
description: "Set the org's intelligence-refinement prompt \u2014 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 \u2014 use leadbay_refine_prompt, which polls for follow-up clarifications.",
|
|
1304
|
+
optional: true,
|
|
1305
|
+
write: true,
|
|
1306
|
+
inputSchema: {
|
|
1307
|
+
type: "object",
|
|
1308
|
+
properties: {
|
|
1309
|
+
prompt: { type: "string", description: "Refinement instruction (free text)" },
|
|
1310
|
+
dry_run: {
|
|
1311
|
+
type: "boolean",
|
|
1312
|
+
description: "If true, return the call shape that WOULD be sent without contacting the backend"
|
|
1313
|
+
}
|
|
1314
|
+
},
|
|
1315
|
+
required: ["prompt"]
|
|
1316
|
+
},
|
|
1317
|
+
execute: async (client, params) => {
|
|
1318
|
+
const orgId = await client.resolveOrgId();
|
|
1319
|
+
if (params.dry_run) {
|
|
1320
|
+
return {
|
|
1321
|
+
dry_run: true,
|
|
1322
|
+
would_call: {
|
|
1323
|
+
method: "POST",
|
|
1324
|
+
path: `/organizations/${orgId}/user_prompt`,
|
|
1325
|
+
body: { prompt: params.prompt }
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
await client.requestVoid("POST", `/organizations/${orgId}/user_prompt`, {
|
|
1330
|
+
prompt: params.prompt
|
|
1331
|
+
});
|
|
1332
|
+
client.invalidateMe();
|
|
1333
|
+
return { set: true };
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
|
|
1337
|
+
// ../core/dist/tools/clear-user-prompt.js
|
|
1338
|
+
var clearUserPrompt = {
|
|
1339
|
+
name: "leadbay_clear_user_prompt",
|
|
1340
|
+
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 \u2014 just call leadbay_refine_prompt; that overwrites.",
|
|
1341
|
+
optional: true,
|
|
1342
|
+
write: true,
|
|
1343
|
+
inputSchema: { type: "object", properties: {} },
|
|
1344
|
+
execute: async (client) => {
|
|
1345
|
+
const orgId = await client.resolveOrgId();
|
|
1346
|
+
await client.requestVoid("DELETE", `/organizations/${orgId}/user_prompt`);
|
|
1347
|
+
client.invalidateMe();
|
|
1348
|
+
return { cleared: true };
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
// ../core/dist/tools/pick-clarification.js
|
|
1353
|
+
var pickClarification = {
|
|
1354
|
+
name: "leadbay_pick_clarification",
|
|
1355
|
+
description: "Answer the pending clarification question \u2014 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 \u2014 use leadbay_answer_clarification.",
|
|
1356
|
+
optional: true,
|
|
1357
|
+
write: true,
|
|
1358
|
+
inputSchema: {
|
|
1359
|
+
type: "object",
|
|
1360
|
+
properties: {
|
|
1361
|
+
option_id: { type: "string", description: "Id of one of the clarification's options" },
|
|
1362
|
+
text_answer: { type: "string", description: "Free-text answer (overrides option_id if both are set)" }
|
|
1363
|
+
}
|
|
1364
|
+
},
|
|
1365
|
+
execute: async (client, params) => {
|
|
1366
|
+
if (!params.option_id && !params.text_answer) {
|
|
1367
|
+
return {
|
|
1368
|
+
error: true,
|
|
1369
|
+
code: "BAD_INPUT",
|
|
1370
|
+
message: "Provide either option_id or text_answer",
|
|
1371
|
+
hint: "Call leadbay_get_clarification to see the options first"
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
const orgId = await client.resolveOrgId();
|
|
1375
|
+
await client.requestVoid("POST", `/organizations/${orgId}/pick_clarification`, params);
|
|
1376
|
+
client.invalidateMe();
|
|
1377
|
+
return { answered: true };
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
// ../core/dist/tools/dismiss-clarification.js
|
|
1382
|
+
var dismissClarification = {
|
|
1383
|
+
name: "leadbay_dismiss_clarification",
|
|
1384
|
+
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 \u2014 answering with even a free-text reason gives Leadbay better signal.",
|
|
1385
|
+
optional: true,
|
|
1386
|
+
write: true,
|
|
1387
|
+
inputSchema: { type: "object", properties: {} },
|
|
1388
|
+
execute: async (client) => {
|
|
1389
|
+
const orgId = await client.resolveOrgId();
|
|
1390
|
+
await client.requestVoid("POST", `/organizations/${orgId}/dismiss_clarification`);
|
|
1391
|
+
client.invalidateMe();
|
|
1392
|
+
return { dismissed: true };
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
// ../core/dist/tools/set-epilogue-status.js
|
|
1397
|
+
var EPILOGUE_LABEL_MAP = {
|
|
1398
|
+
STILL_CHASING: "EPILOGUE_STILL_CHASING",
|
|
1399
|
+
COULD_NOT_REACH_STILL_TRYING: "EPILOGUE_COULD_NOT_REACH_STILL_TRYING",
|
|
1400
|
+
INTEREST_VALIDATED_OR_MEETING_PLANED: "EPILOGUE_INTEREST_VALIDATED_OR_MEETING_PLANED",
|
|
1401
|
+
NOT_INTERESTED_LOST: "EPILOGUE_NOT_INTERESTED_LOST",
|
|
1402
|
+
// Also accept the long forms verbatim
|
|
1403
|
+
EPILOGUE_STILL_CHASING: "EPILOGUE_STILL_CHASING",
|
|
1404
|
+
EPILOGUE_COULD_NOT_REACH_STILL_TRYING: "EPILOGUE_COULD_NOT_REACH_STILL_TRYING",
|
|
1405
|
+
EPILOGUE_INTEREST_VALIDATED_OR_MEETING_PLANED: "EPILOGUE_INTEREST_VALIDATED_OR_MEETING_PLANED",
|
|
1406
|
+
EPILOGUE_NOT_INTERESTED_LOST: "EPILOGUE_NOT_INTERESTED_LOST"
|
|
1407
|
+
};
|
|
1408
|
+
var setEpilogueStatus = {
|
|
1409
|
+
name: "leadbay_set_epilogue_status",
|
|
1410
|
+
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 \u2014 leadbay_report_outreach pairs this with a note + verification, which is what humans actually need to see in Leadbay.",
|
|
1411
|
+
optional: true,
|
|
1412
|
+
write: true,
|
|
1413
|
+
inputSchema: {
|
|
1414
|
+
type: "object",
|
|
1415
|
+
properties: {
|
|
1416
|
+
lead_ids: {
|
|
1417
|
+
type: "array",
|
|
1418
|
+
items: { type: "string" },
|
|
1419
|
+
description: "Lead UUIDs (1-1000)"
|
|
1420
|
+
},
|
|
1421
|
+
status: {
|
|
1422
|
+
type: "string",
|
|
1423
|
+
description: "One of: STILL_CHASING, COULD_NOT_REACH_STILL_TRYING, INTEREST_VALIDATED_OR_MEETING_PLANED, NOT_INTERESTED_LOST"
|
|
1424
|
+
}
|
|
1425
|
+
},
|
|
1426
|
+
required: ["lead_ids", "status"]
|
|
1427
|
+
},
|
|
1428
|
+
execute: async (client, params) => {
|
|
1429
|
+
const wire = EPILOGUE_LABEL_MAP[params.status];
|
|
1430
|
+
if (!wire) {
|
|
1431
|
+
return {
|
|
1432
|
+
error: true,
|
|
1433
|
+
code: "BAD_INPUT",
|
|
1434
|
+
message: `Unknown epilogue status: ${params.status}`,
|
|
1435
|
+
hint: `Use one of: ${Object.keys(EPILOGUE_LABEL_MAP).filter((k) => !k.startsWith("EPILOGUE_")).join(", ")}`
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
await client.requestVoid("POST", "/leads/epilogue", {
|
|
1439
|
+
lead_ids: params.lead_ids,
|
|
1440
|
+
status: wire
|
|
1441
|
+
});
|
|
1442
|
+
return { applied: true, count: params.lead_ids.length, status: wire };
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1446
|
+
// ../core/dist/tools/remove-epilogue.js
|
|
1447
|
+
var removeEpilogue = {
|
|
1448
|
+
name: "leadbay_remove_epilogue",
|
|
1449
|
+
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 \u2014 call leadbay_set_epilogue_status with the new status (it overwrites).",
|
|
1450
|
+
optional: true,
|
|
1451
|
+
write: true,
|
|
1452
|
+
inputSchema: {
|
|
1453
|
+
type: "object",
|
|
1454
|
+
properties: {
|
|
1455
|
+
lead_ids: {
|
|
1456
|
+
type: "array",
|
|
1457
|
+
items: { type: "string" },
|
|
1458
|
+
description: "Lead UUIDs"
|
|
1459
|
+
}
|
|
1460
|
+
},
|
|
1461
|
+
required: ["lead_ids"]
|
|
1462
|
+
},
|
|
1463
|
+
execute: async (client, params) => {
|
|
1464
|
+
await client.requestVoid("POST", "/leads/remove_epilogue", {
|
|
1465
|
+
lead_ids: params.lead_ids
|
|
1466
|
+
});
|
|
1467
|
+
return { cleared: true, count: params.lead_ids.length };
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
|
|
1471
|
+
// ../core/dist/tools/preview-bulk-enrichment.js
|
|
1472
|
+
var previewBulkEnrichment = {
|
|
1473
|
+
name: "leadbay_preview_bulk_enrichment",
|
|
1474
|
+
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) \u2014 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 \u2014 leadbay_enrich_titles wraps preview + launch with the right safety checks.",
|
|
1475
|
+
optional: true,
|
|
1476
|
+
write: true,
|
|
1477
|
+
inputSchema: {
|
|
1478
|
+
type: "object",
|
|
1479
|
+
properties: {
|
|
1480
|
+
titles: {
|
|
1481
|
+
type: "array",
|
|
1482
|
+
items: { type: "string" },
|
|
1483
|
+
description: "Job titles to enrich (matched against contacts in selected leads)"
|
|
1484
|
+
}
|
|
1485
|
+
},
|
|
1486
|
+
required: ["titles"]
|
|
1487
|
+
},
|
|
1488
|
+
execute: async (client, params) => {
|
|
1489
|
+
return await client.request("POST", "/leads/selection/enrichment/preview", { titles: params.titles });
|
|
1490
|
+
}
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
// ../core/dist/tools/launch-bulk-enrichment.js
|
|
1494
|
+
var launchBulkEnrichment = {
|
|
1495
|
+
name: "leadbay_launch_bulk_enrichment",
|
|
1496
|
+
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 \u2014 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 \u2014 leadbay_enrich_titles handles selection lifecycle, preview, launch, and cleanup.",
|
|
1497
|
+
optional: true,
|
|
1498
|
+
write: true,
|
|
1499
|
+
inputSchema: {
|
|
1500
|
+
type: "object",
|
|
1501
|
+
properties: {
|
|
1502
|
+
titles: { type: "array", items: { type: "string" } },
|
|
1503
|
+
email: { type: "boolean", description: "Enrich emails (default true)" },
|
|
1504
|
+
phone: { type: "boolean", description: "Enrich phone numbers (default false)" },
|
|
1505
|
+
dry_run: {
|
|
1506
|
+
type: "boolean",
|
|
1507
|
+
description: "If true, return the call shape WITHOUT contacting the backend"
|
|
1508
|
+
}
|
|
1509
|
+
},
|
|
1510
|
+
required: ["titles"]
|
|
1511
|
+
},
|
|
1512
|
+
execute: async (client, params) => {
|
|
1513
|
+
const email = params.email ?? true;
|
|
1514
|
+
const phone = params.phone ?? false;
|
|
1515
|
+
if (!email && !phone) {
|
|
1516
|
+
return {
|
|
1517
|
+
error: true,
|
|
1518
|
+
code: "BAD_INPUT",
|
|
1519
|
+
message: "Either email or phone must be true",
|
|
1520
|
+
hint: "Set email:true to enrich emails (most common), or phone:true for phone numbers"
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
if (params.dry_run) {
|
|
1524
|
+
return {
|
|
1525
|
+
dry_run: true,
|
|
1526
|
+
would_call: {
|
|
1527
|
+
method: "POST",
|
|
1528
|
+
path: "/leads/selection/enrichment/launch",
|
|
1529
|
+
body: { titles: params.titles, email, phone }
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
await client.requestVoid("POST", "/leads/selection/enrichment/launch", {
|
|
1534
|
+
titles: params.titles,
|
|
1535
|
+
email,
|
|
1536
|
+
phone
|
|
1537
|
+
});
|
|
1538
|
+
return {
|
|
1539
|
+
launched: true,
|
|
1540
|
+
titles: params.titles,
|
|
1541
|
+
email,
|
|
1542
|
+
phone,
|
|
1543
|
+
hint: "Enrichment job launched. Poll individual leads' contacts after ~60s via leadbay_get_contacts(leadId) \u2014 contact.enrichment.done flips to true when done."
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
// ../core/dist/composite/research-company.js
|
|
1549
|
+
var researchCompany = {
|
|
1550
|
+
name: "leadbay_research_company",
|
|
1551
|
+
description: "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 \u2014 use leadbay_research_lead directly (it bundles richer signals + better top-down ordering for the agent).",
|
|
1552
|
+
inputSchema: {
|
|
1553
|
+
type: "object",
|
|
1554
|
+
properties: {
|
|
1555
|
+
companyName: {
|
|
1556
|
+
type: "string",
|
|
1557
|
+
description: "Company name to research (one of companyName or leadId required). Matches the top-scoring lead with this name."
|
|
1558
|
+
},
|
|
1559
|
+
leadId: {
|
|
1560
|
+
type: "string",
|
|
1561
|
+
description: "Lead UUID if already known (one of companyName or leadId required). Takes precedence over companyName."
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
},
|
|
1565
|
+
execute: async (client, params, ctx) => {
|
|
1566
|
+
if (!params.leadId && !params.companyName) {
|
|
1567
|
+
throw client.makeError("INVALID_PARAMS", "Pass either leadId or companyName", "Call leadbay_pull_leads first to surface candidate leads with their IDs, then call this with leadId.");
|
|
1568
|
+
}
|
|
1569
|
+
let leadId = params.leadId;
|
|
1570
|
+
if (!leadId && params.companyName) {
|
|
1571
|
+
const results = await discoverLeads.execute(client, { count: 50, page: 0 }, ctx);
|
|
1572
|
+
const needle = params.companyName.toLowerCase();
|
|
1573
|
+
const match = results.leads.find((l) => l.name.toLowerCase().includes(needle));
|
|
1574
|
+
if (!match) {
|
|
1575
|
+
throw client.makeError("LEAD_NOT_FOUND", `No lead matching "${params.companyName}" in the current lens`, "Call leadbay_pull_leads (optionally with a broader lensId) to see what's available, then call this with leadId.");
|
|
1576
|
+
}
|
|
1577
|
+
leadId = match.id;
|
|
1578
|
+
}
|
|
1579
|
+
const [profile, activities] = await Promise.allSettled([
|
|
1580
|
+
getLeadProfile.execute(client, { leadId }, ctx),
|
|
1581
|
+
getLeadActivities.execute(client, { leadId, count: 20 }, ctx)
|
|
1582
|
+
]);
|
|
1583
|
+
if (profile.status === "rejected") {
|
|
1584
|
+
throw profile.reason;
|
|
1585
|
+
}
|
|
1586
|
+
return {
|
|
1587
|
+
...profile.value,
|
|
1588
|
+
recent_activities: activities.status === "fulfilled" ? activities.value.activities : []
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
// ../core/dist/composite/prepare-outreach.js
|
|
1594
|
+
var prepareOutreach = {
|
|
1595
|
+
name: "leadbay_prepare_outreach",
|
|
1596
|
+
description: "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 \u2014 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).",
|
|
1597
|
+
optional: true,
|
|
1598
|
+
inputSchema: {
|
|
1599
|
+
type: "object",
|
|
1600
|
+
properties: {
|
|
1601
|
+
leadId: {
|
|
1602
|
+
type: "string",
|
|
1603
|
+
description: "Lead UUID (required)"
|
|
1604
|
+
},
|
|
1605
|
+
enrich: {
|
|
1606
|
+
type: "boolean",
|
|
1607
|
+
description: "If true and credits available, trigger enrichment on the recommended contact (default: false). Enrichment is async \u2014 poll leadbay_get_contacts after ~60s."
|
|
1608
|
+
}
|
|
1609
|
+
},
|
|
1610
|
+
required: ["leadId"]
|
|
1611
|
+
},
|
|
1612
|
+
execute: async (client, params, ctx) => {
|
|
1613
|
+
const contactsResult = await getContacts.execute(client, { leadId: params.leadId }, ctx);
|
|
1614
|
+
const contacts = contactsResult.contacts;
|
|
1615
|
+
const recommended = contacts.find((c) => c.recommended) ?? contacts[0];
|
|
1616
|
+
let enrichmentTriggered = false;
|
|
1617
|
+
let enrichmentError = null;
|
|
1618
|
+
if (params.enrich && recommended) {
|
|
1619
|
+
try {
|
|
1620
|
+
await enrichContacts.execute(client, { leadId: params.leadId, contactId: recommended.id }, ctx);
|
|
1621
|
+
enrichmentTriggered = true;
|
|
1622
|
+
} catch (e) {
|
|
1623
|
+
enrichmentError = e?.message ?? String(e);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
let leadSummary = null;
|
|
1627
|
+
try {
|
|
1628
|
+
const profile = await getLeadProfile.execute(client, { leadId: params.leadId }, ctx);
|
|
1629
|
+
leadSummary = {
|
|
1630
|
+
name: profile.lead.name,
|
|
1631
|
+
ai_summary: profile.lead.ai_summary,
|
|
1632
|
+
website: profile.lead.website
|
|
1633
|
+
};
|
|
1634
|
+
} catch {
|
|
1635
|
+
}
|
|
1636
|
+
return {
|
|
1637
|
+
lead: leadSummary,
|
|
1638
|
+
recommended_contact: recommended ? {
|
|
1639
|
+
id: recommended.id,
|
|
1640
|
+
name: [recommended.first_name, recommended.last_name].filter(Boolean).join(" "),
|
|
1641
|
+
job_title: recommended.job_title,
|
|
1642
|
+
email: recommended.email,
|
|
1643
|
+
phone_number: recommended.phone_number,
|
|
1644
|
+
linkedin_page: recommended.linkedin_page
|
|
1645
|
+
} : null,
|
|
1646
|
+
other_contacts_count: Math.max(0, contacts.length - 1),
|
|
1647
|
+
enrichment: {
|
|
1648
|
+
triggered: enrichmentTriggered,
|
|
1649
|
+
error: enrichmentError,
|
|
1650
|
+
hint: enrichmentTriggered ? "Enrichment started. Poll leadbay_get_contacts with the same leadId in ~60 seconds." : null
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
// ../core/dist/composite/pull-leads.js
|
|
1657
|
+
function summarise(responses) {
|
|
1658
|
+
const answered = responses.filter((r) => r.score != null).length;
|
|
1659
|
+
const total = responses.length;
|
|
1660
|
+
const scores = responses.map((r) => r.score).filter((s) => s != null);
|
|
1661
|
+
const avg = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 10) / 10 : null;
|
|
1662
|
+
const best = [...responses].filter((r) => r.response && r.score != null).sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0];
|
|
1663
|
+
let excerpt = best?.response ?? null;
|
|
1664
|
+
if (excerpt && excerpt.length > 200) {
|
|
1665
|
+
excerpt = excerpt.slice(0, 197) + "...";
|
|
1666
|
+
}
|
|
1667
|
+
return { answered, total, avg_score_0_to_10: avg, best_response_excerpt: excerpt };
|
|
1668
|
+
}
|
|
1669
|
+
var pullLeads = {
|
|
1670
|
+
name: "leadbay_pull_leads",
|
|
1671
|
+
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 \u2014 pass lensId to override the auto-resolution. Replaces the older leadbay_find_prospects (which is removed in v0.2.0).",
|
|
1672
|
+
inputSchema: {
|
|
1673
|
+
type: "object",
|
|
1674
|
+
properties: {
|
|
1675
|
+
lensId: {
|
|
1676
|
+
type: "number",
|
|
1677
|
+
description: "Override the auto-resolved last-active lens (escape hatch \u2014 normally omit)"
|
|
1678
|
+
},
|
|
1679
|
+
count: { type: "number", description: "Leads per page, max 50 (default 20)" },
|
|
1680
|
+
page: { type: "number", description: "Page number, 0-indexed (default 0)" },
|
|
1681
|
+
verbose: {
|
|
1682
|
+
type: "boolean",
|
|
1683
|
+
description: "If true, include the full set of lead-summary fields. Default false: returns the trimmed agent-friendly form."
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
},
|
|
1687
|
+
execute: async (client, params, ctx) => {
|
|
1688
|
+
const lensId = params.lensId ?? await client.resolveDefaultLens();
|
|
1689
|
+
const page = params.page ?? 0;
|
|
1690
|
+
const count = Math.min(params.count ?? 20, 50);
|
|
1691
|
+
const verbose = params.verbose ?? false;
|
|
1692
|
+
const res = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${count}&page=${page}&contacts=true`);
|
|
1693
|
+
const summaries = await Promise.all(res.items.map(async (lead) => {
|
|
1694
|
+
try {
|
|
1695
|
+
const r = await client.request("GET", `/leads/${lead.id}/ai_agent_responses`);
|
|
1696
|
+
return { leadId: lead.id, summary: summarise(r) };
|
|
1697
|
+
} catch (err) {
|
|
1698
|
+
ctx?.logger?.warn?.(`pull_leads: ai_agent_responses failed for lead ${lead.id}: ${err?.message ?? err?.code ?? err}`);
|
|
1699
|
+
return {
|
|
1700
|
+
leadId: lead.id,
|
|
1701
|
+
summary: {
|
|
1702
|
+
answered: 0,
|
|
1703
|
+
total: 0,
|
|
1704
|
+
avg_score_0_to_10: null,
|
|
1705
|
+
best_response_excerpt: null
|
|
1706
|
+
}
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
}));
|
|
1710
|
+
const summaryMap = new Map(summaries.map((s) => [s.leadId, s.summary]));
|
|
1711
|
+
const trimmed = (lead) => verbose ? lead : {
|
|
1712
|
+
id: lead.id,
|
|
1713
|
+
name: lead.name,
|
|
1714
|
+
score: lead.score,
|
|
1715
|
+
ai_agent_lead_score: lead.ai_agent_lead_score,
|
|
1716
|
+
location: lead.location,
|
|
1717
|
+
short_description: lead.short_description ?? lead.description,
|
|
1718
|
+
size: lead.size,
|
|
1719
|
+
website: lead.website,
|
|
1720
|
+
tags: lead.tags,
|
|
1721
|
+
recommended_contact_title: lead.recommended_contact_title ?? null,
|
|
1722
|
+
recommended_contact: lead.recommended_contact ?? null,
|
|
1723
|
+
web_fetch_in_progress: lead.web_fetch_in_progress ?? false,
|
|
1724
|
+
enrichment_in_progress: lead.enrichment_in_progress ?? false,
|
|
1725
|
+
liked: lead.liked,
|
|
1726
|
+
disliked: lead.disliked,
|
|
1727
|
+
new: lead.new ?? false,
|
|
1728
|
+
contacts_count: lead.contacts_count,
|
|
1729
|
+
org_contacts_count: lead.org_contacts_count,
|
|
1730
|
+
notes_count: lead.notes_count ?? 0,
|
|
1731
|
+
epilogue_actions_count: lead.epilogue_actions_count ?? 0,
|
|
1732
|
+
prospecting_actions_count: lead.prospecting_actions_count ?? 0
|
|
1733
|
+
};
|
|
1734
|
+
return {
|
|
1735
|
+
lens: { id: lensId },
|
|
1736
|
+
leads: res.items.map((lead) => ({
|
|
1737
|
+
...trimmed(lead),
|
|
1738
|
+
qualification_summary: summaryMap.get(lead.id) ?? null
|
|
1739
|
+
})),
|
|
1740
|
+
pagination: res.pagination,
|
|
1741
|
+
computing_wishlist: res.computing_wishlist,
|
|
1742
|
+
computing_scores: res.computing_scores,
|
|
1743
|
+
_meta: {
|
|
1744
|
+
region: client.region,
|
|
1745
|
+
latency_ms: client.lastMeta?.latency_ms ?? null
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
};
|
|
1750
|
+
|
|
1751
|
+
// ../core/dist/composite/research-lead.js
|
|
1752
|
+
var SECTION_PRIORITY = ["profile", "signals", "clues"];
|
|
1753
|
+
function splitEmojiSection(key) {
|
|
1754
|
+
const m = key.match(/^([^\p{L}\p{N}\s]+)\s+(.+)$/u);
|
|
1755
|
+
if (m)
|
|
1756
|
+
return { emoji: m[1], label: m[2] };
|
|
1757
|
+
return { emoji: null, label: key };
|
|
1758
|
+
}
|
|
1759
|
+
function reshapeWebFetchContent(content) {
|
|
1760
|
+
if (!content)
|
|
1761
|
+
return [];
|
|
1762
|
+
const sections = [];
|
|
1763
|
+
for (const [key, val] of Object.entries(content)) {
|
|
1764
|
+
if (!Array.isArray(val))
|
|
1765
|
+
continue;
|
|
1766
|
+
const { emoji, label } = splitEmojiSection(key);
|
|
1767
|
+
sections.push({
|
|
1768
|
+
section_label: label,
|
|
1769
|
+
section_emoji: emoji,
|
|
1770
|
+
entries: val
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
sections.sort((a, b) => {
|
|
1774
|
+
const ai = SECTION_PRIORITY.findIndex((p) => a.section_label.toLowerCase().includes(p));
|
|
1775
|
+
const bi = SECTION_PRIORITY.findIndex((p) => b.section_label.toLowerCase().includes(p));
|
|
1776
|
+
const aN = ai < 0 ? SECTION_PRIORITY.length : ai;
|
|
1777
|
+
const bN = bi < 0 ? SECTION_PRIORITY.length : bi;
|
|
1778
|
+
if (aN !== bN)
|
|
1779
|
+
return aN - bN;
|
|
1780
|
+
return a.section_label.localeCompare(b.section_label);
|
|
1781
|
+
});
|
|
1782
|
+
return sections;
|
|
1783
|
+
}
|
|
1784
|
+
var researchLead = {
|
|
1785
|
+
name: "leadbay_research_lead",
|
|
1786
|
+
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 \u2014 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.)",
|
|
1787
|
+
inputSchema: {
|
|
1788
|
+
type: "object",
|
|
1789
|
+
properties: {
|
|
1790
|
+
leadId: { type: "string", description: "Lead UUID (required)" },
|
|
1791
|
+
lensId: {
|
|
1792
|
+
type: "number",
|
|
1793
|
+
description: "Lens id (escape hatch \u2014 normally omit; auto-resolves to the active lens)"
|
|
1794
|
+
},
|
|
1795
|
+
concise: {
|
|
1796
|
+
type: "boolean",
|
|
1797
|
+
description: "If true, trim signals to hot=true items only (smaller payload). Default false."
|
|
1798
|
+
}
|
|
1799
|
+
},
|
|
1800
|
+
required: ["leadId"]
|
|
1801
|
+
},
|
|
1802
|
+
execute: async (client, params, ctx) => {
|
|
1803
|
+
const lensId = params.lensId ?? await client.resolveDefaultLens();
|
|
1804
|
+
const leadId = params.leadId;
|
|
1805
|
+
const [profileR, qualR, contactsR, webFetchR] = await Promise.allSettled([
|
|
1806
|
+
client.request("GET", `/lenses/${lensId}/leads/${leadId}`),
|
|
1807
|
+
client.request("GET", `/leads/${leadId}/ai_agent_responses`),
|
|
1808
|
+
client.request("GET", `/leads/${leadId}/enrich/contacts?IncludeEnriched=true`),
|
|
1809
|
+
client.request("GET", `/leads/${leadId}/web_fetch`)
|
|
1810
|
+
]);
|
|
1811
|
+
if (profileR.status === "rejected") {
|
|
1812
|
+
throw profileR.reason;
|
|
1813
|
+
}
|
|
1814
|
+
const lead = profileR.value;
|
|
1815
|
+
const wantNotes = (lead.notes_count ?? 0) > 0;
|
|
1816
|
+
const wantEpilogue = (lead.epilogue_actions_count ?? 0) > 0;
|
|
1817
|
+
const wantProspecting = (lead.prospecting_actions_count ?? 0) > 0;
|
|
1818
|
+
const wantOrgContacts = (lead.org_contacts_count ?? 0) > 0;
|
|
1819
|
+
const engagementFetches = await Promise.allSettled([
|
|
1820
|
+
wantNotes ? client.request("GET", `/leads/${leadId}/notes`) : Promise.resolve(null),
|
|
1821
|
+
wantEpilogue ? client.request("GET", `/leads/${leadId}/epilogue_responses?count=10&page=0`) : Promise.resolve(null),
|
|
1822
|
+
wantProspecting ? client.request("GET", `/leads/${leadId}/prospecting_actions?count=10&page=0`) : Promise.resolve(null),
|
|
1823
|
+
wantOrgContacts ? client.request("GET", `/leads/${leadId}/contacts?IncludeEnriched=true`) : Promise.resolve(null)
|
|
1824
|
+
]);
|
|
1825
|
+
const [notesR, epilogueR, prospR, orgContactsR] = engagementFetches;
|
|
1826
|
+
const valOrNull = (r) => r.status === "fulfilled" ? r.value ?? null : null;
|
|
1827
|
+
let signals = reshapeWebFetchContent(webFetchR.status === "fulfilled" ? webFetchR.value?.content ?? null : null);
|
|
1828
|
+
if (params.concise) {
|
|
1829
|
+
signals = signals.map((s) => ({
|
|
1830
|
+
...s,
|
|
1831
|
+
entries: s.entries.filter((e) => e.hot === true)
|
|
1832
|
+
})).filter((s) => s.entries.length > 0);
|
|
1833
|
+
}
|
|
1834
|
+
const paidContacts = contactsR.status === "fulfilled" ? contactsR.value : [];
|
|
1835
|
+
const orgContacts = valOrNull(orgContactsR) ?? [];
|
|
1836
|
+
return {
|
|
1837
|
+
// 1) qualification — single most important block for "is this lead worth pursuing"
|
|
1838
|
+
qualification: qualR.status === "fulfilled" ? qualR.value.map((r) => ({
|
|
1839
|
+
question: r.question,
|
|
1840
|
+
score_0_to_10: r.score,
|
|
1841
|
+
response: r.response,
|
|
1842
|
+
computed_at: r.computed_at
|
|
1843
|
+
})) : [],
|
|
1844
|
+
// 2) signals — knowledge-base food
|
|
1845
|
+
signals,
|
|
1846
|
+
// 3) firmographics
|
|
1847
|
+
firmographics: {
|
|
1848
|
+
id: lead.id,
|
|
1849
|
+
name: lead.name,
|
|
1850
|
+
sector_id: lead.sector_id ?? null,
|
|
1851
|
+
size: lead.size,
|
|
1852
|
+
location: lead.location,
|
|
1853
|
+
website: lead.website,
|
|
1854
|
+
description: lead.description,
|
|
1855
|
+
short_description: lead.short_description ?? null,
|
|
1856
|
+
keywords: lead.keywords ?? [],
|
|
1857
|
+
tags: lead.tags,
|
|
1858
|
+
score: lead.score,
|
|
1859
|
+
ai_agent_lead_score: lead.ai_agent_lead_score,
|
|
1860
|
+
social_presence: lead.social_presence ?? null,
|
|
1861
|
+
social_urls: lead.social_urls ?? null,
|
|
1862
|
+
registry_ids: lead.registry_ids ?? null
|
|
1863
|
+
},
|
|
1864
|
+
// 4) contacts (paid/enriched, plus org contacts if present)
|
|
1865
|
+
contacts: {
|
|
1866
|
+
enriched: paidContacts.map((c) => ({
|
|
1867
|
+
id: c.id,
|
|
1868
|
+
first_name: c.first_name,
|
|
1869
|
+
last_name: c.last_name,
|
|
1870
|
+
job_title: c.job_title,
|
|
1871
|
+
email: c.email,
|
|
1872
|
+
phone_number: c.phone_number,
|
|
1873
|
+
linkedin_page: c.linkedin_page,
|
|
1874
|
+
recommended: c.recommended,
|
|
1875
|
+
enrichment_done: c.enrichment?.done ?? false
|
|
1876
|
+
})),
|
|
1877
|
+
org: orgContacts.map((c) => ({
|
|
1878
|
+
id: c.id,
|
|
1879
|
+
first_name: c.first_name,
|
|
1880
|
+
last_name: c.last_name,
|
|
1881
|
+
job_title: c.job_title,
|
|
1882
|
+
email: c.email
|
|
1883
|
+
}))
|
|
1884
|
+
},
|
|
1885
|
+
// 5) engagement — what humans/prior agent runs already did
|
|
1886
|
+
engagement: {
|
|
1887
|
+
liked: lead.liked,
|
|
1888
|
+
disliked: lead.disliked,
|
|
1889
|
+
new: lead.new ?? false,
|
|
1890
|
+
recommended_contact_title: lead.recommended_contact_title ?? null,
|
|
1891
|
+
recommended_contact: lead.recommended_contact ?? null,
|
|
1892
|
+
notes_count: lead.notes_count ?? 0,
|
|
1893
|
+
epilogue_actions_count: lead.epilogue_actions_count ?? 0,
|
|
1894
|
+
prospecting_actions_count: lead.prospecting_actions_count ?? 0,
|
|
1895
|
+
recent_notes: valOrNull(notesR)?.slice(0, 3) ?? [],
|
|
1896
|
+
recent_epilogue: valOrNull(epilogueR)?.items?.slice(0, 3) ?? [],
|
|
1897
|
+
recent_prospecting: valOrNull(prospR)?.items?.slice(0, 5) ?? []
|
|
1898
|
+
},
|
|
1899
|
+
_meta: {
|
|
1900
|
+
region: client.region,
|
|
1901
|
+
lens_id: lensId,
|
|
1902
|
+
web_fetch_in_progress: webFetchR.status === "fulfilled" ? webFetchR.value?.in_progress : false
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
};
|
|
1907
|
+
|
|
1908
|
+
// ../core/dist/composite/recall-ordered-titles.js
|
|
1909
|
+
var recallOrderedTitles = {
|
|
1910
|
+
name: "leadbay_recall_ordered_titles",
|
|
1911
|
+
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.",
|
|
1912
|
+
inputSchema: {
|
|
1913
|
+
type: "object",
|
|
1914
|
+
properties: {
|
|
1915
|
+
leadIds: {
|
|
1916
|
+
type: "array",
|
|
1917
|
+
items: { type: "string" },
|
|
1918
|
+
description: "Lead UUIDs to query. Omit to use the current wishlist (top page)."
|
|
1919
|
+
},
|
|
1920
|
+
lensId: {
|
|
1921
|
+
type: "number",
|
|
1922
|
+
description: "Override the auto-resolved last-active lens when omitting leadIds (escape hatch)"
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
},
|
|
1926
|
+
execute: async (client, params, ctx) => {
|
|
1927
|
+
let leadIds = params.leadIds;
|
|
1928
|
+
if (!leadIds || leadIds.length === 0) {
|
|
1929
|
+
const lensId = params.lensId ?? await client.resolveDefaultLens();
|
|
1930
|
+
const wish = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=50&page=0`);
|
|
1931
|
+
leadIds = wish.items.map((l) => l.id);
|
|
1932
|
+
}
|
|
1933
|
+
if (leadIds.length === 0) {
|
|
1934
|
+
return { titles: [], source: "live_aggregate", note: "No candidate leads" };
|
|
1935
|
+
}
|
|
1936
|
+
await client.acquireSelectionLock();
|
|
1937
|
+
try {
|
|
1938
|
+
const qs = leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
|
|
1939
|
+
try {
|
|
1940
|
+
await client.requestVoid("POST", `/leads/selection/select?${qs}`);
|
|
1941
|
+
const preview = await client.request("POST", "/leads/selection/enrichment/preview", { titles: [] });
|
|
1942
|
+
if (Array.isArray(preview.previously_enriched_titles) && preview.previously_enriched_titles.length > 0) {
|
|
1943
|
+
return {
|
|
1944
|
+
source: "preview_field",
|
|
1945
|
+
titles: preview.previously_enriched_titles.map((t) => ({ title: t })),
|
|
1946
|
+
available_in_selection: preview.title_suggestions ?? []
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
} catch (err) {
|
|
1950
|
+
ctx?.logger?.warn?.(`recall_ordered_titles: preview path failed: ${err?.message ?? err?.code ?? err}`);
|
|
1951
|
+
} finally {
|
|
1952
|
+
try {
|
|
1953
|
+
await client.requestVoid("POST", "/leads/selection/clear");
|
|
1954
|
+
} catch (e) {
|
|
1955
|
+
ctx?.logger?.warn?.(`recall_ordered_titles: selection clear failed: ${e?.message}`);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
} finally {
|
|
1959
|
+
client.releaseSelectionLock();
|
|
1960
|
+
}
|
|
1961
|
+
const titleStats = /* @__PURE__ */ new Map();
|
|
1962
|
+
await Promise.all(leadIds.map(async (leadId) => {
|
|
1963
|
+
try {
|
|
1964
|
+
const contacts = await client.request("GET", `/leads/${leadId}/enrich/contacts?IncludeEnriched=true`);
|
|
1965
|
+
const enriched = contacts.filter((c) => c.enrichment?.done && c.job_title);
|
|
1966
|
+
const unenriched = contacts.filter((c) => !c.enrichment?.done && c.job_title);
|
|
1967
|
+
const titlesEnrichedHere = new Set(enriched.map((c) => c.job_title));
|
|
1968
|
+
for (const t of titlesEnrichedHere) {
|
|
1969
|
+
const cur = titleStats.get(t) ?? {
|
|
1970
|
+
title: t,
|
|
1971
|
+
leads_with_enriched: 0,
|
|
1972
|
+
total_enriched_contacts: 0,
|
|
1973
|
+
leads_still_having_unenriched: 0
|
|
1974
|
+
};
|
|
1975
|
+
cur.leads_with_enriched += 1;
|
|
1976
|
+
cur.total_enriched_contacts += enriched.filter((c) => c.job_title === t).length;
|
|
1977
|
+
titleStats.set(t, cur);
|
|
1978
|
+
}
|
|
1979
|
+
for (const c of unenriched) {
|
|
1980
|
+
const t = c.job_title;
|
|
1981
|
+
const cur = titleStats.get(t);
|
|
1982
|
+
if (cur)
|
|
1983
|
+
cur.leads_still_having_unenriched += 1;
|
|
1984
|
+
}
|
|
1985
|
+
} catch (err) {
|
|
1986
|
+
ctx?.logger?.warn?.(`recall_ordered_titles: contacts fetch failed for ${leadId}: ${err?.message}`);
|
|
1987
|
+
}
|
|
1988
|
+
}));
|
|
1989
|
+
return {
|
|
1990
|
+
source: "live_aggregate",
|
|
1991
|
+
titles: [...titleStats.values()].sort((a, b) => b.total_enriched_contacts - a.total_enriched_contacts),
|
|
1992
|
+
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."
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
};
|
|
1996
|
+
|
|
1997
|
+
// ../core/dist/composite/account-status.js
|
|
1998
|
+
var accountStatus = {
|
|
1999
|
+
name: "leadbay_account_status",
|
|
2000
|
+
description: "Show the user's account state \u2014 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 \u2014 operations themselves return 429; this tool is for context, not gating.",
|
|
2001
|
+
inputSchema: { type: "object", properties: {} },
|
|
2002
|
+
execute: async (client, _params, ctx) => {
|
|
2003
|
+
const me = await client.resolveMe();
|
|
2004
|
+
let quota = null;
|
|
2005
|
+
try {
|
|
2006
|
+
quota = await client.request("GET", `/organizations/${me.organization.id}/quota_status`);
|
|
2007
|
+
} catch (err) {
|
|
2008
|
+
ctx?.logger?.warn?.(`account_status: quota_status failed: ${err?.message ?? err?.code ?? err}`);
|
|
2009
|
+
}
|
|
2010
|
+
return {
|
|
2011
|
+
user: {
|
|
2012
|
+
email: me.email ?? null,
|
|
2013
|
+
name: me.name ?? null,
|
|
2014
|
+
admin: me.admin ?? false,
|
|
2015
|
+
manager: me.manager ?? false,
|
|
2016
|
+
language: me.language ?? "en"
|
|
2017
|
+
},
|
|
2018
|
+
organization: {
|
|
2019
|
+
id: me.organization.id,
|
|
2020
|
+
name: me.organization.name,
|
|
2021
|
+
ai_agent_enabled: me.organization.ai_agent_enabled ?? false,
|
|
2022
|
+
computing_intelligence: me.organization.computing_intelligence ?? false,
|
|
2023
|
+
plan: quota?.plan ?? me.organization.quota_plan ?? null
|
|
2024
|
+
},
|
|
2025
|
+
last_requested_lens: me.last_requested_lens ?? null,
|
|
2026
|
+
// Quota goes here verbatim from /quota_status. Legacy freemium.* fields
|
|
2027
|
+
// on /me are intentionally NOT surfaced — they're defunct (see
|
|
2028
|
+
// SHAPE-DRIFT.md probe round 4).
|
|
2029
|
+
quota,
|
|
2030
|
+
_meta: {
|
|
2031
|
+
region: client.region
|
|
2032
|
+
}
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
};
|
|
2036
|
+
|
|
2037
|
+
// ../core/dist/composite/bulk-qualify-leads.js
|
|
2038
|
+
var PAGE_SIZE = 50;
|
|
2039
|
+
var DEFAULT_COUNT = 10;
|
|
2040
|
+
var MAX_COUNT = 25;
|
|
2041
|
+
var DEFAULT_PER_LEAD_BUDGET_MS = 9e4;
|
|
2042
|
+
var DEFAULT_TOTAL_BUDGET_MS = 5 * 6e4;
|
|
2043
|
+
var bulkQualifyLeads = {
|
|
2044
|
+
name: "leadbay_bulk_qualify_leads",
|
|
2045
|
+
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 \u2014 that's leadbay_qualify_lead (granular, advanced).",
|
|
2046
|
+
inputSchema: {
|
|
2047
|
+
type: "object",
|
|
2048
|
+
properties: {
|
|
2049
|
+
count: {
|
|
2050
|
+
type: "number",
|
|
2051
|
+
description: `How many fresh leads to qualify (default ${DEFAULT_COUNT}, max ${MAX_COUNT})`
|
|
2052
|
+
},
|
|
2053
|
+
leadIds: {
|
|
2054
|
+
type: "array",
|
|
2055
|
+
items: { type: "string" },
|
|
2056
|
+
description: "Explicit lead UUIDs to qualify (skips the auto-pagination)"
|
|
2057
|
+
},
|
|
2058
|
+
lensId: {
|
|
2059
|
+
type: "number",
|
|
2060
|
+
description: "Lens id (escape hatch \u2014 defaults to active)"
|
|
2061
|
+
},
|
|
2062
|
+
per_lead_budget_ms: {
|
|
2063
|
+
type: "number",
|
|
2064
|
+
description: `Polling budget per lead in ms (default ${DEFAULT_PER_LEAD_BUDGET_MS})`
|
|
2065
|
+
},
|
|
2066
|
+
total_budget_ms: {
|
|
2067
|
+
type: "number",
|
|
2068
|
+
description: `Total polling budget in ms (default ${DEFAULT_TOTAL_BUDGET_MS})`
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
},
|
|
2072
|
+
execute: async (client, params, ctx) => {
|
|
2073
|
+
const wantCount = Math.min(params.count ?? DEFAULT_COUNT, MAX_COUNT);
|
|
2074
|
+
const perLeadBudget = params.per_lead_budget_ms ?? DEFAULT_PER_LEAD_BUDGET_MS;
|
|
2075
|
+
const totalBudget = params.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS;
|
|
2076
|
+
const totalDeadline = Date.now() + totalBudget;
|
|
2077
|
+
let candidates;
|
|
2078
|
+
let exhausted = false;
|
|
2079
|
+
let totalUnqualifiedFound = 0;
|
|
2080
|
+
let lensId;
|
|
2081
|
+
if (params.leadIds && params.leadIds.length > 0) {
|
|
2082
|
+
candidates = params.leadIds;
|
|
2083
|
+
lensId = params.lensId ?? await client.resolveDefaultLens();
|
|
2084
|
+
} else {
|
|
2085
|
+
lensId = params.lensId ?? await client.resolveDefaultLens();
|
|
2086
|
+
candidates = [];
|
|
2087
|
+
let page = 0;
|
|
2088
|
+
while (candidates.length < wantCount) {
|
|
2089
|
+
const wish = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${PAGE_SIZE}&page=${page}`);
|
|
2090
|
+
if (wish.items.length === 0) {
|
|
2091
|
+
exhausted = true;
|
|
2092
|
+
break;
|
|
2093
|
+
}
|
|
2094
|
+
const fresh = wish.items.filter((l) => l.ai_agent_lead_score == null && l.web_fetch_in_progress !== true);
|
|
2095
|
+
totalUnqualifiedFound += fresh.length;
|
|
2096
|
+
for (const lead of fresh) {
|
|
2097
|
+
candidates.push(lead.id);
|
|
2098
|
+
if (candidates.length >= wantCount)
|
|
2099
|
+
break;
|
|
2100
|
+
}
|
|
2101
|
+
if (page >= wish.pagination.pages - 1) {
|
|
2102
|
+
exhausted = true;
|
|
2103
|
+
break;
|
|
2104
|
+
}
|
|
2105
|
+
page += 1;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
if (candidates.length === 0) {
|
|
2109
|
+
return {
|
|
2110
|
+
qualified: [],
|
|
2111
|
+
still_running: [],
|
|
2112
|
+
failed: [],
|
|
2113
|
+
quota_exceeded: false,
|
|
2114
|
+
exhausted,
|
|
2115
|
+
total_unqualified_found: totalUnqualifiedFound,
|
|
2116
|
+
message: "No unqualified leads found in this lens \u2014 either all leads have been qualified, or the wishlist is still computing (check leadbay_account_status for computing_wishlist)."
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
const launched = [];
|
|
2120
|
+
const failed = [];
|
|
2121
|
+
let quotaExceeded = false;
|
|
2122
|
+
for (const leadId of candidates) {
|
|
2123
|
+
if (quotaExceeded)
|
|
2124
|
+
break;
|
|
2125
|
+
try {
|
|
2126
|
+
await client.requestVoid("POST", `/leads/${leadId}/web_fetch?force_fetch=false`);
|
|
2127
|
+
launched.push(leadId);
|
|
2128
|
+
} catch (err) {
|
|
2129
|
+
if (err?.code === "QUOTA_EXCEEDED") {
|
|
2130
|
+
quotaExceeded = true;
|
|
2131
|
+
ctx?.logger?.warn?.(`bulk_qualify_leads: 429 mid-fanout after launching ${launched.length}/${candidates.length} \u2014 stopping further launches but polling those already in flight`);
|
|
2132
|
+
} else if (err?.code === "NOT_FOUND") {
|
|
2133
|
+
failed.push({ lead_id: leadId, error: "lead not found" });
|
|
2134
|
+
} else {
|
|
2135
|
+
failed.push({
|
|
2136
|
+
lead_id: leadId,
|
|
2137
|
+
error: err?.message ?? err?.code ?? "unknown"
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
const results = await Promise.all(launched.map(async (leadId) => {
|
|
2143
|
+
const leadDeadline = Math.min(Date.now() + perLeadBudget, totalDeadline);
|
|
2144
|
+
let lastQual = null;
|
|
2145
|
+
let lastWf = null;
|
|
2146
|
+
while (Date.now() < leadDeadline) {
|
|
2147
|
+
try {
|
|
2148
|
+
const [wfR, qualR] = await Promise.allSettled([
|
|
2149
|
+
client.request("GET", `/leads/${leadId}/web_fetch`),
|
|
2150
|
+
client.request("GET", `/leads/${leadId}/ai_agent_responses`)
|
|
2151
|
+
]);
|
|
2152
|
+
if (wfR.status === "fulfilled")
|
|
2153
|
+
lastWf = wfR.value;
|
|
2154
|
+
if (qualR.status === "fulfilled")
|
|
2155
|
+
lastQual = qualR.value;
|
|
2156
|
+
const done = lastWf !== null && lastWf.in_progress !== true && Array.isArray(lastQual) && lastQual.length > 0 && lastQual.every((r) => r.score != null);
|
|
2157
|
+
if (done)
|
|
2158
|
+
break;
|
|
2159
|
+
} catch {
|
|
2160
|
+
}
|
|
2161
|
+
await new Promise((r) => setTimeout(r, 5e3));
|
|
2162
|
+
}
|
|
2163
|
+
const stillRunning = lastWf?.in_progress === true || !lastQual || lastQual.length === 0 || lastQual.some((r) => r.score == null);
|
|
2164
|
+
const responses = lastQual ?? [];
|
|
2165
|
+
const scores = responses.map((r) => r.score).filter((s) => s != null);
|
|
2166
|
+
const avg = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 10) / 10 : null;
|
|
2167
|
+
return {
|
|
2168
|
+
lead_id: leadId,
|
|
2169
|
+
qualification_summary: responses.length > 0 ? {
|
|
2170
|
+
answered: responses.filter((r) => r.score != null).length,
|
|
2171
|
+
total: responses.length,
|
|
2172
|
+
avg_score_0_to_10: avg
|
|
2173
|
+
} : null,
|
|
2174
|
+
signals_count: lastWf?.content ? Object.values(lastWf.content).reduce((acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), 0) : null,
|
|
2175
|
+
_stillRunning: stillRunning
|
|
2176
|
+
};
|
|
2177
|
+
}));
|
|
2178
|
+
const qualified = results.filter((r) => !r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
|
|
2179
|
+
const still_running = results.filter((r) => r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
|
|
2180
|
+
return {
|
|
2181
|
+
qualified,
|
|
2182
|
+
still_running,
|
|
2183
|
+
failed,
|
|
2184
|
+
quota_exceeded: quotaExceeded,
|
|
2185
|
+
exhausted,
|
|
2186
|
+
total_unqualified_found: totalUnqualifiedFound,
|
|
2187
|
+
lens_id: lensId,
|
|
2188
|
+
_meta: { region: client.region }
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
};
|
|
2192
|
+
|
|
2193
|
+
// ../core/dist/composite/enrich-titles.js
|
|
2194
|
+
var DEFAULT_CANDIDATE_COUNT = 25;
|
|
2195
|
+
var enrichTitles = {
|
|
2196
|
+
name: "leadbay_enrich_titles",
|
|
2197
|
+
description: "Order contact enrichments by job title across many leads. Two modes: (A) NO titles param \u2014 returns the available titles + Leadbay's title_suggestions + auto_included_titles + a count of enrichable contacts, so the agent can ask the user which titles to enrich. (B) titles given \u2014 calls preview, then launches if there's anything enrichable. On 429 returns {status:'quota_exceeded'} cleanly. Selection lifecycle is wrapped in a try/finally so the user's selection is left clean even on error. When to use: as the agent's go-to enrichment entry point. When NOT to use: to enrich a single contact \u2014 that's leadbay_enrich_contacts (granular).",
|
|
2198
|
+
inputSchema: {
|
|
2199
|
+
type: "object",
|
|
2200
|
+
properties: {
|
|
2201
|
+
titles: {
|
|
2202
|
+
type: "array",
|
|
2203
|
+
items: { type: "string" },
|
|
2204
|
+
description: "Job titles to enrich. Omit to discover what's available without launching."
|
|
2205
|
+
},
|
|
2206
|
+
leadIds: {
|
|
2207
|
+
type: "array",
|
|
2208
|
+
items: { type: "string" },
|
|
2209
|
+
description: "Lead UUIDs to enrich. Omit to use the top page of the active lens's wishlist."
|
|
2210
|
+
},
|
|
2211
|
+
lensId: {
|
|
2212
|
+
type: "number",
|
|
2213
|
+
description: "Lens id (escape hatch \u2014 defaults to active)"
|
|
2214
|
+
},
|
|
2215
|
+
email: { type: "boolean", description: "Enrich emails (default true)" },
|
|
2216
|
+
phone: { type: "boolean", description: "Enrich phone numbers (default false)" },
|
|
2217
|
+
candidateCount: {
|
|
2218
|
+
type: "number",
|
|
2219
|
+
description: `When leadIds is omitted, how many top-of-wishlist leads to use (default ${DEFAULT_CANDIDATE_COUNT})`
|
|
2220
|
+
},
|
|
2221
|
+
dry_run: {
|
|
2222
|
+
type: "boolean",
|
|
2223
|
+
description: "If true, don't launch \u2014 only preview."
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
},
|
|
2227
|
+
execute: async (client, params, ctx) => {
|
|
2228
|
+
const email = params.email ?? true;
|
|
2229
|
+
const phone = params.phone ?? false;
|
|
2230
|
+
if (!email && !phone) {
|
|
2231
|
+
return {
|
|
2232
|
+
error: true,
|
|
2233
|
+
code: "BAD_INPUT",
|
|
2234
|
+
message: "Either email or phone must be true",
|
|
2235
|
+
hint: "Set email:true (most common) or phone:true"
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
let leadIds = params.leadIds;
|
|
2239
|
+
if (!leadIds || leadIds.length === 0) {
|
|
2240
|
+
const lensId = params.lensId ?? await client.resolveDefaultLens();
|
|
2241
|
+
const cnt = params.candidateCount ?? DEFAULT_CANDIDATE_COUNT;
|
|
2242
|
+
const wish = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${Math.min(cnt, 50)}&page=0`);
|
|
2243
|
+
leadIds = wish.items.map((l) => l.id);
|
|
2244
|
+
}
|
|
2245
|
+
if (leadIds.length === 0) {
|
|
2246
|
+
return {
|
|
2247
|
+
error: true,
|
|
2248
|
+
code: "NO_CANDIDATES",
|
|
2249
|
+
message: "No candidate leads",
|
|
2250
|
+
hint: "Pass leadIds explicitly or wait for the wishlist to compute"
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
await client.acquireSelectionLock();
|
|
2254
|
+
try {
|
|
2255
|
+
const qs = leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
|
|
2256
|
+
await client.requestVoid("POST", `/leads/selection/select?${qs}`);
|
|
2257
|
+
try {
|
|
2258
|
+
const availableTitles = await client.request("GET", "/leads/selection/enrichment/job_titles");
|
|
2259
|
+
if (!params.titles || params.titles.length === 0) {
|
|
2260
|
+
let suggestions = [];
|
|
2261
|
+
let autoIncluded = [];
|
|
2262
|
+
let previouslyEnriched = [];
|
|
2263
|
+
let enrichableContacts = 0;
|
|
2264
|
+
try {
|
|
2265
|
+
const prev = await client.request("POST", "/leads/selection/enrichment/preview", { titles: [] });
|
|
2266
|
+
suggestions = prev.title_suggestions ?? [];
|
|
2267
|
+
autoIncluded = prev.auto_included_titles ?? [];
|
|
2268
|
+
previouslyEnriched = prev.previously_enriched_titles ?? [];
|
|
2269
|
+
enrichableContacts = prev.enrichable_contacts;
|
|
2270
|
+
} catch (e) {
|
|
2271
|
+
ctx?.logger?.warn?.(`enrich_titles: 0-titles preview failed: ${e?.message}`);
|
|
2272
|
+
}
|
|
2273
|
+
return {
|
|
2274
|
+
mode: "discover",
|
|
2275
|
+
available_titles: availableTitles,
|
|
2276
|
+
recommendations: suggestions,
|
|
2277
|
+
auto_included: autoIncluded,
|
|
2278
|
+
previously_enriched: previouslyEnriched,
|
|
2279
|
+
enrichable_contacts: enrichableContacts,
|
|
2280
|
+
selected_lead_count: leadIds.length,
|
|
2281
|
+
next_action: "Pick titles to enrich and call leadbay_enrich_titles again with titles=[...]"
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
let preview;
|
|
2285
|
+
try {
|
|
2286
|
+
preview = await client.request("POST", "/leads/selection/enrichment/preview", { titles: params.titles });
|
|
2287
|
+
} catch (err) {
|
|
2288
|
+
if (err?.code === "QUOTA_EXCEEDED") {
|
|
2289
|
+
return {
|
|
2290
|
+
status: "quota_exceeded",
|
|
2291
|
+
message: "Quota exceeded on preview",
|
|
2292
|
+
retry_after_seconds: err?._meta?.retry_after ?? null
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
throw err;
|
|
2296
|
+
}
|
|
2297
|
+
if (preview.enrichable_contacts === 0) {
|
|
2298
|
+
return {
|
|
2299
|
+
mode: "preview_only",
|
|
2300
|
+
preview,
|
|
2301
|
+
launched: false,
|
|
2302
|
+
message: "No enrichable contacts for the chosen titles. Try other titles from available_titles or recommendations.",
|
|
2303
|
+
available_titles: availableTitles
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
if (params.dry_run) {
|
|
2307
|
+
return {
|
|
2308
|
+
mode: "dry_run",
|
|
2309
|
+
preview,
|
|
2310
|
+
launched: false,
|
|
2311
|
+
would_launch: { titles: params.titles, email, phone }
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
try {
|
|
2315
|
+
await client.requestVoid("POST", "/leads/selection/enrichment/launch", { titles: params.titles, email, phone });
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
if (err?.code === "QUOTA_EXCEEDED") {
|
|
2318
|
+
return {
|
|
2319
|
+
status: "quota_exceeded",
|
|
2320
|
+
preview,
|
|
2321
|
+
message: "Quota exceeded on launch",
|
|
2322
|
+
retry_after_seconds: err?._meta?.retry_after ?? null
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
throw err;
|
|
2326
|
+
}
|
|
2327
|
+
return {
|
|
2328
|
+
mode: "launched",
|
|
2329
|
+
preview,
|
|
2330
|
+
launched: true,
|
|
2331
|
+
titles: params.titles,
|
|
2332
|
+
email,
|
|
2333
|
+
phone,
|
|
2334
|
+
message: "Enrichment job launched. The Leadbay backend does not return a bulk_id (probed 2026-04-20) \u2014 track results by polling individual leads via leadbay_get_contacts after ~60s; contact.enrichment.done flips to true.",
|
|
2335
|
+
next_action: "Wait ~60s, then call leadbay_research_lead or leadbay_get_contacts on the leads you care about."
|
|
2336
|
+
};
|
|
2337
|
+
} finally {
|
|
2338
|
+
try {
|
|
2339
|
+
await client.requestVoid("POST", "/leads/selection/clear");
|
|
2340
|
+
} catch (e) {
|
|
2341
|
+
ctx?.logger?.warn?.(`enrich_titles: selection.clear failed: ${e?.message ?? e?.code}`);
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
} finally {
|
|
2345
|
+
client.releaseSelectionLock();
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
};
|
|
2349
|
+
|
|
2350
|
+
// ../core/dist/composite/adjust-audience.js
|
|
2351
|
+
function tokens(s) {
|
|
2352
|
+
return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
2353
|
+
}
|
|
2354
|
+
function bestMatches(text, taxonomy) {
|
|
2355
|
+
const want = new Set(tokens(text));
|
|
2356
|
+
if (want.size === 0)
|
|
2357
|
+
return [];
|
|
2358
|
+
const ranked = taxonomy.map((s) => {
|
|
2359
|
+
const have = new Set(tokens(s.name));
|
|
2360
|
+
let overlap = 0;
|
|
2361
|
+
for (const t of want)
|
|
2362
|
+
if (have.has(t))
|
|
2363
|
+
overlap += 1;
|
|
2364
|
+
const score = overlap / Math.max(want.size, 1);
|
|
2365
|
+
return { id: s.id, name: s.name, score };
|
|
2366
|
+
}).filter((m) => m.score > 0).sort((a, b) => b.score - a.score);
|
|
2367
|
+
return ranked.slice(0, 5);
|
|
2368
|
+
}
|
|
2369
|
+
async function resolveSectors(client, texts) {
|
|
2370
|
+
const looksLikeId = (s) => /^\d+$/.test(s);
|
|
2371
|
+
const direct = texts.filter(looksLikeId);
|
|
2372
|
+
const free = texts.filter((s) => !looksLikeId(s));
|
|
2373
|
+
if (free.length === 0)
|
|
2374
|
+
return { resolved: direct, ambiguities: [] };
|
|
2375
|
+
const me = await client.resolveMe().catch(() => null);
|
|
2376
|
+
const lang = me?.language ?? "en";
|
|
2377
|
+
const taxonomy = await client.request("GET", `/sectors/all?lang=${encodeURIComponent(lang)}&includeInvisible=false`);
|
|
2378
|
+
const resolved = [...direct];
|
|
2379
|
+
const ambiguities = [];
|
|
2380
|
+
for (const text of free) {
|
|
2381
|
+
const matches = bestMatches(text, taxonomy);
|
|
2382
|
+
if (matches.length === 1 || matches.length >= 2 && matches[0].score >= 0.66 && matches[0].score - matches[1].score >= 0.34) {
|
|
2383
|
+
resolved.push(matches[0].id);
|
|
2384
|
+
} else {
|
|
2385
|
+
ambiguities.push({ sector_text: text, matches });
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
return { resolved, ambiguities };
|
|
2389
|
+
}
|
|
2390
|
+
function mergeFilter(current, toAddSectors, toExcludeSectors, sizes) {
|
|
2391
|
+
const items = current?.lens_filter?.items ?? [];
|
|
2392
|
+
const item = items[0] ?? { criteria: [] };
|
|
2393
|
+
const criteria = item.criteria ? [...item.criteria] : [];
|
|
2394
|
+
if (toAddSectors.length > 0) {
|
|
2395
|
+
const idx = criteria.findIndex((c) => c.type === "sector_ids" && !c.is_excluded);
|
|
2396
|
+
if (idx >= 0) {
|
|
2397
|
+
const cur = criteria[idx];
|
|
2398
|
+
const merged = Array.from(/* @__PURE__ */ new Set([...cur.sectors ?? [], ...toAddSectors]));
|
|
2399
|
+
criteria[idx] = { ...cur, sectors: merged };
|
|
2400
|
+
} else {
|
|
2401
|
+
criteria.push({
|
|
2402
|
+
type: "sector_ids",
|
|
2403
|
+
is_excluded: false,
|
|
2404
|
+
sectors: toAddSectors
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
if (toExcludeSectors.length > 0) {
|
|
2409
|
+
const idx = criteria.findIndex((c) => c.type === "sector_ids" && c.is_excluded);
|
|
2410
|
+
if (idx >= 0) {
|
|
2411
|
+
const cur = criteria[idx];
|
|
2412
|
+
const merged = Array.from(/* @__PURE__ */ new Set([...cur.sectors ?? [], ...toExcludeSectors]));
|
|
2413
|
+
criteria[idx] = { ...cur, sectors: merged };
|
|
2414
|
+
} else {
|
|
2415
|
+
criteria.push({
|
|
2416
|
+
type: "sector_ids",
|
|
2417
|
+
is_excluded: true,
|
|
2418
|
+
sectors: toExcludeSectors
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
if (sizes && sizes.length > 0) {
|
|
2423
|
+
const idx = criteria.findIndex((c) => c.type === "size");
|
|
2424
|
+
if (idx >= 0) {
|
|
2425
|
+
criteria[idx] = { type: "size", is_excluded: false, sizes };
|
|
2426
|
+
} else {
|
|
2427
|
+
criteria.push({ type: "size", is_excluded: false, sizes });
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
return {
|
|
2431
|
+
lens_filter: { items: [{ criteria }] },
|
|
2432
|
+
locations: current.locations ?? { results: [], parents: [] }
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
var adjustAudience = {
|
|
2436
|
+
name: "leadbay_adjust_audience",
|
|
2437
|
+
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 \u2014 that's leadbay_refine_prompt.",
|
|
2438
|
+
inputSchema: {
|
|
2439
|
+
type: "object",
|
|
2440
|
+
properties: {
|
|
2441
|
+
sectors: {
|
|
2442
|
+
type: "array",
|
|
2443
|
+
items: { type: "string" },
|
|
2444
|
+
description: "Sector free-text (e.g. ['Healthcare', 'Engineering']) or ids \u2014 auto-resolved"
|
|
2445
|
+
},
|
|
2446
|
+
sector_ids: {
|
|
2447
|
+
type: "array",
|
|
2448
|
+
items: { type: "string" },
|
|
2449
|
+
description: "Explicit sector ids (skips taxonomy lookup)"
|
|
2450
|
+
},
|
|
2451
|
+
exclude_sectors: {
|
|
2452
|
+
type: "array",
|
|
2453
|
+
items: { type: "string" },
|
|
2454
|
+
description: "Sectors to exclude (free text or ids)"
|
|
2455
|
+
},
|
|
2456
|
+
sizes: {
|
|
2457
|
+
type: "array",
|
|
2458
|
+
items: {
|
|
2459
|
+
type: "object",
|
|
2460
|
+
properties: { min: { type: "number" }, max: { type: "number" } }
|
|
2461
|
+
},
|
|
2462
|
+
description: "Company size buckets, e.g. [{min:30,max:300}]"
|
|
2463
|
+
},
|
|
2464
|
+
lensId: { type: "number", description: "Lens id (escape hatch)" },
|
|
2465
|
+
save_for_org: {
|
|
2466
|
+
type: "boolean",
|
|
2467
|
+
description: "Admin only \u2014 propagate the change to the org-level lens for everyone (default false: per-user draft)"
|
|
2468
|
+
},
|
|
2469
|
+
newLensName: {
|
|
2470
|
+
type: "string",
|
|
2471
|
+
description: "Name to use when this composite has to clone the default lens (otherwise auto-named)"
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
},
|
|
2475
|
+
execute: async (client, params, ctx) => {
|
|
2476
|
+
const me = await client.resolveMe();
|
|
2477
|
+
const isAdmin = me.admin === true;
|
|
2478
|
+
const startingLensId = params.lensId ?? me.last_requested_lens ?? await client.resolveDefaultLens();
|
|
2479
|
+
const includeTexts = [
|
|
2480
|
+
...params.sectors ?? [],
|
|
2481
|
+
...params.sector_ids ?? []
|
|
2482
|
+
];
|
|
2483
|
+
const excludeTexts = params.exclude_sectors ?? [];
|
|
2484
|
+
const includeRes = await resolveSectors(client, includeTexts);
|
|
2485
|
+
const excludeRes = await resolveSectors(client, excludeTexts);
|
|
2486
|
+
const ambiguities = [
|
|
2487
|
+
...includeRes.ambiguities,
|
|
2488
|
+
...excludeRes.ambiguities
|
|
2489
|
+
];
|
|
2490
|
+
if (ambiguities.length > 0) {
|
|
2491
|
+
return {
|
|
2492
|
+
status: "ambiguous_sectors",
|
|
2493
|
+
sector_ambiguities: ambiguities,
|
|
2494
|
+
message: "One or more sector names matched multiple sectors. Pick from the matches and re-call with sector_ids=..."
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
const lens = await client.request("GET", `/lenses/${startingLensId}`);
|
|
2498
|
+
const currentFilter = await client.request("GET", `/lenses/${startingLensId}/filter`);
|
|
2499
|
+
const merged = mergeFilter(currentFilter, includeRes.resolved, excludeRes.resolved, params.sizes);
|
|
2500
|
+
const isDefault = lens.is_default || lens.default;
|
|
2501
|
+
const isUserLevel = lens.user_id != null;
|
|
2502
|
+
const isOrgLevel = !isUserLevel && !isDefault;
|
|
2503
|
+
let targetLensId = startingLensId;
|
|
2504
|
+
let wasDraft = false;
|
|
2505
|
+
let wasNew = false;
|
|
2506
|
+
if (isDefault) {
|
|
2507
|
+
const name = params.newLensName ?? `Custom audience \u2014 ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
|
|
2508
|
+
const newLens = await client.request("POST", "/lenses", {
|
|
2509
|
+
base: startingLensId,
|
|
2510
|
+
name
|
|
2511
|
+
});
|
|
2512
|
+
targetLensId = newLens.id;
|
|
2513
|
+
wasNew = true;
|
|
2514
|
+
await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, merged);
|
|
2515
|
+
await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
|
|
2516
|
+
} else if (isUserLevel) {
|
|
2517
|
+
try {
|
|
2518
|
+
await client.requestVoid("POST", `/lenses/${startingLensId}/filter`, merged);
|
|
2519
|
+
} catch (err) {
|
|
2520
|
+
if (err?.code === "FORBIDDEN") {
|
|
2521
|
+
wasDraft = true;
|
|
2522
|
+
const draft = await client.request("POST", `/lenses/${startingLensId}/draft`);
|
|
2523
|
+
targetLensId = draft.id;
|
|
2524
|
+
await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, merged);
|
|
2525
|
+
await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
|
|
2526
|
+
} else {
|
|
2527
|
+
throw err;
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
} else if (isOrgLevel) {
|
|
2531
|
+
const goDraft = !isAdmin || !params.save_for_org;
|
|
2532
|
+
if (goDraft) {
|
|
2533
|
+
wasDraft = true;
|
|
2534
|
+
const draft = await client.request("POST", `/lenses/${startingLensId}/draft`);
|
|
2535
|
+
targetLensId = draft.id;
|
|
2536
|
+
try {
|
|
2537
|
+
await client.requestVoid("POST", `/lenses/${targetLensId}/filter`, merged);
|
|
2538
|
+
} catch (err) {
|
|
2539
|
+
ctx?.logger?.warn?.(`adjust_audience: filter on draft ${targetLensId} failed: ${err?.message}`);
|
|
2540
|
+
try {
|
|
2541
|
+
await client.requestVoid("DELETE", `/lenses/${targetLensId}`);
|
|
2542
|
+
} catch {
|
|
2543
|
+
return {
|
|
2544
|
+
error: true,
|
|
2545
|
+
code: "ORPHAN_DRAFT",
|
|
2546
|
+
message: `Draft ${targetLensId} created but filter update failed; draft cleanup also failed`,
|
|
2547
|
+
hint: `Manually delete draft lens ${targetLensId} via the Leadbay UI`,
|
|
2548
|
+
orphan_draft_id: targetLensId
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2551
|
+
throw err;
|
|
2552
|
+
}
|
|
2553
|
+
await client.requestVoid("POST", `/lenses/${targetLensId}/update_last_requested`);
|
|
2554
|
+
} else {
|
|
2555
|
+
try {
|
|
2556
|
+
await client.requestVoid("POST", `/lenses/${startingLensId}/filter`, merged);
|
|
2557
|
+
} catch (err) {
|
|
2558
|
+
throw err;
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
client.invalidateMe();
|
|
2563
|
+
client.invalidateDefaultLens();
|
|
2564
|
+
return {
|
|
2565
|
+
status: "applied",
|
|
2566
|
+
lens_used: {
|
|
2567
|
+
id: targetLensId,
|
|
2568
|
+
name: lens.name,
|
|
2569
|
+
was_draft: wasDraft,
|
|
2570
|
+
was_new: wasNew,
|
|
2571
|
+
save_for_org: params.save_for_org === true && isAdmin && isOrgLevel
|
|
2572
|
+
},
|
|
2573
|
+
filter_applied: merged,
|
|
2574
|
+
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.",
|
|
2575
|
+
_meta: { region: client.region }
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
};
|
|
2579
|
+
|
|
2580
|
+
// ../core/dist/composite/refine-prompt.js
|
|
2581
|
+
var DEFAULT_POLL_ATTEMPTS = 2;
|
|
2582
|
+
var DEFAULT_POLL_GAP_MS = 5e3;
|
|
2583
|
+
var refinePrompt = {
|
|
2584
|
+
name: "leadbay_refine_prompt",
|
|
2585
|
+
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 \u2014 that's leadbay_answer_clarification.",
|
|
2586
|
+
inputSchema: {
|
|
2587
|
+
type: "object",
|
|
2588
|
+
properties: {
|
|
2589
|
+
prompt: { type: "string", description: "Refinement instruction (free text)" },
|
|
2590
|
+
clarification_poll_attempts: {
|
|
2591
|
+
type: "number",
|
|
2592
|
+
description: `How many times to poll for a clarification after setting (default ${DEFAULT_POLL_ATTEMPTS})`
|
|
2593
|
+
},
|
|
2594
|
+
clarification_poll_gap_ms: {
|
|
2595
|
+
type: "number",
|
|
2596
|
+
description: `Gap between polls in ms (default ${DEFAULT_POLL_GAP_MS})`
|
|
2597
|
+
},
|
|
2598
|
+
dry_run: {
|
|
2599
|
+
type: "boolean",
|
|
2600
|
+
description: "If true, return the call shape WITHOUT setting the prompt"
|
|
2601
|
+
}
|
|
2602
|
+
},
|
|
2603
|
+
required: ["prompt"]
|
|
2604
|
+
},
|
|
2605
|
+
execute: async (client, params, ctx) => {
|
|
2606
|
+
const me = await client.resolveMe();
|
|
2607
|
+
if (me.admin !== true) {
|
|
2608
|
+
return {
|
|
2609
|
+
error: true,
|
|
2610
|
+
code: "FORBIDDEN",
|
|
2611
|
+
message: "leadbay_refine_prompt requires admin rights on the org",
|
|
2612
|
+
hint: "Ask your Leadbay org admin to set the refinement prompt, or use leadbay_adjust_audience for firmographic changes"
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
const orgId = me.organization.id;
|
|
2616
|
+
if (params.dry_run) {
|
|
2617
|
+
return {
|
|
2618
|
+
dry_run: true,
|
|
2619
|
+
would_call: {
|
|
2620
|
+
method: "POST",
|
|
2621
|
+
path: `/organizations/${orgId}/user_prompt`,
|
|
2622
|
+
body: { prompt: params.prompt }
|
|
2623
|
+
}
|
|
2624
|
+
};
|
|
2625
|
+
}
|
|
2626
|
+
const postedAt = Date.now();
|
|
2627
|
+
const STALE_GUARD_MS = 5e3;
|
|
2628
|
+
await client.requestVoid("POST", `/organizations/${orgId}/user_prompt`, {
|
|
2629
|
+
prompt: params.prompt
|
|
2630
|
+
});
|
|
2631
|
+
client.invalidateMe();
|
|
2632
|
+
const attempts = params.clarification_poll_attempts ?? DEFAULT_POLL_ATTEMPTS;
|
|
2633
|
+
const gap = params.clarification_poll_gap_ms ?? DEFAULT_POLL_GAP_MS;
|
|
2634
|
+
let clarification = null;
|
|
2635
|
+
for (let i = 0; i < attempts; i++) {
|
|
2636
|
+
await new Promise((r) => setTimeout(r, gap));
|
|
2637
|
+
try {
|
|
2638
|
+
const c = await client.request("GET", `/organizations/${orgId}/clarifications`);
|
|
2639
|
+
if (c) {
|
|
2640
|
+
if (c.created_at) {
|
|
2641
|
+
const createdMs = Date.parse(c.created_at);
|
|
2642
|
+
if (Number.isFinite(createdMs) && createdMs < postedAt - STALE_GUARD_MS) {
|
|
2643
|
+
ctx?.logger?.warn?.(`refine_prompt: stale clarification (created_at=${c.created_at}, posted=${new Date(postedAt).toISOString()}) \u2014 ignoring`);
|
|
2644
|
+
continue;
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
clarification = c;
|
|
2648
|
+
break;
|
|
2649
|
+
}
|
|
2650
|
+
} catch (err) {
|
|
2651
|
+
ctx?.logger?.warn?.(`refine_prompt: clarification poll error: ${err?.message}`);
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
if (clarification) {
|
|
2655
|
+
return {
|
|
2656
|
+
status: "clarification_pending",
|
|
2657
|
+
clarification,
|
|
2658
|
+
next_action: "Call leadbay_answer_clarification with option_id (preferred) or text_answer to disambiguate",
|
|
2659
|
+
_meta: { region: client.region }
|
|
2660
|
+
};
|
|
2661
|
+
}
|
|
2662
|
+
return {
|
|
2663
|
+
status: "applied",
|
|
2664
|
+
computing_intelligence: true,
|
|
2665
|
+
message: "Prompt set. Leadbay is regenerating intelligence; new leads will reflect the refinement shortly. Check leadbay_account_status to monitor computing_intelligence.",
|
|
2666
|
+
_meta: { region: client.region }
|
|
2667
|
+
};
|
|
2668
|
+
}
|
|
2669
|
+
};
|
|
2670
|
+
|
|
2671
|
+
// ../core/dist/composite/answer-clarification.js
|
|
2672
|
+
var answerClarification = {
|
|
2673
|
+
name: "leadbay_answer_clarification",
|
|
2674
|
+
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 \u2014 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 \u2014 use leadbay_refine_prompt.",
|
|
2675
|
+
inputSchema: {
|
|
2676
|
+
type: "object",
|
|
2677
|
+
properties: {
|
|
2678
|
+
option_id: { type: "string", description: "Id of one of the clarification's options" },
|
|
2679
|
+
text_answer: { type: "string", description: "Free-text answer (overrides option_id)" }
|
|
2680
|
+
}
|
|
2681
|
+
},
|
|
2682
|
+
execute: async (client, params, ctx) => {
|
|
2683
|
+
if (!params.option_id && !params.text_answer) {
|
|
2684
|
+
return {
|
|
2685
|
+
error: true,
|
|
2686
|
+
code: "BAD_INPUT",
|
|
2687
|
+
message: "Provide option_id or text_answer",
|
|
2688
|
+
hint: "Call leadbay_get_clarification first to see the options"
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
const me = await client.resolveMe();
|
|
2692
|
+
if (me.admin !== true) {
|
|
2693
|
+
return {
|
|
2694
|
+
error: true,
|
|
2695
|
+
code: "FORBIDDEN",
|
|
2696
|
+
message: "Answering clarifications requires admin rights",
|
|
2697
|
+
hint: "Ask your Leadbay org admin to answer the clarification"
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
const orgId = me.organization.id;
|
|
2701
|
+
const pending = await client.request("GET", `/organizations/${orgId}/clarifications`);
|
|
2702
|
+
if (!pending) {
|
|
2703
|
+
return {
|
|
2704
|
+
status: "no_pending_clarification",
|
|
2705
|
+
hint: "There's no pending clarification \u2014 either it was already answered or none was raised. Use leadbay_refine_prompt to set a new prompt."
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
const body = {};
|
|
2709
|
+
if (params.text_answer)
|
|
2710
|
+
body.text_answer = params.text_answer;
|
|
2711
|
+
if (params.option_id)
|
|
2712
|
+
body.option_id = params.option_id;
|
|
2713
|
+
await client.requestVoid("POST", `/organizations/${orgId}/pick_clarification`, body);
|
|
2714
|
+
client.invalidateMe();
|
|
2715
|
+
return {
|
|
2716
|
+
status: "answered",
|
|
2717
|
+
recorded_as_user_prompt: true,
|
|
2718
|
+
message: "Answer recorded. Leadbay is regenerating intelligence based on it. Check leadbay_account_status for computing_intelligence.",
|
|
2719
|
+
_meta: { region: client.region }
|
|
2720
|
+
};
|
|
2721
|
+
}
|
|
2722
|
+
};
|
|
2723
|
+
|
|
2724
|
+
// ../core/dist/composite/report-outreach.js
|
|
2725
|
+
var VALID_SOURCES = /* @__PURE__ */ new Set([
|
|
2726
|
+
"gmail_message_id",
|
|
2727
|
+
"calendar_event_id",
|
|
2728
|
+
"user_confirmed"
|
|
2729
|
+
]);
|
|
2730
|
+
function formatNoteWithVerification(note, v) {
|
|
2731
|
+
return `${note}
|
|
2732
|
+
|
|
2733
|
+
\u2014 logged by AI agent (verification: ${v.source}=${v.ref})`;
|
|
2734
|
+
}
|
|
2735
|
+
var reportOutreach = {
|
|
2736
|
+
name: "leadbay_report_outreach",
|
|
2737
|
+
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: '<id-or-confirmation>'} 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.",
|
|
2738
|
+
optional: true,
|
|
2739
|
+
write: true,
|
|
2740
|
+
inputSchema: {
|
|
2741
|
+
type: "object",
|
|
2742
|
+
properties: {
|
|
2743
|
+
lead_id: { type: "string", description: "Single lead UUID (use lead_ids for bulk)" },
|
|
2744
|
+
lead_ids: {
|
|
2745
|
+
type: "array",
|
|
2746
|
+
items: { type: "string" },
|
|
2747
|
+
description: "Bulk: many lead UUIDs (epilogue applies to all; notes fan out)"
|
|
2748
|
+
},
|
|
2749
|
+
note: {
|
|
2750
|
+
type: "string",
|
|
2751
|
+
description: "1-2 sentence summary of what was done (e.g. 'Sent intro email to CTO citing Hornsea 3 contract')"
|
|
2752
|
+
},
|
|
2753
|
+
epilogue_status: {
|
|
2754
|
+
type: "string",
|
|
2755
|
+
description: "Optional: STILL_CHASING | COULD_NOT_REACH_STILL_TRYING | INTEREST_VALIDATED_OR_MEETING_PLANED | NOT_INTERESTED_LOST"
|
|
2756
|
+
},
|
|
2757
|
+
verification: {
|
|
2758
|
+
type: "object",
|
|
2759
|
+
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.",
|
|
2760
|
+
properties: {
|
|
2761
|
+
source: { type: "string" },
|
|
2762
|
+
ref: { type: "string" }
|
|
2763
|
+
},
|
|
2764
|
+
required: ["source", "ref"]
|
|
2765
|
+
},
|
|
2766
|
+
dry_run: {
|
|
2767
|
+
type: "boolean",
|
|
2768
|
+
description: "If true, return what WOULD be called without writing anything"
|
|
2769
|
+
}
|
|
2770
|
+
},
|
|
2771
|
+
required: ["note", "verification"]
|
|
2772
|
+
},
|
|
2773
|
+
execute: async (client, params, ctx) => {
|
|
2774
|
+
if (!params.verification || !params.verification.source || !params.verification.ref) {
|
|
2775
|
+
return {
|
|
2776
|
+
error: true,
|
|
2777
|
+
code: "VERIFICATION_REQUIRED",
|
|
2778
|
+
message: "report_outreach requires verification={source, ref} on every call. This prevents hallucinated outreach from poisoning the pipeline.",
|
|
2779
|
+
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)."
|
|
2780
|
+
};
|
|
2781
|
+
}
|
|
2782
|
+
if (!VALID_SOURCES.has(params.verification.source)) {
|
|
2783
|
+
return {
|
|
2784
|
+
error: true,
|
|
2785
|
+
code: "BAD_VERIFICATION_SOURCE",
|
|
2786
|
+
message: `verification.source must be one of: gmail_message_id, calendar_event_id, user_confirmed (got: ${params.verification.source})`,
|
|
2787
|
+
hint: "Use 'user_confirmed' with verification.ref set to the user's literal text if you don't have a Gmail/Calendar id"
|
|
2788
|
+
};
|
|
2789
|
+
}
|
|
2790
|
+
if (!params.lead_id && (!params.lead_ids || params.lead_ids.length === 0)) {
|
|
2791
|
+
return {
|
|
2792
|
+
error: true,
|
|
2793
|
+
code: "BAD_INPUT",
|
|
2794
|
+
message: "Provide lead_id (single) or lead_ids (bulk)",
|
|
2795
|
+
hint: "lead_id for one lead; lead_ids: [uuid, ...] for many"
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
const noteBody = formatNoteWithVerification(params.note, params.verification);
|
|
2799
|
+
let epilogueWire = null;
|
|
2800
|
+
if (params.epilogue_status) {
|
|
2801
|
+
const w = EPILOGUE_LABEL_MAP[params.epilogue_status];
|
|
2802
|
+
if (!w) {
|
|
2803
|
+
return {
|
|
2804
|
+
error: true,
|
|
2805
|
+
code: "BAD_INPUT",
|
|
2806
|
+
message: `Unknown epilogue_status: ${params.epilogue_status}`,
|
|
2807
|
+
hint: `Use one of: STILL_CHASING, COULD_NOT_REACH_STILL_TRYING, INTEREST_VALIDATED_OR_MEETING_PLANED, NOT_INTERESTED_LOST`
|
|
2808
|
+
};
|
|
2809
|
+
}
|
|
2810
|
+
epilogueWire = w;
|
|
2811
|
+
}
|
|
2812
|
+
const targetLeads = params.lead_ids ?? [params.lead_id];
|
|
2813
|
+
if (params.dry_run) {
|
|
2814
|
+
return {
|
|
2815
|
+
dry_run: true,
|
|
2816
|
+
would_write_notes: targetLeads.map((id) => ({
|
|
2817
|
+
method: "POST",
|
|
2818
|
+
path: `/leads/${id}/notes`,
|
|
2819
|
+
body: { note: noteBody }
|
|
2820
|
+
})),
|
|
2821
|
+
would_set_epilogue: epilogueWire ? {
|
|
2822
|
+
method: "POST",
|
|
2823
|
+
path: "/leads/epilogue",
|
|
2824
|
+
body: { lead_ids: targetLeads, status: epilogueWire }
|
|
2825
|
+
} : null
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
const noteResults = await Promise.all(targetLeads.map(async (leadId) => {
|
|
2829
|
+
try {
|
|
2830
|
+
const note = await client.request("POST", `/leads/${leadId}/notes`, { note: noteBody });
|
|
2831
|
+
return { lead_id: leadId, ok: true, note_id: note.id };
|
|
2832
|
+
} catch (err) {
|
|
2833
|
+
return {
|
|
2834
|
+
lead_id: leadId,
|
|
2835
|
+
ok: false,
|
|
2836
|
+
error: err?.message ?? err?.code ?? String(err)
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
}));
|
|
2840
|
+
let epilogueResult = { applied: false };
|
|
2841
|
+
if (epilogueWire) {
|
|
2842
|
+
try {
|
|
2843
|
+
await client.requestVoid("POST", "/leads/epilogue", {
|
|
2844
|
+
lead_ids: targetLeads,
|
|
2845
|
+
status: epilogueWire
|
|
2846
|
+
});
|
|
2847
|
+
epilogueResult = { applied: true };
|
|
2848
|
+
} catch (err) {
|
|
2849
|
+
epilogueResult = {
|
|
2850
|
+
applied: false,
|
|
2851
|
+
error: err?.message ?? err?.code ?? String(err)
|
|
2852
|
+
};
|
|
2853
|
+
ctx?.logger?.warn?.(`report_outreach: epilogue failed: ${epilogueResult.error}`);
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
return {
|
|
2857
|
+
notes: {
|
|
2858
|
+
succeeded: noteResults.filter((r) => r.ok).map((r) => ({ lead_id: r.lead_id, note_id: r.note_id })),
|
|
2859
|
+
failed: noteResults.filter((r) => !r.ok).map((r) => ({ lead_id: r.lead_id, error: r.error }))
|
|
2860
|
+
},
|
|
2861
|
+
epilogue: {
|
|
2862
|
+
status: epilogueWire,
|
|
2863
|
+
...epilogueResult
|
|
2864
|
+
},
|
|
2865
|
+
verification: params.verification,
|
|
2866
|
+
_meta: { region: client.region }
|
|
2867
|
+
};
|
|
2868
|
+
}
|
|
2869
|
+
};
|
|
2870
|
+
|
|
2871
|
+
// ../core/dist/index.js
|
|
2872
|
+
var granularReadTools = [
|
|
2873
|
+
listLenses,
|
|
2874
|
+
discoverLeads,
|
|
2875
|
+
getLeadProfile,
|
|
2876
|
+
getLeadActivities,
|
|
2877
|
+
getTasteProfile,
|
|
2878
|
+
getContacts,
|
|
2879
|
+
getQuota,
|
|
2880
|
+
getLensFilter,
|
|
2881
|
+
getLensScoring,
|
|
2882
|
+
listSectors,
|
|
2883
|
+
getUserPrompt,
|
|
2884
|
+
getClarification,
|
|
2885
|
+
getLeadNotes,
|
|
2886
|
+
getEpilogueResponses,
|
|
2887
|
+
getProspectingActions,
|
|
2888
|
+
getWebFetch,
|
|
2889
|
+
getSelectionIds,
|
|
2890
|
+
getEnrichmentJobTitles
|
|
2891
|
+
];
|
|
2892
|
+
var granularWriteTools = [
|
|
2893
|
+
qualifyLead,
|
|
2894
|
+
enrichContacts,
|
|
2895
|
+
addNote,
|
|
2896
|
+
selectLeads,
|
|
2897
|
+
deselectLeads,
|
|
2898
|
+
clearSelection,
|
|
2899
|
+
setActiveLens,
|
|
2900
|
+
createLens,
|
|
2901
|
+
updateLens,
|
|
2902
|
+
updateLensFilter,
|
|
2903
|
+
createLensDraft,
|
|
2904
|
+
promoteLens,
|
|
2905
|
+
setUserPrompt,
|
|
2906
|
+
clearUserPrompt,
|
|
2907
|
+
pickClarification,
|
|
2908
|
+
dismissClarification,
|
|
2909
|
+
setEpilogueStatus,
|
|
2910
|
+
removeEpilogue,
|
|
2911
|
+
previewBulkEnrichment,
|
|
2912
|
+
launchBulkEnrichment
|
|
2913
|
+
];
|
|
2914
|
+
var granularTools = [
|
|
2915
|
+
login,
|
|
2916
|
+
...granularReadTools,
|
|
2917
|
+
...granularWriteTools
|
|
2918
|
+
];
|
|
2919
|
+
granularTools.forEach((t) => {
|
|
2920
|
+
t.advanced = true;
|
|
2921
|
+
});
|
|
2922
|
+
var compositeReadTools = [
|
|
2923
|
+
pullLeads,
|
|
2924
|
+
researchLead,
|
|
2925
|
+
recallOrderedTitles,
|
|
2926
|
+
accountStatus,
|
|
2927
|
+
// Keep the existing composites available too.
|
|
2928
|
+
researchCompany,
|
|
2929
|
+
prepareOutreach
|
|
2930
|
+
];
|
|
2931
|
+
var compositeWriteTools = [
|
|
2932
|
+
bulkQualifyLeads,
|
|
2933
|
+
enrichTitles,
|
|
2934
|
+
adjustAudience,
|
|
2935
|
+
refinePrompt,
|
|
2936
|
+
answerClarification,
|
|
2937
|
+
reportOutreach
|
|
2938
|
+
];
|
|
2939
|
+
var compositeTools = [
|
|
2940
|
+
...compositeReadTools,
|
|
2941
|
+
...compositeWriteTools
|
|
2942
|
+
];
|
|
2943
|
+
var tools = [...compositeTools, ...granularTools];
|
|
2944
|
+
|
|
2945
|
+
export {
|
|
2946
|
+
REGIONS,
|
|
2947
|
+
createClient,
|
|
2948
|
+
resolveRegion,
|
|
2949
|
+
getMockJournal,
|
|
2950
|
+
clearMockJournal,
|
|
2951
|
+
LeadbayClient,
|
|
2952
|
+
login,
|
|
2953
|
+
listLenses,
|
|
2954
|
+
discoverLeads,
|
|
2955
|
+
getLeadProfile,
|
|
2956
|
+
getContacts,
|
|
2957
|
+
getQuota,
|
|
2958
|
+
getTasteProfile,
|
|
2959
|
+
qualifyLead,
|
|
2960
|
+
enrichContacts,
|
|
2961
|
+
addNote,
|
|
2962
|
+
getLeadActivities,
|
|
2963
|
+
getLensFilter,
|
|
2964
|
+
getLensScoring,
|
|
2965
|
+
listSectors,
|
|
2966
|
+
getUserPrompt,
|
|
2967
|
+
getClarification,
|
|
2968
|
+
getLeadNotes,
|
|
2969
|
+
getEpilogueResponses,
|
|
2970
|
+
getProspectingActions,
|
|
2971
|
+
getWebFetch,
|
|
2972
|
+
getSelectionIds,
|
|
2973
|
+
getEnrichmentJobTitles,
|
|
2974
|
+
selectLeads,
|
|
2975
|
+
deselectLeads,
|
|
2976
|
+
clearSelection,
|
|
2977
|
+
setActiveLens,
|
|
2978
|
+
createLens,
|
|
2979
|
+
updateLens,
|
|
2980
|
+
updateLensFilter,
|
|
2981
|
+
createLensDraft,
|
|
2982
|
+
promoteLens,
|
|
2983
|
+
setUserPrompt,
|
|
2984
|
+
clearUserPrompt,
|
|
2985
|
+
pickClarification,
|
|
2986
|
+
dismissClarification,
|
|
2987
|
+
setEpilogueStatus,
|
|
2988
|
+
removeEpilogue,
|
|
2989
|
+
previewBulkEnrichment,
|
|
2990
|
+
launchBulkEnrichment,
|
|
2991
|
+
researchCompany,
|
|
2992
|
+
prepareOutreach,
|
|
2993
|
+
pullLeads,
|
|
2994
|
+
researchLead,
|
|
2995
|
+
recallOrderedTitles,
|
|
2996
|
+
accountStatus,
|
|
2997
|
+
bulkQualifyLeads,
|
|
2998
|
+
enrichTitles,
|
|
2999
|
+
adjustAudience,
|
|
3000
|
+
refinePrompt,
|
|
3001
|
+
answerClarification,
|
|
3002
|
+
reportOutreach,
|
|
3003
|
+
granularReadTools,
|
|
3004
|
+
granularWriteTools,
|
|
3005
|
+
granularTools,
|
|
3006
|
+
compositeReadTools,
|
|
3007
|
+
compositeWriteTools,
|
|
3008
|
+
compositeTools,
|
|
3009
|
+
tools
|
|
3010
|
+
};
|