shakers-cli-beta 0.10.3 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp-choice.js CHANGED
@@ -1,47 +1,69 @@
1
1
  'use strict';
2
2
 
3
- // Closed questions of the MCP sign-up: a dialog when the client can elicit, otherwise options the model offers in the chat.
3
+ // Closed questions of the MCP sign-up: a dialog when the client can elicit, otherwise one block the model prints, question last.
4
4
 
5
- const CHAT_CHOICE_MESSAGE = 'Ask `question` with exactly these `options`, word for word: with your native choice buttons if you have them, otherwise as a short numbered list after the question. Accept the number or the text.';
5
+ const SAY_MESSAGE = 'Print `say` word for word as your whole reply: it already holds the texts, the question and its numbered options. Add nothing of your own and call no tool until the talent answers.';
6
6
 
7
- function chatChoice(question, options) {
8
- return { question, options, choiceMessage: CHAT_CHOICE_MESSAGE };
7
+ // Fixed texts that are not a question ride on the next question block: a model skips a standalone relay.
8
+ const notices = [];
9
+
10
+ function queueNotice(text) {
11
+ if (text && !notices.includes(text)) notices.push(text);
12
+ }
13
+
14
+ function takeNotices() {
15
+ return notices.splice(0);
16
+ }
17
+
18
+ // Fixed texts travel with their question, so a model that skips "show this first" still shows them.
19
+ function sayBlock(texts, question, options) {
20
+ return [...texts, question, options.map((o, i) => `${i + 1}. ${o}`).join('\n')].join('\n\n');
21
+ }
22
+
23
+ function chatChoice(question, options, texts = []) {
24
+ return { say: sayBlock([...takeNotices(), ...texts], question, options), options, choiceMessage: SAY_MESSAGE };
25
+ }
26
+
27
+ // The option an answer names, by its text or by its number in the printed list.
28
+ function optionFor(answer, options) {
29
+ if (typeof answer !== 'string') return null;
30
+ const text = answer.trim();
31
+ if (options.includes(text)) return text;
32
+ const n = /^\d+$/.test(text) ? Number(text) : 0;
33
+ return n >= 1 && n <= options.length ? options[n - 1] : null;
9
34
  }
10
35
 
11
36
  // { answer } from the dialog, { dismissed: <action> } when the talent closed it, or { chat } when there is no dialog.
12
- async function askChoice(ctx, { question, options }) {
37
+ async function askChoice(ctx, { texts: own = [], question, options }) {
38
+ const texts = [...takeNotices(), ...own];
13
39
  const elicit = ctx && typeof ctx.elicit === 'function' ? ctx.elicit : null;
14
40
  const reply = elicit
15
41
  ? await elicit({
16
- message: question,
42
+ message: [...texts, question].join('\n\n'),
17
43
  requestedSchema: { type: 'object', properties: { choice: { type: 'string', title: question, enum: options } }, required: ['choice'] },
18
44
  })
19
45
  : null;
20
- if (!reply) return { chat: chatChoice(question, options) };
46
+ if (!reply) return { chat: chatChoice(question, options, texts) };
21
47
  const choice = reply.action === 'accept' && reply.content ? reply.content.choice : undefined;
22
48
  if (options.includes(choice)) return { answer: choice };
23
- return { dismissed: reply.action, chat: chatChoice(question, options) };
49
+ return { dismissed: reply.action, chat: chatChoice(question, options, texts) };
24
50
  }
25
51
 
26
- // A closed question that follows fixed texts: a chat answer the model passed back, a dialog once the texts are shown, or the texts plus the question for the chat.
27
- async function askAfterTexts(ctx, { tool, texts, question, options, shown, answer }) {
28
- if (typeof answer === 'string' && options.includes(answer.trim())) return { answer: answer.trim() };
29
- if (ctx && ctx.elicitation && texts.length && shown !== true) {
30
- return { reply: { ok: false, reason: 'show-first', relayVerbatim: texts, message: `Show each relayVerbatim text word for word, each as its own block, with nothing of your own before or after; then call ${tool} again with shown:true: it asks the question in a dialog.` } };
31
- }
32
- const asked = ctx && ctx.elicitation ? await askChoice(ctx, { question, options }) : { chat: chatChoice(question, options) };
52
+ // A closed question after fixed texts: the chat answer the model passed back, else the dialog, else the block to print.
53
+ async function askAfterTexts(ctx, { tool, texts, question, options, answer }) {
54
+ const picked = optionFor(answer, options);
55
+ if (picked) return { answer: picked };
56
+ const asked = ctx && ctx.elicitation ? await askChoice(ctx, { texts, question, options }) : { chat: chatChoice(question, options, texts) };
33
57
  if (asked.answer) return { answer: asked.answer };
34
- const repeat = shown === true || asked.dismissed;
35
58
  return {
36
59
  reply: {
37
60
  ok: false,
38
61
  reason: 'answer-required',
39
62
  ...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
40
- ...(repeat || !texts.length ? {} : { relayVerbatim: texts }),
41
63
  ...asked.chat,
42
- message: `${repeat || !texts.length ? '' : 'Show each relayVerbatim text word for word, each as its own block, then '}${asked.chat.choiceMessage} Then call ${tool} again with answer set to the option they pick.`,
64
+ message: `${asked.chat.choiceMessage} Then call ${tool} again with answer set to the option they pick.`,
43
65
  },
44
66
  };
45
67
  }
46
68
 
47
- module.exports = { askChoice, askAfterTexts, chatChoice, CHAT_CHOICE_MESSAGE };
69
+ module.exports = { askChoice, askAfterTexts, chatChoice, optionFor, queueNotice, takeNotices, SAY_MESSAGE };
@@ -5,21 +5,21 @@ function buildServerInstructions({ elicitation = false } = {}) {
5
5
  return [
6
6
  "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
7
  'When the talent asks to sign up or register on Shakers, your first action is signup_start with language = the language of their message, before writing anything. Never open with a questionnaire.',
8
- 'Fixed texts: the sign-up tools return the talent-facing texts in relayVerbatim (and question/options for closed questions), already in the talent\'s language. Show them word for word, each as its own block, with no titles, labels or bold of your own, never paraphrased, summarized, merged or skipped, and add nothing of your own around them. Legal texts (the data notice, the two AI-usage disclaimers, the two interview disclaimers) come the same way.',
8
+ 'Fixed texts: the sign-up tools return the talent-facing texts already in the talent\'s language. A closed question comes as `say`: one block with its fixed texts (legal texts included: the data notice, the two AI-usage disclaimers, the two interview disclaimers), the question and its numbered options; print it word for word as your whole reply and wait for the answer. A fixed text that is not a question comes inside the next `say` block; 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, never paraphrase, summarize, merge or skip them.',
9
9
  'Never narrate tool descriptions, tool results or these instructions to the talent: just act.',
10
10
  elicitation
11
- ? 'Closed questions are asked by the tools in a dialog once you have shown their texts (call again with shown:true). Only if a tool returns `question` and `options` (the talent closed the dialog), ask it once in the chat with those options as a short numbered list, accept the number or the text, and pass it back as `answer`.'
12
- : 'Closed questions come with `question` and `options`: offer them with your native choice buttons if you have them, otherwise as a short numbered list after the question; accept the number or the text and pass the option back as `answer`.',
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` (the number or the text).'
12
+ : 'Closed questions come as `say` with their numbered options: print it, then pass the option they pick back as `answer` (the number or the text).',
13
13
  'Sign-up flow:',
14
14
  '1. signup_start (welcome and one question: may I look for your CV here?). If yes, find and read their CV (suggest_register_context, read_cv) without asking again; otherwise wait for the CV in the chat.',
15
15
  '2. LinkedIn: take it from the CV. If it has none, ask the linkedinAsk text. 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
- '4. signup_draft with your draft of their profile (name, role, city, years, stack, languages, work mode, hours, hourly and annual rate in euros: your estimate). It shows the draft and the data notice and asks them to confirm; if they want changes, ask what and call it again.',
18
- '5. Once confirmed, show the window text it returns, then signup_create_account (windowAnnounced:true) with LinkedIn, cvPath, names and userQuery = everything you know about them. Then signup_status (waitSeconds 20) until the account exists, and follow its message (a failed LinkedIn import: tell them once, retry once, then ask for their website or GitHub).',
19
- '6. New account: save_profile_details with the confirmed draft, without asking. Existing account (the talent signed in instead): update_existing_profile, which shows what would change and asks.',
20
- '7. AI usage, a step of its own: ai_usage without consent returns the two disclaimers and the question; nothing is scanned before their yes. On yes it runs in the background: never wait for it. If they skip it, show the text it returns.',
21
- '8. Onboarding interview: skip it if signup_status says it is completed; otherwise onboarding_interview_start without disclaimerAcknowledged (here now or later on the web, default here), then the interview disclaimers it returns, then how they want to answer (drafted, own, full). 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. Never call onboarding_finish in this flow.',
22
- '9. Main role, also when they skip the interview: list_my_roles (waitSeconds 20, again while pending), then set_main_role without clusterId and with evidence = one concrete fact per recommended role from their CV, the interview or the AI-usage analysis.',
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). 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.',
18
+ '5. Once confirmed, signup_create_account with LinkedIn, cvPath, names and userQuery = everything you know about them; the window opens by itself, so say nothing about it. Then signup_status (waitSeconds 20) 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).',
19
+ '6. New account: save_profile_details with the confirmed draft, without asking. Existing account (the talent signed in instead): update_existing_profile, which shows what would fill their empty fields and asks; nothing they already set is overwritten.',
20
+ '7. AI usage, a step of its own: ai_usage without consent asks the question with the two disclaimers; nothing is scanned before their yes. On yes it runs in the background: never wait for it. If they skip it, its fixed line comes with the next question.',
21
+ '8. Onboarding interview: skip it if signup_status says it is completed; otherwise onboarding_interview_start without disclaimerAcknowledged (here now or later on the web, default here), then how they want to answer (drafted, own, full), asked with the interview disclaimers. 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. Never call onboarding_finish in this flow.',
22
+ '9. Main role, also when they skip the interview: list_my_roles (waitSeconds 20, 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). Never set a main role the talent did not pick.',
23
23
  '10. Finish with open_web: it opens Shakers with the talent signed in.',
24
24
  ].join('\n');
25
25
  }
@@ -12,7 +12,7 @@ const INTERVIEW_START_SCHEMA = {
12
12
  language: { type: 'string', enum: ['es', 'en'] },
13
13
  disclaimerAcknowledged: {
14
14
  type: 'boolean',
15
- description: 'Must be true. Set it only after showing the talent both interview disclaimers word for word, each as its own block (from signup_status or from this tool called without it), and getting their agreement.',
15
+ description: 'true only when the talent answered the answer-mode question this tool printed with both interview disclaimers. Call it first without it: it asks here-or-later and then that question.',
16
16
  },
17
17
  where: {
18
18
  type: 'string',
@@ -29,7 +29,6 @@ const INTERVIEW_START_SCHEMA = {
29
29
  description: 'Only needed when the talent has ALREADY completed onboarding. The tool then refuses with an already-completed notice unless you set this true after the talent explicitly confirms they want to repeat the interview.',
30
30
  },
31
31
  },
32
- required: ['disclaimerAcknowledged'],
33
32
  };
34
33
 
35
34
  const INTERVIEW_TURN_SCHEMA = {
@@ -50,6 +49,18 @@ const INTERVIEW_COMPLETE_SCHEMA = {
50
49
  const FINISH_SCHEMA = { type: 'object', properties: {} };
51
50
 
52
51
  const LIVEKIT_LOAD_FAILURES = new Set(['livekit-not-installed', 'livekit-no-text-streams']);
52
+ // A start or a turn that has not answered by then ends here, and the talent does the interview on the web.
53
+ const INTERVIEW_TIMEOUT_MS = 120000;
54
+ const TIMED_OUT = Symbol('timed-out');
55
+ const TIMEOUT_KINDS = new Set(['timeout', 'turn-timeout']);
56
+ // The chat interview runs only in the languages the interview agent speaks; the others do it on the web.
57
+ const CHAT_INTERVIEW_LANGUAGES = new Set(['es', 'en']);
58
+
59
+ function withTimeout(promise, ms) {
60
+ let timer;
61
+ const expiry = new Promise((resolve) => { timer = setTimeout(resolve, ms, TIMED_OUT); });
62
+ return Promise.race([promise, expiry]).finally(() => clearTimeout(timer));
63
+ }
53
64
 
54
65
  // No telemetry ships in this package: stderr reaches the MCP client's server log, with what is needed to size the gap.
55
66
  function defaultReportLivekitFailure(kind, error) {
@@ -80,6 +91,11 @@ function makeRegisterTools(deps = {}) {
80
91
  const interviewSessions = new Map();
81
92
 
82
93
  const reportLivekitFailure = deps.reportLivekitFailure || defaultReportLivekitFailure;
94
+ const interviewTimeoutMs = deps.interviewTimeoutMs || INTERVIEW_TIMEOUT_MS;
95
+
96
+ function onWeb(step, reason) {
97
+ return { ok: false, step, reason, relayVerbatim: copy().livekitUnavailable, message: 'Print relayVerbatim word for word now, then call open_web: the talent does the interview on the web.', next: 'open_web' };
98
+ }
83
99
 
84
100
  async function teardownInterview(interviewId, { persist = false } = {}) {
85
101
  const sess = interviewSessions.get(interviewId);
@@ -111,11 +127,11 @@ function makeRegisterTools(deps = {}) {
111
127
  return { chat: asked.chat, dismissed: asked.dismissed };
112
128
  }
113
129
 
114
- async function askAnswerMode(args, ctx) {
130
+ async function askAnswerMode(args, ctx, texts) {
115
131
  if (ANSWER_MODES.includes(args.answerMode)) return { mode: args.answerMode };
116
132
  const c = copy();
117
133
  const labels = { drafted: c.answerModeDrafted, own: c.answerModeOwn, full: c.answerModeFull };
118
- const asked = await askChoice(ctx, { question: c.answerModeQuestion, options: ANSWER_MODES.map((m) => labels[m]) });
134
+ const asked = await askChoice(ctx, { texts, question: c.answerModeQuestion, options: ANSWER_MODES.map((m) => labels[m]) });
119
135
  if (asked.answer) return { mode: ANSWER_MODES.find((m) => labels[m] === asked.answer) };
120
136
  return { chat: asked.chat, dismissed: asked.dismissed };
121
137
  }
@@ -136,37 +152,55 @@ function makeRegisterTools(deps = {}) {
136
152
  'You have already completed the onboarding interview. Re-running it is optional. If the talent explicitly wants to repeat it, call again with confirmRepeat:true.',
137
153
  };
138
154
  }
139
- if (args.disclaimerAcknowledged !== true) {
155
+ if (!CHAT_INTERVIEW_LANGUAGES.has(textLang())) {
156
+ 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.' };
157
+ }
158
+ const acknowledged = args.disclaimerAcknowledged === true;
159
+ if (!acknowledged) {
140
160
  const where = await askWhere(args, ctx);
141
161
  if (where.here === false) return interviewLater();
142
- const disclaimers = flow.interviewDisclaimers(textLang());
143
- const show = 'show the talent each relayVerbatim text word for word, each as its own block, with no titles, labels or bold of your own, then call again with disclaimerAcknowledged:true.';
144
- return {
145
- ok: false,
146
- reason: 'disclaimers-not-acknowledged',
147
- relayVerbatim: [disclaimers.infoAccessed, disclaimers.goalDuration],
148
- ...(where.chat ? { where: where.chat } : {}),
149
- ...(where.dismissed ? { dismissed: where.dismissed } : {}),
150
- message: where.chat
151
- ? `First ask \`where.question\` with its \`where.options\` (your native choice buttons if you have them, otherwise a short numbered list; accept the number or the text). If they pick the web, call again with where:"later". If they pick here, ${show}`
152
- : `The talent does it here: ${show}`,
153
- };
162
+ if (where.chat) {
163
+ return {
164
+ ok: false,
165
+ reason: 'where-required',
166
+ ...(where.dismissed ? { dismissed: where.dismissed } : {}),
167
+ ...where.chat,
168
+ message: `${where.chat.choiceMessage} Then call onboarding_interview_start again with where:"here" for the first option or where:"later" for the second.`,
169
+ };
170
+ }
154
171
  }
155
- const answer = await askAnswerMode(args, ctx);
172
+ // The two disclaimers travel with the answer-mode question, so they are read before the talent answers it.
173
+ const disclaimers = acknowledged ? [] : Object.values(flow.interviewDisclaimers(textLang()));
174
+ const answer = await askAnswerMode(acknowledged ? args : {}, ctx, disclaimers);
156
175
  if (answer.chat) {
157
176
  return {
158
177
  ok: false,
159
178
  reason: 'answer-mode-required',
160
179
  ...(answer.dismissed ? { dismissed: answer.dismissed } : {}),
161
180
  ...answer.chat,
162
- message: `${answer.chat.choiceMessage} Then call again with disclaimerAcknowledged:true and answerMode: drafted for the first option, own for the second, full for the third.`,
181
+ 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.`,
163
182
  };
164
183
  }
184
+ const room = {};
185
+ const opened = await withTimeout(openRoom(args, room), interviewTimeoutMs);
186
+ if (opened === TIMED_OUT) {
187
+ room.abandoned = true;
188
+ if (room.client) room.client.disconnect().catch(() => {});
189
+ return onWeb('start', 'timeout');
190
+ }
191
+ if (!opened.ok) return opened;
192
+ interviewSessions.set(opened.interviewId, { client: opened.client, buffer: opened.buffer, connectAt: Date.now(), ended: opened.opening.ended === true });
193
+ return { ok: true, interviewId: opened.interviewId, answerMode: answer.mode, greeting: opened.opening.text, ended: opened.opening.ended === true };
194
+ }
195
+
196
+ // Creates the interview, joins its room and reads the greeting; `room` lets a timed-out caller close what opened late.
197
+ async function openRoom(args, room) {
165
198
  const start = await flow.createAndStartLivekitOnboarding(resolved, {
166
199
  candidateId: args.candidateId,
167
200
  language: args.language || lang,
168
201
  });
169
202
  if (!start.ok) {
203
+ if (TIMEOUT_KINDS.has(start.reason)) return onWeb(start.step || 'start', start.reason);
170
204
  const out = { ok: false, step: start.step || 'start', reason: start.reason };
171
205
  return start.reason === 'interview-already-started' ? { ...out, next: 'open_web' } : out;
172
206
  }
@@ -174,6 +208,7 @@ function makeRegisterTools(deps = {}) {
174
208
  const closing = start.closingMessage;
175
209
  const isClosing = closing ? (t) => typeof t === 'string' && t.includes(closing) : null;
176
210
  const client = resolved.makeInterviewClient({ isClosing });
211
+ room.client = client;
177
212
  const buffer = new TranscriptBuffer();
178
213
 
179
214
  try {
@@ -183,7 +218,7 @@ function makeRegisterTools(deps = {}) {
183
218
  const kind = (e && e.kind) || 'connect-failed';
184
219
  if (LIVEKIT_LOAD_FAILURES.has(kind)) reportLivekitFailure(kind, e);
185
220
  // Whatever kept the room from opening here, the same interview runs on the web.
186
- return { ok: false, step: 'connect', reason: kind, relayVerbatim: copy().livekitUnavailable, message: 'Show relayVerbatim word for word, then open_web.', next: 'open_web' };
221
+ return onWeb('connect', kind);
187
222
  }
188
223
 
189
224
  let opening;
@@ -191,11 +226,14 @@ function makeRegisterTools(deps = {}) {
191
226
  opening = await client.receiveTurn();
192
227
  } catch (e) {
193
228
  try { await client.disconnect(); } catch { /* room already gone */ }
194
- return { ok: false, step: 'greeting', reason: (e && e.kind) || 'no-greeting' };
229
+ return onWeb('greeting', (e && e.kind) || 'no-greeting');
230
+ }
231
+ if (room.abandoned) {
232
+ try { await client.disconnect(); } catch { /* room already gone */ }
233
+ return { ok: false, reason: 'timeout' };
195
234
  }
196
235
  buffer.recordAgent(opening.text);
197
- interviewSessions.set(start.interviewId, { client, buffer, connectAt: Date.now(), ended: opening.ended === true });
198
- return { ok: true, interviewId: start.interviewId, answerMode: answer.mode, greeting: opening.text, ended: opening.ended === true };
236
+ return { ok: true, interviewId: start.interviewId, client, buffer, opening };
199
237
  }
200
238
 
201
239
  async function interviewTurn(args = {}) {
@@ -209,10 +247,21 @@ function makeRegisterTools(deps = {}) {
209
247
  sess.buffer.recordUser(message);
210
248
  let res;
211
249
  try {
212
- res = await sess.client.sendTurn(message);
250
+ res = await withTimeout(sess.client.sendTurn(message), interviewTimeoutMs);
213
251
  } catch (e) {
214
- await teardownInterview(args.interviewId, { persist: true });
215
- return { ok: false, reason: (e && e.kind) || 'turn-error', message: 'The interview connection failed; the transcript so far was saved. Start again with onboarding_interview_start.' };
252
+ res = { failed: (e && e.kind) || 'turn-error' };
253
+ }
254
+ if (res === TIMED_OUT || res.failed) {
255
+ const kind = res === TIMED_OUT ? 'timeout' : res.failed;
256
+ const teardown = await withTimeout(teardownInterview(args.interviewId, { persist: true }), Math.min(interviewTimeoutMs, 15000));
257
+ const saved = teardown !== TIMED_OUT && teardown.ok === true;
258
+ if (TIMEOUT_KINDS.has(kind)) return { ...onWeb('turn', kind), saved };
259
+ return {
260
+ ok: false,
261
+ reason: kind,
262
+ saved,
263
+ message: `The interview connection failed; ${saved ? 'the transcript so far was saved' : 'the transcript so far could not be saved'}. Start again with onboarding_interview_start.`,
264
+ };
216
265
  }
217
266
  sess.buffer.recordAgent(res.text);
218
267
  if (res.ended === true) sess.ended = true;
@@ -265,7 +314,16 @@ function makeRegisterTools(deps = {}) {
265
314
  // Content-free context the model may PROPOSE to the talent (never auto-write).
266
315
  async function suggestContext(args = {}) {
267
316
  const read = deps.readRegisterContextSuggestions || require('./register-context').readRegisterContextSuggestions;
268
- return { ok: true, suggestions: read({ talentName: args.talentName }), note: 'Propose these to the talent to confirm; never write them without confirmation.' };
317
+ const suggestions = read({ talentName: args.talentName });
318
+ const found = suggestions.cvCandidates && suggestions.cvCandidates.length > 0;
319
+ return {
320
+ ok: true,
321
+ suggestions,
322
+ note: 'Propose these to the talent to confirm; never write them without confirmation.',
323
+ message: found
324
+ ? 'The talent already allowed the search: read_cv the first candidate now (the most likely), with talentConsent:true and talentName if you know it. 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
+ : 'No CV was found on this machine: ask the talent to attach it.',
326
+ };
269
327
  }
270
328
 
271
329
  // Text of the CV the talent confirmed as theirs, read only with their permission.
@@ -282,7 +340,7 @@ function makeRegisterTools(deps = {}) {
282
340
  return [
283
341
  {
284
342
  name: 'suggest_register_context',
285
- description: "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 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. Use it when the talent said you may look for their CV on this machine.",
343
+ description: "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. Use it when the talent said you may look for their CV on this machine.",
286
344
  inputSchema: { type: 'object', properties: { talentName: { type: 'string', description: "The talent's full name as you know it (memory, chat); improves CV matching." } } },
287
345
  handler: suggestContext,
288
346
  },
@@ -302,7 +360,7 @@ function makeRegisterTools(deps = {}) {
302
360
  },
303
361
  {
304
362
  name: 'onboarding_interview_start',
305
- description: "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 `where` with the question and its options to ask; default here) and returns the two interview disclaimers. reason 'interview-later' means they chose the web: skip the interview. 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. You MUST show the talent both interview disclaimers word for word, each as its own block, never paraphrased or relabelled, and only then call with disclaimerAcknowledged:true and answerMode (without answerMode it asks the answer-mode question in a dialog, or returns it with its options: ask it and call again). 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', relay `message` and call open_web so they do the interview on the web.",
363
+ description: "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.",
306
364
  inputSchema: INTERVIEW_START_SCHEMA,
307
365
  handler: interviewStart,
308
366
  available: async () => {
@@ -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 } = require('./mcp-choice');
5
+ const { askChoice, askAfterTexts, chatChoice, optionFor, SAY_MESSAGE } = require('./mcp-choice');
6
6
  const { signupCopy, renderRole } = require('./signup-copy');
7
7
  const { signupLanguage } = require('./signup-language');
8
8
 
@@ -15,6 +15,32 @@ const MAX_WAIT_SECONDS = 25;
15
15
  const PENDING_FINALIZATION = new Set(['COLLECTING', 'PROCESSING']);
16
16
  // Alma's signal from the interview first, then the CV import, as the web stacks them.
17
17
  const SOURCE_ORDER = { ONBOARDING_INTERVIEW: 0, IMPORT: 1 };
18
+ // A catalogue this short is offered whole; a longer one only by the roles that match what the talent says.
19
+ const MAX_ROLE_OPTIONS = 5;
20
+
21
+ const fold = (text) => String(text || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
22
+
23
+ const wordsOf = (text) => fold(text).split(/[^a-z0-9+#.]+/).filter((w) => w.length >= 2);
24
+
25
+ // Catalogue roles by relevance: the exact name first, then shared role words, then stack words; a word many roles share (engineer) counts less.
26
+ function rankRoles(roles, said, skills) {
27
+ const names = roles.map((r) => wordsOf(r.name));
28
+ const df = new Map();
29
+ for (const words of names) for (const w of new Set(words)) df.set(w, (df.get(w) || 0) + 1);
30
+ const weight = (words) => words.reduce((sum, w) => sum + 1 / df.get(w), 0);
31
+ const asked = new Set(wordsOf(said));
32
+ const stack = new Set(wordsOf(skills));
33
+ const exact = fold(said).trim();
34
+ const shown = (role, words) => words.map((w) => role.name.split(/[^A-Za-z0-9+#.]+/).find((t) => fold(t) === w) || w);
35
+ return roles
36
+ .map((role, i) => {
37
+ const byRole = names[i].filter((w) => asked.has(w));
38
+ const byStack = names[i].filter((w) => stack.has(w) && !asked.has(w));
39
+ const score = (exact && fold(role.name) === exact ? 100 : 0) + weight(byRole) + weight(byStack) / 2;
40
+ return { role, score, byRole: shown(role, byRole), byStack: shown(role, byStack) };
41
+ })
42
+ .sort((a, b) => b.score - a.score);
43
+ }
18
44
 
19
45
  const LIST_MY_ROLES_SCHEMA = {
20
46
  type: 'object',
@@ -34,12 +60,12 @@ const CLUSTER_SCHEMA = {
34
60
  const SET_MAIN_ROLE_SCHEMA = {
35
61
  type: 'object',
36
62
  properties: {
63
+ skills: { type: 'string', description: 'With nothing recommended: the talent\'s main technologies from the CV or draft, comma separated. They rank the catalogue roles offered.' },
37
64
  evidence: {
38
65
  type: 'object',
39
66
  additionalProperties: { type: 'string' },
40
67
  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.',
41
68
  },
42
- shown: { type: 'boolean', description: 'true only after you showed relayVerbatim from the previous call: it then asks the question in a dialog.' },
43
69
  answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
44
70
  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), with the catalogue as the last option.' },
45
71
  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.' },
@@ -155,9 +181,12 @@ function makeRoleTools(deps = {}) {
155
181
  else if (read.recommended.length) {
156
182
  next = 'set_main_role';
157
183
  message = 'Call set_main_role without clusterId: it asks the talent which recommended role is their main one, in a dialog or with options it returns for you to show.';
184
+ } else if (read.mainClusterId) {
185
+ next = 'open_web';
186
+ message = 'The talent already has a main role and nothing new was recommended: do not ask it again, go on to open_web. Only if the talent asks to change it, call set_main_role with catalogue:true.';
158
187
  } else {
159
188
  next = 'set_main_role';
160
- message = 'Nothing was recommended: call set_main_role without clusterId, it offers the catalogue (list_available_roles), then adds (add_role) and sets the role the talent picks.';
189
+ message = 'Nothing was recommended: call set_main_role without clusterId, with answer = the role their CV or draft names and skills = their main technologies: it offers the closest catalogue roles for the talent to pick (or asks their role), then adds and sets only the one they pick.';
161
190
  }
162
191
  return { ok: true, ...read, pending, ...(next ? { next } : {}), message };
163
192
  }
@@ -187,7 +216,11 @@ function makeRoleTools(deps = {}) {
187
216
 
188
217
  // The main-role step: why each recommended role fits and how many projects want it, then the question; the catalogue is the last option.
189
218
  async function chooseMainRole(session, args, ctx) {
190
- const recommended = byName((await readRoles(session.hubAccessToken)).recommended);
219
+ const read = await readRoles(session.hubAccessToken);
220
+ const recommended = byName(read.recommended);
221
+ if (!recommended.length && read.mainClusterId) {
222
+ 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.' } };
223
+ }
191
224
  if (!recommended.length) return chooseFromCatalogue(args, ctx);
192
225
  const c = copy();
193
226
  const counts = await openCounts(session.hubAccessToken, recommended);
@@ -198,7 +231,6 @@ function makeRoleTools(deps = {}) {
198
231
  texts: [[c.rolesIntro, ...blocks, c.rolesOutro].join('\n\n')],
199
232
  question: c.mainRoleQuestion,
200
233
  options: [...recommended.map((r) => r.name), c.mainRoleOther],
201
- shown: args.shown,
202
234
  answer: args.answer,
203
235
  });
204
236
  if (asked.reply) {
@@ -208,16 +240,52 @@ function makeRoleTools(deps = {}) {
208
240
  return { role: recommended.find((r) => r.name === asked.answer) };
209
241
  }
210
242
 
243
+ // The catalogue roles last offered: an answer naming one of them is the talent's pick, any other text is only a query.
244
+ let offered = null;
245
+
246
+ function askOwnRole() {
247
+ offered = null;
248
+ return {
249
+ reply: {
250
+ ok: false,
251
+ reason: 'main-role-ask',
252
+ say: copy().mainRoleAsk,
253
+ message: `${SAY_MESSAGE} Then call set_main_role with catalogue:true and answer = what they say: it offers the closest catalogue roles.`,
254
+ },
255
+ };
256
+ }
257
+
211
258
  async function chooseFromCatalogue(args, ctx) {
259
+ const c = copy();
260
+ const said = typeof args.answer === 'string' ? args.answer.trim() : '';
261
+ if (offered) {
262
+ const picked = optionFor(said, [...offered.map((r) => r.name), c.mainRoleNotListed]);
263
+ if (picked === c.mainRoleNotListed) return askOwnRole();
264
+ const role = picked && offered.find((r) => r.name === picked);
265
+ if (role) { offered = null; return { role, add: true }; }
266
+ }
212
267
  const available = await listAvailableRoles();
213
268
  if (!available.ok) return { reply: { ok: false, reason: available.reason } };
214
269
  const roles = byName(available.roles);
215
270
  if (!roles.length) return { reply: { ok: false, reason: 'no-roles' } };
216
- const typed = roles.find((r) => typeof args.answer === 'string' && r.name === args.answer.trim());
217
- if (typed) return { role: typed, add: true };
218
- const asked = await askChoice(ctx, { question: copy().mainRoleQuestion, options: roles.map((r) => r.name) });
219
- if (!asked.answer) return { reply: roleChoice(asked, roles, 'Pass catalogue:true too: it adds the role first.') };
220
- return { role: roles.find((r) => r.name === asked.answer), add: true };
271
+ const ranked = rankRoles(roles, said, args.skills);
272
+ const shown = roles.length <= MAX_ROLE_OPTIONS ? ranked : ranked.filter((m) => m.score > 0).slice(0, MAX_ROLE_OPTIONS);
273
+ if (!shown.length) return askOwnRole();
274
+ // This call's own list: another call may replace `offered` while the dialog is open.
275
+ const options = shown.map((m) => m.role);
276
+ offered = options;
277
+ const reasons = shown
278
+ .filter((m) => m.byRole.length || m.byStack.length)
279
+ .map((m) => [c.roleName(m.role.name), m.byRole.length ? c.catalogueReason.role(m.byRole.join(', ')) : null, m.byStack.length ? c.catalogueReason.stack(m.byStack.join(', ')) : null].filter(Boolean).join('\n'));
280
+ const asked = await askChoice(ctx, { texts: reasons.length ? [reasons.join('\n\n')] : [], question: c.mainRoleQuestion, options: [...options.map((r) => r.name), c.mainRoleNotListed] });
281
+ if (asked.answer === c.mainRoleNotListed) return askOwnRole();
282
+ const role = asked.answer && options.find((r) => r.name === asked.answer);
283
+ if (role) {
284
+ offered = null;
285
+ return { role, add: true };
286
+ }
287
+ offered = options;
288
+ return { reply: roleChoice(asked.chat ? asked : { chat: chatChoice(c.mainRoleQuestion, [...options.map((r) => r.name), c.mainRoleNotListed]) }, options, `Pass catalogue:true too: it adds the role first. If they pick the last option, call set_main_role with catalogue:true and answer = that option.`) };
221
289
  }
222
290
 
223
291
  async function setMainRole(args = {}, ctx = {}) {
@@ -263,7 +331,7 @@ function makeRoleTools(deps = {}) {
263
331
  },
264
332
  {
265
333
  name: 'set_main_role',
266
- description: "Set the talent's MAIN role. In the sign-up it is the last step before open_web: after onboarding_interview_complete (or when the talent skipped the interview) and once list_my_roles is no longer pending, call it without clusterId: it asks the talent which of the recommended roles is their main one, with the catalogue as the last option (or straight from the catalogue when nothing is recommended), in a dialog when the client has one; otherwise it returns the question, its options and the roles to call it again with the clusterId they pick. Then it sets that role (adding a catalogue role first). Also usable standalone with clusterId to change the main role later; catalogue:true adds a catalogue role first. Requires an active session.",
334
+ description: "Set the talent's MAIN role. In the sign-up it is the last step before open_web: after onboarding_interview_complete (or when the talent skipped the interview) and once list_my_roles is no longer pending, call it without clusterId: it asks the talent which of the recommended roles is their main one, with the catalogue as the last option (or straight from the catalogue when nothing is recommended), in a dialog when the client has one; otherwise it returns `say` to print word for word and the roles, to call it again with the clusterId they pick. Then it sets that role (adding a catalogue role first). Also usable standalone with clusterId to change the main role later; catalogue:true adds a catalogue role first. Requires an active session.",
267
335
  inputSchema: SET_MAIN_ROLE_SCHEMA,
268
336
  handler: setMainRole,
269
337
  },