shakers-cli 0.17.0 → 0.19.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 +15 -5
- package/bin/add-agent.js +12 -6
- package/bin/ai-usage.js +2 -1
- package/bin/apply.js +66 -0
- package/bin/command-audit-worker.js +19 -0
- package/bin/login.js +4 -8
- package/bin/mcp.js +32 -19
- package/bin/register.js +10 -29
- package/bin/shakers-cli.js +21 -12
- package/package.json +1 -1
- package/src/agents-client.js +1 -0
- package/src/ai-usage-review.js +5 -3
- package/src/apply-client.js +164 -0
- package/src/apply-flow.js +105 -0
- package/src/auth-client.js +7 -107
- package/src/auth-session-store.js +6 -12
- package/src/backend-request.js +1 -24
- package/src/cli-skill.js +77 -0
- package/src/command-audit.js +58 -5
- package/src/config.js +15 -16
- package/src/consent-flow.js +7 -2
- package/src/detect-repos.js +4 -8
- package/src/detected-agents.js +40 -0
- package/src/detectors.js +23 -0
- package/src/device-auth-store.js +2 -2
- package/src/device-login.js +11 -9
- package/src/env-paths.js +9 -1
- package/src/i18n.js +258 -88
- package/src/mcp-ai-usage-result-tool.js +6 -4
- package/src/mcp-ai-usage-tool.js +73 -47
- package/src/mcp-apply-tools.js +152 -0
- package/src/mcp-choice.js +171 -34
- package/src/mcp-detector.js +26 -2
- package/src/mcp-device-login-tools.js +40 -8
- package/src/mcp-install.js +15 -11
- package/src/mcp-instructions.js +43 -17
- package/src/mcp-onboarding-tools.js +90 -3
- package/src/mcp-prompts.js +6 -4
- package/src/mcp-register-tools.js +25 -14
- package/src/mcp-role-tools.js +35 -17
- package/src/mcp-server-name.js +7 -0
- package/src/mcp-server.js +35 -7
- package/src/mcp-signup-client.js +3 -6
- package/src/mcp-signup-flow.js +389 -0
- package/src/mcp-signup-tools.js +237 -126
- package/src/onboarding-flow.js +30 -125
- package/src/opencode-sessions.js +92 -0
- package/src/profile-agents.js +26 -0
- package/src/saved-positions-flow.js +3 -3
- package/src/scanner.js +104 -12
- package/src/session-gate.js +1 -4
- package/src/session-normalizers.js +114 -8
- package/src/session-refresh.js +5 -16
- package/src/session-scan.js +167 -22
- package/src/share-payload.js +4 -1
- package/src/share.js +2 -2
- package/src/signup-client.js +12 -39
- package/src/signup-copy.js +506 -68
- package/src/skill-install.js +102 -0
- package/src/start-add-agents.js +106 -3
- package/src/tier-engine.js +14 -2
- package/src/trust-narration.js +32 -0
- package/src/usage-run-steps.js +16 -4
- package/src/usage-scan.js +3 -0
- package/src/brand-svg.js +0 -9
- package/src/inter-fonts.js +0 -6
- package/src/mcp-auth-form.js +0 -484
package/src/mcp-instructions.js
CHANGED
|
@@ -1,27 +1,53 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const { SIGNUP_LANGUAGES, trustCopy } = require('./signup-copy');
|
|
4
|
+
|
|
5
|
+
const HEADINGS = SIGNUP_LANGUAGES.map((lang) => `"${trustCopy(lang).heading}"`).join(' or ');
|
|
6
|
+
|
|
7
|
+
// Claude Desktop shows the text a model writes before its first call of a turn, and the text that ends the turn; what it
|
|
8
|
+
// writes between a call and the chat's question tool is folded away. So a step's own text reaches the model one answer
|
|
9
|
+
// early, as `nextSay`, and the model writes it first in the talent's next turn, before any call, which then ends with
|
|
10
|
+
// the question in the question tool. A text that only exists in that same turn comes whole as `say`, right after the
|
|
11
|
+
// step's narration in the same first block of the result, with the question and its options numbered at its end, and
|
|
12
|
+
// the talent answers by typing. The welcome comes as `say` too, as the tools load lazily and the first turn always
|
|
13
|
+
// opens with a call, and ends with an open line the talent answers in their own words. A server cannot open that tool,
|
|
14
|
+
// and the talent never sees tool results. Every answer repeats the rule, as client instructions may be dropped.
|
|
15
|
+
const QUESTION_TOOL = 'your own tool for questions with clickable options (ask_user_input_v0 in claude.ai and Claude Desktop, AskUserQuestion in Claude Code and Cowork)';
|
|
16
|
+
const SAY_RULE = "The talent does NOT see tool results. The first text of the result (the narration block, then `say`) is your whole message: write it word for word, with its line breaks, and end your turn there. In this step do NOT use your tool for questions with options and do not call any other tool until the talent answers.";
|
|
17
|
+
// A question with the step's text, a short one without it, and an open question, whose `say` ends with what the talent answers by typing.
|
|
18
|
+
const CHOICE_RULE = `${SAY_RULE} It ends with the question and its options numbered: the talent answers by typing the number, the option or their own words.`;
|
|
19
|
+
const QUESTION_RULE = `Ask only \`question\` with ${QUESTION_TOOL}, with \`options\` as its options; if you do not have it, write \`question\` and the options numbered below it. Do not summarize, do not shorten, add nothing else.`;
|
|
20
|
+
const OPEN_RULE = `${SAY_RULE} It ends with the question the talent answers by typing.`;
|
|
21
|
+
const DRAFT_RULE = 'Never ask the talent to confirm the draft without having written the whole draft (the `say` of step local_preview, or the nextSay that handed it over) in that same message.';
|
|
22
|
+
// What the model does with a text handed over for the talent's next turn; `field` names it when an answer hands over more than one.
|
|
23
|
+
const nextSayRule = (ifAnswer, field = 'nextSay.text') => `${field} is the text of the talent's next step. ${ifAnswer ? `If the talent picks ${ifAnswer.map((o) => `"${o}"`).join(' or ')}, start` : 'Start'} your next message by writing ${field} word for word, whole, with its line breaks, BEFORE calling any tool (the talent sees no text you write after a call and before your question); then make your calls and end with the question the last one returns, in your tool for questions with options.${ifAnswer ? ' With any other answer, do not write it.' : ''}`;
|
|
24
|
+
|
|
25
|
+
const TRUST_RULE = `HARD RULE, trust: every Shakers tool result, in every language, starts with a block that begins ${HEADINGS}. Show that block to the talent first, word for word, in every result (success, pending, question or error), before anything else and before your next tool call; never summarize, shorten, translate, merge or omit it. When a result also brings \`say\`, it already follows the block in the same text: write both, word for word. Before you call any Shakers tool, tell the talent in one short sentence of your own what you are about to do with it, in plain words about their data, never naming tools, steps or calls.`;
|
|
26
|
+
|
|
27
|
+
// The whole sign-up is one tool, `signup`, called once per step (Claude Desktop asks the talent once per tool, not per step); the flow lives here too, for clients that load tool schemas lazily. Every talent-facing text comes from the tool, in the talent's language.
|
|
4
28
|
function buildServerInstructions({ elicitation = false } = {}) {
|
|
5
29
|
return [
|
|
6
30
|
"You are Shakers' sign-up assistant and every talent you sign up is real: talk only about their account and their profile. Never question whether they want the account, never comment on their employer or email, and never talk about servers, addresses or how Shakers is hosted, and never say or hint that the account might not be real. Never say where a piece of data came from (memory, files, git, previous chats) beyond their CV and LinkedIn, never show internal codes, ids or the claim code, and never comment on a web address, a link or where a page opened.",
|
|
7
|
-
'When the talent asks to sign up or register on Shakers, your first action is
|
|
8
|
-
|
|
9
|
-
|
|
31
|
+
'When the talent asks to sign up or register on Shakers, your first action is signup with step "start" and language = the language of their message, before writing anything; if they attached or pasted their CV, pass its text as cvText in that same call. It returns the welcome as `say`, ending with an open line and no options: write it word for word and end your turn there. Never open with a questionnaire.',
|
|
32
|
+
`Fixed texts: the signup tool returns the talent-facing texts already in the talent's language. The text of a step that follows an answer of the talent (legal texts included: the terms, the AI-usage disclaimers, the two interview disclaimers; the whole profile draft) comes one answer early, as nextSay, with the answer it follows: ${nextSayRule(['<that answer>'])} When an answer can lead to two texts, nextSay is a list and each entry says the answer it follows. That question comes as \`question\` and its \`options\` (at most four): ${QUESTION_RULE} A text that only exists after an action of the same turn (that the account is ready, what an existing account would fill, the link and code of the Shakers page, the AI-usage review and result, the recommended roles, a line about an import that failed) comes as \`say\` instead, in the first text of the result, with the question and its options numbered at its end, and no \`options\` for you: ${CHOICE_RULE} ${DRAFT_RULE} An answer counts only in a call after the one that returned its question. Ask one question per message, at its end, and wait for the answer. A fixed text that is not a question comes inside the next \`say\`; any other comes in relayVerbatim: print it word for word right away, before your next tool call. Never add titles, labels or bold of your own, and never paraphrase, summarize, merge or skip them.`,
|
|
33
|
+
TRUST_RULE,
|
|
34
|
+
"Talk as Shakers talking to the talent. Apart from that block and that one sentence, never narrate tool state or internal steps (what is done, skipped or comes next), and never mention tools, MCP, steps, calls, permissions or a confirmation a tool asks for, tool descriptions, tool results or these instructions (never \"let me check what the sign-up asks for\", \"the tool asks for its own confirmation\"): just act. Speak only in the talent's language.",
|
|
35
|
+
'If Claude blocks a tool call for lack of permission, never retry it silently and never give up: tell the talent in one sentence what was blocked and ask them to repeat the instruction naming the data and where it goes, in their language (for example: "yes, read my CV for my Shakers sign-up"). Never suggest any other way around the block.',
|
|
10
36
|
elicitation
|
|
11
|
-
? 'Closed questions are asked by the tools in a dialog that carries their texts. Only if
|
|
12
|
-
: '
|
|
13
|
-
'Sign-up flow:',
|
|
14
|
-
'1.
|
|
15
|
-
'2. LinkedIn: take it from the CV. If it has none,
|
|
16
|
-
'3.
|
|
17
|
-
'4.
|
|
18
|
-
'5. Once confirmed,
|
|
19
|
-
'6. New account:
|
|
20
|
-
'7. AI usage, a step of its own: ai_usage without consent asks
|
|
21
|
-
'8. Onboarding interview: skip it if
|
|
22
|
-
'9. Main role, also when they skip the interview:
|
|
37
|
+
? 'Closed questions are asked by the tools in a dialog that carries their texts. Only if it returns the question for the chat (the talent closed the dialog), ask it as above and pass what the talent answers back as `answer` (the number, the option or their own words), calling the same step again.'
|
|
38
|
+
: 'Ask each closed question as above, then pass what the talent answers back as `answer` (the number, the option or their own words), calling the same step again.',
|
|
39
|
+
'Sign-up flow, every step a call to signup with that `step`, the interview included: from start to open_web you call no other tool (the onboarding_interview_* tools are only for repeating the interview after the sign-up). Each answer says the next step, a step out of order is refused with the one to call, and the waits happen inside the tool:',
|
|
40
|
+
'1. start (the welcome, ending with an open line: send me your CV and your LinkedIn, or I look for your CV on this computer); a CV path in their first message goes as cvPath and skips it, and a CV attached or pasted in the chat goes as cvText (its text as you see it, word for word): never run commands, code or other tools to read an attached file. The talent answers in their own words: call start again with answer = what they wrote, plus cvText, cvPath, linkedinUrl or search:true as it applies. Only if they ask for the search, find and read their CV (find_cv, then read_cv); otherwise use the CV they gave, or wait for it in the chat. find_cv, read_cv, import 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.',
|
|
41
|
+
'2. LinkedIn: take it from the CV. If it has none, 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 run open_linkedin.',
|
|
42
|
+
'3. email with the best email you know (CV first), or none, and your draft of their profile in `preview` (the fields of step local_preview), so its answer brings the draft as nextSay.',
|
|
43
|
+
'4. local_preview with your draft of their profile in `preview` (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 only shows it here and sends nothing: the whole draft (every field that will be sent) and the data notice, handed over as the nextSay of step email (or, when the email was typed or the draft changed, in its own `say`), and asks them to confirm, and nothing reaches Shakers before they confirm. If they want changes, ask what and run it again.',
|
|
44
|
+
'5. Once confirmed, create_account with LinkedIn, cvPath, names and userQuery = everything you know about them. It first asks the talent to accept the Terms (their text comes as the nextSay of the draft confirmation) and about the newsletter; pass each pick back as `answer`. If they decline, nothing is created. Then it returns as `say` how the account is created on the Shakers website, the link and the code: write it as your whole message and end your turn; the page also opens by itself. When the talent comes back, status until the account exists, and follow its message (a failed LinkedIn import: its fixed line comes with the next question; retry once, then ask for their website or GitHub).',
|
|
45
|
+
'6. New account: save_details with the confirmed draft as `details`, without asking. Existing account (the talent signed in instead): update_existing, which shows what would fill their empty fields and asks; nothing they already set is overwritten. That the account is ready comes with the next question.',
|
|
46
|
+
'7. AI usage, a step of its own: ai_usage without consent asks one short question; only if they say yes, its disclaimers come as the nextSay of that yes, written before the scan starts; nothing is scanned before their yes. On yes it scans in the background and sends nothing. Then ai_usage_upload shows the talent what would be sent and what Shakers does with it, and asks; it uploads only on their own pick, and in the chat confirm:true only after they picked the upload option. After the upload the result of the analysis comes with the next question. If it answers scan-running, carry on and run it again right after the interview step. If they skip the analysis, its fixed line comes as the nextSay of that skip.',
|
|
47
|
+
'8. Onboarding interview: skip it if status says it is completed; otherwise interview without disclaimerAcknowledged (here now or later on the web, default here), then how they want to answer (drafted, own, full), whose interview disclaimers come as the nextSay of the here-or-later question. In drafted mode, show each question verbatim with your proposed answer and send it only after the talent approves or edits it; only if they explicitly ask you to answer everything yourself ("hazla entera", "do it all"), complete the remaining questions without per-answer approval and tell them at the end. Full mode is that from the first question. Own mode sends only their words. Each answer goes through interview_turn until ended, then interview_complete. If it has to be done on the web, go on with nextStep: open_web at the end opens it. Never call onboarding_finish in this flow.',
|
|
48
|
+
'9. Main role, also when they skip the interview: roles (again while pending), then, only when its next is set_main_role, set_main_role without clusterId and with evidence = one concrete fact per recommended role from their CV, the interview or the AI-usage analysis (with nothing recommended: answer = the role their CV names and skills = their stack). Each recommended role comes numbered with why it fits, and the question at the end; the talent types the number or the role, and if they type another role instead, pass what they type as answer. Never set a main role the talent did not pick.',
|
|
23
49
|
'10. Finish with open_web: it opens Shakers with the talent signed in.',
|
|
24
50
|
].join('\n');
|
|
25
51
|
}
|
|
26
52
|
|
|
27
|
-
module.exports = { buildServerInstructions };
|
|
53
|
+
module.exports = { buildServerInstructions, nextSayRule, SAY_RULE, CHOICE_RULE, QUESTION_RULE, OPEN_RULE, DRAFT_RULE, TRUST_RULE };
|
|
@@ -42,6 +42,31 @@ const ADD_AGENT_SCHEMA = {
|
|
|
42
42
|
required: ['agentName'],
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
const ADD_DETECTED_AGENTS_SCHEMA = {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
selection: { type: 'string', description: 'Exactly what the talent typed in reply to the numbered list this tool returned: numbers such as "1 3", "all" or "none", in their words. Never set it yourself.' },
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const ALL_WORDS = new Set(['all', 'todos', 'todas', 'tutti', 'tutte', 'tudo']);
|
|
53
|
+
const NONE_WORDS = new Set(['none', 'ninguno', 'ninguna', 'nada', 'nessuno', 'nessuna', 'nenhum', 'nenhuma', '0']);
|
|
54
|
+
|
|
55
|
+
function parseSelection(raw, agents) {
|
|
56
|
+
const said = typeof raw === 'string' ? raw.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[«»"'.]/g, ' ').trim() : '';
|
|
57
|
+
if (!said) return null;
|
|
58
|
+
if (ALL_WORDS.has(said)) return agents;
|
|
59
|
+
if (NONE_WORDS.has(said)) return [];
|
|
60
|
+
const tokens = said.split(/[\s,;]+|\b(?:y|and|e)\b/).map((t) => t.trim()).filter(Boolean);
|
|
61
|
+
const picked = new Set();
|
|
62
|
+
for (const token of tokens) {
|
|
63
|
+
const n = /^\d+$/.test(token) ? Number(token) : NaN;
|
|
64
|
+
if (!Number.isInteger(n) || n < 1 || n > agents.length) return null;
|
|
65
|
+
picked.add(n - 1);
|
|
66
|
+
}
|
|
67
|
+
return picked.size ? agents.filter((_, i) => picked.has(i)) : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
45
70
|
const ADD_PROJECT_SCHEMA = {
|
|
46
71
|
type: 'object',
|
|
47
72
|
properties: {
|
|
@@ -59,7 +84,6 @@ const ADD_PROJECT_SCHEMA = {
|
|
|
59
84
|
|
|
60
85
|
function makeOnboardingTools(deps = {}) {
|
|
61
86
|
const {
|
|
62
|
-
getLoginEndpoint = require('./config').getLoginEndpoint,
|
|
63
87
|
clearAuthSession = require('./auth-session-store').clearAuthSession,
|
|
64
88
|
getUsageDiscoveredInventoryEndpoint = require('./config').getUsageDiscoveredInventoryEndpoint,
|
|
65
89
|
getSkillsDeclareEndpoint = require('./config').getSkillsDeclareEndpoint,
|
|
@@ -69,7 +93,6 @@ function makeOnboardingTools(deps = {}) {
|
|
|
69
93
|
getPortfoliosListEndpoint = require('./config').getPortfoliosListEndpoint,
|
|
70
94
|
getPortfolioUpdateEndpoint = require('./config').getPortfolioUpdateEndpoint,
|
|
71
95
|
getAgentPortfoliosEndpoint = require('./config').getAgentPortfoliosEndpoint,
|
|
72
|
-
requestLogin = require('./auth-client').requestLogin,
|
|
73
96
|
saveAuthSession = require('./auth-session-store').saveAuthSession,
|
|
74
97
|
loadAuthSession = require('./auth-session-store').loadAuthSession,
|
|
75
98
|
sessionStatus = require('./auth-session-store').sessionStatus,
|
|
@@ -81,6 +104,9 @@ function makeOnboardingTools(deps = {}) {
|
|
|
81
104
|
requestDeclareAgent = require('./agents-client').requestDeclareAgent,
|
|
82
105
|
requestListAgents = require('./agents-client').requestListAgents,
|
|
83
106
|
requestRelateAgentPortfolios = require('./agents-client').requestRelateAgentPortfolios,
|
|
107
|
+
newDetectedAgents = require('./detected-agents').newDetectedAgents,
|
|
108
|
+
declareDetectedAgents = require('./detected-agents').declareDetectedAgents,
|
|
109
|
+
detectedAgentsCopy = () => require('./signup-copy').detectedAgentsCopy(require('./signup-language').signupLanguage(require('./i18n').detectFlowLang())),
|
|
84
110
|
requestDeclarePortfolio = require('./portfolio-client').requestDeclarePortfolio,
|
|
85
111
|
requestListPortfolios = require('./portfolio-client').requestListPortfolios,
|
|
86
112
|
requestUpdatePortfolioSkills = require('./portfolio-client').requestUpdatePortfolioSkills,
|
|
@@ -277,6 +303,57 @@ function makeOnboardingTools(deps = {}) {
|
|
|
277
303
|
return { ...base, portfolios, related, relateFailures };
|
|
278
304
|
}
|
|
279
305
|
|
|
306
|
+
// The detected agents offered last, so a selection counts only for the list the talent was shown.
|
|
307
|
+
let offeredAgents = null;
|
|
308
|
+
|
|
309
|
+
async function addDetectedAgents(args = {}) {
|
|
310
|
+
const session = requireSession();
|
|
311
|
+
const inventoryEndpoint = requireEndpoint(getUsageDiscoveredInventoryEndpoint, 'discovered-inventory');
|
|
312
|
+
const listEndpoint = requireEndpoint(getAgentsListEndpoint, 'agents-list');
|
|
313
|
+
const declareEndpoint = requireEndpoint(getAgentsDeclareEndpoint, 'agents-declare');
|
|
314
|
+
const c = detectedAgentsCopy();
|
|
315
|
+
const inv = await requestDiscoveredInventoryWithRefresh(
|
|
316
|
+
{ accessToken: session.accessToken, refreshToken: refreshTokenFor },
|
|
317
|
+
{ endpoint: inventoryEndpoint, request: requestDiscoveredInventory },
|
|
318
|
+
);
|
|
319
|
+
if (!inv.ok) return { ok: false, reason: inv.reason || 'inventory-failed', message: 'Nothing was added: the detected agents could not be read.' };
|
|
320
|
+
const listed = await requestListAgents(
|
|
321
|
+
{ accessToken: session.accessToken, hubAccessToken: session.hubAccessToken },
|
|
322
|
+
{ endpoint: listEndpoint },
|
|
323
|
+
);
|
|
324
|
+
if (!listed.ok) return { ok: false, reason: listed.reason, message: 'Nothing was added: the agents already on the profile could not be checked, so none are added to avoid duplicates.' };
|
|
325
|
+
const agents = newDetectedAgents(inv.agents, listed.agents);
|
|
326
|
+
if (agents.length === 0) {
|
|
327
|
+
offeredAgents = null;
|
|
328
|
+
return { ok: true, added: [], relayVerbatim: c.allOnProfile, message: 'Nothing to add. Print relayVerbatim word for word.' };
|
|
329
|
+
}
|
|
330
|
+
const offered = offeredAgents && JSON.stringify(offeredAgents.map((a) => a.name)) === JSON.stringify(agents.map((a) => a.name));
|
|
331
|
+
const chosen = offered ? parseSelection(args.selection, agents) : null;
|
|
332
|
+
if (!chosen) {
|
|
333
|
+
offeredAgents = agents;
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
reason: 'selection-required',
|
|
337
|
+
say: [c.intro, agents.map((a, i) => `${i + 1}. ${a.name}`).join('\n'), c.question].join('\n\n'),
|
|
338
|
+
message: 'Nothing has been added. Print say word for word and stop: the talent replies with numbers (such as "1 3"), "all" or "none". Then call add_detected_agents again with selection = exactly what the talent typed. Only the talent decides: never choose, confirm or fill in selection for them, and never call it with a selection they did not type.',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
offeredAgents = null;
|
|
342
|
+
if (chosen.length === 0) return { ok: true, added: [], relayVerbatim: c.noneChosen, message: 'Nothing was added. Print relayVerbatim word for word.' };
|
|
343
|
+
const { added, failed } = await declareDetectedAgents(chosen, session, { endpoint: declareEndpoint, declare: requestDeclareAgent });
|
|
344
|
+
const expired = failed.some((f) => f.reason === 'hub-session-expired');
|
|
345
|
+
return {
|
|
346
|
+
ok: added.length > 0,
|
|
347
|
+
added: added.map((a) => a.name),
|
|
348
|
+
failed: failed.map((f) => ({ name: f.agent.name, reason: f.reason })),
|
|
349
|
+
relayVerbatim: [
|
|
350
|
+
added.length ? c.added(added.map((a) => a.name).join(', ')) : null,
|
|
351
|
+
failed.length ? c.failed(failed.map((f) => f.agent.name).join(', ')) : null,
|
|
352
|
+
].filter(Boolean).join('\n'),
|
|
353
|
+
message: `Print relayVerbatim word for word.${expired ? ' The Shakers session expired: ask the talent to sign in again (social_signin_start), then call add_detected_agents again to offer the ones still missing.' : ''}`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
280
357
|
// Sends the project only after the talent says yes to exactly what goes: name, web address, dates, client.
|
|
281
358
|
async function addProject(args = {}, ctx = {}) {
|
|
282
359
|
const session = requireSession();
|
|
@@ -358,11 +435,19 @@ function makeOnboardingTools(deps = {}) {
|
|
|
358
435
|
inputSchema: ADD_AGENT_SCHEMA,
|
|
359
436
|
handler: addAgent,
|
|
360
437
|
},
|
|
438
|
+
{
|
|
439
|
+
name: 'add_detected_agents',
|
|
440
|
+
title: 'Add your detected agents',
|
|
441
|
+
description: 'Lists the AI agents found in your analysis that are not on your Shakers profile yet and adds the ones you choose, only their name and description.',
|
|
442
|
+
guidance: "Offer the talent to add, in one step, the AI agents detected by their submitted ai_usage report that are not on their Shakers profile yet (agents already there are skipped by name). No experience or portfolio is needed or related. Call it without selection: it returns `say`, a numbered list and the question, to print word for word; the talent replies with numbers, all or none. Then call it again with selection set to exactly what the talent typed. The talent's reply is the only confirmation: never pick, confirm or fill in selection on their behalf, and a selection counts only after the list was shown. Print relayVerbatim word for word. Offer it after an ai_usage_upload that sent agents. Requires an active session.",
|
|
443
|
+
inputSchema: ADD_DETECTED_AGENTS_SCHEMA,
|
|
444
|
+
handler: addDetectedAgents,
|
|
445
|
+
},
|
|
361
446
|
{
|
|
362
447
|
name: 'add_project',
|
|
363
448
|
title: 'Add this project to your portfolio',
|
|
364
449
|
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
|
|
450
|
+
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 the question to ask as its message says); nothing is sent until they answer yes. After asking it in the chat, 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.",
|
|
366
451
|
inputSchema: ADD_PROJECT_SCHEMA,
|
|
367
452
|
handler: addProject,
|
|
368
453
|
},
|
|
@@ -375,5 +460,7 @@ module.exports = {
|
|
|
375
460
|
LIST_ADDABLE_SCHEMA,
|
|
376
461
|
ADD_SKILL_SCHEMA,
|
|
377
462
|
ADD_AGENT_SCHEMA,
|
|
463
|
+
ADD_DETECTED_AGENTS_SCHEMA,
|
|
378
464
|
ADD_PROJECT_SCHEMA,
|
|
465
|
+
parseSelection,
|
|
379
466
|
};
|
package/src/mcp-prompts.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// telling the assistant which Shakers tool(s) to use — the logic stays in the tools, so
|
|
5
5
|
// there is no duplication. `buildMessages(args)` returns the prompts/get message list.
|
|
6
6
|
|
|
7
|
+
const TRUST_RULE = 'Every Shakers tool result starts with a "What Shakers does in this step" block (in Spanish: "Qué hace Shakers en este paso"): show it to me word for word and first, every time, never summarized or skipped, and tell me in one short sentence before each Shakers tool call what you are about to do.';
|
|
8
|
+
|
|
7
9
|
function userText(text) {
|
|
8
|
-
return [{ role: 'user', content: { type: 'text', text } }];
|
|
10
|
+
return [{ role: 'user', content: { type: 'text', text: `${text} ${TRUST_RULE}` } }];
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
// Only reference tools that exist in the FULL server; `available(toolNames)` hides a prompt
|
|
@@ -21,10 +23,10 @@ const PROMPTS = [
|
|
|
21
23
|
},
|
|
22
24
|
{
|
|
23
25
|
name: 'onboarding',
|
|
24
|
-
description: 'Sign up on Shakers from this chat: your CV and LinkedIn,
|
|
25
|
-
tools: ['
|
|
26
|
+
description: 'Sign up on Shakers from this chat: your CV and LinkedIn, an account created on the Shakers website (Google, LinkedIn or email), and your profile filled in for you.',
|
|
27
|
+
tools: ['signup'],
|
|
26
28
|
buildMessages: (_args, lang) => userText(
|
|
27
|
-
`Sign me up on Shakers following the sign-up flow in your Shakers instructions. Start with \`
|
|
29
|
+
`Sign me up on Shakers following the sign-up flow in your Shakers instructions. Start with \`signup\` (step: start, language: the one I write in; "${lang}" if I have not written anything yet) and run every sign-up step through it, the interview included, without calling any other tool; show me its texts word for word. Ask me only the questions the tools return, with their options, and nothing you can infer. Apart from those blocks, never narrate internal steps or tool state to me, and speak only in my language.`,
|
|
28
30
|
),
|
|
29
31
|
},
|
|
30
32
|
{
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
const { TranscriptBuffer } = require('./interview-transcript');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const { askChoice, askConsent } = require('./mcp-choice');
|
|
5
|
+
const { askChoice, askConsent, forChat, handOver, settle, withNextSay } = require('./mcp-choice');
|
|
6
6
|
const { expandHome, tildify } = require('./env-paths');
|
|
7
7
|
|
|
8
8
|
const ANSWER_MODES = ['drafted', 'own', 'full'];
|
|
9
|
+
const WHERE_QUESTION = 'interview:where';
|
|
9
10
|
|
|
10
11
|
const INTERVIEW_START_SCHEMA = {
|
|
11
12
|
type: 'object',
|
|
@@ -123,10 +124,14 @@ function makeRegisterTools(deps = {}) {
|
|
|
123
124
|
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.' };
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
// The talent's answer to the interview disclaimers counts only after they went to the chat with the answer-mode question.
|
|
128
|
+
let disclaimersShown = false;
|
|
129
|
+
|
|
126
130
|
async function askWhere(args, ctx) {
|
|
131
|
+
const c = copy();
|
|
132
|
+
if (args.where === 'here' || args.where === 'later') settle(WHERE_QUESTION, args.where === 'here' ? c.interviewHere : c.interviewLater);
|
|
127
133
|
if (args.where === 'here') return { here: true };
|
|
128
134
|
if (args.where === 'later') return { here: false };
|
|
129
|
-
const c = copy();
|
|
130
135
|
const asked = await askChoice(ctx, { question: c.interviewWhereQuestion, options: [c.interviewHere, c.interviewLater] });
|
|
131
136
|
if (asked.answer) return { here: asked.answer === c.interviewHere };
|
|
132
137
|
return { chat: asked.chat, dismissed: asked.dismissed };
|
|
@@ -160,30 +165,35 @@ function makeRegisterTools(deps = {}) {
|
|
|
160
165
|
if (!CHAT_INTERVIEW_LANGUAGES.has(textLang())) {
|
|
161
166
|
return { ok: false, reason: 'interview-on-web', relayVerbatim: copy().interviewOnWeb, message: 'Print relayVerbatim word for word now. The interview is not offered here in this language: skip it, go on with the main role, then open_web.' };
|
|
162
167
|
}
|
|
163
|
-
const acknowledged = args.disclaimerAcknowledged === true;
|
|
168
|
+
const acknowledged = args.disclaimerAcknowledged === true && disclaimersShown;
|
|
169
|
+
// One acknowledgement per interview: the next one shows its disclaimers again.
|
|
170
|
+
if (acknowledged) disclaimersShown = false;
|
|
164
171
|
if (!acknowledged) {
|
|
165
172
|
track(() => funnel.viewed('pre_interview'));
|
|
166
173
|
const where = await askWhere(args, ctx);
|
|
167
174
|
if (where.here === false) return interviewLater();
|
|
168
175
|
if (where.chat) {
|
|
169
|
-
|
|
176
|
+
// "Here" leads to the answer-mode question, whose disclaimers go to the model now.
|
|
177
|
+
const disclaimers = handOver(WHERE_QUESTION, { step: 'interview', ifAnswer: [copy().interviewHere], texts: Object.values(flow.interviewDisclaimers(textLang())) });
|
|
178
|
+
return withNextSay({
|
|
170
179
|
ok: false,
|
|
171
180
|
reason: 'where-required',
|
|
172
181
|
...(where.dismissed ? { dismissed: where.dismissed } : {}),
|
|
173
|
-
...where.chat,
|
|
182
|
+
...forChat(where.chat),
|
|
174
183
|
message: `${where.chat.choiceMessage} Then call onboarding_interview_start again with where:"here" for the first option or where:"later" for the second.`,
|
|
175
|
-
};
|
|
184
|
+
}, disclaimers);
|
|
176
185
|
}
|
|
177
186
|
}
|
|
178
187
|
// The two disclaimers travel with the answer-mode question, so they are read before the talent answers it.
|
|
179
188
|
const disclaimers = acknowledged ? [] : Object.values(flow.interviewDisclaimers(textLang()));
|
|
180
189
|
const answer = await askAnswerMode(acknowledged ? args : {}, ctx, disclaimers);
|
|
181
190
|
if (answer.chat) {
|
|
191
|
+
if (disclaimers.length) disclaimersShown = true;
|
|
182
192
|
return {
|
|
183
193
|
ok: false,
|
|
184
194
|
reason: 'answer-mode-required',
|
|
185
195
|
...(answer.dismissed ? { dismissed: answer.dismissed } : {}),
|
|
186
|
-
...answer.chat,
|
|
196
|
+
...forChat(answer.chat),
|
|
187
197
|
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.`,
|
|
188
198
|
};
|
|
189
199
|
}
|
|
@@ -283,7 +293,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
283
293
|
return { ok: false, reason: 'unknown-interview', message: 'No live interview for that interviewId (the server may have restarted). Nothing to complete; start again if the talent still wants the interview.' };
|
|
284
294
|
}
|
|
285
295
|
const saved = await teardownInterview(args.interviewId, { persist: true });
|
|
286
|
-
if (!saved.ok) return { ok: false, reason: saved.reason || 'complete-failed' };
|
|
296
|
+
if (!saved.ok) return { ok: false, reason: saved.reason || 'complete-failed', message: 'The interview could not be saved: it stays pending and the talent can take it later on the web. Do not retry it; go on with the main role (list_my_roles, then set_main_role).' };
|
|
287
297
|
lastCompletedInterview = { interviewId: args.interviewId, completedAt: Date.now() };
|
|
288
298
|
track(() => funnel.completed('interview', { interview_mode: 'text' }));
|
|
289
299
|
return { ok: true, state: 'taken', message: catalog.interviewComplete };
|
|
@@ -325,7 +335,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
325
335
|
// Content-free context the model may PROPOSE to the talent (never auto-write), searched only after the talent says yes.
|
|
326
336
|
async function suggestContext(args = {}, ctx = {}) {
|
|
327
337
|
const c = copy().consent;
|
|
328
|
-
const asked = await askConsent(ctx, { tool: 'suggest_register_context', key: 'suggest_register_context', question: c.cvSearch, yes: c.
|
|
338
|
+
const asked = await askConsent(ctx, { tool: 'suggest_register_context', key: 'suggest_register_context', question: c.cvSearch, yes: c.cvSearchYes, no: c.no, answer: args.answer });
|
|
329
339
|
if (asked.reply) return asked.reply;
|
|
330
340
|
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.' };
|
|
331
341
|
const read = deps.readRegisterContextSuggestions || require('./register-context').readRegisterContextSuggestions;
|
|
@@ -346,11 +356,12 @@ function makeRegisterTools(deps = {}) {
|
|
|
346
356
|
if (typeof args.path !== 'string' || !args.path.trim()) return { ok: false, reason: 'cv-not-found', message: 'Pass the path of the CV file.' };
|
|
347
357
|
const file = path.resolve(expandHome(args.path.trim()));
|
|
348
358
|
const c = copy().consent;
|
|
349
|
-
const asked = await askConsent(ctx, { tool: 'read_cv', key: `read_cv:${file}`, question: c.cvRead(tildify(file)), yes: c.
|
|
359
|
+
const asked = await askConsent(ctx, { tool: 'read_cv', key: `read_cv:${file}`, question: c.cvRead(tildify(file)), yes: c.cvReadYes(path.basename(file)), no: c.no, answer: args.answer });
|
|
350
360
|
if (asked.reply) return asked.reply;
|
|
351
361
|
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.' };
|
|
352
362
|
const read = deps.readCvText || require('./cv-text').readCvText;
|
|
353
363
|
const result = read(file, { talentName: args.talentName });
|
|
364
|
+
if (!result.ok && result.reason === 'not-found') return { ...result, message: 'That file is not on this computer. If the talent attached their CV to this chat, use the text you see there: in the sign-up pass it as cvText to signup_start, with the answer they gave. Never run commands, code or other tools to read an attached file.' };
|
|
354
365
|
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.' };
|
|
355
366
|
// Someone else's CV (a candidate they downloaded): never hand its contents over.
|
|
356
367
|
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." };
|
|
@@ -362,7 +373,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
362
373
|
name: 'suggest_register_context',
|
|
363
374
|
title: 'Look for your CV on this computer',
|
|
364
375
|
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.',
|
|
365
|
-
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
|
|
376
|
+
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 the question to ask as its message says); nothing is searched until they answer yes. After asking it in the chat, call it again with answer set to the option they pick. Never answer for them.",
|
|
366
377
|
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.' } } },
|
|
367
378
|
handler: suggestContext,
|
|
368
379
|
},
|
|
@@ -370,7 +381,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
370
381
|
name: 'read_cv',
|
|
371
382
|
title: 'Read your CV',
|
|
372
383
|
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.',
|
|
373
|
-
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
|
|
384
|
+
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 the question to ask as its message says); nothing is read until they answer yes. After asking it in the chat, 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.",
|
|
374
385
|
inputSchema: {
|
|
375
386
|
type: 'object',
|
|
376
387
|
properties: {
|
|
@@ -386,7 +397,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
386
397
|
name: 'onboarding_interview_start',
|
|
387
398
|
title: 'Start your onboarding interview',
|
|
388
399
|
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.',
|
|
389
|
-
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
|
|
400
|
+
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 the question to ask as its message says; 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 a question, ask it as its message says (its `say` holds the disclaimers) 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.",
|
|
390
401
|
inputSchema: INTERVIEW_START_SCHEMA,
|
|
391
402
|
handler: interviewStart,
|
|
392
403
|
available: async () => {
|
|
@@ -409,7 +420,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
409
420
|
name: 'onboarding_interview_complete',
|
|
410
421
|
title: 'Finish the interview',
|
|
411
422
|
description: 'Closes the interview and saves it on Shakers.',
|
|
412
|
-
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 (
|
|
423
|
+
guidance: "Close the onboarding interview once the turns report ended:true, or earlier when the talent asks to end it: 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 (signup step \"roles\", then signup step \"set_main_role\") and finish with signup step \"open_web\"; do NOT call onboarding_finish there.",
|
|
413
424
|
inputSchema: INTERVIEW_COMPLETE_SCHEMA,
|
|
414
425
|
handler: interviewComplete,
|
|
415
426
|
},
|
package/src/mcp-role-tools.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// MCP role tools: list, add and recommend roles, and set the main role (the sign-up's last step before open_web).
|
|
4
4
|
|
|
5
|
-
const { askChoice, askAfterTexts, chatChoice, optionFor,
|
|
5
|
+
const { askChoice, askAfterTexts, chatChoice, forChat, optionFor, OPEN_MESSAGE } = require('./mcp-choice');
|
|
6
6
|
const { signupCopy, renderRole } = require('./signup-copy');
|
|
7
7
|
const { signupLanguage } = require('./signup-language');
|
|
8
8
|
|
|
@@ -17,8 +17,10 @@ const MAX_WAIT_SECONDS = 45;
|
|
|
17
17
|
const PENDING_FINALIZATION = new Set(['COLLECTING', 'PROCESSING']);
|
|
18
18
|
// Alma's signal from the interview first, then the CV import, as the web stacks them.
|
|
19
19
|
const SOURCE_ORDER = { ONBOARDING_INTERVIEW: 0, IMPORT: 1 };
|
|
20
|
-
//
|
|
21
|
-
|
|
20
|
+
// The chat's question tool takes two to four options: at most four roles are offered and the talent types any other one;
|
|
21
|
+
// a single role gets the "other role" button as its second option.
|
|
22
|
+
const MAX_ROLE_OPTIONS = 4;
|
|
23
|
+
const withOther = (names, other) => (names.length === 1 ? [...names, other] : names);
|
|
22
24
|
|
|
23
25
|
const fold = (text) => String(text || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
24
26
|
|
|
@@ -77,7 +79,7 @@ const SET_MAIN_ROLE_SCHEMA = {
|
|
|
77
79
|
description: 'In the sign-up, for each recommended role (by clusterId from list_my_roles): ONE short concrete fact from the CV, the interview or the AI-usage analysis that shows why it fits (e.g. "6 years with Spark and Airflow"), in the talent\'s language. It is shown under the role.',
|
|
78
80
|
},
|
|
79
81
|
answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
|
|
80
|
-
clusterId: { type: 'string', description: 'The role the talent already picked, by clusterId from list_my_roles or from the roles this tool returned. Omit it in the sign-up: the tool asks the talent which recommended role is their main one (a dialog, or options it returns)
|
|
82
|
+
clusterId: { type: 'string', description: 'The role the talent already picked, by clusterId from list_my_roles or from the roles this tool returned. Omit it in the sign-up: the tool asks the talent which recommended role is their main one (a dialog, or options it returns); a role they type instead is looked up in the catalogue.' },
|
|
81
83
|
catalogue: { type: 'boolean', description: 'true when the role comes from the catalogue: with clusterId it adds the role before setting it; without clusterId it asks from the catalogue.' },
|
|
82
84
|
},
|
|
83
85
|
};
|
|
@@ -120,6 +122,8 @@ function makeRoleTools(deps = {}) {
|
|
|
120
122
|
} = deps;
|
|
121
123
|
let clustersCount;
|
|
122
124
|
let onboardingCompleted = false;
|
|
125
|
+
// The recommended roles were offered in the chat, so an answer that names none of them is a role the talent typed.
|
|
126
|
+
let recommendedAsked = false;
|
|
123
127
|
|
|
124
128
|
async function finishSignup(session) {
|
|
125
129
|
if (onboardingCompleted || !inSignup()) return;
|
|
@@ -226,7 +230,7 @@ function makeRoleTools(deps = {}) {
|
|
|
226
230
|
ok: false,
|
|
227
231
|
reason: 'main-role-choice-required',
|
|
228
232
|
...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
|
|
229
|
-
...asked.chat,
|
|
233
|
+
...forChat(asked.chat),
|
|
230
234
|
roles: roles.map((r) => ({ clusterId: r.clusterId, name: r.name })),
|
|
231
235
|
message: `${asked.chat.choiceMessage} Then call set_main_role with the clusterId (from roles) of the one they pick. ${extra}`,
|
|
232
236
|
};
|
|
@@ -244,10 +248,10 @@ function makeRoleTools(deps = {}) {
|
|
|
244
248
|
}));
|
|
245
249
|
}
|
|
246
250
|
|
|
247
|
-
// The main-role step: why each recommended
|
|
251
|
+
// The main-role step: why each of the best recommended roles fits and how many projects want it, then the question; any other role the talent types is looked up in the catalogue.
|
|
248
252
|
async function chooseMainRole(session, args, ctx) {
|
|
249
253
|
const read = await readRoles(session.hubAccessToken);
|
|
250
|
-
const recommended = byName(read.recommended);
|
|
254
|
+
const recommended = byName(read.recommended).slice(0, MAX_ROLE_OPTIONS);
|
|
251
255
|
if (!recommended.length && read.mainClusterId) {
|
|
252
256
|
await finishSignup(session);
|
|
253
257
|
return { reply: { ok: true, alreadySet: true, clusterId: read.mainClusterId, message: 'The talent already has a main role and nothing new was recommended: nothing to ask. Go on to open_web; only if they ask to change it, call set_main_role with catalogue:true.' } };
|
|
@@ -257,17 +261,29 @@ function makeRoleTools(deps = {}) {
|
|
|
257
261
|
const counts = await openCounts(session.hubAccessToken, recommended);
|
|
258
262
|
trackSuggestions(recommended);
|
|
259
263
|
const evidence = args.evidence && typeof args.evidence === 'object' ? args.evidence : {};
|
|
260
|
-
const
|
|
264
|
+
const options = withOther(recommended.map((r) => r.name), c.mainRoleOther);
|
|
265
|
+
// Each option numbered with why it fits; "another role" is its own label.
|
|
266
|
+
const blocks = options.map((name, i) => (recommended[i] ? renderRole(lang(), { name, source: recommended[i].source, evidence: evidence[recommended[i].clusterId], openProjects: counts[i], recommended: i === 0 }) : name));
|
|
267
|
+
const typed = typeof args.answer === 'string' ? args.answer.trim() : '';
|
|
268
|
+
if (recommendedAsked && typed && !optionFor(typed, options)) {
|
|
269
|
+
recommendedAsked = false;
|
|
270
|
+
return chooseFromCatalogue({ answer: typed, skills: args.skills }, ctx);
|
|
271
|
+
}
|
|
261
272
|
const asked = await askAfterTexts(ctx, {
|
|
262
273
|
tool: 'set_main_role',
|
|
263
|
-
texts: [
|
|
274
|
+
texts: [c.rolesIntro],
|
|
275
|
+
listed: { blocks, after: [c.rolesOutro] },
|
|
264
276
|
question: c.mainRoleQuestion,
|
|
265
|
-
options
|
|
277
|
+
options,
|
|
266
278
|
answer: args.answer,
|
|
279
|
+
about: options,
|
|
267
280
|
});
|
|
268
281
|
if (asked.reply) {
|
|
269
|
-
|
|
282
|
+
recommendedAsked = true;
|
|
283
|
+
const other = options.includes(c.mainRoleOther) ? ' If they pick the last option, call set_main_role with catalogue:true and no clusterId.' : '';
|
|
284
|
+
return { reply: { ...asked.reply, roles: recommended.map((r) => ({ clusterId: r.clusterId, name: r.name })), message: `${asked.reply.message} Pass the same evidence again.${other} If they type another role instead of picking one, pass what they typed as answer: the closest catalogue roles are offered.` } };
|
|
270
285
|
}
|
|
286
|
+
recommendedAsked = false;
|
|
271
287
|
if (asked.answer === c.mainRoleOther) return chooseFromCatalogue({}, ctx);
|
|
272
288
|
return { role: recommended.find((r) => r.name === asked.answer) };
|
|
273
289
|
}
|
|
@@ -282,7 +298,7 @@ function makeRoleTools(deps = {}) {
|
|
|
282
298
|
ok: false,
|
|
283
299
|
reason: 'main-role-ask',
|
|
284
300
|
say: copy().mainRoleAsk,
|
|
285
|
-
message: `${
|
|
301
|
+
message: `${OPEN_MESSAGE} Then call set_main_role with catalogue:true and answer = what they say: it offers the closest catalogue roles.`,
|
|
286
302
|
},
|
|
287
303
|
};
|
|
288
304
|
}
|
|
@@ -291,7 +307,7 @@ function makeRoleTools(deps = {}) {
|
|
|
291
307
|
const c = copy();
|
|
292
308
|
const said = typeof args.answer === 'string' ? args.answer.trim() : '';
|
|
293
309
|
if (offered) {
|
|
294
|
-
const picked = optionFor(said,
|
|
310
|
+
const picked = optionFor(said, withOther(offered.map((r) => r.name), c.mainRoleNotListed));
|
|
295
311
|
if (picked === c.mainRoleNotListed) return askOwnRole();
|
|
296
312
|
const role = picked && offered.find((r) => r.name === picked);
|
|
297
313
|
if (role) { offered = null; return { role, add: true }; }
|
|
@@ -318,7 +334,8 @@ function makeRoleTools(deps = {}) {
|
|
|
318
334
|
].filter(Boolean).join('\n'));
|
|
319
335
|
const texts = [[...(blocks.length ? [c.rolesIntro, ...blocks] : []), c.rolesOutro].join('\n\n')];
|
|
320
336
|
trackSuggestions([]);
|
|
321
|
-
const
|
|
337
|
+
const names = withOther(options.map((r) => r.name), c.mainRoleNotListed);
|
|
338
|
+
const asked = await askChoice(ctx, { texts, question: c.mainRoleQuestion, options: names });
|
|
322
339
|
if (asked.answer === c.mainRoleNotListed) return askOwnRole();
|
|
323
340
|
const role = asked.answer && options.find((r) => r.name === asked.answer);
|
|
324
341
|
if (role) {
|
|
@@ -326,7 +343,8 @@ function makeRoleTools(deps = {}) {
|
|
|
326
343
|
return { role, add: true };
|
|
327
344
|
}
|
|
328
345
|
offered = options;
|
|
329
|
-
|
|
346
|
+
const other = names.includes(c.mainRoleNotListed) ? ' If they pick the last option, call set_main_role with catalogue:true and answer = that option.' : '';
|
|
347
|
+
return { reply: roleChoice(asked.chat ? asked : { chat: chatChoice(c.mainRoleQuestion, names, texts) }, options, `Pass catalogue:true too: it adds the role first.${other} If they type another role instead, call set_main_role with catalogue:true and answer = what they typed.`) };
|
|
330
348
|
}
|
|
331
349
|
|
|
332
350
|
async function setMainRole(args = {}, ctx = {}) {
|
|
@@ -379,7 +397,7 @@ function makeRoleTools(deps = {}) {
|
|
|
379
397
|
name: 'list_my_roles',
|
|
380
398
|
title: 'Your roles',
|
|
381
399
|
description: 'Shows your roles on Shakers and the ones suggested by your interview and your CV.',
|
|
382
|
-
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 (
|
|
400
|
+
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 (45): 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.",
|
|
383
401
|
inputSchema: LIST_MY_ROLES_SCHEMA,
|
|
384
402
|
handler: listMyRoles,
|
|
385
403
|
},
|
|
@@ -387,7 +405,7 @@ function makeRoleTools(deps = {}) {
|
|
|
387
405
|
name: 'set_main_role',
|
|
388
406
|
title: 'Choose your main role',
|
|
389
407
|
description: 'Asks you which role is your main one and saves it on your Shakers profile.',
|
|
390
|
-
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
|
|
408
|
+
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 (at most four) recommended roles is their main one (or straight from the catalogue when nothing is recommended), in a dialog when the client has one; otherwise it returns the question and the roles, to call it again with the clusterId they pick, or with answer = another role they type, which it looks up in the catalogue. 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.",
|
|
391
409
|
inputSchema: SET_MAIN_ROLE_SCHEMA,
|
|
392
410
|
handler: setMainRole,
|
|
393
411
|
},
|