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.
- package/bin/shakers.js +25 -6
- package/bin/update-check-worker.js +70 -0
- package/bin/update.js +111 -0
- package/package.json +1 -1
- package/src/certify-dimension-client.js +79 -0
- package/src/certify-dimension-flow.js +104 -20
- package/src/command-audit.js +105 -0
- package/src/config.js +9 -0
- package/src/i18n.js +166 -32
- package/src/mcp-ai-usage-tool.js +13 -32
- package/src/mcp-certify-dimension-tools.js +7 -1
- package/src/mcp-choice.js +41 -19
- package/src/mcp-install.js +2 -1
- package/src/mcp-instructions.js +9 -9
- package/src/mcp-register-tools.js +87 -29
- package/src/mcp-role-tools.js +79 -11
- package/src/mcp-signup-tools.js +187 -59
- package/src/register-context.js +3 -1
- package/src/signup-copy.js +49 -17
- package/src/update-flow.js +223 -0
- package/src/update-notifier.js +113 -0
|
@@ -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: '
|
|
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 (
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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 `
|
|
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 () => {
|
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 } = 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 (
|
|
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
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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
|
},
|