shakers-cli-beta 0.8.0-beta.4 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -11
- package/bin/ai-usage.js +5 -0
- package/bin/login.js +1 -1
- package/bin/mcp.js +25 -12
- package/package.json +4 -2
- package/src/authorship.js +7 -6
- package/src/certify-dimension-client.js +0 -54
- package/src/certify-dimension-flow.js +20 -81
- package/src/config.js +0 -8
- package/src/cv-text.js +48 -13
- package/src/git-activity.js +8 -7
- package/src/git-log-prefetch.js +53 -0
- package/src/i18n.js +10 -36
- package/src/mcp-auth-form.js +35 -21
- package/src/mcp-certify-dimension-tools.js +2 -2
- package/src/mcp-onboarding-tools.js +1 -1
- package/src/mcp-prompts.js +71 -0
- package/src/mcp-register-tools.js +16 -11
- package/src/mcp-server.js +38 -2
- package/src/register-context.js +44 -13
- package/src/scanner.js +13 -1
- package/src/session-normalizers.js +174 -0
- package/src/session-scan.js +149 -10
- package/src/work-streams.js +7 -2
package/src/i18n.js
CHANGED
|
@@ -702,11 +702,11 @@ const catalogs = {
|
|
|
702
702
|
// language the Talent may not read. Vocabulario CONTEXT: Talent, Skill.
|
|
703
703
|
certifyDimension: {
|
|
704
704
|
help:
|
|
705
|
-
'certify
|
|
705
|
+
'certify - certifica una DIMENSIÓN de tu rol. La entrevista se hace en la web.\n\n'
|
|
706
706
|
+ 'Uso:\n'
|
|
707
707
|
+ ' certify [--lang es|en] [--dimension <clave|slug|n>]\n\n'
|
|
708
708
|
+ 'Requiere una sesión activa (ejecuta antes: shakers login).\n'
|
|
709
|
-
+ '
|
|
709
|
+
+ 'Eliges la dimensión aquí y te damos el enlace para certificarla en la web.\n',
|
|
710
710
|
title: 'Certificar una dimensión',
|
|
711
711
|
loginRequired: '"certify" requiere una sesión activa.\n Falta: iniciar sesión. Ejecuta primero: shakers login',
|
|
712
712
|
discovering: 'Buscando las dimensiones que puedes certificar…',
|
|
@@ -722,22 +722,9 @@ const catalogs = {
|
|
|
722
722
|
stateExpired: 'caducada',
|
|
723
723
|
dimensionInvalid: 'Esa dimensión no está entre las certificables ahora mismo.',
|
|
724
724
|
dimensionUsing: (slug) => `Dimensión elegida: ${slug}`,
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
? `Ya hiciste esta dimensión hace poco. Podrás repetirla a partir del ${day}.`
|
|
729
|
-
: 'Ya hiciste esta dimensión hace poco y todavía no se puede repetir.'),
|
|
730
|
-
needsWeb: 'Esta dimensión lleva un ejercicio que por ahora solo se hace en la web, no por terminal.',
|
|
731
|
-
interviewUnavailable: (reason) => `La entrevista no pudo completarse (${reason}).`,
|
|
732
|
-
reportPolling: 'Calculando tu veredicto…',
|
|
733
|
-
reportTimeout: 'El veredicto todavía se está calculando. Consúltalo en tu perfil en unos minutos.',
|
|
734
|
-
reportFailed: (reason) => `No se pudo obtener el veredicto (${reason}). Consúltalo en tu perfil.`,
|
|
735
|
-
verdictTitle: 'Veredicto',
|
|
736
|
-
verdictDimension: (dim) => `Dimensión: ${dim}`,
|
|
737
|
-
verdictBand: (band) => `Nivel: ${band}`,
|
|
738
|
-
verdictCertified: 'Resultado: certificada',
|
|
739
|
-
verdictNotCertified: 'Resultado: no certificada esta vez',
|
|
740
|
-
done: 'Certificación completada.',
|
|
725
|
+
webHandoff: 'La entrevista de certificación se hace en la web.',
|
|
726
|
+
webHandoffLink: (url) => `Ábrela aquí para certificar esta dimensión:\n ${url}`,
|
|
727
|
+
webHandoffNoLink: 'Entra en tu perfil de Shakers en la web y ve a Certificaciones para hacerla.',
|
|
741
728
|
},
|
|
742
729
|
findProjects: {
|
|
743
730
|
help:
|
|
@@ -2310,11 +2297,11 @@ const catalogs = {
|
|
|
2310
2297
|
// English mirror of the `certify` catalog. Same content/invariants.
|
|
2311
2298
|
certifyDimension: {
|
|
2312
2299
|
help:
|
|
2313
|
-
'certify
|
|
2300
|
+
'certify - certify a DIMENSION of your role. The interview is done on the web.\n\n'
|
|
2314
2301
|
+ 'Usage:\n'
|
|
2315
2302
|
+ ' certify [--lang es|en] [--dimension <key|slug|n>]\n\n'
|
|
2316
2303
|
+ 'Requires an active session (run `shakers login` first).\n'
|
|
2317
|
-
+ '
|
|
2304
|
+
+ 'You pick the dimension here and we hand you the link to certify it on the web.\n',
|
|
2318
2305
|
title: 'Certify a dimension',
|
|
2319
2306
|
loginRequired: '"certify" requires an active session.\n Missing: sign-in. Run `shakers login` first.',
|
|
2320
2307
|
discovering: 'Looking up the dimensions you can certify…',
|
|
@@ -2330,22 +2317,9 @@ const catalogs = {
|
|
|
2330
2317
|
stateExpired: 'expired',
|
|
2331
2318
|
dimensionInvalid: 'That dimension is not among the certifiable ones right now.',
|
|
2332
2319
|
dimensionUsing: (slug) => `Chosen dimension: ${slug}`,
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
? `You took this dimension recently. You can retake it from ${day}.`
|
|
2337
|
-
: 'You took this dimension recently and cannot retake it yet.'),
|
|
2338
|
-
needsWeb: 'This dimension has an exercise that for now is only taken on the web, not in the terminal.',
|
|
2339
|
-
interviewUnavailable: (reason) => `The interview could not be completed (${reason}).`,
|
|
2340
|
-
reportPolling: 'Computing your verdict…',
|
|
2341
|
-
reportTimeout: 'The verdict is still being computed. Check your profile in a few minutes.',
|
|
2342
|
-
reportFailed: (reason) => `Could not fetch the verdict (${reason}). Check your profile.`,
|
|
2343
|
-
verdictTitle: 'Verdict',
|
|
2344
|
-
verdictDimension: (dim) => `Dimension: ${dim}`,
|
|
2345
|
-
verdictBand: (band) => `Level: ${band}`,
|
|
2346
|
-
verdictCertified: 'Result: certified',
|
|
2347
|
-
verdictNotCertified: 'Result: not certified this time',
|
|
2348
|
-
done: 'Certification complete.',
|
|
2320
|
+
webHandoff: 'The certification interview is done on the web.',
|
|
2321
|
+
webHandoffLink: (url) => `Open it here to certify this dimension:\n ${url}`,
|
|
2322
|
+
webHandoffNoLink: 'Open your Shakers profile on the web and go to Certifications to take it.',
|
|
2349
2323
|
},
|
|
2350
2324
|
findProjects: {
|
|
2351
2325
|
help:
|
package/src/mcp-auth-form.js
CHANGED
|
@@ -18,24 +18,26 @@ const FONT_STACK = '"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSy
|
|
|
18
18
|
|
|
19
19
|
const COPY = {
|
|
20
20
|
en: {
|
|
21
|
-
signin: 'Sign in', create: 'Create account', welcome: 'Welcome to Shakers
|
|
21
|
+
signin: 'Sign in', create: 'Create account', welcome: 'Welcome to Shakers. Create your talent account to get started.',
|
|
22
22
|
email: 'Email', password: 'Password', repeat: 'Repeat password', first: 'First name', last: 'Last name',
|
|
23
23
|
lang: 'Language', newsletter: 'Receive the newsletter', how: 'How do you work?', intent: 'Freelance intent',
|
|
24
|
-
hint: 'Your password stays on this
|
|
25
|
-
done: 'You are signed in.', doneSub: 'You can close this tab and
|
|
24
|
+
hint: 'Your password stays on this computer. We never send it to the chat.', mismatch: 'The passwords do not match.',
|
|
25
|
+
done: 'You are signed in.', doneSub: 'You can close this tab and go back to your AI app.',
|
|
26
26
|
chooseTitleLogin: 'How do you want to sign in?', chooseTitleRegister: 'How do you want to create your account?',
|
|
27
|
-
continueGoogle: 'Continue with Google', continueEmail: 'Email and password', continueEmailRegister: 'Continue with email',
|
|
28
|
-
|
|
27
|
+
continueGoogle: 'Continue with Google', continueLinkedin: 'Continue with LinkedIn', continueEmail: 'Email and password', continueEmailRegister: 'Continue with email',
|
|
28
|
+
socialOpening: (name) => `Opening sign-in with ${name}…`,
|
|
29
|
+
socialOpeningSub: 'A sign-in window just opened. Finish there and come back to your AI app, it will carry on.',
|
|
29
30
|
},
|
|
30
31
|
es: {
|
|
31
|
-
signin: 'Iniciar sesión', create: 'Crear cuenta', welcome: 'Bienvenido a Shakers
|
|
32
|
+
signin: 'Iniciar sesión', create: 'Crear cuenta', welcome: 'Bienvenido a Shakers. Crea tu cuenta de talento para empezar.',
|
|
32
33
|
email: 'Email', password: 'Contraseña', repeat: 'Repite la contraseña', first: 'Nombre', last: 'Apellidos',
|
|
33
34
|
lang: 'Idioma', newsletter: 'Recibir la newsletter', how: '¿Cómo trabajas?', intent: 'Intención freelance',
|
|
34
|
-
hint: 'Tu contraseña se queda en este equipo. Nunca
|
|
35
|
+
hint: 'Tu contraseña se queda en este equipo. Nunca la enviamos al chat.', mismatch: 'Las contraseñas no coinciden.',
|
|
35
36
|
done: 'Sesión iniciada.', doneSub: 'Puedes cerrar esta pestaña y volver a tu app de IA.',
|
|
36
37
|
chooseTitleLogin: '¿Cómo quieres iniciar sesión?', chooseTitleRegister: '¿Cómo quieres crear tu cuenta?',
|
|
37
|
-
continueGoogle: 'Continuar con Google', continueEmail: 'Email y contraseña', continueEmailRegister: 'Continuar con email',
|
|
38
|
-
|
|
38
|
+
continueGoogle: 'Continuar con Google', continueLinkedin: 'Continuar con LinkedIn', continueEmail: 'Email y contraseña', continueEmailRegister: 'Continuar con email',
|
|
39
|
+
socialOpening: (name) => `Abriendo el acceso con ${name}…`,
|
|
40
|
+
socialOpeningSub: 'Se acaba de abrir una ventana para terminar el acceso. Cuando acabes, vuelve a tu app de IA y seguirá por su cuenta.',
|
|
39
41
|
},
|
|
40
42
|
};
|
|
41
43
|
|
|
@@ -56,7 +58,7 @@ function styles() {
|
|
|
56
58
|
+ `:root{--background:#ffffff;--foreground:#18181b;--primary:#05342c;--primary-foreground:#fafafa;`
|
|
57
59
|
+ `--secondary:#e2f2f0;--secondary-foreground:#0b5a4c;--muted-foreground:#3f3f46;--accent:#e2f2f0;--accent-foreground:#08473c;`
|
|
58
60
|
+ `--input:#d4d4d8;--border:#e4e4e7;--ring:#0e7d69;--destructive:#e11d48;--radius:0.625rem;--radius-md:calc(var(--radius) - 2px);`
|
|
59
|
-
+ `--shadow-xs:0 1px
|
|
61
|
+
+ `--shadow-xs:0 1px 3px 0px rgb(0 0 0 / .05)}`
|
|
60
62
|
+ `*{box-sizing:border-box}`
|
|
61
63
|
+ `body{font-family:${FONT_STACK};margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#fafafa;color:var(--foreground);padding:1.5rem;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}`
|
|
62
64
|
+ `.card{width:100%;max-width:26rem;background:var(--background);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 10px 30px rgb(3 33 28 / .08);overflow:hidden}`
|
|
@@ -104,7 +106,7 @@ function errorBanner(reason) {
|
|
|
104
106
|
|
|
105
107
|
function loginFormHtml(lang, { error } = {}) {
|
|
106
108
|
const c = copy(lang);
|
|
107
|
-
return page(`Shakers
|
|
109
|
+
return page(`Shakers · ${c.signin}`, {
|
|
108
110
|
inner: `<h1>${c.signin}</h1>${errorBanner(error)}`
|
|
109
111
|
+ `<form method="post" action="/submit">`
|
|
110
112
|
+ `<label>${c.email}</label><input name="email" type="email" required autofocus>`
|
|
@@ -117,7 +119,7 @@ function loginFormHtml(lang, { error } = {}) {
|
|
|
117
119
|
|
|
118
120
|
function registerFormHtml(lang, { error } = {}) {
|
|
119
121
|
const c = copy(lang);
|
|
120
|
-
return page(`Shakers
|
|
122
|
+
return page(`Shakers · ${c.create}`, {
|
|
121
123
|
banner: panel(c.welcome),
|
|
122
124
|
inner: `<h1>${c.create}</h1>${errorBanner(error)}`
|
|
123
125
|
+ `<form method="post" action="/submit">`
|
|
@@ -140,22 +142,28 @@ const GOOGLE_G = '<svg class="g" viewBox="0 0 48 48" aria-hidden="true">'
|
|
|
140
142
|
+ '<path fill="#FBBC05" d="M10.4 28.3c-.5-1.5-.8-3.1-.8-4.8s.3-3.3.8-4.8l-7.8-6.1C.9 15.9 0 19.8 0 23.5s.9 7.6 2.6 10.9l7.8-6.1z"/>'
|
|
141
143
|
+ '<path fill="#34A853" d="M24 47c6.5 0 11.9-2.1 15.9-5.8l-7.3-5.7c-2 1.4-4.7 2.3-8.6 2.3-6.4 0-11.8-3.8-13.6-9.8l-7.8 6.1C6.5 41.6 14.6 47 24 47z"/></svg>';
|
|
142
144
|
|
|
145
|
+
const LINKEDIN_IN = '<svg class="g" viewBox="0 0 24 24" aria-hidden="true">'
|
|
146
|
+
+ '<path fill="#0A66C2" d="M20.45 20.45h-3.56v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.26 2.37 4.26 5.45v6.29zM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.73v20.54C0 23.22.79 24 1.77 24h20.45c.98 0 1.78-.78 1.78-1.73V1.73C24 .77 23.2 0 22.22 0z"/></svg>';
|
|
147
|
+
|
|
143
148
|
// The method-choice screen (talents-ai-score) — the FIRST screen of the loopback form when more than one method is offered for this flow.
|
|
144
149
|
function methodChoiceHtml(mode, lang, methods) {
|
|
145
150
|
const c = copy(lang);
|
|
146
151
|
const title = mode === 'register' ? c.chooseTitleRegister : c.chooseTitleLogin;
|
|
147
152
|
const emailLabel = mode === 'register' ? c.continueEmailRegister : c.continueEmail;
|
|
148
|
-
const buttons = methods.map((id) =>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
const buttons = methods.map((id) => {
|
|
154
|
+
if (id === 'google') return `<a class="mbtn primary" href="/google">${GOOGLE_G}${c.continueGoogle}</a>`;
|
|
155
|
+
if (id === 'linkedin') return `<a class="mbtn secondary" href="/linkedin">${LINKEDIN_IN}${c.continueLinkedin}</a>`;
|
|
156
|
+
return `<a class="mbtn secondary" href="/email">${emailLabel}</a>`;
|
|
157
|
+
}).join('');
|
|
158
|
+
return page(`Shakers · ${title}`, {
|
|
152
159
|
inner: `<h1>${title}</h1><div class="actions">${buttons}</div><p class="hint">${c.hint}</p>`,
|
|
153
160
|
});
|
|
154
161
|
}
|
|
155
162
|
|
|
156
|
-
function
|
|
163
|
+
function socialHandoffHtml(lang, provider) {
|
|
157
164
|
const c = copy(lang);
|
|
158
|
-
|
|
165
|
+
const name = provider === 'linkedin' ? 'LinkedIn' : 'Google';
|
|
166
|
+
return page('Shakers', { inner: `<h1>${c.socialOpening(name)}</h1><p>${c.socialOpeningSub}</p>` });
|
|
159
167
|
}
|
|
160
168
|
|
|
161
169
|
function successHtml(lang) {
|
|
@@ -247,8 +255,14 @@ function runLoopbackAuth(
|
|
|
247
255
|
}
|
|
248
256
|
if (req.method === 'GET' && url.pathname === '/google' && methods.includes('google')) {
|
|
249
257
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
250
|
-
res.end(
|
|
251
|
-
finish({ ok: true,
|
|
258
|
+
res.end(socialHandoffHtml(lang, 'google'));
|
|
259
|
+
finish({ ok: true, viaSocial: true, provider: 'google' });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (req.method === 'GET' && url.pathname === '/linkedin' && methods.includes('linkedin')) {
|
|
263
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
264
|
+
res.end(socialHandoffHtml(lang, 'linkedin'));
|
|
265
|
+
finish({ ok: true, viaSocial: true, provider: 'linkedin' });
|
|
252
266
|
return;
|
|
253
267
|
}
|
|
254
268
|
if (req.method === 'POST' && url.pathname === '/submit') {
|
|
@@ -279,4 +293,4 @@ function runLoopbackAuth(
|
|
|
279
293
|
});
|
|
280
294
|
}
|
|
281
295
|
|
|
282
|
-
module.exports = { runLoopbackAuth, doAuth, loginFormHtml, registerFormHtml, methodChoiceHtml,
|
|
296
|
+
module.exports = { runLoopbackAuth, doAuth, loginFormHtml, registerFormHtml, methodChoiceHtml, socialHandoffHtml, DEFAULT_TIMEOUT_MS };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// MCP: list the offerable dimensions (the interview itself
|
|
3
|
+
// MCP: list the offerable dimensions (the certification interview itself is done on the web).
|
|
4
4
|
|
|
5
5
|
const LIST_CERTIFIABLE_DIMENSIONS_SCHEMA = {
|
|
6
6
|
type: 'object',
|
|
@@ -45,7 +45,7 @@ function makeCertifyDimensionTools(deps = {}) {
|
|
|
45
45
|
return [
|
|
46
46
|
{
|
|
47
47
|
name: 'list_certifiable_dimensions',
|
|
48
|
-
description: "List the dimensions the talent can certify
|
|
48
|
+
description: "List the dimensions the talent can certify right now: their main role's hub dimensions whose interview runs by text (terminal or spoken-case templates) and not already CERTIFIED (UNCERTIFIED/EXPIRED). Requires an active session. The certification interview itself is done on the web; running `shakers certify` in the terminal hands the talent the web link to take it. The dimensionKey/clusterId in each item are INTERNAL handles for tools only — never show or mention them to the talent; refer to a dimension by its name.",
|
|
49
49
|
inputSchema: LIST_CERTIFIABLE_DIMENSIONS_SCHEMA,
|
|
50
50
|
handler: listCertifiableDimensions,
|
|
51
51
|
},
|
|
@@ -131,7 +131,7 @@ function makeOnboardingTools(deps = {}) {
|
|
|
131
131
|
// No explicit `method`: the loopback form opens on its email/social choice screen.
|
|
132
132
|
async function loginViaForm({ method = null } = {}) {
|
|
133
133
|
const result = await runLoopbackAuth(method ? { mode: 'login', method } : { mode: 'login' });
|
|
134
|
-
if (result.
|
|
134
|
+
if (result.viaSocial) return socialSigninPointer('login', result.provider || 'google');
|
|
135
135
|
if (!result.ok) return { ok: false, reason: result.reason || 'login-failed' };
|
|
136
136
|
return { ok: true, method: 'email', email: result.email || null };
|
|
137
137
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MCP prompts = in-client slash-commands. Each is a thin wrapper that injects a user turn
|
|
4
|
+
// telling the assistant which Shakers tool(s) to use — the logic stays in the tools, so
|
|
5
|
+
// there is no duplication. `buildMessages(args)` returns the prompts/get message list.
|
|
6
|
+
|
|
7
|
+
function userText(text) {
|
|
8
|
+
return [{ role: 'user', content: { type: 'text', text } }];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Only reference tools that exist in the FULL server; `available(toolNames)` hides a prompt
|
|
12
|
+
// when its tool isn't registered (e.g. the --local scan-only server).
|
|
13
|
+
const PROMPTS = [
|
|
14
|
+
{
|
|
15
|
+
name: 'ai_usage',
|
|
16
|
+
description: 'Evaluate and showcase how you work with AI (scans this machine, builds your "My work with AI").',
|
|
17
|
+
tools: ['ai_usage'],
|
|
18
|
+
buildMessages: () => userText(
|
|
19
|
+
'Evaluate how I work with AI for my Shakers profile. First ask me for consent and the email to attribute it to, then call the `ai_usage` tool and show me the resulting report verbatim. Never show internal ids/codes.',
|
|
20
|
+
),
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'onboarding',
|
|
24
|
+
description: 'Register and onboard on Shakers (profile import, pricing, availability, interview).',
|
|
25
|
+
tools: ['register', 'suggest_register_context'],
|
|
26
|
+
buildMessages: () => userText(
|
|
27
|
+
'Help me get set up on Shakers. Use `suggest_register_context` to pre-fill what you can, show me `register_preview`, then `register`, and offer the `onboarding_interview_*` tools. Ask me only for what you cannot infer.',
|
|
28
|
+
),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'find_projects',
|
|
32
|
+
description: 'Find open Shakers projects/positions matched to you.',
|
|
33
|
+
tools: ['find_projects'],
|
|
34
|
+
buildMessages: () => userText(
|
|
35
|
+
'Find open Shakers positions that match me: call `find_projects` and summarize them by title/company (never show internal ids). Offer `save_project` / `show_project` for ones I like.',
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'certify',
|
|
40
|
+
description: 'See which role dimensions you can certify and start a certification.',
|
|
41
|
+
tools: ['list_certifiable_dimensions'],
|
|
42
|
+
buildMessages: () => userText(
|
|
43
|
+
'Help me certify on Shakers: call `list_certifiable_dimensions`, present the dimensions I can certify by name, and guide me through certifying the one I pick.',
|
|
44
|
+
),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'ask',
|
|
48
|
+
description: 'Ask Alma, the Shakers AI assistant, a question.',
|
|
49
|
+
tools: ['ask'],
|
|
50
|
+
arguments: [{ name: 'message', description: 'What to ask Alma.', required: false }],
|
|
51
|
+
buildMessages: (args = {}) => userText(
|
|
52
|
+
`Ask Alma (the Shakers assistant) and relay her answer as-is. My question: ${typeof args.message === 'string' && args.message ? args.message : '(ask me what I want to know first)'}`,
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'my_profile',
|
|
57
|
+
description: 'Show your Shakers profile (role, rate, availability, "My work with AI").',
|
|
58
|
+
tools: ['get_profile'],
|
|
59
|
+
buildMessages: () => userText(
|
|
60
|
+
'Show my Shakers profile: call `get_profile` (and `get_rate` / `get_availability` if useful) and present it in plain terms. Any ids/codes are internal — never show them.',
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function makePrompts(toolNames = null) {
|
|
66
|
+
const available = toolNames instanceof Set ? toolNames : null;
|
|
67
|
+
// Keep a prompt only if at least one of its tools is present in this server.
|
|
68
|
+
return PROMPTS.filter((p) => !available || p.tools.some((t) => available.has(t)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { makePrompts, PROMPTS };
|
|
@@ -96,7 +96,7 @@ function buildSchemas(lang) {
|
|
|
96
96
|
// collection guide with friendly labels inline for every choice field.
|
|
97
97
|
function buildRegisterDescription(lang) {
|
|
98
98
|
const L = getCatalog(lang).onboarding;
|
|
99
|
-
return "The talent registers THEMSELVES on Shakers (self-service; no prior session needed). Your job is to relay the non-secret profile details the talent gives you in the chat as arguments. Credentials never touch the chat or any tool argument: the tool opens a sign-up window on the talent's own machine (127.0.0.1) where they either Continue with Google (no password at all) or type an email and password directly into that local window.\n\
|
|
99
|
+
return "The talent registers THEMSELVES on Shakers (self-service; no prior session needed). Your job is to relay the non-secret profile details the talent gives you in the chat as arguments. Credentials never touch the chat or any tool argument: the tool opens a sign-up window on the talent's own machine (127.0.0.1) where they either Continue with Google (no password at all) or type an email and password directly into that local window.\n\nGO STRAIGHT TO A FILLED DRAFT — never open with a questionnaire or a list of what you do not know. (1) Your FIRST reply is one short question: may you search for and read their CV on this machine to fill everything in? (skip it if they already said yes). (2) With their yes: call suggest_register_context with talentName = the name you know, then read_cv the candidates (nameMatch first, then generic ones like cv.pdf) with talentConsent:true and talentName, until one returns mentionsTalent:true; a CV with mentionsTalent:false belongs to someone else: drop it and never show or use anything from it. From their CV take LinkedIn/GitHub (links), experience, education, languages and location; pass paths exactly as given (the short ~/ form is fine). If they say no, there is no CV, or none of the CVs is theirs, do NOT fall back to questions: build the same draft from everything else you know about them — your memory of past conversations, a search of those conversations if you have that tool, this conversation and any context or memory files you can see; the CV adds facts, it is never a precondition. (3) Pre-fill EVERY field: identity from git/your memory/the CV; workSituation from what you know (a company email or employer in memory or CV means employed there — propose it); `pricing` ALWAYS as a concrete per-project estimate from their role, seniority, stack and location, labelled as your estimate (ask only if you know nothing about their work); `workSituation.changeMotivators` as your best match; availability, languages and country from context. What you know about the person outranks git: if the git identity is someone else, ignore it. (4) Call register_preview and show ONE summary that says where each value comes from, for the talent to confirm or correct in one reply. Required fields you truly have no basis for go in one short question at the end; optional unknowns (phone, website, newsletter) in a single 'si quieres' line, never as missing items. (5) After their confirmation, call register. The sign-up goes to suggest_register_context's `environment` (local/development/staging/production): name it correctly and never call a test environment production. All of this is self-declared profile data.\n\nFor whatever you still have to ASK (only what you could not infer), ask in ONE grouped message, not one field at a time, and for every CHOICE field PRESENT THE VALID OPTIONS so the talent just picks one. For every choice field SHOW THE TALENT THE FRIENDLY LABEL, never the raw code (e.g. show 'Nativo', not 'NATIVE'), and pass the code as the value. Fields, grouped:\n1. Identity — name, lastName, email (optional: preferredLanguage, newsletterConsent).\n2. Profile — `profile.linkedinUrl` (required source; optional GitHub/website/CV path).\n3. Work — freelanceType (offer: "
|
|
100
100
|
+ labelledOptions(FREELANCE_TYPES, L.freelanceTypeLabels)
|
|
101
101
|
+ ") and `workSituation` (offer situation: "
|
|
102
102
|
+ labelledOptions(WORK_SITUATIONS, L.workSituationLabels)
|
|
@@ -244,15 +244,17 @@ function makeRegisterTools(deps = {}) {
|
|
|
244
244
|
if (!check.ok) return { ok: false, step: 'signup', reason: check.reason };
|
|
245
245
|
|
|
246
246
|
const created = await runLoopbackAuth({ mode: 'register', deps: resolved, lang, registerFields });
|
|
247
|
-
if (created.
|
|
247
|
+
if (created.viaSocial) {
|
|
248
248
|
// Social registration is the device flow (start/poll tools), not this single blocking call.
|
|
249
|
+
const provider = created.provider || 'google';
|
|
249
250
|
return {
|
|
250
251
|
ok: false,
|
|
251
252
|
step: 'signup',
|
|
252
253
|
reason: 'use-device-flow',
|
|
253
254
|
next: 'social_signin_start',
|
|
254
255
|
mode: 'register',
|
|
255
|
-
|
|
256
|
+
provider,
|
|
257
|
+
message: `To register with ${provider}, call social_signin_start (provider "${provider}", mode "register", freelanceType "${registerFields.freelanceType || ''}") to get a URL and code to show the talent, then social_signin_poll. Once it returns authorized, run the profile steps (import, work situation, pricing).`,
|
|
256
258
|
};
|
|
257
259
|
}
|
|
258
260
|
if (!created.ok) {
|
|
@@ -460,17 +462,19 @@ function makeRegisterTools(deps = {}) {
|
|
|
460
462
|
}
|
|
461
463
|
|
|
462
464
|
// Content-free context the model may PROPOSE to the talent (never auto-write).
|
|
463
|
-
async function suggestContext() {
|
|
465
|
+
async function suggestContext(args = {}) {
|
|
464
466
|
const read = deps.readRegisterContextSuggestions || require('./register-context').readRegisterContextSuggestions;
|
|
465
|
-
return { ok: true, suggestions: read({}), note: 'Propose these to the talent to confirm; never write them without confirmation.' };
|
|
467
|
+
return { ok: true, suggestions: read({ talentName: args.talentName }), note: 'Propose these to the talent to confirm; never write them without confirmation.' };
|
|
466
468
|
}
|
|
467
469
|
|
|
468
470
|
// Text of the CV the talent confirmed as theirs, read only with their permission.
|
|
469
471
|
async function readCv(args = {}) {
|
|
470
472
|
if (args.talentConsent !== true) return { ok: false, reason: 'consent-required', message: 'Ask the talent first whether this CV is theirs and whether you may read it; call again with talentConsent:true only after they say yes.' };
|
|
471
473
|
const read = deps.readCvText || require('./cv-text').readCvText;
|
|
472
|
-
const result = read(args.path);
|
|
474
|
+
const result = read(args.path, { talentName: args.talentName });
|
|
473
475
|
if (!result.ok) return { ...result, message: 'Could not read the CV text here. Ask the talent to attach the CV to the chat, or to tell you what it says.' };
|
|
476
|
+
// Someone else's CV (a candidate they downloaded): never hand its contents over.
|
|
477
|
+
if (result.mentionsTalent === false) return { ok: true, mentionsTalent: false, note: "This CV does not name the talent: it belongs to someone else. Do not use, quote or describe it; try the next candidate." };
|
|
474
478
|
return result;
|
|
475
479
|
}
|
|
476
480
|
|
|
@@ -489,18 +493,19 @@ function makeRegisterTools(deps = {}) {
|
|
|
489
493
|
},
|
|
490
494
|
{
|
|
491
495
|
name: 'suggest_register_context',
|
|
492
|
-
description: "
|
|
493
|
-
inputSchema: { type: 'object', properties: {} },
|
|
496
|
+
description: "Local SUGGESTIONS to fill the registration (no network, no file contents): `environment` the sign-up goes to (local/development/staging/production — name it correctly, never call a test environment production), timezone, git name/email, the GitHub login of the gh CLI (githubUser/githubUrl), and cvCandidates — PDF/DOC/DOCX files in Desktop, Documents, Downloads and iCloud Drive whose NAME looks like a CV or carries the talent's full name, each with nameMatch and generic flags (people who hire keep other people's CVs, so a candidate is only a lead: confirm it with read_cv). Pass talentName = the talent's name as you know it to sharpen the match. Propose values, never write without the talent's confirmation.",
|
|
497
|
+
inputSchema: { type: 'object', properties: { talentName: { type: 'string', description: "The talent's full name as you know it (memory, chat); improves CV matching." } } },
|
|
494
498
|
handler: suggestContext,
|
|
495
499
|
},
|
|
496
500
|
{
|
|
497
501
|
name: 'read_cv',
|
|
498
|
-
description: "Read the TEXT of
|
|
502
|
+
description: "Read the TEXT of a CV file (PDF, DOC, DOCX) on this machine to fill the register and the onboarding from it. Call it once the talent has allowed you to search and read their CV (talentConsent:true), with talentName set. Returns { mentionsTalent, links: { linkedin, github, emails, phones }, text } for the talent's own CV; for a CV that does not name them it returns only mentionsTalent:false — it is someone else's: drop it, never describe it, try the next candidate. Local only: nothing is uploaded by this tool (the CV is sent to Shakers only as profile.cvPath in a confirmed register). On failure ask the talent to attach the CV to the chat.",
|
|
499
503
|
inputSchema: {
|
|
500
504
|
type: 'object',
|
|
501
505
|
properties: {
|
|
502
|
-
path: { type: 'string', description: 'Path to the
|
|
503
|
-
talentConsent: { type: 'boolean', description: 'true ONLY after the talent
|
|
506
|
+
path: { type: 'string', description: 'Path to the CV file, exactly as suggest_register_context gave it (the short ~/ form is fine) or absolute.' },
|
|
507
|
+
talentConsent: { type: 'boolean', description: 'true ONLY after the talent allowed you to search for and read their CV.' },
|
|
508
|
+
talentName: { type: 'string', description: "The talent's full name as you know it; used to tell their CV from other people's." },
|
|
504
509
|
},
|
|
505
510
|
required: ['path', 'talentConsent'],
|
|
506
511
|
},
|
package/src/mcp-server.js
CHANGED
|
@@ -27,7 +27,7 @@ function errorResponse(id, code, message, data) {
|
|
|
27
27
|
return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null } = {}) {
|
|
30
|
+
function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null } = {}) {
|
|
31
31
|
const registry = new Map();
|
|
32
32
|
for (const t of tools) {
|
|
33
33
|
if (!t || !t.name || typeof t.handler !== 'function') {
|
|
@@ -36,6 +36,14 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
36
36
|
registry.set(t.name, t);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const promptRegistry = new Map();
|
|
40
|
+
for (const p of prompts) {
|
|
41
|
+
if (!p || !p.name || typeof p.buildMessages !== 'function') {
|
|
42
|
+
throw new Error('each prompt needs a name and a buildMessages function');
|
|
43
|
+
}
|
|
44
|
+
promptRegistry.set(p.name, p);
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
const advertisedVersion = protocolVersion || DEFAULT_PROTOCOL_VERSION;
|
|
40
48
|
|
|
41
49
|
function shapeTool(t) {
|
|
@@ -50,6 +58,27 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
50
58
|
return { tools: [...registry.values()].map(shapeTool) };
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
function shapePrompt(p) {
|
|
62
|
+
const out = { name: p.name, description: p.description || '' };
|
|
63
|
+
if (Array.isArray(p.arguments)) out.arguments = p.arguments;
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function listPrompts() {
|
|
68
|
+
return { prompts: [...promptRegistry.values()].map(shapePrompt) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getPrompt(params) {
|
|
72
|
+
const name = params && params.name;
|
|
73
|
+
const prompt = name && promptRegistry.get(name);
|
|
74
|
+
if (!prompt) {
|
|
75
|
+
const err = new Error(`unknown prompt: ${name}`);
|
|
76
|
+
err.rpcCode = ERROR.INVALID_PARAMS;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
return { description: prompt.description || '', messages: prompt.buildMessages((params && params.arguments) || {}) };
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
async function listAvailableTools() {
|
|
54
83
|
const out = [];
|
|
55
84
|
for (const t of registry.values()) {
|
|
@@ -112,7 +141,10 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
112
141
|
const requested = msg.params && msg.params.protocolVersion;
|
|
113
142
|
return resultResponse(id, {
|
|
114
143
|
protocolVersion: requested || advertisedVersion,
|
|
115
|
-
capabilities: {
|
|
144
|
+
capabilities: {
|
|
145
|
+
tools: { listChanged: false },
|
|
146
|
+
...(promptRegistry.size ? { prompts: { listChanged: false } } : {}),
|
|
147
|
+
},
|
|
116
148
|
serverInfo: {
|
|
117
149
|
name: serverInfo.name || 'shakers-mcp',
|
|
118
150
|
version: serverInfo.version || '0.0.0',
|
|
@@ -128,6 +160,10 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
128
160
|
return resultResponse(id, await listAvailableTools());
|
|
129
161
|
case 'tools/call':
|
|
130
162
|
return resultResponse(id, await callTool(msg.params));
|
|
163
|
+
case 'prompts/list':
|
|
164
|
+
return resultResponse(id, listPrompts());
|
|
165
|
+
case 'prompts/get':
|
|
166
|
+
return resultResponse(id, getPrompt(msg.params));
|
|
131
167
|
default:
|
|
132
168
|
if (isNotification(msg)) return null;
|
|
133
169
|
return errorResponse(id, ERROR.METHOD_NOT_FOUND, `method not found: ${method}`);
|
package/src/register-context.js
CHANGED
|
@@ -11,10 +11,12 @@ const path = require('path');
|
|
|
11
11
|
const { execFileSync } = require('child_process');
|
|
12
12
|
const { getHomeDir, tildify } = require('./env-paths');
|
|
13
13
|
|
|
14
|
-
// Where a talent keeps a CV, and how deep we look under each folder.
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
14
|
+
// Where a talent keeps a CV, and how deep we look under each folder. iCloud
|
|
15
|
+
// Drive holds Desktop/Documents for many Mac users.
|
|
16
|
+
const CV_DIRS = ['Desktop', 'Documents', 'Downloads', 'Escritorio', 'Documentos', 'Descargas', 'Library/Mobile Documents/com~apple~CloudDocs'];
|
|
17
|
+
const CV_MAX_DEPTH = 3;
|
|
18
|
+
const CV_MAX_RESULTS = 8;
|
|
19
|
+
const CV_EXTENSIONS = /\.(pdf|docx?)$/i;
|
|
18
20
|
const CV_NAME = /(^|[^a-z])(cv|curriculum|curr[ií]culum|resume|r[ée]sum[ée])([^a-z]|$)/i;
|
|
19
21
|
|
|
20
22
|
function detectTimezone() {
|
|
@@ -56,12 +58,22 @@ function detectGithubUser(home, cwd) {
|
|
|
56
58
|
|
|
57
59
|
const fold = (text) => String(text || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
58
60
|
|
|
61
|
+
const nameTokens = (personName) => fold(personName).split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
|
|
62
|
+
|
|
59
63
|
// Whether a file name carries a token of the person's name. People who hire keep
|
|
60
64
|
// other people's CVs in Downloads; only a name match makes a candidate theirs.
|
|
61
65
|
function matchesName(fileName, personName) {
|
|
62
|
-
const tokens = fold(personName).split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
|
|
63
66
|
const file = fold(fileName);
|
|
64
|
-
return
|
|
67
|
+
return nameTokens(personName).some((t) => file.includes(t));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A file named after the person but without a CV word (`Alvaro Torro.pdf`) is a
|
|
71
|
+
// candidate only when it carries their full name, so one common first name does
|
|
72
|
+
// not pull in every contract and invoice.
|
|
73
|
+
function carriesFullName(fileName, personName) {
|
|
74
|
+
const tokens = nameTokens(personName);
|
|
75
|
+
const file = fold(fileName);
|
|
76
|
+
return tokens.length >= 2 && tokens.every((t) => file.includes(t));
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
// Words that say nothing about whose CV it is: `cv.pdf`, `Curriculum_2024_ES.pdf`,
|
|
@@ -73,8 +85,8 @@ function isGenericCvName(fileName) {
|
|
|
73
85
|
return rest.trim().length === 0;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
|
-
//
|
|
77
|
-
// newest. Never opens a file.
|
|
88
|
+
// PDF/Word files whose NAME looks like a CV or carries the person's full name:
|
|
89
|
+
// name matches first, then generic names, then newest. Never opens a file.
|
|
78
90
|
function findCvCandidates(home, personName) {
|
|
79
91
|
const found = [];
|
|
80
92
|
const walk = (dir, depth) => {
|
|
@@ -89,7 +101,8 @@ function findCvCandidates(home, personName) {
|
|
|
89
101
|
const full = path.join(dir, entry.name);
|
|
90
102
|
if (entry.isDirectory()) {
|
|
91
103
|
if (depth < CV_MAX_DEPTH) walk(full, depth + 1);
|
|
92
|
-
} else if (entry.isFile() &&
|
|
104
|
+
} else if (entry.isFile() && CV_EXTENSIONS.test(entry.name)
|
|
105
|
+
&& (CV_NAME.test(entry.name.replace(CV_EXTENSIONS, '')) || carriesFullName(entry.name, personName))) {
|
|
93
106
|
try {
|
|
94
107
|
found.push({
|
|
95
108
|
path: full,
|
|
@@ -110,19 +123,37 @@ function findCvCandidates(home, personName) {
|
|
|
110
123
|
.map(({ path: cvPath, nameMatch, generic }) => ({ path: tildify(cvPath, { SHAKERS_CLI_HOME_DIR: home }), nameMatch, generic }));
|
|
111
124
|
}
|
|
112
125
|
|
|
113
|
-
//
|
|
114
|
-
|
|
126
|
+
// Which backend this install talks to, so the AI never calls a test sign-up
|
|
127
|
+
// "production".
|
|
128
|
+
function detectEnvironment(env) {
|
|
129
|
+
let base = '';
|
|
130
|
+
try {
|
|
131
|
+
base = String(require('./config').getHubBase(env) || '');
|
|
132
|
+
} catch {
|
|
133
|
+
return 'unknown';
|
|
134
|
+
}
|
|
135
|
+
if (/localhost|127\.0\.0\.1/.test(base)) return 'local';
|
|
136
|
+
if (/staging/.test(base)) return 'staging';
|
|
137
|
+
if (/\.dev\.|new-works/.test(base)) return 'development';
|
|
138
|
+
return base ? 'production' : 'unknown';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Returns the signals found, each clearly a SUGGESTION to confirm. `talentName`
|
|
142
|
+
// (what the AI already knows) sharpens the CV match beyond the git name.
|
|
143
|
+
function readRegisterContextSuggestions({ cwd, env = process.env, talentName } = {}) {
|
|
115
144
|
const home = getHomeDir(env);
|
|
116
145
|
const githubUser = detectGithubUser(home, cwd);
|
|
117
146
|
const gitName = gitConfig('user.name', cwd);
|
|
147
|
+
const personName = [talentName, gitName].filter((n) => typeof n === 'string' && n.trim()).join(' ');
|
|
118
148
|
return {
|
|
149
|
+
environment: detectEnvironment(env),
|
|
119
150
|
timezone: detectTimezone(),
|
|
120
151
|
gitName,
|
|
121
152
|
gitEmail: gitConfig('user.email', cwd),
|
|
122
153
|
githubUser,
|
|
123
154
|
githubUrl: githubUser ? `https://github.com/${githubUser}` : null,
|
|
124
|
-
cvCandidates: findCvCandidates(home,
|
|
155
|
+
cvCandidates: findCvCandidates(home, personName),
|
|
125
156
|
};
|
|
126
157
|
}
|
|
127
158
|
|
|
128
|
-
module.exports = { readRegisterContextSuggestions, detectTimezone, findCvCandidates, detectGithubUser, isGenericCvName };
|
|
159
|
+
module.exports = { readRegisterContextSuggestions, detectTimezone, findCvCandidates, detectGithubUser, isGenericCvName, detectEnvironment, nameTokens, fold };
|
package/src/scanner.js
CHANGED
|
@@ -202,7 +202,19 @@ function computeRecency(paths) {
|
|
|
202
202
|
/* ---------- version: ONLY runs the ALREADY detected binary, with --version ---------- */
|
|
203
203
|
/* Never arbitrary commands; all output is discarded except the version pattern. */
|
|
204
204
|
|
|
205
|
+
// scan() runs once per selected repo; remember each binary's version for a minute instead of asking again.
|
|
206
|
+
const VERSION_TTL_MS = 60 * 1000;
|
|
207
|
+
const versionCache = new Map();
|
|
208
|
+
|
|
205
209
|
function getVersion(bin) {
|
|
210
|
+
const cached = versionCache.get(bin);
|
|
211
|
+
if (cached && Date.now() - cached.at <= VERSION_TTL_MS) return cached.version;
|
|
212
|
+
const version = readVersion(bin);
|
|
213
|
+
versionCache.set(bin, { version, at: Date.now() });
|
|
214
|
+
return version;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readVersion(bin) {
|
|
206
218
|
try {
|
|
207
219
|
const out = execFileSync(bin, ['--version'], {
|
|
208
220
|
timeout: 3000,
|
|
@@ -609,4 +621,4 @@ function mergeReports(reports) {
|
|
|
609
621
|
};
|
|
610
622
|
}
|
|
611
623
|
|
|
612
|
-
module.exports = { scan, scanForRoots };
|
|
624
|
+
module.exports = { scan, scanForRoots, getVersion };
|