shakers-cli-beta 0.13.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/backend-request.js +12 -8
- package/src/config.js +17 -5
- package/src/consent-flow.js +10 -10
- package/src/i18n.js +6 -6
- 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-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 +27 -1
- package/src/mcp-device-login-tools.js +6 -2
- package/src/mcp-find-projects-tools.js +4 -2
- package/src/mcp-instructions.js +1 -1
- 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 +2 -2
- package/src/mcp-rate-tools.js +4 -2
- package/src/mcp-register-tools.js +52 -19
- package/src/mcp-report-tools.js +7 -3
- package/src/mcp-role-tools.js +23 -8
- package/src/mcp-saved-positions-tools.js +7 -3
- package/src/mcp-server.js +8 -2
- package/src/mcp-show-project-tools.js +4 -2
- package/src/mcp-signup-tools.js +153 -20
- package/src/mcp-socials-tools.js +4 -2
- package/src/onboarding-flow.js +112 -13
- package/src/onboarding-funnel.js +272 -0
- package/src/share.js +4 -3
- package/src/signup-copy.js +55 -1
- package/src/mcp-ask-tools.js +0 -50
package/src/mcp-role-tools.js
CHANGED
|
@@ -109,12 +109,13 @@ function makeRoleTools(deps = {}) {
|
|
|
109
109
|
countOpenPositions = (opts) => require('./find-projects-client').countOpenPositions({}, opts),
|
|
110
110
|
sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
|
|
111
111
|
now = () => Date.now(),
|
|
112
|
+
funnel = require('./onboarding-funnel').NOOP_FUNNEL,
|
|
112
113
|
} = deps;
|
|
113
114
|
|
|
114
115
|
const requireSession = () => {
|
|
115
116
|
const session = loadAuthSession();
|
|
116
117
|
if (sessionStatus(session) !== 'active') {
|
|
117
|
-
throw new Error('no active Shakers session —
|
|
118
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first.');
|
|
118
119
|
}
|
|
119
120
|
return session;
|
|
120
121
|
};
|
|
@@ -317,37 +318,51 @@ function makeRoleTools(deps = {}) {
|
|
|
317
318
|
const added = await addRole({ clusterId: picked.role.clusterId });
|
|
318
319
|
if (!added.ok) return added;
|
|
319
320
|
}
|
|
320
|
-
const set = await
|
|
321
|
+
const set = await applyMainRole(session, picked.role.clusterId, { suggestion: !picked.add });
|
|
321
322
|
return set.ok ? { ...set, name: picked.role.name } : set;
|
|
322
323
|
}
|
|
323
|
-
|
|
324
|
+
return applyMainRole(session, args.clusterId, { suggestion: args.catalogue === true ? false : undefined });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function applyMainRole(session, clusterId, { suggestion } = {}) {
|
|
328
|
+
const res = await requestSetMainRole({ clusterId, hubAccessToken: session.hubAccessToken }, { endpoint: getSetMainRoleEndpoint() });
|
|
324
329
|
if (res.ok === false && res.reason === 'http-400') return { ok: false, reason: res.reason, message: 'The talent does not hold that role yet: call add_role with it, then set_main_role again.' };
|
|
325
330
|
if (!res.ok) return { ok: false, reason: res.reason };
|
|
326
|
-
|
|
331
|
+
funnel.completed('supercluster', { supercluster: clusterId, was_suggestion_accepted: suggestion });
|
|
332
|
+
funnel.flush();
|
|
333
|
+
return { ok: true, clusterId };
|
|
327
334
|
}
|
|
328
335
|
|
|
329
336
|
return [
|
|
330
337
|
{
|
|
331
338
|
name: 'list_available_roles',
|
|
332
|
-
|
|
339
|
+
title: 'Roles you can add',
|
|
340
|
+
description: 'Shows the Shakers roles you can add to your profile.',
|
|
341
|
+
guidance: "List the roles the talent can add to their profile (the cluster catalog minus roles they already hold). Each row has clusterId (internal handle), name, category and an optional proposalKind (why it is suggested). Present roles to the talent by NAME; never show clusterId. Requires an active session.",
|
|
333
342
|
inputSchema: EMPTY_SCHEMA,
|
|
334
343
|
handler: listAvailableRoles,
|
|
335
344
|
},
|
|
336
345
|
{
|
|
337
346
|
name: 'add_role',
|
|
338
|
-
|
|
347
|
+
title: 'Add a role',
|
|
348
|
+
description: 'Adds the role you pick to your Shakers profile.',
|
|
349
|
+
guidance: "Add a role to the talent's profile (a GROWTH role) by clusterId from list_available_roles. Requires an active session. Returns { clusterId, alreadyAssigned? }.",
|
|
339
350
|
inputSchema: CLUSTER_SCHEMA,
|
|
340
351
|
handler: addRole,
|
|
341
352
|
},
|
|
342
353
|
{
|
|
343
354
|
name: 'list_my_roles',
|
|
344
|
-
|
|
355
|
+
title: 'Your roles',
|
|
356
|
+
description: 'Shows your roles on Shakers and the ones suggested by your interview and your CV.',
|
|
357
|
+
guidance: "List the roles the talent already holds, plus mainClusterId (their current main role) and `recommended`: the roles the onboarding interview and the CV import suggest, interview first. Use it before set_main_role. In the sign-up pass waitSeconds (20): it waits for the interview evaluation to recommend roles and says when to call again (`pending`) or to fall back to list_available_roles. Follow `message`. clusterId is an internal handle — refer to roles by name to the talent. Requires an active session.",
|
|
345
358
|
inputSchema: LIST_MY_ROLES_SCHEMA,
|
|
346
359
|
handler: listMyRoles,
|
|
347
360
|
},
|
|
348
361
|
{
|
|
349
362
|
name: 'set_main_role',
|
|
350
|
-
|
|
363
|
+
title: 'Choose your main role',
|
|
364
|
+
description: 'Asks you which role is your main one and saves it on your Shakers profile.',
|
|
365
|
+
guidance: "Set the talent's MAIN role. In the sign-up it is the last step before open_web: after onboarding_interview_complete (or when the talent skipped the interview) and once list_my_roles is no longer pending, call it without clusterId: it asks the talent which of the recommended roles is their main one, with the catalogue as the last option (or straight from the catalogue when nothing is recommended), in a dialog when the client has one; otherwise it returns `say` to print word for word and the roles, to call it again with the clusterId they pick. Then it sets that role (adding a catalogue role first). Also usable standalone with clusterId to change the main role later; catalogue:true adds a catalogue role first. Requires an active session.",
|
|
351
366
|
inputSchema: SET_MAIN_ROLE_SCHEMA,
|
|
352
367
|
handler: setMainRole,
|
|
353
368
|
},
|
|
@@ -19,7 +19,7 @@ function makeSavedPositionsTools(deps = {}) {
|
|
|
19
19
|
const requireLoginSession = () => {
|
|
20
20
|
const session = loadAuthSession();
|
|
21
21
|
if (sessionStatus(session) !== 'active') {
|
|
22
|
-
throw new Error('no active Shakers session —
|
|
22
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (this tool is session-gated).');
|
|
23
23
|
}
|
|
24
24
|
return session;
|
|
25
25
|
};
|
|
@@ -36,13 +36,17 @@ function makeSavedPositionsTools(deps = {}) {
|
|
|
36
36
|
return [
|
|
37
37
|
{
|
|
38
38
|
name: 'save_project',
|
|
39
|
-
|
|
39
|
+
title: 'Save a project',
|
|
40
|
+
description: 'Saves a project to your list on Shakers.',
|
|
41
|
+
guidance: 'Bookmark a project/Position for the talent (Find Positions "Saved"). Idempotent — saving twice is a no-op. Requires an active session and the position id. The position id is an INTERNAL handle for calling tools — never show or mention it to the talent.',
|
|
40
42
|
inputSchema: ID_SCHEMA,
|
|
41
43
|
handler: toggle(savePosition),
|
|
42
44
|
},
|
|
43
45
|
{
|
|
44
46
|
name: 'unsave_project',
|
|
45
|
-
|
|
47
|
+
title: 'Remove a saved project',
|
|
48
|
+
description: 'Removes a project from your saved list on Shakers.',
|
|
49
|
+
guidance: 'Remove a project/Position bookmark for the talent. Idempotent — un-saving something not saved is not an error. Requires an active session and the position id. The position id is an INTERNAL handle for calling tools — never show or mention it to the talent.',
|
|
46
50
|
inputSchema: ID_SCHEMA,
|
|
47
51
|
handler: toggle(unsavePosition),
|
|
48
52
|
},
|
package/src/mcp-server.js
CHANGED
|
@@ -34,7 +34,7 @@ function errorResponse(id, code, message, data) {
|
|
|
34
34
|
return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
|
|
37
|
+
function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, onInitialize = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
|
|
38
38
|
const registry = new Map();
|
|
39
39
|
for (const t of tools) {
|
|
40
40
|
if (!t || !t.name || typeof t.handler !== 'function') {
|
|
@@ -107,11 +107,14 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// `description` and `title` are what the talent reads in the client; `guidance` is how the model must use the tool, so it rides in the schema.
|
|
110
111
|
function shapeTool(t) {
|
|
112
|
+
const schema = t.inputSchema || { type: 'object' };
|
|
111
113
|
return {
|
|
112
114
|
name: t.name,
|
|
115
|
+
...(t.title ? { title: t.title } : {}),
|
|
113
116
|
description: t.description || '',
|
|
114
|
-
inputSchema: t.
|
|
117
|
+
inputSchema: t.guidance ? { ...schema, description: [t.guidance, schema.description].filter(Boolean).join(' ') } : schema,
|
|
115
118
|
};
|
|
116
119
|
}
|
|
117
120
|
|
|
@@ -205,6 +208,9 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
205
208
|
case 'initialize': {
|
|
206
209
|
const requested = msg.params && msg.params.protocolVersion;
|
|
207
210
|
clientCapabilities = (msg.params && msg.params.capabilities) || {};
|
|
211
|
+
if (typeof onInitialize === 'function') {
|
|
212
|
+
try { onInitialize({ clientInfo: (msg.params && msg.params.clientInfo) || {} }); } catch { /* never breaks the handshake */ }
|
|
213
|
+
}
|
|
208
214
|
// One line per connection on stderr (never stdout: it carries JSON-RPC), so the host's
|
|
209
215
|
// declared elicitation shape and the resulting decision are visible in the MCP log.
|
|
210
216
|
process.stderr.write(` [mcp] initialize elicitation=${JSON.stringify(clientCapabilities.elicitation)} -> elicit=${canElicitForm()}\n`);
|
|
@@ -20,7 +20,7 @@ function makeShowProjectTools(deps = {}) {
|
|
|
20
20
|
const requireLoginSession = () => {
|
|
21
21
|
const session = loadAuthSession();
|
|
22
22
|
if (sessionStatus(session) !== 'active') {
|
|
23
|
-
throw new Error('no active Shakers session —
|
|
23
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (show_project is session-gated).');
|
|
24
24
|
}
|
|
25
25
|
return session;
|
|
26
26
|
};
|
|
@@ -37,7 +37,9 @@ function makeShowProjectTools(deps = {}) {
|
|
|
37
37
|
return [
|
|
38
38
|
{
|
|
39
39
|
name: 'show_project',
|
|
40
|
-
|
|
40
|
+
title: 'See a project',
|
|
41
|
+
description: 'Shows the full details of one Shakers project.',
|
|
42
|
+
guidance: "Get the full detail of one Position/Project available to the talent (Shakers Find Positions detail): title, parent project, company, budget, work mode, required skills/languages, description, goals, FAQs, and the talent's saved/applied/canApply state. Requires an active session and the position id. The `id` (and any positionId/projectId in the result) is an INTERNAL handle for calling tools — NEVER show or mention it to the talent; refer to the project by its title/company.",
|
|
41
43
|
inputSchema: SHOW_PROJECT_SCHEMA,
|
|
42
44
|
handler: showProject,
|
|
43
45
|
},
|
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
|
|
|
@@ -173,6 +176,15 @@ const EMAIL_SCHEMA = {
|
|
|
173
176
|
|
|
174
177
|
// In the order the data notice names them.
|
|
175
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
|
+
};
|
|
176
188
|
|
|
177
189
|
const DRAFT_SCHEMA = {
|
|
178
190
|
type: 'object',
|
|
@@ -181,6 +193,9 @@ const DRAFT_SCHEMA = {
|
|
|
181
193
|
name: { type: 'string', description: 'Full name.' },
|
|
182
194
|
role: { type: 'string', description: 'Role, e.g. "Data Engineer".' },
|
|
183
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.' },
|
|
184
199
|
yearsOfExperience: { type: 'number' },
|
|
185
200
|
stack: { type: 'string', description: 'Main technologies, comma separated, at most five.' },
|
|
186
201
|
languages: { type: 'string', description: 'Languages and level, e.g. "Español nativo, inglés C1".' },
|
|
@@ -230,6 +245,7 @@ const IMPORT_SCHEMA = {
|
|
|
230
245
|
websiteUrl: { type: 'string' },
|
|
231
246
|
userQuery: { type: 'string', description: 'Everything you know about the talent, as free text.' },
|
|
232
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.' },
|
|
233
249
|
},
|
|
234
250
|
};
|
|
235
251
|
|
|
@@ -288,6 +304,7 @@ function makeSignupTools(deps = {}) {
|
|
|
288
304
|
fetchPricingRate = (opts) => require('./rate-client').fetchPricingRate({}, opts),
|
|
289
305
|
fetchAvailability = (opts) => require('./availability-client').fetchAvailability({}, opts),
|
|
290
306
|
fetchLanguages = (opts) => require('./profile-client').fetchLanguages({}, opts),
|
|
307
|
+
funnel = NOOP_FUNNEL,
|
|
291
308
|
fetchTelephone = (opts) => require('./profile-client').fetchTelephone({}, opts),
|
|
292
309
|
openBrowser = require('./open-file').openPath,
|
|
293
310
|
sleep = defaultSleep,
|
|
@@ -301,6 +318,7 @@ function makeSignupTools(deps = {}) {
|
|
|
301
318
|
sessionToken: null,
|
|
302
319
|
email: null,
|
|
303
320
|
draft: null,
|
|
321
|
+
firstDraft: null,
|
|
304
322
|
draftConfirmed: false,
|
|
305
323
|
keepProfile: false,
|
|
306
324
|
// Fields the update question already told the talent they keep.
|
|
@@ -313,11 +331,18 @@ function makeSignupTools(deps = {}) {
|
|
|
313
331
|
failedSources: new Set(),
|
|
314
332
|
cvPath: null,
|
|
315
333
|
wake: null,
|
|
334
|
+
importStartedAt: null,
|
|
335
|
+
importReported: new Set(),
|
|
316
336
|
};
|
|
317
337
|
currentSignup = state;
|
|
318
338
|
|
|
319
339
|
function setAccount(next) {
|
|
340
|
+
const before = state.account.state;
|
|
320
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
|
+
}
|
|
321
346
|
if (state.wake) state.wake();
|
|
322
347
|
}
|
|
323
348
|
|
|
@@ -432,7 +457,12 @@ function makeSignupTools(deps = {}) {
|
|
|
432
457
|
}
|
|
433
458
|
if (!nonEmpty(args.answer)) {
|
|
434
459
|
fixSignupLanguage(args.language);
|
|
460
|
+
funnel.begin();
|
|
461
|
+
funnel.viewed('start');
|
|
435
462
|
state.draft = null;
|
|
463
|
+
state.firstDraft = null;
|
|
464
|
+
state.importStartedAt = null;
|
|
465
|
+
state.importReported = new Set();
|
|
436
466
|
state.draftConfirmed = false;
|
|
437
467
|
state.email = null;
|
|
438
468
|
state.failedSources.clear();
|
|
@@ -447,13 +477,14 @@ function makeSignupTools(deps = {}) {
|
|
|
447
477
|
// A CV path in the first message already answers the CV question: that CV is read, and the welcome
|
|
448
478
|
// rides on the next question block, because models skip a relay that comes before another tool call.
|
|
449
479
|
if (!nonEmpty(args.answer) && state.cvPath) {
|
|
480
|
+
funnel.completed('start');
|
|
450
481
|
queueNotice(c.welcome(nonEmpty(args.firstName)));
|
|
451
482
|
return {
|
|
452
483
|
ok: true,
|
|
453
484
|
step: 'cv-given',
|
|
454
485
|
cvPath: state.cvPath,
|
|
455
486
|
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
|
|
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}`,
|
|
457
488
|
};
|
|
458
489
|
}
|
|
459
490
|
const asked = await askAfterTexts(ctx, {
|
|
@@ -464,6 +495,7 @@ function makeSignupTools(deps = {}) {
|
|
|
464
495
|
answer: args.answer,
|
|
465
496
|
});
|
|
466
497
|
if (asked.reply) return { ...asked.reply, language: lang() };
|
|
498
|
+
funnel.completed('start');
|
|
467
499
|
// A CV path the talent gave wins over both answers: searching could read another candidate.
|
|
468
500
|
const cvPath = state.cvPath;
|
|
469
501
|
if (cvPath) {
|
|
@@ -471,7 +503,7 @@ function makeSignupTools(deps = {}) {
|
|
|
471
503
|
ok: true,
|
|
472
504
|
step: 'cv-given',
|
|
473
505
|
cvPath,
|
|
474
|
-
message: `The talent already gave their CV: read_cv
|
|
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}`,
|
|
475
507
|
};
|
|
476
508
|
}
|
|
477
509
|
if (asked.answer === c.cvSearch) {
|
|
@@ -479,7 +511,7 @@ function makeSignupTools(deps = {}) {
|
|
|
479
511
|
ok: true,
|
|
480
512
|
step: 'cv-search',
|
|
481
513
|
cvNotFound: c.cvNotFound,
|
|
482
|
-
message: `The talent
|
|
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}`,
|
|
483
515
|
};
|
|
484
516
|
}
|
|
485
517
|
return {
|
|
@@ -525,9 +557,10 @@ function makeSignupTools(deps = {}) {
|
|
|
525
557
|
const pricing = pricingFromRates(args);
|
|
526
558
|
if (!pricing.ok) return pricing;
|
|
527
559
|
const draft = {};
|
|
528
|
-
for (const key of
|
|
560
|
+
for (const key of DRAFT_FIELDS) {
|
|
529
561
|
if (args[key] !== undefined && args[key] !== null && args[key] !== '') draft[key] = args[key];
|
|
530
562
|
}
|
|
563
|
+
if (typeof draft.country === 'string') draft.country = draft.country.trim().toUpperCase();
|
|
531
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.' };
|
|
532
565
|
if (draft.hourlyRate === undefined || draft.annualRate === undefined) {
|
|
533
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.' };
|
|
@@ -538,6 +571,7 @@ function makeSignupTools(deps = {}) {
|
|
|
538
571
|
if (!sources.length && !optionFor(args.answer, [c.draftOk, c.draftChange])) {
|
|
539
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." };
|
|
540
573
|
}
|
|
574
|
+
if (!state.firstDraft) state.firstDraft = draft;
|
|
541
575
|
const asked = await askAfterTexts(ctx, {
|
|
542
576
|
tool: 'signup_draft',
|
|
543
577
|
texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice(sources), knownAccount() ? c.windowNoticeSignIn : c.windowNotice],
|
|
@@ -617,7 +651,16 @@ function makeSignupTools(deps = {}) {
|
|
|
617
651
|
state.sources = sources;
|
|
618
652
|
state.reported = { account: 'waiting', import: 'none' };
|
|
619
653
|
const reused = state.account.state === 'waiting';
|
|
620
|
-
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
|
+
}
|
|
621
664
|
return {
|
|
622
665
|
ok: true,
|
|
623
666
|
status: 'window-open',
|
|
@@ -628,13 +671,35 @@ function makeSignupTools(deps = {}) {
|
|
|
628
671
|
};
|
|
629
672
|
}
|
|
630
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
|
+
|
|
631
693
|
async function readImport() {
|
|
632
694
|
// An account that already existed got no import from this sign-up: its last import is not news.
|
|
633
695
|
if (state.account.state === 'existing') return { state: 'none' };
|
|
634
696
|
const session = resolved.loadAuthSession();
|
|
635
697
|
if (resolved.sessionStatus(session) !== 'active' || !session || !session.hubAccessToken) return { state: 'none' };
|
|
636
698
|
const res = await requestMyImportStatus({ hubAccessToken: session.hubAccessToken }, { endpoint: getMyImportStatusEndpoint() });
|
|
637
|
-
|
|
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;
|
|
638
703
|
}
|
|
639
704
|
|
|
640
705
|
function nextSteps(account) {
|
|
@@ -644,7 +709,7 @@ function makeSignupTools(deps = {}) {
|
|
|
644
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}`;
|
|
645
710
|
}
|
|
646
711
|
const unclaimed = account.claimed === false ? ' This account did not take over the imported profile: call import_profile with the same LinkedIn/CV first.' : '';
|
|
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}`;
|
|
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}`;
|
|
648
713
|
}
|
|
649
714
|
|
|
650
715
|
// A source that did not import gets its fixed line once, on the next question block.
|
|
@@ -697,6 +762,19 @@ function makeSignupTools(deps = {}) {
|
|
|
697
762
|
return out;
|
|
698
763
|
}
|
|
699
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
|
+
|
|
700
778
|
async function importProfile(args = {}) {
|
|
701
779
|
if (nonEmpty(args.linkedinUrl) && !isLinkedinProfileUrl(nonEmpty(args.linkedinUrl))) return { ok: false, reason: 'invalid-linkedin-url', message: INVALID_LINKEDIN_MESSAGE };
|
|
702
780
|
const res = await flow.startProfileImport(resolved, {
|
|
@@ -710,7 +788,7 @@ function makeSignupTools(deps = {}) {
|
|
|
710
788
|
});
|
|
711
789
|
if (!res.ok) {
|
|
712
790
|
if (res.reason === 'no-session') {
|
|
713
|
-
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.' };
|
|
714
792
|
}
|
|
715
793
|
return { ok: false, reason: res.reason };
|
|
716
794
|
}
|
|
@@ -869,6 +947,40 @@ function makeSignupTools(deps = {}) {
|
|
|
869
947
|
return c.ratesSaved(shown, !(state.draft && state.draft.ratesFromTalent));
|
|
870
948
|
}
|
|
871
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
|
+
|
|
872
984
|
async function saveProfileDetails(args = {}) {
|
|
873
985
|
const { saved } = copy();
|
|
874
986
|
const existing = state.account.state === 'existing';
|
|
@@ -902,6 +1014,7 @@ function makeSignupTools(deps = {}) {
|
|
|
902
1014
|
results.phone = again.ok ? { ok: true } : { ok: false, reason: again.reason };
|
|
903
1015
|
}
|
|
904
1016
|
}
|
|
1017
|
+
trackSavedSteps(details, results);
|
|
905
1018
|
const confirmations = sections.filter(([name]) => results[name] && results[name].ok).map(([, , , text]) => text);
|
|
906
1019
|
const ok = Object.values(results).every((r) => r.ok);
|
|
907
1020
|
const report = keptReport(kept);
|
|
@@ -974,61 +1087,81 @@ function makeSignupTools(deps = {}) {
|
|
|
974
1087
|
return [
|
|
975
1088
|
{
|
|
976
1089
|
name: 'signup_start',
|
|
977
|
-
|
|
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.",
|
|
978
1093
|
inputSchema: WELCOME_SCHEMA,
|
|
979
1094
|
handler: signupStart,
|
|
980
1095
|
},
|
|
981
1096
|
{
|
|
982
1097
|
name: 'signup_email',
|
|
983
|
-
|
|
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.',
|
|
984
1101
|
inputSchema: EMAIL_SCHEMA,
|
|
985
1102
|
handler: signupEmail,
|
|
986
1103
|
},
|
|
987
1104
|
{
|
|
988
1105
|
name: 'signup_draft',
|
|
989
|
-
|
|
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.',
|
|
990
1109
|
inputSchema: DRAFT_SCHEMA,
|
|
991
1110
|
handler: signupDraft,
|
|
992
1111
|
},
|
|
993
1112
|
{
|
|
994
1113
|
name: 'signup_create_account',
|
|
995
|
-
|
|
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.",
|
|
996
1117
|
inputSchema: CREATE_SCHEMA,
|
|
997
1118
|
handler: signupCreateAccount,
|
|
998
1119
|
},
|
|
999
1120
|
{
|
|
1000
1121
|
name: 'signup_status',
|
|
1001
|
-
|
|
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.",
|
|
1002
1125
|
inputSchema: STATUS_SCHEMA,
|
|
1003
1126
|
handler: signupStatus,
|
|
1004
1127
|
},
|
|
1005
1128
|
{
|
|
1006
1129
|
name: 'update_existing_profile',
|
|
1007
|
-
|
|
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.',
|
|
1008
1133
|
inputSchema: UPDATE_EXISTING_SCHEMA,
|
|
1009
1134
|
handler: updateExistingProfile,
|
|
1010
1135
|
},
|
|
1011
1136
|
{
|
|
1012
1137
|
name: 'import_profile',
|
|
1013
|
-
|
|
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.",
|
|
1014
1141
|
inputSchema: IMPORT_SCHEMA,
|
|
1015
|
-
handler:
|
|
1142
|
+
handler: importProfileAsked,
|
|
1016
1143
|
},
|
|
1017
1144
|
{
|
|
1018
1145
|
name: 'save_profile_details',
|
|
1019
|
-
|
|
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.",
|
|
1020
1149
|
inputSchema: buildDetailsSchema(deps.lang || 'en'),
|
|
1021
1150
|
handler: saveProfileDetails,
|
|
1022
1151
|
},
|
|
1023
1152
|
{
|
|
1024
1153
|
name: 'open_web',
|
|
1025
|
-
|
|
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.",
|
|
1026
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.' } } },
|
|
1027
1158
|
handler: openWeb,
|
|
1028
1159
|
},
|
|
1029
1160
|
{
|
|
1030
1161
|
name: 'open_linkedin_profile',
|
|
1031
|
-
|
|
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.",
|
|
1032
1165
|
inputSchema: { type: 'object', properties: {} },
|
|
1033
1166
|
handler: openLinkedinProfile,
|
|
1034
1167
|
},
|
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
|
},
|