shakers-cli-beta 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -3
- package/bin/mcp.js +13 -7
- package/bin/register.js +5 -2
- package/package.json +1 -1
- package/src/authorship-mismatch.js +62 -0
- package/src/backend-request.js +12 -8
- package/src/config.js +27 -6
- package/src/consent-flow.js +10 -10
- package/src/i18n.js +16 -12
- package/src/mcp-ai-usage-result-tool.js +4 -2
- package/src/mcp-ai-usage-tool.js +3 -1
- package/src/mcp-alma-tools.js +7 -3
- package/src/mcp-applications-tools.js +4 -2
- package/src/mcp-auth-form.js +39 -10
- package/src/mcp-availability-tools.js +4 -2
- package/src/mcp-certifications-tools.js +4 -2
- package/src/mcp-certify-dimension-tools.js +4 -2
- package/src/mcp-choice.js +30 -4
- package/src/mcp-device-login-tools.js +6 -2
- package/src/mcp-find-projects-tools.js +4 -2
- package/src/mcp-instructions.js +6 -6
- package/src/mcp-invitations-tools.js +4 -2
- package/src/mcp-lang-tools.js +4 -2
- package/src/mcp-onboarding-tools.js +41 -44
- package/src/mcp-portfolios-tools.js +7 -3
- package/src/mcp-profile-tools.js +4 -2
- package/src/mcp-prompts.js +3 -3
- package/src/mcp-rate-tools.js +4 -2
- package/src/mcp-register-tools.js +53 -20
- package/src/mcp-report-tools.js +7 -3
- package/src/mcp-role-tools.js +36 -13
- package/src/mcp-saved-positions-tools.js +7 -3
- package/src/mcp-server.js +17 -4
- package/src/mcp-show-project-tools.js +4 -2
- package/src/mcp-signup-tools.js +229 -62
- package/src/mcp-socials-tools.js +4 -2
- package/src/onboarding-flow.js +112 -13
- package/src/onboarding-funnel.js +272 -0
- package/src/profile-client.js +11 -0
- package/src/share.js +4 -3
- package/src/signup-copy.js +84 -26
- package/src/terminal-agents.js +8 -0
- package/src/usage-run-steps.js +3 -0
- package/src/mcp-ask-tools.js +0 -50
package/src/mcp-signup-tools.js
CHANGED
|
@@ -6,9 +6,12 @@ const {
|
|
|
6
6
|
} = require('./onboarding-flow');
|
|
7
7
|
const { signupCopy, legalCopy, renderDraft, amount } = require('./signup-copy');
|
|
8
8
|
const { fixSignupLanguage, signupLanguage } = require('./signup-language');
|
|
9
|
+
const path = require('path');
|
|
9
10
|
const { readLocalCv } = require('./cv-file');
|
|
10
11
|
const { emailFromHubToken } = require('./auth-session-store');
|
|
11
|
-
const { askAfterTexts, optionFor, queueNotice, takeNotices } = require('./mcp-choice');
|
|
12
|
+
const { askAfterTexts, askConsent, optionFor, queueNotice, takeNotices } = require('./mcp-choice');
|
|
13
|
+
const { expandHome, tildify } = require('./env-paths');
|
|
14
|
+
const { NOOP_FUNNEL, weeklyHours, rateTypes, workSituationValue } = require('./onboarding-funnel');
|
|
12
15
|
|
|
13
16
|
// MCP sign-up (ticket 39540): hub imports into an UNREGISTERED profile that the local window claims with a code kept only in memory.
|
|
14
17
|
|
|
@@ -45,8 +48,23 @@ const IMPORT_CLOSING = {
|
|
|
45
48
|
};
|
|
46
49
|
const VERBATIM = 'Print relayVerbatim word for word now, as its own block, before any other tool call, with nothing of your own added to it.';
|
|
47
50
|
|
|
51
|
+
// Providers that serve the same personal mailboxes under many country domains (hotmail.co.uk, yahoo.com.br, outlook.it).
|
|
52
|
+
const PERSONAL_EMAIL_PROVIDERS = new Set(['gmail', 'googlemail', 'outlook', 'hotmail', 'live', 'msn', 'yahoo', 'ymail', 'icloud', 'proton', 'protonmail', 'gmx', 'aol', 'zoho', 'tutanota', 'libero', 'virgilio', 'tiscali', 'sapo', 'yandex', 'fastmail', 'uol', 'bol', 'terra']);
|
|
53
|
+
// Reserved for documentation and tests (RFC 2606, RFC 6761): never anybody's employer.
|
|
54
|
+
const RESERVED_EMAIL_DOMAIN_RE = /(^|\.)(example\.(com|net|org)|example|test|invalid|localhost)$/;
|
|
55
|
+
|
|
56
|
+
function emailDomain(email) {
|
|
57
|
+
return String(email).split('@').pop().toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
function isPersonalEmail(email) {
|
|
49
|
-
|
|
61
|
+
const domain = emailDomain(email);
|
|
62
|
+
return PERSONAL_EMAIL_DOMAINS.has(domain) || PERSONAL_EMAIL_PROVIDERS.has(domain.split('.')[0]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Only an address that looks like a company one gets the hint to prefer a personal email.
|
|
66
|
+
function looksLikeWorkEmail(email) {
|
|
67
|
+
return !isPersonalEmail(email) && !RESERVED_EMAIL_DOMAIN_RE.test(emailDomain(email));
|
|
50
68
|
}
|
|
51
69
|
|
|
52
70
|
function isLinkedinProfileUrl(url) {
|
|
@@ -142,6 +160,7 @@ const WELCOME_SCHEMA = {
|
|
|
142
160
|
firstName: { type: 'string', description: 'Only if you already know it: the welcome greets them by name.' },
|
|
143
161
|
answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
|
|
144
162
|
cvPath: { type: 'string', description: 'When the talent\'s first message or answer already gives their CV as a path on this machine: that exact file is read, with no CV question, no search and no request for the file.' },
|
|
163
|
+
linkedinMissing: { type: 'boolean', description: 'true only after read_cv, when the CV has no LinkedIn URL: returns the LinkedIn question to print as your whole reply.' },
|
|
145
164
|
},
|
|
146
165
|
required: ['language'],
|
|
147
166
|
};
|
|
@@ -157,6 +176,15 @@ const EMAIL_SCHEMA = {
|
|
|
157
176
|
|
|
158
177
|
// In the order the data notice names them.
|
|
159
178
|
const DRAFT_SOURCES = ['linkedin', 'cv'];
|
|
179
|
+
const DRAFT_FIELDS = ['name', 'role', 'city', 'country', 'situation', 'participation', 'yearsOfExperience', 'stack', 'languages', 'workMode', 'monthlyHours', 'hourlyRate', 'annualRate'];
|
|
180
|
+
// Draft fields per funnel step, for was_edited.
|
|
181
|
+
const STEP_DRAFT_FIELDS = {
|
|
182
|
+
work_situation: ['situation', 'participation'],
|
|
183
|
+
availability: ['monthlyHours', 'workMode'],
|
|
184
|
+
rates: ['hourlyRate', 'annualRate'],
|
|
185
|
+
languages: ['languages'],
|
|
186
|
+
location: ['country', 'city'],
|
|
187
|
+
};
|
|
160
188
|
|
|
161
189
|
const DRAFT_SCHEMA = {
|
|
162
190
|
type: 'object',
|
|
@@ -165,6 +193,9 @@ const DRAFT_SCHEMA = {
|
|
|
165
193
|
name: { type: 'string', description: 'Full name.' },
|
|
166
194
|
role: { type: 'string', description: 'Role, e.g. "Data Engineer".' },
|
|
167
195
|
city: { type: 'string' },
|
|
196
|
+
country: { type: 'string', description: 'ISO-2 country code (ES, US, GB): the same country save_profile_details will write.' },
|
|
197
|
+
situation: { type: 'string', enum: WORK_SITUATIONS, description: 'Current work situation, the same code save_profile_details will write.' },
|
|
198
|
+
participation: { type: 'string', enum: EMPLOYMENT_PARTICIPATIONS, description: 'Only when situation is EMPLOYED.' },
|
|
168
199
|
yearsOfExperience: { type: 'number' },
|
|
169
200
|
stack: { type: 'string', description: 'Main technologies, comma separated, at most five.' },
|
|
170
201
|
languages: { type: 'string', description: 'Languages and level, e.g. "Español nativo, inglés C1".' },
|
|
@@ -214,6 +245,7 @@ const IMPORT_SCHEMA = {
|
|
|
214
245
|
websiteUrl: { type: 'string' },
|
|
215
246
|
userQuery: { type: 'string', description: 'Everything you know about the talent, as free text.' },
|
|
216
247
|
language: { type: 'string', enum: [...IMPORT_LANGUAGES] },
|
|
248
|
+
answer: { type: 'string', description: 'The option the talent picked when you printed the question this tool returned about the CV file. Never set it yourself.' },
|
|
217
249
|
},
|
|
218
250
|
};
|
|
219
251
|
|
|
@@ -272,6 +304,8 @@ function makeSignupTools(deps = {}) {
|
|
|
272
304
|
fetchPricingRate = (opts) => require('./rate-client').fetchPricingRate({}, opts),
|
|
273
305
|
fetchAvailability = (opts) => require('./availability-client').fetchAvailability({}, opts),
|
|
274
306
|
fetchLanguages = (opts) => require('./profile-client').fetchLanguages({}, opts),
|
|
307
|
+
funnel = NOOP_FUNNEL,
|
|
308
|
+
fetchTelephone = (opts) => require('./profile-client').fetchTelephone({}, opts),
|
|
275
309
|
openBrowser = require('./open-file').openPath,
|
|
276
310
|
sleep = defaultSleep,
|
|
277
311
|
now = () => Date.now(),
|
|
@@ -284,21 +318,31 @@ function makeSignupTools(deps = {}) {
|
|
|
284
318
|
sessionToken: null,
|
|
285
319
|
email: null,
|
|
286
320
|
draft: null,
|
|
321
|
+
firstDraft: null,
|
|
287
322
|
draftConfirmed: false,
|
|
288
323
|
keepProfile: false,
|
|
289
324
|
// Fields the update question already told the talent they keep.
|
|
290
325
|
toldKept: new Set(),
|
|
326
|
+
// The confirmed draft already said the account exists (this machine knew it).
|
|
327
|
+
toldExisting: false,
|
|
291
328
|
sources: null,
|
|
292
329
|
account: { state: 'not-started' },
|
|
293
330
|
reported: { account: null, import: null },
|
|
294
331
|
failedSources: new Set(),
|
|
295
332
|
cvPath: null,
|
|
296
333
|
wake: null,
|
|
334
|
+
importStartedAt: null,
|
|
335
|
+
importReported: new Set(),
|
|
297
336
|
};
|
|
298
337
|
currentSignup = state;
|
|
299
338
|
|
|
300
339
|
function setAccount(next) {
|
|
340
|
+
const before = state.account.state;
|
|
301
341
|
state.account = next;
|
|
342
|
+
if ((next.state === 'created' || next.state === 'existing') && before !== next.state) {
|
|
343
|
+
funnel.completed('account_creation', { auth_method: next.via, is_returning_account: next.state === 'existing' });
|
|
344
|
+
funnel.identified();
|
|
345
|
+
}
|
|
302
346
|
if (state.wake) state.wake();
|
|
303
347
|
}
|
|
304
348
|
|
|
@@ -398,42 +442,60 @@ function makeSignupTools(deps = {}) {
|
|
|
398
442
|
return Promise.race([announced, sleep(2000).then(() => state.windowUrl)]);
|
|
399
443
|
}
|
|
400
444
|
|
|
401
|
-
// Step 1: welcome, value proposal, the three steps and one question (
|
|
445
|
+
// Step 1: welcome, value proposal, the three steps and one question (send me your CV, or shall I look for it here?).
|
|
402
446
|
async function signupStart(args = {}, ctx = {}) {
|
|
447
|
+
// The LinkedIn question is a tool block, so a welcome still pending is read before it and the AI words nothing.
|
|
448
|
+
if (args.linkedinMissing === true && !nonEmpty(args.answer)) {
|
|
449
|
+
const c = copy();
|
|
450
|
+
return {
|
|
451
|
+
ok: true,
|
|
452
|
+
step: 'linkedin-ask',
|
|
453
|
+
say: [...takeNotices(), c.linkedinAsk].join('\n\n'),
|
|
454
|
+
linkedinOpening: c.linkedinOpening,
|
|
455
|
+
message: 'Print `say` word for word as your whole reply, with nothing of your own before or after it, and call no tool until the talent answers. With their URL, call signup_email. Only if they say they do not know where to find it, say `linkedinOpening` word for word and call open_linkedin_profile.',
|
|
456
|
+
};
|
|
457
|
+
}
|
|
403
458
|
if (!nonEmpty(args.answer)) {
|
|
404
459
|
fixSignupLanguage(args.language);
|
|
460
|
+
funnel.begin();
|
|
461
|
+
funnel.viewed('start');
|
|
405
462
|
state.draft = null;
|
|
463
|
+
state.firstDraft = null;
|
|
464
|
+
state.importStartedAt = null;
|
|
465
|
+
state.importReported = new Set();
|
|
406
466
|
state.draftConfirmed = false;
|
|
407
467
|
state.email = null;
|
|
408
468
|
state.failedSources.clear();
|
|
409
469
|
state.toldKept = new Set();
|
|
470
|
+
state.toldExisting = false;
|
|
410
471
|
state.cvPath = null;
|
|
411
472
|
takeNotices();
|
|
412
473
|
}
|
|
413
474
|
if (nonEmpty(args.cvPath)) state.cvPath = nonEmpty(args.cvPath);
|
|
414
475
|
const c = copy();
|
|
415
|
-
const linkedin =
|
|
416
|
-
// A CV path in the first message already answers the CV question:
|
|
476
|
+
const linkedin = 'LinkedIn: use the URL in the CV (read_cv returns links.linkedin) and call signup_email. Only if the CV has none, call signup_start with linkedinMissing:true: it returns the LinkedIn question to print. Never ask for LinkedIn in your own words.';
|
|
477
|
+
// A CV path in the first message already answers the CV question: that CV is read, and the welcome
|
|
478
|
+
// rides on the next question block, because models skip a relay that comes before another tool call.
|
|
417
479
|
if (!nonEmpty(args.answer) && state.cvPath) {
|
|
480
|
+
funnel.completed('start');
|
|
481
|
+
queueNotice(c.welcome(nonEmpty(args.firstName)));
|
|
418
482
|
return {
|
|
419
483
|
ok: true,
|
|
420
484
|
step: 'cv-given',
|
|
421
485
|
cvPath: state.cvPath,
|
|
422
|
-
relayVerbatim: c.welcome(nonEmpty(args.firstName)),
|
|
423
|
-
linkedinAsk: c.linkedinAsk,
|
|
424
|
-
linkedinOpening: c.linkedinOpening,
|
|
425
486
|
language: lang(),
|
|
426
|
-
message:
|
|
487
|
+
message: `Never explain to the talent what you are doing or what the CV holds: every text they read in this step comes from the tools, the welcome included. Now call read_cv with cvPath: it asks the talent itself before reading the file, so ask nothing about the CV in your own words. ${linkedin}`,
|
|
427
488
|
};
|
|
428
489
|
}
|
|
429
490
|
const asked = await askAfterTexts(ctx, {
|
|
430
491
|
tool: 'signup_start',
|
|
431
492
|
texts: [c.welcome(nonEmpty(args.firstName))],
|
|
432
493
|
question: c.welcomeQuestion,
|
|
433
|
-
options: [c.
|
|
494
|
+
options: [c.cvAttach, c.cvSearch],
|
|
434
495
|
answer: args.answer,
|
|
435
496
|
});
|
|
436
497
|
if (asked.reply) return { ...asked.reply, language: lang() };
|
|
498
|
+
funnel.completed('start');
|
|
437
499
|
// A CV path the talent gave wins over both answers: searching could read another candidate.
|
|
438
500
|
const cvPath = state.cvPath;
|
|
439
501
|
if (cvPath) {
|
|
@@ -441,9 +503,7 @@ function makeSignupTools(deps = {}) {
|
|
|
441
503
|
ok: true,
|
|
442
504
|
step: 'cv-given',
|
|
443
505
|
cvPath,
|
|
444
|
-
|
|
445
|
-
linkedinOpening: c.linkedinOpening,
|
|
446
|
-
message: `The talent already gave their CV: read_cv it now with talentConsent:true, saying nothing about attaching it. ${linkedin}`,
|
|
506
|
+
message: `The talent already gave their CV: call read_cv with cvPath now (it asks the talent itself before reading the file), saying nothing about attaching it. ${linkedin}`,
|
|
447
507
|
};
|
|
448
508
|
}
|
|
449
509
|
if (asked.answer === c.cvSearch) {
|
|
@@ -451,17 +511,13 @@ function makeSignupTools(deps = {}) {
|
|
|
451
511
|
ok: true,
|
|
452
512
|
step: 'cv-search',
|
|
453
513
|
cvNotFound: c.cvNotFound,
|
|
454
|
-
|
|
455
|
-
linkedinOpening: c.linkedinOpening,
|
|
456
|
-
message: `The talent allowed you to look for their CV: call suggest_register_context, then read_cv on its first candidate without asking again, even when nameMatch is null (their name is just unknown). Only if there is no candidate, or every one comes back mentionsTalent:false, show \`cvNotFound\` word for word and wait for the file. ${linkedin}`,
|
|
514
|
+
message: `The talent chose to look for their CV on this computer: call suggest_register_context, then read_cv on its first candidate, even when nameMatch is null (their name is just unknown). Each of them asks the talent itself before searching or reading, so ask nothing in your own words. Only if there is no candidate, or every one comes back mentionsTalent:false, show \`cvNotFound\` word for word and wait for the file. ${linkedin}`,
|
|
457
515
|
};
|
|
458
516
|
}
|
|
459
517
|
return {
|
|
460
518
|
ok: true,
|
|
461
519
|
step: 'cv-attach',
|
|
462
520
|
relayVerbatim: c.cvAttachAsk,
|
|
463
|
-
linkedinAsk: c.linkedinAsk,
|
|
464
|
-
linkedinOpening: c.linkedinOpening,
|
|
465
521
|
message: `If their message already holds the CV (attached, pasted or as a path), do not print relayVerbatim: use that CV. Otherwise ${VERBATIM} Then wait for the CV in the chat. ${linkedin}`,
|
|
466
522
|
};
|
|
467
523
|
}
|
|
@@ -478,7 +534,7 @@ function makeSignupTools(deps = {}) {
|
|
|
478
534
|
if (!email) {
|
|
479
535
|
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.` };
|
|
480
536
|
}
|
|
481
|
-
const question =
|
|
537
|
+
const question = looksLikeWorkEmail(email) ? `${c.emailKnown(email)} ${c.emailWorkHint}` : c.emailKnown(email);
|
|
482
538
|
const asked = await askAfterTexts(ctx, { tool: 'signup_email', texts: [], question, options: [c.emailYes, c.emailOther], answer: args.answer });
|
|
483
539
|
if (asked.reply) return asked.reply;
|
|
484
540
|
if (asked.answer === c.emailYes) {
|
|
@@ -501,9 +557,10 @@ function makeSignupTools(deps = {}) {
|
|
|
501
557
|
const pricing = pricingFromRates(args);
|
|
502
558
|
if (!pricing.ok) return pricing;
|
|
503
559
|
const draft = {};
|
|
504
|
-
for (const key of
|
|
560
|
+
for (const key of DRAFT_FIELDS) {
|
|
505
561
|
if (args[key] !== undefined && args[key] !== null && args[key] !== '') draft[key] = args[key];
|
|
506
562
|
}
|
|
563
|
+
if (typeof draft.country === 'string') draft.country = draft.country.trim().toUpperCase();
|
|
507
564
|
if (!draft.name || !draft.role) return { ok: false, reason: 'draft-incomplete', message: 'The draft needs at least name and role: read the CV first.' };
|
|
508
565
|
if (draft.hourlyRate === undefined || draft.annualRate === undefined) {
|
|
509
566
|
return { ok: false, reason: 'draft-rates-required', message: 'The draft needs both rates: if the talent gave them, send them with ratesFromTalent:true; otherwise estimate hourlyRate (€/h) and annualRate (€/year) from role, seniority, stack and location, and send them without it. Then call signup_draft again.' };
|
|
@@ -514,6 +571,7 @@ function makeSignupTools(deps = {}) {
|
|
|
514
571
|
if (!sources.length && !optionFor(args.answer, [c.draftOk, c.draftChange])) {
|
|
515
572
|
return { ok: false, reason: 'draft-sources-required', message: "The draft needs sources: 'cv' if you read their CV, 'linkedin' only if you have their LinkedIn URL. Then call signup_draft again." };
|
|
516
573
|
}
|
|
574
|
+
if (!state.firstDraft) state.firstDraft = draft;
|
|
517
575
|
const asked = await askAfterTexts(ctx, {
|
|
518
576
|
tool: 'signup_draft',
|
|
519
577
|
texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice(sources), knownAccount() ? c.windowNoticeSignIn : c.windowNotice],
|
|
@@ -528,6 +586,7 @@ function makeSignupTools(deps = {}) {
|
|
|
528
586
|
}
|
|
529
587
|
state.draft = draft;
|
|
530
588
|
state.draftConfirmed = true;
|
|
589
|
+
state.toldExisting = knownAccount();
|
|
531
590
|
return {
|
|
532
591
|
ok: true,
|
|
533
592
|
next: 'signup_create_account',
|
|
@@ -592,7 +651,16 @@ function makeSignupTools(deps = {}) {
|
|
|
592
651
|
state.sources = sources;
|
|
593
652
|
state.reported = { account: 'waiting', import: 'none' };
|
|
594
653
|
const reused = state.account.state === 'waiting';
|
|
595
|
-
if (!
|
|
654
|
+
if (!state.importStartedAt) {
|
|
655
|
+
const attempted = DRAFT_SOURCES.filter((source) => (source === 'cv' ? !!cv : !!sources.linkedinUrl));
|
|
656
|
+
state.importStartedAt = Object.fromEntries(attempted.map((source) => [source, now()]));
|
|
657
|
+
for (const source of attempted) funnel.track('source_import_started', 'import_sources', { source_type: source });
|
|
658
|
+
funnel.completed('import_sources', { sources_attempted: attempted });
|
|
659
|
+
}
|
|
660
|
+
if (!reused) {
|
|
661
|
+
funnel.viewed('account_creation');
|
|
662
|
+
await openWindow(identity);
|
|
663
|
+
}
|
|
596
664
|
return {
|
|
597
665
|
ok: true,
|
|
598
666
|
status: 'window-open',
|
|
@@ -603,23 +671,45 @@ function makeSignupTools(deps = {}) {
|
|
|
603
671
|
};
|
|
604
672
|
}
|
|
605
673
|
|
|
674
|
+
function trackImportOutcome(imp) {
|
|
675
|
+
if (!state.importStartedAt) return;
|
|
676
|
+
let tracked = false;
|
|
677
|
+
for (const [source, startedAt] of Object.entries(state.importStartedAt)) {
|
|
678
|
+
if (state.importReported.has(source)) continue;
|
|
679
|
+
const outcome = imp.sources && imp.sources[source] ? imp.sources[source].state : (imp.state === 'failed' ? 'failed' : null);
|
|
680
|
+
if (outcome !== 'done' && outcome !== 'failed') continue;
|
|
681
|
+
state.importReported.add(source);
|
|
682
|
+
const code = imp.sources && imp.sources[source] && imp.sources[source].code ? imp.sources[source].code : imp.code;
|
|
683
|
+
funnel.track(outcome === 'done' ? 'source_import_completed' : 'source_import_failed', 'import_sources', {
|
|
684
|
+
source_type: source,
|
|
685
|
+
duration_ms: Math.max(0, now() - startedAt),
|
|
686
|
+
...(outcome === 'failed' && code ? { failure_reason: code } : {}),
|
|
687
|
+
});
|
|
688
|
+
tracked = true;
|
|
689
|
+
}
|
|
690
|
+
if (tracked) funnel.flush();
|
|
691
|
+
}
|
|
692
|
+
|
|
606
693
|
async function readImport() {
|
|
607
694
|
// An account that already existed got no import from this sign-up: its last import is not news.
|
|
608
695
|
if (state.account.state === 'existing') return { state: 'none' };
|
|
609
696
|
const session = resolved.loadAuthSession();
|
|
610
697
|
if (resolved.sessionStatus(session) !== 'active' || !session || !session.hubAccessToken) return { state: 'none' };
|
|
611
698
|
const res = await requestMyImportStatus({ hubAccessToken: session.hubAccessToken }, { endpoint: getMyImportStatusEndpoint() });
|
|
612
|
-
|
|
699
|
+
if (!res.ok) return { state: 'unknown', reason: res.reason };
|
|
700
|
+
const imp = { state: res.state, sources: res.sources, ...(res.code ? { code: res.code } : {}) };
|
|
701
|
+
trackImportOutcome(imp);
|
|
702
|
+
return imp;
|
|
613
703
|
}
|
|
614
704
|
|
|
615
705
|
function nextSteps(account) {
|
|
616
|
-
const interview = 'Then the onboarding interview: if onboardingInterview.completed is true
|
|
706
|
+
const interview = 'Then the onboarding interview: if onboardingInterview.completed is true, skip it without mentioning 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), then set_main_role without clusterId only when its next says so. Finally open_web.';
|
|
617
707
|
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).';
|
|
618
708
|
if (account.state === 'existing') {
|
|
619
|
-
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}`;
|
|
709
|
+
return `This talent already had an account and signed in. Call update_existing_profile: it shows what would change and asks whether to update, and its text tells the talent about the account, so say nothing about it yourself. ${aiUsage} ${interview}`;
|
|
620
710
|
}
|
|
621
711
|
const unclaimed = account.claimed === false ? ' This account did not take over the imported profile: call import_profile with the same LinkedIn/CV first.' : '';
|
|
622
|
-
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}`;
|
|
712
|
+
return `The account exists.${unclaimed} Now, without asking: save_profile_details with the confirmed draft (situation, motivation, both rates, availability and location with its country, languages). ${aiUsage} ${interview}`;
|
|
623
713
|
}
|
|
624
714
|
|
|
625
715
|
// A source that did not import gets its fixed line once, on the next question block.
|
|
@@ -672,6 +762,19 @@ function makeSignupTools(deps = {}) {
|
|
|
672
762
|
return out;
|
|
673
763
|
}
|
|
674
764
|
|
|
765
|
+
// The tool: a CV file from this computer is sent only after the talent says yes to that file.
|
|
766
|
+
async function importProfileAsked(args = {}, ctx = {}) {
|
|
767
|
+
if (nonEmpty(args.cvPath)) {
|
|
768
|
+
const file = path.resolve(expandHome(nonEmpty(args.cvPath)));
|
|
769
|
+
const c = copy().consent;
|
|
770
|
+
const asked = await askConsent(ctx, { tool: 'import_profile', key: `import_profile:${file}`, question: c.cvSend(tildify(file)), yes: c.yes, no: c.no, answer: args.answer });
|
|
771
|
+
if (asked.reply) return asked.reply;
|
|
772
|
+
if (!asked.granted) return { ok: false, reason: 'consent-declined', message: 'The talent said no: nothing was sent. Do not send that file; call import_profile again without cvPath if the other sources still need importing.' };
|
|
773
|
+
return importProfile({ ...args, cvPath: file });
|
|
774
|
+
}
|
|
775
|
+
return importProfile(args);
|
|
776
|
+
}
|
|
777
|
+
|
|
675
778
|
async function importProfile(args = {}) {
|
|
676
779
|
if (nonEmpty(args.linkedinUrl) && !isLinkedinProfileUrl(nonEmpty(args.linkedinUrl))) return { ok: false, reason: 'invalid-linkedin-url', message: INVALID_LINKEDIN_MESSAGE };
|
|
677
780
|
const res = await flow.startProfileImport(resolved, {
|
|
@@ -685,7 +788,7 @@ function makeSignupTools(deps = {}) {
|
|
|
685
788
|
});
|
|
686
789
|
if (!res.ok) {
|
|
687
790
|
if (res.reason === 'no-session') {
|
|
688
|
-
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:
|
|
791
|
+
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: sign them in with social_signin_start first.' };
|
|
689
792
|
}
|
|
690
793
|
return { ok: false, reason: res.reason };
|
|
691
794
|
}
|
|
@@ -748,7 +851,7 @@ function makeSignupTools(deps = {}) {
|
|
|
748
851
|
state.toldKept = kept;
|
|
749
852
|
const given = state.sources ? [state.sources.linkedinUrl && 'linkedin', state.sources.cvPath && 'cv'].filter(Boolean) : [];
|
|
750
853
|
if (given.length) lines.push(c.existingLists(given));
|
|
751
|
-
return `${c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
|
|
854
|
+
return `${state.toldExisting ? c.existingUpdateQuestion : c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
|
|
752
855
|
}
|
|
753
856
|
|
|
754
857
|
// The sections of save_profile_details an existing profile can take (only fields it leaves empty), and what it keeps instead.
|
|
@@ -844,6 +947,40 @@ function makeSignupTools(deps = {}) {
|
|
|
844
947
|
return c.ratesSaved(shown, !(state.draft && state.draft.ratesFromTalent));
|
|
845
948
|
}
|
|
846
949
|
|
|
950
|
+
function wasEdited(step) {
|
|
951
|
+
if (!state.firstDraft || !state.draft) return undefined;
|
|
952
|
+
return STEP_DRAFT_FIELDS[step].some((key) => String(state.firstDraft[key] ?? '') !== String(state.draft[key] ?? ''));
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function trackSavedSteps(details, results) {
|
|
956
|
+
const saved = (section) => results[section] && results[section].ok === true;
|
|
957
|
+
const step = (name, props) => funnel.completed(name, { ...props, is_prefilled: true, was_edited: wasEdited(name) });
|
|
958
|
+
if (saved('workSituation')) {
|
|
959
|
+
step('work_situation', { work_situation: workSituationValue(String(details.workSituation.situation || '').trim().toUpperCase()) });
|
|
960
|
+
}
|
|
961
|
+
if (saved('availability')) {
|
|
962
|
+
const a = details.availability;
|
|
963
|
+
step('availability', { availability_hours_per_week: weeklyHours(a.monthlyHours) });
|
|
964
|
+
const country = typeof a.country === 'string' && /^[A-Za-z]{2}$/.test(a.country.trim()) ? a.country.trim().toUpperCase() : null;
|
|
965
|
+
const proposed = state.firstDraft && state.firstDraft.country;
|
|
966
|
+
const modes = Array.isArray(a.workModes) ? a.workModes.map((m) => String(m).toUpperCase()) : [];
|
|
967
|
+
step('location', {
|
|
968
|
+
location_country: country ? country.toLowerCase() : undefined,
|
|
969
|
+
location_method: country && proposed === country ? 'prefilled' : 'manual',
|
|
970
|
+
is_remote_only: modes.length ? modes.every((m) => m === 'REMOTE') : undefined,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
if (saved('pricing')) {
|
|
974
|
+
const types = rateTypes(pricingFromRates(details.pricing).input);
|
|
975
|
+
funnel.completed('rates', { rate_types_selected: types, rate_type: types[0], rate_currency: 'EUR', is_prefilled: !(state.draft && state.draft.ratesFromTalent), was_edited: wasEdited('rates') });
|
|
976
|
+
}
|
|
977
|
+
if (saved('languages')) {
|
|
978
|
+
const codes = details.languages.map((row) => String(row.language || '').trim().toLowerCase()).filter(Boolean);
|
|
979
|
+
step('languages', { languages_count: codes.length, languages: codes });
|
|
980
|
+
}
|
|
981
|
+
funnel.flush();
|
|
982
|
+
}
|
|
983
|
+
|
|
847
984
|
async function saveProfileDetails(args = {}) {
|
|
848
985
|
const { saved } = copy();
|
|
849
986
|
const existing = state.account.state === 'existing';
|
|
@@ -858,17 +995,27 @@ function makeSignupTools(deps = {}) {
|
|
|
858
995
|
['availability', details.availability, (v) => flow.saveAvailability(resolved, v), saved.availability],
|
|
859
996
|
['languages', Array.isArray(details.languages) && details.languages.length ? details.languages : null, (v) => flow.saveLanguages(resolved, v), saved.languages],
|
|
860
997
|
['phone', details.phone, (v) => flow.savePhone(resolved, v), saved.phone],
|
|
861
|
-
];
|
|
998
|
+
].filter(([, value]) => value && typeof value === 'object');
|
|
999
|
+
// Hub's async profile-completion recalculation saves the whole users row from a copy it read earlier, so a
|
|
1000
|
+
// phone written while it runs comes back NULL: the phone goes first, and is read back once the rest is saved.
|
|
1001
|
+
const phoneFirst = [...sections.filter(([name]) => name === 'phone'), ...sections.filter(([name]) => name !== 'phone')];
|
|
862
1002
|
const results = {};
|
|
863
|
-
const
|
|
864
|
-
for (const [name, value, save, saved] of sections) {
|
|
865
|
-
if (!value || typeof value !== 'object') continue;
|
|
1003
|
+
for (const [name, value, save] of phoneFirst) {
|
|
866
1004
|
const res = await save(value);
|
|
867
|
-
if (res.ok) confirmations.push(saved);
|
|
868
1005
|
if (!res.ok && res.reason === 'no-session') return { ok: false, reason: 'no-session', message: 'The talent is not signed in yet.' };
|
|
869
1006
|
const help = res.message || SECTION_HELP[res.reason];
|
|
870
1007
|
results[name] = res.ok ? { ok: true } : { ok: false, reason: res.reason, ...(help ? { message: help } : {}) };
|
|
871
1008
|
}
|
|
1009
|
+
if (results.phone && results.phone.ok) {
|
|
1010
|
+
const session = resolved.loadAuthSession();
|
|
1011
|
+
const stored = await fetchTelephone({ hubAccessToken: session && session.hubAccessToken }).catch(() => ({ ok: false }));
|
|
1012
|
+
if (stored.ok && !stored.telephone) {
|
|
1013
|
+
const again = await flow.savePhone(resolved, details.phone);
|
|
1014
|
+
results.phone = again.ok ? { ok: true } : { ok: false, reason: again.reason };
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
trackSavedSteps(details, results);
|
|
1018
|
+
const confirmations = sections.filter(([name]) => results[name] && results[name].ok).map(([, , , text]) => text);
|
|
872
1019
|
const ok = Object.values(results).every((r) => r.ok);
|
|
873
1020
|
const report = keptReport(kept);
|
|
874
1021
|
if (report.line) queueNotice(report.line);
|
|
@@ -898,32 +1045,32 @@ function makeSignupTools(deps = {}) {
|
|
|
898
1045
|
if (!url) return { ok: false, reason: 'no-endpoint' };
|
|
899
1046
|
// The address reaches the AI only when the talent needs a link: the AI comments on hosts it sees.
|
|
900
1047
|
const opened = args.open !== false && openBrowser(url) !== false;
|
|
1048
|
+
// Read right before the closing words, so they never state an import state that is already stale.
|
|
1049
|
+
const imp = await readImport();
|
|
1050
|
+
// A source that failed after the last signup_status gets its fixed line here, as there is no question left.
|
|
1051
|
+
const failed = DRAFT_SOURCES.filter((s) => imp.sources && imp.sources[s] && imp.sources[s].state === 'failed');
|
|
1052
|
+
noticeFailed(failed);
|
|
901
1053
|
// The flow ends here, so a fixed line no question carried is printed now.
|
|
902
1054
|
const unseen = takeNotices();
|
|
903
1055
|
const relay = unseen.length ? { relayVerbatim: unseen.join('\n\n') } : {};
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
...relay,
|
|
915
|
-
message: relayFirst + (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.')
|
|
916
|
-
+ ` If they say it did not open, call open_web with open:false and give them its link. ${importLine}`,
|
|
917
|
-
};
|
|
918
|
-
}
|
|
1056
|
+
const importLine = failed.length
|
|
1057
|
+
? `The import of ${failed.join(' and ')} failed and its fixed line is the only thing to say about it: never say or imply it was imported; if you mention the import, name only what did import.`
|
|
1058
|
+
: IMPORT_CLOSING[imp.state] || IMPORT_CLOSING.none;
|
|
1059
|
+
const where = opened
|
|
1060
|
+
? `Shakers is open in their browser${loggedIn ? ', already signed in' : ' at the sign-in page, where they sign in'}`
|
|
1061
|
+
: `they open Shakers from \`link\` (give it to click, with no comment on the address${loggedIn ? '; it works once, for 2 minutes' : ''})`;
|
|
1062
|
+
const closing = `one short closing sentence of your own, in the talent's language, saying ${where}.`;
|
|
1063
|
+
const reply = unseen.length
|
|
1064
|
+
? `Your reply has two parts and needs both: first relayVerbatim word for word as its own block, then ${closing}`
|
|
1065
|
+
: `End with ${closing}`;
|
|
919
1066
|
return {
|
|
920
1067
|
ok: true,
|
|
921
|
-
opened
|
|
1068
|
+
opened,
|
|
922
1069
|
loggedIn,
|
|
923
|
-
link: url,
|
|
1070
|
+
...(opened ? {} : { link: url }),
|
|
924
1071
|
import: { state: imp.state },
|
|
925
1072
|
...relay,
|
|
926
|
-
message: `${
|
|
1073
|
+
message: `${reply}${opened ? ' If they say it did not open, call open_web with open:false and give them its link.' : ''} ${importLine}`,
|
|
927
1074
|
};
|
|
928
1075
|
}
|
|
929
1076
|
|
|
@@ -940,65 +1087,85 @@ function makeSignupTools(deps = {}) {
|
|
|
940
1087
|
return [
|
|
941
1088
|
{
|
|
942
1089
|
name: 'signup_start',
|
|
943
|
-
|
|
1090
|
+
title: 'Start your Shakers sign-up',
|
|
1091
|
+
description: 'Starts your sign-up here in the chat and asks whether you send your CV or want it looked for on this computer.',
|
|
1092
|
+
guidance: "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 (send me your CV, or shall I look for it 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 `say` or relayVerbatim is printed word for word, in that language.",
|
|
944
1093
|
inputSchema: WELCOME_SCHEMA,
|
|
945
1094
|
handler: signupStart,
|
|
946
1095
|
},
|
|
947
1096
|
{
|
|
948
1097
|
name: 'signup_email',
|
|
949
|
-
|
|
1098
|
+
title: 'Choose your account email',
|
|
1099
|
+
description: 'Asks which email your Shakers account will use.',
|
|
1100
|
+
guidance: '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.',
|
|
950
1101
|
inputSchema: EMAIL_SCHEMA,
|
|
951
1102
|
handler: signupEmail,
|
|
952
1103
|
},
|
|
953
1104
|
{
|
|
954
1105
|
name: 'signup_draft',
|
|
955
|
-
|
|
1106
|
+
title: 'Review your profile draft',
|
|
1107
|
+
description: 'Shows the profile draft prepared from your CV and LinkedIn, explains how your data is used and asks you to confirm it. Your account is not created until you confirm.',
|
|
1108
|
+
guidance: '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 how the account window works, and asks them to confirm it. The window to create the account opens only after they confirm.',
|
|
956
1109
|
inputSchema: DRAFT_SCHEMA,
|
|
957
1110
|
handler: signupDraft,
|
|
958
1111
|
},
|
|
959
1112
|
{
|
|
960
1113
|
name: 'signup_create_account',
|
|
961
|
-
|
|
1114
|
+
title: 'Create your account',
|
|
1115
|
+
description: 'Once you confirm the draft, sends your CV, LinkedIn and draft to Shakers and opens a browser window where you create your account with Google or a password. Your password never goes through the chat.',
|
|
1116
|
+
guidance: "After the talent confirmed the draft: 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.",
|
|
962
1117
|
inputSchema: CREATE_SCHEMA,
|
|
963
1118
|
handler: signupCreateAccount,
|
|
964
1119
|
},
|
|
965
1120
|
{
|
|
966
1121
|
name: 'signup_status',
|
|
967
|
-
|
|
1122
|
+
title: 'Sign-up progress',
|
|
1123
|
+
description: 'Shows whether your account is ready and how the import of your profile is going.',
|
|
1124
|
+
guidance: "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.",
|
|
968
1125
|
inputSchema: STATUS_SCHEMA,
|
|
969
1126
|
handler: signupStatus,
|
|
970
1127
|
},
|
|
971
1128
|
{
|
|
972
1129
|
name: 'update_existing_profile',
|
|
973
|
-
|
|
1130
|
+
title: 'Update your existing profile',
|
|
1131
|
+
description: 'If you already had an account, shows what your draft would add to the empty fields of your profile and asks before changing anything. It never overwrites what you already filled in.',
|
|
1132
|
+
guidance: 'When signup_status says the talent already had an account: shows what the confirmed draft would fill in the empty fields of their profile and the value each field they already had keeps, and asks whether to update it; on yes it imports their LinkedIn/CV filling only what is empty.',
|
|
974
1133
|
inputSchema: UPDATE_EXISTING_SCHEMA,
|
|
975
1134
|
handler: updateExistingProfile,
|
|
976
1135
|
},
|
|
977
1136
|
{
|
|
978
1137
|
name: 'import_profile',
|
|
979
|
-
|
|
1138
|
+
title: 'Import into your profile',
|
|
1139
|
+
description: 'Sends your LinkedIn, GitHub or website to Shakers to fill the empty parts of your profile, and a CV file from this computer only after asking you first. What you already wrote is kept.',
|
|
1140
|
+
guidance: "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. With cvPath the tool first asks the talent, naming the file (a dialog when the client has one, otherwise it returns `say` to print word for word as your whole reply); nothing is sent until they answer yes. After a `say`, call it again with the same arguments and answer set to the option they pick. Never answer for them. Takes up to ~90 s.",
|
|
980
1141
|
inputSchema: IMPORT_SCHEMA,
|
|
981
|
-
handler:
|
|
1142
|
+
handler: importProfileAsked,
|
|
982
1143
|
},
|
|
983
1144
|
{
|
|
984
1145
|
name: 'save_profile_details',
|
|
985
|
-
|
|
1146
|
+
title: 'Save your profile details',
|
|
1147
|
+
description: 'Saves the details you confirmed in your draft (work situation, rates, availability, location, languages and phone) to your Shakers profile. On an existing account it only fills empty fields.',
|
|
1148
|
+
guidance: "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. On an account that already existed it only fills what the profile leaves empty, never overwrites a value the talent set.",
|
|
986
1149
|
inputSchema: buildDetailsSchema(deps.lang || 'en'),
|
|
987
1150
|
handler: saveProfileDetails,
|
|
988
1151
|
},
|
|
989
1152
|
{
|
|
990
1153
|
name: 'open_web',
|
|
991
|
-
|
|
1154
|
+
title: 'Open Shakers in your browser',
|
|
1155
|
+
description: 'Opens Shakers in your browser, already signed in.',
|
|
1156
|
+
guidance: "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.",
|
|
992
1157
|
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.' } } },
|
|
993
1158
|
handler: openWeb,
|
|
994
1159
|
},
|
|
995
1160
|
{
|
|
996
1161
|
name: 'open_linkedin_profile',
|
|
997
|
-
|
|
1162
|
+
title: 'Open your LinkedIn profile',
|
|
1163
|
+
description: 'Opens your own LinkedIn profile in your browser so you can copy its address.',
|
|
1164
|
+
guidance: "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.",
|
|
998
1165
|
inputSchema: { type: 'object', properties: {} },
|
|
999
1166
|
handler: openLinkedinProfile,
|
|
1000
1167
|
},
|
|
1001
1168
|
];
|
|
1002
1169
|
}
|
|
1003
1170
|
|
|
1004
|
-
module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };
|
|
1171
|
+
module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, looksLikeWorkEmail, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };
|
package/src/mcp-socials-tools.js
CHANGED
|
@@ -14,7 +14,7 @@ function makeSocialsTools(deps = {}) {
|
|
|
14
14
|
const requireLoginSession = () => {
|
|
15
15
|
const session = loadAuthSession();
|
|
16
16
|
if (sessionStatus(session) !== 'active') {
|
|
17
|
-
throw new Error('no active Shakers session —
|
|
17
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (list_socials is session-gated).');
|
|
18
18
|
}
|
|
19
19
|
return session;
|
|
20
20
|
};
|
|
@@ -29,7 +29,9 @@ function makeSocialsTools(deps = {}) {
|
|
|
29
29
|
return [
|
|
30
30
|
{
|
|
31
31
|
name: 'list_socials',
|
|
32
|
-
|
|
32
|
+
title: 'Your social links',
|
|
33
|
+
description: 'Shows the social and web links on your Shakers profile.',
|
|
34
|
+
guidance: "View the talent's social links (linkedin/github/website/twitter/instagram/facebook/dribbble/behance — null when unset). Read-only. Requires an active session.",
|
|
33
35
|
inputSchema: LIST_SOCIALS_SCHEMA,
|
|
34
36
|
handler: listSocials,
|
|
35
37
|
},
|