@sellable/mcp 0.1.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.
Files changed (200) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/.mcp.json +9 -0
  3. package/README.md +355 -0
  4. package/dist/api.d.ts +21 -0
  5. package/dist/api.js +73 -0
  6. package/dist/auth.d.ts +60 -0
  7. package/dist/auth.js +246 -0
  8. package/dist/engage-memory.d.ts +63 -0
  9. package/dist/engage-memory.js +354 -0
  10. package/dist/index-dev.d.ts +2 -0
  11. package/dist/index-dev.js +17 -0
  12. package/dist/index.d.ts +7 -0
  13. package/dist/index.js +8 -0
  14. package/dist/server.d.ts +1 -0
  15. package/dist/server.js +499 -0
  16. package/dist/skills.d.ts +11 -0
  17. package/dist/skills.js +97 -0
  18. package/dist/tools/auth.d.ts +30 -0
  19. package/dist/tools/auth.js +124 -0
  20. package/dist/tools/blueprint-commit.d.ts +174 -0
  21. package/dist/tools/blueprint-commit.js +286 -0
  22. package/dist/tools/bootstrap.d.ts +64 -0
  23. package/dist/tools/bootstrap.js +246 -0
  24. package/dist/tools/campaigns.d.ts +589 -0
  25. package/dist/tools/campaigns.js +892 -0
  26. package/dist/tools/cells.d.ts +58 -0
  27. package/dist/tools/cells.js +48 -0
  28. package/dist/tools/context.d.ts +88 -0
  29. package/dist/tools/context.js +271 -0
  30. package/dist/tools/csv-domains.d.ts +73 -0
  31. package/dist/tools/csv-domains.js +464 -0
  32. package/dist/tools/csv-linkedin.d.ts +102 -0
  33. package/dist/tools/csv-linkedin.js +712 -0
  34. package/dist/tools/direct-campaigns.d.ts +240 -0
  35. package/dist/tools/direct-campaigns.js +250 -0
  36. package/dist/tools/engage-bootstrap.d.ts +94 -0
  37. package/dist/tools/engage-bootstrap.js +205 -0
  38. package/dist/tools/engage-discovery.d.ts +78 -0
  39. package/dist/tools/engage-discovery.js +150 -0
  40. package/dist/tools/engage-memory.d.ts +181 -0
  41. package/dist/tools/engage-memory.js +143 -0
  42. package/dist/tools/engage-state.d.ts +72 -0
  43. package/dist/tools/engage-state.js +62 -0
  44. package/dist/tools/enrichment.d.ts +167 -0
  45. package/dist/tools/enrichment.js +174 -0
  46. package/dist/tools/flow-preflight.d.ts +68 -0
  47. package/dist/tools/flow-preflight.js +138 -0
  48. package/dist/tools/framework.d.ts +44 -0
  49. package/dist/tools/framework.js +153 -0
  50. package/dist/tools/interaction-mode.d.ts +27 -0
  51. package/dist/tools/interaction-mode.js +102 -0
  52. package/dist/tools/leads.d.ts +2417 -0
  53. package/dist/tools/leads.js +2307 -0
  54. package/dist/tools/linkedin.d.ts +210 -0
  55. package/dist/tools/linkedin.js +229 -0
  56. package/dist/tools/navigation.d.ts +91 -0
  57. package/dist/tools/navigation.js +381 -0
  58. package/dist/tools/one-off.d.ts +229 -0
  59. package/dist/tools/one-off.js +273 -0
  60. package/dist/tools/processing.d.ts +70 -0
  61. package/dist/tools/processing.js +56 -0
  62. package/dist/tools/prompts.d.ts +211 -0
  63. package/dist/tools/prompts.js +210 -0
  64. package/dist/tools/provider-preflight.d.ts +21 -0
  65. package/dist/tools/provider-preflight.js +59 -0
  66. package/dist/tools/readiness.d.ts +261 -0
  67. package/dist/tools/readiness.js +510 -0
  68. package/dist/tools/rows.d.ts +126 -0
  69. package/dist/tools/rows.js +105 -0
  70. package/dist/tools/rubrics.d.ts +497 -0
  71. package/dist/tools/rubrics.js +681 -0
  72. package/dist/tools/senders.d.ts +44 -0
  73. package/dist/tools/senders.js +69 -0
  74. package/dist/tools/sequencer.d.ts +127 -0
  75. package/dist/tools/sequencer.js +194 -0
  76. package/dist/tools/tables.d.ts +35 -0
  77. package/dist/tools/tables.js +36 -0
  78. package/dist/tools/verify-row.d.ts +36 -0
  79. package/dist/tools/verify-row.js +38 -0
  80. package/dist/tools/workspaces.d.ts +140 -0
  81. package/dist/tools/workspaces.js +139 -0
  82. package/dist/utils/workspace-root.d.ts +1 -0
  83. package/dist/utils/workspace-root.js +39 -0
  84. package/package.json +46 -0
  85. package/skills/building-gtm-tables/SKILL.md +216 -0
  86. package/skills/building-gtm-tables/core/auto-execute.yaml +19 -0
  87. package/skills/building-gtm-tables/core/blueprint-schema.json +72 -0
  88. package/skills/building-gtm-tables/references/brief-to-blueprint.md +334 -0
  89. package/skills/building-gtm-tables/references/column-type-catalog.md +318 -0
  90. package/skills/building-gtm-tables/references/common-blueprints.fixtures.ts +199 -0
  91. package/skills/building-gtm-tables/references/common-blueprints.md +44 -0
  92. package/skills/building-gtm-tables/references/failure-taxonomy.md +197 -0
  93. package/skills/building-gtm-tables/references/uat-seed-prompts.md +37 -0
  94. package/skills/building-gtm-tables/references/verify-loop.md +74 -0
  95. package/skills/campaign-messages/SKILL.md +173 -0
  96. package/skills/campaign-messages/flow.v1.json +75 -0
  97. package/skills/craft-message/SKILL.md +401 -0
  98. package/skills/create-campaign/ARCHITECTURE.md +232 -0
  99. package/skills/create-campaign/DISCUSS.md +296 -0
  100. package/skills/create-campaign/FLOW_ASCII.md +240 -0
  101. package/skills/create-campaign/HOST-PARITY-CHECKLIST.md +49 -0
  102. package/skills/create-campaign/README.md +142 -0
  103. package/skills/create-campaign/SKILL.md +286 -0
  104. package/skills/create-campaign/context/README.md +67 -0
  105. package/skills/create-campaign/context/_TEMPLATE.md +12 -0
  106. package/skills/create-campaign/context/context.md +35 -0
  107. package/skills/create-campaign/context/learnings.md +16 -0
  108. package/skills/create-campaign/context/registry.json +19 -0
  109. package/skills/create-campaign/core/flow.v1.json +217 -0
  110. package/skills/create-campaign/core/policy.md +191 -0
  111. package/skills/create-campaign/core/providers/apollo.json +35 -0
  112. package/skills/create-campaign/core/providers/prospeo.json +34 -0
  113. package/skills/create-campaign/core/providers/registry.json +31 -0
  114. package/skills/create-campaign/core/providers/sales-nav.json +37 -0
  115. package/skills/create-campaign/core/providers/signal-discovery.json +42 -0
  116. package/skills/create-campaign/references/brief-template.md +64 -0
  117. package/skills/create-campaign/references/campaign-quality.md +84 -0
  118. package/skills/create-campaign/references/copy-calibration-examples.md +120 -0
  119. package/skills/create-campaign/references/offer-patterns.md +108 -0
  120. package/skills/create-campaign/references/provider-selection-strategy.md +212 -0
  121. package/skills/create-campaign/references/question-examples.md +167 -0
  122. package/skills/create-campaign/references/token-fill-examples.md +81 -0
  123. package/skills/create-campaign-brief/ARCHITECTURE.md +72 -0
  124. package/skills/create-campaign-brief/DISCUSS.md +64 -0
  125. package/skills/create-campaign-brief/README.md +176 -0
  126. package/skills/create-campaign-brief/SKILL.md +537 -0
  127. package/skills/create-campaign-brief/references/brief-synthesis-rules.md +100 -0
  128. package/skills/create-campaign-brief/references/brief-template.md +220 -0
  129. package/skills/create-campaign-brief/references/campaign-idea-options.md +30 -0
  130. package/skills/create-campaign-brief/references/copy-appendix-template.md +62 -0
  131. package/skills/create-campaign-brief/references/draft-lifecycle.md +23 -0
  132. package/skills/create-campaign-brief/references/examples/MANIFEST.json +89 -0
  133. package/skills/create-campaign-brief/references/examples/briefs/clover.md +223 -0
  134. package/skills/create-campaign-brief/references/examples/briefs/galley.md +222 -0
  135. package/skills/create-campaign-brief/references/examples/briefs/gelee.md +220 -0
  136. package/skills/create-campaign-brief/references/examples/briefs/hey-digital.md +234 -0
  137. package/skills/create-campaign-brief/references/examples/briefs/persona.md +231 -0
  138. package/skills/create-campaign-brief/references/examples/briefs/revvix.md +220 -0
  139. package/skills/create-campaign-brief/references/examples/briefs/sellable-dev.md +220 -0
  140. package/skills/create-campaign-brief/references/examples/briefs/superposition.md +233 -0
  141. package/skills/create-campaign-brief/references/examples/briefs/superpower.md +219 -0
  142. package/skills/create-campaign-brief/references/examples/briefs/westpark-villas.md +220 -0
  143. package/skills/create-campaign-brief/references/icp-lock-question-bank.md +43 -0
  144. package/skills/create-campaign-brief/references/messaging-inputs.md +58 -0
  145. package/skills/create-campaign-brief/references/output-acceptance-rubric.md +62 -0
  146. package/skills/create-campaign-brief/references/phase75-active-runtime-message-pack.md +248 -0
  147. package/skills/create-campaign-brief/references/phase75-canonical-brief-template.md +319 -0
  148. package/skills/create-campaign-brief/references/phase75-good-brief-and-messaging-examples.md +445 -0
  149. package/skills/create-campaign-brief/references/quick-research-protocol.md +39 -0
  150. package/skills/create-campaign-brief/references/reference-sheet-protocol.md +60 -0
  151. package/skills/create-campaign-brief/references/zero-shot-iteration-rules.md +66 -0
  152. package/skills/create-campaign-v2/SKILL.md +1619 -0
  153. package/skills/create-campaign-v2/core/auto-execute.README.md +219 -0
  154. package/skills/create-campaign-v2/core/auto-execute.yaml +121 -0
  155. package/skills/create-campaign-v2/core/flow.v2.json +1643 -0
  156. package/skills/create-campaign-v2/core/policy.md +82 -0
  157. package/skills/create-campaign-v2/references/ai-tells.md +253 -0
  158. package/skills/create-campaign-v2/references/approval-gate-framing.md +346 -0
  159. package/skills/create-campaign-v2/references/draft-lifecycle.md +110 -0
  160. package/skills/create-campaign-v2/references/escalation-ladder.md +119 -0
  161. package/skills/create-campaign-v2/references/filter-leads.md +495 -0
  162. package/skills/create-campaign-v2/references/final-handoff-contract.md +176 -0
  163. package/skills/create-campaign-v2/references/gold-standard-message-examples.md +394 -0
  164. package/skills/create-campaign-v2/references/gold-standard-message-patterns.md +314 -0
  165. package/skills/create-campaign-v2/references/gold-standard-message-validation-example.md +212 -0
  166. package/skills/create-campaign-v2/references/lead-validation-preview.md +172 -0
  167. package/skills/create-campaign-v2/references/parallel-critique-protocol.md +368 -0
  168. package/skills/create-campaign-v2/references/sample-validation-loop.md +289 -0
  169. package/skills/create-campaign-v2/references/step-13-import-leads.md +151 -0
  170. package/skills/create-campaign-v2/references/step-15-re-cascade.md +90 -0
  171. package/skills/create-campaign-v2/references/thomas-revision-filters.md +521 -0
  172. package/skills/create-campaign-v2/references/thomas-variant-selection.md +202 -0
  173. package/skills/create-campaign-v2/references/tier-routing-matrix.md +66 -0
  174. package/skills/create-campaign-v2/references/validation-criteria.md +367 -0
  175. package/skills/create-campaign-v2/references/watch-link-handoff.md +106 -0
  176. package/skills/create-campaign-v2-validation/SKILL.md +296 -0
  177. package/skills/create-post/SKILL.md +1308 -0
  178. package/skills/create-rubric/SKILL.md +251 -0
  179. package/skills/engage/SKILL.md +549 -0
  180. package/skills/engage/core/README.md +23 -0
  181. package/skills/engage/core/proven-searches.json +11 -0
  182. package/skills/engage/core/style-guide.template.md +47 -0
  183. package/skills/engage/core/tracked-people.json +10 -0
  184. package/skills/enrich-prospects/SKILL.md +97 -0
  185. package/skills/find-leads/SKILL.md +467 -0
  186. package/skills/generate-messages/SKILL.md +2361 -0
  187. package/skills/interview/SKILL.md +132 -0
  188. package/skills/interview/core/ENGAGE_STYLE_GUIDE.template.md +54 -0
  189. package/skills/interview/core/ICP.template.md +54 -0
  190. package/skills/interview/core/VOICE_PROFILE.template.md +101 -0
  191. package/skills/providers/apollo.md +520 -0
  192. package/skills/providers/prospeo.md +398 -0
  193. package/skills/providers/sales-nav.md +372 -0
  194. package/skills/providers/signal-discovery.md +495 -0
  195. package/skills/research/SKILL.md +258 -0
  196. package/skills/research/config.json +9 -0
  197. package/skills/research/override.md +13 -0
  198. package/skills/research-prospect/SKILL.md +99 -0
  199. package/skills/research-sender/SKILL.md +158 -0
  200. package/skills/workflow-sequences/SKILL.md +85 -0
