shakers-cli-beta 0.13.0 → 0.14.1
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-instructions.js
CHANGED
|
@@ -11,7 +11,7 @@ function buildServerInstructions({ elicitation = false } = {}) {
|
|
|
11
11
|
? 'Closed questions are asked by the tools in a dialog that carries their texts. Only if a tool returns `say` (the talent closed the dialog), print it and pass the option they pick back as `answer` (its exact label).'
|
|
12
12
|
: 'Closed questions come as `say`: the fixed texts, the question and its options as a list; print it, then pass the option they pick back as `answer` (its exact label).',
|
|
13
13
|
'Sign-up flow:',
|
|
14
|
-
'1. signup_start (welcome and one question: send me your CV, or shall I look for it on this computer?); a CV path in their first message goes as cvPath and skips the question. Only if they pick the search, find and read their CV (suggest_register_context, read_cv)
|
|
14
|
+
'1. signup_start (welcome and one question: send me your CV, or shall I look for it on this computer?); a CV path in their first message goes as cvPath and skips the question. Only if they pick the search, find and read their CV (suggest_register_context, then read_cv); otherwise wait for the CV in the chat. suggest_register_context, read_cv, import_profile with a CV file and add_project each ask the talent themselves, naming what they read or send, before doing anything: never ask that in your own words and never pass an answer the talent did not give.',
|
|
15
15
|
'2. LinkedIn: take it from the CV. If it has none, signup_start with linkedinMissing:true returns the question to print; never ask it in your own words. Only if they say they do not know where to find it, say the linkedinOpening text and call open_linkedin_profile.',
|
|
16
16
|
'3. signup_email with the best email you know (CV first), or none.',
|
|
17
17
|
'4. signup_draft with your draft of their profile (name, role, city, years, stack, languages, work mode, hours, hourly and annual rate in euros, both always: the talent\'s figures with ratesFromTalent:true, else your estimate; sources: cv and/or linkedin, only those you have). It shows the draft, the data notice and how the account window works, and asks them to confirm; if they want changes, ask what and call it again.',
|
|
@@ -14,7 +14,7 @@ function makeInvitationsTools(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_invitations is session-gated).');
|
|
18
18
|
}
|
|
19
19
|
return session;
|
|
20
20
|
};
|
|
@@ -29,7 +29,9 @@ function makeInvitationsTools(deps = {}) {
|
|
|
29
29
|
return [
|
|
30
30
|
{
|
|
31
31
|
name: 'list_invitations',
|
|
32
|
-
|
|
32
|
+
title: 'Your invitations',
|
|
33
|
+
description: 'Shows the projects that invited you and that you have not read yet.',
|
|
34
|
+
guidance: "List the talent's unread position invitations (projects that invited them). Requires an active session. Any projectId/chatId in the result are INTERNAL handles for calling tools — never show or mention them to the talent; refer to a project by its name.",
|
|
33
35
|
inputSchema: LIST_INVITATIONS_SCHEMA,
|
|
34
36
|
handler: listInvitations,
|
|
35
37
|
},
|
package/src/mcp-lang-tools.js
CHANGED
|
@@ -14,7 +14,7 @@ function makeLangTools(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 (get_languages is session-gated).');
|
|
18
18
|
}
|
|
19
19
|
return session;
|
|
20
20
|
};
|
|
@@ -29,7 +29,9 @@ function makeLangTools(deps = {}) {
|
|
|
29
29
|
return [
|
|
30
30
|
{
|
|
31
31
|
name: 'get_languages',
|
|
32
|
-
|
|
32
|
+
title: 'Your languages',
|
|
33
|
+
description: 'Shows the languages and levels on your Shakers profile.',
|
|
34
|
+
guidance: "View the talent's languages and proficiency levels (NATIVE/ADVANCED/INTERMEDIATE/INTERMEDIATE_WRITTEN). Read-only. Requires an active session. Any language ids in the result are internal handles for tools only — never show or mention them to the talent; refer to a language by its name.",
|
|
33
35
|
inputSchema: GET_LANGUAGES_SCHEMA,
|
|
34
36
|
handler: getLanguages,
|
|
35
37
|
},
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
type: 'object',
|
|
7
|
-
properties: {
|
|
8
|
-
method: { type: 'string', enum: ['email', 'google', 'linkedin'], description: "Optional auth method. None of them sign in here: this tool points you to social_signin_start/social_signin_poll (the device flow) with that provider. 'email' reuses the talent's open Shakers web session or signs them in on the page (never a password in the chat); 'google'/'linkedin' open that social sign-in. Omit it to default to email (the page still offers Google and LinkedIn)." },
|
|
9
|
-
},
|
|
10
|
-
};
|
|
4
|
+
const { askConsent } = require('./mcp-choice');
|
|
5
|
+
const { tildify } = require('./env-paths');
|
|
11
6
|
|
|
12
7
|
const LOGOUT_SCHEMA = { type: 'object', properties: {} };
|
|
13
8
|
|
|
@@ -58,6 +53,7 @@ const ADD_PROJECT_SCHEMA = {
|
|
|
58
53
|
clientName: { type: 'string', description: 'Client name (optional, e.g. for freelance work).' },
|
|
59
54
|
clientDomain: { type: 'string', description: "Client website or domain (optional, e.g. 'acme.com' or a full URL — normalized to a bare domain). Hub generates the client logo from it." },
|
|
60
55
|
skillIds: { type: 'array', items: { type: 'integer' } },
|
|
56
|
+
answer: { type: 'string', description: 'The option the talent picked when you printed the question this tool returned. Never set it yourself.' },
|
|
61
57
|
},
|
|
62
58
|
};
|
|
63
59
|
|
|
@@ -89,6 +85,7 @@ function makeOnboardingTools(deps = {}) {
|
|
|
89
85
|
requestUpdatePortfolioSkills = require('./portfolio-client').requestUpdatePortfolioSkills,
|
|
90
86
|
getRemoteUrl = require('./portfolio-git-info').getRemoteUrl,
|
|
91
87
|
getCommitDateRange = require('./portfolio-git-info').getCommitDateRange,
|
|
88
|
+
consentCopy = () => require('./signup-copy').signupCopy(require('./signup-language').signupLanguage(require('./i18n').detectFlowLang())).consent,
|
|
92
89
|
normalizeDomain = require('./start-add-portfolio').normalizeDomain,
|
|
93
90
|
isValidEmail = require('./share').isValidEmail,
|
|
94
91
|
normalizeEmail = require('./share').normalizeEmail,
|
|
@@ -97,7 +94,7 @@ function makeOnboardingTools(deps = {}) {
|
|
|
97
94
|
const requireSession = () => {
|
|
98
95
|
const session = loadAuthSession();
|
|
99
96
|
if (sessionStatus(session) !== 'active') {
|
|
100
|
-
throw new Error('no active Shakers session —
|
|
97
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first.');
|
|
101
98
|
}
|
|
102
99
|
return session;
|
|
103
100
|
};
|
|
@@ -117,25 +114,6 @@ function makeOnboardingTools(deps = {}) {
|
|
|
117
114
|
return endpoint;
|
|
118
115
|
};
|
|
119
116
|
|
|
120
|
-
// Every method runs through the device flow (start/poll tools); a blocking form
|
|
121
|
-
// can't surface the code, and email now reuses the web session there instead of
|
|
122
|
-
// prompting for a password. The start/poll pair persists the session.
|
|
123
|
-
const socialSigninPointer = (mode, provider = 'email') => ({
|
|
124
|
-
ok: false,
|
|
125
|
-
reason: 'use-device-flow',
|
|
126
|
-
next: 'social_signin_start',
|
|
127
|
-
mode,
|
|
128
|
-
provider,
|
|
129
|
-
message: `For ${provider}, call social_signin_start (provider "${provider}", mode "${mode}") to get a URL and code to show the talent, then social_signin_poll.`,
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// Email, Google and LinkedIn all hand off to social_signin_start/poll. Email
|
|
133
|
-
// defaults when no method is given; the /cli-login page still offers the others.
|
|
134
|
-
async function login(args = {}) {
|
|
135
|
-
const provider = args.method === 'google' || args.method === 'linkedin' ? args.method : 'email';
|
|
136
|
-
return socialSigninPointer('login', provider);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
117
|
async function logout() {
|
|
140
118
|
const wasLoggedIn = loadAuthSession() !== null;
|
|
141
119
|
try { clearAuthSession(); } catch { /* idempotent — nothing to clear */ }
|
|
@@ -299,10 +277,11 @@ function makeOnboardingTools(deps = {}) {
|
|
|
299
277
|
return { ...base, portfolios, related, relateFailures };
|
|
300
278
|
}
|
|
301
279
|
|
|
302
|
-
|
|
280
|
+
// Sends the project only after the talent says yes to exactly what goes: name, web address, dates, client.
|
|
281
|
+
async function addProject(args = {}, ctx = {}) {
|
|
303
282
|
const session = requireSession();
|
|
304
283
|
const endpoint = requireEndpoint(getPortfoliosDeclareEndpoint, 'portfolios-declare');
|
|
305
|
-
const root = typeof args.root === 'string' && args.root ? args.root : process.cwd();
|
|
284
|
+
const root = path.resolve(typeof args.root === 'string' && args.root ? args.root : process.cwd());
|
|
306
285
|
const name = typeof args.name === 'string' && args.name ? args.name : path.basename(root);
|
|
307
286
|
const type = args.type === 'EXPERIENCE' ? 'EXPERIENCE' : 'PORTFOLIO';
|
|
308
287
|
let url = typeof args.url === 'string' && args.url ? args.url : null;
|
|
@@ -312,14 +291,29 @@ function makeOnboardingTools(deps = {}) {
|
|
|
312
291
|
let startDate;
|
|
313
292
|
let endDate;
|
|
314
293
|
try { ({ startDate, endDate } = getCommitDateRange(root)); } catch { startDate = null; endDate = null; }
|
|
294
|
+
const clientName = typeof args.clientName === 'string' && args.clientName ? args.clientName : null;
|
|
295
|
+
const clientDomain = normalizeDomain(args.clientDomain) || null;
|
|
296
|
+
const description = typeof args.description === 'string' && args.description ? args.description : null;
|
|
297
|
+
const sent = { name, type, url, startDate, endDate, clientName, clientDomain, description };
|
|
298
|
+
const c = consentCopy();
|
|
299
|
+
const asked = await askConsent(ctx, {
|
|
300
|
+
tool: 'add_project',
|
|
301
|
+
key: `add_project:${root}:${JSON.stringify(sent)}`,
|
|
302
|
+
question: c.project({ folder: tildify(root), name, url, startDate, endDate, client: [clientName, clientDomain].filter(Boolean).join(', '), description }),
|
|
303
|
+
yes: c.yes,
|
|
304
|
+
no: c.no,
|
|
305
|
+
answer: args.answer,
|
|
306
|
+
});
|
|
307
|
+
if (asked.reply) return asked.reply;
|
|
308
|
+
if (!asked.granted) return { ok: false, reason: 'consent-declined', message: 'The talent said no: nothing was sent. Do not add the project.' };
|
|
315
309
|
const result = await requestDeclarePortfolio(
|
|
316
310
|
{
|
|
317
311
|
name,
|
|
318
312
|
type,
|
|
319
|
-
description:
|
|
313
|
+
description: description || undefined,
|
|
320
314
|
url: url || undefined,
|
|
321
|
-
clientName:
|
|
322
|
-
clientDomain:
|
|
315
|
+
clientName: clientName || undefined,
|
|
316
|
+
clientDomain: clientDomain || undefined,
|
|
323
317
|
startDate: startDate || undefined,
|
|
324
318
|
endDate: endDate || undefined,
|
|
325
319
|
skillIds: Array.isArray(args.skillIds) ? args.skillIds : undefined,
|
|
@@ -332,39 +326,43 @@ function makeOnboardingTools(deps = {}) {
|
|
|
332
326
|
}
|
|
333
327
|
|
|
334
328
|
return [
|
|
335
|
-
{
|
|
336
|
-
name: 'login',
|
|
337
|
-
description: "Start signing the talent in to Shakers so the add_* and certify tools can run. This tool does not sign in itself: it points you to social_signin_start + social_signin_poll (the device flow), which open the Shakers web page and persist the session. Email (the default) reuses the talent's open web session or signs them in on the page — never a password in the chat; 'google'/'linkedin' open that social sign-in. Returns a { ok: false, reason: 'use-device-flow', next, provider } pointer; call social_signin_start next.",
|
|
338
|
-
inputSchema: LOGIN_SCHEMA,
|
|
339
|
-
handler: login,
|
|
340
|
-
},
|
|
341
329
|
{
|
|
342
330
|
name: 'logout',
|
|
343
|
-
|
|
331
|
+
title: 'Sign out of Shakers',
|
|
332
|
+
description: 'Signs you out by deleting the Shakers sign-in saved on this computer.',
|
|
333
|
+
guidance: "Sign the talent out of Shakers by clearing the persisted session. Idempotent — returns { ok: true, wasLoggedIn } whether or not a session existed.",
|
|
344
334
|
inputSchema: LOGOUT_SCHEMA,
|
|
345
335
|
handler: logout,
|
|
346
336
|
},
|
|
347
337
|
{
|
|
348
338
|
name: 'list_addable',
|
|
349
|
-
|
|
339
|
+
title: 'Skills and agents you can add',
|
|
340
|
+
description: 'Shows the skills and AI agents found in the AI analysis you sent to Shakers, so you can pick which ones go on your profile.',
|
|
341
|
+
guidance: "List the skills and AI agents that Shakers discovered from the talent's submitted ai_usage report and that can be added to their Shakers profile. Use it to offer choices before add_skill / add_agent. Present skills and agents to the talent by NAME only; skillId, agent code and catalogId are internal handles for the add_* tools — never show raw IDs or codes. Requires an active session.",
|
|
350
342
|
inputSchema: LIST_ADDABLE_SCHEMA,
|
|
351
343
|
handler: listAddable,
|
|
352
344
|
},
|
|
353
345
|
{
|
|
354
346
|
name: 'add_skill',
|
|
355
|
-
|
|
347
|
+
title: 'Add a skill to your profile',
|
|
348
|
+
description: 'Adds the skill you pick to your Shakers profile.',
|
|
349
|
+
guidance: "Add a discovered skill to the talent's Shakers profile by skillId (taken from list_addable). skillId is an internal handle — refer to the skill by its name when talking to the talent, never show the id. Optionally pass relatedPortfolioIds to relate it to one or more of the talent's portfolio experiences (matches the web's 'Relate to an experience' step) — the response always includes `portfolios` (id/name/type) so you can ask the talent which one(s) on a later call. If the talent has no portfolios yet, the skill is still added, unrelated. Requires an active session.",
|
|
356
350
|
inputSchema: ADD_SKILL_SCHEMA,
|
|
357
351
|
handler: addSkill,
|
|
358
352
|
},
|
|
359
353
|
{
|
|
360
354
|
name: 'add_agent',
|
|
361
|
-
|
|
355
|
+
title: 'Add an AI agent to your profile',
|
|
356
|
+
description: 'Adds the AI agent you pick, with the description you agree on, to your Shakers profile.',
|
|
357
|
+
guidance: "Add a discovered AI agent to the talent's Shakers profile by name (from list_addable). Optionally pass whatItDoes and humanDecides (what the talent does around the agent — supervision/validation). Optionally pass relatedPortfolios ({ portfolioId }) to relate it to the talent's experiences (matches the web's 'Relate to an experience' step) — the response always includes `portfolios` (id/name/type) so you can ask which one(s) on a later call. If the talent has no experiences yet, the agent is still added, unrelated. Requires an active session.",
|
|
362
358
|
inputSchema: ADD_AGENT_SCHEMA,
|
|
363
359
|
handler: addAgent,
|
|
364
360
|
},
|
|
365
361
|
{
|
|
366
362
|
name: 'add_project',
|
|
367
|
-
|
|
363
|
+
title: 'Add this project to your portfolio',
|
|
364
|
+
description: 'Reads the name of the project folder open here, the web address of its code and the dates of its first and last change. It asks you first, naming all of them, and only then sends them to Shakers as a new portfolio project.',
|
|
365
|
+
guidance: "Add the current repository to the talent's Shakers portfolio as a project. Name and URL default to the repo directory and its git remote, and the start/end dates are derived from the repo's git history. Optionally pass clientName and clientDomain (the client's website/domain, e.g. 'acme.com' — Shakers fetches the client logo from it). The tool asks the talent first, naming the folder, web address, dates and client it will send (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. Requires an active session. Any ids/codes in the result are internal handles for tools only — never show or mention them to the talent.",
|
|
368
366
|
inputSchema: ADD_PROJECT_SCHEMA,
|
|
369
367
|
handler: addProject,
|
|
370
368
|
},
|
|
@@ -373,7 +371,6 @@ function makeOnboardingTools(deps = {}) {
|
|
|
373
371
|
|
|
374
372
|
module.exports = {
|
|
375
373
|
makeOnboardingTools,
|
|
376
|
-
LOGIN_SCHEMA,
|
|
377
374
|
LOGOUT_SCHEMA,
|
|
378
375
|
LIST_ADDABLE_SCHEMA,
|
|
379
376
|
ADD_SKILL_SCHEMA,
|
|
@@ -15,7 +15,7 @@ function makePortfolioTools(deps = {}) {
|
|
|
15
15
|
const requireLoginSession = (tool) => {
|
|
16
16
|
const session = loadAuthSession();
|
|
17
17
|
if (sessionStatus(session) !== 'active') {
|
|
18
|
-
throw new Error(`no active Shakers session —
|
|
18
|
+
throw new Error(`no active Shakers session — sign in with social_signin_start (or run \`shakers login\`) first (${tool} is session-gated).`);
|
|
19
19
|
}
|
|
20
20
|
return session;
|
|
21
21
|
};
|
|
@@ -30,13 +30,17 @@ function makePortfolioTools(deps = {}) {
|
|
|
30
30
|
return [
|
|
31
31
|
{
|
|
32
32
|
name: 'list_experiences',
|
|
33
|
-
|
|
33
|
+
title: 'Your work experience',
|
|
34
|
+
description: 'Shows the work experience on your Shakers profile.',
|
|
35
|
+
guidance: "List the talent's work experiences (company, role/title, dates, location, skills). Read-only. Requires an active session. Any ids in the result are internal handles for tools only — never show or mention them to the talent.",
|
|
34
36
|
inputSchema: SCHEMA,
|
|
35
37
|
handler: lister('experiences', 'list_experiences'),
|
|
36
38
|
},
|
|
37
39
|
{
|
|
38
40
|
name: 'list_portfolios',
|
|
39
|
-
|
|
41
|
+
title: 'Your portfolio',
|
|
42
|
+
description: 'Shows the portfolio projects on your Shakers profile.',
|
|
43
|
+
guidance: "List the talent's portfolio showcase pieces (name, dates, location, skills, link). Read-only. Requires an active session. Any ids in the result are internal handles for tools only — never show or mention them to the talent.",
|
|
40
44
|
inputSchema: SCHEMA,
|
|
41
45
|
handler: lister('portfolios', 'list_portfolios'),
|
|
42
46
|
},
|
package/src/mcp-profile-tools.js
CHANGED
|
@@ -17,7 +17,7 @@ function makeProfileTools(deps = {}) {
|
|
|
17
17
|
const requireLoginSession = () => {
|
|
18
18
|
const session = loadAuthSession();
|
|
19
19
|
if (sessionStatus(session) !== 'active') {
|
|
20
|
-
throw new Error('no active Shakers session —
|
|
20
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (get_profile is session-gated).');
|
|
21
21
|
}
|
|
22
22
|
return session;
|
|
23
23
|
};
|
|
@@ -33,7 +33,9 @@ function makeProfileTools(deps = {}) {
|
|
|
33
33
|
return [
|
|
34
34
|
{
|
|
35
35
|
name: 'get_profile',
|
|
36
|
-
|
|
36
|
+
title: 'Your Shakers profile',
|
|
37
|
+
description: 'Shows your main role, your rate and your "My work with AI" summary on Shakers.',
|
|
38
|
+
guidance: "Get the talent's summary: main role, per-project rate, and the 'My work with AI' read model (setup/usage tier+level, 3x3 cell, vision, how-I-work). Requires an active session. Present it in human terms; any internal ids/codes in the result are handles for tools only — never show or mention them to the talent.",
|
|
37
39
|
inputSchema: GET_PROFILE_SCHEMA,
|
|
38
40
|
handler: getProfile,
|
|
39
41
|
},
|
package/src/mcp-prompts.js
CHANGED
|
@@ -46,10 +46,10 @@ const PROMPTS = [
|
|
|
46
46
|
{
|
|
47
47
|
name: 'ask',
|
|
48
48
|
description: 'Ask Alma, the Shakers AI assistant, a question.',
|
|
49
|
-
tools: ['
|
|
49
|
+
tools: ['alma_chat'],
|
|
50
50
|
arguments: [{ name: 'message', description: 'What to ask Alma.', required: false }],
|
|
51
51
|
buildMessages: (args = {}) => userText(
|
|
52
|
-
`Ask Alma (the Shakers assistant) and relay her answer as-is. My question: ${typeof args.message === 'string' && args.message ? args.message : '(ask me what I want to know first)'}`,
|
|
52
|
+
`Ask Alma (the Shakers assistant) with \`alma_chat\` and relay her answer as-is. My question: ${typeof args.message === 'string' && args.message ? args.message : '(ask me what I want to know first)'}`,
|
|
53
53
|
),
|
|
54
54
|
},
|
|
55
55
|
{
|
package/src/mcp-rate-tools.js
CHANGED
|
@@ -14,7 +14,7 @@ function makeRateTools(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 (get_rate is session-gated).');
|
|
18
18
|
}
|
|
19
19
|
return session;
|
|
20
20
|
};
|
|
@@ -29,7 +29,9 @@ function makeRateTools(deps = {}) {
|
|
|
29
29
|
return [
|
|
30
30
|
{
|
|
31
31
|
name: 'get_rate',
|
|
32
|
-
|
|
32
|
+
title: 'Your rates',
|
|
33
|
+
description: 'Shows your hourly rate and your yearly target on Shakers.',
|
|
34
|
+
guidance: "View the talent's rates as the web shows them: partTimePrice is the hourly rate (per hour) and fullTimePrice the annual target (per year); a variant not selected is not offered. Read-only. Requires an active session. Show only the human-facing amounts/currency; any internal ids in the result are handles for tools only — never show or mention them to the talent.",
|
|
33
35
|
inputSchema: GET_RATE_SCHEMA,
|
|
34
36
|
handler: getRate,
|
|
35
37
|
},
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { TranscriptBuffer } = require('./interview-transcript');
|
|
4
|
-
const
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { askChoice, askConsent } = require('./mcp-choice');
|
|
6
|
+
const { expandHome, tildify } = require('./env-paths');
|
|
5
7
|
|
|
6
8
|
const ANSWER_MODES = ['drafted', 'own', 'full'];
|
|
7
9
|
|
|
@@ -62,7 +64,7 @@ function withTimeout(promise, ms) {
|
|
|
62
64
|
return Promise.race([promise, expiry]).finally(() => clearTimeout(timer));
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
//
|
|
67
|
+
// Stays local (stderr, the MCP client's log); only the sign-up funnel is sent.
|
|
66
68
|
function defaultReportLivekitFailure(kind, error) {
|
|
67
69
|
const cause = error && error.cause && error.cause.message ? error.cause.message : (error && error.message) || '';
|
|
68
70
|
process.stderr.write(` [mcp] livekit-unavailable kind=${kind} os=${process.platform} arch=${process.arch} node=${process.version} cause=${JSON.stringify(cause.slice(0, 300))}\n`);
|
|
@@ -85,6 +87,8 @@ function makeRegisterTools(deps = {}) {
|
|
|
85
87
|
const textLang = () => require('./signup-language').signupLanguage(lang);
|
|
86
88
|
const copy = () => require('./signup-copy').signupCopy(textLang());
|
|
87
89
|
const checkCompleted = deps.checkOnboardingCompleted || require('./onboarding-availability').checkOnboardingCompleted;
|
|
90
|
+
const funnel = deps.funnel || require('./onboarding-funnel').NOOP_FUNNEL;
|
|
91
|
+
const track = (record) => { record(); funnel.flush(); };
|
|
88
92
|
|
|
89
93
|
// The LiveKit room is a live WebRTC connection that must survive between the
|
|
90
94
|
// separate start/turn/complete JSON-RPC calls of this long-lived stdio server.
|
|
@@ -115,6 +119,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
function interviewLater() {
|
|
122
|
+
track(() => funnel.completed('pre_interview', { interview_choice: 'skip' }));
|
|
118
123
|
return { ok: false, reason: 'interview-later', message: 'The talent does the interview later on the web: skip it here and go on with the main role, then open_web.' };
|
|
119
124
|
}
|
|
120
125
|
|
|
@@ -157,6 +162,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
157
162
|
}
|
|
158
163
|
const acknowledged = args.disclaimerAcknowledged === true;
|
|
159
164
|
if (!acknowledged) {
|
|
165
|
+
track(() => funnel.viewed('pre_interview'));
|
|
160
166
|
const where = await askWhere(args, ctx);
|
|
161
167
|
if (where.here === false) return interviewLater();
|
|
162
168
|
if (where.chat) {
|
|
@@ -181,8 +187,10 @@ function makeRegisterTools(deps = {}) {
|
|
|
181
187
|
message: `${answer.chat.choiceMessage} Then call onboarding_interview_start again with where:"here", disclaimerAcknowledged:true and answerMode: drafted for the first option, own for the second, full for the third.`,
|
|
182
188
|
};
|
|
183
189
|
}
|
|
190
|
+
track(() => funnel.completed('pre_interview', { interview_choice: 'start_now' }));
|
|
184
191
|
const room = {};
|
|
185
192
|
const opened = await withTimeout(openRoom(args, room), interviewTimeoutMs);
|
|
193
|
+
if (opened === TIMED_OUT || !opened.ok) track(() => funnel.errored('interview', opened === TIMED_OUT ? 'timeout' : opened.reason));
|
|
186
194
|
if (opened === TIMED_OUT) {
|
|
187
195
|
room.abandoned = true;
|
|
188
196
|
if (room.client) room.client.disconnect().catch(() => {});
|
|
@@ -190,6 +198,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
190
198
|
}
|
|
191
199
|
if (!opened.ok) return opened;
|
|
192
200
|
interviewSessions.set(opened.interviewId, { client: opened.client, buffer: opened.buffer, connectAt: Date.now(), ended: opened.opening.ended === true });
|
|
201
|
+
track(() => funnel.viewed('interview', { interview_mode: 'text' }));
|
|
193
202
|
return { ok: true, interviewId: opened.interviewId, answerMode: answer.mode, greeting: opened.opening.text, ended: opened.opening.ended === true };
|
|
194
203
|
}
|
|
195
204
|
|
|
@@ -275,6 +284,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
275
284
|
const saved = await teardownInterview(args.interviewId, { persist: true });
|
|
276
285
|
if (!saved.ok) return { ok: false, reason: saved.reason || 'complete-failed' };
|
|
277
286
|
lastCompletedInterview = { interviewId: args.interviewId, completedAt: Date.now() };
|
|
287
|
+
track(() => funnel.completed('interview', { interview_mode: 'text' }));
|
|
278
288
|
return { ok: true, state: 'taken', message: catalog.interviewComplete };
|
|
279
289
|
}
|
|
280
290
|
|
|
@@ -311,8 +321,12 @@ function makeRegisterTools(deps = {}) {
|
|
|
311
321
|
};
|
|
312
322
|
}
|
|
313
323
|
|
|
314
|
-
// Content-free context the model may PROPOSE to the talent (never auto-write).
|
|
315
|
-
async function suggestContext(args = {}) {
|
|
324
|
+
// Content-free context the model may PROPOSE to the talent (never auto-write), searched only after the talent says yes.
|
|
325
|
+
async function suggestContext(args = {}, ctx = {}) {
|
|
326
|
+
const c = copy().consent;
|
|
327
|
+
const asked = await askConsent(ctx, { tool: 'suggest_register_context', key: 'suggest_register_context', question: c.cvSearch, yes: c.yes, no: c.no, answer: args.answer });
|
|
328
|
+
if (asked.reply) return asked.reply;
|
|
329
|
+
if (!asked.granted) return { ok: false, reason: 'consent-declined', message: 'The talent said no: nothing was searched. Do not look for the CV on this computer; ask them to attach it in the chat.' };
|
|
316
330
|
const read = deps.readRegisterContextSuggestions || require('./register-context').readRegisterContextSuggestions;
|
|
317
331
|
const suggestions = read({ talentName: args.talentName });
|
|
318
332
|
const found = suggestions.cvCandidates && suggestions.cvCandidates.length > 0;
|
|
@@ -321,16 +335,21 @@ function makeRegisterTools(deps = {}) {
|
|
|
321
335
|
suggestions,
|
|
322
336
|
note: 'Propose these to the talent to confirm; never write them without confirmation.',
|
|
323
337
|
message: found
|
|
324
|
-
? '
|
|
338
|
+
? 'Now call read_cv on the first candidate (the most likely), with talentName if you know it: read_cv asks the talent itself before reading it, so ask nothing in your own words. nameMatch:null only means their name is unknown, never that the CV is not theirs. Move to the next candidate only if read_cv says mentionsTalent:false; say you did not find it only when none is left.'
|
|
325
339
|
: 'No CV was found on this machine: ask the talent to attach it.',
|
|
326
340
|
};
|
|
327
341
|
}
|
|
328
342
|
|
|
329
|
-
// Text of the CV the talent
|
|
330
|
-
async function readCv(args = {}) {
|
|
331
|
-
if (args.
|
|
343
|
+
// Text of the CV file the talent allowed this tool to read, in its own question.
|
|
344
|
+
async function readCv(args = {}, ctx = {}) {
|
|
345
|
+
if (typeof args.path !== 'string' || !args.path.trim()) return { ok: false, reason: 'cv-not-found', message: 'Pass the path of the CV file.' };
|
|
346
|
+
const file = path.resolve(expandHome(args.path.trim()));
|
|
347
|
+
const c = copy().consent;
|
|
348
|
+
const asked = await askConsent(ctx, { tool: 'read_cv', key: `read_cv:${file}`, question: c.cvRead(tildify(file)), yes: c.yes, no: c.no, answer: args.answer });
|
|
349
|
+
if (asked.reply) return asked.reply;
|
|
350
|
+
if (!asked.granted) return { ok: false, reason: 'consent-declined', message: 'The talent said no: the file was not read. Do not read it; ask them to attach their CV in the chat or tell you what it says.' };
|
|
332
351
|
const read = deps.readCvText || require('./cv-text').readCvText;
|
|
333
|
-
const result = read(
|
|
352
|
+
const result = read(file, { talentName: args.talentName });
|
|
334
353
|
if (!result.ok) return { ...result, message: 'Could not read the CV text here. Ask the talent to attach the CV to the chat, or to tell you what it says.' };
|
|
335
354
|
// Someone else's CV (a candidate they downloaded): never hand its contents over.
|
|
336
355
|
if (result.mentionsTalent === false) return { ok: true, mentionsTalent: false, note: "This CV does not name the talent: it belongs to someone else. Do not use, quote or describe it; try the next candidate." };
|
|
@@ -340,27 +359,33 @@ function makeRegisterTools(deps = {}) {
|
|
|
340
359
|
return [
|
|
341
360
|
{
|
|
342
361
|
name: 'suggest_register_context',
|
|
343
|
-
|
|
344
|
-
|
|
362
|
+
title: 'Look for your CV on this computer',
|
|
363
|
+
description: 'Asks you first, then lists the names of PDF and Word files in your Desktop, Documents, Downloads and iCloud Drive folders that look like a CV and reads your time zone and the name, email and GitHub user saved by your coding tools. It does not open those files or send anything.',
|
|
364
|
+
guidance: "SUGGESTIONS for the sign-up read on this machine (no network, no file contents): timezone, git name/email, the gh CLI GitHub login, and cvCandidates: PDF/DOC/DOCX files in Desktop, Documents, Downloads and iCloud Drive whose NAME looks like a CV or carries the talent's name, each with nameMatch (null when their name is unknown) and generic flags. A candidate is only a lead (people who hire keep other people's CVs): confirm it with read_cv. Pass talentName to sharpen the match. The tool asks the talent first (a dialog when the client has one, otherwise it returns `say` to print word for word as your whole reply); nothing is searched until they answer yes. After a `say`, call it again with answer set to the option they pick. Never answer for them.",
|
|
365
|
+
inputSchema: { type: 'object', properties: { talentName: { type: 'string', description: "The talent's full name as you know it (memory, chat); improves CV matching." }, answer: { type: 'string', description: 'The option the talent picked when you printed the question this tool returned. Never set it yourself.' } } },
|
|
345
366
|
handler: suggestContext,
|
|
346
367
|
},
|
|
347
368
|
{
|
|
348
369
|
name: 'read_cv',
|
|
349
|
-
|
|
370
|
+
title: 'Read your CV',
|
|
371
|
+
description: 'Asks you first, then reads the text of the CV file you allow to prepare your profile draft. This step sends nothing to Shakers.',
|
|
372
|
+
guidance: "Read the TEXT of a CV file (PDF, DOC, DOCX) on this machine, with talentName set. The tool asks the talent first, 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 read until they answer yes. After a `say`, call it again with the same path and answer set to the option they pick. Never answer for them. Returns { mentionsTalent, links: { linkedin, github, emails, phones }, text } for the talent's own CV; a CV that does not name them returns only mentionsTalent:false: it is someone else's, drop it, never describe it, try the next candidate. Nothing is uploaded by this tool: pass the confirmed path to signup_start (or import_profile) as cvPath, use the text for userQuery and the email, and links.linkedin as their LinkedIn URL.",
|
|
350
373
|
inputSchema: {
|
|
351
374
|
type: 'object',
|
|
352
375
|
properties: {
|
|
353
376
|
path: { type: 'string', description: 'Path to the CV file, exactly as suggest_register_context gave it (the short ~/ form is fine) or absolute.' },
|
|
354
|
-
|
|
377
|
+
answer: { type: 'string', description: 'The option the talent picked when you printed the question this tool returned. Never set it yourself.' },
|
|
355
378
|
talentName: { type: 'string', description: "The talent's full name as you know it; used to tell their CV from other people's." },
|
|
356
379
|
},
|
|
357
|
-
required: ['path'
|
|
380
|
+
required: ['path'],
|
|
358
381
|
},
|
|
359
382
|
handler: readCv,
|
|
360
383
|
},
|
|
361
384
|
{
|
|
362
385
|
name: 'onboarding_interview_start',
|
|
363
|
-
|
|
386
|
+
title: 'Start your onboarding interview',
|
|
387
|
+
description: 'Starts the short Shakers interview here in the chat, after asking you where and how you want to answer it. Your answers go to Shakers to get to know your profile.',
|
|
388
|
+
guidance: "Start the onboarding interview, here in the chat. Offer it only when signup_status says onboardingInterview.completed is not true. Call it first without disclaimerAcknowledged: it asks 'here now or later on the web?' (in a dialog when the client has one, otherwise it returns `say` to print word for word; default here), then how they want to answer, with the two interview disclaimers in that same question. reason 'interview-later' means they chose the web: skip the interview. reason 'interview-on-web' (a sign-up language the chat interview does not speak) is the same: print relayVerbatim and skip it. If it returns reason 'interview-already-started' (begun elsewhere and not finished), call open_web so they continue on the web. It runs over LiveKit and the server agent conducts it; the client does not decide its branch. When it returns `say`, print it word for word as your whole reply (it holds the disclaimers and the question) and, once they answer, call with where:'here', disclaimerAcknowledged:true and answerMode. Returns { interviewId, answerMode, greeting, ended }: relay the greeting VERBATIM, then drive it with onboarding_interview_turn. ANSWER MODES. For drafted and full, first gather everything you know (memory, a search of past conversations if you have that tool, their CV, the ai_usage report, the sign-up data) and show ONE short sheet 'esto es lo que se de ti' (roles and projects with concrete facts, stack, languages, what they want next); let them correct it once; those corrections override your memory. Your answers are first person, one or two short sentences in their plain voice, built only from facts on the confirmed sheet: no embellishment, no adjectives, metrics or claims they did not give, no repeating a fact already said unless asked; for wishes, preferences or opinions give your best guess and say it is a guess. (1) drafted: show each question VERBATIM with your proposed answer and send it only after the talent approves or edits it; if the sheet does not cover a question, ask them briefly instead of guessing. Only if the talent explicitly asks you to answer everything yourself (for example 'hazla entera' or 'contéstalas tú todas'), answer the remaining questions without asking for each approval, as in full. (2) full (they chose it, or asked for it as above): answer every question yourself without per-answer approval, still showing each question and your answer, and when the interview ends tell them in one line that you answered those questions yourself. (3) own: you are a pure CONDUIT: show every question VERBATIM and pass back only their REAL, VERBATIM words. In every mode never reword, summarize or translate the questions. `interviewId` is internal: never show it. If it fails with next:'open_web', print relayVerbatim word for word and call open_web so they do the interview on the web.",
|
|
364
389
|
inputSchema: INTERVIEW_START_SCHEMA,
|
|
365
390
|
handler: interviewStart,
|
|
366
391
|
available: async () => {
|
|
@@ -373,25 +398,33 @@ function makeRegisterTools(deps = {}) {
|
|
|
373
398
|
},
|
|
374
399
|
{
|
|
375
400
|
name: 'onboarding_interview_turn',
|
|
376
|
-
|
|
401
|
+
title: 'Answer an interview question',
|
|
402
|
+
description: 'Sends the answer you gave or approved for the current interview question to Shakers and brings the next one.',
|
|
403
|
+
guidance: "Send the talent's answer to the current onboarding-interview question and get the next one. Follow the answerMode the talent chose at onboarding_interview_start. drafted: `message` is the answer the talent APPROVED or edited after seeing your proposal; never send one they have not confirmed, unless they explicitly asked you to answer the rest yourself ('hazla entera', 'contéstalas tú todas'): from then on, as in full, send your own answers without per-answer approval. full: send your own answer to each question, built only from what they confirmed, and when it ends tell them you answered those questions yourself. own: `message` is ONLY the talent's REAL, VERBATIM answer — never invent, infer or complete it. Outside full, if the talent has not answered or approved yet, do NOT call this, wait for them. Relay the returned `response` (the next question) to the talent VERBATIM — do not reword, summarize or translate it. Keep calling with their next `message` until `ended` is true, then call onboarding_interview_complete. Do NOT show or mention `interviewId` to the talent (internal handle). If it returns reason 'unknown-interview' (the server was restarted and the live room is gone), start again with onboarding_interview_start.",
|
|
377
404
|
inputSchema: INTERVIEW_TURN_SCHEMA,
|
|
378
405
|
handler: interviewTurn,
|
|
379
406
|
},
|
|
380
407
|
{
|
|
381
408
|
name: 'onboarding_interview_complete',
|
|
382
|
-
|
|
409
|
+
title: 'Finish the interview',
|
|
410
|
+
description: 'Closes the interview and saves it on Shakers.',
|
|
411
|
+
guidance: "Close the onboarding interview once the turns report ended:true: the buffered transcript is saved and the live room is disconnected. `interviewId` is internal: never show it. Returns { state:'taken' }. If it returns reason 'unknown-interview' the room was already gone (server restart): nothing to complete. In the sign-up flow, go on to the main role (list_my_roles with waitSeconds, then set_main_role) and finish with open_web; do NOT call onboarding_finish there.",
|
|
383
412
|
inputSchema: INTERVIEW_COMPLETE_SCHEMA,
|
|
384
413
|
handler: interviewComplete,
|
|
385
414
|
},
|
|
386
415
|
{
|
|
387
416
|
name: 'repeat_onboarding_interview',
|
|
388
|
-
|
|
417
|
+
title: 'Repeat your onboarding interview',
|
|
418
|
+
description: 'Deletes your previous onboarding interview on Shakers so you can take it again. This cannot be undone.',
|
|
419
|
+
guidance: "OVERWRITE-repeat the onboarding interview: resets the talent's completed onboarding interview IN PLACE; the previous evaluation is DISCARDED and CANNOT be recovered. Clearly warn the talent that this is irreversible and get their explicit go-ahead BEFORE calling this. On success run the interview again (onboarding_interview_start, onboarding_interview_turn until ended, onboarding_interview_complete), then onboarding_finish. Returns { interviewId, language } or reason 'onboarding-not-found' (no onboarding yet), 'http-401'/'http-403'.",
|
|
389
420
|
inputSchema: { type: 'object', properties: {} },
|
|
390
421
|
handler: repeatOnboarding,
|
|
391
422
|
},
|
|
392
423
|
{
|
|
393
424
|
name: 'onboarding_finish',
|
|
394
|
-
|
|
425
|
+
title: 'Complete your onboarding',
|
|
426
|
+
description: 'Marks your onboarding as completed on Shakers after you repeat the interview.',
|
|
427
|
+
guidance: "Mark the onboarding as COMPLETED on Shakers. NOT part of the sign-up flow (signup_start): there, never call it. Use it only to close a repeat_onboarding_interview run. Idempotent. Returns { finalized, onboardingStatus, registrationLevel, completedProfilePercentage, profileUrl }.",
|
|
395
428
|
inputSchema: FINISH_SCHEMA,
|
|
396
429
|
handler: onboardingFinish,
|
|
397
430
|
},
|
package/src/mcp-report-tools.js
CHANGED
|
@@ -33,7 +33,7 @@ function makeReportTools(deps = {}) {
|
|
|
33
33
|
const requireSession = () => {
|
|
34
34
|
const session = loadAuthSession();
|
|
35
35
|
if (sessionStatus(session) !== 'active') {
|
|
36
|
-
throw new Error('no active Shakers session —
|
|
36
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first.');
|
|
37
37
|
}
|
|
38
38
|
return session;
|
|
39
39
|
};
|
|
@@ -85,13 +85,17 @@ function makeReportTools(deps = {}) {
|
|
|
85
85
|
return [
|
|
86
86
|
{
|
|
87
87
|
name: 'report',
|
|
88
|
-
|
|
88
|
+
title: 'Show your AI report',
|
|
89
|
+
description: 'Shows your AI report, either the one saved on Shakers or one built from your last AI analysis on this computer, which it also saves there as a file.',
|
|
90
|
+
guidance: "Two modes. stored:true returns the talent's ENTIRE stored server report (inventory + activity + AI-fluency level + prompting/interaction level) as plain text, read from certs WITHOUT re-running any scan — says so cleanly when none is stored (this stored view does NOT include the Setup×Usage matrix or the vision/how-I-work narratives). Otherwise builds the local report for this repo from local state: it returns the full human-readable report in `text` (Markdown mirroring the CLI terminal — Setup level and rank rationale, tools, MCP servers, agents, activity, plus the Setup×Usage matrix and vision/how-I-work when available) AND the HTML file path (no browser). Render `text` verbatim to the talent; keep the fenced blocks fenced so the tables stay aligned. Requires an active session. Any internal ids/codes are handles for tools only — never show or mention them to the talent.",
|
|
89
91
|
inputSchema: REPORT_SCHEMA,
|
|
90
92
|
handler: report,
|
|
91
93
|
},
|
|
92
94
|
{
|
|
93
95
|
name: 'share',
|
|
94
|
-
|
|
96
|
+
title: 'Create your share card',
|
|
97
|
+
description: 'Creates on this computer a card with your AI report to share on LinkedIn. It sends nothing.',
|
|
98
|
+
guidance: "Generate the Shakers-branded LinkedIn share card for this repo and return its file path. Does not open a browser. Requires an active session.",
|
|
95
99
|
inputSchema: SHARE_SCHEMA,
|
|
96
100
|
handler: share,
|
|
97
101
|
},
|