shakers-cli-beta 0.8.0-beta.2 → 0.8.0-beta.3
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/mcp.js +1 -1
- package/package.json +1 -1
- package/src/backend-request.js +8 -0
- package/src/cv-text.js +57 -0
- package/src/env-paths.js +15 -1
- package/src/mcp-register-tools.js +61 -7
- package/src/onboarding-client.js +36 -2
- package/src/onboarding-flow.js +116 -0
- package/src/register-context.js +128 -0
package/bin/mcp.js
CHANGED
|
@@ -42,7 +42,7 @@ const HELP = `\n shakers mcp — local MCP server (stdio)\n\n`
|
|
|
42
42
|
+ ` MCP client, not run by hand.\n`
|
|
43
43
|
+ ` Tools: ai_usage, ai_usage_result, login, logout, social_signin_start, social_signin_poll,\n`
|
|
44
44
|
+ ` list_addable, add_skill, add_agent,\n`
|
|
45
|
-
+ ` add_project, register, register_status, onboarding_interview_start,\n`
|
|
45
|
+
+ ` add_project, suggest_register_context, read_cv, register_preview, register, register_status, onboarding_interview_start,\n`
|
|
46
46
|
+ ` onboarding_interview_turn, onboarding_interview_complete,\n`
|
|
47
47
|
+ ` repeat_onboarding_interview, onboarding_finish,\n`
|
|
48
48
|
+ ` list_available_roles, add_role, list_my_roles, set_main_role,\n`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shakers-cli-beta",
|
|
3
|
-
"version": "0.8.0-beta.
|
|
3
|
+
"version": "0.8.0-beta.3",
|
|
4
4
|
"description": "Shakers AI usage & skills evaluator: a local-first CLI (`shakers` binary) plus a local MCP server. Commands: ai-usage (scan + classify your AI-tool setup, T0-T7), certify (certify Skills from your code), share (branded LinkedIn card).",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
package/src/backend-request.js
CHANGED
|
@@ -105,6 +105,14 @@ function postMultipartWithTimeout(url, fields, timeoutMs, method = 'POST', extra
|
|
|
105
105
|
const chunks = [];
|
|
106
106
|
for (const [name, value] of Object.entries(fields || {})) {
|
|
107
107
|
if (value === undefined || value === null) continue;
|
|
108
|
+
// A file part: { filename, contentType, data: Buffer }.
|
|
109
|
+
if (typeof value === 'object' && Buffer.isBuffer(value.data)) {
|
|
110
|
+
const filename = String(value.filename || 'file').replace(/["\r\n\0]/g, '');
|
|
111
|
+
const contentType = String(value.contentType || 'application/octet-stream').replace(UNSAFE_HEADER_VALUE_RE, '');
|
|
112
|
+
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`));
|
|
113
|
+
chunks.push(value.data, Buffer.from('\r\n'));
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
108
116
|
const clean = String(value).replace(UNSAFE_HEADER_VALUE_RE, '');
|
|
109
117
|
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${clean}\r\n`));
|
|
110
118
|
}
|
package/src/cv-text.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Plain text out of the talent's CV PDF, so the AI can pre-fill from what the CV
|
|
4
|
+
// says. Zero-dependency on purpose (install.sh copies the CLI without npm
|
|
5
|
+
// install): macOS reads it with the system PDFKit through JXA, elsewhere with
|
|
6
|
+
// poppler's pdftotext when it is installed. With neither, the caller asks the
|
|
7
|
+
// talent to attach the CV to the chat instead.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { execFileSync } = require('child_process');
|
|
12
|
+
const { expandHome } = require('./env-paths');
|
|
13
|
+
|
|
14
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
15
|
+
const MAX_CHARS = 20000;
|
|
16
|
+
|
|
17
|
+
const JXA_PDF_TEXT = 'function run(argv){ObjC.import("PDFKit");'
|
|
18
|
+
+ 'const d=$.PDFDocument.alloc.initWithURL($.NSURL.fileURLWithPath(argv[0]));'
|
|
19
|
+
+ 'return d.isNil()?"":d.string.js}';
|
|
20
|
+
|
|
21
|
+
function run(cmd, args, exec) {
|
|
22
|
+
return String(exec(cmd, args, { encoding: 'utf8', timeout: 20000, maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }) || '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function extractors(platform) {
|
|
26
|
+
const list = [];
|
|
27
|
+
if (platform === 'darwin') list.push({ name: 'pdfkit', cmd: 'osascript', args: (file) => ['-l', 'JavaScript', '-e', JXA_PDF_TEXT, file] });
|
|
28
|
+
list.push({ name: 'pdftotext', cmd: 'pdftotext', args: (file) => ['-layout', '-q', file, '-'] });
|
|
29
|
+
return list;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readCvText(input, { platform = process.platform, exec = execFileSync, env = process.env } = {}) {
|
|
33
|
+
const file = expandHome(input, env);
|
|
34
|
+
if (typeof file !== 'string' || !path.isAbsolute(file) || !/\.pdf$/i.test(file)) return { ok: false, reason: 'not-a-pdf-path' };
|
|
35
|
+
let stat;
|
|
36
|
+
try {
|
|
37
|
+
stat = fs.statSync(file);
|
|
38
|
+
} catch {
|
|
39
|
+
return { ok: false, reason: 'not-found' };
|
|
40
|
+
}
|
|
41
|
+
if (!stat.isFile()) return { ok: false, reason: 'not-found' };
|
|
42
|
+
if (stat.size > MAX_BYTES) return { ok: false, reason: 'too-large' };
|
|
43
|
+
|
|
44
|
+
for (const extractor of extractors(platform)) {
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = run(extractor.cmd, extractor.args(file), exec).trim();
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!text) continue;
|
|
52
|
+
return { ok: true, extractor: extractor.name, truncated: text.length > MAX_CHARS, text: text.slice(0, MAX_CHARS) };
|
|
53
|
+
}
|
|
54
|
+
return { ok: false, reason: 'no-text-extracted' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { readCvText, MAX_CHARS };
|
package/src/env-paths.js
CHANGED
|
@@ -13,4 +13,18 @@ function getHomeDir(env = process.env) {
|
|
|
13
13
|
return readEnv(env, 'HOME_DIR') || os.homedir();
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// `~/Documents/cv.pdf` -> absolute, so a model can pass the short form it was shown.
|
|
17
|
+
function expandHome(file, env = process.env) {
|
|
18
|
+
if (typeof file !== 'string') return file;
|
|
19
|
+
if (file === '~') return getHomeDir(env);
|
|
20
|
+
if (file.startsWith('~/')) return require('path').join(getHomeDir(env), file.slice(2));
|
|
21
|
+
return file;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The short `~/...` form of a path under home, for showing (and copying) without typos.
|
|
25
|
+
function tildify(file, env = process.env) {
|
|
26
|
+
const home = getHomeDir(env);
|
|
27
|
+
return typeof file === 'string' && file.startsWith(`${home}/`) ? `~/${file.slice(home.length + 1)}` : file;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { getHomeDir, readEnv, expandHome, tildify };
|
|
@@ -81,14 +81,22 @@ function buildSchemas(lang) {
|
|
|
81
81
|
required: ['name', 'lastName', 'email', 'freelanceType', 'profile', 'workSituation', 'pricing'],
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
// Same shape as REGISTER_SCHEMA but NOTHING is required: the preview accepts a
|
|
85
|
+
// partial, pre-filled payload and reports what is still missing.
|
|
86
|
+
const PREVIEW_SCHEMA = {
|
|
87
|
+
type: 'object',
|
|
88
|
+
description: 'A partial, pre-filled register payload to REVIEW before writing. Every field is optional — fill only what the talent has said or what is verifiable from context, omit the rest. Returns what WOULD be written, what is still missing to ask, and any invalid values. Writes nothing.',
|
|
89
|
+
properties: { ...REGISTER_SCHEMA.properties },
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return { ACCOUNT_FIELDS, WORK_SITUATION_SCHEMA, LANGUAGES_SCHEMA, AVAILABILITY_SCHEMA, REGISTER_SCHEMA, PREVIEW_SCHEMA };
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
// The register tool's own long description, per language: the batched
|
|
88
96
|
// collection guide with friendly labels inline for every choice field.
|
|
89
97
|
function buildRegisterDescription(lang) {
|
|
90
98
|
const L = getCatalog(lang).onboarding;
|
|
91
|
-
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\nCOLLECT THE DETAILS IN A FEW GROUPED BATCHES, 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. Suggested batches:\n1. Identity — name, lastName, email (optional: preferredLanguage, newsletterConsent).\n2. Profile — `profile.linkedinUrl` (required source; optional GitHub/website/CV path).\n3. Work — freelanceType (offer: "
|
|
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\nSAVE THE TALENT TYPING: first call suggest_register_context (timezone, local git identity, GitHub login, CV files) and PROPOSE those values for them to confirm; then call register_preview with the partial payload to get a review (what would be written, what is still missing) and show the talent a short summary to confirm or correct BEFORE calling register. Pre-fill EVERY field you can reasonably infer from what you know about the talent (your memory of past conversations, the chat, context) — including `pricing` and `workSituation.changeMotivators`. PRICING: whenever you know anything about their role, seniority, stack or location, ALWAYS propose a concrete per-project amount yourself (full-time and/or part-time, in their currency), labelled as your estimate — do not hand the question back to the talent; only ask for it when you know nothing at all about their work. MOTIVATORS: pick the one that best matches what they have told you. What you know about the person outranks suggest_register_context: if the git identity does not match the person you know, ignore it. For each inferred value briefly say where it comes from. Ask ONLY for what you genuinely have no basis for. Use suggest_register_context's githubUrl and cvCandidates to propose profile.githubUrl and profile.cvPath (cvCandidates often include OTHER people's CVs, e.g. candidates they downloaded: propose a CV ONLY if its file name matches the talent's name — nameMatch:true or a name you know — and never one named after someone else; if none matches but there is a generic:true one like cv.pdf, ASK the talent whether it is theirs and whether you may read it and send it with the registration; otherwise ask for the path). Once the talent confirms a CV and allows it, ALWAYS call read_cv BEFORE register_preview and use its text to pre-fill or correct the other fields (languages and levels, experience, location); if the CV adds or changes any value, show the talent just those changes and get their OK before calling register; pass paths exactly as given, the short ~/ form is fine. For optional fields you have no basis for (phone, website, newsletter), do not ask one by one: add a single closing line offering them (e.g. '¿Quieres añadir teléfono o web?'). The goal is that the talent only has to answer 'sí' or correct a value. Never write without the talent's confirmation of the summary. All of this is self-declared profile data.\n\nCOLLECT THE DETAILS IN A FEW GROUPED BATCHES, 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. Suggested batches:\n1. Identity — name, lastName, email (optional: preferredLanguage, newsletterConsent).\n2. Profile — `profile.linkedinUrl` (required source; optional GitHub/website/CV path).\n3. Work — freelanceType (offer: "
|
|
92
100
|
+ labelledOptions(FREELANCE_TYPES, L.freelanceTypeLabels)
|
|
93
101
|
+ ") and `workSituation` (offer situation: "
|
|
94
102
|
+ labelledOptions(WORK_SITUATIONS, L.workSituationLabels)
|
|
@@ -121,7 +129,7 @@ const PROFILE_SCHEMA = {
|
|
|
121
129
|
type: 'object',
|
|
122
130
|
properties: {
|
|
123
131
|
linkedinUrl: { type: 'string', description: "The talent's LinkedIn URL. Required — it is the mandatory profile source." },
|
|
124
|
-
cvPath: { type: 'string', description: '
|
|
132
|
+
cvPath: { type: 'string', description: 'Path to the CV (PDF/DOC/DOCX, max 10 MB) on this machine, absolute or ~/... (optional). Only a CV the talent confirmed is theirs. The FILE is uploaded to Shakers with the profile import.' },
|
|
125
133
|
githubUrl: { type: 'string', description: 'GitHub URL (optional).' },
|
|
126
134
|
websiteUrl: { type: 'string', description: 'Personal website URL (optional).' },
|
|
127
135
|
},
|
|
@@ -143,7 +151,7 @@ const PRICING_SCHEMA = {
|
|
|
143
151
|
|
|
144
152
|
// Default (English) static exports for callers/tests that read the module-level
|
|
145
153
|
// schemas directly. The live tools rebuild these per session language.
|
|
146
|
-
const { ACCOUNT_FIELDS, WORK_SITUATION_SCHEMA, LANGUAGES_SCHEMA, AVAILABILITY_SCHEMA, REGISTER_SCHEMA } = buildSchemas('en');
|
|
154
|
+
const { ACCOUNT_FIELDS, WORK_SITUATION_SCHEMA, LANGUAGES_SCHEMA, AVAILABILITY_SCHEMA, REGISTER_SCHEMA, PREVIEW_SCHEMA } = buildSchemas('en');
|
|
147
155
|
|
|
148
156
|
const STATUS_SCHEMA = {
|
|
149
157
|
type: 'object',
|
|
@@ -172,7 +180,7 @@ const INTERVIEW_TURN_SCHEMA = {
|
|
|
172
180
|
type: 'object',
|
|
173
181
|
properties: {
|
|
174
182
|
interviewId: { type: 'string', description: 'Internal handle for this interview — for calling the interview tools ONLY; never shown or mentioned to the talent.' },
|
|
175
|
-
message: { type: 'string', description: "The talent's
|
|
183
|
+
message: { type: 'string', description: "The talent's answer to the current question: in conduit mode their REAL, VERBATIM words; in AI-drafted mode (only if the talent opted in) the answer they APPROVED after seeing your draft, with their edits. Never a value they have not seen and confirmed. If they have not answered or approved yet, do NOT call this." },
|
|
176
184
|
},
|
|
177
185
|
required: ['interviewId', 'message'],
|
|
178
186
|
};
|
|
@@ -446,6 +454,26 @@ function makeRegisterTools(deps = {}) {
|
|
|
446
454
|
};
|
|
447
455
|
}
|
|
448
456
|
|
|
457
|
+
// PURE preview: reuses the write-path validators, touches no network, no session.
|
|
458
|
+
async function registerPreview(args = {}) {
|
|
459
|
+
return flow.buildRegisterPreview(args || {});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Content-free context the model may PROPOSE to the talent (never auto-write).
|
|
463
|
+
async function suggestContext() {
|
|
464
|
+
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.' };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Text of the CV the talent confirmed as theirs, read only with their permission.
|
|
469
|
+
async function readCv(args = {}) {
|
|
470
|
+
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
|
+
const read = deps.readCvText || require('./cv-text').readCvText;
|
|
472
|
+
const result = read(args.path);
|
|
473
|
+
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.' };
|
|
474
|
+
return result;
|
|
475
|
+
}
|
|
476
|
+
|
|
449
477
|
return [
|
|
450
478
|
{
|
|
451
479
|
name: 'register',
|
|
@@ -453,6 +481,31 @@ function makeRegisterTools(deps = {}) {
|
|
|
453
481
|
inputSchema: schemas.REGISTER_SCHEMA,
|
|
454
482
|
handler: register,
|
|
455
483
|
},
|
|
484
|
+
{
|
|
485
|
+
name: 'register_preview',
|
|
486
|
+
description: "REVIEW a (partial) register payload BEFORE writing — the preferred way to register with the least typing. Fill every field you can reasonably infer, then call this. It writes NOTHING; it returns { wouldWrite: the normalized data per hub endpoint, missingFields: what is still needed (required:true) or just suggested (required:false) to ASK the talent, warnings: any value that is invalid, mustConfirm }. Show the talent a short human summary of wouldWrite and the missing items, let them confirm or correct, then call `register` with the final payload. Pre-fill every field you can reasonably infer (memory, chat, context), including pricing and changeMotivators; omit only what you have no basis for. The talent confirms the whole summary once; fields in `mustConfirm` just need to be visible in that summary, not asked separately. All register data is self-declared profile data.",
|
|
487
|
+
inputSchema: schemas.PREVIEW_SCHEMA,
|
|
488
|
+
handler: registerPreview,
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
name: 'suggest_register_context',
|
|
492
|
+
description: "Get local SUGGESTIONS to speed up registration: the environment timezone, the local git identity (user.name / user.email), the GitHub login the gh CLI is signed in with (githubUser/githubUrl), and cvCandidates — PDF files in Desktop/Documents/Downloads whose NAME looks like a CV (names only, never read), each with nameMatch (file name contains the git name). Only propose a CV that is clearly the talent's own; never propose one named after someone else. A generic:true name (cv.pdf, curriculum_2024.pdf) may be theirs: ask 'is this CV yours? may I read it and send it with your registration?'. These are PROPOSALS only — present them to the talent to confirm (e.g. 'I see your timezone is Europe/Madrid and your git email is X — use these?') and never write any of them without the talent's confirmation. No session needed. Use it before register_preview to pre-fill availability.timezone, name/email, profile.githubUrl and profile.cvPath.",
|
|
493
|
+
inputSchema: { type: 'object', properties: {} },
|
|
494
|
+
handler: suggestContext,
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
name: 'read_cv',
|
|
498
|
+
description: "Read the TEXT of the talent's CV PDF so you can pre-fill the register and the onboarding from what it says (experience, projects, stack, languages, education). Only for a CV the talent has CONFIRMED is theirs, and only after they gave you PERMISSION to read it — set talentConsent:true only then. Typical path: one of suggest_register_context's cvCandidates. Local only: the file is read on this machine and its text comes back to you; nothing is uploaded by this tool (the CV is sent to Shakers only if the talent also confirms it as profile.cvPath in register). Returns { ok, text, truncated } or { ok:false, reason } — on failure ask the talent to attach the CV to the chat.",
|
|
499
|
+
inputSchema: {
|
|
500
|
+
type: 'object',
|
|
501
|
+
properties: {
|
|
502
|
+
path: { type: 'string', description: 'Path to the PDF the talent confirmed as their CV, exactly as suggest_register_context gave it (the short ~/ form is fine) or absolute.' },
|
|
503
|
+
talentConsent: { type: 'boolean', description: 'true ONLY after the talent explicitly agreed that this is their CV and that you may read it.' },
|
|
504
|
+
},
|
|
505
|
+
required: ['path', 'talentConsent'],
|
|
506
|
+
},
|
|
507
|
+
handler: readCv,
|
|
508
|
+
},
|
|
456
509
|
{
|
|
457
510
|
name: 'register_status',
|
|
458
511
|
description: 'Check the state of the background profile import started by register. Pass the importJobId (an internal handle — never shown to the talent). Returns { state } (e.g. running | done | failed).',
|
|
@@ -461,7 +514,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
461
514
|
},
|
|
462
515
|
{
|
|
463
516
|
name: 'onboarding_interview_start',
|
|
464
|
-
description: "Start the onboarding interview (step 5, optional). Runs over LiveKit: the server joins a live room and the agent conducts it, transparently resolving the branch (role/dedication only when AI-usage evidence exists, otherwise the AI-usage blocks too) — the client does NOT decide the branch. You MUST first show the talent BOTH interview disclaimers separately (from register's disclaimers.interview) and only then call with disclaimerAcknowledged:true. Returns { interviewId, greeting, ended }: relay the greeting to the talent VERBATIM, then drive the interview with onboarding_interview_turn.
|
|
517
|
+
description: "Start the onboarding interview (step 5, optional). Runs over LiveKit: the server joins a live room and the agent conducts it, transparently resolving the branch (role/dedication only when AI-usage evidence exists, otherwise the AI-usage blocks too) — the client does NOT decide the branch. You MUST first show the talent BOTH interview disclaimers separately (from register's disclaimers.interview) and only then call with disclaimerAcknowledged:true. Returns { interviewId, greeting, ended }: relay the greeting to the talent VERBATIM, then drive the interview with onboarding_interview_turn. ANSWER MODE — before calling this, ASK the talent once: do they want you to draft their answers from what you already know about them (your memory of past conversations, the chat, the register data), so they only confirm, correct or add their opinion? (1) If they say YES (AI-drafted mode): FIRST gather everything you can know about them — your memory, a search of your past conversations with them if you have such a tool, their CV (read_cv, with their permission, if not read yet), their AI-usage report and the register data — and show ONE short sheet 'esto es lo que sé de ti' (roles and projects with the concrete facts, stack, languages, what they want next). Let them correct it once; those corrections override your memory for every later answer. Then for every interview question show the question VERBATIM and your proposed answer in the 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. If the sheet does not cover the question, do not guess a fact: ask them briefly. Ask them to confirm, edit or add their view. Send ONLY the version they approve — their edits and opinions included — never a draft they have not seen. For questions about wishes, preferences or opinions, give your best guess but make clear it is a guess. (2) If they say NO: you are a pure CONDUIT — show every question VERBATIM and pass only the talent's REAL, VERBATIM words back; do not opine, guide or answer for them. In both modes never reword, summarize or translate the interview's questions. `interviewId` is an internal handle for the interview tools only — never show or mention it to the talent. If it returns reason 'livekit-not-installed', relay the `message` (it tells the talent how to install the optional @livekit/rtc-node package). If the talent declines the interview, do not call this — instead run the final main-role step (list_my_roles + set_main_role) and offer them their profile URL from register. If the talent has ALREADY completed onboarding this refuses with an already-completed notice unless confirmRepeat:true; note a completed onboarding interview cannot actually be restarted (it returns reason 'interview-already-started') — in that case just run set_main_role.",
|
|
465
518
|
inputSchema: INTERVIEW_START_SCHEMA,
|
|
466
519
|
handler: interviewStart,
|
|
467
520
|
available: async () => {
|
|
@@ -474,7 +527,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
474
527
|
},
|
|
475
528
|
{
|
|
476
529
|
name: 'onboarding_interview_turn',
|
|
477
|
-
description: "Send the talent's answer to the current onboarding-interview question and get the next one.
|
|
530
|
+
description: "Send the talent's answer to the current onboarding-interview question and get the next one. Follow the ANSWER MODE the talent chose at onboarding_interview_start. AI-drafted mode: `message` is the answer the talent APPROVED after seeing your draft (with their edits/opinion); never send a draft they have not confirmed. Conduit mode: `message` is ONLY the talent's REAL, VERBATIM answer — never invent, infer or complete it. In both modes, if the talent has not answered or approved yet, do NOT call this, wait for them. Relay the returned `response` (the next question) to the talent VERBATIM — do not reword, summarize or translate it. Keep calling with their next `message` until `ended` is true, then call onboarding_interview_complete. Do NOT show or mention `interviewId` to the talent (internal handle). If it returns reason 'unknown-interview' (the server was restarted and the live room is gone), start again with onboarding_interview_start.",
|
|
478
531
|
inputSchema: INTERVIEW_TURN_SCHEMA,
|
|
479
532
|
handler: interviewTurn,
|
|
480
533
|
},
|
|
@@ -503,6 +556,7 @@ module.exports = {
|
|
|
503
556
|
makeRegisterTools,
|
|
504
557
|
ACCOUNT_FIELDS,
|
|
505
558
|
REGISTER_SCHEMA,
|
|
559
|
+
PREVIEW_SCHEMA,
|
|
506
560
|
STATUS_SCHEMA,
|
|
507
561
|
INTERVIEW_START_SCHEMA,
|
|
508
562
|
INTERVIEW_TURN_SCHEMA,
|
package/src/onboarding-client.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { postJsonWithTimeout, postMultipartWithTimeout } = require('./backend-request');
|
|
4
6
|
const { WORKS_CODE_TO_REASON } = require('./skills-client');
|
|
5
7
|
const { toLowerLang, toUpperLang } = require('./lang-codes');
|
|
8
|
+
const { expandHome } = require('./env-paths');
|
|
6
9
|
|
|
7
10
|
const DEFAULT_TIMEOUT_MS = 20000;
|
|
8
11
|
const IMPORT_KICKOFF_TIMEOUT_MS = 90000;
|
|
@@ -48,6 +51,28 @@ function buildImportSources({ linkedinUrl, cvPath, githubUrl, websiteUrl, langua
|
|
|
48
51
|
return sources;
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
// The CV formats and size hub accepts on import-profile.
|
|
55
|
+
const CV_CONTENT_TYPES = {
|
|
56
|
+
'.pdf': 'application/pdf',
|
|
57
|
+
'.doc': 'application/msword',
|
|
58
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
59
|
+
};
|
|
60
|
+
const CV_MAX_BYTES = 10 * 1024 * 1024;
|
|
61
|
+
|
|
62
|
+
function readCvUpload(input) {
|
|
63
|
+
const cvPath = expandHome(input);
|
|
64
|
+
const contentType = CV_CONTENT_TYPES[path.extname(cvPath).toLowerCase()];
|
|
65
|
+
if (!contentType) return { ok: false, reason: 'cv-bad-format' };
|
|
66
|
+
let data;
|
|
67
|
+
try {
|
|
68
|
+
data = fs.readFileSync(cvPath);
|
|
69
|
+
} catch {
|
|
70
|
+
return { ok: false, reason: 'cv-not-found' };
|
|
71
|
+
}
|
|
72
|
+
if (data.length > CV_MAX_BYTES) return { ok: false, reason: 'cv-too-large' };
|
|
73
|
+
return { ok: true, file: { filename: path.basename(cvPath), contentType, data } };
|
|
74
|
+
}
|
|
75
|
+
|
|
51
76
|
async function requestImportProfile(
|
|
52
77
|
{ linkedinUrl, cvPath, githubUrl, websiteUrl, language, accessToken, hubAccessToken } = {},
|
|
53
78
|
{ endpoint, timeoutMs = IMPORT_KICKOFF_TIMEOUT_MS } = {},
|
|
@@ -59,7 +84,16 @@ async function requestImportProfile(
|
|
|
59
84
|
const body = buildImportSources({ linkedinUrl, cvPath, githubUrl, websiteUrl, language });
|
|
60
85
|
let res;
|
|
61
86
|
try {
|
|
62
|
-
|
|
87
|
+
if (body.cvPath) {
|
|
88
|
+
// Hub reads the CV as an uploaded `cv` file (multipart), never as a local
|
|
89
|
+
// path: a path on the talent's machine means nothing to the server.
|
|
90
|
+
const cv = readCvUpload(body.cvPath);
|
|
91
|
+
if (!cv.ok) return cv;
|
|
92
|
+
const { cvPath: _localPath, ...fields } = body;
|
|
93
|
+
res = await postMultipartWithTimeout(endpoint, { ...fields, cv: cv.file }, timeoutMs, 'POST', hubHeaders({ hubAccessToken }));
|
|
94
|
+
} else {
|
|
95
|
+
res = await postJsonWithTimeout(endpoint, body, timeoutMs, 'POST', null, hubHeaders({ hubAccessToken }));
|
|
96
|
+
}
|
|
63
97
|
} catch (e) {
|
|
64
98
|
return { ok: false, reason: (e && e.kind) || 'network-error' };
|
|
65
99
|
}
|
package/src/onboarding-flow.js
CHANGED
|
@@ -241,6 +241,120 @@ function validateAvailability(input = {}) {
|
|
|
241
241
|
return { ok: true, availability: body };
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
// Fields the talent must ALWAYS confirm explicitly — never inferred/invented by
|
|
245
|
+
// the model (a wrong price or motivation is worse than a gap).
|
|
246
|
+
const MUST_CONFIRM_FIELDS = ['pricing', 'workSituation.changeMotivators'];
|
|
247
|
+
|
|
248
|
+
// PURE preview of a (possibly partial) register payload: what WOULD be written,
|
|
249
|
+
// what is still missing, and what is invalid — reusing the same validators the
|
|
250
|
+
// write path uses. Touches no network and needs no session, so the model can show
|
|
251
|
+
// the talent a review before the single `register` write.
|
|
252
|
+
function buildRegisterPreview(args = {}) {
|
|
253
|
+
const wouldWrite = [];
|
|
254
|
+
const missingFields = [];
|
|
255
|
+
const warnings = [];
|
|
256
|
+
const addMissing = (field, required) => missingFields.push({ field, required });
|
|
257
|
+
|
|
258
|
+
// --- account (always written at registration; password never in the payload) ---
|
|
259
|
+
const account = {
|
|
260
|
+
name: args.name,
|
|
261
|
+
lastName: args.lastName,
|
|
262
|
+
email: args.email,
|
|
263
|
+
freelanceType: args.freelanceType,
|
|
264
|
+
};
|
|
265
|
+
if (!isNonEmptyString(account.name)) addMissing('name', true);
|
|
266
|
+
if (!isNonEmptyString(account.lastName)) addMissing('lastName', true);
|
|
267
|
+
if (!isNonEmptyString(account.email)) addMissing('email', true);
|
|
268
|
+
const ftype = normalizeFreelanceType(account.freelanceType);
|
|
269
|
+
if (!ftype) {
|
|
270
|
+
if (isNonEmptyString(account.freelanceType)) warnings.push({ field: 'freelanceType', reason: 'bad-freelance-type' });
|
|
271
|
+
else addMissing('freelanceType', true);
|
|
272
|
+
}
|
|
273
|
+
wouldWrite.push({
|
|
274
|
+
section: 'account',
|
|
275
|
+
endpoint: 'hub: auth/sign-up/email + works/auth/complete-registration',
|
|
276
|
+
data: {
|
|
277
|
+
name: isNonEmptyString(account.name) ? account.name : null,
|
|
278
|
+
lastName: isNonEmptyString(account.lastName) ? account.lastName : null,
|
|
279
|
+
email: isNonEmptyString(account.email) ? account.email : null,
|
|
280
|
+
freelanceType: ftype || null,
|
|
281
|
+
preferredLanguage: isNonEmptyString(args.preferredLanguage) ? args.preferredLanguage : null,
|
|
282
|
+
newsletterConsent: args.newsletterConsent === true,
|
|
283
|
+
},
|
|
284
|
+
note: 'password is never part of this payload — the talent types it in the local sign-up window',
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// --- profile import (linkedinUrl is the required source) ---
|
|
288
|
+
const profile = args.profile && typeof args.profile === 'object' ? args.profile : {};
|
|
289
|
+
if (!isNonEmptyString(profile.linkedinUrl)) addMissing('profile.linkedinUrl', true);
|
|
290
|
+
else {
|
|
291
|
+
const pdata = { linkedinUrl: profile.linkedinUrl };
|
|
292
|
+
for (const k of ['githubUrl', 'websiteUrl', 'cvPath']) if (isNonEmptyString(profile[k])) pdata[k] = profile[k];
|
|
293
|
+
wouldWrite.push({ section: 'profile', endpoint: 'hub: works/me/import-profile', data: pdata });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// --- work situation (situation required) ---
|
|
297
|
+
const ws = args.workSituation && typeof args.workSituation === 'object' ? args.workSituation : {};
|
|
298
|
+
if (!isNonEmptyString(ws.situation)) addMissing('workSituation.situation', true);
|
|
299
|
+
else {
|
|
300
|
+
const vws = validateWorkSituation(ws);
|
|
301
|
+
if (!vws.ok) warnings.push({ field: 'workSituation', reason: vws.reason });
|
|
302
|
+
else wouldWrite.push({ section: 'workSituation', endpoint: 'hub: works/me/professional-details', data: vws.professionalDetails });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// --- pricing (at least one variant; ALWAYS confirm) ---
|
|
306
|
+
const pricing = args.pricing && typeof args.pricing === 'object' ? args.pricing : {};
|
|
307
|
+
const pricingEmpty = pricing.fullTimeSelected !== true && pricing.partTimeSelected !== true
|
|
308
|
+
&& pricing.fullTimeAmount == null && pricing.partTimeAmount == null;
|
|
309
|
+
if (pricingEmpty) addMissing('pricing', true);
|
|
310
|
+
else {
|
|
311
|
+
const vp = validatePricing(pricing);
|
|
312
|
+
if (!vp.ok) warnings.push({ field: 'pricing', reason: vp.reason });
|
|
313
|
+
else wouldWrite.push({ section: 'pricing', endpoint: 'hub: works/talents/me/work-details/pricing-rate', data: vp.pricing });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// --- availability (optional; suggest the sub-fields the talent did not give) ---
|
|
317
|
+
if (args.availability && typeof args.availability === 'object') {
|
|
318
|
+
const va = validateAvailability(args.availability);
|
|
319
|
+
if (!va.ok) warnings.push({ field: 'availability', reason: va.reason });
|
|
320
|
+
else {
|
|
321
|
+
wouldWrite.push({ section: 'availability', endpoint: 'hub: works/talents/me/work-details/availability', data: va.availability });
|
|
322
|
+
for (const k of ['available', 'monthlyHours', 'workModes', 'country', 'timezone']) {
|
|
323
|
+
if (!(k in va.availability)) addMissing(`availability.${k}`, false);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
addMissing('availability', false);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- languages (optional; ids resolve at write-time, so the preview shows codes) ---
|
|
331
|
+
if (Array.isArray(args.languages) && args.languages.length > 0) {
|
|
332
|
+
const vl = validateLanguageRows(args.languages);
|
|
333
|
+
if (!vl.ok) warnings.push({ field: 'languages', reason: vl.reason });
|
|
334
|
+
else wouldWrite.push({ section: 'languages', endpoint: 'hub: works/talents/me/work-details/languages', data: vl.rows, note: 'language codes resolve to ids at write time' });
|
|
335
|
+
} else {
|
|
336
|
+
addMissing('languages', false);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// --- phone (optional) ---
|
|
340
|
+
if (args.phone && typeof args.phone === 'object') {
|
|
341
|
+
const vph = validatePhone(args.phone);
|
|
342
|
+
if (!vph.ok) warnings.push({ field: 'phone', reason: vph.reason });
|
|
343
|
+
else wouldWrite.push({ section: 'phone', endpoint: 'hub: works/me', data: { telephoneCode: vph.telephoneCode, telephoneNumber: vph.telephoneNumber } });
|
|
344
|
+
} else {
|
|
345
|
+
addMissing('phone', false);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
ok: true,
|
|
350
|
+
provenance: 'self-declared',
|
|
351
|
+
wouldWrite,
|
|
352
|
+
missingFields,
|
|
353
|
+
warnings,
|
|
354
|
+
mustConfirm: MUST_CONFIRM_FIELDS,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
244
358
|
function makeDeps(overrides = {}) {
|
|
245
359
|
const config = require('./config');
|
|
246
360
|
const client = require('./onboarding-client');
|
|
@@ -1052,6 +1166,8 @@ module.exports = {
|
|
|
1052
1166
|
buildPricingUpsert,
|
|
1053
1167
|
isAcceptablePassword,
|
|
1054
1168
|
validateRegisterFields,
|
|
1169
|
+
buildRegisterPreview,
|
|
1170
|
+
MUST_CONFIRM_FIELDS,
|
|
1055
1171
|
deriveCurrentEmploymentStatus,
|
|
1056
1172
|
showsFreelanceOpinion,
|
|
1057
1173
|
deriveFreelanceIntent,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Local context SUGGESTIONS for the register flow: the environment timezone, the
|
|
4
|
+
// local git identity, the GitHub login and CV candidates. These are proposals the
|
|
5
|
+
// model offers the talent to confirm — they are NEVER auto-written. No file
|
|
6
|
+
// content, no commit scanning, no network, no session: CV candidates are file
|
|
7
|
+
// NAMES only. This is deliberately NOT the authorship/evidence path.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { execFileSync } = require('child_process');
|
|
12
|
+
const { getHomeDir, tildify } = require('./env-paths');
|
|
13
|
+
|
|
14
|
+
// Where a talent keeps a CV, and how deep we look under each folder.
|
|
15
|
+
const CV_DIRS = ['Desktop', 'Documents', 'Downloads', 'Escritorio', 'Documentos', 'Descargas'];
|
|
16
|
+
const CV_MAX_DEPTH = 2;
|
|
17
|
+
const CV_MAX_RESULTS = 5;
|
|
18
|
+
const CV_NAME = /(^|[^a-z])(cv|curriculum|curr[ií]culum|resume|r[ée]sum[ée])([^a-z]|$)/i;
|
|
19
|
+
|
|
20
|
+
function detectTimezone() {
|
|
21
|
+
try {
|
|
22
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function gitConfig(key, cwd) {
|
|
29
|
+
try {
|
|
30
|
+
const out = execFileSync('git', ['config', '--get', key], {
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
timeout: 5000,
|
|
33
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
34
|
+
cwd: cwd || process.cwd(),
|
|
35
|
+
});
|
|
36
|
+
const value = String(out || '').trim();
|
|
37
|
+
return value || null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The login the GitHub CLI is signed in with, read from its local hosts file
|
|
44
|
+
// (no network), else the conventional `github.user` git key.
|
|
45
|
+
function detectGithubUser(home, cwd) {
|
|
46
|
+
try {
|
|
47
|
+
const hosts = fs.readFileSync(path.join(home, '.config', 'gh', 'hosts.yml'), 'utf8');
|
|
48
|
+
const block = hosts.split(/^github\.com:\s*$/m)[1];
|
|
49
|
+
const match = block && block.match(/^\s+user:\s*(\S+)\s*$/m);
|
|
50
|
+
if (match) return match[1];
|
|
51
|
+
} catch {
|
|
52
|
+
// no gh CLI config
|
|
53
|
+
}
|
|
54
|
+
return gitConfig('github.user', cwd);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fold = (text) => String(text || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
58
|
+
|
|
59
|
+
// Whether a file name carries a token of the person's name. People who hire keep
|
|
60
|
+
// other people's CVs in Downloads; only a name match makes a candidate theirs.
|
|
61
|
+
function matchesName(fileName, personName) {
|
|
62
|
+
const tokens = fold(personName).split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
|
|
63
|
+
const file = fold(fileName);
|
|
64
|
+
return tokens.some((t) => file.includes(t));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Words that say nothing about whose CV it is: `cv.pdf`, `Curriculum_2024_ES.pdf`,
|
|
68
|
+
// `resume-final-v2.pdf`. Such a file may be the talent's own, so it is worth asking.
|
|
69
|
+
const GENERIC_WORDS = /\b(cv|curriculum|curriculo|resume|vitae|mi|my|el|the|de|of|final|actualizado|updated|nuevo|new|latest|es|en|esp|eng|spanish|english|espanol|ingles|v\d+|copia|copy)\b/g;
|
|
70
|
+
|
|
71
|
+
function isGenericCvName(fileName) {
|
|
72
|
+
const rest = fold(fileName.replace(/\.pdf$/i, '')).replace(/[^a-z0-9]+/g, ' ').replace(GENERIC_WORDS, ' ').replace(/\d+/g, ' ');
|
|
73
|
+
return rest.trim().length === 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// PDFs whose NAME looks like a CV: name matches first, then generic names, then
|
|
77
|
+
// newest. Never opens a file.
|
|
78
|
+
function findCvCandidates(home, personName) {
|
|
79
|
+
const found = [];
|
|
80
|
+
const walk = (dir, depth) => {
|
|
81
|
+
let entries;
|
|
82
|
+
try {
|
|
83
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.name.startsWith('.')) continue;
|
|
89
|
+
const full = path.join(dir, entry.name);
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
if (depth < CV_MAX_DEPTH) walk(full, depth + 1);
|
|
92
|
+
} else if (entry.isFile() && /\.pdf$/i.test(entry.name) && CV_NAME.test(entry.name.replace(/\.pdf$/i, ''))) {
|
|
93
|
+
try {
|
|
94
|
+
found.push({
|
|
95
|
+
path: full,
|
|
96
|
+
nameMatch: matchesName(entry.name, personName),
|
|
97
|
+
generic: isGenericCvName(entry.name),
|
|
98
|
+
mtimeMs: fs.statSync(full).mtimeMs,
|
|
99
|
+
});
|
|
100
|
+
} catch {
|
|
101
|
+
// vanished between readdir and stat
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
for (const name of CV_DIRS) walk(path.join(home, name), 1);
|
|
107
|
+
return found
|
|
108
|
+
.sort((a, b) => Number(b.nameMatch) - Number(a.nameMatch) || Number(b.generic) - Number(a.generic) || b.mtimeMs - a.mtimeMs)
|
|
109
|
+
.slice(0, CV_MAX_RESULTS)
|
|
110
|
+
.map(({ path: cvPath, nameMatch, generic }) => ({ path: tildify(cvPath, { SHAKERS_CLI_HOME_DIR: home }), nameMatch, generic }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Returns the signals found, each clearly a SUGGESTION to confirm.
|
|
114
|
+
function readRegisterContextSuggestions({ cwd, env = process.env } = {}) {
|
|
115
|
+
const home = getHomeDir(env);
|
|
116
|
+
const githubUser = detectGithubUser(home, cwd);
|
|
117
|
+
const gitName = gitConfig('user.name', cwd);
|
|
118
|
+
return {
|
|
119
|
+
timezone: detectTimezone(),
|
|
120
|
+
gitName,
|
|
121
|
+
gitEmail: gitConfig('user.email', cwd),
|
|
122
|
+
githubUser,
|
|
123
|
+
githubUrl: githubUser ? `https://github.com/${githubUser}` : null,
|
|
124
|
+
cvCandidates: findCvCandidates(home, gitName),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { readRegisterContextSuggestions, detectTimezone, findCvCandidates, detectGithubUser, isGenericCvName };
|