shakers-cli-beta 0.10.3 → 0.10.4
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/package.json +1 -1
- package/src/certify-dimension-client.js +79 -0
- package/src/certify-dimension-flow.js +104 -20
- package/src/config.js +9 -0
- package/src/i18n.js +98 -32
- package/src/mcp-ai-usage-tool.js +13 -32
- package/src/mcp-certify-dimension-tools.js +7 -1
- package/src/mcp-choice.js +41 -19
- package/src/mcp-instructions.js +9 -9
- package/src/mcp-register-tools.js +87 -29
- package/src/mcp-role-tools.js +79 -11
- package/src/mcp-signup-tools.js +187 -59
- package/src/register-context.js +3 -1
- package/src/signup-copy.js +49 -17
package/src/mcp-signup-tools.js
CHANGED
|
@@ -8,12 +8,12 @@ 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 } = require('./mcp-choice');
|
|
11
|
+
const { askAfterTexts, 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
|
|
|
15
15
|
const SIGNUP_WINDOW_TIMEOUT_MS = 15 * 60 * 1000;
|
|
16
|
-
// The browser opens this long after the tool answers, so the AI's
|
|
16
|
+
// The browser opens this long after the tool answers, so the AI's reply is on screen first.
|
|
17
17
|
const BROWSER_OPEN_DELAY_MS = 4000;
|
|
18
18
|
const MAX_WAIT_SECONDS = 25;
|
|
19
19
|
const IMPORT_POLL_MS = 3000;
|
|
@@ -29,7 +29,14 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
29
29
|
// Personal mailbox providers; any other domain may be the talent's employer, so the email question adds the work-email hint.
|
|
30
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
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
|
-
|
|
32
|
+
// What the model must send instead when a work-situation section is refused.
|
|
33
|
+
const SECTION_HELP = {
|
|
34
|
+
'bad-situation': 'workSituation.situation must be one of its enum codes.',
|
|
35
|
+
'bad-participation': 'workSituation.participation (FULL_TIME or PART_TIME) is required when situation is EMPLOYED.',
|
|
36
|
+
'bad-opinion': 'workSituation.opinion is required unless situation is FREELANCE: pick your best match from its enum.',
|
|
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
|
+
};
|
|
39
|
+
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.';
|
|
33
40
|
|
|
34
41
|
function isPersonalEmail(email) {
|
|
35
42
|
return PERSONAL_EMAIL_DOMAINS.has(String(email).split('@').pop().toLowerCase());
|
|
@@ -70,8 +77,8 @@ function buildDetailsSchema(lang) {
|
|
|
70
77
|
properties: {
|
|
71
78
|
situation: { type: 'string', enum: WORK_SITUATIONS, description: `${show}: ${labelledOptions(WORK_SITUATIONS, L.workSituationLabels)}. 'EMPLOYED' requires participation.` },
|
|
72
79
|
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
|
|
74
|
-
changeMotivators: { type: 'string', enum: CHANGE_MOTIVATORS, description: `Motivation, your best match; omit when opinion is NOT_INTERESTED. ${show}: ${labelledOptions(CHANGE_MOTIVATORS, L.changeMotivatorLabels)}.` },
|
|
80
|
+
opinion: { type: 'string', enum: FREELANCE_OPINIONS, description: `Opinion on freelancing. Required unless situation is FREELANCE. ${show}: ${labelledOptions(FREELANCE_OPINIONS, L.freelanceOpinionLabels)}.` },
|
|
81
|
+
changeMotivators: { type: 'string', enum: CHANGE_MOTIVATORS, description: `Motivation, your best match. Required when situation is FREELANCE or opinion is WAS_FREELANCE_BEFORE or OPEN_TO_FREELANCE; omit it only when opinion is NOT_INTERESTED. ${show}: ${labelledOptions(CHANGE_MOTIVATORS, L.changeMotivatorLabels)}.` },
|
|
75
82
|
},
|
|
76
83
|
required: ['situation'],
|
|
77
84
|
},
|
|
@@ -126,8 +133,8 @@ const WELCOME_SCHEMA = {
|
|
|
126
133
|
properties: {
|
|
127
134
|
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
135
|
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
136
|
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 answer (either option) already gives their CV as a path on this machine: that exact file is read, with no search and no request for the file.' },
|
|
131
138
|
},
|
|
132
139
|
required: ['language'],
|
|
133
140
|
};
|
|
@@ -137,7 +144,6 @@ const EMAIL_SCHEMA = {
|
|
|
137
144
|
properties: {
|
|
138
145
|
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
146
|
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
147
|
answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
|
|
142
148
|
},
|
|
143
149
|
};
|
|
@@ -154,17 +160,17 @@ const DRAFT_SCHEMA = {
|
|
|
154
160
|
languages: { type: 'string', description: 'Languages and level, e.g. "Español nativo, inglés C1".' },
|
|
155
161
|
workMode: { type: 'string', description: 'Remote, hybrid or on site, in the talent\'s language.' },
|
|
156
162
|
monthlyHours: { type: 'string', description: `Hours a month they can work: one of ${MONTHLY_HOURS.join(', ')}.` },
|
|
157
|
-
hourlyRate: { type: 'number', description: `
|
|
158
|
-
annualRate: { type: 'number', description: `
|
|
159
|
-
|
|
163
|
+
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
|
+
annualRate: { type: 'number', description: `Euros per year (full-time projects), at least ${MIN_ANNUAL_RATE}: the talent's figure, else your estimate.` },
|
|
165
|
+
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).' },
|
|
160
166
|
answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
|
|
161
167
|
},
|
|
168
|
+
required: ['name', 'role', 'hourlyRate', 'annualRate'],
|
|
162
169
|
};
|
|
163
170
|
|
|
164
171
|
const CREATE_SCHEMA = {
|
|
165
172
|
type: 'object',
|
|
166
173
|
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
174
|
linkedinUrl: { type: 'string', description: 'The talent\'s LinkedIn profile URL (linkedin.com/in/<slug>, never /in/me).' },
|
|
169
175
|
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
176
|
firstName: { type: 'string' },
|
|
@@ -254,6 +260,7 @@ function makeSignupTools(deps = {}) {
|
|
|
254
260
|
checkOnboardingCompleted = require('./onboarding-availability').checkOnboardingCompleted,
|
|
255
261
|
fetchPricingRate = (opts) => require('./rate-client').fetchPricingRate({}, opts),
|
|
256
262
|
fetchAvailability = (opts) => require('./availability-client').fetchAvailability({}, opts),
|
|
263
|
+
fetchLanguages = (opts) => require('./profile-client').fetchLanguages({}, opts),
|
|
257
264
|
openBrowser = require('./open-file').openPath,
|
|
258
265
|
sleep = defaultSleep,
|
|
259
266
|
now = () => Date.now(),
|
|
@@ -267,9 +274,12 @@ function makeSignupTools(deps = {}) {
|
|
|
267
274
|
email: null,
|
|
268
275
|
draft: null,
|
|
269
276
|
draftConfirmed: false,
|
|
277
|
+
keepProfile: false,
|
|
270
278
|
sources: null,
|
|
271
279
|
account: { state: 'not-started' },
|
|
272
280
|
reported: { account: null, import: null },
|
|
281
|
+
failedSources: new Set(),
|
|
282
|
+
cvPath: null,
|
|
273
283
|
wake: null,
|
|
274
284
|
};
|
|
275
285
|
currentSignup = state;
|
|
@@ -377,23 +387,38 @@ function makeSignupTools(deps = {}) {
|
|
|
377
387
|
|
|
378
388
|
// Step 1: welcome, value proposal, the three steps and one question (may I look for your CV here?).
|
|
379
389
|
async function signupStart(args = {}, ctx = {}) {
|
|
380
|
-
if (
|
|
390
|
+
if (!nonEmpty(args.answer)) {
|
|
381
391
|
fixSignupLanguage(args.language);
|
|
382
392
|
state.draft = null;
|
|
383
393
|
state.draftConfirmed = false;
|
|
384
394
|
state.email = null;
|
|
395
|
+
state.failedSources.clear();
|
|
396
|
+
state.cvPath = null;
|
|
397
|
+
takeNotices();
|
|
385
398
|
}
|
|
399
|
+
if (nonEmpty(args.cvPath)) state.cvPath = nonEmpty(args.cvPath);
|
|
386
400
|
const c = copy();
|
|
387
401
|
const asked = await askAfterTexts(ctx, {
|
|
388
402
|
tool: 'signup_start',
|
|
389
403
|
texts: [c.welcome(nonEmpty(args.firstName))],
|
|
390
404
|
question: c.welcomeQuestion,
|
|
391
405
|
options: [c.cvSearch, c.cvAttach],
|
|
392
|
-
shown: args.shown,
|
|
393
406
|
answer: args.answer,
|
|
394
407
|
});
|
|
395
408
|
if (asked.reply) return { ...asked.reply, language: lang() };
|
|
396
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
|
+
// A CV path the talent gave wins over both answers: searching could read another candidate.
|
|
411
|
+
const cvPath = state.cvPath;
|
|
412
|
+
if (cvPath) {
|
|
413
|
+
return {
|
|
414
|
+
ok: true,
|
|
415
|
+
step: 'cv-given',
|
|
416
|
+
cvPath,
|
|
417
|
+
linkedinAsk: c.linkedinAsk,
|
|
418
|
+
linkedinOpening: c.linkedinOpening,
|
|
419
|
+
message: `The talent already gave their CV: read_cv it now with talentConsent:true, saying nothing about attaching it. ${linkedin}`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
397
422
|
if (asked.answer === c.cvSearch) {
|
|
398
423
|
return {
|
|
399
424
|
ok: true,
|
|
@@ -401,7 +426,7 @@ function makeSignupTools(deps = {}) {
|
|
|
401
426
|
cvNotFound: c.cvNotFound,
|
|
402
427
|
linkedinAsk: c.linkedinAsk,
|
|
403
428
|
linkedinOpening: c.linkedinOpening,
|
|
404
|
-
message: `The talent allowed you to look for their CV: call suggest_register_context
|
|
429
|
+
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}`,
|
|
405
430
|
};
|
|
406
431
|
}
|
|
407
432
|
return {
|
|
@@ -410,7 +435,7 @@ function makeSignupTools(deps = {}) {
|
|
|
410
435
|
relayVerbatim: c.cvAttachAsk,
|
|
411
436
|
linkedinAsk: c.linkedinAsk,
|
|
412
437
|
linkedinOpening: c.linkedinOpening,
|
|
413
|
-
message:
|
|
438
|
+
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}`,
|
|
414
439
|
};
|
|
415
440
|
}
|
|
416
441
|
|
|
@@ -436,7 +461,7 @@ function makeSignupTools(deps = {}) {
|
|
|
436
461
|
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
462
|
}
|
|
438
463
|
|
|
439
|
-
// Step 3: the draft
|
|
464
|
+
// Step 3: the draft, the data notice and how the window works; confirming it is what opens the window.
|
|
440
465
|
async function signupDraft(args = {}, ctx = {}) {
|
|
441
466
|
const c = copy();
|
|
442
467
|
const pricing = pricingFromRates(args);
|
|
@@ -446,12 +471,15 @@ function makeSignupTools(deps = {}) {
|
|
|
446
471
|
if (args[key] !== undefined && args[key] !== null && args[key] !== '') draft[key] = args[key];
|
|
447
472
|
}
|
|
448
473
|
if (!draft.name || !draft.role) return { ok: false, reason: 'draft-incomplete', message: 'The draft needs at least name and role: read the CV first.' };
|
|
474
|
+
if (draft.hourlyRate === undefined || draft.annualRate === undefined) {
|
|
475
|
+
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
|
+
}
|
|
477
|
+
if (args.ratesFromTalent === true) draft.ratesFromTalent = true;
|
|
449
478
|
const asked = await askAfterTexts(ctx, {
|
|
450
479
|
tool: 'signup_draft',
|
|
451
|
-
texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice],
|
|
480
|
+
texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice, c.windowNotice],
|
|
452
481
|
question: c.draftQuestion,
|
|
453
482
|
options: [c.draftOk, c.draftChange],
|
|
454
|
-
shown: args.shown,
|
|
455
483
|
answer: args.answer,
|
|
456
484
|
});
|
|
457
485
|
if (asked.reply) return { ...asked.reply, message: `${asked.reply.message} Pass the same draft fields again.` };
|
|
@@ -464,8 +492,7 @@ function makeSignupTools(deps = {}) {
|
|
|
464
492
|
return {
|
|
465
493
|
ok: true,
|
|
466
494
|
next: 'signup_create_account',
|
|
467
|
-
|
|
468
|
-
message: `Confirmed. ${VERBATIM} Then call signup_create_account with windowAnnounced:true: the window opens a few seconds later.`,
|
|
495
|
+
message: 'Confirmed. Call signup_create_account now: the talent already read how the window works, so say nothing before it.',
|
|
469
496
|
};
|
|
470
497
|
}
|
|
471
498
|
|
|
@@ -490,9 +517,6 @@ function makeSignupTools(deps = {}) {
|
|
|
490
517
|
if (!state.draftConfirmed) {
|
|
491
518
|
return { ok: false, reason: 'draft-not-confirmed', message: 'Show the draft with signup_draft and get the talent\'s confirmation first.' };
|
|
492
519
|
}
|
|
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
520
|
const session = resolved.loadAuthSession();
|
|
497
521
|
if (resolved.sessionStatus(session) === 'active' && state.account.state !== 'waiting') {
|
|
498
522
|
return {
|
|
@@ -535,8 +559,8 @@ function makeSignupTools(deps = {}) {
|
|
|
535
559
|
status: 'window-open',
|
|
536
560
|
...(cvNote ? { cvNote } : {}),
|
|
537
561
|
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
|
|
539
|
-
+ 'Say nothing
|
|
562
|
+
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 read how it works in the draft they confirmed. ')
|
|
563
|
+
+ 'Say nothing about the window. Only if they say it did not open, call signup_status with windowLink:true. Now call signup_status (waitSeconds 20) until the account exists.',
|
|
540
564
|
};
|
|
541
565
|
}
|
|
542
566
|
|
|
@@ -550,7 +574,7 @@ function makeSignupTools(deps = {}) {
|
|
|
550
574
|
}
|
|
551
575
|
|
|
552
576
|
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)
|
|
577
|
+
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), then set_main_role without clusterId only when its next says so. Finally open_web.';
|
|
554
578
|
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
579
|
if (account.state === 'existing') {
|
|
556
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}`;
|
|
@@ -559,11 +583,21 @@ function makeSignupTools(deps = {}) {
|
|
|
559
583
|
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
584
|
}
|
|
561
585
|
|
|
586
|
+
// A source that did not import gets its fixed line once, on the next question block.
|
|
587
|
+
function noticeFailed(sources) {
|
|
588
|
+
const fresh = sources.filter((s) => !state.failedSources.has(s));
|
|
589
|
+
fresh.forEach((s) => state.failedSources.add(s));
|
|
590
|
+
const c = copy();
|
|
591
|
+
if (fresh.length) queueNotice(c.importFailed(fresh.map((s) => c.importSources[s] || s)));
|
|
592
|
+
return fresh.length > 0;
|
|
593
|
+
}
|
|
594
|
+
|
|
562
595
|
function guidance(account, imp) {
|
|
563
596
|
const lines = [];
|
|
564
597
|
const linkedin = imp.sources && imp.sources.linkedin;
|
|
565
598
|
if (linkedin && linkedin.state === 'failed') {
|
|
566
|
-
|
|
599
|
+
noticeFailed(['linkedin']);
|
|
600
|
+
lines.push('The LinkedIn import failed. Its fixed line for the talent comes with the next question: do not add your own, never with codes; retry once with import_profile; if it fails again, ask for their website or GitHub and send them with import_profile.');
|
|
567
601
|
}
|
|
568
602
|
if (account.state === 'waiting') lines.push('The talent is still in the sign-up window. Call signup_status again.');
|
|
569
603
|
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.');
|
|
@@ -616,12 +650,13 @@ function makeSignupTools(deps = {}) {
|
|
|
616
650
|
}
|
|
617
651
|
return { ok: false, reason: res.reason };
|
|
618
652
|
}
|
|
619
|
-
const failed = res.report ? res.report.sources.filter((src) => src.status === 'failed') : [];
|
|
653
|
+
const failed = res.report ? res.report.sources.filter((src) => src.status === 'failed').map((src) => src.source) : [];
|
|
654
|
+
noticeFailed(failed);
|
|
620
655
|
return {
|
|
621
656
|
ok: true,
|
|
622
657
|
report: res.report,
|
|
623
658
|
message: failed.length
|
|
624
|
-
? `Imported, except: ${failed.
|
|
659
|
+
? `Imported, except: ${failed.join(', ')}. Its fixed line for the talent comes with the next question: do not add your own, never with codes.`
|
|
625
660
|
: 'Imported. Existing texts and languages were kept; lists were added without duplicates.',
|
|
626
661
|
};
|
|
627
662
|
}
|
|
@@ -630,30 +665,95 @@ function makeSignupTools(deps = {}) {
|
|
|
630
665
|
return value === null || value === undefined ? copy().existingEmpty : `${amount(lang(), value)} ${unit}`;
|
|
631
666
|
}
|
|
632
667
|
|
|
633
|
-
//
|
|
634
|
-
async function
|
|
635
|
-
const c = copy();
|
|
636
|
-
const draft = state.draft || {};
|
|
668
|
+
// The existing profile as far as it can be read; a field that could not be read counts as set, so it is never overwritten.
|
|
669
|
+
async function currentProfile() {
|
|
637
670
|
const session = resolved.loadAuthSession();
|
|
638
671
|
const hubAccessToken = session && session.hubAccessToken;
|
|
639
|
-
const [rate, availability] = await Promise.all([
|
|
672
|
+
const [rate, availability, languages] = await Promise.all([
|
|
640
673
|
fetchPricingRate({ hubAccessToken }).catch(() => ({ ok: false })),
|
|
641
674
|
fetchAvailability({ hubAccessToken }).catch(() => ({ ok: false })),
|
|
675
|
+
fetchLanguages({ hubAccessToken }).catch(() => ({ ok: false })),
|
|
642
676
|
]);
|
|
677
|
+
const pricing = rate && rate.ok ? rate.pricing || {} : null;
|
|
678
|
+
// An unselected rate may keep an old amount: only a selected one counts as set.
|
|
679
|
+
const amountOf = (selected, price) => (selected === true && price && typeof price.amount === 'number' ? price.amount : null);
|
|
680
|
+
return {
|
|
681
|
+
pricing: pricing && { hourly: amountOf(pricing.partTimeSelected, pricing.partTimePrice), annual: amountOf(pricing.fullTimeSelected, pricing.fullTimePrice) },
|
|
682
|
+
availability: availability && availability.ok ? availability.availability || {} : null,
|
|
683
|
+
languageCount: languages && languages.ok ? languages.languageCount : null,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Fill-empty only: what of the confirmed draft would land in an existing profile, plus the import that only adds.
|
|
688
|
+
async function existingDiff() {
|
|
689
|
+
const c = copy();
|
|
690
|
+
const draft = state.draft || {};
|
|
691
|
+
const current = await currentProfile();
|
|
643
692
|
const lines = [];
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
if (
|
|
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
|
-
}
|
|
693
|
+
const has = (v) => v !== undefined && v !== null && v !== '';
|
|
694
|
+
if (current.pricing && current.pricing.hourly === null && has(draft.hourlyRate)) lines.push(c.existingRate(c.existingEmpty, money(draft.hourlyRate, c.perHour)));
|
|
695
|
+
if (current.pricing && current.pricing.annual === null && has(draft.annualRate)) lines.push(c.existingAnnual(c.existingEmpty, money(draft.annualRate, c.perYear)));
|
|
696
|
+
if (current.availability && !has(current.availability.monthlyHours) && has(draft.monthlyHours)) lines.push(c.existingHours(c.existingEmpty, c.hours(draft.monthlyHours)));
|
|
653
697
|
lines.push(c.existingLists);
|
|
654
698
|
return `${c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
|
|
655
699
|
}
|
|
656
700
|
|
|
701
|
+
// The sections of save_profile_details an existing profile can take (only fields it leaves empty), and what it keeps instead.
|
|
702
|
+
async function fillEmpty(details) {
|
|
703
|
+
const current = await currentProfile();
|
|
704
|
+
const out = {};
|
|
705
|
+
const kept = {};
|
|
706
|
+
const has = (v) => v !== undefined && v !== null && v !== '';
|
|
707
|
+
const keep = (section, key, value, requested) => { kept[section] = { ...kept[section], [key]: { kept: value, requested } }; };
|
|
708
|
+
const p = details.pricing;
|
|
709
|
+
if (p && typeof p === 'object') {
|
|
710
|
+
const now = current.pricing || { hourly: undefined, annual: undefined };
|
|
711
|
+
for (const [key, field] of [['hourlyRate', 'hourly'], ['annualRate', 'annual']]) {
|
|
712
|
+
if (has(p[key]) && now[field] !== null) keep('pricing', key, now[field] ?? null, p[key]);
|
|
713
|
+
}
|
|
714
|
+
const fills = current.pricing && ((current.pricing.hourly === null && has(p.hourlyRate)) || (current.pricing.annual === null && has(p.annualRate)));
|
|
715
|
+
if (fills) out.pricing = { hourlyRate: current.pricing.hourly ?? p.hourlyRate, annualRate: current.pricing.annual ?? p.annualRate };
|
|
716
|
+
}
|
|
717
|
+
const a = details.availability;
|
|
718
|
+
if (a && typeof a === 'object') {
|
|
719
|
+
const now = current.availability;
|
|
720
|
+
const empty = (key) => !!now && (key === 'workModes' ? !(Array.isArray(now.workModes) && now.workModes.length) : !has(now[key]));
|
|
721
|
+
const given = Object.entries(a).filter(([, value]) => has(value));
|
|
722
|
+
given.filter(([key]) => !empty(key)).forEach(([key, value]) => keep('availability', key, now ? now[key] ?? null : null, value));
|
|
723
|
+
const filled = Object.fromEntries(given.filter(([key]) => empty(key)));
|
|
724
|
+
if (Object.keys(filled).length) out.availability = filled;
|
|
725
|
+
}
|
|
726
|
+
if (Array.isArray(details.languages) && details.languages.length) {
|
|
727
|
+
if (current.languageCount === 0) out.languages = details.languages;
|
|
728
|
+
else kept.languages = { kept: current.languageCount, requested: details.languages.length };
|
|
729
|
+
}
|
|
730
|
+
for (const section of ['workSituation', 'phone']) if (details[section] && typeof details[section] === 'object') kept[section] = { kept: null, requested: details[section] };
|
|
731
|
+
return { details: out, kept };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// What the existing profile kept, for the model per field and for the talent as one fixed line.
|
|
735
|
+
function keptReport(kept) {
|
|
736
|
+
const c = copy();
|
|
737
|
+
const fields = [];
|
|
738
|
+
const items = [];
|
|
739
|
+
const value = (v, shown) => (v === null || v === undefined ? null : shown(v));
|
|
740
|
+
if (kept.workSituation) { fields.push('workSituation: not changed (the profile keeps what it had)'); items.push(c.keptLabels.workSituation); }
|
|
741
|
+
for (const [key, label, unit] of [['hourlyRate', c.hourlyRateLabel, c.perHour], ['annualRate', c.annualRateLabel, c.perYear]]) {
|
|
742
|
+
const k = kept.pricing && kept.pricing[key];
|
|
743
|
+
if (!k) continue;
|
|
744
|
+
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)}`);
|
|
746
|
+
}
|
|
747
|
+
for (const [key, k] of Object.entries(kept.availability || {})) {
|
|
748
|
+
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
|
+
}
|
|
750
|
+
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); }
|
|
753
|
+
if (kept.phone) { fields.push('phone: not changed (the profile keeps what it had)'); items.push(c.keptLabels.phone); }
|
|
754
|
+
return { fields, line: items.length ? c.existingUnchanged(items) : null };
|
|
755
|
+
}
|
|
756
|
+
|
|
657
757
|
// Existing account: after signing in, the talent sees what would change and decides whether to update.
|
|
658
758
|
async function updateExistingProfile(args = {}, ctx = {}) {
|
|
659
759
|
const c = copy();
|
|
@@ -663,15 +763,17 @@ function makeSignupTools(deps = {}) {
|
|
|
663
763
|
const asked = await askAfterTexts(ctx, { tool: 'update_existing_profile', texts: [], question, options: [c.existingYes, c.existingNo], answer: args.answer });
|
|
664
764
|
if (asked.reply) return asked.reply;
|
|
665
765
|
if (asked.answer === c.existingNo) {
|
|
766
|
+
state.keepProfile = true;
|
|
666
767
|
return { ok: true, updated: false, relayVerbatim: c.existingKept, message: `${VERBATIM} Do not save the draft. Carry on with ai_usage.` };
|
|
667
768
|
}
|
|
769
|
+
state.keepProfile = false;
|
|
668
770
|
const sources = state.sources || {};
|
|
669
771
|
const imported = sources.linkedinUrl || sources.cvPath || sources.userQuery ? await importProfile(sources) : { ok: true };
|
|
670
772
|
return {
|
|
671
773
|
ok: true,
|
|
672
774
|
updated: true,
|
|
673
775
|
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.`,
|
|
776
|
+
message: `${imported.ok ? '' : 'The import did not go through; carry on anyway. '}Now save_profile_details with the confirmed draft, without asking: it only fills what the profile leaves empty. Then ai_usage without consent.`,
|
|
675
777
|
};
|
|
676
778
|
}
|
|
677
779
|
|
|
@@ -686,17 +788,23 @@ function makeSignupTools(deps = {}) {
|
|
|
686
788
|
const shown = [];
|
|
687
789
|
if (rates.hourlyRate != null && rates.hourlyRate !== '') shown.push(`${c.hourlyRateLabel}: ${money(rates.hourlyRate, c.perHour)}`);
|
|
688
790
|
if (rates.annualRate != null && rates.annualRate !== '') shown.push(`${c.annualRateLabel}: ${money(rates.annualRate, c.perYear)}`);
|
|
689
|
-
return c.ratesSaved(shown);
|
|
791
|
+
return c.ratesSaved(shown, !(state.draft && state.draft.ratesFromTalent));
|
|
690
792
|
}
|
|
691
793
|
|
|
692
794
|
async function saveProfileDetails(args = {}) {
|
|
693
795
|
const { saved } = copy();
|
|
796
|
+
const existing = state.account.state === 'existing';
|
|
797
|
+
if (existing && state.keepProfile) return { ok: true, kept: true, results: {}, confirmations: [], message: 'The talent chose to keep their profile as it is: nothing was saved.' };
|
|
798
|
+
// An existing profile only takes what it leaves empty; the work situation and the phone cannot be read, so they are never written there.
|
|
799
|
+
const { details, kept } = existing ? await fillEmpty(args) : { details: args, kept: {} };
|
|
800
|
+
const keptPricing = kept.pricing || {};
|
|
801
|
+
const shownPricing = details.pricing && Object.fromEntries(Object.entries(details.pricing).filter(([key]) => !keptPricing[key]));
|
|
694
802
|
const sections = [
|
|
695
|
-
['workSituation',
|
|
696
|
-
['pricing',
|
|
697
|
-
['availability',
|
|
698
|
-
['languages', Array.isArray(
|
|
699
|
-
['phone',
|
|
803
|
+
['workSituation', details.workSituation, (v) => flow.saveWorkSituation(resolved, v), saved.workSituation],
|
|
804
|
+
['pricing', details.pricing, savePricing, ratesSaved(shownPricing)],
|
|
805
|
+
['availability', details.availability, (v) => flow.saveAvailability(resolved, v), saved.availability],
|
|
806
|
+
['languages', Array.isArray(details.languages) && details.languages.length ? details.languages : null, (v) => flow.saveLanguages(resolved, v), saved.languages],
|
|
807
|
+
['phone', details.phone, (v) => flow.savePhone(resolved, v), saved.phone],
|
|
700
808
|
];
|
|
701
809
|
const results = {};
|
|
702
810
|
const confirmations = [];
|
|
@@ -705,9 +813,23 @@ function makeSignupTools(deps = {}) {
|
|
|
705
813
|
const res = await save(value);
|
|
706
814
|
if (res.ok) confirmations.push(saved);
|
|
707
815
|
if (!res.ok && res.reason === 'no-session') return { ok: false, reason: 'no-session', message: 'The talent is not signed in yet.' };
|
|
708
|
-
|
|
816
|
+
const help = res.message || SECTION_HELP[res.reason];
|
|
817
|
+
results[name] = res.ok ? { ok: true } : { ok: false, reason: res.reason, ...(help ? { message: help } : {}) };
|
|
709
818
|
}
|
|
710
|
-
|
|
819
|
+
const ok = Object.values(results).every((r) => r.ok);
|
|
820
|
+
const report = keptReport(kept);
|
|
821
|
+
if (report.line) queueNotice(report.line);
|
|
822
|
+
const messages = [
|
|
823
|
+
ok ? '' : 'Some sections were not saved: fix each one as its message says and call save_profile_details again with only those sections. Never show the talent these reasons.',
|
|
824
|
+
report.fields.length ? `This account already existed, so these values were NOT saved: ${report.fields.join('; ')}. Never tell the talent they were saved; the fixed line saying so comes with the next question.` : '',
|
|
825
|
+
].filter(Boolean);
|
|
826
|
+
return {
|
|
827
|
+
ok,
|
|
828
|
+
results,
|
|
829
|
+
confirmations,
|
|
830
|
+
...(report.fields.length ? { kept } : {}),
|
|
831
|
+
...(messages.length ? { message: messages.join(' ') } : {}),
|
|
832
|
+
};
|
|
711
833
|
}
|
|
712
834
|
|
|
713
835
|
async function openWeb(args = {}) {
|
|
@@ -723,12 +845,17 @@ function makeSignupTools(deps = {}) {
|
|
|
723
845
|
if (!url) return { ok: false, reason: 'no-endpoint' };
|
|
724
846
|
// The address reaches the AI only when the talent needs a link: the AI comments on hosts it sees.
|
|
725
847
|
const opened = args.open !== false && openBrowser(url) !== false;
|
|
848
|
+
// The flow ends here, so a fixed line no question carried is printed now.
|
|
849
|
+
const unseen = takeNotices();
|
|
850
|
+
const relay = unseen.length ? { relayVerbatim: unseen.join('\n\n') } : {};
|
|
851
|
+
const relayFirst = unseen.length ? `${VERBATIM} ` : '';
|
|
726
852
|
if (opened) {
|
|
727
853
|
return {
|
|
728
854
|
ok: true,
|
|
729
855
|
opened: true,
|
|
730
856
|
loggedIn,
|
|
731
|
-
|
|
857
|
+
...relay,
|
|
858
|
+
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.')
|
|
732
859
|
+ ' If they say it did not open, call open_web with open:false and give them its link.',
|
|
733
860
|
};
|
|
734
861
|
}
|
|
@@ -737,7 +864,8 @@ function makeSignupTools(deps = {}) {
|
|
|
737
864
|
opened: false,
|
|
738
865
|
loggedIn,
|
|
739
866
|
link: url,
|
|
740
|
-
|
|
867
|
+
...relay,
|
|
868
|
+
message: relayFirst + 'Give the talent `link` to click, with no comment on the address.' + (loggedIn ? ' It works once, for 2 minutes.' : ''),
|
|
741
869
|
};
|
|
742
870
|
}
|
|
743
871
|
|
|
@@ -754,7 +882,7 @@ function makeSignupTools(deps = {}) {
|
|
|
754
882
|
return [
|
|
755
883
|
{
|
|
756
884
|
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
|
|
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 (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 `say` or relayVerbatim is printed word for word, in that language.",
|
|
758
886
|
inputSchema: WELCOME_SCHEMA,
|
|
759
887
|
handler: signupStart,
|
|
760
888
|
},
|
|
@@ -766,13 +894,13 @@ function makeSignupTools(deps = {}) {
|
|
|
766
894
|
},
|
|
767
895
|
{
|
|
768
896
|
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.',
|
|
897
|
+
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 how the account window works, and asks them to confirm it. The window to create the account opens only after they confirm.',
|
|
770
898
|
inputSchema: DRAFT_SCHEMA,
|
|
771
899
|
handler: signupDraft,
|
|
772
900
|
},
|
|
773
901
|
{
|
|
774
902
|
name: 'signup_create_account',
|
|
775
|
-
description: "After the talent confirmed the draft
|
|
903
|
+
description: "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.",
|
|
776
904
|
inputSchema: CREATE_SCHEMA,
|
|
777
905
|
handler: signupCreateAccount,
|
|
778
906
|
},
|
|
@@ -784,7 +912,7 @@ function makeSignupTools(deps = {}) {
|
|
|
784
912
|
},
|
|
785
913
|
{
|
|
786
914
|
name: 'update_existing_profile',
|
|
787
|
-
description: 'When signup_status says the talent already had an account: shows what the confirmed draft would
|
|
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.',
|
|
788
916
|
inputSchema: UPDATE_EXISTING_SCHEMA,
|
|
789
917
|
handler: updateExistingProfile,
|
|
790
918
|
},
|
|
@@ -796,7 +924,7 @@ function makeSignupTools(deps = {}) {
|
|
|
796
924
|
},
|
|
797
925
|
{
|
|
798
926
|
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.",
|
|
927
|
+
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. On an account that already existed it only fills what the profile leaves empty, never overwrites a value the talent set.",
|
|
800
928
|
inputSchema: buildDetailsSchema(deps.lang || 'en'),
|
|
801
929
|
handler: saveProfileDetails,
|
|
802
930
|
},
|
package/src/register-context.js
CHANGED
|
@@ -88,6 +88,8 @@ function isGenericCvName(fileName) {
|
|
|
88
88
|
// PDF/Word files whose NAME looks like a CV or carries the person's full name:
|
|
89
89
|
// name matches first, then generic names, then newest. Never opens a file.
|
|
90
90
|
function findCvCandidates(home, personName) {
|
|
91
|
+
// Without a name there is nothing to match: null says unknown, never a mismatch.
|
|
92
|
+
const known = nameTokens(personName).length > 0;
|
|
91
93
|
const found = [];
|
|
92
94
|
const walk = (dir, depth) => {
|
|
93
95
|
let entries;
|
|
@@ -106,7 +108,7 @@ function findCvCandidates(home, personName) {
|
|
|
106
108
|
try {
|
|
107
109
|
found.push({
|
|
108
110
|
path: full,
|
|
109
|
-
nameMatch: matchesName(entry.name, personName),
|
|
111
|
+
nameMatch: known ? matchesName(entry.name, personName) : null,
|
|
110
112
|
generic: isGenericCvName(entry.name),
|
|
111
113
|
mtimeMs: fs.statSync(full).mtimeMs,
|
|
112
114
|
});
|