@@ -0,0 +1,2307 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { dirname, join, resolve } from "path";
3
+ import { getApi, SellableApiError } from "../api.js";
4
+ import { resolveWorkspaceRoot } from "../utils/workspace-root.js";
5
+ import { buildCsvDomainPreview, matchesConfirmationToken, parseConfirmationToken, projectCsvCarryRows, } from "./csv-domains.js";
6
+ import { buildCsvLinkedinPreview, matchesLinkedinConfirmationToken, parseLinkedinConfirmationToken, uploadCsvLinkedinFile, } from "./csv-linkedin.js";
7
+ import { assertInteractionApproval } from "./interaction-mode.js";
8
+ import { assertProviderPromptLoaded, markProviderPromptLoaded, } from "./provider-preflight.js";
9
+ import { waitForLeadListReady } from "./readiness.js";
10
+ const entryPath = process.argv[1] ? resolve(process.argv[1]) : process.cwd();
11
+ const entryDir = dirname(entryPath);
12
+ const workspaceRoot = resolveWorkspaceRoot(entryDir);
13
+ const signalProviderConfigPath = join(workspaceRoot, "mcp/sellable/skills/create-campaign/core/providers/signal-discovery.json");
14
+ const leadImportLimitsPath = join(workspaceRoot, "lead-import-limits.json");
15
+ const defaultLeadImportLimits = {
16
+ apollo: { maxImportCount: 2500 },
17
+ "sales-nav": { maxImportCount: 2500 },
18
+ prospeo: { maxImportCount: 10000 },
19
+ };
20
+ const defaultSignalDiscoveryConfig = {
21
+ selection: {
22
+ promisingPostsTarget: 3,
23
+ },
24
+ };
25
+ const prospeoFilterValueSchema = {
26
+ type: "object",
27
+ description: "Include/exclude list filter (values must match Prospeo enums)",
28
+ properties: {
29
+ include: { type: "array", items: { type: "string" } },
30
+ exclude: { type: "array", items: { type: "string" } },
31
+ },
32
+ };
33
+ const prospeoRangeFilterSchema = {
34
+ type: "object",
35
+ description: "Numeric range filter (min/max)",
36
+ properties: {
37
+ min: { type: "number" },
38
+ max: { type: "number" },
39
+ },
40
+ };
41
+ const prospeoDateRangeFilterSchema = {
42
+ type: "object",
43
+ description: "Date range filter (YYYY-MM-DD)",
44
+ properties: {
45
+ min: { type: "string" },
46
+ max: { type: "string" },
47
+ },
48
+ };
49
+ const prospeoJobTitleSchema = {
50
+ type: "object",
51
+ description: "Job title filter (use include/exclude OR boolean_search, not both)",
52
+ properties: {
53
+ include: { type: "array", items: { type: "string" } },
54
+ exclude: { type: "array", items: { type: "string" } },
55
+ boolean_search: { type: "string" },
56
+ match_only_exact_job_titles: { type: "boolean" },
57
+ },
58
+ };
59
+ const prospeoContactDetailsSchema = {
60
+ type: "object",
61
+ description: "Contact details filter (email/mobile availability)",
62
+ properties: {
63
+ email: { type: "array", items: { type: "string" } },
64
+ mobile: { type: "array", items: { type: "string" } },
65
+ operator: { type: "string", enum: ["OR", "AND"] },
66
+ hide_people_with_details_already_revealed: { type: "boolean" },
67
+ },
68
+ };
69
+ const prospeoJobChangeSchema = {
70
+ type: "object",
71
+ description: "Recent job change filter",
72
+ properties: {
73
+ timeframe_days: { type: "number" },
74
+ only_promotion: { type: "boolean" },
75
+ only_new_company: { type: "boolean" },
76
+ },
77
+ };
78
+ const prospeoFundingAmountRangeSchema = {
79
+ type: "object",
80
+ description: "Funding amount range (<100K, 100K, 1M, 5M, etc.)",
81
+ properties: {
82
+ min: { type: "string" },
83
+ max: { type: "string" },
84
+ },
85
+ };
86
+ const prospeoFundingFilterSchema = {
87
+ type: "object",
88
+ description: "Company funding filters (stage array, funding_date days, last_funding/total_funding buckets)",
89
+ properties: {
90
+ stage: { type: "array", items: { type: "string" } },
91
+ funding_date: {
92
+ oneOf: [{ type: "number" }, prospeoDateRangeFilterSchema],
93
+ description: "Days since last funding (90/180/270/365)",
94
+ },
95
+ last_funding: prospeoFundingAmountRangeSchema,
96
+ total_funding: prospeoFundingAmountRangeSchema,
97
+ funding_stage: prospeoFilterValueSchema, // legacy
98
+ funding_amount: prospeoRangeFilterSchema, // legacy
99
+ },
100
+ };
101
+ const prospeoCompanyKeywordsSchema = {
102
+ type: "object",
103
+ description: "Company keyword search",
104
+ properties: {
105
+ include: { type: "array", items: { type: "string" } },
106
+ exclude: { type: "array", items: { type: "string" } },
107
+ include_all: { type: "boolean" },
108
+ include_company_description: { type: "boolean" },
109
+ include_company_description_seo: { type: "boolean" },
110
+ },
111
+ };
112
+ const prospeoCompanyAttributesSchema = {
113
+ type: "object",
114
+ description: "Company attributes (boolean flags)",
115
+ properties: {
116
+ b2b: { type: ["boolean", "null"] },
117
+ demo: { type: ["boolean", "null"] },
118
+ freetrial: { type: ["boolean", "null"] },
119
+ downloadable: { type: ["boolean", "null"] },
120
+ mobileapps: { type: ["boolean", "null"] },
121
+ onlinereviews: { type: ["boolean", "null"] },
122
+ pricing: { type: ["boolean", "null"] },
123
+ },
124
+ };
125
+ const prospeoCompanyRevenueSchema = {
126
+ type: "object",
127
+ description: "Company revenue range",
128
+ properties: {
129
+ min: { type: "string" },
130
+ max: { type: "string" },
131
+ include_unknown_revenue: { type: "boolean" },
132
+ },
133
+ };
134
+ const prospeoCompanyFoundedSchema = {
135
+ type: "object",
136
+ description: "Company founded year range",
137
+ properties: {
138
+ min: { type: "number" },
139
+ max: { type: "number" },
140
+ include_unknown_founded: { type: "boolean" },
141
+ },
142
+ };
143
+ const prospeoCompanyHeadcountGrowthSchema = {
144
+ type: "object",
145
+ description: "Company headcount growth",
146
+ properties: {
147
+ timeframe_month: { type: "number" },
148
+ min: { type: "number" },
149
+ max: { type: "number" },
150
+ departments: { type: "array", items: { type: "string" } },
151
+ },
152
+ };
153
+ const prospeoCompanyHeadcountByDepartmentSchema = {
154
+ type: "object",
155
+ description: "Headcount by department",
156
+ properties: {
157
+ department: { type: "string" },
158
+ min: { type: "number" },
159
+ max: { type: "number" },
160
+ },
161
+ };
162
+ function loadSignalDiscoveryConfig() {
163
+ if (!existsSync(signalProviderConfigPath)) {
164
+ return defaultSignalDiscoveryConfig;
165
+ }
166
+ try {
167
+ const raw = readFileSync(signalProviderConfigPath, "utf-8");
168
+ const parsed = JSON.parse(raw);
169
+ const selection = parsed.selection ?? {};
170
+ return {
171
+ selection: {
172
+ promisingPostsTarget: selection.promisingPostsTarget ??
173
+ defaultSignalDiscoveryConfig.selection.promisingPostsTarget,
174
+ },
175
+ };
176
+ }
177
+ catch {
178
+ return defaultSignalDiscoveryConfig;
179
+ }
180
+ }
181
+ function parseApiErrorBody(body) {
182
+ try {
183
+ return JSON.parse(body);
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }
189
+ function loadLeadImportLimits() {
190
+ if (!existsSync(leadImportLimitsPath)) {
191
+ return defaultLeadImportLimits;
192
+ }
193
+ try {
194
+ const raw = readFileSync(leadImportLimitsPath, "utf-8");
195
+ const parsed = JSON.parse(raw);
196
+ return {
197
+ ...defaultLeadImportLimits,
198
+ ...parsed,
199
+ };
200
+ }
201
+ catch {
202
+ return defaultLeadImportLimits;
203
+ }
204
+ }
205
+ function getMaxImportCount(provider) {
206
+ const limits = loadLeadImportLimits();
207
+ const max = limits[provider]?.maxImportCount;
208
+ if (typeof max === "number" && Number.isFinite(max) && max > 0) {
209
+ return max;
210
+ }
211
+ return defaultLeadImportLimits[provider].maxImportCount;
212
+ }
213
+ export function normalizeTargetLeadCount(targetLeadCount, maxImportCount) {
214
+ if (targetLeadCount === null || targetLeadCount === undefined) {
215
+ return undefined;
216
+ }
217
+ const numeric = typeof targetLeadCount === "number"
218
+ ? targetLeadCount
219
+ : Number(targetLeadCount);
220
+ if (!Number.isFinite(numeric) || numeric <= 0) {
221
+ return undefined;
222
+ }
223
+ return Math.min(numeric, maxImportCount);
224
+ }
225
+ function toDate(value) {
226
+ if (!value)
227
+ return null;
228
+ const date = new Date(value);
229
+ return Number.isNaN(date.getTime()) ? null : date;
230
+ }
231
+ function recencyDays(postedAt) {
232
+ const date = toDate(postedAt);
233
+ if (!date)
234
+ return null;
235
+ const diffMs = Date.now() - date.getTime();
236
+ return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)));
237
+ }
238
+ function recencyWeight(days) {
239
+ if (days === null)
240
+ return 0.6;
241
+ // Use a fixed decay so "recent" stays first without extra config knobs.
242
+ const raw = Math.exp(-days / 30);
243
+ return Math.min(1, Math.max(0.3, raw));
244
+ }
245
+ function engagementScore(post) {
246
+ const likes = post.engagement?.likes ?? 0;
247
+ const comments = post.engagement?.comments ?? 0;
248
+ const shares = post.engagement?.shares ?? 0;
249
+ // Fixed weights: comments and shares signal stronger intent than likes.
250
+ return likes + comments * 2 + shares * 3;
251
+ }
252
+ function truncate(text, max = 220) {
253
+ if (!text)
254
+ return "";
255
+ const clean = text.replace(/\s+/g, " ").trim();
256
+ if (clean.length <= max)
257
+ return clean;
258
+ return `${clean.slice(0, max - 1)}...`;
259
+ }
260
+ function normalizePostUrl(url) {
261
+ if (!url)
262
+ return null;
263
+ try {
264
+ const parsed = new URL(url);
265
+ parsed.hash = "";
266
+ parsed.search = "";
267
+ return `${parsed.origin}${parsed.pathname}`.toLowerCase();
268
+ }
269
+ catch {
270
+ return url.trim().toLowerCase();
271
+ }
272
+ }
273
+ function rankSignalPosts(posts, config) {
274
+ void config;
275
+ return posts
276
+ .map((post) => {
277
+ const days = recencyDays(post.postedAt);
278
+ const recency = recencyWeight(days);
279
+ const engage = engagementScore(post);
280
+ const engageLog = Math.log1p(engage);
281
+ const aiScore = post.aiScore ?? 50;
282
+ const score = engageLog * 12 * recency + aiScore * 1.5;
283
+ return {
284
+ ...post,
285
+ _score: score,
286
+ _engagementScore: engage,
287
+ _recencyDays: days,
288
+ _recencyWeight: recency,
289
+ };
290
+ })
291
+ .sort((a, b) => b._score - a._score);
292
+ }
293
+ function summarizeSignalPost(post) {
294
+ const likes = post.engagement?.likes ?? 0;
295
+ const comments = post.engagement?.comments ?? 0;
296
+ const shares = post.engagement?.shares ?? 0;
297
+ return {
298
+ id: post.id,
299
+ url: post.url,
300
+ matchedKeyword: post.matchedKeyword ?? null,
301
+ authorName: post.author?.name ?? "",
302
+ authorHeadline: post.author?.headline ?? "",
303
+ authorProfileUrl: post.author?.profileUrl ?? "",
304
+ postedAt: post.postedAt ?? null,
305
+ recencyDays: post._recencyDays,
306
+ aiScore: post.aiScore ?? null,
307
+ engagement: {
308
+ likes,
309
+ comments,
310
+ shares,
311
+ weighted: post._engagementScore,
312
+ },
313
+ score: Number(post._score.toFixed(2)),
314
+ excerpt: truncate(post.content),
315
+ };
316
+ }
317
+ function summarizeSignalSearchResponse(response) {
318
+ const config = loadSignalDiscoveryConfig();
319
+ const posts = response.posts ?? [];
320
+ const ranked = rankSignalPosts(posts, config);
321
+ const selectionTarget = Math.max(1, config.selection.promisingPostsTarget);
322
+ const recommendedPostIds = ranked.slice(0, selectionTarget).map((p) => p.id);
323
+ // Include top posts preview so LLM can see content and make informed selections
324
+ const TOP_POSTS_PREVIEW_COUNT = 15;
325
+ const topPosts = ranked
326
+ .slice(0, TOP_POSTS_PREVIEW_COUNT)
327
+ .map(summarizeSignalPost);
328
+ const keywordResults = (response.keywordResults ?? []).map((kw) => ({
329
+ keyword: kw.keyword,
330
+ postCount: kw.postCount,
331
+ avgEngagement: kw.avgEngagement ?? null,
332
+ hasMore: kw.hasMore ?? false,
333
+ error: kw.error ?? null,
334
+ }));
335
+ return {
336
+ success: response.success === true,
337
+ totalPosts: response.totalPosts ?? posts.length,
338
+ postsReturned: posts.length,
339
+ selectionTarget,
340
+ recommendedPostIds,
341
+ topPosts,
342
+ keywordResults,
343
+ notes: [
344
+ `Showing top ${topPosts.length} posts by score. Review excerpts to pick the best ones.`,
345
+ `Select about ${selectionTarget} posts, then call select_promising_posts.`,
346
+ ],
347
+ };
348
+ }
349
+ export const leadToolDefinitions = [
350
+ {
351
+ name: "get_provider_prompt",
352
+ description: "Get the search instructions for a lead source provider and register provider prompt preflight readiness. Must be called before provider search/import tools.",
353
+ inputSchema: {
354
+ type: "object",
355
+ properties: {
356
+ provider: {
357
+ type: "string",
358
+ enum: ["apollo", "sales-nav", "prospeo", "signal-discovery"],
359
+ description: "Provider to get instructions for: apollo, sales-nav, prospeo, or signal-discovery",
360
+ },
361
+ campaignOfferId: {
362
+ type: "string",
363
+ description: "Optional campaign offer ID for campaign-scoped preflight tracking.",
364
+ },
365
+ confirmed: {
366
+ type: "boolean",
367
+ description: "Set true after user approval when interaction mode requires confirmation for this action.",
368
+ },
369
+ },
370
+ required: ["provider"],
371
+ },
372
+ },
373
+ {
374
+ name: "search_apollo",
375
+ description: "Search Apollo for people/contacts. Requires get_provider_prompt({ provider: \"apollo\" }) first. Supports two modes: 'people' (default) searches contacts directly, 'combined' (RECOMMENDED for B2B) first finds companies then contacts at those companies. Auto-detects combined mode when company filters are provided.",
376
+ inputSchema: {
377
+ type: "object",
378
+ properties: {
379
+ searchMode: {
380
+ type: "string",
381
+ enum: ["people", "combined"],
382
+ description: "Search mode: 'people' for direct contact search, 'combined' (auto-detected when company filters present) to find companies first then contacts. Combined is RECOMMENDED for B2B lead generation.",
383
+ },
384
+ // ============ COMPANY FILTERS (for combined search) ============
385
+ // These filters will auto-trigger combined mode if provided in filters object
386
+ organization_num_employees_ranges: {
387
+ type: "array",
388
+ items: { type: "string" },
389
+ description: "Employee count ranges. IMPORTANT: Use ONLY these exact valid ranges: '1,10' (1-10 employees), '11,50' (11-50), '51,200' (51-200), '201,500' (201-500), '501,1000' (501-1000), '1001,5000' (1001-5000), '5001,10000' (5001-10000), '10000+' (10000+). Combine multiple: ['11,50', '51,200'] for 11-200 employees.",
390
+ },
391
+ organization_locations: {
392
+ type: "array",
393
+ items: { type: "string" },
394
+ description: "Company HQ locations. Can be cities, US states, or countries. Examples: ['texas', 'tokyo', 'spain']",
395
+ },
396
+ organization_not_locations: {
397
+ type: "array",
398
+ items: { type: "string" },
399
+ description: "Locations to exclude. Examples: ['minnesota', 'ireland', 'seoul']",
400
+ },
401
+ q_organization_keyword_tags: {
402
+ type: "array",
403
+ items: { type: "string" },
404
+ description: "Keywords associated with companies. Examples: ['mining', 'sales strategy', 'consulting']. IMPORTANT: Use this instead of q_keywords when doing combined search - q_keywords doesn't work in combined mode.",
405
+ },
406
+ organization_industry_tag_ids: {
407
+ type: "array",
408
+ items: { type: "string" },
409
+ description: "Apollo industry tag IDs to filter companies by.",
410
+ },
411
+ // ============ FUNDING FILTERS (triggers combined mode) ============
412
+ latest_funding_amount_min: {
413
+ type: "number",
414
+ description: "**Use for Series A/B/C filtering**. Minimum amount raised in MOST RECENT round (integer). TYPICAL RANGES BY STAGE:\n- Seed: $500K-$2M → use 500000\n- Series A: $2M-$15M → use 2000000\n- Series B: $15M-$50M → use 15000000\n- Series C+: $50M+ → use 50000000\nExample: For 'Series A companies', use 2000000",
415
+ },
416
+ latest_funding_amount_max: {
417
+ type: "number",
418
+ description: "Maximum amount raised in MOST RECENT round (integer). Use to cap funding stage:\n- Seed max: 2000000\n- Series A max: 15000000\n- Series B max: 50000000\nExample: For 'Series A only', use min: 2000000, max: 15000000",
419
+ },
420
+ // ============ REVENUE FILTERS (triggers combined mode) ============
421
+ organization_revenue_min: {
422
+ type: "number",
423
+ description: "**Use for ARR/revenue filtering**. Minimum annual revenue (integer only). Examples: 1000000 for '$1M ARR', 10000000 for '$10M+ revenue'. Use when user mentions ARR, revenue, or revenue thresholds.",
424
+ },
425
+ organization_revenue_max: {
426
+ type: "number",
427
+ description: "Maximum annual revenue (integer only). ONLY use if user explicitly wants to exclude larger companies (e.g., 'under $50M revenue'). Omit unless user specifies upper limit.",
428
+ },
429
+ // ============ CONTACT/PERSON FILTERS ============
430
+ person_titles: {
431
+ type: "array",
432
+ items: { type: "string" },
433
+ description: "Job titles to find. Results include similar titles by default. Examples: ['sales development representative', 'marketing manager', 'cto']. For precision, provide comprehensive title variations.",
434
+ },
435
+ person_seniorities: {
436
+ type: "array",
437
+ items: { type: "string" },
438
+ description: "Seniority levels (current position only). Valid options: ['owner', 'founder', 'c_suite', 'partner', 'vp', 'head', 'director', 'manager', 'senior', 'entry', 'intern']",
439
+ },
440
+ person_locations: {
441
+ type: "array",
442
+ items: { type: "string" },
443
+ description: "Where people live (not company HQ). Examples: ['california', 'ireland', 'chicago']",
444
+ },
445
+ include_similar_titles: {
446
+ type: "boolean",
447
+ description: "Set to FALSE for precision with comprehensive title list, TRUE for Apollo's fuzzy matching. Default: FALSE.",
448
+ },
449
+ q_keywords: {
450
+ type: "string",
451
+ description: "GOTCHA: Only works in 'people' mode. In combined search, this is auto-converted to q_organization_keyword_tags. For combined searches, use q_organization_keyword_tags directly instead.",
452
+ },
453
+ // ============ LEGACY NESTED FILTERS (still supported) ============
454
+ filters: {
455
+ type: "object",
456
+ description: "Alternative: pass all filters in a single object. Company filters will auto-trigger combined mode. Prefer flat parameters above for clarity.",
457
+ },
458
+ companyFilters: {
459
+ type: "object",
460
+ description: "Explicit company filters object for combined mode (alternative to flat params).",
461
+ },
462
+ contactFilters: {
463
+ type: "object",
464
+ description: "Explicit contact filters object for combined mode (alternative to flat params).",
465
+ },
466
+ // ============ PAGINATION & CONTEXT ============
467
+ page: {
468
+ type: "number",
469
+ description: "Page number (default 1)",
470
+ },
471
+ searchId: {
472
+ type: "string",
473
+ description: "Existing Apollo search ID for pagination",
474
+ },
475
+ campaignOfferId: {
476
+ type: "string",
477
+ description: "Campaign offer ID to associate search",
478
+ },
479
+ searchName: {
480
+ type: "string",
481
+ description: "REQUIRED: Descriptive name for this search. Include key filters. Examples: 'SaaS + Series A-B + 11-200 employees', 'Healthcare VPs + 51-500 employees'",
482
+ },
483
+ companySearchMode: {
484
+ type: "string",
485
+ description: "Domain search mode (ai | domainList)",
486
+ },
487
+ uploadedDomains: {
488
+ type: "array",
489
+ items: { type: "string" },
490
+ description: "Domains for domainList mode",
491
+ },
492
+ currentStep: {
493
+ type: ["string", "null"],
494
+ description: 'Headless workflow step ID. If omitted while in lead sourcing, defaults to "confirm-lead-list". Pass null to skip auto-advance.',
495
+ },
496
+ confirmed: {
497
+ type: "boolean",
498
+ description: "Set true after user approval when interaction mode requires confirmation for search.",
499
+ },
500
+ },
501
+ required: [],
502
+ },
503
+ },
504
+ {
505
+ name: "lookup_sales_nav_filter",
506
+ description: "Look up filter IDs for Sales Navigator dynamic filters. MUST be called before using REGION, INDUSTRY, CURRENT_TITLE, CURRENT_COMPANY, SCHOOL, or TECHNOLOGIES filters. Returns matching options with IDs that can be used in search_sales_nav.",
507
+ inputSchema: {
508
+ type: "object",
509
+ properties: {
510
+ filterType: {
511
+ type: "string",
512
+ enum: [
513
+ "REGION",
514
+ "GEOGRAPHY",
515
+ "HEADQUARTERS_LOCATION",
516
+ "INDUSTRY",
517
+ "CURRENT_TITLE",
518
+ "PAST_TITLE",
519
+ "CURRENT_COMPANY",
520
+ "PAST_COMPANY",
521
+ "SCHOOL",
522
+ ],
523
+ description: "Filter type to look up. Use REGION for geography (countries, states, cities).",
524
+ },
525
+ query: {
526
+ type: "string",
527
+ description: 'Search query for the filter (e.g., "United States", "Software", "CEO")',
528
+ },
529
+ },
530
+ required: ["filterType", "query"],
531
+ },
532
+ },
533
+ {
534
+ name: "search_sales_nav",
535
+ description: 'Search LinkedIn Sales Navigator. Requires get_provider_prompt({ provider: "sales-nav" }) first. Returns normalized results with pagination. `campaignOfferId` is optional for directional preview runs; include it only when the search should be associated with a campaign.',
536
+ inputSchema: {
537
+ type: "object",
538
+ properties: {
539
+ filters: {
540
+ type: "array",
541
+ description: "Sales Nav filter array",
542
+ },
543
+ keywords: {
544
+ type: "string",
545
+ description: "Optional keywords",
546
+ },
547
+ page: {
548
+ type: "number",
549
+ description: "Page number (default 1)",
550
+ },
551
+ searchId: {
552
+ type: "string",
553
+ description: "Existing Sales Nav search ID for pagination",
554
+ },
555
+ campaignOfferId: {
556
+ type: "string",
557
+ description: "Campaign offer ID to associate search",
558
+ },
559
+ searchName: {
560
+ type: "string",
561
+ description: "Optional name for the search",
562
+ },
563
+ currentStep: {
564
+ type: ["string", "null"],
565
+ description: "Headless workflow step ID",
566
+ },
567
+ confirmed: {
568
+ type: "boolean",
569
+ description: "Set true after user approval when interaction mode requires confirmation for search.",
570
+ },
571
+ },
572
+ required: ["filters"],
573
+ },
574
+ },
575
+ {
576
+ name: "load_csv_domains",
577
+ description: "Preview and confirm a CSV file that lives on disk, then create a Prospeo domainFilterId for account targeting. This is the CSV-on-disk path for known-account targeting. First call previews the file and returns a confirmationToken. Confirmed execution requires the preview-issued confirmationToken and then returns a domainFilterId you can pass into search_prospeo.",
578
+ inputSchema: {
579
+ type: "object",
580
+ properties: {
581
+ filePath: {
582
+ type: "string",
583
+ description: "Absolute path on the MCP host, or a workspace-relative path to the CSV file.",
584
+ },
585
+ campaignOfferId: {
586
+ type: "string",
587
+ description: "Optional campaign offer ID. When provided, success output includes direct follow-up calls for get_provider_prompt and search_prospeo.",
588
+ },
589
+ domainColumn: {
590
+ type: "string",
591
+ description: "Optional CSV header to use as the domain column. Required when the preview cannot auto-detect a strong domain header.",
592
+ },
593
+ selectedColumns: {
594
+ type: "array",
595
+ items: { type: "string" },
596
+ description: "Optional carryover columns to preserve with the domain filter. Max 20. Cannot include the chosen domain column.",
597
+ },
598
+ confirmationToken: {
599
+ type: "string",
600
+ description: "Preview-issued token that binds file path, file metadata, domainColumn, and selectedColumns.",
601
+ },
602
+ confirmed: {
603
+ type: "boolean",
604
+ description: "Set true only when confirming the preview with the matching confirmationToken.",
605
+ },
606
+ },
607
+ required: ["filePath"],
608
+ },
609
+ },
610
+ {
611
+ name: "load_csv_linkedin_leads",
612
+ description: "Preview and confirm a CSV file that lives on disk, then create or append to a lead list using LinkedIn profile URLs. This is the person-CSV path. First call previews the file and returns a confirmationToken. Confirmed execution requires the preview-issued confirmationToken, uploads valid LinkedIn profile rows into a lead list, and does NOT use the 1000-domain Prospeo cap.",
613
+ inputSchema: {
614
+ type: "object",
615
+ properties: {
616
+ filePath: {
617
+ type: "string",
618
+ description: "Absolute path on the MCP host, or a workspace-relative path to the CSV file.",
619
+ },
620
+ campaignOfferId: {
621
+ type: "string",
622
+ description: "Optional campaign offer ID. When provided, success output also updates selectedLeadListId so the campaign can review this list.",
623
+ },
624
+ currentStep: {
625
+ type: ["string", "null"],
626
+ description: "Optional workflow step to persist when selectedLeadListId is updated on the campaign.",
627
+ },
628
+ leadListId: {
629
+ type: "string",
630
+ description: "Optional existing lead list ID. When omitted, a new lead list is created.",
631
+ },
632
+ leadListName: {
633
+ type: "string",
634
+ description: "Optional lead list name. Used when creating a new list and also passed through as the uploaded listName.",
635
+ },
636
+ linkedInColumn: {
637
+ type: "string",
638
+ description: "Optional CSV header to use as the LinkedIn URL column. Required when preview cannot auto-detect a single strong/profile-url column.",
639
+ },
640
+ selectedColumns: {
641
+ type: "array",
642
+ items: { type: "string" },
643
+ description: "Optional source columns to carry into the lead list. Max 20. LinkedIn URL is always preserved and should not be included here.",
644
+ },
645
+ confirmationToken: {
646
+ type: "string",
647
+ description: "Preview-issued token that binds file path, file metadata, linkedInColumn, and selectedColumns.",
648
+ },
649
+ confirmed: {
650
+ type: "boolean",
651
+ description: "Set true only when confirming the preview with the matching confirmationToken.",
652
+ },
653
+ },
654
+ required: ["filePath"],
655
+ },
656
+ },
657
+ {
658
+ name: "save_domain_filters",
659
+ description: "Save pasted or raw include/exclude domain lists for Prospeo search. Validates domains and returns a domainFilterId to pass into search_prospeo. Use load_csv_domains when the user already has a CSV file on disk. If the user provides company names, resolve names to official website domains first, then call this tool.",
660
+ inputSchema: {
661
+ type: "object",
662
+ properties: {
663
+ include: {
664
+ type: "array",
665
+ items: { type: "string" },
666
+ description: "Company domains to include in search (e.g. ['stripe.com', 'notion.so']). Max 1000.",
667
+ },
668
+ exclude: {
669
+ type: "array",
670
+ items: { type: "string" },
671
+ description: "Company domains to exclude from search (e.g. ['competitor.com']). Max 1000.",
672
+ },
673
+ },
674
+ required: [],
675
+ },
676
+ },
677
+ {
678
+ name: "search_prospeo",
679
+ description: 'Search Prospeo for people using filters and optional domainFilterId. Requires get_provider_prompt({ provider: "prospeo" }) first. When targeting known accounts, call load_csv_domains for CSV-on-disk workflows or save_domain_filters for pasted/raw domain lists, then pass domainFilterId. Raw domain inputs and company-name targeting are NOT supported in this MCP tool. Strategy: start with 2-3 high-signal filters (title/seniority + industry or domainFilterId + headcount), then tighten one filter at a time. For security, AppSec, SOC, RevOps, Demand Gen, and similar function-specific lanes, do not widen with bare seniority labels like "Head" or "Director" alone; pair them with explicit function-title keywords and inspect the sample for off-function `Head of X` leakage. Prefer person location over company HQ unless HQ is explicitly needed. `campaignOfferId` routing rule: OMIT campaignOfferId ONLY in pre-mint Phase 84 `find leads` discovery mode (validating ICP before the commit gate). In every other context — post-mint lead additions, operator-driven searches on a live campaign, any search where the intent is to persist results to a specific campaign — you MUST pass campaignOfferId so the search shows up in that campaign\'s Contact Search panel. Omitting campaignOfferId post-mint orphans the search from the UI. Returns normalized results with pagination.',
680
+ inputSchema: {
681
+ type: "object",
682
+ properties: {
683
+ filters: {
684
+ type: "object",
685
+ description: "Prospeo search filters. Use include/exclude arrays for enum filters. For account targeting, use domainFilterId from load_csv_domains or save_domain_filters (do not send company.websites or company.names in MCP). Prefer comprehensive person_job_title include lists (synonyms + role variants) for role precision; use person_department + person_seniority as supporting constraints when title variance is high. In function-specific lanes, do not rely on bare `Head` / `Director` / `VP` widening without matching function keywords because Prospeo can leak off-function `Head of X` rows. Provide at least one filter or domainFilterId for new searches.",
686
+ properties: {
687
+ person_name: prospeoFilterValueSchema,
688
+ person_name_or_job_title: {
689
+ type: "string",
690
+ description: "Quick search across name + job title",
691
+ },
692
+ person_job_title: prospeoJobTitleSchema,
693
+ person_seniority: {
694
+ ...prospeoFilterValueSchema,
695
+ description: 'Seniority filter. EXACT values: "Founder/Owner", "C-Suite", "Partner", "Vice President", "Head", "Director", "Manager", "Senior", "Entry", "Intern". Common aliases like "VP", "Founder", "Owner" are auto-corrected server-side.',
696
+ },
697
+ person_department: {
698
+ ...prospeoFilterValueSchema,
699
+ description: 'Department filter. EXACT values: "C-Suite", "Product", "Engineering & Technical", "Design", "Education & Coaching", "Finance", "Human Resources", "Information Technology", "Legal", "Marketing", "Medical & Health", "Consulting", "Sales", "Operations". Common aliases like "Engineering", "IT", "HR" are auto-corrected server-side.',
700
+ },
701
+ person_year_of_experience: prospeoRangeFilterSchema,
702
+ person_time_in_current_role: prospeoRangeFilterSchema,
703
+ person_time_in_current_company: prospeoRangeFilterSchema,
704
+ person_location_search: prospeoFilterValueSchema,
705
+ person_contact_details: prospeoContactDetailsSchema,
706
+ person_job_change: prospeoJobChangeSchema,
707
+ max_person_per_company: { type: "number" },
708
+ company_industry: {
709
+ ...prospeoFilterValueSchema,
710
+ description: 'Industry filter (345+ values). Common: "Software Development", "IT Services and IT Consulting", "Technology, Information and Internet", "Financial Services", "Marketing Services", "Advertising Services", "SaaS" is NOT valid — use "Software Development" or "Technology, Information and Internet". Case-insensitive matching applied server-side.',
711
+ },
712
+ company_technology: {
713
+ ...prospeoFilterValueSchema,
714
+ description: "Technology filter (2000+ values, max 20 per search). Common: React, Next.js, Node.js, Python, AWS, Google Cloud, HubSpot, Salesforce, Shopify, WordPress, etc. Case-sensitive — use exact names.",
715
+ },
716
+ company_email_provider: {
717
+ type: "array",
718
+ items: { type: "string" },
719
+ description: 'MX/email provider filter. Common: "Google Workspace", "Microsoft 365", "Zoho Mail", "ProtonMail".',
720
+ },
721
+ company_naics: prospeoFilterValueSchema,
722
+ company_sics: prospeoFilterValueSchema,
723
+ company_headcount_range: {
724
+ type: "array",
725
+ items: { type: "string" },
726
+ description: 'Employee count ranges. EXACT values: "1-10", "11-20", "21-50", "51-100", "101-200", "201-500", "501-1000", "1001-2000", "2001-5000", "5001-10000", "10000+". Common aliases like "1-50", "500+" are auto-expanded server-side.',
727
+ },
728
+ company_headcount_custom: prospeoRangeFilterSchema,
729
+ company_location_search: prospeoFilterValueSchema,
730
+ company_type: {
731
+ type: "string",
732
+ description: 'Company type. EXACT values: "Private", "Public", "Non profit", "Other". Aliases like "nonprofit", "NGO" are auto-corrected server-side.',
733
+ },
734
+ company_funding: {
735
+ ...prospeoFundingFilterSchema,
736
+ description: 'Funding filters. stage values: "Pre seed", "Seed", "Series A", "Series B", "Series C", "Series D", "Series E-J", "Grant", "Angel", "Private equity", "Debt financing", "Convertible note", etc. Aliases like "pre-seed", "PE" are auto-corrected. funding_date: 30/60/90/180/270/365 days. last_funding/total_funding: string buckets like "<100K", "1M", "10M".',
737
+ },
738
+ company_keywords: prospeoCompanyKeywordsSchema,
739
+ company_attributes: prospeoCompanyAttributesSchema,
740
+ company_revenue: prospeoCompanyRevenueSchema,
741
+ company_founded: prospeoCompanyFoundedSchema,
742
+ company_headcount_growth: prospeoCompanyHeadcountGrowthSchema,
743
+ company_job_posting_hiring_for: {
744
+ type: "array",
745
+ items: { type: "string" },
746
+ },
747
+ company_job_posting_quantity: prospeoRangeFilterSchema,
748
+ company_headcount_by_department: {
749
+ type: "array",
750
+ items: prospeoCompanyHeadcountByDepartmentSchema,
751
+ },
752
+ },
753
+ },
754
+ page: {
755
+ type: "number",
756
+ description: "Page number (default 1)",
757
+ },
758
+ searchId: {
759
+ type: "string",
760
+ description: "Existing Prospeo search ID for pagination",
761
+ },
762
+ campaignOfferId: {
763
+ type: "string",
764
+ description: "Campaign offer ID to associate search",
765
+ },
766
+ searchName: {
767
+ type: "string",
768
+ description: "Optional name for the search. Use descriptive names that encode ICP intent (e.g., 'Fintech Compliance Directors 201-1000 US').",
769
+ },
770
+ domainFilterId: {
771
+ type: "string",
772
+ description: "ID from save_domain_filters. The search route resolves include/exclude domains from this ID.",
773
+ },
774
+ currentStep: {
775
+ type: ["string", "null"],
776
+ description: "Headless workflow step ID",
777
+ },
778
+ confirmed: {
779
+ type: "boolean",
780
+ description: "Set true after user approval when interaction mode requires confirmation for search.",
781
+ },
782
+ },
783
+ required: ["filters"],
784
+ },
785
+ },
786
+ {
787
+ name: "search_signals",
788
+ description: 'Search LinkedIn posts for signal discovery. Requires get_provider_prompt({ provider: "signal-discovery" }) first. Supports keyword search, profile posts, company posts, or a single post URL. Use campaignless preview mode for directional discovery; include `campaignOfferId` only when the search should persist into campaign state. Returns a compact summary with recommended posts to avoid context bloat.',
789
+ inputSchema: {
790
+ type: "object",
791
+ properties: {
792
+ type: {
793
+ type: "string",
794
+ enum: ["keywords", "profile", "post", "company"],
795
+ description: "Search mode: 'keywords' (default), 'profile' for a user's posts, 'post' for a single post URL, or 'company' for recent company posts.",
796
+ default: "keywords",
797
+ },
798
+ keywords: {
799
+ type: "array",
800
+ description: "Keyword objects to search (when type='keywords')",
801
+ items: {
802
+ type: "object",
803
+ properties: {
804
+ keyword: { type: "string" },
805
+ id: { type: "string" },
806
+ confidence: { type: ["number", "string"] },
807
+ page: { type: "number" },
808
+ },
809
+ required: ["keyword"],
810
+ },
811
+ },
812
+ profileUrl: {
813
+ type: "string",
814
+ description: "LinkedIn profile URL (when type='profile')",
815
+ },
816
+ postUrl: {
817
+ type: "string",
818
+ description: "LinkedIn post URL (when type='post')",
819
+ },
820
+ companyUrl: {
821
+ type: "string",
822
+ description: "LinkedIn company URL (when type='company')",
823
+ },
824
+ campaignOfferId: {
825
+ type: "string",
826
+ description: "Campaign offer ID to associate search. Optional for directional preview runs.",
827
+ },
828
+ currentStep: {
829
+ type: ["string", "null"],
830
+ description: "Headless workflow step ID",
831
+ },
832
+ page: {
833
+ type: "number",
834
+ description: "Page number (default 1). Used for keyword search.",
835
+ },
836
+ limit: {
837
+ type: "number",
838
+ description: "Max posts (default 25, max 50). Used for profile search.",
839
+ },
840
+ headlineICPCriteria: {
841
+ type: "array",
842
+ items: { type: "string" },
843
+ description: "Optional: Persist headline ICP criteria early so the UI can show filters if the user takes over mid-flow.",
844
+ },
845
+ rubricGuidelines: {
846
+ type: "array",
847
+ items: { type: "string" },
848
+ description: "Deprecated alias for headlineICPCriteria. Prefer headlineICPCriteria.",
849
+ },
850
+ confirmed: {
851
+ type: "boolean",
852
+ description: "Set true after user approval when interaction mode requires confirmation for search.",
853
+ },
854
+ },
855
+ required: [],
856
+ },
857
+ },
858
+ {
859
+ name: "import_leads",
860
+ description: "Create/select a lead list and start the provider import job. Requires provider prompt preflight via get_provider_prompt for the active provider. Sets selectedLeadListId to the lead list so the user can review it. Does NOT import into the campaign table. After the user confirms the list, call confirm_lead_list.",
861
+ inputSchema: {
862
+ type: "object",
863
+ properties: {
864
+ campaignOfferId: {
865
+ type: "string",
866
+ description: "Campaign offer ID",
867
+ },
868
+ provider: {
869
+ type: "string",
870
+ enum: ["apollo", "sales-nav", "prospeo", "signal-discovery"],
871
+ description: "Lead provider. Auto-detected from campaign.leadSourceProvider if not specified.",
872
+ },
873
+ currentStep: {
874
+ type: ["string", "null"],
875
+ description: "Headless workflow step ID",
876
+ },
877
+ sourceLeadListId: {
878
+ type: "string",
879
+ description: "Existing lead list ID to import from (skips search import)",
880
+ },
881
+ searchId: {
882
+ type: "string",
883
+ description: "Search ID for apollo or sales-nav import",
884
+ },
885
+ targetLeadCount: {
886
+ type: "number",
887
+ description: "Target number of leads (max per provider from config)",
888
+ },
889
+ mode: {
890
+ type: "string",
891
+ enum: ["add", "replace"],
892
+ description: "How to handle existing lead list rows. 'add' appends, 'replace' archives existing rows before import.",
893
+ },
894
+ searchName: {
895
+ type: "string",
896
+ description: "Name of the search (used for list naming)",
897
+ },
898
+ leadListName: {
899
+ type: "string",
900
+ description: "Override lead list name when creating a new one",
901
+ },
902
+ headlineICPCriteria: {
903
+ type: "array",
904
+ items: { type: "string" },
905
+ description: "Signal Discovery: headline ICP criteria (3-5 rules). Uses campaign.headlineICPCriteria if not provided.",
906
+ },
907
+ rubricGuidelines: {
908
+ type: "array",
909
+ items: { type: "string" },
910
+ description: "Deprecated alias for headlineICPCriteria. Prefer headlineICPCriteria.",
911
+ },
912
+ confirmed: {
913
+ type: "boolean",
914
+ description: "Set true after user approval when interaction mode requires confirmation for import.",
915
+ },
916
+ },
917
+ required: ["campaignOfferId"],
918
+ },
919
+ },
920
+ {
921
+ name: "cancel_lead_import",
922
+ description: "Cancel an in-flight Apollo, Prospeo, or Sales Nav lead import. Use when the operator realizes the current search/filter is wrong and wants to start over without waiting for the broad import to finish. Partial rows already inserted into the lead list are preserved. Provider credits are NOT refunded. After cancellation, call import_leads again with new filters. Signal Discovery cancellation is not supported.",
923
+ inputSchema: {
924
+ type: "object",
925
+ properties: {
926
+ campaignOfferId: {
927
+ type: "string",
928
+ description: "Campaign offer ID. Required for audit context; not used to resolve provider or tableId.",
929
+ },
930
+ tableId: {
931
+ type: "string",
932
+ description: "Lead list (workflow table) ID to cancel. Required; callers have this from campaign context.",
933
+ },
934
+ provider: {
935
+ type: "string",
936
+ enum: ["apollo", "prospeo", "sales-nav"],
937
+ description: "Provider to cancel. Required; callers have this from campaign context. Signal Discovery is not supported.",
938
+ },
939
+ },
940
+ required: ["campaignOfferId", "tableId", "provider"],
941
+ },
942
+ },
943
+ {
944
+ name: "confirm_lead_list",
945
+ description: "After the user confirms the lead list looks good, import it into the campaign table (clone) and update selectedLeadListId to the campaign table. Recommended post-confirm order: update_campaign(currentStep='filter-choice') -> wait_for_campaign_table_ready -> get_rows_minimal.",
946
+ inputSchema: {
947
+ type: "object",
948
+ properties: {
949
+ campaignOfferId: {
950
+ type: "string",
951
+ description: "Campaign offer ID",
952
+ },
953
+ sourceLeadListId: {
954
+ type: "string",
955
+ description: "Lead list ID to import into campaign. If omitted, uses campaign.selectedLeadListId.",
956
+ },
957
+ currentStep: {
958
+ type: ["string", "null"],
959
+ description: 'Headless workflow step ID. If omitted, defaults to "confirm-lead-list". Pass null to skip auto-advance.',
960
+ },
961
+ campaignName: {
962
+ type: "string",
963
+ description: "Optional campaign table name override",
964
+ },
965
+ keepInSync: {
966
+ type: "boolean",
967
+ description: "Keep campaign table synced to lead list",
968
+ },
969
+ jobId: {
970
+ type: "string",
971
+ description: "Import job ID (required for Sales Nav/Prospeo status checks).",
972
+ },
973
+ targetLeadCount: {
974
+ type: "number",
975
+ description: "Target lead count requested (used to validate completion when status is unavailable).",
976
+ },
977
+ confirmed: {
978
+ type: "boolean",
979
+ description: "Set true after user approval when interaction mode requires confirmation for confirm/import.",
980
+ },
981
+ },
982
+ required: ["campaignOfferId"],
983
+ },
984
+ },
985
+ {
986
+ name: "select_promising_posts",
987
+ description: "Select the most promising LinkedIn posts for lead scraping AND provide headline ICP criteria. Use the selectionTarget returned by search_signals (default 3).",
988
+ inputSchema: {
989
+ type: "object",
990
+ properties: {
991
+ campaignOfferId: {
992
+ type: "string",
993
+ description: "Campaign offer ID",
994
+ },
995
+ selections: {
996
+ type: "array",
997
+ description: "Array of promising posts to select for scraping. Prefer the recommendedPostIds from search_signals.",
998
+ items: {
999
+ type: "object",
1000
+ properties: {
1001
+ postId: {
1002
+ type: "string",
1003
+ description: "The post ID from search_signals results",
1004
+ },
1005
+ reason: {
1006
+ type: "string",
1007
+ description: "Why this post is promising (content relevance, author credibility, engagement volume, recency)",
1008
+ },
1009
+ },
1010
+ required: ["postId", "reason"],
1011
+ },
1012
+ minItems: 1,
1013
+ maxItems: 20,
1014
+ },
1015
+ headlineICPCriteria: {
1016
+ type: "array",
1017
+ description: "REQUIRED: 3-5 LinkedIn headline criteria for filtering engagers. Each should be a clear yes/no rule.",
1018
+ items: { type: "string" },
1019
+ minItems: 3,
1020
+ maxItems: 5,
1021
+ },
1022
+ selectionMode: {
1023
+ type: "string",
1024
+ enum: ["add", "replace"],
1025
+ description: 'How to apply selections. "add" keeps existing selections; "replace" clears previous selections not included in this call.',
1026
+ },
1027
+ currentStep: {
1028
+ type: ["string", "null"],
1029
+ description: "Headless workflow step ID",
1030
+ },
1031
+ },
1032
+ required: ["campaignOfferId", "selections", "headlineICPCriteria"],
1033
+ },
1034
+ },
1035
+ {
1036
+ name: "set_headline_icp_criteria",
1037
+ description: "Set 3-5 headline ICP criteria for filtering post engagers. Use when user selects posts manually or attempts to scrape without criteria.",
1038
+ inputSchema: {
1039
+ type: "object",
1040
+ properties: {
1041
+ campaignOfferId: {
1042
+ type: "string",
1043
+ description: "Campaign offer ID",
1044
+ },
1045
+ headlineICPCriteria: {
1046
+ type: "array",
1047
+ description: "Array of 3-5 yes/no headline criteria for identifying ICP fits.",
1048
+ items: { type: "string" },
1049
+ minItems: 3,
1050
+ maxItems: 5,
1051
+ },
1052
+ currentStep: {
1053
+ type: ["string", "null"],
1054
+ description: "Headless workflow step ID",
1055
+ },
1056
+ },
1057
+ required: ["campaignOfferId", "headlineICPCriteria"],
1058
+ },
1059
+ },
1060
+ ];
1061
+ // Company/organization filter keys that indicate combined search mode
1062
+ const COMPANY_FILTER_KEYS = [
1063
+ "organization_num_employees_ranges",
1064
+ "organization_locations",
1065
+ "organization_ids",
1066
+ "organization_not_ids",
1067
+ "q_organization_keyword_tags",
1068
+ "organization_industry_tag_ids",
1069
+ "latest_funding_amount_min",
1070
+ "latest_funding_amount_max",
1071
+ "latest_funding_stage_cd",
1072
+ "organization_revenue_min",
1073
+ "organization_revenue_max",
1074
+ ];
1075
+ function hasCompanyFilters(filters) {
1076
+ return COMPANY_FILTER_KEYS.some((key) => {
1077
+ const value = filters[key];
1078
+ if (value === undefined || value === null)
1079
+ return false;
1080
+ if (Array.isArray(value) && value.length === 0)
1081
+ return false;
1082
+ if (typeof value === "string" && value.trim() === "")
1083
+ return false;
1084
+ return true;
1085
+ });
1086
+ }
1087
+ function splitFilters(filters) {
1088
+ const companyFilters = {};
1089
+ const contactFilters = {};
1090
+ for (const [key, value] of Object.entries(filters)) {
1091
+ if (COMPANY_FILTER_KEYS.includes(key)) {
1092
+ companyFilters[key] = value;
1093
+ }
1094
+ else if (key === "q_keywords") {
1095
+ // GOTCHA: q_keywords doesn't work in combined search (causes 0 results)
1096
+ // Move it to q_organization_keyword_tags in company filters instead
1097
+ if (value && typeof value === "string" && value.trim()) {
1098
+ // Convert keyword string to tags array if not already using keyword tags
1099
+ if (!companyFilters["q_organization_keyword_tags"]) {
1100
+ companyFilters["q_organization_keyword_tags"] = value
1101
+ .split(",")
1102
+ .map((s) => s.trim())
1103
+ .filter(Boolean);
1104
+ }
1105
+ }
1106
+ // Don't add q_keywords to contactFilters - it breaks combined search
1107
+ }
1108
+ else {
1109
+ contactFilters[key] = value;
1110
+ }
1111
+ }
1112
+ return { companyFilters, contactFilters };
1113
+ }
1114
+ // Keys that are NOT filters (workflow/pagination params)
1115
+ const NON_FILTER_KEYS = new Set([
1116
+ "searchMode",
1117
+ "filters",
1118
+ "companyFilters",
1119
+ "contactFilters",
1120
+ "page",
1121
+ "searchId",
1122
+ "campaignOfferId",
1123
+ "searchName",
1124
+ "companySearchMode",
1125
+ "uploadedDomains",
1126
+ "currentStep",
1127
+ "confirmed",
1128
+ ]);
1129
+ function resolveCsvHeaderName(headers, requested) {
1130
+ const trimmed = requested.trim();
1131
+ if (!trimmed) {
1132
+ throw new Error("CSV column names must be non-empty strings.");
1133
+ }
1134
+ const exact = headers.find((header) => header === trimmed);
1135
+ if (exact) {
1136
+ return exact;
1137
+ }
1138
+ const lowered = trimmed.toLowerCase();
1139
+ const matches = headers.filter((header) => header.trim().toLowerCase() === lowered);
1140
+ if (matches.length === 1) {
1141
+ return matches[0];
1142
+ }
1143
+ throw new Error(`CSV column "${requested}" does not exist in the current preview. Re-run load_csv_domains preview.`);
1144
+ }
1145
+ function normalizeConfirmationPayload(payload) {
1146
+ return {
1147
+ ...payload,
1148
+ selectedColumns: [...payload.selectedColumns],
1149
+ };
1150
+ }
1151
+ function normalizeLinkedinConfirmationPayload(payload) {
1152
+ return {
1153
+ ...payload,
1154
+ selectedColumns: [...payload.selectedColumns],
1155
+ };
1156
+ }
1157
+ function compareStringArrays(left, right) {
1158
+ if (left.length !== right.length) {
1159
+ return false;
1160
+ }
1161
+ return left.every((value, index) => value === right[index]);
1162
+ }
1163
+ function buildCsvPreviewSuggestedToolCalls(input, token) {
1164
+ return [
1165
+ {
1166
+ tool: "load_csv_domains",
1167
+ args: {
1168
+ ...(input.campaignOfferId
1169
+ ? { campaignOfferId: input.campaignOfferId }
1170
+ : {}),
1171
+ filePath: input.filePath,
1172
+ confirmed: true,
1173
+ confirmationToken: token,
1174
+ },
1175
+ },
1176
+ ];
1177
+ }
1178
+ function buildCsvSearchSuggestedToolCalls(campaignOfferId, domainFilterId) {
1179
+ return [
1180
+ {
1181
+ tool: "get_provider_prompt",
1182
+ args: { provider: "prospeo", campaignOfferId },
1183
+ },
1184
+ {
1185
+ tool: "search_prospeo",
1186
+ args: { campaignOfferId, filters: {}, domainFilterId },
1187
+ },
1188
+ ];
1189
+ }
1190
+ function resolveCsvLinkedinHeaderName(headers, requested) {
1191
+ const trimmed = requested.trim();
1192
+ if (!trimmed) {
1193
+ throw new Error("CSV column names must be non-empty strings.");
1194
+ }
1195
+ const exact = headers.find((header) => header === trimmed);
1196
+ if (exact) {
1197
+ return exact;
1198
+ }
1199
+ const lowered = trimmed.toLowerCase();
1200
+ const matches = headers.filter((header) => header.trim().toLowerCase() === lowered);
1201
+ if (matches.length === 1) {
1202
+ return matches[0];
1203
+ }
1204
+ throw new Error(`CSV column "${requested}" does not exist in the current preview. Re-run load_csv_linkedin_leads preview.`);
1205
+ }
1206
+ function buildCsvLinkedinPreviewSuggestedToolCalls(input, token) {
1207
+ return [
1208
+ {
1209
+ tool: "load_csv_linkedin_leads",
1210
+ args: {
1211
+ ...(input.campaignOfferId
1212
+ ? { campaignOfferId: input.campaignOfferId }
1213
+ : {}),
1214
+ ...(input.currentStep !== undefined
1215
+ ? { currentStep: input.currentStep }
1216
+ : {}),
1217
+ ...(input.leadListId ? { leadListId: input.leadListId } : {}),
1218
+ ...(input.leadListName ? { leadListName: input.leadListName } : {}),
1219
+ filePath: input.filePath,
1220
+ confirmed: true,
1221
+ confirmationToken: token,
1222
+ },
1223
+ },
1224
+ ];
1225
+ }
1226
+ function buildCsvLinkedinConfirmSuggestedToolCalls(campaignOfferId, sourceLeadListId) {
1227
+ return [
1228
+ {
1229
+ tool: "confirm_lead_list",
1230
+ args: { campaignOfferId, sourceLeadListId },
1231
+ },
1232
+ ];
1233
+ }
1234
+ export async function searchApollo(input) {
1235
+ if (input?.campaignOfferId) {
1236
+ assertInteractionApproval({
1237
+ campaignId: input.campaignOfferId,
1238
+ action: "provider-search",
1239
+ confirmed: input.confirmed,
1240
+ });
1241
+ }
1242
+ assertProviderPromptLoaded({
1243
+ provider: "apollo",
1244
+ campaignOfferId: input?.campaignOfferId,
1245
+ });
1246
+ const api = getApi();
1247
+ const { searchMode: explicitSearchMode, filters: nestedFilters = {}, companyFilters: explicitCompanyFilters, contactFilters: explicitContactFilters, page, searchId, campaignOfferId, searchName, companySearchMode, uploadedDomains, currentStep, ...flatParams } = input || {};
1248
+ // Merge flat parameters with nested filters
1249
+ // Flat params take precedence (they're more explicit)
1250
+ const mergedFilters = {
1251
+ ...nestedFilters,
1252
+ };
1253
+ // Extract flat filters from input
1254
+ for (const [key, value] of Object.entries(flatParams)) {
1255
+ if (!NON_FILTER_KEYS.has(key) && value !== undefined && value !== null) {
1256
+ // Skip empty arrays and empty strings
1257
+ if (Array.isArray(value) && value.length === 0)
1258
+ continue;
1259
+ if (typeof value === "string" && value.trim() === "")
1260
+ continue;
1261
+ mergedFilters[key] = value;
1262
+ }
1263
+ }
1264
+ // Auto-detect combined mode if company filters are present
1265
+ const shouldUseCombined = explicitSearchMode === "combined" ||
1266
+ (explicitSearchMode !== "people" && hasCompanyFilters(mergedFilters));
1267
+ // Build the common request params (non-filter workflow params)
1268
+ const requestParams = {
1269
+ page,
1270
+ searchId,
1271
+ campaignOfferId,
1272
+ searchName,
1273
+ companySearchMode,
1274
+ uploadedDomains,
1275
+ currentStep,
1276
+ };
1277
+ if (shouldUseCombined) {
1278
+ // If explicit company/contact filters provided, use them
1279
+ // Otherwise, split the merged filters object
1280
+ let companyFilters = explicitCompanyFilters;
1281
+ let contactFilters = explicitContactFilters;
1282
+ if (!companyFilters && !contactFilters) {
1283
+ const split = splitFilters(mergedFilters);
1284
+ companyFilters = split.companyFilters;
1285
+ contactFilters = split.contactFilters;
1286
+ }
1287
+ // Combined search: find companies first, then contacts at those companies
1288
+ return api.post(`/api/v2/apollo/combined-search`, {
1289
+ companyFilters: companyFilters || {},
1290
+ contactFilters: contactFilters || {},
1291
+ ...requestParams,
1292
+ });
1293
+ }
1294
+ // Default: direct people search
1295
+ return api.post(`/api/v2/apollo/people-search`, {
1296
+ ...mergedFilters,
1297
+ ...requestParams,
1298
+ });
1299
+ }
1300
+ export async function searchSalesNav(input) {
1301
+ if (input?.campaignOfferId) {
1302
+ assertInteractionApproval({
1303
+ campaignId: input.campaignOfferId,
1304
+ action: "provider-search",
1305
+ confirmed: input.confirmed,
1306
+ });
1307
+ }
1308
+ assertProviderPromptLoaded({
1309
+ provider: "sales-nav",
1310
+ campaignOfferId: input?.campaignOfferId,
1311
+ });
1312
+ const api = getApi();
1313
+ return api.post(`/api/v3/sales-nav/search`, input);
1314
+ }
1315
+ export async function searchProspeo(input) {
1316
+ if (input?.campaignOfferId) {
1317
+ assertInteractionApproval({
1318
+ campaignId: input.campaignOfferId,
1319
+ action: "provider-search",
1320
+ confirmed: input.confirmed,
1321
+ });
1322
+ }
1323
+ assertProviderPromptLoaded({
1324
+ provider: "prospeo",
1325
+ campaignOfferId: input?.campaignOfferId,
1326
+ });
1327
+ const api = getApi();
1328
+ const rawInput = input;
1329
+ if ("domains" in rawInput) {
1330
+ throw new Error("search_prospeo does not accept raw domains. Use save_domain_filters and pass domainFilterId.");
1331
+ }
1332
+ const nestedCompany = input?.filters
1333
+ ?.company;
1334
+ if (nestedCompany?.websites !== undefined ||
1335
+ nestedCompany?.names !== undefined) {
1336
+ throw new Error("search_prospeo does not accept filters.company.websites or filters.company.names. Resolve names to domains, then use save_domain_filters and pass domainFilterId.");
1337
+ }
1338
+ const response = await api.post(`/api/v3/prospeo/search`, input);
1339
+ return compactProspeoSearchResponse(response);
1340
+ }
1341
+ function compactProspeoSearchResponse(response) {
1342
+ if (!response || typeof response !== "object") {
1343
+ return response;
1344
+ }
1345
+ const peopleResults = Array.isArray(response?.people?.results)
1346
+ ? response.people.results
1347
+ : [];
1348
+ const sampleResults = peopleResults.slice(0, 10).map(compactProspeoPerson);
1349
+ return {
1350
+ success: response.success ?? true,
1351
+ searchId: response.searchId ?? null,
1352
+ searchName: response.searchName ?? null,
1353
+ currentPage: response.currentPage ?? null,
1354
+ lastAvailablePage: response.lastAvailablePage ?? null,
1355
+ totalResults: typeof response.totalResults === "number"
1356
+ ? response.totalResults
1357
+ : peopleResults.length,
1358
+ resultsPerPage: typeof response.resultsPerPage === "number"
1359
+ ? response.resultsPerPage
1360
+ : peopleResults.length,
1361
+ nextPageAvailable: typeof response.currentPage === "number" &&
1362
+ typeof response.lastAvailablePage === "number"
1363
+ ? response.currentPage < response.lastAvailablePage
1364
+ : null,
1365
+ people: {
1366
+ count: peopleResults.length,
1367
+ sampleCount: sampleResults.length,
1368
+ results: sampleResults,
1369
+ },
1370
+ };
1371
+ }
1372
+ function compactProspeoPerson(person) {
1373
+ const currentRole = Array.isArray(person?._prospeo?.job_history)
1374
+ ? (person._prospeo.job_history.find((job) => job?.current) ?? null)
1375
+ : null;
1376
+ const location = [person?.city, person?.country]
1377
+ .filter((value) => typeof value === "string" && value.trim())
1378
+ .join(", ");
1379
+ return {
1380
+ name: person?.name ?? null,
1381
+ title: person?.title ?? currentRole?.title ?? null,
1382
+ company: person?.organization?.name ?? currentRole?.company_name ?? null,
1383
+ linkedinUrl: person?.linkedin_url ?? null,
1384
+ email: person?.email ?? null,
1385
+ emailStatus: person?.emailStatus ?? null,
1386
+ location: location || null,
1387
+ headline: person?._prospeo?.headline ?? null,
1388
+ currentRole: currentRole
1389
+ ? {
1390
+ title: currentRole.title ?? null,
1391
+ company: currentRole.company_name ?? null,
1392
+ seniority: currentRole.seniority ?? null,
1393
+ departments: Array.isArray(currentRole.departments)
1394
+ ? currentRole.departments
1395
+ : [],
1396
+ startYear: currentRole.start_year ?? null,
1397
+ startMonth: currentRole.start_month ?? null,
1398
+ }
1399
+ : null,
1400
+ };
1401
+ }
1402
+ export async function loadCsvDomains(input) {
1403
+ const api = getApi();
1404
+ if (!input?.filePath) {
1405
+ throw new Error("load_csv_domains requires filePath.");
1406
+ }
1407
+ if (input.confirmed) {
1408
+ if (!input.confirmationToken) {
1409
+ throw new Error("load_csv_domains confirmed execution requires confirmationToken from a preview response.");
1410
+ }
1411
+ const tokenPayload = normalizeConfirmationPayload(parseConfirmationToken(input.confirmationToken));
1412
+ const previewBuild = buildCsvDomainPreview({
1413
+ filePath: input.filePath,
1414
+ domainColumn: tokenPayload.domainColumn,
1415
+ selectedColumns: tokenPayload.selectedColumns,
1416
+ workspaceRoot,
1417
+ });
1418
+ const currentPayload = {
1419
+ version: tokenPayload.version,
1420
+ filePath: previewBuild.preview.resolvedFilePath,
1421
+ fileSizeBytes: previewBuild.fileSizeBytes,
1422
+ fileMtimeMs: previewBuild.fileMtimeMs,
1423
+ domainColumn: previewBuild.resolvedDomainColumn ?? "",
1424
+ selectedColumns: previewBuild.preview.selectedColumns,
1425
+ };
1426
+ if (!matchesConfirmationToken(input.confirmationToken, currentPayload)) {
1427
+ throw new Error("CSV file changed after preview. Re-run load_csv_domains preview before confirming.");
1428
+ }
1429
+ if (input.domainColumn) {
1430
+ const requestedDomainColumn = resolveCsvHeaderName(previewBuild.preview.headers, input.domainColumn);
1431
+ if (requestedDomainColumn !== tokenPayload.domainColumn) {
1432
+ throw new Error("domainColumn changed after preview. Re-run load_csv_domains preview before confirming.");
1433
+ }
1434
+ }
1435
+ if (input.selectedColumns) {
1436
+ const requestedSelectedColumns = input.selectedColumns.map((column) => resolveCsvHeaderName(previewBuild.preview.headers, column));
1437
+ if (!compareStringArrays(requestedSelectedColumns, tokenPayload.selectedColumns)) {
1438
+ throw new Error("selectedColumns changed after preview. Re-run load_csv_domains preview before confirming.");
1439
+ }
1440
+ }
1441
+ if (previewBuild.preview.blockingErrors.length > 0) {
1442
+ throw new Error(`CSV preview is no longer actionable: ${previewBuild.preview.blockingErrors.join(" ")}`);
1443
+ }
1444
+ if (!previewBuild.resolvedDomainColumn) {
1445
+ throw new Error("CSV preview no longer has a resolved domain column. Re-run load_csv_domains preview.");
1446
+ }
1447
+ const carryRows = projectCsvCarryRows(previewBuild);
1448
+ const response = await api.post("/api/v3/prospeo/validate-domains", {
1449
+ domains: previewBuild.keptCandidateDomains,
1450
+ target: "include",
1451
+ carry: {
1452
+ domainColumn: previewBuild.resolvedDomainColumn,
1453
+ selectedColumns: previewBuild.preview.selectedColumns,
1454
+ rows: carryRows,
1455
+ },
1456
+ });
1457
+ const validCount = Array.isArray(response.valid)
1458
+ ? response.valid.length
1459
+ : 0;
1460
+ const invalidCount = Array.isArray(response.invalid)
1461
+ ? response.invalid.length
1462
+ : 0;
1463
+ const droppedCount = Array.isArray(response.dropped)
1464
+ ? response.dropped.length
1465
+ : 0;
1466
+ if (!response.domainFilterId ||
1467
+ typeof response.domainFilterId !== "string") {
1468
+ throw new Error("Prospeo validation did not return a domainFilterId for the confirmed CSV upload.");
1469
+ }
1470
+ if (validCount === 0) {
1471
+ throw new Error(`CSV upload produced zero valid domains after backend validation (invalid=${invalidCount}, dropped=${droppedCount}).`);
1472
+ }
1473
+ return {
1474
+ ok: true,
1475
+ requiresConfirmation: false,
1476
+ resolvedFilePath: previewBuild.preview.resolvedFilePath,
1477
+ domainColumn: previewBuild.resolvedDomainColumn,
1478
+ selectedColumns: previewBuild.preview.selectedColumns,
1479
+ domainFilterId: response.domainFilterId,
1480
+ validCount,
1481
+ invalidCount,
1482
+ droppedCount,
1483
+ truncatedDomainCount: previewBuild.preview.truncatedDomainCount,
1484
+ apiCallCount: typeof response.apiCallCount === "number" ? response.apiCallCount : 0,
1485
+ duplicatePolicy: previewBuild.preview.duplicatePolicy,
1486
+ limits: previewBuild.preview.limits,
1487
+ warnings: previewBuild.preview.warnings,
1488
+ suggestedToolCalls: input.campaignOfferId
1489
+ ? buildCsvSearchSuggestedToolCalls(input.campaignOfferId, response.domainFilterId)
1490
+ : [],
1491
+ guidance: input.campaignOfferId
1492
+ ? "Domain filter is ready. Load the Prospeo provider prompt, then run search_prospeo with the returned domainFilterId."
1493
+ : "Domain filter is ready. Pass the returned domainFilterId into search_prospeo once you have a campaignOfferId.",
1494
+ };
1495
+ }
1496
+ const previewBuild = buildCsvDomainPreview({
1497
+ filePath: input.filePath,
1498
+ domainColumn: input.domainColumn,
1499
+ selectedColumns: input.selectedColumns,
1500
+ workspaceRoot,
1501
+ });
1502
+ const preview = previewBuild.preview;
1503
+ if (preview.blockingErrors.length > 0) {
1504
+ return {
1505
+ ok: false,
1506
+ requiresConfirmation: false,
1507
+ preview,
1508
+ suggestedToolCalls: [],
1509
+ };
1510
+ }
1511
+ if (!preview.confirmationToken || !previewBuild.resolvedDomainColumn) {
1512
+ return {
1513
+ ok: false,
1514
+ requiresConfirmation: false,
1515
+ preview,
1516
+ suggestedToolCalls: [],
1517
+ };
1518
+ }
1519
+ return {
1520
+ ok: true,
1521
+ requiresConfirmation: true,
1522
+ preview,
1523
+ confirmationToken: preview.confirmationToken,
1524
+ suggestedToolCalls: buildCsvPreviewSuggestedToolCalls(input, preview.confirmationToken),
1525
+ };
1526
+ }
1527
+ export async function loadCsvLinkedinLeads(input) {
1528
+ const api = getApi();
1529
+ if (!input?.filePath) {
1530
+ throw new Error("load_csv_linkedin_leads requires filePath.");
1531
+ }
1532
+ if (input.confirmed) {
1533
+ if (!input.confirmationToken) {
1534
+ throw new Error("load_csv_linkedin_leads confirmed execution requires confirmationToken from a preview response.");
1535
+ }
1536
+ const tokenPayload = normalizeLinkedinConfirmationPayload(parseLinkedinConfirmationToken(input.confirmationToken));
1537
+ const previewBuild = buildCsvLinkedinPreview({
1538
+ filePath: input.filePath,
1539
+ linkedInColumn: tokenPayload.linkedInColumn,
1540
+ selectedColumns: tokenPayload.selectedColumns,
1541
+ workspaceRoot,
1542
+ });
1543
+ const currentPayload = {
1544
+ version: tokenPayload.version,
1545
+ filePath: previewBuild.preview.resolvedFilePath,
1546
+ fileSizeBytes: previewBuild.fileSizeBytes,
1547
+ fileMtimeMs: previewBuild.fileMtimeMs,
1548
+ linkedInColumn: previewBuild.resolvedLinkedInColumn ?? "",
1549
+ selectedColumns: previewBuild.preview.selectedColumns,
1550
+ };
1551
+ if (!matchesLinkedinConfirmationToken(input.confirmationToken, currentPayload)) {
1552
+ throw new Error("CSV file changed after preview. Re-run load_csv_linkedin_leads preview before confirming.");
1553
+ }
1554
+ if (input.linkedInColumn) {
1555
+ const requestedLinkedInColumn = resolveCsvLinkedinHeaderName(previewBuild.preview.headers, input.linkedInColumn);
1556
+ if (requestedLinkedInColumn !== tokenPayload.linkedInColumn) {
1557
+ throw new Error("linkedInColumn changed after preview. Re-run load_csv_linkedin_leads preview before confirming.");
1558
+ }
1559
+ }
1560
+ if (input.selectedColumns) {
1561
+ const requestedSelectedColumns = input.selectedColumns.map((column) => resolveCsvLinkedinHeaderName(previewBuild.preview.headers, column));
1562
+ if (!compareStringArrays(requestedSelectedColumns, tokenPayload.selectedColumns)) {
1563
+ throw new Error("selectedColumns changed after preview. Re-run load_csv_linkedin_leads preview before confirming.");
1564
+ }
1565
+ }
1566
+ if (previewBuild.preview.blockingErrors.length > 0) {
1567
+ throw new Error(`CSV preview is no longer actionable: ${previewBuild.preview.blockingErrors.join(" ")}`);
1568
+ }
1569
+ if (!previewBuild.resolvedLinkedInColumn) {
1570
+ throw new Error("CSV preview no longer has a resolved LinkedIn URL column. Re-run load_csv_linkedin_leads preview.");
1571
+ }
1572
+ const leadListName = input.leadListName?.trim() || previewBuild.preview.suggestedLeadListName;
1573
+ let leadListId = input.leadListId?.trim() || "";
1574
+ let createdLeadList = false;
1575
+ let existingRowCount = 0;
1576
+ if (leadListId) {
1577
+ const tableMeta = await api.get(`/api/v3/workflow-tables/${leadListId}?mode=meta`);
1578
+ const tableType = tableMeta.table?.config
1579
+ ?.type;
1580
+ if (tableType !== "lead_list") {
1581
+ throw new Error(`Existing table ${leadListId} is not a lead list and cannot accept LinkedIn CSV uploads.`);
1582
+ }
1583
+ existingRowCount = tableMeta.rowCount ?? 0;
1584
+ }
1585
+ else {
1586
+ const created = await api.post("/api/v3/lead-lists", {
1587
+ name: leadListName,
1588
+ });
1589
+ leadListId = created.leadList?.id ?? "";
1590
+ if (!leadListId) {
1591
+ throw new Error("Lead list creation did not return a leadList.id for the CSV upload.");
1592
+ }
1593
+ createdLeadList = true;
1594
+ }
1595
+ const uploadedFile = await uploadCsvLinkedinFile({
1596
+ api,
1597
+ leadListId,
1598
+ filePath: input.filePath,
1599
+ workspaceRoot,
1600
+ fileSizeBytes: previewBuild.fileSizeBytes,
1601
+ });
1602
+ const importMode = existingRowCount > 0 ? "add" : undefined;
1603
+ const importStart = await api.post(`/api/v3/lead-lists/${leadListId}/csv-import/start`, {
1604
+ bucket: uploadedFile.bucket,
1605
+ objectPath: uploadedFile.objectPath,
1606
+ originalFilename: uploadedFile.originalFilename,
1607
+ contentType: uploadedFile.contentType,
1608
+ fileSizeBytes: uploadedFile.fileSizeBytes,
1609
+ linkedInColumn: previewBuild.resolvedLinkedInColumn,
1610
+ selectedColumns: previewBuild.preview.selectedColumns,
1611
+ standardMappings: previewBuild.preview.standardMappings,
1612
+ customColumnTargets: previewBuild.preview.customColumnTargets,
1613
+ targetLeadCount: previewBuild.preview.validLinkedInRowCount,
1614
+ duplicateCount: previewBuild.preview.duplicateLinkedInCount,
1615
+ invalidCount: previewBuild.preview.invalidLinkedInRowCount,
1616
+ ...(importMode ? { mode: importMode } : {}),
1617
+ });
1618
+ if (input.campaignOfferId) {
1619
+ await api.put(`/api/v2/campaign-offers/${input.campaignOfferId}`, {
1620
+ selectedLeadListId: leadListId,
1621
+ ...(input.currentStep !== undefined
1622
+ ? { currentStep: input.currentStep }
1623
+ : {}),
1624
+ });
1625
+ }
1626
+ const readiness = await waitForLeadListReady({
1627
+ leadListId,
1628
+ provider: "csv-linkedin",
1629
+ jobId: importStart.jobId,
1630
+ targetLeadCount: importStart.effectiveTargetLeadCount ??
1631
+ importStart.targetLeadCount ??
1632
+ previewBuild.preview.validLinkedInRowCount,
1633
+ timeoutMs: 5000,
1634
+ intervalMs: 1000,
1635
+ });
1636
+ if (!readiness.ready) {
1637
+ if (readiness.reason === "import_failed") {
1638
+ throw new Error(readiness.error || "CSV import failed. Please retry the upload.");
1639
+ }
1640
+ throw new Error("CSV import is still processing. Re-run the tool shortly.");
1641
+ }
1642
+ return {
1643
+ ok: true,
1644
+ requiresConfirmation: false,
1645
+ resolvedFilePath: previewBuild.preview.resolvedFilePath,
1646
+ linkedInColumn: previewBuild.resolvedLinkedInColumn,
1647
+ selectedColumns: previewBuild.preview.selectedColumns,
1648
+ standardMappings: previewBuild.preview.standardMappings,
1649
+ customColumnTargets: previewBuild.preview.customColumnTargets,
1650
+ leadListId,
1651
+ leadListName,
1652
+ createdLeadList,
1653
+ selectedLeadListIdUpdated: Boolean(input.campaignOfferId),
1654
+ rowsInserted: typeof readiness.rowCount === "number"
1655
+ ? readiness.rowCount
1656
+ : previewBuild.preparedRows.length,
1657
+ jobId: importStart.jobId ?? null,
1658
+ importStatus: readiness.status ?? importStart.status ?? null,
1659
+ validCount: previewBuild.preview.validLinkedInRowCount,
1660
+ invalidCount: previewBuild.preview.invalidLinkedInRowCount,
1661
+ duplicateCount: previewBuild.preview.duplicateLinkedInCount,
1662
+ emptyLinkedInRowCount: previewBuild.preview.emptyLinkedInRowCount,
1663
+ duplicatePolicy: previewBuild.preview.duplicatePolicy,
1664
+ limits: previewBuild.preview.limits,
1665
+ warnings: previewBuild.preview.warnings,
1666
+ readinessWarning: readiness.warning ?? null,
1667
+ suggestedToolCalls: input.campaignOfferId
1668
+ ? buildCsvLinkedinConfirmSuggestedToolCalls(input.campaignOfferId, leadListId)
1669
+ : [],
1670
+ guidance: input.campaignOfferId
1671
+ ? "Lead list is ready. Review it, then call confirm_lead_list when the user approves importing it into the campaign table."
1672
+ : "Lead list is ready. Reuse the returned leadListId when attaching this audience to a campaign later.",
1673
+ };
1674
+ }
1675
+ const previewBuild = buildCsvLinkedinPreview({
1676
+ filePath: input.filePath,
1677
+ linkedInColumn: input.linkedInColumn,
1678
+ selectedColumns: input.selectedColumns,
1679
+ workspaceRoot,
1680
+ });
1681
+ const preview = previewBuild.preview;
1682
+ if (preview.blockingErrors.length > 0) {
1683
+ return {
1684
+ ok: false,
1685
+ requiresConfirmation: false,
1686
+ preview,
1687
+ suggestedToolCalls: [],
1688
+ };
1689
+ }
1690
+ if (!preview.confirmationToken || !previewBuild.resolvedLinkedInColumn) {
1691
+ return {
1692
+ ok: false,
1693
+ requiresConfirmation: false,
1694
+ preview,
1695
+ suggestedToolCalls: [],
1696
+ };
1697
+ }
1698
+ return {
1699
+ ok: true,
1700
+ requiresConfirmation: true,
1701
+ preview,
1702
+ confirmationToken: preview.confirmationToken,
1703
+ suggestedToolCalls: buildCsvLinkedinPreviewSuggestedToolCalls(input, preview.confirmationToken),
1704
+ };
1705
+ }
1706
+ export async function saveDomainFilters(input) {
1707
+ const api = getApi();
1708
+ return api.post(`/api/v3/prospeo/domain-filters`, {
1709
+ include: input.include,
1710
+ exclude: input.exclude,
1711
+ });
1712
+ }
1713
+ export async function lookupSalesNavFilter(input) {
1714
+ const api = getApi();
1715
+ const { filterType, query } = input;
1716
+ const response = await api.post(`/api/v3/sales-nav/autocomplete`, { filterType, query });
1717
+ if (!response.success) {
1718
+ throw new Error(response.message || "Failed to lookup filter options");
1719
+ }
1720
+ return {
1721
+ filterType,
1722
+ query,
1723
+ options: response.options.map((opt) => ({
1724
+ id: opt.id,
1725
+ displayValue: opt.displayValue || opt.text,
1726
+ text: opt.text || opt.displayValue,
1727
+ })),
1728
+ usage: `Use these in search_sales_nav filters like: { "type": "${filterType}", "values": [{ "id": "${response.options[0]?.id || "ID"}", "text": "${response.options[0]?.text || "TEXT"}", "selectionType": "INCLUDED" }] }`,
1729
+ };
1730
+ }
1731
+ export async function searchSignals(input) {
1732
+ if (input?.campaignOfferId) {
1733
+ assertInteractionApproval({
1734
+ campaignId: input.campaignOfferId,
1735
+ action: "provider-search",
1736
+ confirmed: input.confirmed,
1737
+ });
1738
+ }
1739
+ assertProviderPromptLoaded({
1740
+ provider: "signal-discovery",
1741
+ campaignOfferId: input?.campaignOfferId,
1742
+ });
1743
+ const api = getApi();
1744
+ const { campaignOfferId, headlineICPCriteria, rubricGuidelines } = input;
1745
+ const effectiveHeadlineICPCriteria = headlineICPCriteria && headlineICPCriteria.length > 0
1746
+ ? headlineICPCriteria
1747
+ : rubricGuidelines;
1748
+ // Persist criteria early so user take-over has filters ready.
1749
+ if (campaignOfferId && effectiveHeadlineICPCriteria?.length) {
1750
+ await api.patch(`/api/v3/campaigns/${campaignOfferId}/signal-discovery/posts`, {
1751
+ selectedIds: [],
1752
+ unselectedIds: [],
1753
+ headlineICPCriteria: effectiveHeadlineICPCriteria,
1754
+ rubricGuidelines: effectiveHeadlineICPCriteria,
1755
+ });
1756
+ }
1757
+ const response = await api.post(`/api/v1/signal-discovery/search-signals`, input);
1758
+ return summarizeSignalSearchResponse(response);
1759
+ }
1760
+ export async function importLeads(input) {
1761
+ const api = getApi();
1762
+ const { campaignOfferId, currentStep, sourceLeadListId: inputSourceLeadListId, searchId, targetLeadCount, mode, searchName, leadListName, headlineICPCriteria, rubricGuidelines, confirmed, } = input;
1763
+ assertInteractionApproval({
1764
+ campaignId: campaignOfferId,
1765
+ action: "import-leads",
1766
+ confirmed,
1767
+ });
1768
+ // Make sourceLeadListId mutable so it can be auto-resolved from campaign
1769
+ let sourceLeadListId = inputSourceLeadListId;
1770
+ // Auto-detect provider from campaign if not specified
1771
+ let provider = input.provider;
1772
+ let campaignCurrentStep;
1773
+ let campaignSelectedLeadListId;
1774
+ if (!provider || currentStep === undefined || mode === undefined) {
1775
+ // Pull campaign once when we need provider or to determine default step behavior.
1776
+ const campaign = await api.get(`/api/v2/campaign-offers/${campaignOfferId}`);
1777
+ if (!provider) {
1778
+ const leadSourceProvider = campaign.leadSourceProvider;
1779
+ provider =
1780
+ leadSourceProvider === "apollo-ai" || leadSourceProvider === "apollo"
1781
+ ? "apollo"
1782
+ : leadSourceProvider === "sales-nav"
1783
+ ? "sales-nav"
1784
+ : leadSourceProvider === "prospeo"
1785
+ ? "prospeo"
1786
+ : leadSourceProvider === "signal-discovery"
1787
+ ? "signal-discovery"
1788
+ : undefined;
1789
+ }
1790
+ if (currentStep === undefined) {
1791
+ campaignCurrentStep = campaign.currentStep ?? null;
1792
+ }
1793
+ campaignSelectedLeadListId = campaign.selectedLeadListId ?? null;
1794
+ }
1795
+ // If caller doesn't specify a step, default to showing the lead list
1796
+ // while in lead sourcing phases. Pass null to skip auto-advance.
1797
+ const sourcingSteps = new Set([
1798
+ null,
1799
+ "pick-provider",
1800
+ "apollo",
1801
+ "sales-nav",
1802
+ "prospeo",
1803
+ "signal-discovery",
1804
+ "signal-discovery-results",
1805
+ ]);
1806
+ const shouldDefaultStep = currentStep === undefined && sourcingSteps.has(campaignCurrentStep ?? null);
1807
+ const effectiveCurrentStep = shouldDefaultStep
1808
+ ? "confirm-lead-list"
1809
+ : currentStep;
1810
+ const shouldSetCurrentStep = typeof effectiveCurrentStep === "string" && effectiveCurrentStep.length > 0;
1811
+ const normalizedMode = mode === "add" || mode === "replace" ? mode : undefined;
1812
+ // If no sourceLeadListId but campaign has one selected, handle mode selection
1813
+ if (!sourceLeadListId && campaignSelectedLeadListId) {
1814
+ // If mode is provided but sourceLeadListId is missing, remind LLM to pass it
1815
+ if (normalizedMode) {
1816
+ return {
1817
+ error: "sourceLeadListId_required",
1818
+ message: `Mode "${normalizedMode}" requires sourceLeadListId. Pass the existingLeadListId from the previous needsModeSelection response.`,
1819
+ existingLeadListId: campaignSelectedLeadListId,
1820
+ suggestedToolCalls: [
1821
+ {
1822
+ tool: "import_leads",
1823
+ args: {
1824
+ ...input,
1825
+ sourceLeadListId: campaignSelectedLeadListId,
1826
+ },
1827
+ },
1828
+ ],
1829
+ };
1830
+ }
1831
+ // No mode provided - ask user to choose
1832
+ return {
1833
+ provider: provider || "apollo",
1834
+ leadListId: campaignSelectedLeadListId,
1835
+ existingLeadListId: campaignSelectedLeadListId,
1836
+ needsModeSelection: true,
1837
+ modeOptions: ["add", "replace"],
1838
+ message: "A lead list already exists for this campaign. Should I add to it or replace it?",
1839
+ suggestedToolCalls: [
1840
+ {
1841
+ tool: "import_leads",
1842
+ args: {
1843
+ ...input,
1844
+ sourceLeadListId: campaignSelectedLeadListId,
1845
+ mode: "add",
1846
+ },
1847
+ },
1848
+ {
1849
+ tool: "import_leads",
1850
+ args: {
1851
+ ...input,
1852
+ sourceLeadListId: campaignSelectedLeadListId,
1853
+ mode: "replace",
1854
+ },
1855
+ },
1856
+ ],
1857
+ };
1858
+ }
1859
+ const normalizedProvider = provider === "sales-nav" || provider === "prospeo" ? provider : "apollo";
1860
+ const providerForGuard = provider === "sales-nav" ||
1861
+ provider === "prospeo" ||
1862
+ provider === "signal-discovery"
1863
+ ? provider
1864
+ : "apollo";
1865
+ assertProviderPromptLoaded({
1866
+ provider: providerForGuard,
1867
+ campaignOfferId,
1868
+ });
1869
+ const maxImportCount = getMaxImportCount(normalizedProvider);
1870
+ const normalizedTargetLeadCount = normalizeTargetLeadCount(targetLeadCount, maxImportCount);
1871
+ const requestedLeadCount = normalizedTargetLeadCount ??
1872
+ (normalizedProvider === "sales-nav" || normalizedProvider === "prospeo"
1873
+ ? 100
1874
+ : undefined);
1875
+ const cappedTargetLeadCount = requestedLeadCount !== undefined
1876
+ ? Math.min(requestedLeadCount, maxImportCount)
1877
+ : undefined;
1878
+ // === SIGNAL DISCOVERY FLOW ===
1879
+ if (provider === "signal-discovery") {
1880
+ // Get selected posts from the campaign's signal search tabs
1881
+ // Note: API returns flat fields (postUrl, postContent, authorName, etc.)
1882
+ const tabsResponse = await api.get(`/api/v3/campaigns/${campaignOfferId}/signal-discovery/tabs`);
1883
+ // Collect all selected posts, mapping from API format to signal-leads/create format
1884
+ const selectedPosts = [];
1885
+ for (const tab of tabsResponse.tabs || []) {
1886
+ for (const post of tab.posts || []) {
1887
+ if (post.isSelected) {
1888
+ selectedPosts.push({
1889
+ id: post.id,
1890
+ url: post.postUrl,
1891
+ content: post.postContent,
1892
+ likes: post.likes,
1893
+ comments: post.comments,
1894
+ keyword: tab.keyword,
1895
+ postedAt: post.postedAt,
1896
+ author: {
1897
+ name: post.authorName,
1898
+ headline: post.authorHeadline || "",
1899
+ },
1900
+ });
1901
+ }
1902
+ }
1903
+ }
1904
+ if (selectedPosts.length === 0) {
1905
+ throw new Error("No posts selected. Call select_promising_posts first.");
1906
+ }
1907
+ // De-duplicate selected posts by canonical URL to avoid double scraping.
1908
+ const uniqueByUrl = new Map();
1909
+ for (const post of selectedPosts) {
1910
+ const key = normalizePostUrl(post.url) || post.url;
1911
+ const existing = uniqueByUrl.get(key);
1912
+ if (!existing) {
1913
+ uniqueByUrl.set(key, post);
1914
+ continue;
1915
+ }
1916
+ const existingEngagement = existing.likes + existing.comments;
1917
+ const postEngagement = post.likes + post.comments;
1918
+ if (postEngagement > existingEngagement) {
1919
+ uniqueByUrl.set(key, post);
1920
+ }
1921
+ }
1922
+ const uniqueSelectedPosts = Array.from(uniqueByUrl.values());
1923
+ const effectiveHeadlineICPCriteria = headlineICPCriteria && headlineICPCriteria.length > 0
1924
+ ? headlineICPCriteria
1925
+ : rubricGuidelines;
1926
+ // Start the scrape job
1927
+ const result = await api.post(`/api/v3/campaigns/${campaignOfferId}/signal-leads/create`, {
1928
+ posts: uniqueSelectedPosts,
1929
+ ...(effectiveHeadlineICPCriteria &&
1930
+ effectiveHeadlineICPCriteria.length > 0
1931
+ ? {
1932
+ headlineICPCriteria: effectiveHeadlineICPCriteria,
1933
+ rubricGuidelines: effectiveHeadlineICPCriteria,
1934
+ }
1935
+ : {}),
1936
+ });
1937
+ // CRITICAL: Update selectedLeadListId so UI subscribes to correct table
1938
+ // This enables realtime updates in LeadListCanvas
1939
+ await api.put(`/api/v2/campaign-offers/${campaignOfferId}`, {
1940
+ selectedLeadListId: result.tableId,
1941
+ ...(shouldSetCurrentStep ? { currentStep: effectiveCurrentStep } : {}),
1942
+ });
1943
+ return {
1944
+ provider: "signal-discovery",
1945
+ leadListId: result.tableId,
1946
+ jobId: result.jobId,
1947
+ estimatedEngagers: result.estimatedEngagers,
1948
+ selectedPostCount: uniqueSelectedPosts.length,
1949
+ targetLeadCount: cappedTargetLeadCount ?? null,
1950
+ message: `Started scraping ${uniqueSelectedPosts.length} posts (~${result.estimatedEngagers} engagers). Leads will appear as scraping completes.`,
1951
+ };
1952
+ }
1953
+ // === APOLLO / SALES NAV FLOW ===
1954
+ let leadListId = sourceLeadListId;
1955
+ let createdLeadList = null;
1956
+ // Create lead list if not provided
1957
+ if (!leadListId) {
1958
+ if (!searchId) {
1959
+ throw new Error(`import_leads for ${provider || "apollo"} requires sourceLeadListId or searchId`);
1960
+ }
1961
+ const providerLabel = provider === "sales-nav"
1962
+ ? "Sales Nav"
1963
+ : provider === "prospeo"
1964
+ ? "Prospeo"
1965
+ : "Apollo";
1966
+ const fallbackName = leadListName ||
1967
+ (searchName ? `${providerLabel} - ${searchName}` : undefined) ||
1968
+ `${providerLabel} Import ${new Date().toISOString().slice(0, 10)}`;
1969
+ const createResult = await api.post(`/api/v3/lead-lists`, {
1970
+ name: fallbackName,
1971
+ templateType: "lead_list",
1972
+ });
1973
+ leadListId = createResult.leadList?.id;
1974
+ createdLeadList = createResult.leadList;
1975
+ }
1976
+ if (!leadListId) {
1977
+ throw new Error("Failed to determine lead list ID for import");
1978
+ }
1979
+ // Start import job based on provider
1980
+ let jobResult = null;
1981
+ let jobId;
1982
+ if (searchId) {
1983
+ const startImport = async () => {
1984
+ if (provider === "sales-nav") {
1985
+ // Sales Nav export flow
1986
+ return api.post(`/api/v3/sales-nav/export`, {
1987
+ searchId,
1988
+ workflowTableId: leadListId,
1989
+ targetLeadCount: cappedTargetLeadCount ?? 100,
1990
+ ...(normalizedMode ? { mode: normalizedMode } : {}),
1991
+ });
1992
+ }
1993
+ if (provider === "prospeo") {
1994
+ return api.post(`/api/v3/lead-lists/${leadListId}/prospeo-import/start`, {
1995
+ searchId,
1996
+ targetLeadCount: cappedTargetLeadCount,
1997
+ ...(normalizedMode ? { mode: normalizedMode } : {}),
1998
+ });
1999
+ }
2000
+ // Apollo import flow (default)
2001
+ return api.post(`/api/v3/lead-lists/${leadListId}/apollo-import/start`, {
2002
+ searchId,
2003
+ targetLeadCount: cappedTargetLeadCount,
2004
+ ...(normalizedMode ? { mode: normalizedMode } : {}),
2005
+ });
2006
+ };
2007
+ try {
2008
+ jobResult = await startImport();
2009
+ jobId = jobResult?.jobId;
2010
+ }
2011
+ catch (error) {
2012
+ if (error instanceof SellableApiError && error.status === 429) {
2013
+ const parsed = parseApiErrorBody(error.body) || {};
2014
+ if (parsed.error === "lead_list_exists" || parsed.modeRequired) {
2015
+ return {
2016
+ provider: provider || "apollo",
2017
+ leadListId,
2018
+ existingLeadListId: leadListId,
2019
+ needsModeSelection: true,
2020
+ modeOptions: ["add", "replace"],
2021
+ existingCount: typeof parsed.existingCount === "number"
2022
+ ? parsed.existingCount
2023
+ : undefined,
2024
+ message: "This lead list already has rows. Should I add to it or replace it?",
2025
+ suggestedToolCalls: [
2026
+ {
2027
+ tool: "import_leads",
2028
+ args: {
2029
+ ...input,
2030
+ sourceLeadListId: leadListId,
2031
+ mode: "add",
2032
+ },
2033
+ },
2034
+ {
2035
+ tool: "import_leads",
2036
+ args: {
2037
+ ...input,
2038
+ sourceLeadListId: leadListId,
2039
+ mode: "replace",
2040
+ },
2041
+ },
2042
+ ],
2043
+ };
2044
+ }
2045
+ }
2046
+ throw error;
2047
+ }
2048
+ }
2049
+ // Update selectedLeadListId so UI subscribes to the source lead list
2050
+ // This enables realtime updates while the import job runs.
2051
+ await api.put(`/api/v2/campaign-offers/${campaignOfferId}`, {
2052
+ selectedLeadListId: leadListId,
2053
+ ...(shouldSetCurrentStep ? { currentStep: effectiveCurrentStep } : {}),
2054
+ });
2055
+ return {
2056
+ provider: provider || "apollo",
2057
+ leadListId,
2058
+ createdLeadList,
2059
+ jobResult,
2060
+ jobId,
2061
+ targetLeadCount: cappedTargetLeadCount ?? null,
2062
+ message: "Import started. Review the lead list as it fills; once it looks good, confirm to import into the campaign table.",
2063
+ };
2064
+ }
2065
+ export async function cancelLeadImport(input) {
2066
+ if (!input.campaignOfferId) {
2067
+ throw new Error("cancel_lead_import: campaignOfferId is required.");
2068
+ }
2069
+ if (!input.tableId) {
2070
+ throw new Error("cancel_lead_import: tableId is required.");
2071
+ }
2072
+ if (input.provider !== "apollo" &&
2073
+ input.provider !== "prospeo" &&
2074
+ input.provider !== "sales-nav") {
2075
+ throw new Error(`cancel_lead_import: provider must be 'apollo', 'prospeo', or 'sales-nav' (got '${input.provider}'). Signal Discovery is not supported.`);
2076
+ }
2077
+ const api = getApi();
2078
+ const path = input.provider === "apollo"
2079
+ ? `/api/v3/lead-lists/${input.tableId}/apollo-import/cancel`
2080
+ : input.provider === "sales-nav"
2081
+ ? `/api/v3/lead-lists/${input.tableId}/sales-nav-import/cancel`
2082
+ : `/api/v3/lead-lists/${input.tableId}/prospeo-import/cancel`;
2083
+ try {
2084
+ const result = await api.post(path, {});
2085
+ return {
2086
+ cancelled: true,
2087
+ provider: input.provider,
2088
+ tableId: input.tableId,
2089
+ jobId: result.jobId,
2090
+ status: result.status,
2091
+ fetchedLeads: result.fetchedLeads,
2092
+ previousStatus: result.previousStatus,
2093
+ };
2094
+ }
2095
+ catch (error) {
2096
+ if (error?.status === 404 || /404/.test(error?.message ?? "")) {
2097
+ return {
2098
+ cancelled: false,
2099
+ provider: input.provider,
2100
+ tableId: input.tableId,
2101
+ reason: "no_active_import",
2102
+ };
2103
+ }
2104
+ throw error;
2105
+ }
2106
+ }
2107
+ export async function confirmLeadList(input) {
2108
+ const api = getApi();
2109
+ const { campaignOfferId, currentStep, confirmed, sourceLeadListId, campaignName, keepInSync, jobId, targetLeadCount, } = input;
2110
+ assertInteractionApproval({
2111
+ campaignId: campaignOfferId,
2112
+ action: "confirm-lead-list",
2113
+ confirmed,
2114
+ });
2115
+ let resolvedLeadListId = sourceLeadListId;
2116
+ let resolvedProvider;
2117
+ if (!resolvedLeadListId || currentStep === undefined || !resolvedProvider) {
2118
+ const campaign = await api.get(`/api/v2/campaign-offers/${campaignOfferId}`);
2119
+ if (!resolvedLeadListId) {
2120
+ resolvedLeadListId = campaign.selectedLeadListId ?? undefined;
2121
+ }
2122
+ if (!resolvedProvider) {
2123
+ const leadSourceProvider = campaign.leadSourceProvider;
2124
+ resolvedProvider =
2125
+ leadSourceProvider === "apollo-ai" || leadSourceProvider === "apollo"
2126
+ ? "apollo"
2127
+ : leadSourceProvider === "sales-nav"
2128
+ ? "sales-nav"
2129
+ : leadSourceProvider === "prospeo"
2130
+ ? "prospeo"
2131
+ : leadSourceProvider === "signal-discovery"
2132
+ ? "signal-discovery"
2133
+ : undefined;
2134
+ }
2135
+ // currentStep defaults handled below
2136
+ }
2137
+ if (!resolvedLeadListId) {
2138
+ throw new Error("confirm_lead_list requires sourceLeadListId");
2139
+ }
2140
+ const effectiveCurrentStep = currentStep === undefined ? "confirm-lead-list" : currentStep;
2141
+ const shouldSetCurrentStep = typeof effectiveCurrentStep === "string" && effectiveCurrentStep.length > 0;
2142
+ let readiness;
2143
+ const leadListMeta = await api.get(`/api/v3/workflow-tables/${resolvedLeadListId}?mode=meta`);
2144
+ const leadListConfig = leadListMeta.table?.config ?? null;
2145
+ const leadListRowCount = leadListMeta.rowCount ?? 0;
2146
+ const importProgress = leadListConfig?.importProgress ?? null;
2147
+ const progressProcessed = typeof importProgress?.processed === "number"
2148
+ ? importProgress.processed
2149
+ : null;
2150
+ const progressTotal = typeof importProgress?.total === "number" ? importProgress.total : null;
2151
+ const progressImported = typeof importProgress?.leadsImported === "number"
2152
+ ? importProgress.leadsImported
2153
+ : null;
2154
+ const csvImportRowsMaterialized = leadListConfig?.importProvider === "csv-linkedin" &&
2155
+ leadListRowCount > 0 &&
2156
+ progressImported !== null &&
2157
+ progressImported >= leadListRowCount &&
2158
+ (progressProcessed === null ||
2159
+ progressTotal === null ||
2160
+ progressProcessed >= progressTotal);
2161
+ const hasProviderImportMetadata = Boolean(leadListConfig?.importProvider || leadListConfig?.importJobId);
2162
+ const hasAsyncImportMetadata = Boolean(leadListConfig?.importStatus || hasProviderImportMetadata);
2163
+ if ((!hasAsyncImportMetadata ||
2164
+ !hasProviderImportMetadata ||
2165
+ csvImportRowsMaterialized) &&
2166
+ leadListRowCount > 0) {
2167
+ readiness = {
2168
+ ready: true,
2169
+ leadListId: resolvedLeadListId,
2170
+ provider: null,
2171
+ attempts: 1,
2172
+ elapsedMs: 0,
2173
+ rowCount: leadListRowCount,
2174
+ status: null,
2175
+ targetLeadCount: leadListRowCount,
2176
+ };
2177
+ }
2178
+ else {
2179
+ readiness = await waitForLeadListReady({
2180
+ leadListId: resolvedLeadListId,
2181
+ campaignOfferId,
2182
+ provider: resolvedProvider,
2183
+ jobId,
2184
+ targetLeadCount,
2185
+ timeoutMs: 5000,
2186
+ intervalMs: 1000,
2187
+ });
2188
+ }
2189
+ if (!readiness.ready) {
2190
+ if (readiness.reason === "missing_job_id") {
2191
+ throw new Error("Import job ID is missing. Please provide the jobId for this import.");
2192
+ }
2193
+ if (readiness.reason === "import_failed") {
2194
+ throw new Error("Import failed. Please retry before confirming.");
2195
+ }
2196
+ throw new Error("Import still in progress. Please wait for completion.");
2197
+ }
2198
+ const importResult = await api.post(`/api/v3/campaign-builder/import-leads`, {
2199
+ sourceLeadListId: resolvedLeadListId,
2200
+ campaignOfferId,
2201
+ campaignName,
2202
+ keepInSync,
2203
+ ...(shouldSetCurrentStep ? { currentStep: effectiveCurrentStep } : {}),
2204
+ });
2205
+ const campaignTableId = importResult.workflowTableId ?? importResult.campaignTableId;
2206
+ if (campaignTableId) {
2207
+ await api.put(`/api/v2/campaign-offers/${campaignOfferId}`, {
2208
+ selectedLeadListId: campaignTableId,
2209
+ ...(shouldSetCurrentStep ? { currentStep: effectiveCurrentStep } : {}),
2210
+ });
2211
+ }
2212
+ return {
2213
+ sourceLeadListId: resolvedLeadListId,
2214
+ campaignTableId: campaignTableId ?? null,
2215
+ importResult,
2216
+ message: "Lead list imported into campaign table. Next: move to filter-choice, then wait_for_campaign_table_ready, then sample with get_rows_minimal.",
2217
+ };
2218
+ }
2219
+ export function getProviderPrompt(input) {
2220
+ const { provider, campaignOfferId, confirmed } = input;
2221
+ if (campaignOfferId) {
2222
+ assertInteractionApproval({
2223
+ campaignId: campaignOfferId,
2224
+ action: "provider-preflight",
2225
+ confirmed,
2226
+ });
2227
+ }
2228
+ const providerFiles = {
2229
+ apollo: "apollo.md",
2230
+ "sales-nav": "sales-nav.md",
2231
+ prospeo: "prospeo.md",
2232
+ "signal-discovery": "signal-discovery.md",
2233
+ };
2234
+ const filename = providerFiles[provider];
2235
+ if (!filename) {
2236
+ throw new Error(`Unknown provider: ${provider}. Valid options: apollo, sales-nav, prospeo, signal-discovery`);
2237
+ }
2238
+ const promptPath = join(workspaceRoot, "mcp/sellable/skills/providers", filename);
2239
+ try {
2240
+ const prompt = readFileSync(promptPath, "utf-8");
2241
+ markProviderPromptLoaded({
2242
+ provider,
2243
+ campaignOfferId,
2244
+ });
2245
+ return prompt;
2246
+ }
2247
+ catch (error) {
2248
+ throw new Error(`Failed to read provider prompt for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
2249
+ }
2250
+ }
2251
+ export async function selectPromisingPosts(input) {
2252
+ const api = getApi();
2253
+ const { campaignOfferId, selections, headlineICPCriteria, currentStep, selectionMode, mode, } = input;
2254
+ const effectiveMode = selectionMode ?? mode ?? "add";
2255
+ // Update post selections via API
2256
+ const postIds = selections.map((s) => s.postId);
2257
+ let unselectedIds = [];
2258
+ if (effectiveMode === "replace") {
2259
+ const existing = await api.get(`/api/v3/campaigns/${campaignOfferId}/signal-discovery/posts?selected=true`);
2260
+ const existingIds = existing?.posts
2261
+ ?.map((post) => post.id)
2262
+ .filter((id) => Boolean(id)) ?? [];
2263
+ const selectedSet = new Set(postIds);
2264
+ unselectedIds = existingIds.filter((id) => !selectedSet.has(id));
2265
+ }
2266
+ const selectionResult = await api.patch(`/api/v3/campaigns/${campaignOfferId}/signal-discovery/posts`, {
2267
+ selectedIds: postIds,
2268
+ unselectedIds,
2269
+ headlineICPCriteria,
2270
+ rubricGuidelines: headlineICPCriteria,
2271
+ });
2272
+ // Update currentStep if provided (via v2 endpoint)
2273
+ if (currentStep) {
2274
+ await api.put(`/api/v2/campaign-offers/${campaignOfferId}`, {
2275
+ currentStep,
2276
+ });
2277
+ }
2278
+ return {
2279
+ success: true,
2280
+ selectedCount: selectionResult.selectedCount,
2281
+ unselectedCount: selectionResult.unselectedCount,
2282
+ criteriaCount: selectionResult.criteriaCount,
2283
+ message: `Selected ${selectionResult.selectedCount} posts with ${selectionResult.criteriaCount} ICP criteria (persisted). Ask the user to confirm, then call import_leads with provider: "signal-discovery". Criteria are already saved to the campaign.`,
2284
+ };
2285
+ }
2286
+ export async function setHeadlineICPCriteria(input) {
2287
+ const api = getApi();
2288
+ const { campaignOfferId, headlineICPCriteria, currentStep } = input;
2289
+ // Persist criteria to the campaign so import_leads can rely on backend state.
2290
+ await api.patch(`/api/v3/campaigns/${campaignOfferId}/signal-discovery/posts`, {
2291
+ selectedIds: [],
2292
+ unselectedIds: [],
2293
+ headlineICPCriteria,
2294
+ rubricGuidelines: headlineICPCriteria,
2295
+ });
2296
+ // Update currentStep if provided (via v2 endpoint)
2297
+ if (currentStep) {
2298
+ await api.put(`/api/v2/campaign-offers/${campaignOfferId}`, {
2299
+ currentStep,
2300
+ });
2301
+ }
2302
+ return {
2303
+ success: true,
2304
+ criteriaCount: headlineICPCriteria.length,
2305
+ message: `Prepared ${headlineICPCriteria.length} ICP criteria (persisted). Call import_leads with provider: "signal-discovery". Criteria are already saved to the campaign.`,
2306
+ };
2307
+ }