shakers-cli-beta 0.11.0 → 0.13.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/bin/certify.js +1 -1
- package/bin/login.js +44 -111
- package/package.json +1 -1
- package/src/agents-client.js +1 -1
- package/src/ai-profile-client.js +1 -1
- package/src/applications-client.js +1 -1
- package/src/authorship-mismatch.js +62 -0
- package/src/availability-client.js +1 -1
- package/src/certifications-client.js +1 -1
- package/src/certify-dimension-client.js +0 -79
- package/src/certify-dimension-flow.js +24 -105
- package/src/config.js +40 -22
- package/src/find-projects-client.js +1 -1
- package/src/i18n.js +34 -84
- package/src/lang-client.js +1 -1
- package/src/mcp-auth-form.js +39 -10
- package/src/mcp-choice.js +3 -3
- package/src/mcp-device-login-tools.js +3 -3
- package/src/mcp-instructions.js +7 -7
- package/src/mcp-onboarding-tools.js +10 -16
- package/src/mcp-prompts.js +1 -1
- package/src/mcp-register-tools.js +1 -1
- package/src/mcp-role-tools.js +26 -10
- package/src/mcp-server.js +9 -2
- package/src/mcp-signup-client.js +1 -1
- package/src/mcp-signup-tools.js +142 -50
- package/src/profile-client.js +12 -4
- package/src/rate-client.js +1 -2
- package/src/roles-client.js +0 -4
- package/src/saved-positions-client.js +1 -1
- package/src/show-project-client.js +1 -1
- package/src/signup-copy.js +59 -33
- package/src/terminal-agents.js +8 -0
- package/src/usage-run-steps.js +3 -0
package/src/mcp-signup-tools.js
CHANGED
|
@@ -8,7 +8,7 @@ const { signupCopy, legalCopy, renderDraft, amount } = require('./signup-copy');
|
|
|
8
8
|
const { fixSignupLanguage, signupLanguage } = require('./signup-language');
|
|
9
9
|
const { readLocalCv } = require('./cv-file');
|
|
10
10
|
const { emailFromHubToken } = require('./auth-session-store');
|
|
11
|
-
const { askAfterTexts, queueNotice, takeNotices } = require('./mcp-choice');
|
|
11
|
+
const { askAfterTexts, optionFor, queueNotice, takeNotices } = require('./mcp-choice');
|
|
12
12
|
|
|
13
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
14
|
|
|
@@ -36,10 +36,32 @@ const SECTION_HELP = {
|
|
|
36
36
|
'bad-opinion': 'workSituation.opinion is required unless situation is FREELANCE: pick your best match from its enum.',
|
|
37
37
|
'bad-motivation': 'workSituation.changeMotivators is required when situation is FREELANCE or opinion is WAS_FREELANCE_BEFORE or OPEN_TO_FREELANCE: pick your best match from its enum.',
|
|
38
38
|
};
|
|
39
|
+
// What the closing words may say about the profile import, by the state read at that moment.
|
|
40
|
+
const IMPORT_CLOSING = {
|
|
41
|
+
done: 'The profile import finished: if you mention it, say it is done, never that it is pending.',
|
|
42
|
+
running: 'The profile import is still running: if you mention it, say it finishes on its own in a few minutes.',
|
|
43
|
+
failed: 'Say nothing more about the profile import than the fixed lines already shown.',
|
|
44
|
+
none: 'Say nothing about the state of the profile import.',
|
|
45
|
+
};
|
|
39
46
|
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.';
|
|
40
47
|
|
|
48
|
+
// Providers that serve the same personal mailboxes under many country domains (hotmail.co.uk, yahoo.com.br, outlook.it).
|
|
49
|
+
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']);
|
|
50
|
+
// Reserved for documentation and tests (RFC 2606, RFC 6761): never anybody's employer.
|
|
51
|
+
const RESERVED_EMAIL_DOMAIN_RE = /(^|\.)(example\.(com|net|org)|example|test|invalid|localhost)$/;
|
|
52
|
+
|
|
53
|
+
function emailDomain(email) {
|
|
54
|
+
return String(email).split('@').pop().toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
function isPersonalEmail(email) {
|
|
42
|
-
|
|
58
|
+
const domain = emailDomain(email);
|
|
59
|
+
return PERSONAL_EMAIL_DOMAINS.has(domain) || PERSONAL_EMAIL_PROVIDERS.has(domain.split('.')[0]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Only an address that looks like a company one gets the hint to prefer a personal email.
|
|
63
|
+
function looksLikeWorkEmail(email) {
|
|
64
|
+
return !isPersonalEmail(email) && !RESERVED_EMAIL_DOMAIN_RE.test(emailDomain(email));
|
|
43
65
|
}
|
|
44
66
|
|
|
45
67
|
function isLinkedinProfileUrl(url) {
|
|
@@ -134,7 +156,8 @@ const WELCOME_SCHEMA = {
|
|
|
134
156
|
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." },
|
|
135
157
|
firstName: { type: 'string', description: 'Only if you already know it: the welcome greets them by name.' },
|
|
136
158
|
answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
|
|
137
|
-
cvPath: { type: 'string', description: 'When the talent\'s
|
|
159
|
+
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.' },
|
|
160
|
+
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.' },
|
|
138
161
|
},
|
|
139
162
|
required: ['language'],
|
|
140
163
|
};
|
|
@@ -148,6 +171,9 @@ const EMAIL_SCHEMA = {
|
|
|
148
171
|
},
|
|
149
172
|
};
|
|
150
173
|
|
|
174
|
+
// In the order the data notice names them.
|
|
175
|
+
const DRAFT_SOURCES = ['linkedin', 'cv'];
|
|
176
|
+
|
|
151
177
|
const DRAFT_SCHEMA = {
|
|
152
178
|
type: 'object',
|
|
153
179
|
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.',
|
|
@@ -163,9 +189,10 @@ const DRAFT_SCHEMA = {
|
|
|
163
189
|
hourlyRate: { type: 'number', description: `Euros per hour (part-time projects), at most ${MAX_HOURLY_RATE}: the talent's figure, else your estimate from role, seniority, stack and location.` },
|
|
164
190
|
annualRate: { type: 'number', description: `Euros per year (full-time projects), at least ${MIN_ANNUAL_RATE}: the talent's figure, else your estimate.` },
|
|
165
191
|
ratesFromTalent: { type: 'boolean', description: 'true only when the talent gave you both rates; omit it when you estimated them (they are shown as your estimate).' },
|
|
192
|
+
sources: { type: 'array', items: { type: 'string', enum: DRAFT_SOURCES }, description: "What the draft comes from: 'cv' when you read their CV, 'linkedin' only when you have their LinkedIn URL. The data notice names exactly these." },
|
|
166
193
|
answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
|
|
167
194
|
},
|
|
168
|
-
required: ['name', 'role', 'hourlyRate', 'annualRate'],
|
|
195
|
+
required: ['name', 'role', 'hourlyRate', 'annualRate', 'sources'],
|
|
169
196
|
};
|
|
170
197
|
|
|
171
198
|
const CREATE_SCHEMA = {
|
|
@@ -261,6 +288,7 @@ function makeSignupTools(deps = {}) {
|
|
|
261
288
|
fetchPricingRate = (opts) => require('./rate-client').fetchPricingRate({}, opts),
|
|
262
289
|
fetchAvailability = (opts) => require('./availability-client').fetchAvailability({}, opts),
|
|
263
290
|
fetchLanguages = (opts) => require('./profile-client').fetchLanguages({}, opts),
|
|
291
|
+
fetchTelephone = (opts) => require('./profile-client').fetchTelephone({}, opts),
|
|
264
292
|
openBrowser = require('./open-file').openPath,
|
|
265
293
|
sleep = defaultSleep,
|
|
266
294
|
now = () => Date.now(),
|
|
@@ -275,6 +303,10 @@ function makeSignupTools(deps = {}) {
|
|
|
275
303
|
draft: null,
|
|
276
304
|
draftConfirmed: false,
|
|
277
305
|
keepProfile: false,
|
|
306
|
+
// Fields the update question already told the talent they keep.
|
|
307
|
+
toldKept: new Set(),
|
|
308
|
+
// The confirmed draft already said the account exists (this machine knew it).
|
|
309
|
+
toldExisting: false,
|
|
278
310
|
sources: null,
|
|
279
311
|
account: { state: 'not-started' },
|
|
280
312
|
reported: { account: null, import: null },
|
|
@@ -385,28 +417,53 @@ function makeSignupTools(deps = {}) {
|
|
|
385
417
|
return Promise.race([announced, sleep(2000).then(() => state.windowUrl)]);
|
|
386
418
|
}
|
|
387
419
|
|
|
388
|
-
// Step 1: welcome, value proposal, the three steps and one question (
|
|
420
|
+
// Step 1: welcome, value proposal, the three steps and one question (send me your CV, or shall I look for it here?).
|
|
389
421
|
async function signupStart(args = {}, ctx = {}) {
|
|
422
|
+
// The LinkedIn question is a tool block, so a welcome still pending is read before it and the AI words nothing.
|
|
423
|
+
if (args.linkedinMissing === true && !nonEmpty(args.answer)) {
|
|
424
|
+
const c = copy();
|
|
425
|
+
return {
|
|
426
|
+
ok: true,
|
|
427
|
+
step: 'linkedin-ask',
|
|
428
|
+
say: [...takeNotices(), c.linkedinAsk].join('\n\n'),
|
|
429
|
+
linkedinOpening: c.linkedinOpening,
|
|
430
|
+
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.',
|
|
431
|
+
};
|
|
432
|
+
}
|
|
390
433
|
if (!nonEmpty(args.answer)) {
|
|
391
434
|
fixSignupLanguage(args.language);
|
|
392
435
|
state.draft = null;
|
|
393
436
|
state.draftConfirmed = false;
|
|
394
437
|
state.email = null;
|
|
395
438
|
state.failedSources.clear();
|
|
439
|
+
state.toldKept = new Set();
|
|
440
|
+
state.toldExisting = false;
|
|
396
441
|
state.cvPath = null;
|
|
397
442
|
takeNotices();
|
|
398
443
|
}
|
|
399
444
|
if (nonEmpty(args.cvPath)) state.cvPath = nonEmpty(args.cvPath);
|
|
400
445
|
const c = copy();
|
|
446
|
+
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.';
|
|
447
|
+
// A CV path in the first message already answers the CV question: that CV is read, and the welcome
|
|
448
|
+
// rides on the next question block, because models skip a relay that comes before another tool call.
|
|
449
|
+
if (!nonEmpty(args.answer) && state.cvPath) {
|
|
450
|
+
queueNotice(c.welcome(nonEmpty(args.firstName)));
|
|
451
|
+
return {
|
|
452
|
+
ok: true,
|
|
453
|
+
step: 'cv-given',
|
|
454
|
+
cvPath: state.cvPath,
|
|
455
|
+
language: lang(),
|
|
456
|
+
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 read_cv cvPath with talentConsent:true, asking nothing about the CV. ${linkedin}`,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
401
459
|
const asked = await askAfterTexts(ctx, {
|
|
402
460
|
tool: 'signup_start',
|
|
403
461
|
texts: [c.welcome(nonEmpty(args.firstName))],
|
|
404
462
|
question: c.welcomeQuestion,
|
|
405
|
-
options: [c.
|
|
463
|
+
options: [c.cvAttach, c.cvSearch],
|
|
406
464
|
answer: args.answer,
|
|
407
465
|
});
|
|
408
466
|
if (asked.reply) return { ...asked.reply, language: lang() };
|
|
409
|
-
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.`;
|
|
410
467
|
// A CV path the talent gave wins over both answers: searching could read another candidate.
|
|
411
468
|
const cvPath = state.cvPath;
|
|
412
469
|
if (cvPath) {
|
|
@@ -414,8 +471,6 @@ function makeSignupTools(deps = {}) {
|
|
|
414
471
|
ok: true,
|
|
415
472
|
step: 'cv-given',
|
|
416
473
|
cvPath,
|
|
417
|
-
linkedinAsk: c.linkedinAsk,
|
|
418
|
-
linkedinOpening: c.linkedinOpening,
|
|
419
474
|
message: `The talent already gave their CV: read_cv it now with talentConsent:true, saying nothing about attaching it. ${linkedin}`,
|
|
420
475
|
};
|
|
421
476
|
}
|
|
@@ -424,8 +479,6 @@ function makeSignupTools(deps = {}) {
|
|
|
424
479
|
ok: true,
|
|
425
480
|
step: 'cv-search',
|
|
426
481
|
cvNotFound: c.cvNotFound,
|
|
427
|
-
linkedinAsk: c.linkedinAsk,
|
|
428
|
-
linkedinOpening: c.linkedinOpening,
|
|
429
482
|
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}`,
|
|
430
483
|
};
|
|
431
484
|
}
|
|
@@ -433,8 +486,6 @@ function makeSignupTools(deps = {}) {
|
|
|
433
486
|
ok: true,
|
|
434
487
|
step: 'cv-attach',
|
|
435
488
|
relayVerbatim: c.cvAttachAsk,
|
|
436
|
-
linkedinAsk: c.linkedinAsk,
|
|
437
|
-
linkedinOpening: c.linkedinOpening,
|
|
438
489
|
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}`,
|
|
439
490
|
};
|
|
440
491
|
}
|
|
@@ -451,7 +502,7 @@ function makeSignupTools(deps = {}) {
|
|
|
451
502
|
if (!email) {
|
|
452
503
|
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.` };
|
|
453
504
|
}
|
|
454
|
-
const question =
|
|
505
|
+
const question = looksLikeWorkEmail(email) ? `${c.emailKnown(email)} ${c.emailWorkHint}` : c.emailKnown(email);
|
|
455
506
|
const asked = await askAfterTexts(ctx, { tool: 'signup_email', texts: [], question, options: [c.emailYes, c.emailOther], answer: args.answer });
|
|
456
507
|
if (asked.reply) return asked.reply;
|
|
457
508
|
if (asked.answer === c.emailYes) {
|
|
@@ -461,6 +512,13 @@ function makeSignupTools(deps = {}) {
|
|
|
461
512
|
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.` };
|
|
462
513
|
}
|
|
463
514
|
|
|
515
|
+
// A session this machine kept for the same email means the account exists, so the window will ask to sign in.
|
|
516
|
+
function knownAccount() {
|
|
517
|
+
const stored = resolved.loadAuthSession();
|
|
518
|
+
const email = stored && typeof stored.email === 'string' ? stored.email.toLowerCase() : null;
|
|
519
|
+
return !!email && !!state.email && email === state.email.toLowerCase();
|
|
520
|
+
}
|
|
521
|
+
|
|
464
522
|
// Step 3: the draft, the data notice and how the window works; confirming it is what opens the window.
|
|
465
523
|
async function signupDraft(args = {}, ctx = {}) {
|
|
466
524
|
const c = copy();
|
|
@@ -475,9 +533,14 @@ function makeSignupTools(deps = {}) {
|
|
|
475
533
|
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.' };
|
|
476
534
|
}
|
|
477
535
|
if (args.ratesFromTalent === true) draft.ratesFromTalent = true;
|
|
536
|
+
const sources = Array.isArray(args.sources) ? DRAFT_SOURCES.filter((s) => args.sources.includes(s)) : [];
|
|
537
|
+
// A confirmation of the printed block stands even if the model drops sources on the answer call.
|
|
538
|
+
if (!sources.length && !optionFor(args.answer, [c.draftOk, c.draftChange])) {
|
|
539
|
+
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." };
|
|
540
|
+
}
|
|
478
541
|
const asked = await askAfterTexts(ctx, {
|
|
479
542
|
tool: 'signup_draft',
|
|
480
|
-
texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice, c.windowNotice],
|
|
543
|
+
texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice(sources), knownAccount() ? c.windowNoticeSignIn : c.windowNotice],
|
|
481
544
|
question: c.draftQuestion,
|
|
482
545
|
options: [c.draftOk, c.draftChange],
|
|
483
546
|
answer: args.answer,
|
|
@@ -489,6 +552,7 @@ function makeSignupTools(deps = {}) {
|
|
|
489
552
|
}
|
|
490
553
|
state.draft = draft;
|
|
491
554
|
state.draftConfirmed = true;
|
|
555
|
+
state.toldExisting = knownAccount();
|
|
492
556
|
return {
|
|
493
557
|
ok: true,
|
|
494
558
|
next: 'signup_create_account',
|
|
@@ -574,10 +638,10 @@ function makeSignupTools(deps = {}) {
|
|
|
574
638
|
}
|
|
575
639
|
|
|
576
640
|
function nextSteps(account) {
|
|
577
|
-
const interview = 'Then the onboarding interview: if onboardingInterview.completed is true
|
|
641
|
+
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.';
|
|
578
642
|
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).';
|
|
579
643
|
if (account.state === 'existing') {
|
|
580
|
-
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}`;
|
|
644
|
+
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}`;
|
|
581
645
|
}
|
|
582
646
|
const unclaimed = account.claimed === false ? ' This account did not take over the imported profile: call import_profile with the same LinkedIn/CV first.' : '';
|
|
583
647
|
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}`;
|
|
@@ -684,18 +748,32 @@ function makeSignupTools(deps = {}) {
|
|
|
684
748
|
};
|
|
685
749
|
}
|
|
686
750
|
|
|
687
|
-
// Fill-empty only:
|
|
751
|
+
// Fill-empty only: per draft field, what it fills or the value the profile keeps, plus the import that only adds.
|
|
688
752
|
async function existingDiff() {
|
|
689
753
|
const c = copy();
|
|
690
754
|
const draft = state.draft || {};
|
|
691
755
|
const current = await currentProfile();
|
|
692
756
|
const lines = [];
|
|
757
|
+
const kept = new Set();
|
|
693
758
|
const has = (v) => v !== undefined && v !== null && v !== '';
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
759
|
+
// An unreadable section (undefined) counts as set, as fillEmpty never writes it.
|
|
760
|
+
const field = (key, now, filled, label, shown) => {
|
|
761
|
+
if (!has(draft[key])) return;
|
|
762
|
+
if (now === null) { lines.push(filled); return; }
|
|
763
|
+
kept.add(key);
|
|
764
|
+
lines.push(c.existingStays(label, now === undefined ? null : shown(now), shown(draft[key])));
|
|
765
|
+
};
|
|
766
|
+
const pricing = current.pricing || { hourly: undefined, annual: undefined };
|
|
767
|
+
const hours = current.availability ? (has(current.availability.monthlyHours) ? current.availability.monthlyHours : null) : undefined;
|
|
768
|
+
field('hourlyRate', pricing.hourly, c.existingRate(c.existingEmpty, money(draft.hourlyRate, c.perHour)), c.hourlyRateLabel, (v) => money(v, c.perHour));
|
|
769
|
+
field('annualRate', pricing.annual, c.existingAnnual(c.existingEmpty, money(draft.annualRate, c.perYear)), c.annualRateLabel, (v) => money(v, c.perYear));
|
|
770
|
+
field('monthlyHours', hours, c.existingHours(c.existingEmpty, c.hours(draft.monthlyHours)), c.keptLabels.availability, (v) => c.hours(v));
|
|
771
|
+
if (has(draft.languages) && current.languageCount !== 0) { kept.add('languages'); lines.push(c.existingStays(c.keptLabels.languages)); }
|
|
772
|
+
if (kept.size) lines.push(c.existingStaysNote);
|
|
773
|
+
state.toldKept = kept;
|
|
774
|
+
const given = state.sources ? [state.sources.linkedinUrl && 'linkedin', state.sources.cvPath && 'cv'].filter(Boolean) : [];
|
|
775
|
+
if (given.length) lines.push(c.existingLists(given));
|
|
776
|
+
return `${state.toldExisting ? c.existingUpdateQuestion : c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
|
|
699
777
|
}
|
|
700
778
|
|
|
701
779
|
// The sections of save_profile_details an existing profile can take (only fields it leaves empty), and what it keeps instead.
|
|
@@ -731,25 +809,25 @@ function makeSignupTools(deps = {}) {
|
|
|
731
809
|
return { details: out, kept };
|
|
732
810
|
}
|
|
733
811
|
|
|
734
|
-
// What the existing profile kept, for the model per field and for the talent as one fixed line.
|
|
812
|
+
// What the existing profile kept, for the model per field and for the talent as one fixed line without what the update question already told.
|
|
735
813
|
function keptReport(kept) {
|
|
736
814
|
const c = copy();
|
|
737
815
|
const fields = [];
|
|
738
816
|
const items = [];
|
|
739
|
-
const
|
|
817
|
+
const told = (key) => state.toldKept.has(key);
|
|
740
818
|
if (kept.workSituation) { fields.push('workSituation: not changed (the profile keeps what it had)'); items.push(c.keptLabels.workSituation); }
|
|
741
819
|
for (const [key, label, unit] of [['hourlyRate', c.hourlyRateLabel, c.perHour], ['annualRate', c.annualRateLabel, c.perYear]]) {
|
|
742
820
|
const k = kept.pricing && kept.pricing[key];
|
|
743
821
|
if (!k) continue;
|
|
744
822
|
fields.push(k.kept === null ? `${key}: not changed (the profile keeps what it had, not ${k.requested})` : `${key}: kept existing value ${k.kept} (not changed, not ${k.requested})`);
|
|
745
|
-
items.push(k.kept === null ? label : `${label}: ${money(k.kept, unit)}`);
|
|
823
|
+
if (!told(key)) items.push(k.kept === null ? label : `${label}: ${money(k.kept, unit)}`);
|
|
746
824
|
}
|
|
747
825
|
for (const [key, k] of Object.entries(kept.availability || {})) {
|
|
748
826
|
fields.push(k.kept === null ? `${key}: not changed (the profile keeps what it had)` : `${key}: kept existing value ${Array.isArray(k.kept) ? k.kept.join('/') : k.kept} (not changed, not ${Array.isArray(k.requested) ? k.requested.join('/') : k.requested})`);
|
|
749
827
|
}
|
|
750
828
|
const hours = kept.availability && kept.availability.monthlyHours;
|
|
751
|
-
if (hours) items.push(hours.kept === null ? c.keptLabels.availability : `${c.keptLabels.availability}: ${c.hours(hours.kept)}`);
|
|
752
|
-
if (kept.languages) { fields.push('languages: not changed (the profile already had languages)'); items.push(c.keptLabels.languages); }
|
|
829
|
+
if (hours && !told('monthlyHours')) items.push(hours.kept === null ? c.keptLabels.availability : `${c.keptLabels.availability}: ${c.hours(hours.kept)}`);
|
|
830
|
+
if (kept.languages) { fields.push('languages: not changed (the profile already had languages)'); if (!told('languages')) items.push(c.keptLabels.languages); }
|
|
753
831
|
if (kept.phone) { fields.push('phone: not changed (the profile keeps what it had)'); items.push(c.keptLabels.phone); }
|
|
754
832
|
return { fields, line: items.length ? c.existingUnchanged(items) : null };
|
|
755
833
|
}
|
|
@@ -805,17 +883,26 @@ function makeSignupTools(deps = {}) {
|
|
|
805
883
|
['availability', details.availability, (v) => flow.saveAvailability(resolved, v), saved.availability],
|
|
806
884
|
['languages', Array.isArray(details.languages) && details.languages.length ? details.languages : null, (v) => flow.saveLanguages(resolved, v), saved.languages],
|
|
807
885
|
['phone', details.phone, (v) => flow.savePhone(resolved, v), saved.phone],
|
|
808
|
-
];
|
|
886
|
+
].filter(([, value]) => value && typeof value === 'object');
|
|
887
|
+
// Hub's async profile-completion recalculation saves the whole users row from a copy it read earlier, so a
|
|
888
|
+
// phone written while it runs comes back NULL: the phone goes first, and is read back once the rest is saved.
|
|
889
|
+
const phoneFirst = [...sections.filter(([name]) => name === 'phone'), ...sections.filter(([name]) => name !== 'phone')];
|
|
809
890
|
const results = {};
|
|
810
|
-
const
|
|
811
|
-
for (const [name, value, save, saved] of sections) {
|
|
812
|
-
if (!value || typeof value !== 'object') continue;
|
|
891
|
+
for (const [name, value, save] of phoneFirst) {
|
|
813
892
|
const res = await save(value);
|
|
814
|
-
if (res.ok) confirmations.push(saved);
|
|
815
893
|
if (!res.ok && res.reason === 'no-session') return { ok: false, reason: 'no-session', message: 'The talent is not signed in yet.' };
|
|
816
894
|
const help = res.message || SECTION_HELP[res.reason];
|
|
817
895
|
results[name] = res.ok ? { ok: true } : { ok: false, reason: res.reason, ...(help ? { message: help } : {}) };
|
|
818
896
|
}
|
|
897
|
+
if (results.phone && results.phone.ok) {
|
|
898
|
+
const session = resolved.loadAuthSession();
|
|
899
|
+
const stored = await fetchTelephone({ hubAccessToken: session && session.hubAccessToken }).catch(() => ({ ok: false }));
|
|
900
|
+
if (stored.ok && !stored.telephone) {
|
|
901
|
+
const again = await flow.savePhone(resolved, details.phone);
|
|
902
|
+
results.phone = again.ok ? { ok: true } : { ok: false, reason: again.reason };
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const confirmations = sections.filter(([name]) => results[name] && results[name].ok).map(([, , , text]) => text);
|
|
819
906
|
const ok = Object.values(results).every((r) => r.ok);
|
|
820
907
|
const report = keptReport(kept);
|
|
821
908
|
if (report.line) queueNotice(report.line);
|
|
@@ -845,27 +932,32 @@ function makeSignupTools(deps = {}) {
|
|
|
845
932
|
if (!url) return { ok: false, reason: 'no-endpoint' };
|
|
846
933
|
// The address reaches the AI only when the talent needs a link: the AI comments on hosts it sees.
|
|
847
934
|
const opened = args.open !== false && openBrowser(url) !== false;
|
|
935
|
+
// Read right before the closing words, so they never state an import state that is already stale.
|
|
936
|
+
const imp = await readImport();
|
|
937
|
+
// A source that failed after the last signup_status gets its fixed line here, as there is no question left.
|
|
938
|
+
const failed = DRAFT_SOURCES.filter((s) => imp.sources && imp.sources[s] && imp.sources[s].state === 'failed');
|
|
939
|
+
noticeFailed(failed);
|
|
848
940
|
// The flow ends here, so a fixed line no question carried is printed now.
|
|
849
941
|
const unseen = takeNotices();
|
|
850
942
|
const relay = unseen.length ? { relayVerbatim: unseen.join('\n\n') } : {};
|
|
851
|
-
const
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
}
|
|
861
|
-
}
|
|
943
|
+
const importLine = failed.length
|
|
944
|
+
? `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.`
|
|
945
|
+
: IMPORT_CLOSING[imp.state] || IMPORT_CLOSING.none;
|
|
946
|
+
const where = opened
|
|
947
|
+
? `Shakers is open in their browser${loggedIn ? ', already signed in' : ' at the sign-in page, where they sign in'}`
|
|
948
|
+
: `they open Shakers from \`link\` (give it to click, with no comment on the address${loggedIn ? '; it works once, for 2 minutes' : ''})`;
|
|
949
|
+
const closing = `one short closing sentence of your own, in the talent's language, saying ${where}.`;
|
|
950
|
+
const reply = unseen.length
|
|
951
|
+
? `Your reply has two parts and needs both: first relayVerbatim word for word as its own block, then ${closing}`
|
|
952
|
+
: `End with ${closing}`;
|
|
862
953
|
return {
|
|
863
954
|
ok: true,
|
|
864
|
-
opened
|
|
955
|
+
opened,
|
|
865
956
|
loggedIn,
|
|
866
|
-
link: url,
|
|
957
|
+
...(opened ? {} : { link: url }),
|
|
958
|
+
import: { state: imp.state },
|
|
867
959
|
...relay,
|
|
868
|
-
message:
|
|
960
|
+
message: `${reply}${opened ? ' If they say it did not open, call open_web with open:false and give them its link.' : ''} ${importLine}`,
|
|
869
961
|
};
|
|
870
962
|
}
|
|
871
963
|
|
|
@@ -882,7 +974,7 @@ function makeSignupTools(deps = {}) {
|
|
|
882
974
|
return [
|
|
883
975
|
{
|
|
884
976
|
name: 'signup_start',
|
|
885
|
-
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 (
|
|
977
|
+
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 (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.",
|
|
886
978
|
inputSchema: WELCOME_SCHEMA,
|
|
887
979
|
handler: signupStart,
|
|
888
980
|
},
|
|
@@ -912,7 +1004,7 @@ function makeSignupTools(deps = {}) {
|
|
|
912
1004
|
},
|
|
913
1005
|
{
|
|
914
1006
|
name: 'update_existing_profile',
|
|
915
|
-
description: '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 asks whether to update it; on yes it imports their LinkedIn/CV filling only what is empty.',
|
|
1007
|
+
description: '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.',
|
|
916
1008
|
inputSchema: UPDATE_EXISTING_SCHEMA,
|
|
917
1009
|
handler: updateExistingProfile,
|
|
918
1010
|
},
|
|
@@ -943,4 +1035,4 @@ function makeSignupTools(deps = {}) {
|
|
|
943
1035
|
];
|
|
944
1036
|
}
|
|
945
1037
|
|
|
946
|
-
module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };
|
|
1038
|
+
module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, looksLikeWorkEmail, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };
|
package/src/profile-client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// AI-profile client
|
|
3
|
+
// AI-profile client. The "My work
|
|
4
4
|
// with AI" read model: setup/usage tier+level, 3x3 cell, vision, howIWork.
|
|
5
5
|
|
|
6
6
|
const { postJsonWithTimeout } = require('./backend-request');
|
|
@@ -87,14 +87,12 @@ async function getMapped(endpoint, hubAccessToken, timeoutMs, map) {
|
|
|
87
87
|
return { ok: true, ...map(data) };
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
// `works/me/profile` -> headline (basic profile text).
|
|
91
90
|
async function fetchMeProfile(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
92
91
|
const getEndpoint = deps.getMeProfileEndpoint || require('./config').getMeProfileEndpoint;
|
|
93
92
|
const req = deps.getMapped || getMapped;
|
|
94
93
|
return req(getEndpoint(), hubAccessToken, timeoutMs, (d) => ({ headline: str(d && d.headline) }));
|
|
95
94
|
}
|
|
96
95
|
|
|
97
|
-
// `works/talents/me/profile` -> completion %, freelance type, onboarding status.
|
|
98
96
|
async function fetchTalentMeProfile(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
99
97
|
const getEndpoint = deps.getTalentMeProfileEndpoint || require('./config').getTalentMeProfileEndpoint;
|
|
100
98
|
const req = deps.getMapped || getMapped;
|
|
@@ -105,7 +103,6 @@ async function fetchTalentMeProfile(deps = {}, { hubAccessToken, timeoutMs = DEF
|
|
|
105
103
|
}));
|
|
106
104
|
}
|
|
107
105
|
|
|
108
|
-
// `works/talents/me/work-details/languages` -> count + codes.
|
|
109
106
|
async function fetchLanguages(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
110
107
|
const getEndpoint = deps.getLanguagesEndpoint || require('./config').getLanguagesEndpoint;
|
|
111
108
|
const req = deps.getMapped || getMapped;
|
|
@@ -116,10 +113,21 @@ async function fetchLanguages(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_T
|
|
|
116
113
|
});
|
|
117
114
|
}
|
|
118
115
|
|
|
116
|
+
// `works/users/me` -> the stored phone, null when hub has none.
|
|
117
|
+
async function fetchTelephone(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
118
|
+
const getEndpoint = deps.getUserMeEndpoint || require('./config').getUserMeEndpoint;
|
|
119
|
+
const req = deps.getMapped || getMapped;
|
|
120
|
+
return req(getEndpoint(), hubAccessToken, timeoutMs, (d) => {
|
|
121
|
+
const t = d && d.telephone;
|
|
122
|
+
return { telephone: t && str(t.code) && str(t.number) ? { code: str(t.code), number: str(t.number) } : null };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
119
126
|
module.exports = {
|
|
120
127
|
requestAiProfile,
|
|
121
128
|
fetchAiProfile,
|
|
122
129
|
fetchMeProfile,
|
|
123
130
|
fetchTalentMeProfile,
|
|
124
131
|
fetchLanguages,
|
|
132
|
+
fetchTelephone,
|
|
125
133
|
};
|
package/src/rate-client.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Pricing-rate client
|
|
4
|
-
// Bearer). READ-ONLY here: setting the rate is a financial PUT, left to register.
|
|
3
|
+
// Pricing-rate client. READ-ONLY here: setting the rate is a financial PUT, left to register.
|
|
5
4
|
|
|
6
5
|
const { postJsonWithTimeout } = require('./backend-request');
|
|
7
6
|
const { reasonForError } = require('./onboarding-client');
|
package/src/roles-client.js
CHANGED
|
@@ -43,7 +43,6 @@ function normalizeRole(r) {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// GET works/certifications/me/available-roles -> { roles:[{clusterId,name,category,proposalKind}] }.
|
|
47
46
|
async function requestAvailableRoles({ hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
48
47
|
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
49
48
|
if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
|
|
@@ -60,7 +59,6 @@ async function requestAvailableRoles({ hubAccessToken } = {}, { endpoint, timeou
|
|
|
60
59
|
return { ok: true, roles: rows.map(normalizeRole).filter(Boolean) };
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
// GET works/talents/me/work-details/assigned-clusters -> the talent's GROWTH/RECOMMENDED roles.
|
|
64
62
|
async function requestAssignedClusters({ hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
65
63
|
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
66
64
|
if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
|
|
@@ -77,7 +75,6 @@ async function requestAssignedClusters({ hubAccessToken } = {}, { endpoint, time
|
|
|
77
75
|
return { ok: true, clusters: rows.map(normalizeRole).filter(Boolean) };
|
|
78
76
|
}
|
|
79
77
|
|
|
80
|
-
// POST works/talents/me/work-details/assigned-clusters { clusterId } -> adds a GROWTH role.
|
|
81
78
|
async function requestAddGrowthRole({ clusterId, hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
82
79
|
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
83
80
|
if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
|
|
@@ -92,7 +89,6 @@ async function requestAddGrowthRole({ clusterId, hubAccessToken } = {}, { endpoi
|
|
|
92
89
|
return { ok: true, clusterId };
|
|
93
90
|
}
|
|
94
91
|
|
|
95
|
-
// PATCH works/certifications/me/main-role { clusterId } -> sets the preferred main role.
|
|
96
92
|
async function requestSetMainRole({ clusterId, hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
97
93
|
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
98
94
|
if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Saved-positions client
|
|
3
|
+
// Saved-positions client. Both
|
|
4
4
|
// operations are idempotent server-side, so no read-before-write is needed.
|
|
5
5
|
|
|
6
6
|
const { postJsonWithTimeout } = require('./backend-request');
|