shakers-cli-beta 0.10.3 → 0.11.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.
@@ -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
  },