@sellable/mcp 0.1.257 → 0.1.259
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index-dev.js +0 -0
- package/dist/index.js +0 -0
- package/dist/server.js +4 -1
- package/dist/tools/csv-dnc.d.ts +36 -0
- package/dist/tools/csv-dnc.js +94 -2
- package/dist/tools/engage-discovery.d.ts +0 -21
- package/dist/tools/engage-discovery.js +9 -136
- package/dist/tools/leads.d.ts +381 -21
- package/dist/tools/leads.js +219 -5
- package/dist/tools/registry.d.ts +207 -23
- package/package.json +1 -1
- package/skills/create-campaign/SKILL.md +6 -0
- package/skills/create-campaign-v2/references/filter-leads.md +2 -0
- package/skills/create-campaign-v2/references/lead-validation-preview.md +2 -0
- package/skills/create-campaign-v2/references/step-13-import-leads.md +3 -1
- package/skills/create-post/SKILL.md +21 -67
- package/skills/create-post/references/gold-standard-post-pack.md +0 -11
- package/skills/create-post/references/hook-research-playbook.md +15 -205
- package/skills/create-post/references/post-file-contract.md +0 -12
- package/skills/create-post/references/post-validation.md +14 -101
- package/skills/find-leads/SKILL.md +6 -0
- package/skills/create-post/references/linkedin-preview-rendering.md +0 -163
- package/skills/research/config.json +0 -9
package/dist/tools/leads.js
CHANGED
|
@@ -4,7 +4,7 @@ import { getApi, SellableApiError } from "../api.js";
|
|
|
4
4
|
import { resolveSkillsDir } from "../skills.js";
|
|
5
5
|
import { resolveWorkspaceRoot } from "../utils/workspace-root.js";
|
|
6
6
|
import { buildCsvDomainPreview, matchesConfirmationToken, parseConfirmationToken, projectCsvCarryRows, } from "./csv-domains.js";
|
|
7
|
-
import { loadCsvDncEntries, } from "./csv-dnc.js";
|
|
7
|
+
import { listDncEntries, loadCsvDncEntries, } from "./csv-dnc.js";
|
|
8
8
|
import { buildCsvLinkedinPreview, matchesLinkedinConfirmationToken, parseLinkedinConfirmationToken, uploadCsvLinkedinFile, } from "./csv-linkedin.js";
|
|
9
9
|
import { assertInteractionApproval } from "./interaction-mode.js";
|
|
10
10
|
import { assertProviderPromptLoaded, markProviderPromptLoaded, } from "./provider-preflight.js";
|
|
@@ -1149,6 +1149,109 @@ Approval card should say:
|
|
|
1149
1149
|
targetEngagerCount: effectiveTargetEngagerCount,
|
|
1150
1150
|
};
|
|
1151
1151
|
}
|
|
1152
|
+
function hasUsableSignalValidationPost(post) {
|
|
1153
|
+
if (!post)
|
|
1154
|
+
return false;
|
|
1155
|
+
const likes = post.engagement?.likes ?? 0;
|
|
1156
|
+
const comments = post.engagement?.comments ?? 0;
|
|
1157
|
+
const shares = post.engagement?.shares ?? 0;
|
|
1158
|
+
return Boolean(post.url ||
|
|
1159
|
+
post.content?.trim() ||
|
|
1160
|
+
post.author?.name?.trim() ||
|
|
1161
|
+
likes > 0 ||
|
|
1162
|
+
comments > 0 ||
|
|
1163
|
+
shares > 0);
|
|
1164
|
+
}
|
|
1165
|
+
function signalImportPostKey(post) {
|
|
1166
|
+
return normalizePostUrl(post.url) || post.url.trim().toLowerCase();
|
|
1167
|
+
}
|
|
1168
|
+
function compactSignalValidationReason(value) {
|
|
1169
|
+
const text = typeof value === "string"
|
|
1170
|
+
? value
|
|
1171
|
+
: value instanceof Error
|
|
1172
|
+
? value.message
|
|
1173
|
+
: "";
|
|
1174
|
+
const compacted = text.replace(/\s+/g, " ").trim();
|
|
1175
|
+
return compacted
|
|
1176
|
+
? compacted.slice(0, 220)
|
|
1177
|
+
: "Post lookup returned no usable post details.";
|
|
1178
|
+
}
|
|
1179
|
+
function describeSignalPostValidationError(error) {
|
|
1180
|
+
if (error instanceof SellableApiError) {
|
|
1181
|
+
try {
|
|
1182
|
+
const parsed = JSON.parse(error.body);
|
|
1183
|
+
return compactSignalValidationReason(parsed.details ?? parsed.error);
|
|
1184
|
+
}
|
|
1185
|
+
catch {
|
|
1186
|
+
return compactSignalValidationReason(error.body);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return compactSignalValidationReason(error);
|
|
1190
|
+
}
|
|
1191
|
+
function invalidSignalPostExamples(posts) {
|
|
1192
|
+
return posts
|
|
1193
|
+
.slice(0, 3)
|
|
1194
|
+
.map((post) => {
|
|
1195
|
+
const label = post.author.name || post.keyword || "Selected post";
|
|
1196
|
+
return `${label}: ${post.url}`;
|
|
1197
|
+
})
|
|
1198
|
+
.join("; ");
|
|
1199
|
+
}
|
|
1200
|
+
function serializeInvalidSignalPosts(posts) {
|
|
1201
|
+
return posts.map((post) => ({
|
|
1202
|
+
id: post.id,
|
|
1203
|
+
url: post.url,
|
|
1204
|
+
authorName: post.author.name,
|
|
1205
|
+
keyword: post.keyword,
|
|
1206
|
+
reason: post.reason,
|
|
1207
|
+
}));
|
|
1208
|
+
}
|
|
1209
|
+
async function validateSelectedSignalPostsForImport(api, posts) {
|
|
1210
|
+
const results = await Promise.all(posts.map(async (post) => {
|
|
1211
|
+
if (!post.url.trim()) {
|
|
1212
|
+
return {
|
|
1213
|
+
post,
|
|
1214
|
+
valid: false,
|
|
1215
|
+
reason: "Selected post is missing a LinkedIn URL.",
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
try {
|
|
1219
|
+
const response = await api.post("/api/v1/signal-discovery/search-signals", {
|
|
1220
|
+
type: "post",
|
|
1221
|
+
postUrl: post.url,
|
|
1222
|
+
});
|
|
1223
|
+
const valid = response.success !== false &&
|
|
1224
|
+
Array.isArray(response.posts) &&
|
|
1225
|
+
response.posts.some(hasUsableSignalValidationPost);
|
|
1226
|
+
return valid
|
|
1227
|
+
? { post, valid: true }
|
|
1228
|
+
: {
|
|
1229
|
+
post,
|
|
1230
|
+
valid: false,
|
|
1231
|
+
reason: "Post lookup returned no usable post details.",
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
catch (error) {
|
|
1235
|
+
if (error instanceof SellableApiError &&
|
|
1236
|
+
(error.isAuthError || error.status === 429 || error.guidance)) {
|
|
1237
|
+
throw error;
|
|
1238
|
+
}
|
|
1239
|
+
return {
|
|
1240
|
+
post,
|
|
1241
|
+
valid: false,
|
|
1242
|
+
reason: describeSignalPostValidationError(error),
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
}));
|
|
1246
|
+
return {
|
|
1247
|
+
validPosts: results
|
|
1248
|
+
.filter((result) => result.valid)
|
|
1249
|
+
.map((result) => result.post),
|
|
1250
|
+
invalidPosts: results
|
|
1251
|
+
.filter((result) => !result.valid)
|
|
1252
|
+
.map((result) => ({ ...result.post, reason: result.reason })),
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1152
1255
|
function normalizeImportProvider(provider) {
|
|
1153
1256
|
if (provider === "apollo-ai" || provider === "apollo")
|
|
1154
1257
|
return "apollo";
|
|
@@ -1477,6 +1580,41 @@ export const leadToolDefinitions = [
|
|
|
1477
1580
|
required: ["filePath"],
|
|
1478
1581
|
},
|
|
1479
1582
|
},
|
|
1583
|
+
{
|
|
1584
|
+
name: "list_dnc_entries",
|
|
1585
|
+
description: "Show the active workspace's current Sellable DNC count, list names, and a compact page of entries through the workspace-scoped DNC endpoint. Use this when the user asks to show/check the current DNC list, count, or first page before importing. This is Sellable DNC used by DNC Check, not provider source-list routing.",
|
|
1586
|
+
inputSchema: {
|
|
1587
|
+
type: "object",
|
|
1588
|
+
properties: {
|
|
1589
|
+
page: {
|
|
1590
|
+
type: "number",
|
|
1591
|
+
description: "Page to read. Defaults to 1.",
|
|
1592
|
+
},
|
|
1593
|
+
limit: {
|
|
1594
|
+
type: "number",
|
|
1595
|
+
description: "Entries per page. Defaults to 25. Backend hard cap is 100.",
|
|
1596
|
+
},
|
|
1597
|
+
search: {
|
|
1598
|
+
type: "string",
|
|
1599
|
+
description: "Optional search across domain, LinkedIn username, or entry name.",
|
|
1600
|
+
},
|
|
1601
|
+
listName: {
|
|
1602
|
+
type: "string",
|
|
1603
|
+
description: "Optional Sellable DNC list name filter.",
|
|
1604
|
+
},
|
|
1605
|
+
sourceType: {
|
|
1606
|
+
type: "string",
|
|
1607
|
+
enum: ["all", "manual", "hubspot"],
|
|
1608
|
+
description: "Optional source filter. Defaults to all Sellable DNC entries.",
|
|
1609
|
+
},
|
|
1610
|
+
includeDeleted: {
|
|
1611
|
+
type: "boolean",
|
|
1612
|
+
description: "Include deleted/disabled source entries when true. Defaults to false.",
|
|
1613
|
+
},
|
|
1614
|
+
},
|
|
1615
|
+
required: [],
|
|
1616
|
+
},
|
|
1617
|
+
},
|
|
1480
1618
|
{
|
|
1481
1619
|
name: "load_csv_dnc_entries",
|
|
1482
1620
|
description: "Preview and confirm pasted text, pasted CSV, or a CSV file of domains and LinkedIn profile URLs, then add the valid entries to the active workspace's Sellable DNC list. This is the blocklist/suppression-list path. It reuses Sellable's workspace-scoped DNC endpoints and the campaign DNC Check column, not provider search filters or source-list workarounds.",
|
|
@@ -1928,6 +2066,10 @@ export const leadToolDefinitions = [
|
|
|
1928
2066
|
type: "number",
|
|
1929
2067
|
description: "Signal Discovery: optional hard cap on selected posts to scrape after ranking selected posts by engagement. Values above 10 are clamped to the backend hard cap.",
|
|
1930
2068
|
},
|
|
2069
|
+
allowInvalidSignalPosts: {
|
|
2070
|
+
type: "boolean",
|
|
2071
|
+
description: "Signal Discovery: set true only after the user explicitly approves continuing when import_leads reports that one or more planned posts no longer fetch. Invalid posts are skipped before scraping.",
|
|
2072
|
+
},
|
|
1931
2073
|
rubricGuidelines: {
|
|
1932
2074
|
type: "array",
|
|
1933
2075
|
items: { type: "string" },
|
|
@@ -3384,6 +3526,9 @@ export async function loadCsvLinkedinLeads(input) {
|
|
|
3384
3526
|
export async function loadCsvDncEntriesTool(input) {
|
|
3385
3527
|
return loadCsvDncEntries(input);
|
|
3386
3528
|
}
|
|
3529
|
+
export async function listDncEntriesTool(input = {}) {
|
|
3530
|
+
return listDncEntries(input);
|
|
3531
|
+
}
|
|
3387
3532
|
export async function saveDomainFilters(input) {
|
|
3388
3533
|
const api = getApi();
|
|
3389
3534
|
return api.post(`/api/v3/prospeo/domain-filters`, {
|
|
@@ -3458,7 +3603,7 @@ export async function searchSignals(input) {
|
|
|
3458
3603
|
}
|
|
3459
3604
|
export async function importLeads(input) {
|
|
3460
3605
|
const api = getApi();
|
|
3461
|
-
const { campaignOfferId, currentStep, sourceLeadListId: inputSourceLeadListId, searchId, targetLeadCount, mode, searchName, leadListName, headlineICPCriteria, targetEngagerCount, maxPostsToScrape, rubricGuidelines, confirmed, } = input;
|
|
3606
|
+
const { campaignOfferId, currentStep, sourceLeadListId: inputSourceLeadListId, searchId, targetLeadCount, mode, searchName, leadListName, headlineICPCriteria, targetEngagerCount, maxPostsToScrape, allowInvalidSignalPosts, rubricGuidelines, confirmed, } = input;
|
|
3462
3607
|
assertInteractionApproval({
|
|
3463
3608
|
campaignId: campaignOfferId,
|
|
3464
3609
|
action: "import-leads",
|
|
@@ -3640,17 +3785,84 @@ export async function importLeads(input) {
|
|
|
3640
3785
|
if (uniqueSelectedPosts.length > MAX_SIGNAL_DISCOVERY_POSTS) {
|
|
3641
3786
|
throw new Error(`Maximum ${MAX_SIGNAL_DISCOVERY_POSTS} Signal Discovery posts can be imported for scraping. ${uniqueSelectedPosts.length} unique posts are currently selected; reduce the selected posts to the strongest ${MAX_SIGNAL_DISCOVERY_POSTS} before calling import_leads.`);
|
|
3642
3787
|
}
|
|
3643
|
-
|
|
3788
|
+
let importSelection = selectSignalPostsForImport(uniqueSelectedPosts, {
|
|
3644
3789
|
targetEngagerCount: effectiveTargetEngagerCount,
|
|
3645
3790
|
maxPostsToScrape,
|
|
3646
3791
|
});
|
|
3647
|
-
|
|
3792
|
+
let postsToScrape = importSelection.posts;
|
|
3648
3793
|
if (!importSelection.targetReached && importSelection.targetEngagerCount) {
|
|
3649
3794
|
const capClause = importSelection.availableEngagers >= importSelection.targetEngagerCount
|
|
3650
3795
|
? ` The selected posts can cover the target, but maxPostsToScrape=${normalizePositiveInteger(maxPostsToScrape)} prevents reaching it. Increase maxPostsToScrape or remove that cap.`
|
|
3651
3796
|
: " Select/promote more right-content posts, run another narrow Signal Discovery search, or switch to Sales Nav recent activity if the lane cannot produce enough source candidates.";
|
|
3652
3797
|
throw new Error(`Signal Discovery selected posts only cover about ${importSelection.estimatedEngagers.toLocaleString("en-US")} people to check, below the approved ${importSelection.targetEngagerCount.toLocaleString("en-US")} source-candidate target. Do not scrape this under-capacity post set.${capClause}`);
|
|
3653
3798
|
}
|
|
3799
|
+
const postValidation = await validateSelectedSignalPostsForImport(api, uniqueSelectedPosts);
|
|
3800
|
+
const plannedPostKeys = new Set(postsToScrape.map(signalImportPostKey));
|
|
3801
|
+
const invalidPlannedPosts = postValidation.invalidPosts.filter((post) => plannedPostKeys.has(signalImportPostKey(post)));
|
|
3802
|
+
const skippedInvalidPostWarning = postValidation.invalidPosts.length > 0
|
|
3803
|
+
? `Skipped ${postValidation.invalidPosts.length.toLocaleString("en-US")} invalid selected post${postValidation.invalidPosts.length === 1 ? "" : "s"} after validation.`
|
|
3804
|
+
: "";
|
|
3805
|
+
if (postValidation.invalidPosts.length > 0 &&
|
|
3806
|
+
postValidation.validPosts.length === 0) {
|
|
3807
|
+
return {
|
|
3808
|
+
provider: "signal-discovery",
|
|
3809
|
+
error: "all_signal_posts_invalid",
|
|
3810
|
+
message: `I checked ${uniqueSelectedPosts.length.toLocaleString("en-US")} selected LinkedIn post${uniqueSelectedPosts.length === 1 ? "" : "s"} before scraping, and none of them currently fetch. Do not call import_leads again with this selected-post set. Run another narrow Signal Discovery search or select replacement posts, then call select_promising_posts before retrying import_leads. Invalid: ${invalidSignalPostExamples(postValidation.invalidPosts)}`,
|
|
3811
|
+
invalidPostCount: postValidation.invalidPosts.length,
|
|
3812
|
+
invalidPosts: serializeInvalidSignalPosts(postValidation.invalidPosts),
|
|
3813
|
+
validSelectedPostCount: 0,
|
|
3814
|
+
recommendedValidPostCount: 0,
|
|
3815
|
+
estimatedValidEngagers: 0,
|
|
3816
|
+
targetEngagerCount: effectiveTargetEngagerCount,
|
|
3817
|
+
suggestedToolCalls: [],
|
|
3818
|
+
};
|
|
3819
|
+
}
|
|
3820
|
+
if (invalidPlannedPosts.length > 0 && !allowInvalidSignalPosts) {
|
|
3821
|
+
const validImportSelection = selectSignalPostsForImport(postValidation.validPosts, {
|
|
3822
|
+
targetEngagerCount: effectiveTargetEngagerCount,
|
|
3823
|
+
maxPostsToScrape,
|
|
3824
|
+
});
|
|
3825
|
+
const validRecommendedCount = validImportSelection.posts.length;
|
|
3826
|
+
const shortfallCopy = validImportSelection.targetEngagerCount &&
|
|
3827
|
+
!validImportSelection.targetReached
|
|
3828
|
+
? ` The remaining valid selected posts cover about ${validImportSelection.estimatedEngagers.toLocaleString("en-US")} of the approved ${validImportSelection.targetEngagerCount.toLocaleString("en-US")} people-to-check target.`
|
|
3829
|
+
: "";
|
|
3830
|
+
const continueCopy = postValidation.validPosts.length > 0
|
|
3831
|
+
? `Continue anyway with ${validRecommendedCount.toLocaleString("en-US")} valid post${validRecommendedCount === 1 ? "" : "s"}, or replace the invalid post${invalidPlannedPosts.length === 1 ? "" : "s"} first?`
|
|
3832
|
+
: "No valid selected posts remain. Replace the invalid posts before importing leads.";
|
|
3833
|
+
return {
|
|
3834
|
+
provider: "signal-discovery",
|
|
3835
|
+
needsInvalidPostConfirmation: true,
|
|
3836
|
+
invalidPostCount: invalidPlannedPosts.length,
|
|
3837
|
+
invalidPosts: serializeInvalidSignalPosts(invalidPlannedPosts),
|
|
3838
|
+
validSelectedPostCount: postValidation.validPosts.length,
|
|
3839
|
+
recommendedValidPostCount: validRecommendedCount,
|
|
3840
|
+
estimatedValidEngagers: validImportSelection.estimatedEngagers,
|
|
3841
|
+
targetEngagerCount: effectiveTargetEngagerCount,
|
|
3842
|
+
message: `I checked ${uniqueSelectedPosts.length.toLocaleString("en-US")} selected LinkedIn post${uniqueSelectedPosts.length === 1 ? "" : "s"} before scraping. ${invalidPlannedPosts.length.toLocaleString("en-US")} planned post${invalidPlannedPosts.length === 1 ? "" : "s"} no longer fetch${invalidPlannedPosts.length === 1 ? "es" : ""}, so starting the scrape as-is could break the flow. ${continueCopy}${shortfallCopy} Invalid: ${invalidSignalPostExamples(invalidPlannedPosts)}`,
|
|
3843
|
+
suggestedToolCalls: postValidation.validPosts.length > 0
|
|
3844
|
+
? [
|
|
3845
|
+
{
|
|
3846
|
+
tool: "import_leads",
|
|
3847
|
+
args: {
|
|
3848
|
+
...input,
|
|
3849
|
+
campaignOfferId,
|
|
3850
|
+
provider: "signal-discovery",
|
|
3851
|
+
confirmed: true,
|
|
3852
|
+
allowInvalidSignalPosts: true,
|
|
3853
|
+
},
|
|
3854
|
+
},
|
|
3855
|
+
]
|
|
3856
|
+
: [],
|
|
3857
|
+
};
|
|
3858
|
+
}
|
|
3859
|
+
if (allowInvalidSignalPosts && postValidation.invalidPosts.length > 0) {
|
|
3860
|
+
importSelection = selectSignalPostsForImport(postValidation.validPosts, {
|
|
3861
|
+
targetEngagerCount: effectiveTargetEngagerCount,
|
|
3862
|
+
maxPostsToScrape,
|
|
3863
|
+
});
|
|
3864
|
+
postsToScrape = importSelection.posts;
|
|
3865
|
+
}
|
|
3654
3866
|
const effectiveHeadlineICPCriteria = headlineICPCriteria && headlineICPCriteria.length > 0
|
|
3655
3867
|
? headlineICPCriteria
|
|
3656
3868
|
: rubricGuidelines;
|
|
@@ -3692,8 +3904,10 @@ export async function importLeads(input) {
|
|
|
3692
3904
|
targetEngagerCount: effectiveTargetEngagerCount,
|
|
3693
3905
|
maxPostsToScrape: normalizePositiveInteger(maxPostsToScrape) ?? null,
|
|
3694
3906
|
limitedSelectedPosts: importSelection.limited,
|
|
3907
|
+
invalidSelectedPostCount: postValidation.invalidPosts.length,
|
|
3908
|
+
warnings: skippedInvalidPostWarning ? [skippedInvalidPostWarning] : [],
|
|
3695
3909
|
targetLeadCount: cappedTargetLeadCount ?? null,
|
|
3696
|
-
message: `Started scraping ${postsToScrape.length} posts (up to ~${result.estimatedEngagers} people to check). Leads will appear as scraping completes.${importSelection.limited
|
|
3910
|
+
message: `${skippedInvalidPostWarning ? `${skippedInvalidPostWarning} ` : ""}Started scraping ${postsToScrape.length} posts (up to ~${result.estimatedEngagers} people to check). Leads will appear as scraping completes.${importSelection.limited
|
|
3697
3911
|
? ` Limited from ${uniqueSelectedPosts.length} selected posts by the approved source-capacity scrape plan.`
|
|
3698
3912
|
: ""} The watched campaign has been moved to confirm-lead-list with import progress copy. Keep calling wait_for_lead_list_ready until it reports ready, failed, or cancelled before confirm_lead_list; only confirm a partial list with allowPartialSourceList after the user explicitly asks to keep going early. Do not call update_campaign to fix that step.`,
|
|
3699
3913
|
};
|