shakers-cli-beta 0.9.0 → 0.10.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.
@@ -0,0 +1,818 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ WORK_SITUATIONS, EMPLOYMENT_PARTICIPATIONS, FREELANCE_OPINIONS, CHANGE_MOTIVATORS,
5
+ WORK_MODES, MONTHLY_HOURS, LANGUAGE_CODES, LANGUAGE_LEVELS,
6
+ } = require('./onboarding-flow');
7
+ const { signupCopy, legalCopy, renderDraft, amount } = require('./signup-copy');
8
+ const { fixSignupLanguage, signupLanguage } = require('./signup-language');
9
+ const { readLocalCv } = require('./cv-file');
10
+ const { emailFromHubToken } = require('./auth-session-store');
11
+ const { askAfterTexts } = require('./mcp-choice');
12
+
13
+ // MCP sign-up (ticket 39540): hub imports into an UNREGISTERED profile that the local window claims with a code kept only in memory.
14
+
15
+ const SIGNUP_WINDOW_TIMEOUT_MS = 15 * 60 * 1000;
16
+ // The browser opens this long after the tool answers, so the AI's line announcing it is on screen first.
17
+ const BROWSER_OPEN_DELAY_MS = 4000;
18
+ const MAX_WAIT_SECONDS = 25;
19
+ const IMPORT_POLL_MS = 3000;
20
+ // Languages the profile importer writes in.
21
+ const IMPORT_LANGUAGES = new Set(['es', 'en', 'it', 'pt', 'fr']);
22
+ // The web's high-rate notice; above it a rate is a per-project or annual figure in the wrong field.
23
+ const MAX_HOURLY_RATE = 500;
24
+ const MIN_ANNUAL_RATE = 1000;
25
+ // LinkedIn redirects a signed-in member from /in/me/ to their own /in/<slug>/.
26
+ const LINKEDIN_SELF_URL = 'https://www.linkedin.com/in/me/';
27
+ const LINKEDIN_PROFILE_RE = /^(?:https?:\/\/)?(?:[a-z]{2,3}\.)?linkedin\.com\/in\/([A-Za-z0-9_%-]+)\/?(?:[?#].*)?$/i;
28
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
29
+ // Personal mailbox providers; any other domain may be the talent's employer, so the email question adds the work-email hint.
30
+ const PERSONAL_EMAIL_DOMAINS = new Set(['gmail.com', 'googlemail.com', 'outlook.com', 'outlook.es', 'hotmail.com', 'hotmail.es', 'hotmail.it', 'live.com', 'msn.com', 'yahoo.com', 'yahoo.es', 'yahoo.it', 'ymail.com', 'icloud.com', 'me.com', 'mac.com', 'proton.me', 'protonmail.com', 'pm.me', 'gmx.com', 'gmx.es', 'gmx.de', 'aol.com', 'zoho.com', 'tutanota.com', 'libero.it', 'virgilio.it', 'tiscali.it', 'sapo.pt', 'mail.com', 'yandex.com', 'fastmail.com', 'hey.com', 'uol.com.br', 'bol.com.br']);
31
+ const INVALID_LINKEDIN_MESSAGE = 'That is not a LinkedIn profile URL (linkedin.com/in/<slug>; /in/me only redirects to it). Ask the talent once more to copy the address bar of their profile page, or continue with the CV only.';
32
+ const VERBATIM = 'Show relayVerbatim word for word, as its own block, with nothing of your own added to it.';
33
+
34
+ function isPersonalEmail(email) {
35
+ return PERSONAL_EMAIL_DOMAINS.has(String(email).split('@').pop().toLowerCase());
36
+ }
37
+
38
+ function isLinkedinProfileUrl(url) {
39
+ const m = LINKEDIN_PROFILE_RE.exec(url);
40
+ return !!m && m[1].toLowerCase() !== 'me';
41
+ }
42
+
43
+ // Web semantics: the hourly rate is the part-time project price and the annual target the full-time one, always EUR.
44
+ function pricingFromRates({ hourlyRate, annualRate } = {}) {
45
+ const hourly = hourlyRate != null && hourlyRate !== '';
46
+ const annual = annualRate != null && annualRate !== '';
47
+ if ((hourly && Number(hourlyRate) > MAX_HOURLY_RATE) || (annual && Number(annualRate) < MIN_ANNUAL_RATE)) {
48
+ return { ok: false, reason: 'implausible-rate', message: `hourlyRate is euros per hour (at most ${MAX_HOURLY_RATE}) and annualRate euros per year (at least ${MIN_ANNUAL_RATE}): re-estimate and save the pricing again.` };
49
+ }
50
+ return {
51
+ ok: true,
52
+ input: { partTimeSelected: hourly, partTimeAmount: hourlyRate, partTimeCurrency: 'EUR', fullTimeSelected: annual, fullTimeAmount: annualRate, fullTimeCurrency: 'EUR' },
53
+ };
54
+ }
55
+
56
+ function labelledOptions(codes, labels) {
57
+ return codes.map((code) => `${labels[code] || code} (${code})`).join(', ');
58
+ }
59
+
60
+ function buildDetailsSchema(lang) {
61
+ const L = require('./i18n').getCatalog(lang).onboarding;
62
+ const show = 'Show the talent the label, pass the code';
63
+ return {
64
+ type: 'object',
65
+ description: 'Every section is optional and saved on its own; send what you inferred.',
66
+ properties: {
67
+ workSituation: {
68
+ type: 'object',
69
+ description: 'Current work situation, inferred from what you know (an employer in the CV or memory means EMPLOYED there).',
70
+ properties: {
71
+ situation: { type: 'string', enum: WORK_SITUATIONS, description: `${show}: ${labelledOptions(WORK_SITUATIONS, L.workSituationLabels)}. 'EMPLOYED' requires participation.` },
72
+ participation: { type: 'string', enum: EMPLOYMENT_PARTICIPATIONS, description: `Only when EMPLOYED. ${show}: ${labelledOptions(EMPLOYMENT_PARTICIPATIONS, L.employmentParticipationLabels)}.` },
73
+ opinion: { type: 'string', enum: FREELANCE_OPINIONS, description: `Opinion on freelancing, required unless FREELANCE. ${show}: ${labelledOptions(FREELANCE_OPINIONS, L.freelanceOpinionLabels)}.` },
74
+ changeMotivators: { type: 'string', enum: CHANGE_MOTIVATORS, description: `Motivation, your best match; omit when opinion is NOT_INTERESTED. ${show}: ${labelledOptions(CHANGE_MOTIVATORS, L.changeMotivatorLabels)}.` },
75
+ },
76
+ required: ['situation'],
77
+ },
78
+ pricing: {
79
+ type: 'object',
80
+ description: 'The two rates the web asks for, in euros: your concrete estimate from role, seniority, stack and location. Plain numbers, no thousands separators. Send both when you can.',
81
+ properties: {
82
+ hourlyRate: { type: 'number', description: `Euros per hour (e.g. 45), at most ${MAX_HOURLY_RATE}.` },
83
+ annualRate: { type: 'number', description: `Target euros per year (e.g. 60000), at least ${MIN_ANNUAL_RATE}.` },
84
+ },
85
+ },
86
+ availability: {
87
+ type: 'object',
88
+ description: 'Availability AND location (there is no separate location field).',
89
+ properties: {
90
+ available: { type: 'boolean' },
91
+ monthlyHours: { type: 'string', enum: MONTHLY_HOURS, description: "Exactly one of the enum values ('> 160' verbatim, with the space)." },
92
+ workModes: { type: 'array', items: { type: 'string', enum: WORK_MODES }, description: `Any subset of ${WORK_MODES.join('/')}.` },
93
+ country: { type: 'string', description: 'ISO-2 country code (ES, US, GB).' },
94
+ timezone: { type: 'string', description: 'IANA timezone (Europe/Madrid).' },
95
+ subdivision: { type: 'string', description: 'Optional region code (ES-CT).' },
96
+ longFullTimeProjects: { type: 'boolean' },
97
+ },
98
+ },
99
+ languages: {
100
+ type: 'array',
101
+ description: `The FULL list of languages the talent speaks (bulk upsert). Languages: ${labelledOptions(LANGUAGE_CODES, L.languageNames)}. Levels: ${labelledOptions(LANGUAGE_LEVELS, L.languageLevelLabels)}.`,
102
+ items: {
103
+ type: 'object',
104
+ properties: {
105
+ language: { type: 'string', enum: LANGUAGE_CODES },
106
+ level: { type: 'string', enum: LANGUAGE_LEVELS },
107
+ },
108
+ required: ['language', 'level'],
109
+ },
110
+ },
111
+ phone: {
112
+ type: 'object',
113
+ description: 'Only a phone the talent gave or that is on their CV.',
114
+ properties: {
115
+ telephoneCode: { type: 'string', description: "International code, e.g. '+34'." },
116
+ telephoneNumber: { type: 'string' },
117
+ },
118
+ required: ['telephoneCode', 'telephoneNumber'],
119
+ },
120
+ },
121
+ };
122
+ }
123
+
124
+ const WELCOME_SCHEMA = {
125
+ type: 'object',
126
+ properties: {
127
+ language: { type: 'string', description: "ISO code of the language the talent wrote their first message in (es, en, it, pt...). It fixes the language of every sign-up text from here on." },
128
+ firstName: { type: 'string', description: 'Only if you already know it: the welcome greets them by name.' },
129
+ shown: { type: 'boolean', description: 'true only after you showed relayVerbatim from the previous call: it then asks the question in a dialog.' },
130
+ answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
131
+ },
132
+ required: ['language'],
133
+ };
134
+
135
+ const EMAIL_SCHEMA = {
136
+ type: 'object',
137
+ properties: {
138
+ email: { type: 'string', description: 'The best email you know for the talent (CV first, then what they told you). Omit it when you know none.' },
139
+ typed: { type: 'boolean', description: 'true when `email` is the one the talent just wrote in answer to the email question: it is taken as is.' },
140
+ shown: { type: 'boolean' },
141
+ answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
142
+ },
143
+ };
144
+
145
+ const DRAFT_SCHEMA = {
146
+ type: 'object',
147
+ description: 'Your draft of the profile, from the CV, LinkedIn and what you know. Every field is shown as you write it, in the talent\'s language.',
148
+ properties: {
149
+ name: { type: 'string', description: 'Full name.' },
150
+ role: { type: 'string', description: 'Role, e.g. "Data Engineer".' },
151
+ city: { type: 'string' },
152
+ yearsOfExperience: { type: 'number' },
153
+ stack: { type: 'string', description: 'Main technologies, comma separated, at most five.' },
154
+ languages: { type: 'string', description: 'Languages and level, e.g. "Español nativo, inglés C1".' },
155
+ workMode: { type: 'string', description: 'Remote, hybrid or on site, in the talent\'s language.' },
156
+ monthlyHours: { type: 'string', description: `Hours a month they can work: one of ${MONTHLY_HOURS.join(', ')}.` },
157
+ hourlyRate: { type: 'number', description: `Your estimate in euros per hour (part-time projects), at most ${MAX_HOURLY_RATE}.` },
158
+ annualRate: { type: 'number', description: `Your estimate in euros per year (full-time projects), at least ${MIN_ANNUAL_RATE}.` },
159
+ shown: { type: 'boolean' },
160
+ answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
161
+ },
162
+ };
163
+
164
+ const CREATE_SCHEMA = {
165
+ type: 'object',
166
+ properties: {
167
+ windowAnnounced: { type: 'boolean', description: 'true only after you showed the talent, word for word, the text announcing the window (signup_draft returns it).' },
168
+ linkedinUrl: { type: 'string', description: 'The talent\'s LinkedIn profile URL (linkedin.com/in/<slug>, never /in/me).' },
169
+ cvPath: { type: 'string', description: 'Path of the talent\'s own CV on this machine (from read_cv / suggest_register_context, ~/ form is fine). Only a PDF is uploaded at sign-up.' },
170
+ firstName: { type: 'string' },
171
+ lastName: { type: 'string' },
172
+ userQuery: { type: 'string', description: 'EVERYTHING you know about the talent as free text (max 20000 chars): roles, projects, stack, education, languages, location, what they want next, and the CV text when the CV was only attached in the chat.' },
173
+ },
174
+ };
175
+
176
+ const STATUS_SCHEMA = {
177
+ type: 'object',
178
+ properties: {
179
+ waitSeconds: { type: 'number', description: `Wait up to this many seconds (max ${MAX_WAIT_SECONDS}) for the account or the import to change before answering.` },
180
+ windowLink: { type: 'boolean', description: 'true only when the talent says the sign-up window did not open: returns the text with its link.' },
181
+ },
182
+ };
183
+
184
+ const UPDATE_EXISTING_SCHEMA = {
185
+ type: 'object',
186
+ properties: {
187
+ answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
188
+ },
189
+ };
190
+
191
+ const IMPORT_SCHEMA = {
192
+ type: 'object',
193
+ properties: {
194
+ linkedinUrl: { type: 'string' },
195
+ cvPath: { type: 'string', description: 'Path of the talent\'s own CV on this machine (PDF, DOC or DOCX, max 10 MB).' },
196
+ githubUrl: { type: 'string' },
197
+ websiteUrl: { type: 'string' },
198
+ userQuery: { type: 'string', description: 'Everything you know about the talent, as free text.' },
199
+ language: { type: 'string', enum: [...IMPORT_LANGUAGES] },
200
+ },
201
+ };
202
+
203
+ function defaultSleep(ms) {
204
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
205
+ }
206
+
207
+ function nonEmpty(value) {
208
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
209
+ }
210
+
211
+ // Hub's sign-up takes the CV only as a PDF; anything else travels as text in userQuery and is imported after sign-up.
212
+ function readSignupCv(cvPath) {
213
+ return readLocalCv(cvPath, { extensions: ['.pdf'], badFormat: 'cv-not-pdf' });
214
+ }
215
+
216
+ let currentSignup = null;
217
+
218
+ // Whether a sign-up window opened by this process is still waiting for the talent.
219
+ function isSignupPending() {
220
+ return signupPhase() === 'waiting';
221
+ }
222
+
223
+ // Where this process's sign-up stands: ai_usage is offered only once the account exists.
224
+ function signupPhase() {
225
+ const account = currentSignup ? currentSignup.account.state : 'not-started';
226
+ if (account === 'waiting') return 'waiting';
227
+ return account === 'created' || account === 'existing' ? 'account' : 'none';
228
+ }
229
+
230
+ function makeSignupTools(deps = {}) {
231
+ const lang = () => signupLanguage(deps.lang || 'en');
232
+ const copy = () => signupCopy(lang());
233
+ const flow = require('./onboarding-flow');
234
+ const resolved = flow.makeDeps(deps.flowDeps || {});
235
+ const config = require('./config');
236
+ const {
237
+ requestMcpSignup = require('./mcp-signup-client').requestMcpSignup,
238
+ requestMyImportStatus = require('./mcp-signup-client').requestMyImportStatus,
239
+ requestOneTimeToken = require('./mcp-signup-client').requestOneTimeToken,
240
+ getMcpSignupEndpoint = config.getMcpSignupEndpoint,
241
+ getMyImportStatusEndpoint = config.getMyImportStatusEndpoint,
242
+ getOneTimeTokenEndpoint = config.getOneTimeTokenEndpoint,
243
+ getOneTimeLoginUrl = config.getOneTimeLoginUrl,
244
+ getDeviceAuthorizeEndpoint = config.getDeviceAuthorizeEndpoint,
245
+ getDeviceTokenEndpoint = config.getDeviceTokenEndpoint,
246
+ getAuthTokenEndpoint = config.getAuthTokenEndpoint,
247
+ getCompleteRegistrationEndpoint = config.getCompleteRegistrationEndpoint,
248
+ startDeviceLogin = require('./device-login').startDeviceLogin,
249
+ pollDeviceToken = require('./device-login').pollDeviceToken,
250
+ finalizeDeviceSession = require('./device-login').finalizeDeviceSession,
251
+ appendProviderParam = require('./device-login').appendProviderParam,
252
+ buildRegistrationContext = require('./signup-client').buildRegistrationContext,
253
+ runLoopbackAuth = require('./mcp-auth-form').runLoopbackAuth,
254
+ checkOnboardingCompleted = require('./onboarding-availability').checkOnboardingCompleted,
255
+ fetchPricingRate = (opts) => require('./rate-client').fetchPricingRate({}, opts),
256
+ fetchAvailability = (opts) => require('./availability-client').fetchAvailability({}, opts),
257
+ openBrowser = require('./open-file').openPath,
258
+ sleep = defaultSleep,
259
+ now = () => Date.now(),
260
+ } = deps;
261
+
262
+ const state = {
263
+ claimCode: null,
264
+ windowUrl: null,
265
+ windowOpenFailed: false,
266
+ sessionToken: null,
267
+ email: null,
268
+ draft: null,
269
+ draftConfirmed: false,
270
+ sources: null,
271
+ account: { state: 'not-started' },
272
+ reported: { account: null, import: null },
273
+ wake: null,
274
+ };
275
+ currentSignup = state;
276
+
277
+ function setAccount(next) {
278
+ state.account = next;
279
+ if (state.wake) state.wake();
280
+ }
281
+
282
+ function changed() {
283
+ return new Promise((resolve) => { state.wake = resolve; });
284
+ }
285
+
286
+ function openSoon(url, onFailed = () => {}) {
287
+ sleep(BROWSER_OPEN_DELAY_MS).then(() => { if (openBrowser(url) === false) onFailed(); }).catch(onFailed);
288
+ }
289
+
290
+ // Background device flow behind the window's Google button; the claim code is read when it finalizes.
291
+ async function finishGoogle(authz, tokenEndpoint, authTokenEndpoint) {
292
+ let interval = authz.interval;
293
+ const deadline = now() + authz.expiresIn * 1000;
294
+ while (now() < deadline) {
295
+ await sleep(interval * 1000);
296
+ const res = await pollDeviceToken({ deviceCode: authz.deviceCode, tokenEndpoint });
297
+ if (res.status === 'pending') continue;
298
+ if (res.status === 'slow_down') { interval += 5; continue; }
299
+ if (res.status !== 'authorized') {
300
+ setAccount({ state: 'failed', reason: res.status === 'denied' || res.status === 'expired' ? `google-${res.status}` : (res.reason || 'google-failed') });
301
+ return;
302
+ }
303
+ const fin = await finalizeDeviceSession({
304
+ sessionToken: res.accessToken,
305
+ isNewUser: res.isNewUser,
306
+ authTokenEndpoint,
307
+ completeRegistrationEndpoint: getCompleteRegistrationEndpoint(),
308
+ registrationContext: buildRegistrationContext({ preferredLanguage: lang(), newsletterConsent: false, freelanceType: 'POTENTIAL_FREELANCE' }),
309
+ claimCode: res.isNewUser ? state.claimCode : null,
310
+ });
311
+ if (!fin.ok) {
312
+ setAccount({ state: 'failed', reason: fin.reason });
313
+ return;
314
+ }
315
+ const email = res.email || emailFromHubToken(fin.token);
316
+ try {
317
+ resolved.saveAuthSession({ accessToken: fin.token, hubAccessToken: fin.token, email });
318
+ } catch {
319
+ setAccount({ state: 'failed', reason: 'session-persist-failed' });
320
+ return;
321
+ }
322
+ state.sessionToken = res.accessToken;
323
+ setAccount(res.isNewUser
324
+ ? { state: 'created', via: 'google', email, claimed: fin.claimed === true }
325
+ : { state: 'existing', via: 'google', email });
326
+ return;
327
+ }
328
+ setAccount({ state: 'failed', reason: 'google-expired' });
329
+ }
330
+
331
+ async function onGoogle() {
332
+ const authorizeEndpoint = getDeviceAuthorizeEndpoint();
333
+ const tokenEndpoint = getDeviceTokenEndpoint();
334
+ const authTokenEndpoint = getAuthTokenEndpoint();
335
+ if (!authorizeEndpoint || !tokenEndpoint || !authTokenEndpoint) return { ok: false, reason: 'no-endpoint' };
336
+ const authz = await startDeviceLogin({ authorizeEndpoint });
337
+ if (!authz.ok) return { ok: false, reason: authz.reason || 'authorize-failed' };
338
+ finishGoogle(authz, tokenEndpoint, authTokenEndpoint).catch(() => setAccount({ state: 'failed', reason: 'google-failed' }));
339
+ return { ok: true, url: appendProviderParam(authz.verificationUriComplete, 'google') };
340
+ }
341
+
342
+ function openWindow(identity) {
343
+ setAccount({ state: 'waiting' });
344
+ let announce;
345
+ const announced = new Promise((resolve) => { announce = resolve; });
346
+ runLoopbackAuth(
347
+ {
348
+ mode: 'register',
349
+ deps: resolved,
350
+ lang: lang(),
351
+ registerFields: {
352
+ email: identity.email,
353
+ name: identity.firstName,
354
+ lastName: identity.lastName,
355
+ preferredLanguage: lang(),
356
+ claimCode: () => state.claimCode,
357
+ },
358
+ },
359
+ {
360
+ timeoutMs: SIGNUP_WINDOW_TIMEOUT_MS,
361
+ openBrowser: (url) => openSoon(url, () => { state.windowOpenFailed = true; }),
362
+ onGoogle,
363
+ onUrl: (url) => { state.windowUrl = url; announce(url); },
364
+ },
365
+ ).then((outcome) => {
366
+ if (outcome && outcome.viaSocial) return;
367
+ if (!outcome || !outcome.ok) {
368
+ setAccount({ state: 'failed', reason: (outcome && outcome.reason) || 'window-failed' });
369
+ return;
370
+ }
371
+ setAccount(outcome.accountExists
372
+ ? { state: 'existing', via: 'email', email: outcome.email }
373
+ : { state: 'created', via: 'email', email: outcome.email, claimed: outcome.claimed === true });
374
+ }, () => setAccount({ state: 'failed', reason: 'window-failed' }));
375
+ return Promise.race([announced, sleep(2000).then(() => state.windowUrl)]);
376
+ }
377
+
378
+ // Step 1: welcome, value proposal, the three steps and one question (may I look for your CV here?).
379
+ async function signupStart(args = {}, ctx = {}) {
380
+ if (args.shown !== true && !nonEmpty(args.answer)) {
381
+ fixSignupLanguage(args.language);
382
+ state.draft = null;
383
+ state.draftConfirmed = false;
384
+ state.email = null;
385
+ }
386
+ const c = copy();
387
+ const asked = await askAfterTexts(ctx, {
388
+ tool: 'signup_start',
389
+ texts: [c.welcome(nonEmpty(args.firstName))],
390
+ question: c.welcomeQuestion,
391
+ options: [c.cvSearch, c.cvAttach],
392
+ shown: args.shown,
393
+ answer: args.answer,
394
+ });
395
+ if (asked.reply) return { ...asked.reply, language: lang() };
396
+ const linkedin = `LinkedIn: use the URL in the CV (read_cv returns links.linkedin). Only if it has none, ask \`linkedinAsk\` word for word; only if they say they do not know where to find it, say \`linkedinOpening\` and call open_linkedin_profile. Then call signup_email.`;
397
+ if (asked.answer === c.cvSearch) {
398
+ return {
399
+ ok: true,
400
+ step: 'cv-search',
401
+ cvNotFound: c.cvNotFound,
402
+ linkedinAsk: c.linkedinAsk,
403
+ linkedinOpening: c.linkedinOpening,
404
+ message: `The talent allowed you to look for their CV: call suggest_register_context and read_cv on the most likely one without asking again. If there is none, show \`cvNotFound\` word for word and wait for the file. ${linkedin}`,
405
+ };
406
+ }
407
+ return {
408
+ ok: true,
409
+ step: 'cv-attach',
410
+ relayVerbatim: c.cvAttachAsk,
411
+ linkedinAsk: c.linkedinAsk,
412
+ linkedinOpening: c.linkedinOpening,
413
+ message: `${VERBATIM} Then wait for the CV in the chat. ${linkedin}`,
414
+ };
415
+ }
416
+
417
+ // Step 2: which email the account is created with, the one they read most; a company domain gets the work-email hint.
418
+ async function signupEmail(args = {}, ctx = {}) {
419
+ const c = copy();
420
+ const email = nonEmpty(args.email);
421
+ if (email && !EMAIL_RE.test(email)) return { ok: false, reason: 'invalid-email', relayVerbatim: c.emailUnknown, message: `That is not an email. ${VERBATIM} Then call signup_email with email set to what they write and typed:true.` };
422
+ if (email && args.typed === true) {
423
+ state.email = email;
424
+ return { ok: true, email, next: 'signup_draft', message: 'Email set. Now show the profile draft with signup_draft.' };
425
+ }
426
+ if (!email) {
427
+ return { ok: false, reason: 'email-required', relayVerbatim: c.emailUnknown, message: `${VERBATIM} It is the whole question. Then call signup_email with email set to what they write and typed:true.` };
428
+ }
429
+ const question = isPersonalEmail(email) ? c.emailKnown(email) : `${c.emailKnown(email)} ${c.emailWorkHint}`;
430
+ const asked = await askAfterTexts(ctx, { tool: 'signup_email', texts: [], question, options: [c.emailYes, c.emailOther], answer: args.answer });
431
+ if (asked.reply) return asked.reply;
432
+ if (asked.answer === c.emailYes) {
433
+ state.email = email;
434
+ return { ok: true, email, next: 'signup_draft', message: 'Email set. Now show the profile draft with signup_draft.' };
435
+ }
436
+ return { ok: false, reason: 'email-required', relayVerbatim: c.emailUnknown, message: `${VERBATIM} Then call signup_email with email set to what they write and typed:true.` };
437
+ }
438
+
439
+ // Step 3: the draft of the profile and the data notice; the talent confirms it before the window opens.
440
+ async function signupDraft(args = {}, ctx = {}) {
441
+ const c = copy();
442
+ const pricing = pricingFromRates(args);
443
+ if (!pricing.ok) return pricing;
444
+ const draft = {};
445
+ for (const key of ['name', 'role', 'city', 'yearsOfExperience', 'stack', 'languages', 'workMode', 'monthlyHours', 'hourlyRate', 'annualRate']) {
446
+ if (args[key] !== undefined && args[key] !== null && args[key] !== '') draft[key] = args[key];
447
+ }
448
+ if (!draft.name || !draft.role) return { ok: false, reason: 'draft-incomplete', message: 'The draft needs at least name and role: read the CV first.' };
449
+ const asked = await askAfterTexts(ctx, {
450
+ tool: 'signup_draft',
451
+ texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice],
452
+ question: c.draftQuestion,
453
+ options: [c.draftOk, c.draftChange],
454
+ shown: args.shown,
455
+ answer: args.answer,
456
+ });
457
+ if (asked.reply) return { ...asked.reply, message: `${asked.reply.message} Pass the same draft fields again.` };
458
+ if (asked.answer === c.draftChange) {
459
+ state.draftConfirmed = false;
460
+ return { ok: false, reason: 'draft-change', message: 'Ask what they want to change in one short question, then call signup_draft again with the corrected fields.' };
461
+ }
462
+ state.draft = draft;
463
+ state.draftConfirmed = true;
464
+ return {
465
+ ok: true,
466
+ next: 'signup_create_account',
467
+ relayVerbatim: c.windowOpening,
468
+ message: `Confirmed. ${VERBATIM} Then call signup_create_account with windowAnnounced:true: the window opens a few seconds later.`,
469
+ };
470
+ }
471
+
472
+ function signupFailureHelp(reason) {
473
+ switch (reason) {
474
+ case 'invalid-linkedin-url':
475
+ return { message: INVALID_LINKEDIN_MESSAGE };
476
+ case 'invalid-cv':
477
+ return { message: 'Shakers could not read that CV as a PDF. Ask for another file, or continue with LinkedIn only.' };
478
+ case 'missing-source':
479
+ return { message: 'Sign-up needs the LinkedIn URL or a PDF CV.' };
480
+ case 'rate-limited':
481
+ return { message: 'Too many sign-ups from this network in the last hour. Ask the talent to try again later or register on the web.' };
482
+ default:
483
+ return { message: 'The sign-up could not start right now. Try again in a moment.' };
484
+ }
485
+ }
486
+
487
+ // Step 4: hub starts the import into an UNREGISTERED profile and the window opens to create the account (or sign in).
488
+ async function signupCreateAccount(args = {}) {
489
+ const c = copy();
490
+ if (!state.draftConfirmed) {
491
+ return { ok: false, reason: 'draft-not-confirmed', message: 'Show the draft with signup_draft and get the talent\'s confirmation first.' };
492
+ }
493
+ if (args.windowAnnounced !== true) {
494
+ return { ok: false, reason: 'window-not-announced', relayVerbatim: c.windowOpening, message: `${VERBATIM} Then call signup_create_account again with windowAnnounced:true.` };
495
+ }
496
+ const session = resolved.loadAuthSession();
497
+ if (resolved.sessionStatus(session) === 'active' && state.account.state !== 'waiting') {
498
+ return {
499
+ ok: false,
500
+ reason: 'already-signed-in',
501
+ next: 'import_profile',
502
+ message: 'This machine is already signed in to Shakers. If that account is the talent\'s, import their LinkedIn/CV into it with import_profile; if it is not, call logout and then signup_create_account again.',
503
+ };
504
+ }
505
+
506
+ const sources = { linkedinUrl: nonEmpty(args.linkedinUrl), cvPath: nonEmpty(args.cvPath), userQuery: nonEmpty(args.userQuery) };
507
+ if (sources.linkedinUrl && !isLinkedinProfileUrl(sources.linkedinUrl)) return { ok: false, reason: 'invalid-linkedin-url', message: INVALID_LINKEDIN_MESSAGE };
508
+ let cv = null;
509
+ let cvNote = null;
510
+ if (sources.cvPath) {
511
+ const read = readSignupCv(sources.cvPath);
512
+ if (read.ok) cv = read.file;
513
+ else if (read.reason === 'cv-not-pdf') cvNote = 'The CV is not a PDF, so it was not uploaded now: make sure its text is in userQuery, and once the account exists import the file with import_profile.';
514
+ else return { ok: false, reason: read.reason, message: 'That CV could not be read. Ask the talent to check the file, or continue with LinkedIn and what you know.' };
515
+ }
516
+ if (!sources.linkedinUrl && !cv) {
517
+ return { ok: false, reason: 'missing-source', linkedinAsk: c.linkedinAsk, message: 'Sign-up needs the LinkedIn URL or a PDF CV: ask `linkedinAsk` word for word.' };
518
+ }
519
+
520
+ const identity = { email: state.email, firstName: nonEmpty(args.firstName), lastName: nonEmpty(args.lastName) };
521
+ const language = IMPORT_LANGUAGES.has(lang()) ? lang() : 'en';
522
+ const res = await requestMcpSignup(
523
+ { linkedinUrl: sources.linkedinUrl, cv, ...identity, userQuery: sources.userQuery, language },
524
+ { endpoint: getMcpSignupEndpoint() },
525
+ );
526
+ if (!res.ok) return { ok: false, reason: res.reason, ...signupFailureHelp(res.reason) };
527
+
528
+ state.claimCode = res.claimCode;
529
+ state.sources = sources;
530
+ state.reported = { account: 'waiting', import: 'none' };
531
+ const reused = state.account.state === 'waiting';
532
+ if (!reused) await openWindow(identity);
533
+ return {
534
+ ok: true,
535
+ status: 'window-open',
536
+ ...(cvNote ? { cvNote } : {}),
537
+ next: 'signup_status',
538
+ message: (reused ? 'The sign-up window was already open; it now uses the new data. ' : 'The window opens in the talent\'s browser in a few seconds: they create the account with Google or a password there, or sign in if they already have one. ')
539
+ + 'Say nothing more about it. Only if they say it did not open, call signup_status with windowLink:true. Call signup_status (waitSeconds 20) until the account exists.',
540
+ };
541
+ }
542
+
543
+ async function readImport() {
544
+ // An account that already existed got no import from this sign-up: its last import is not news.
545
+ if (state.account.state === 'existing') return { state: 'none' };
546
+ const session = resolved.loadAuthSession();
547
+ if (resolved.sessionStatus(session) !== 'active' || !session || !session.hubAccessToken) return { state: 'none' };
548
+ const res = await requestMyImportStatus({ hubAccessToken: session.hubAccessToken }, { endpoint: getMyImportStatusEndpoint() });
549
+ return res.ok ? { state: res.state, sources: res.sources, ...(res.code ? { code: res.code } : {}) } : { state: 'unknown', reason: res.reason };
550
+ }
551
+
552
+ function nextSteps(account) {
553
+ const interview = 'Then the onboarding interview: if onboardingInterview.completed is true do not offer it; otherwise call onboarding_interview_start without disclaimerAcknowledged (it asks here now or later on the web). Then, also if they skip it, the main role: list_my_roles (waitSeconds 20) and set_main_role without clusterId. Finally open_web.';
554
+ const aiUsage = 'Then ai_usage without consent: it returns the two AI-usage disclaimers and the question (show them right before asking; no scan happens before a yes).';
555
+ if (account.state === 'existing') {
556
+ return `This talent already had an account and signed in. Call update_existing_profile: it shows what would change and asks whether to update. ${aiUsage} ${interview}`;
557
+ }
558
+ const unclaimed = account.claimed === false ? ' This account did not take over the imported profile: call import_profile with the same LinkedIn/CV first.' : '';
559
+ return `The account exists.${unclaimed} Now, without asking: save_profile_details with the confirmed draft (situation, motivation, both rates, availability and location, languages). ${aiUsage} ${interview}`;
560
+ }
561
+
562
+ function guidance(account, imp) {
563
+ const lines = [];
564
+ const linkedin = imp.sources && imp.sources.linkedin;
565
+ if (linkedin && linkedin.state === 'failed') {
566
+ lines.push('The LinkedIn import failed. Tell the talent once, without codes, and check the URL with them; retry once with import_profile; if it fails again, ask for their website or GitHub and send them with import_profile.');
567
+ }
568
+ if (account.state === 'waiting') lines.push('The talent is still in the sign-up window. Call signup_status again.');
569
+ if (account.state === 'failed') lines.push('The sign-up window ended without an account. Ask the talent whether to try again; signup_create_account reopens it.');
570
+ if (account.state === 'created' || account.state === 'existing') lines.push(nextSteps(account));
571
+ return lines.join(' ');
572
+ }
573
+
574
+ async function signupStatus(args = {}) {
575
+ if (state.account.state === 'not-started') {
576
+ return { ok: false, reason: 'no-signup', message: 'No sign-up was started in this session: follow signup_start.' };
577
+ }
578
+ const waitMs = Math.min(Math.max(Number(args.waitSeconds) || 0, 0), MAX_WAIT_SECONDS) * 1000;
579
+ const deadline = now() + waitMs;
580
+ let imp = await readImport();
581
+ const fresh = () => state.account.state !== state.reported.account || imp.state !== state.reported.import;
582
+ while (now() < deadline && !fresh()) {
583
+ await Promise.race([changed(), sleep(Math.min(IMPORT_POLL_MS, Math.max(deadline - now(), 0)))]);
584
+ imp = await readImport();
585
+ }
586
+ state.reported = { account: state.account.state, import: imp.state };
587
+
588
+ const account = { ...state.account };
589
+ const out = { ok: true, account, import: imp };
590
+ if (account.state === 'waiting' && (args.windowLink === true || state.windowOpenFailed) && state.windowUrl) {
591
+ out.relayVerbatim = copy().windowLink(state.windowUrl);
592
+ }
593
+ if (account.state === 'created' || account.state === 'existing') {
594
+ let completed = null;
595
+ try { completed = await checkOnboardingCompleted({}); } catch { completed = null; }
596
+ out.onboardingInterview = { completed };
597
+ }
598
+ out.message = [out.relayVerbatim ? VERBATIM : '', guidance(account, imp)].filter(Boolean).join(' ');
599
+ return out;
600
+ }
601
+
602
+ async function importProfile(args = {}) {
603
+ if (nonEmpty(args.linkedinUrl) && !isLinkedinProfileUrl(nonEmpty(args.linkedinUrl))) return { ok: false, reason: 'invalid-linkedin-url', message: INVALID_LINKEDIN_MESSAGE };
604
+ const res = await flow.startProfileImport(resolved, {
605
+ linkedinUrl: nonEmpty(args.linkedinUrl),
606
+ cvPath: nonEmpty(args.cvPath),
607
+ githubUrl: nonEmpty(args.githubUrl),
608
+ websiteUrl: nonEmpty(args.websiteUrl),
609
+ userQuery: nonEmpty(args.userQuery),
610
+ language: IMPORT_LANGUAGES.has(args.language) ? args.language : (IMPORT_LANGUAGES.has(lang()) ? lang() : 'en'),
611
+ fillEmptyOnly: true,
612
+ });
613
+ if (!res.ok) {
614
+ if (res.reason === 'no-session') {
615
+ return { ok: false, reason: isSignupPending() ? 'signup-pending' : 'no-session', message: isSignupPending() ? 'The account does not exist yet: wait for signup_status to report it.' : 'The talent is not signed in: use login first.' };
616
+ }
617
+ return { ok: false, reason: res.reason };
618
+ }
619
+ const failed = res.report ? res.report.sources.filter((src) => src.status === 'failed') : [];
620
+ return {
621
+ ok: true,
622
+ report: res.report,
623
+ message: failed.length
624
+ ? `Imported, except: ${failed.map((src) => src.source).join(', ')}. Tell the talent which source did not come through, without codes.`
625
+ : 'Imported. Existing texts and languages were kept; lists were added without duplicates.',
626
+ };
627
+ }
628
+
629
+ function money(value, unit) {
630
+ return value === null || value === undefined ? copy().existingEmpty : `${amount(lang(), value)} ${unit}`;
631
+ }
632
+
633
+ // What the confirmed draft would change in an existing profile: the rates and hours it overwrites, plus the import that only adds.
634
+ async function existingDiff() {
635
+ const c = copy();
636
+ const draft = state.draft || {};
637
+ const session = resolved.loadAuthSession();
638
+ const hubAccessToken = session && session.hubAccessToken;
639
+ const [rate, availability] = await Promise.all([
640
+ fetchPricingRate({ hubAccessToken }).catch(() => ({ ok: false })),
641
+ fetchAvailability({ hubAccessToken }).catch(() => ({ ok: false })),
642
+ ]);
643
+ const lines = [];
644
+ const pricing = rate && rate.ok ? rate.pricing : null;
645
+ const currentHourly = pricing && pricing.partTimePrice ? pricing.partTimePrice.amount : null;
646
+ const currentAnnual = pricing && pricing.fullTimePrice ? pricing.fullTimePrice.amount : null;
647
+ if (draft.hourlyRate !== undefined && Number(draft.hourlyRate) !== currentHourly) lines.push(c.existingRate(money(currentHourly, c.perHour), money(draft.hourlyRate, c.perHour)));
648
+ if (draft.annualRate !== undefined && Number(draft.annualRate) !== currentAnnual) lines.push(c.existingAnnual(money(currentAnnual, c.perYear), money(draft.annualRate, c.perYear)));
649
+ const currentHours = availability && availability.ok && availability.availability ? availability.availability.monthlyHours : null;
650
+ if (draft.monthlyHours !== undefined && String(draft.monthlyHours) !== String(currentHours)) {
651
+ lines.push(c.existingHours(currentHours ? c.hours(currentHours) : c.existingEmpty, c.hours(draft.monthlyHours)));
652
+ }
653
+ lines.push(c.existingLists);
654
+ return `${c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
655
+ }
656
+
657
+ // Existing account: after signing in, the talent sees what would change and decides whether to update.
658
+ async function updateExistingProfile(args = {}, ctx = {}) {
659
+ const c = copy();
660
+ const session = resolved.loadAuthSession();
661
+ if (resolved.sessionStatus(session) !== 'active') return { ok: false, reason: 'no-session', message: 'The talent is not signed in yet: wait for signup_status.' };
662
+ const question = await existingDiff();
663
+ const asked = await askAfterTexts(ctx, { tool: 'update_existing_profile', texts: [], question, options: [c.existingYes, c.existingNo], answer: args.answer });
664
+ if (asked.reply) return asked.reply;
665
+ if (asked.answer === c.existingNo) {
666
+ return { ok: true, updated: false, relayVerbatim: c.existingKept, message: `${VERBATIM} Do not save the draft. Carry on with ai_usage.` };
667
+ }
668
+ const sources = state.sources || {};
669
+ const imported = sources.linkedinUrl || sources.cvPath || sources.userQuery ? await importProfile(sources) : { ok: true };
670
+ return {
671
+ ok: true,
672
+ updated: true,
673
+ import: imported,
674
+ message: `${imported.ok ? '' : 'The import did not go through; carry on anyway. '}Now save_profile_details with the confirmed draft, without asking. Then ai_usage without consent.`,
675
+ };
676
+ }
677
+
678
+ async function savePricing(rates) {
679
+ const mapped = pricingFromRates(rates);
680
+ return mapped.ok ? flow.savePricing(resolved, mapped.input) : mapped;
681
+ }
682
+
683
+ function ratesSaved(rates) {
684
+ if (!rates || typeof rates !== 'object') return null;
685
+ const c = copy();
686
+ const shown = [];
687
+ if (rates.hourlyRate != null && rates.hourlyRate !== '') shown.push(`${c.hourlyRateLabel}: ${money(rates.hourlyRate, c.perHour)}`);
688
+ if (rates.annualRate != null && rates.annualRate !== '') shown.push(`${c.annualRateLabel}: ${money(rates.annualRate, c.perYear)}`);
689
+ return c.ratesSaved(shown);
690
+ }
691
+
692
+ async function saveProfileDetails(args = {}) {
693
+ const { saved } = copy();
694
+ const sections = [
695
+ ['workSituation', args.workSituation, (v) => flow.saveWorkSituation(resolved, v), saved.workSituation],
696
+ ['pricing', args.pricing, savePricing, ratesSaved(args.pricing)],
697
+ ['availability', args.availability, (v) => flow.saveAvailability(resolved, v), saved.availability],
698
+ ['languages', Array.isArray(args.languages) && args.languages.length ? args.languages : null, (v) => flow.saveLanguages(resolved, v), saved.languages],
699
+ ['phone', args.phone, (v) => flow.savePhone(resolved, v), saved.phone],
700
+ ];
701
+ const results = {};
702
+ const confirmations = [];
703
+ for (const [name, value, save, saved] of sections) {
704
+ if (!value || typeof value !== 'object') continue;
705
+ const res = await save(value);
706
+ if (res.ok) confirmations.push(saved);
707
+ if (!res.ok && res.reason === 'no-session') return { ok: false, reason: 'no-session', message: 'The talent is not signed in yet.' };
708
+ results[name] = res.ok ? { ok: true } : { ok: false, reason: res.reason, ...(res.message ? { message: res.message } : {}) };
709
+ }
710
+ return { ok: Object.values(results).every((r) => r.ok), results, confirmations };
711
+ }
712
+
713
+ async function openWeb(args = {}) {
714
+ const session = resolved.loadAuthSession();
715
+ const cookie = session && resolved.sessionStatus(session) === 'active' ? session.cookie || null : null;
716
+ let url = null;
717
+ if (cookie || state.sessionToken) {
718
+ const minted = await requestOneTimeToken({ cookie, sessionToken: state.sessionToken }, { endpoint: getOneTimeTokenEndpoint() });
719
+ if (minted.ok) url = getOneTimeLoginUrl(minted.token);
720
+ }
721
+ const loggedIn = !!url;
722
+ if (!url) url = flow.profileUrl(resolved);
723
+ if (!url) return { ok: false, reason: 'no-endpoint' };
724
+ // The address reaches the AI only when the talent needs a link: the AI comments on hosts it sees.
725
+ const opened = args.open !== false && openBrowser(url) !== false;
726
+ if (opened) {
727
+ return {
728
+ ok: true,
729
+ opened: true,
730
+ loggedIn,
731
+ message: (loggedIn ? 'Shakers opened in the talent\'s browser, signed in.' : 'Shakers opened in the talent\'s browser at the sign-in page; they sign in there.')
732
+ + ' If they say it did not open, call open_web with open:false and give them its link.',
733
+ };
734
+ }
735
+ return {
736
+ ok: true,
737
+ opened: false,
738
+ loggedIn,
739
+ link: url,
740
+ message: 'Give the talent `link` to click, with no comment on the address.' + (loggedIn ? ' It works once, for 2 minutes.' : ''),
741
+ };
742
+ }
743
+
744
+ function openLinkedinProfile() {
745
+ openSoon(LINKEDIN_SELF_URL);
746
+ return {
747
+ ok: true,
748
+ url: LINKEDIN_SELF_URL,
749
+ relayVerbatim: copy().linkedinOpened,
750
+ message: `${VERBATIM} The browser opens in a few seconds. If they say it did not open, give \`url\` as a link. When they paste the URL, check it is linkedin.com/in/<slug> (not /in/me, a feed or a search) and ask once more if not.`,
751
+ };
752
+ }
753
+
754
+ return [
755
+ {
756
+ name: 'signup_start',
757
+ description: "FIRST call when the talent asks to sign up or register on Shakers, before you write anything: pass `language` = the language of their message. It returns the welcome to show word for word and its one question (may I look for your CV on this computer?). Then follow its message: CV, LinkedIn, signup_email, signup_draft, signup_create_account, signup_status. Every text it and the next sign-up tools return in relayVerbatim is shown word for word, in that language.",
758
+ inputSchema: WELCOME_SCHEMA,
759
+ handler: signupStart,
760
+ },
761
+ {
762
+ name: 'signup_email',
763
+ description: 'Sign-up step after the CV and LinkedIn: asks which email to create the account with (the one they read most). Pass the best email you know, or none. Never comment on the email yourself.',
764
+ inputSchema: EMAIL_SCHEMA,
765
+ handler: signupEmail,
766
+ },
767
+ {
768
+ name: 'signup_draft',
769
+ description: 'Sign-up step before the account: shows the talent the profile draft you prepared from their CV, LinkedIn and what you know, with the data notice, and asks them to confirm it. The window to create the account opens only after they confirm.',
770
+ inputSchema: DRAFT_SCHEMA,
771
+ handler: signupDraft,
772
+ },
773
+ {
774
+ name: 'signup_create_account',
775
+ description: "After the talent confirmed the draft and you showed the window text word for word: starts the profile import on Shakers and, a few seconds later, opens a window in the talent's browser where they create the account with Google or a password, or sign in if they already have one. No password ever goes through the chat. Returns next: signup_status.",
776
+ inputSchema: CREATE_SCHEMA,
777
+ handler: signupCreateAccount,
778
+ },
779
+ {
780
+ name: 'signup_status',
781
+ description: "State of the sign-up: account (waiting | created | existing | failed) and, once it exists, the profile import per source (linkedin, cv: running | done | failed). Pass waitSeconds (up to 25) to wait for a change. Follow `message`: it gives the remaining steps. Never show internal codes to the talent.",
782
+ inputSchema: STATUS_SCHEMA,
783
+ handler: signupStatus,
784
+ },
785
+ {
786
+ name: 'update_existing_profile',
787
+ description: 'When signup_status says the talent already had an account: shows what the confirmed draft would change in their profile and asks whether to update it; on yes it imports their LinkedIn/CV filling only what is empty.',
788
+ inputSchema: UPDATE_EXISTING_SCHEMA,
789
+ handler: updateExistingProfile,
790
+ },
791
+ {
792
+ name: 'import_profile',
793
+ description: "Import LinkedIn, CV, GitHub or website into the SIGNED-IN talent's profile, filling only what is empty: existing texts and languages are kept, lists are added without duplicates. Use it when the sign-up import failed for a source, or for a non-PDF CV. Takes up to ~90 s.",
794
+ inputSchema: IMPORT_SCHEMA,
795
+ handler: importProfile,
796
+ },
797
+ {
798
+ name: 'save_profile_details',
799
+ description: "After the account exists, write the confirmed draft — work situation and motivation, hourly rate and annual target, availability and location, languages, phone — WITHOUT asking again: they can edit every field on the web. Each section is saved on its own; returns per-section results.",
800
+ inputSchema: buildDetailsSchema(deps.lang || 'en'),
801
+ handler: saveProfileDetails,
802
+ },
803
+ {
804
+ name: 'open_web',
805
+ description: "Open Shakers in the talent's browser already signed in (one-time link, valid 2 minutes, single use). Use it at the end of the sign-up, or when the onboarding interview has to be done on the web. Returns { opened, loggedIn }, plus `link` only when the browser was not opened.",
806
+ inputSchema: { type: 'object', properties: { open: { type: 'boolean', description: 'false returns a fresh link without opening the browser: only when the talent says the browser did not open.' } } },
807
+ handler: openWeb,
808
+ },
809
+ {
810
+ name: 'open_linkedin_profile',
811
+ description: "Open the talent's own LinkedIn profile in their browser (linkedin.com/in/me redirects to it) so they copy the address bar. Use it ONLY when the talent says they do not know where to find their LinkedIn URL, and only after showing them, word for word, the `linkedinOpening` text signup_start returned.",
812
+ inputSchema: { type: 'object', properties: {} },
813
+ handler: openLinkedinProfile,
814
+ },
815
+ ];
816
+ }
817
+
818
+ module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };