shakers-cli 0.0.0-stage → 0.14.1

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.
Files changed (237) hide show
  1. package/LICENSE +59 -0
  2. package/README.md +1233 -2
  3. package/bin/add-agent.js +88 -0
  4. package/bin/add-project.js +88 -0
  5. package/bin/add-role.js +61 -0
  6. package/bin/add-skill.js +88 -0
  7. package/bin/ai-usage.js +99 -0
  8. package/bin/alma.js +99 -0
  9. package/bin/applications.js +69 -0
  10. package/bin/availability.js +70 -0
  11. package/bin/certifications.js +69 -0
  12. package/bin/certify.js +73 -0
  13. package/bin/change-role.js +61 -0
  14. package/bin/config.js +124 -0
  15. package/bin/experiences.js +69 -0
  16. package/bin/find-projects.js +89 -0
  17. package/bin/invitations.js +69 -0
  18. package/bin/lang.js +70 -0
  19. package/bin/login.js +216 -0
  20. package/bin/mcp.js +124 -0
  21. package/bin/mcpb.js +5 -0
  22. package/bin/me.js +69 -0
  23. package/bin/onboarding.js +134 -0
  24. package/bin/portfolios.js +69 -0
  25. package/bin/rate.js +70 -0
  26. package/bin/register.js +337 -0
  27. package/bin/report-html.js +134 -0
  28. package/bin/save.js +77 -0
  29. package/bin/shakers.js +363 -0
  30. package/bin/share.js +72 -0
  31. package/bin/show-project.js +72 -0
  32. package/bin/socials.js +70 -0
  33. package/bin/superadmin.js +351 -0
  34. package/bin/update-check-worker.js +70 -0
  35. package/bin/update.js +111 -0
  36. package/package.json +40 -4
  37. package/src/agent-category.js +13 -0
  38. package/src/agent-evaluation.js +177 -0
  39. package/src/agent-org-chart.js +355 -0
  40. package/src/agent-synthesis.js +132 -0
  41. package/src/agent-usage.js +118 -0
  42. package/src/agents-client.js +165 -0
  43. package/src/ai-fluency-cell.js +72 -0
  44. package/src/ai-profile-client.js +45 -0
  45. package/src/ai-profile-preview.js +154 -0
  46. package/src/ai.js +66 -0
  47. package/src/alma-client.js +123 -0
  48. package/src/alma-flow.js +32 -0
  49. package/src/alma-repo-context.js +47 -0
  50. package/src/alma-shell.js +161 -0
  51. package/src/ansi.js +62 -0
  52. package/src/applications-client.js +79 -0
  53. package/src/applications-flow.js +58 -0
  54. package/src/auth-client.js +144 -0
  55. package/src/auth-device-client.js +118 -0
  56. package/src/auth-method-picker.js +44 -0
  57. package/src/auth-methods.js +35 -0
  58. package/src/auth-session-store.js +215 -0
  59. package/src/authorship-mismatch.js +62 -0
  60. package/src/authorship.js +204 -0
  61. package/src/automations-detector.js +170 -0
  62. package/src/availability-client.js +93 -0
  63. package/src/availability-flow.js +124 -0
  64. package/src/backend-request.js +191 -0
  65. package/src/blind-approval-extract.js +48 -0
  66. package/src/brand-ansi.js +14 -0
  67. package/src/brand-svg.js +9 -0
  68. package/src/browser-tools-detector.js +21 -0
  69. package/src/build-next-level.js +56 -0
  70. package/src/certifications-client.js +89 -0
  71. package/src/certifications-flow.js +75 -0
  72. package/src/certify-dimension-client.js +163 -0
  73. package/src/certify-dimension-flow.js +135 -0
  74. package/src/certify-disclaimer.js +51 -0
  75. package/src/certify-remediation-prompt.js +23 -0
  76. package/src/cli-args.js +83 -0
  77. package/src/command-audit.js +105 -0
  78. package/src/config-dir.js +120 -0
  79. package/src/config.js +877 -0
  80. package/src/consent-flow.js +115 -0
  81. package/src/consent-skip.js +27 -0
  82. package/src/cv-file.js +62 -0
  83. package/src/cv-text.js +92 -0
  84. package/src/decisions-extract.js +43 -0
  85. package/src/detect-repos.js +145 -0
  86. package/src/detected-tools.js +15 -0
  87. package/src/detection-evidence.js +105 -0
  88. package/src/detection-noise.js +132 -0
  89. package/src/detectors.js +221 -0
  90. package/src/device-auth-store.js +100 -0
  91. package/src/device-login.js +120 -0
  92. package/src/email-verification.js +152 -0
  93. package/src/env-paths.js +30 -0
  94. package/src/find-projects-cache.js +89 -0
  95. package/src/find-projects-client.js +148 -0
  96. package/src/find-projects-flow.js +164 -0
  97. package/src/git-activity.js +124 -0
  98. package/src/git-log-prefetch.js +53 -0
  99. package/src/graph-certs.js +58 -0
  100. package/src/graph-generator.js +230 -0
  101. package/src/graph-scan.js +239 -0
  102. package/src/i18n.js +3341 -0
  103. package/src/inter-fonts.js +6 -0
  104. package/src/interactive-select.js +183 -0
  105. package/src/interview-client.js +226 -0
  106. package/src/interview-transcript.js +56 -0
  107. package/src/inventory-client.js +90 -0
  108. package/src/invitations-client.js +66 -0
  109. package/src/invitations-flow.js +50 -0
  110. package/src/lang-client.js +147 -0
  111. package/src/lang-codes.js +14 -0
  112. package/src/lang-flow.js +85 -0
  113. package/src/legal-notice.js +53 -0
  114. package/src/locale.js +72 -0
  115. package/src/markdown-ansi.js +50 -0
  116. package/src/maturity.js +111 -0
  117. package/src/mcp-ai-usage-result-tool.js +111 -0
  118. package/src/mcp-ai-usage-tool.js +401 -0
  119. package/src/mcp-alma-tools.js +95 -0
  120. package/src/mcp-applications-tools.js +44 -0
  121. package/src/mcp-auth-form.js +480 -0
  122. package/src/mcp-availability-tools.js +45 -0
  123. package/src/mcp-certifications-tools.js +44 -0
  124. package/src/mcp-certify-dimension-tools.js +66 -0
  125. package/src/mcp-choice.js +95 -0
  126. package/src/mcp-detector.js +223 -0
  127. package/src/mcp-device-login-tools.js +177 -0
  128. package/src/mcp-find-projects-tools.js +69 -0
  129. package/src/mcp-install.js +275 -0
  130. package/src/mcp-instructions.js +27 -0
  131. package/src/mcp-invitations-tools.js +44 -0
  132. package/src/mcp-lang-tools.js +41 -0
  133. package/src/mcp-onboarding-tools.js +379 -0
  134. package/src/mcp-portfolios-tools.js +50 -0
  135. package/src/mcp-profile-tools.js +48 -0
  136. package/src/mcp-prompts.js +73 -0
  137. package/src/mcp-rate-tools.js +44 -0
  138. package/src/mcp-register-tools.js +441 -0
  139. package/src/mcp-report-tools.js +105 -0
  140. package/src/mcp-role-tools.js +372 -0
  141. package/src/mcp-saved-positions-tools.js +59 -0
  142. package/src/mcp-server.js +290 -0
  143. package/src/mcp-show-project-tools.js +52 -0
  144. package/src/mcp-signup-client.js +118 -0
  145. package/src/mcp-signup-tools.js +1171 -0
  146. package/src/mcp-socials-tools.js +41 -0
  147. package/src/memory-structure-detector.js +108 -0
  148. package/src/model-call-record.js +198 -0
  149. package/src/onboarding-availability.js +26 -0
  150. package/src/onboarding-client.js +374 -0
  151. package/src/onboarding-flow.js +1162 -0
  152. package/src/onboarding-funnel.js +272 -0
  153. package/src/onboarding-livekit-client.js +102 -0
  154. package/src/onboarding-status-client.js +57 -0
  155. package/src/open-file.js +32 -0
  156. package/src/osc-link.js +16 -0
  157. package/src/portfolio-client.js +164 -0
  158. package/src/portfolio-git-info.js +61 -0
  159. package/src/portfolios-client.js +85 -0
  160. package/src/portfolios-flow.js +79 -0
  161. package/src/profile-client.js +133 -0
  162. package/src/profile-flow.js +115 -0
  163. package/src/profile-switch.js +46 -0
  164. package/src/prompt-select.js +66 -0
  165. package/src/rate-client.js +81 -0
  166. package/src/rate-flow.js +137 -0
  167. package/src/register-context.js +145 -0
  168. package/src/render-ai-fluency.js +266 -0
  169. package/src/render-certification.js +177 -0
  170. package/src/render-html.js +140 -0
  171. package/src/render-report-text.js +27 -0
  172. package/src/render-sheet.js +261 -0
  173. package/src/render-terminal.js +109 -0
  174. package/src/render-usage-report-text.js +155 -0
  175. package/src/repo-context.js +215 -0
  176. package/src/report-gating.js +49 -0
  177. package/src/report-store.js +226 -0
  178. package/src/report-theme.js +177 -0
  179. package/src/report-view-client.js +50 -0
  180. package/src/reveal-output.js +29 -0
  181. package/src/roadmap-content.js +453 -0
  182. package/src/roadmap-personalization.js +132 -0
  183. package/src/roadmap-prompt.js +154 -0
  184. package/src/roles-client.js +112 -0
  185. package/src/roles-flow.js +192 -0
  186. package/src/sanitize-network-text.js +36 -0
  187. package/src/saved-positions-client.js +71 -0
  188. package/src/saved-positions-flow.js +50 -0
  189. package/src/scan-exclusions.js +50 -0
  190. package/src/scanner.js +624 -0
  191. package/src/scope-signals.js +148 -0
  192. package/src/secret-prompt.js +72 -0
  193. package/src/session-gate.js +30 -0
  194. package/src/session-normalizers.js +174 -0
  195. package/src/session-refresh.js +51 -0
  196. package/src/session-scan.js +475 -0
  197. package/src/session-signals.js +36 -0
  198. package/src/shakers-wordmark.js +17 -0
  199. package/src/share-card.js +362 -0
  200. package/src/share-certification.js +111 -0
  201. package/src/share-consent-state.js +157 -0
  202. package/src/share-payload.js +300 -0
  203. package/src/share.js +185 -0
  204. package/src/show-project-client.js +58 -0
  205. package/src/show-project-flow.js +119 -0
  206. package/src/signup-client.js +103 -0
  207. package/src/signup-copy.js +450 -0
  208. package/src/signup-language.js +21 -0
  209. package/src/skills-client.js +102 -0
  210. package/src/socials-client.js +91 -0
  211. package/src/socials-flow.js +83 -0
  212. package/src/specificity-extract.js +41 -0
  213. package/src/start-add-agents.js +306 -0
  214. package/src/start-add-portfolio.js +388 -0
  215. package/src/start-add-skills.js +260 -0
  216. package/src/stdin-ask.js +129 -0
  217. package/src/steering-extract.js +43 -0
  218. package/src/strip-internal-comments.js +45 -0
  219. package/src/tech-detector.js +295 -0
  220. package/src/templates/report-sheet.html +572 -0
  221. package/src/terminal-agents.js +207 -0
  222. package/src/terminal-format.js +85 -0
  223. package/src/terminal-progress.js +106 -0
  224. package/src/terminal-sections.js +219 -0
  225. package/src/tier-analysis.js +126 -0
  226. package/src/tier-engine.js +114 -0
  227. package/src/tier-projection.js +117 -0
  228. package/src/transcript-turns.js +49 -0
  229. package/src/translations-client.js +84 -0
  230. package/src/typewriter.js +154 -0
  231. package/src/update-flow.js +223 -0
  232. package/src/update-notifier.js +113 -0
  233. package/src/usage-model-calls.js +119 -0
  234. package/src/usage-oneshot-commands.js +180 -0
  235. package/src/usage-repo-scope.js +162 -0
  236. package/src/usage-run-steps.js +354 -0
  237. package/src/work-streams.js +79 -0
@@ -0,0 +1,1171 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ WORK_SITUATIONS, EMPLOYMENT_PARTICIPATIONS, FREELANCE_OPINIONS, CHANGE_MOTIVATORS,
5
+ WORK_MODES, MONTHLY_HOURS, LANGUAGE_CODES, LANGUAGE_LEVELS,
6
+ } = require('./onboarding-flow');
7
+ const { signupCopy, legalCopy, renderDraft, amount } = require('./signup-copy');
8
+ const { fixSignupLanguage, signupLanguage } = require('./signup-language');
9
+ const path = require('path');
10
+ const { readLocalCv } = require('./cv-file');
11
+ const { emailFromHubToken } = require('./auth-session-store');
12
+ const { askAfterTexts, askConsent, optionFor, queueNotice, takeNotices } = require('./mcp-choice');
13
+ const { expandHome, tildify } = require('./env-paths');
14
+ const { NOOP_FUNNEL, weeklyHours, rateTypes, workSituationValue } = require('./onboarding-funnel');
15
+
16
+ // MCP sign-up (ticket 39540): hub imports into an UNREGISTERED profile that the local window claims with a code kept only in memory.
17
+
18
+ const SIGNUP_WINDOW_TIMEOUT_MS = 15 * 60 * 1000;
19
+ // The browser opens this long after the tool answers, so the AI's reply is on screen first.
20
+ const BROWSER_OPEN_DELAY_MS = 4000;
21
+ const MAX_WAIT_SECONDS = 25;
22
+ const IMPORT_POLL_MS = 3000;
23
+ // Languages the profile importer writes in.
24
+ const IMPORT_LANGUAGES = new Set(['es', 'en', 'it', 'pt', 'fr']);
25
+ // The web's high-rate notice; above it a rate is a per-project or annual figure in the wrong field.
26
+ const MAX_HOURLY_RATE = 500;
27
+ const MIN_ANNUAL_RATE = 1000;
28
+ // LinkedIn redirects a signed-in member from /in/me/ to their own /in/<slug>/.
29
+ const LINKEDIN_SELF_URL = 'https://www.linkedin.com/in/me/';
30
+ const LINKEDIN_PROFILE_RE = /^(?:https?:\/\/)?(?:[a-z]{2,3}\.)?linkedin\.com\/in\/([A-Za-z0-9_%-]+)\/?(?:[?#].*)?$/i;
31
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
32
+ // Personal mailbox providers; any other domain may be the talent's employer, so the email question adds the work-email hint.
33
+ const PERSONAL_EMAIL_DOMAINS = new Set(['gmail.com', 'googlemail.com', 'outlook.com', 'outlook.es', 'hotmail.com', 'hotmail.es', 'hotmail.it', 'live.com', 'msn.com', 'yahoo.com', 'yahoo.es', 'yahoo.it', 'ymail.com', 'icloud.com', 'me.com', 'mac.com', 'proton.me', 'protonmail.com', 'pm.me', 'gmx.com', 'gmx.es', 'gmx.de', 'aol.com', 'zoho.com', 'tutanota.com', 'libero.it', 'virgilio.it', 'tiscali.it', 'sapo.pt', 'mail.com', 'yandex.com', 'fastmail.com', 'hey.com', 'uol.com.br', 'bol.com.br']);
34
+ const INVALID_LINKEDIN_MESSAGE = 'That is not a LinkedIn profile URL (linkedin.com/in/<slug>; /in/me only redirects to it). Ask the talent once more to copy the address bar of their profile page, or continue with the CV only.';
35
+ // What the model must send instead when a work-situation section is refused.
36
+ const SECTION_HELP = {
37
+ 'bad-situation': 'workSituation.situation must be one of its enum codes.',
38
+ 'bad-participation': 'workSituation.participation (FULL_TIME or PART_TIME) is required when situation is EMPLOYED.',
39
+ 'bad-opinion': 'workSituation.opinion is required unless situation is FREELANCE: pick your best match from its enum.',
40
+ 'bad-motivation': 'workSituation.changeMotivators is required when situation is FREELANCE or opinion is WAS_FREELANCE_BEFORE or OPEN_TO_FREELANCE: pick your best match from its enum.',
41
+ };
42
+ // What the closing words may say about the profile import, by the state read at that moment.
43
+ const IMPORT_CLOSING = {
44
+ done: 'The profile import finished: if you mention it, say it is done, never that it is pending.',
45
+ running: 'The profile import is still running: if you mention it, say it finishes on its own in a few minutes.',
46
+ failed: 'Say nothing more about the profile import than the fixed lines already shown.',
47
+ none: 'Say nothing about the state of the profile import.',
48
+ };
49
+ const VERBATIM = 'Print relayVerbatim word for word now, as its own block, before any other tool call, with nothing of your own added to it.';
50
+
51
+ // Providers that serve the same personal mailboxes under many country domains (hotmail.co.uk, yahoo.com.br, outlook.it).
52
+ const PERSONAL_EMAIL_PROVIDERS = new Set(['gmail', 'googlemail', 'outlook', 'hotmail', 'live', 'msn', 'yahoo', 'ymail', 'icloud', 'proton', 'protonmail', 'gmx', 'aol', 'zoho', 'tutanota', 'libero', 'virgilio', 'tiscali', 'sapo', 'yandex', 'fastmail', 'uol', 'bol', 'terra']);
53
+ // Reserved for documentation and tests (RFC 2606, RFC 6761): never anybody's employer.
54
+ const RESERVED_EMAIL_DOMAIN_RE = /(^|\.)(example\.(com|net|org)|example|test|invalid|localhost)$/;
55
+
56
+ function emailDomain(email) {
57
+ return String(email).split('@').pop().toLowerCase();
58
+ }
59
+
60
+ function isPersonalEmail(email) {
61
+ const domain = emailDomain(email);
62
+ return PERSONAL_EMAIL_DOMAINS.has(domain) || PERSONAL_EMAIL_PROVIDERS.has(domain.split('.')[0]);
63
+ }
64
+
65
+ // Only an address that looks like a company one gets the hint to prefer a personal email.
66
+ function looksLikeWorkEmail(email) {
67
+ return !isPersonalEmail(email) && !RESERVED_EMAIL_DOMAIN_RE.test(emailDomain(email));
68
+ }
69
+
70
+ function isLinkedinProfileUrl(url) {
71
+ const m = LINKEDIN_PROFILE_RE.exec(url);
72
+ return !!m && m[1].toLowerCase() !== 'me';
73
+ }
74
+
75
+ // Web semantics: the hourly rate is the part-time project price and the annual target the full-time one, always EUR.
76
+ function pricingFromRates({ hourlyRate, annualRate } = {}) {
77
+ const hourly = hourlyRate != null && hourlyRate !== '';
78
+ const annual = annualRate != null && annualRate !== '';
79
+ if ((hourly && Number(hourlyRate) > MAX_HOURLY_RATE) || (annual && Number(annualRate) < MIN_ANNUAL_RATE)) {
80
+ return { ok: false, reason: 'implausible-rate', message: `hourlyRate is euros per hour (at most ${MAX_HOURLY_RATE}) and annualRate euros per year (at least ${MIN_ANNUAL_RATE}): re-estimate and save the pricing again.` };
81
+ }
82
+ return {
83
+ ok: true,
84
+ input: { partTimeSelected: hourly, partTimeAmount: hourlyRate, partTimeCurrency: 'EUR', fullTimeSelected: annual, fullTimeAmount: annualRate, fullTimeCurrency: 'EUR' },
85
+ };
86
+ }
87
+
88
+ function labelledOptions(codes, labels) {
89
+ return codes.map((code) => `${labels[code] || code} (${code})`).join(', ');
90
+ }
91
+
92
+ function buildDetailsSchema(lang) {
93
+ const L = require('./i18n').getCatalog(lang).onboarding;
94
+ const show = 'Show the talent the label, pass the code';
95
+ return {
96
+ type: 'object',
97
+ description: 'Every section is optional and saved on its own; send what you inferred.',
98
+ properties: {
99
+ workSituation: {
100
+ type: 'object',
101
+ description: 'Current work situation, inferred from what you know (an employer in the CV or memory means EMPLOYED there).',
102
+ properties: {
103
+ situation: { type: 'string', enum: WORK_SITUATIONS, description: `${show}: ${labelledOptions(WORK_SITUATIONS, L.workSituationLabels)}. 'EMPLOYED' requires participation.` },
104
+ participation: { type: 'string', enum: EMPLOYMENT_PARTICIPATIONS, description: `Only when EMPLOYED. ${show}: ${labelledOptions(EMPLOYMENT_PARTICIPATIONS, L.employmentParticipationLabels)}.` },
105
+ opinion: { type: 'string', enum: FREELANCE_OPINIONS, description: `Opinion on freelancing. Required unless situation is FREELANCE. ${show}: ${labelledOptions(FREELANCE_OPINIONS, L.freelanceOpinionLabels)}.` },
106
+ changeMotivators: { type: 'string', enum: CHANGE_MOTIVATORS, description: `Motivation, your best match. Required when situation is FREELANCE or opinion is WAS_FREELANCE_BEFORE or OPEN_TO_FREELANCE; omit it only when opinion is NOT_INTERESTED. ${show}: ${labelledOptions(CHANGE_MOTIVATORS, L.changeMotivatorLabels)}.` },
107
+ },
108
+ required: ['situation'],
109
+ },
110
+ pricing: {
111
+ type: 'object',
112
+ description: 'The two rates the web asks for, in euros: your concrete estimate from role, seniority, stack and location. Plain numbers, no thousands separators. Send both when you can.',
113
+ properties: {
114
+ hourlyRate: { type: 'number', description: `Euros per hour (e.g. 45), at most ${MAX_HOURLY_RATE}.` },
115
+ annualRate: { type: 'number', description: `Target euros per year (e.g. 60000), at least ${MIN_ANNUAL_RATE}.` },
116
+ },
117
+ },
118
+ availability: {
119
+ type: 'object',
120
+ description: 'Availability AND location (there is no separate location field).',
121
+ properties: {
122
+ available: { type: 'boolean' },
123
+ monthlyHours: { type: 'string', enum: MONTHLY_HOURS, description: "Exactly one of the enum values ('> 160' verbatim, with the space)." },
124
+ workModes: { type: 'array', items: { type: 'string', enum: WORK_MODES }, description: `Any subset of ${WORK_MODES.join('/')}.` },
125
+ country: { type: 'string', description: 'ISO-2 country code (ES, US, GB).' },
126
+ timezone: { type: 'string', description: 'IANA timezone (Europe/Madrid).' },
127
+ subdivision: { type: 'string', description: 'Optional region code (ES-CT).' },
128
+ longFullTimeProjects: { type: 'boolean' },
129
+ },
130
+ },
131
+ languages: {
132
+ type: 'array',
133
+ description: `The FULL list of languages the talent speaks (bulk upsert). Languages: ${labelledOptions(LANGUAGE_CODES, L.languageNames)}. Levels: ${labelledOptions(LANGUAGE_LEVELS, L.languageLevelLabels)}.`,
134
+ items: {
135
+ type: 'object',
136
+ properties: {
137
+ language: { type: 'string', enum: LANGUAGE_CODES },
138
+ level: { type: 'string', enum: LANGUAGE_LEVELS },
139
+ },
140
+ required: ['language', 'level'],
141
+ },
142
+ },
143
+ phone: {
144
+ type: 'object',
145
+ description: 'Only a phone the talent gave or that is on their CV.',
146
+ properties: {
147
+ telephoneCode: { type: 'string', description: "International code, e.g. '+34'." },
148
+ telephoneNumber: { type: 'string' },
149
+ },
150
+ required: ['telephoneCode', 'telephoneNumber'],
151
+ },
152
+ },
153
+ };
154
+ }
155
+
156
+ const WELCOME_SCHEMA = {
157
+ type: 'object',
158
+ properties: {
159
+ language: { type: 'string', description: "ISO code of the language the talent wrote their first message in (es, en, it, pt...). It fixes the language of every sign-up text from here on." },
160
+ firstName: { type: 'string', description: 'Only if you already know it: the welcome greets them by name.' },
161
+ answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
162
+ cvPath: { type: 'string', description: 'When the talent\'s first message or answer already gives their CV as a path on this machine: that exact file is read, with no CV question, no search and no request for the file.' },
163
+ linkedinMissing: { type: 'boolean', description: 'true only after read_cv, when the CV has no LinkedIn URL: returns the LinkedIn question to print as your whole reply.' },
164
+ },
165
+ required: ['language'],
166
+ };
167
+
168
+ const EMAIL_SCHEMA = {
169
+ type: 'object',
170
+ properties: {
171
+ email: { type: 'string', description: 'The best email you know for the talent (CV first, then what they told you). Omit it when you know none.' },
172
+ typed: { type: 'boolean', description: 'true when `email` is the one the talent just wrote in answer to the email question: it is taken as is.' },
173
+ answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
174
+ },
175
+ };
176
+
177
+ // In the order the data notice names them.
178
+ const DRAFT_SOURCES = ['linkedin', 'cv'];
179
+ const DRAFT_FIELDS = ['name', 'role', 'city', 'country', 'situation', 'participation', 'yearsOfExperience', 'stack', 'languages', 'workMode', 'monthlyHours', 'hourlyRate', 'annualRate'];
180
+ // Draft fields per funnel step, for was_edited.
181
+ const STEP_DRAFT_FIELDS = {
182
+ work_situation: ['situation', 'participation'],
183
+ availability: ['monthlyHours', 'workMode'],
184
+ rates: ['hourlyRate', 'annualRate'],
185
+ languages: ['languages'],
186
+ location: ['country', 'city'],
187
+ };
188
+
189
+ const DRAFT_SCHEMA = {
190
+ type: 'object',
191
+ description: 'Your draft of the profile, from the CV, LinkedIn and what you know. Every field is shown as you write it, in the talent\'s language.',
192
+ properties: {
193
+ name: { type: 'string', description: 'Full name.' },
194
+ role: { type: 'string', description: 'Role, e.g. "Data Engineer".' },
195
+ city: { type: 'string' },
196
+ country: { type: 'string', description: 'ISO-2 country code (ES, US, GB): the same country save_profile_details will write.' },
197
+ situation: { type: 'string', enum: WORK_SITUATIONS, description: 'Current work situation, the same code save_profile_details will write.' },
198
+ participation: { type: 'string', enum: EMPLOYMENT_PARTICIPATIONS, description: 'Only when situation is EMPLOYED.' },
199
+ yearsOfExperience: { type: 'number' },
200
+ stack: { type: 'string', description: 'Main technologies, comma separated, at most five.' },
201
+ languages: { type: 'string', description: 'Languages and level, e.g. "Español nativo, inglés C1".' },
202
+ workMode: { type: 'string', description: 'Remote, hybrid or on site, in the talent\'s language.' },
203
+ monthlyHours: { type: 'string', description: `Hours a month they can work: one of ${MONTHLY_HOURS.join(', ')}.` },
204
+ hourlyRate: { type: 'number', description: `Euros per hour (part-time projects), at most ${MAX_HOURLY_RATE}: the talent's figure, else your estimate from role, seniority, stack and location.` },
205
+ annualRate: { type: 'number', description: `Euros per year (full-time projects), at least ${MIN_ANNUAL_RATE}: the talent's figure, else your estimate.` },
206
+ ratesFromTalent: { type: 'boolean', description: 'true only when the talent gave you both rates; omit it when you estimated them (they are shown as your estimate).' },
207
+ sources: { type: 'array', items: { type: 'string', enum: DRAFT_SOURCES }, description: "What the draft comes from: 'cv' when you read their CV, 'linkedin' only when you have their LinkedIn URL. The data notice names exactly these." },
208
+ answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
209
+ },
210
+ required: ['name', 'role', 'hourlyRate', 'annualRate', 'sources'],
211
+ };
212
+
213
+ const CREATE_SCHEMA = {
214
+ type: 'object',
215
+ properties: {
216
+ linkedinUrl: { type: 'string', description: 'The talent\'s LinkedIn profile URL (linkedin.com/in/<slug>, never /in/me).' },
217
+ cvPath: { type: 'string', description: 'Path of the talent\'s own CV on this machine (from read_cv / suggest_register_context, ~/ form is fine). Only a PDF is uploaded at sign-up.' },
218
+ firstName: { type: 'string' },
219
+ lastName: { type: 'string' },
220
+ userQuery: { type: 'string', description: 'EVERYTHING you know about the talent as free text (max 20000 chars): roles, projects, stack, education, languages, location, what they want next, and the CV text when the CV was only attached in the chat.' },
221
+ },
222
+ };
223
+
224
+ const STATUS_SCHEMA = {
225
+ type: 'object',
226
+ properties: {
227
+ waitSeconds: { type: 'number', description: `Wait up to this many seconds (max ${MAX_WAIT_SECONDS}) for the account or the import to change before answering.` },
228
+ windowLink: { type: 'boolean', description: 'true only when the talent says the sign-up window did not open: returns the text with its link.' },
229
+ },
230
+ };
231
+
232
+ const UPDATE_EXISTING_SCHEMA = {
233
+ type: 'object',
234
+ properties: {
235
+ answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
236
+ },
237
+ };
238
+
239
+ const IMPORT_SCHEMA = {
240
+ type: 'object',
241
+ properties: {
242
+ linkedinUrl: { type: 'string' },
243
+ cvPath: { type: 'string', description: 'Path of the talent\'s own CV on this machine (PDF, DOC or DOCX, max 10 MB).' },
244
+ githubUrl: { type: 'string' },
245
+ websiteUrl: { type: 'string' },
246
+ userQuery: { type: 'string', description: 'Everything you know about the talent, as free text.' },
247
+ language: { type: 'string', enum: [...IMPORT_LANGUAGES] },
248
+ answer: { type: 'string', description: 'The option the talent picked when you printed the question this tool returned about the CV file. Never set it yourself.' },
249
+ },
250
+ };
251
+
252
+ function defaultSleep(ms) {
253
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
254
+ }
255
+
256
+ function nonEmpty(value) {
257
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
258
+ }
259
+
260
+ // Hub's sign-up takes the CV only as a PDF; anything else travels as text in userQuery and is imported after sign-up.
261
+ function readSignupCv(cvPath) {
262
+ return readLocalCv(cvPath, { extensions: ['.pdf'], badFormat: 'cv-not-pdf' });
263
+ }
264
+
265
+ let currentSignup = null;
266
+
267
+ // Whether a sign-up window opened by this process is still waiting for the talent.
268
+ function isSignupPending() {
269
+ return signupPhase() === 'waiting';
270
+ }
271
+
272
+ // Where this process's sign-up stands: ai_usage is offered only once the account exists.
273
+ function signupPhase() {
274
+ const account = currentSignup ? currentSignup.account.state : 'not-started';
275
+ if (account === 'waiting') return 'waiting';
276
+ return account === 'created' || account === 'existing' ? 'account' : 'none';
277
+ }
278
+
279
+ function makeSignupTools(deps = {}) {
280
+ const lang = () => signupLanguage(deps.lang || 'en');
281
+ const copy = () => signupCopy(lang());
282
+ const flow = require('./onboarding-flow');
283
+ const resolved = flow.makeDeps(deps.flowDeps || {});
284
+ const config = require('./config');
285
+ const {
286
+ requestMcpSignup = require('./mcp-signup-client').requestMcpSignup,
287
+ requestMyImportStatus = require('./mcp-signup-client').requestMyImportStatus,
288
+ requestOneTimeToken = require('./mcp-signup-client').requestOneTimeToken,
289
+ getMcpSignupEndpoint = config.getMcpSignupEndpoint,
290
+ getMyImportStatusEndpoint = config.getMyImportStatusEndpoint,
291
+ getOneTimeTokenEndpoint = config.getOneTimeTokenEndpoint,
292
+ getOneTimeLoginUrl = config.getOneTimeLoginUrl,
293
+ getDeviceAuthorizeEndpoint = config.getDeviceAuthorizeEndpoint,
294
+ getDeviceTokenEndpoint = config.getDeviceTokenEndpoint,
295
+ getAuthTokenEndpoint = config.getAuthTokenEndpoint,
296
+ getCompleteRegistrationEndpoint = config.getCompleteRegistrationEndpoint,
297
+ startDeviceLogin = require('./device-login').startDeviceLogin,
298
+ pollDeviceToken = require('./device-login').pollDeviceToken,
299
+ finalizeDeviceSession = require('./device-login').finalizeDeviceSession,
300
+ appendProviderParam = require('./device-login').appendProviderParam,
301
+ buildRegistrationContext = require('./signup-client').buildRegistrationContext,
302
+ runLoopbackAuth = require('./mcp-auth-form').runLoopbackAuth,
303
+ checkOnboardingCompleted = require('./onboarding-availability').checkOnboardingCompleted,
304
+ fetchPricingRate = (opts) => require('./rate-client').fetchPricingRate({}, opts),
305
+ fetchAvailability = (opts) => require('./availability-client').fetchAvailability({}, opts),
306
+ fetchLanguages = (opts) => require('./profile-client').fetchLanguages({}, opts),
307
+ funnel = NOOP_FUNNEL,
308
+ fetchTelephone = (opts) => require('./profile-client').fetchTelephone({}, opts),
309
+ openBrowser = require('./open-file').openPath,
310
+ sleep = defaultSleep,
311
+ now = () => Date.now(),
312
+ } = deps;
313
+
314
+ const state = {
315
+ claimCode: null,
316
+ windowUrl: null,
317
+ windowOpenFailed: false,
318
+ sessionToken: null,
319
+ email: null,
320
+ draft: null,
321
+ firstDraft: null,
322
+ draftConfirmed: false,
323
+ keepProfile: false,
324
+ // Fields the update question already told the talent they keep.
325
+ toldKept: new Set(),
326
+ // The confirmed draft already said the account exists (this machine knew it).
327
+ toldExisting: false,
328
+ sources: null,
329
+ account: { state: 'not-started' },
330
+ reported: { account: null, import: null },
331
+ failedSources: new Set(),
332
+ cvPath: null,
333
+ wake: null,
334
+ importStartedAt: null,
335
+ importReported: new Set(),
336
+ };
337
+ currentSignup = state;
338
+
339
+ function setAccount(next) {
340
+ const before = state.account.state;
341
+ state.account = next;
342
+ if ((next.state === 'created' || next.state === 'existing') && before !== next.state) {
343
+ funnel.completed('account_creation', { auth_method: next.via, is_returning_account: next.state === 'existing' });
344
+ funnel.identified();
345
+ }
346
+ if (state.wake) state.wake();
347
+ }
348
+
349
+ function changed() {
350
+ return new Promise((resolve) => { state.wake = resolve; });
351
+ }
352
+
353
+ function openSoon(url, onFailed = () => {}) {
354
+ sleep(BROWSER_OPEN_DELAY_MS).then(() => { if (openBrowser(url) === false) onFailed(); }).catch(onFailed);
355
+ }
356
+
357
+ // Background device flow behind the window's Google button; the claim code is read when it finalizes.
358
+ async function finishGoogle(authz, tokenEndpoint, authTokenEndpoint) {
359
+ let interval = authz.interval;
360
+ const deadline = now() + authz.expiresIn * 1000;
361
+ while (now() < deadline) {
362
+ await sleep(interval * 1000);
363
+ const res = await pollDeviceToken({ deviceCode: authz.deviceCode, tokenEndpoint });
364
+ if (res.status === 'pending') continue;
365
+ if (res.status === 'slow_down') { interval += 5; continue; }
366
+ if (res.status !== 'authorized') {
367
+ setAccount({ state: 'failed', reason: res.status === 'denied' || res.status === 'expired' ? `google-${res.status}` : (res.reason || 'google-failed') });
368
+ return;
369
+ }
370
+ const fin = await finalizeDeviceSession({
371
+ sessionToken: res.accessToken,
372
+ isNewUser: res.isNewUser,
373
+ authTokenEndpoint,
374
+ completeRegistrationEndpoint: getCompleteRegistrationEndpoint(),
375
+ registrationContext: buildRegistrationContext({ preferredLanguage: lang(), newsletterConsent: false, freelanceType: 'POTENTIAL_FREELANCE' }),
376
+ claimCode: res.isNewUser ? state.claimCode : null,
377
+ });
378
+ if (!fin.ok) {
379
+ setAccount({ state: 'failed', reason: fin.reason });
380
+ return;
381
+ }
382
+ const email = res.email || emailFromHubToken(fin.token);
383
+ try {
384
+ resolved.saveAuthSession({ accessToken: fin.token, hubAccessToken: fin.token, email });
385
+ } catch {
386
+ setAccount({ state: 'failed', reason: 'session-persist-failed' });
387
+ return;
388
+ }
389
+ state.sessionToken = res.accessToken;
390
+ setAccount(res.isNewUser
391
+ ? { state: 'created', via: 'google', email, claimed: fin.claimed === true }
392
+ : { state: 'existing', via: 'google', email });
393
+ return;
394
+ }
395
+ setAccount({ state: 'failed', reason: 'google-expired' });
396
+ }
397
+
398
+ async function onGoogle() {
399
+ const authorizeEndpoint = getDeviceAuthorizeEndpoint();
400
+ const tokenEndpoint = getDeviceTokenEndpoint();
401
+ const authTokenEndpoint = getAuthTokenEndpoint();
402
+ if (!authorizeEndpoint || !tokenEndpoint || !authTokenEndpoint) return { ok: false, reason: 'no-endpoint' };
403
+ const authz = await startDeviceLogin({ authorizeEndpoint });
404
+ if (!authz.ok) return { ok: false, reason: authz.reason || 'authorize-failed' };
405
+ finishGoogle(authz, tokenEndpoint, authTokenEndpoint).catch(() => setAccount({ state: 'failed', reason: 'google-failed' }));
406
+ return { ok: true, url: appendProviderParam(authz.verificationUriComplete, 'google') };
407
+ }
408
+
409
+ function openWindow(identity) {
410
+ setAccount({ state: 'waiting' });
411
+ let announce;
412
+ const announced = new Promise((resolve) => { announce = resolve; });
413
+ runLoopbackAuth(
414
+ {
415
+ mode: 'register',
416
+ deps: resolved,
417
+ lang: lang(),
418
+ registerFields: {
419
+ email: identity.email,
420
+ name: identity.firstName,
421
+ lastName: identity.lastName,
422
+ preferredLanguage: lang(),
423
+ claimCode: () => state.claimCode,
424
+ },
425
+ },
426
+ {
427
+ timeoutMs: SIGNUP_WINDOW_TIMEOUT_MS,
428
+ openBrowser: (url) => openSoon(url, () => { state.windowOpenFailed = true; }),
429
+ onGoogle,
430
+ onUrl: (url) => { state.windowUrl = url; announce(url); },
431
+ },
432
+ ).then((outcome) => {
433
+ if (outcome && outcome.viaSocial) return;
434
+ if (!outcome || !outcome.ok) {
435
+ setAccount({ state: 'failed', reason: (outcome && outcome.reason) || 'window-failed' });
436
+ return;
437
+ }
438
+ setAccount(outcome.accountExists
439
+ ? { state: 'existing', via: 'email', email: outcome.email }
440
+ : { state: 'created', via: 'email', email: outcome.email, claimed: outcome.claimed === true });
441
+ }, () => setAccount({ state: 'failed', reason: 'window-failed' }));
442
+ return Promise.race([announced, sleep(2000).then(() => state.windowUrl)]);
443
+ }
444
+
445
+ // Step 1: welcome, value proposal, the three steps and one question (send me your CV, or shall I look for it here?).
446
+ async function signupStart(args = {}, ctx = {}) {
447
+ // The LinkedIn question is a tool block, so a welcome still pending is read before it and the AI words nothing.
448
+ if (args.linkedinMissing === true && !nonEmpty(args.answer)) {
449
+ const c = copy();
450
+ return {
451
+ ok: true,
452
+ step: 'linkedin-ask',
453
+ say: [...takeNotices(), c.linkedinAsk].join('\n\n'),
454
+ linkedinOpening: c.linkedinOpening,
455
+ message: 'Print `say` word for word as your whole reply, with nothing of your own before or after it, and call no tool until the talent answers. With their URL, call signup_email. Only if they say they do not know where to find it, say `linkedinOpening` word for word and call open_linkedin_profile.',
456
+ };
457
+ }
458
+ if (!nonEmpty(args.answer)) {
459
+ fixSignupLanguage(args.language);
460
+ funnel.begin();
461
+ funnel.viewed('start');
462
+ state.draft = null;
463
+ state.firstDraft = null;
464
+ state.importStartedAt = null;
465
+ state.importReported = new Set();
466
+ state.draftConfirmed = false;
467
+ state.email = null;
468
+ state.failedSources.clear();
469
+ state.toldKept = new Set();
470
+ state.toldExisting = false;
471
+ state.cvPath = null;
472
+ takeNotices();
473
+ }
474
+ if (nonEmpty(args.cvPath)) state.cvPath = nonEmpty(args.cvPath);
475
+ const c = copy();
476
+ const linkedin = 'LinkedIn: use the URL in the CV (read_cv returns links.linkedin) and call signup_email. Only if the CV has none, call signup_start with linkedinMissing:true: it returns the LinkedIn question to print. Never ask for LinkedIn in your own words.';
477
+ // A CV path in the first message already answers the CV question: that CV is read, and the welcome
478
+ // rides on the next question block, because models skip a relay that comes before another tool call.
479
+ if (!nonEmpty(args.answer) && state.cvPath) {
480
+ funnel.completed('start');
481
+ queueNotice(c.welcome(nonEmpty(args.firstName)));
482
+ return {
483
+ ok: true,
484
+ step: 'cv-given',
485
+ cvPath: state.cvPath,
486
+ language: lang(),
487
+ message: `Never explain to the talent what you are doing or what the CV holds: every text they read in this step comes from the tools, the welcome included. Now call read_cv with cvPath: it asks the talent itself before reading the file, so ask nothing about the CV in your own words. ${linkedin}`,
488
+ };
489
+ }
490
+ const asked = await askAfterTexts(ctx, {
491
+ tool: 'signup_start',
492
+ texts: [c.welcome(nonEmpty(args.firstName))],
493
+ question: c.welcomeQuestion,
494
+ options: [c.cvAttach, c.cvSearch],
495
+ answer: args.answer,
496
+ });
497
+ if (asked.reply) return { ...asked.reply, language: lang() };
498
+ funnel.completed('start');
499
+ // A CV path the talent gave wins over both answers: searching could read another candidate.
500
+ const cvPath = state.cvPath;
501
+ if (cvPath) {
502
+ return {
503
+ ok: true,
504
+ step: 'cv-given',
505
+ cvPath,
506
+ message: `The talent already gave their CV: call read_cv with cvPath now (it asks the talent itself before reading the file), saying nothing about attaching it. ${linkedin}`,
507
+ };
508
+ }
509
+ if (asked.answer === c.cvSearch) {
510
+ return {
511
+ ok: true,
512
+ step: 'cv-search',
513
+ cvNotFound: c.cvNotFound,
514
+ message: `The talent chose to look for their CV on this computer: call suggest_register_context, then read_cv on its first candidate, even when nameMatch is null (their name is just unknown). Each of them asks the talent itself before searching or reading, so ask nothing in your own words. Only if there is no candidate, or every one comes back mentionsTalent:false, show \`cvNotFound\` word for word and wait for the file. ${linkedin}`,
515
+ };
516
+ }
517
+ return {
518
+ ok: true,
519
+ step: 'cv-attach',
520
+ relayVerbatim: c.cvAttachAsk,
521
+ message: `If their message already holds the CV (attached, pasted or as a path), do not print relayVerbatim: use that CV. Otherwise ${VERBATIM} Then wait for the CV in the chat. ${linkedin}`,
522
+ };
523
+ }
524
+
525
+ // Step 2: which email the account is created with, the one they read most; a company domain gets the work-email hint.
526
+ async function signupEmail(args = {}, ctx = {}) {
527
+ const c = copy();
528
+ const email = nonEmpty(args.email);
529
+ if (email && !EMAIL_RE.test(email)) return { ok: false, reason: 'invalid-email', relayVerbatim: c.emailUnknown, message: `That is not an email. ${VERBATIM} Then call signup_email with email set to what they write and typed:true.` };
530
+ if (email && args.typed === true) {
531
+ state.email = email;
532
+ return { ok: true, email, next: 'signup_draft', message: 'Email set. Now show the profile draft with signup_draft.' };
533
+ }
534
+ if (!email) {
535
+ return { ok: false, reason: 'email-required', relayVerbatim: c.emailUnknown, message: `${VERBATIM} It is the whole question. Then call signup_email with email set to what they write and typed:true.` };
536
+ }
537
+ const question = looksLikeWorkEmail(email) ? `${c.emailKnown(email)} ${c.emailWorkHint}` : c.emailKnown(email);
538
+ const asked = await askAfterTexts(ctx, { tool: 'signup_email', texts: [], question, options: [c.emailYes, c.emailOther], answer: args.answer });
539
+ if (asked.reply) return asked.reply;
540
+ if (asked.answer === c.emailYes) {
541
+ state.email = email;
542
+ return { ok: true, email, next: 'signup_draft', message: 'Email set. Now show the profile draft with signup_draft.' };
543
+ }
544
+ return { ok: false, reason: 'email-required', relayVerbatim: c.emailUnknown, message: `${VERBATIM} Then call signup_email with email set to what they write and typed:true.` };
545
+ }
546
+
547
+ // A session this machine kept for the same email means the account exists, so the window will ask to sign in.
548
+ function knownAccount() {
549
+ const stored = resolved.loadAuthSession();
550
+ const email = stored && typeof stored.email === 'string' ? stored.email.toLowerCase() : null;
551
+ return !!email && !!state.email && email === state.email.toLowerCase();
552
+ }
553
+
554
+ // Step 3: the draft, the data notice and how the window works; confirming it is what opens the window.
555
+ async function signupDraft(args = {}, ctx = {}) {
556
+ const c = copy();
557
+ const pricing = pricingFromRates(args);
558
+ if (!pricing.ok) return pricing;
559
+ const draft = {};
560
+ for (const key of DRAFT_FIELDS) {
561
+ if (args[key] !== undefined && args[key] !== null && args[key] !== '') draft[key] = args[key];
562
+ }
563
+ if (typeof draft.country === 'string') draft.country = draft.country.trim().toUpperCase();
564
+ if (!draft.name || !draft.role) return { ok: false, reason: 'draft-incomplete', message: 'The draft needs at least name and role: read the CV first.' };
565
+ if (draft.hourlyRate === undefined || draft.annualRate === undefined) {
566
+ return { ok: false, reason: 'draft-rates-required', message: 'The draft needs both rates: if the talent gave them, send them with ratesFromTalent:true; otherwise estimate hourlyRate (€/h) and annualRate (€/year) from role, seniority, stack and location, and send them without it. Then call signup_draft again.' };
567
+ }
568
+ if (args.ratesFromTalent === true) draft.ratesFromTalent = true;
569
+ const sources = Array.isArray(args.sources) ? DRAFT_SOURCES.filter((s) => args.sources.includes(s)) : [];
570
+ // A confirmation of the printed block stands even if the model drops sources on the answer call.
571
+ if (!sources.length && !optionFor(args.answer, [c.draftOk, c.draftChange])) {
572
+ return { ok: false, reason: 'draft-sources-required', message: "The draft needs sources: 'cv' if you read their CV, 'linkedin' only if you have their LinkedIn URL. Then call signup_draft again." };
573
+ }
574
+ if (!state.firstDraft) state.firstDraft = draft;
575
+ const asked = await askAfterTexts(ctx, {
576
+ tool: 'signup_draft',
577
+ texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice(sources), knownAccount() ? c.windowNoticeSignIn : c.windowNotice],
578
+ question: c.draftQuestion,
579
+ options: [c.draftOk, c.draftChange],
580
+ answer: args.answer,
581
+ });
582
+ if (asked.reply) return { ...asked.reply, message: `${asked.reply.message} Pass the same draft fields again.` };
583
+ if (asked.answer === c.draftChange) {
584
+ state.draftConfirmed = false;
585
+ return { ok: false, reason: 'draft-change', message: 'Ask what they want to change in one short question, then call signup_draft again with the corrected fields.' };
586
+ }
587
+ state.draft = draft;
588
+ state.draftConfirmed = true;
589
+ state.toldExisting = knownAccount();
590
+ return {
591
+ ok: true,
592
+ next: 'signup_create_account',
593
+ message: 'Confirmed. Call signup_create_account now: the talent already read how the window works, so say nothing before it.',
594
+ };
595
+ }
596
+
597
+ function signupFailureHelp(reason) {
598
+ switch (reason) {
599
+ case 'invalid-linkedin-url':
600
+ return { message: INVALID_LINKEDIN_MESSAGE };
601
+ case 'invalid-cv':
602
+ return { message: 'Shakers could not read that CV as a PDF. Ask for another file, or continue with LinkedIn only.' };
603
+ case 'missing-source':
604
+ return { message: 'Sign-up needs the LinkedIn URL or a PDF CV.' };
605
+ case 'rate-limited':
606
+ return { message: 'Too many sign-ups from this network in the last hour. Ask the talent to try again later or register on the web.' };
607
+ default:
608
+ return { message: 'The sign-up could not start right now. Try again in a moment.' };
609
+ }
610
+ }
611
+
612
+ // Step 4: hub starts the import into an UNREGISTERED profile and the window opens to create the account (or sign in).
613
+ async function signupCreateAccount(args = {}) {
614
+ const c = copy();
615
+ if (!state.draftConfirmed) {
616
+ return { ok: false, reason: 'draft-not-confirmed', message: 'Show the draft with signup_draft and get the talent\'s confirmation first.' };
617
+ }
618
+ const session = resolved.loadAuthSession();
619
+ if (resolved.sessionStatus(session) === 'active' && state.account.state !== 'waiting') {
620
+ return {
621
+ ok: false,
622
+ reason: 'already-signed-in',
623
+ next: 'import_profile',
624
+ message: 'This machine is already signed in to Shakers. If that account is the talent\'s, import their LinkedIn/CV into it with import_profile; if it is not, call logout and then signup_create_account again.',
625
+ };
626
+ }
627
+
628
+ const sources = { linkedinUrl: nonEmpty(args.linkedinUrl), cvPath: nonEmpty(args.cvPath), userQuery: nonEmpty(args.userQuery) };
629
+ if (sources.linkedinUrl && !isLinkedinProfileUrl(sources.linkedinUrl)) return { ok: false, reason: 'invalid-linkedin-url', message: INVALID_LINKEDIN_MESSAGE };
630
+ let cv = null;
631
+ let cvNote = null;
632
+ if (sources.cvPath) {
633
+ const read = readSignupCv(sources.cvPath);
634
+ if (read.ok) cv = read.file;
635
+ else if (read.reason === 'cv-not-pdf') cvNote = 'The CV is not a PDF, so it was not uploaded now: make sure its text is in userQuery, and once the account exists import the file with import_profile.';
636
+ else return { ok: false, reason: read.reason, message: 'That CV could not be read. Ask the talent to check the file, or continue with LinkedIn and what you know.' };
637
+ }
638
+ if (!sources.linkedinUrl && !cv) {
639
+ return { ok: false, reason: 'missing-source', linkedinAsk: c.linkedinAsk, message: 'Sign-up needs the LinkedIn URL or a PDF CV: ask `linkedinAsk` word for word.' };
640
+ }
641
+
642
+ const identity = { email: state.email, firstName: nonEmpty(args.firstName), lastName: nonEmpty(args.lastName) };
643
+ const language = IMPORT_LANGUAGES.has(lang()) ? lang() : 'en';
644
+ const res = await requestMcpSignup(
645
+ { linkedinUrl: sources.linkedinUrl, cv, ...identity, userQuery: sources.userQuery, language },
646
+ { endpoint: getMcpSignupEndpoint() },
647
+ );
648
+ if (!res.ok) return { ok: false, reason: res.reason, ...signupFailureHelp(res.reason) };
649
+
650
+ state.claimCode = res.claimCode;
651
+ state.sources = sources;
652
+ state.reported = { account: 'waiting', import: 'none' };
653
+ const reused = state.account.state === 'waiting';
654
+ if (!state.importStartedAt) {
655
+ const attempted = DRAFT_SOURCES.filter((source) => (source === 'cv' ? !!cv : !!sources.linkedinUrl));
656
+ state.importStartedAt = Object.fromEntries(attempted.map((source) => [source, now()]));
657
+ for (const source of attempted) funnel.track('source_import_started', 'import_sources', { source_type: source });
658
+ funnel.completed('import_sources', { sources_attempted: attempted });
659
+ }
660
+ if (!reused) {
661
+ funnel.viewed('account_creation');
662
+ await openWindow(identity);
663
+ }
664
+ return {
665
+ ok: true,
666
+ status: 'window-open',
667
+ ...(cvNote ? { cvNote } : {}),
668
+ next: 'signup_status',
669
+ message: (reused ? 'The sign-up window was already open; it now uses the new data. ' : 'The window opens in the talent\'s browser in a few seconds; they read how it works in the draft they confirmed. ')
670
+ + 'Say nothing about the window. Only if they say it did not open, call signup_status with windowLink:true. Now call signup_status (waitSeconds 20) until the account exists.',
671
+ };
672
+ }
673
+
674
+ function trackImportOutcome(imp) {
675
+ if (!state.importStartedAt) return;
676
+ let tracked = false;
677
+ for (const [source, startedAt] of Object.entries(state.importStartedAt)) {
678
+ if (state.importReported.has(source)) continue;
679
+ const outcome = imp.sources && imp.sources[source] ? imp.sources[source].state : (imp.state === 'failed' ? 'failed' : null);
680
+ if (outcome !== 'done' && outcome !== 'failed') continue;
681
+ state.importReported.add(source);
682
+ const code = imp.sources && imp.sources[source] && imp.sources[source].code ? imp.sources[source].code : imp.code;
683
+ funnel.track(outcome === 'done' ? 'source_import_completed' : 'source_import_failed', 'import_sources', {
684
+ source_type: source,
685
+ duration_ms: Math.max(0, now() - startedAt),
686
+ ...(outcome === 'failed' && code ? { failure_reason: code } : {}),
687
+ });
688
+ tracked = true;
689
+ }
690
+ if (tracked) funnel.flush();
691
+ }
692
+
693
+ async function readImport() {
694
+ // An account that already existed got no import from this sign-up: its last import is not news.
695
+ if (state.account.state === 'existing') return { state: 'none' };
696
+ const session = resolved.loadAuthSession();
697
+ if (resolved.sessionStatus(session) !== 'active' || !session || !session.hubAccessToken) return { state: 'none' };
698
+ const res = await requestMyImportStatus({ hubAccessToken: session.hubAccessToken }, { endpoint: getMyImportStatusEndpoint() });
699
+ if (!res.ok) return { state: 'unknown', reason: res.reason };
700
+ const imp = { state: res.state, sources: res.sources, ...(res.code ? { code: res.code } : {}) };
701
+ trackImportOutcome(imp);
702
+ return imp;
703
+ }
704
+
705
+ function nextSteps(account) {
706
+ const interview = 'Then the onboarding interview: if onboardingInterview.completed is true, skip it without mentioning it; otherwise call onboarding_interview_start without disclaimerAcknowledged (it asks here now or later on the web). Then, also if they skip it, the main role: list_my_roles (waitSeconds 20), then set_main_role without clusterId only when its next says so. Finally open_web.';
707
+ const aiUsage = 'Then ai_usage without consent: it returns the two AI-usage disclaimers and the question (show them right before asking; no scan happens before a yes).';
708
+ if (account.state === 'existing') {
709
+ return `This talent already had an account and signed in. Call update_existing_profile: it shows what would change and asks whether to update, and its text tells the talent about the account, so say nothing about it yourself. ${aiUsage} ${interview}`;
710
+ }
711
+ const unclaimed = account.claimed === false ? ' This account did not take over the imported profile: call import_profile with the same LinkedIn/CV first.' : '';
712
+ return `The account exists.${unclaimed} Now, without asking: save_profile_details with the confirmed draft (situation, motivation, both rates, availability and location with its country, languages). ${aiUsage} ${interview}`;
713
+ }
714
+
715
+ // A source that did not import gets its fixed line once, on the next question block.
716
+ function noticeFailed(sources) {
717
+ const fresh = sources.filter((s) => !state.failedSources.has(s));
718
+ fresh.forEach((s) => state.failedSources.add(s));
719
+ const c = copy();
720
+ if (fresh.length) queueNotice(c.importFailed(fresh.map((s) => c.importSources[s] || s)));
721
+ return fresh.length > 0;
722
+ }
723
+
724
+ function guidance(account, imp) {
725
+ const lines = [];
726
+ const linkedin = imp.sources && imp.sources.linkedin;
727
+ if (linkedin && linkedin.state === 'failed') {
728
+ noticeFailed(['linkedin']);
729
+ lines.push('The LinkedIn import failed. Its fixed line for the talent comes with the next question: do not add your own, never with codes; retry once with import_profile; if it fails again, ask for their website or GitHub and send them with import_profile.');
730
+ }
731
+ if (account.state === 'waiting') lines.push('The talent is still in the sign-up window. Call signup_status again.');
732
+ if (account.state === 'failed') lines.push('The sign-up window ended without an account. Ask the talent whether to try again; signup_create_account reopens it.');
733
+ if (account.state === 'created' || account.state === 'existing') lines.push(nextSteps(account));
734
+ return lines.join(' ');
735
+ }
736
+
737
+ async function signupStatus(args = {}) {
738
+ if (state.account.state === 'not-started') {
739
+ return { ok: false, reason: 'no-signup', message: 'No sign-up was started in this session: follow signup_start.' };
740
+ }
741
+ const waitMs = Math.min(Math.max(Number(args.waitSeconds) || 0, 0), MAX_WAIT_SECONDS) * 1000;
742
+ const deadline = now() + waitMs;
743
+ let imp = await readImport();
744
+ const fresh = () => state.account.state !== state.reported.account || imp.state !== state.reported.import;
745
+ while (now() < deadline && !fresh()) {
746
+ await Promise.race([changed(), sleep(Math.min(IMPORT_POLL_MS, Math.max(deadline - now(), 0)))]);
747
+ imp = await readImport();
748
+ }
749
+ state.reported = { account: state.account.state, import: imp.state };
750
+
751
+ const account = { ...state.account };
752
+ const out = { ok: true, account, import: imp };
753
+ if (account.state === 'waiting' && (args.windowLink === true || state.windowOpenFailed) && state.windowUrl) {
754
+ out.relayVerbatim = copy().windowLink(state.windowUrl);
755
+ }
756
+ if (account.state === 'created' || account.state === 'existing') {
757
+ let completed = null;
758
+ try { completed = await checkOnboardingCompleted({}); } catch { completed = null; }
759
+ out.onboardingInterview = { completed };
760
+ }
761
+ out.message = [out.relayVerbatim ? VERBATIM : '', guidance(account, imp)].filter(Boolean).join(' ');
762
+ return out;
763
+ }
764
+
765
+ // The tool: a CV file from this computer is sent only after the talent says yes to that file.
766
+ async function importProfileAsked(args = {}, ctx = {}) {
767
+ if (nonEmpty(args.cvPath)) {
768
+ const file = path.resolve(expandHome(nonEmpty(args.cvPath)));
769
+ const c = copy().consent;
770
+ const asked = await askConsent(ctx, { tool: 'import_profile', key: `import_profile:${file}`, question: c.cvSend(tildify(file)), yes: c.yes, no: c.no, answer: args.answer });
771
+ if (asked.reply) return asked.reply;
772
+ if (!asked.granted) return { ok: false, reason: 'consent-declined', message: 'The talent said no: nothing was sent. Do not send that file; call import_profile again without cvPath if the other sources still need importing.' };
773
+ return importProfile({ ...args, cvPath: file });
774
+ }
775
+ return importProfile(args);
776
+ }
777
+
778
+ async function importProfile(args = {}) {
779
+ if (nonEmpty(args.linkedinUrl) && !isLinkedinProfileUrl(nonEmpty(args.linkedinUrl))) return { ok: false, reason: 'invalid-linkedin-url', message: INVALID_LINKEDIN_MESSAGE };
780
+ const res = await flow.startProfileImport(resolved, {
781
+ linkedinUrl: nonEmpty(args.linkedinUrl),
782
+ cvPath: nonEmpty(args.cvPath),
783
+ githubUrl: nonEmpty(args.githubUrl),
784
+ websiteUrl: nonEmpty(args.websiteUrl),
785
+ userQuery: nonEmpty(args.userQuery),
786
+ language: IMPORT_LANGUAGES.has(args.language) ? args.language : (IMPORT_LANGUAGES.has(lang()) ? lang() : 'en'),
787
+ fillEmptyOnly: true,
788
+ });
789
+ if (!res.ok) {
790
+ if (res.reason === 'no-session') {
791
+ return { ok: false, reason: isSignupPending() ? 'signup-pending' : 'no-session', message: isSignupPending() ? 'The account does not exist yet: wait for signup_status to report it.' : 'The talent is not signed in: sign them in with social_signin_start first.' };
792
+ }
793
+ return { ok: false, reason: res.reason };
794
+ }
795
+ const failed = res.report ? res.report.sources.filter((src) => src.status === 'failed').map((src) => src.source) : [];
796
+ noticeFailed(failed);
797
+ return {
798
+ ok: true,
799
+ report: res.report,
800
+ message: failed.length
801
+ ? `Imported, except: ${failed.join(', ')}. Its fixed line for the talent comes with the next question: do not add your own, never with codes.`
802
+ : 'Imported. Existing texts and languages were kept; lists were added without duplicates.',
803
+ };
804
+ }
805
+
806
+ function money(value, unit) {
807
+ return value === null || value === undefined ? copy().existingEmpty : `${amount(lang(), value)} ${unit}`;
808
+ }
809
+
810
+ // The existing profile as far as it can be read; a field that could not be read counts as set, so it is never overwritten.
811
+ async function currentProfile() {
812
+ const session = resolved.loadAuthSession();
813
+ const hubAccessToken = session && session.hubAccessToken;
814
+ const [rate, availability, languages] = await Promise.all([
815
+ fetchPricingRate({ hubAccessToken }).catch(() => ({ ok: false })),
816
+ fetchAvailability({ hubAccessToken }).catch(() => ({ ok: false })),
817
+ fetchLanguages({ hubAccessToken }).catch(() => ({ ok: false })),
818
+ ]);
819
+ const pricing = rate && rate.ok ? rate.pricing || {} : null;
820
+ // An unselected rate may keep an old amount: only a selected one counts as set.
821
+ const amountOf = (selected, price) => (selected === true && price && typeof price.amount === 'number' ? price.amount : null);
822
+ return {
823
+ pricing: pricing && { hourly: amountOf(pricing.partTimeSelected, pricing.partTimePrice), annual: amountOf(pricing.fullTimeSelected, pricing.fullTimePrice) },
824
+ availability: availability && availability.ok ? availability.availability || {} : null,
825
+ languageCount: languages && languages.ok ? languages.languageCount : null,
826
+ };
827
+ }
828
+
829
+ // Fill-empty only: per draft field, what it fills or the value the profile keeps, plus the import that only adds.
830
+ async function existingDiff() {
831
+ const c = copy();
832
+ const draft = state.draft || {};
833
+ const current = await currentProfile();
834
+ const lines = [];
835
+ const kept = new Set();
836
+ const has = (v) => v !== undefined && v !== null && v !== '';
837
+ // An unreadable section (undefined) counts as set, as fillEmpty never writes it.
838
+ const field = (key, now, filled, label, shown) => {
839
+ if (!has(draft[key])) return;
840
+ if (now === null) { lines.push(filled); return; }
841
+ kept.add(key);
842
+ lines.push(c.existingStays(label, now === undefined ? null : shown(now), shown(draft[key])));
843
+ };
844
+ const pricing = current.pricing || { hourly: undefined, annual: undefined };
845
+ const hours = current.availability ? (has(current.availability.monthlyHours) ? current.availability.monthlyHours : null) : undefined;
846
+ field('hourlyRate', pricing.hourly, c.existingRate(c.existingEmpty, money(draft.hourlyRate, c.perHour)), c.hourlyRateLabel, (v) => money(v, c.perHour));
847
+ field('annualRate', pricing.annual, c.existingAnnual(c.existingEmpty, money(draft.annualRate, c.perYear)), c.annualRateLabel, (v) => money(v, c.perYear));
848
+ field('monthlyHours', hours, c.existingHours(c.existingEmpty, c.hours(draft.monthlyHours)), c.keptLabels.availability, (v) => c.hours(v));
849
+ if (has(draft.languages) && current.languageCount !== 0) { kept.add('languages'); lines.push(c.existingStays(c.keptLabels.languages)); }
850
+ if (kept.size) lines.push(c.existingStaysNote);
851
+ state.toldKept = kept;
852
+ const given = state.sources ? [state.sources.linkedinUrl && 'linkedin', state.sources.cvPath && 'cv'].filter(Boolean) : [];
853
+ if (given.length) lines.push(c.existingLists(given));
854
+ return `${state.toldExisting ? c.existingUpdateQuestion : c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
855
+ }
856
+
857
+ // The sections of save_profile_details an existing profile can take (only fields it leaves empty), and what it keeps instead.
858
+ async function fillEmpty(details) {
859
+ const current = await currentProfile();
860
+ const out = {};
861
+ const kept = {};
862
+ const has = (v) => v !== undefined && v !== null && v !== '';
863
+ const keep = (section, key, value, requested) => { kept[section] = { ...kept[section], [key]: { kept: value, requested } }; };
864
+ const p = details.pricing;
865
+ if (p && typeof p === 'object') {
866
+ const now = current.pricing || { hourly: undefined, annual: undefined };
867
+ for (const [key, field] of [['hourlyRate', 'hourly'], ['annualRate', 'annual']]) {
868
+ if (has(p[key]) && now[field] !== null) keep('pricing', key, now[field] ?? null, p[key]);
869
+ }
870
+ const fills = current.pricing && ((current.pricing.hourly === null && has(p.hourlyRate)) || (current.pricing.annual === null && has(p.annualRate)));
871
+ if (fills) out.pricing = { hourlyRate: current.pricing.hourly ?? p.hourlyRate, annualRate: current.pricing.annual ?? p.annualRate };
872
+ }
873
+ const a = details.availability;
874
+ if (a && typeof a === 'object') {
875
+ const now = current.availability;
876
+ const empty = (key) => !!now && (key === 'workModes' ? !(Array.isArray(now.workModes) && now.workModes.length) : !has(now[key]));
877
+ const given = Object.entries(a).filter(([, value]) => has(value));
878
+ given.filter(([key]) => !empty(key)).forEach(([key, value]) => keep('availability', key, now ? now[key] ?? null : null, value));
879
+ const filled = Object.fromEntries(given.filter(([key]) => empty(key)));
880
+ if (Object.keys(filled).length) out.availability = filled;
881
+ }
882
+ if (Array.isArray(details.languages) && details.languages.length) {
883
+ if (current.languageCount === 0) out.languages = details.languages;
884
+ else kept.languages = { kept: current.languageCount, requested: details.languages.length };
885
+ }
886
+ for (const section of ['workSituation', 'phone']) if (details[section] && typeof details[section] === 'object') kept[section] = { kept: null, requested: details[section] };
887
+ return { details: out, kept };
888
+ }
889
+
890
+ // What the existing profile kept, for the model per field and for the talent as one fixed line without what the update question already told.
891
+ function keptReport(kept) {
892
+ const c = copy();
893
+ const fields = [];
894
+ const items = [];
895
+ const told = (key) => state.toldKept.has(key);
896
+ if (kept.workSituation) { fields.push('workSituation: not changed (the profile keeps what it had)'); items.push(c.keptLabels.workSituation); }
897
+ for (const [key, label, unit] of [['hourlyRate', c.hourlyRateLabel, c.perHour], ['annualRate', c.annualRateLabel, c.perYear]]) {
898
+ const k = kept.pricing && kept.pricing[key];
899
+ if (!k) continue;
900
+ fields.push(k.kept === null ? `${key}: not changed (the profile keeps what it had, not ${k.requested})` : `${key}: kept existing value ${k.kept} (not changed, not ${k.requested})`);
901
+ if (!told(key)) items.push(k.kept === null ? label : `${label}: ${money(k.kept, unit)}`);
902
+ }
903
+ for (const [key, k] of Object.entries(kept.availability || {})) {
904
+ fields.push(k.kept === null ? `${key}: not changed (the profile keeps what it had)` : `${key}: kept existing value ${Array.isArray(k.kept) ? k.kept.join('/') : k.kept} (not changed, not ${Array.isArray(k.requested) ? k.requested.join('/') : k.requested})`);
905
+ }
906
+ const hours = kept.availability && kept.availability.monthlyHours;
907
+ if (hours && !told('monthlyHours')) items.push(hours.kept === null ? c.keptLabels.availability : `${c.keptLabels.availability}: ${c.hours(hours.kept)}`);
908
+ if (kept.languages) { fields.push('languages: not changed (the profile already had languages)'); if (!told('languages')) items.push(c.keptLabels.languages); }
909
+ if (kept.phone) { fields.push('phone: not changed (the profile keeps what it had)'); items.push(c.keptLabels.phone); }
910
+ return { fields, line: items.length ? c.existingUnchanged(items) : null };
911
+ }
912
+
913
+ // Existing account: after signing in, the talent sees what would change and decides whether to update.
914
+ async function updateExistingProfile(args = {}, ctx = {}) {
915
+ const c = copy();
916
+ const session = resolved.loadAuthSession();
917
+ if (resolved.sessionStatus(session) !== 'active') return { ok: false, reason: 'no-session', message: 'The talent is not signed in yet: wait for signup_status.' };
918
+ const question = await existingDiff();
919
+ const asked = await askAfterTexts(ctx, { tool: 'update_existing_profile', texts: [], question, options: [c.existingYes, c.existingNo], answer: args.answer });
920
+ if (asked.reply) return asked.reply;
921
+ if (asked.answer === c.existingNo) {
922
+ state.keepProfile = true;
923
+ return { ok: true, updated: false, relayVerbatim: c.existingKept, message: `${VERBATIM} Do not save the draft. Carry on with ai_usage.` };
924
+ }
925
+ state.keepProfile = false;
926
+ const sources = state.sources || {};
927
+ const imported = sources.linkedinUrl || sources.cvPath || sources.userQuery ? await importProfile(sources) : { ok: true };
928
+ return {
929
+ ok: true,
930
+ updated: true,
931
+ import: imported,
932
+ message: `${imported.ok ? '' : 'The import did not go through; carry on anyway. '}Now save_profile_details with the confirmed draft, without asking: it only fills what the profile leaves empty. Then ai_usage without consent.`,
933
+ };
934
+ }
935
+
936
+ async function savePricing(rates) {
937
+ const mapped = pricingFromRates(rates);
938
+ return mapped.ok ? flow.savePricing(resolved, mapped.input) : mapped;
939
+ }
940
+
941
+ function ratesSaved(rates) {
942
+ if (!rates || typeof rates !== 'object') return null;
943
+ const c = copy();
944
+ const shown = [];
945
+ if (rates.hourlyRate != null && rates.hourlyRate !== '') shown.push(`${c.hourlyRateLabel}: ${money(rates.hourlyRate, c.perHour)}`);
946
+ if (rates.annualRate != null && rates.annualRate !== '') shown.push(`${c.annualRateLabel}: ${money(rates.annualRate, c.perYear)}`);
947
+ return c.ratesSaved(shown, !(state.draft && state.draft.ratesFromTalent));
948
+ }
949
+
950
+ function wasEdited(step) {
951
+ if (!state.firstDraft || !state.draft) return undefined;
952
+ return STEP_DRAFT_FIELDS[step].some((key) => String(state.firstDraft[key] ?? '') !== String(state.draft[key] ?? ''));
953
+ }
954
+
955
+ function trackSavedSteps(details, results) {
956
+ const saved = (section) => results[section] && results[section].ok === true;
957
+ const step = (name, props) => funnel.completed(name, { ...props, is_prefilled: true, was_edited: wasEdited(name) });
958
+ if (saved('workSituation')) {
959
+ step('work_situation', { work_situation: workSituationValue(String(details.workSituation.situation || '').trim().toUpperCase()) });
960
+ }
961
+ if (saved('availability')) {
962
+ const a = details.availability;
963
+ step('availability', { availability_hours_per_week: weeklyHours(a.monthlyHours) });
964
+ const country = typeof a.country === 'string' && /^[A-Za-z]{2}$/.test(a.country.trim()) ? a.country.trim().toUpperCase() : null;
965
+ const proposed = state.firstDraft && state.firstDraft.country;
966
+ const modes = Array.isArray(a.workModes) ? a.workModes.map((m) => String(m).toUpperCase()) : [];
967
+ step('location', {
968
+ location_country: country ? country.toLowerCase() : undefined,
969
+ location_method: country && proposed === country ? 'prefilled' : 'manual',
970
+ is_remote_only: modes.length ? modes.every((m) => m === 'REMOTE') : undefined,
971
+ });
972
+ }
973
+ if (saved('pricing')) {
974
+ const types = rateTypes(pricingFromRates(details.pricing).input);
975
+ funnel.completed('rates', { rate_types_selected: types, rate_type: types[0], rate_currency: 'EUR', is_prefilled: !(state.draft && state.draft.ratesFromTalent), was_edited: wasEdited('rates') });
976
+ }
977
+ if (saved('languages')) {
978
+ const codes = details.languages.map((row) => String(row.language || '').trim().toLowerCase()).filter(Boolean);
979
+ step('languages', { languages_count: codes.length, languages: codes });
980
+ }
981
+ funnel.flush();
982
+ }
983
+
984
+ async function saveProfileDetails(args = {}) {
985
+ const { saved } = copy();
986
+ const existing = state.account.state === 'existing';
987
+ if (existing && state.keepProfile) return { ok: true, kept: true, results: {}, confirmations: [], message: 'The talent chose to keep their profile as it is: nothing was saved.' };
988
+ // An existing profile only takes what it leaves empty; the work situation and the phone cannot be read, so they are never written there.
989
+ const { details, kept } = existing ? await fillEmpty(args) : { details: args, kept: {} };
990
+ const keptPricing = kept.pricing || {};
991
+ const shownPricing = details.pricing && Object.fromEntries(Object.entries(details.pricing).filter(([key]) => !keptPricing[key]));
992
+ const sections = [
993
+ ['workSituation', details.workSituation, (v) => flow.saveWorkSituation(resolved, v), saved.workSituation],
994
+ ['pricing', details.pricing, savePricing, ratesSaved(shownPricing)],
995
+ ['availability', details.availability, (v) => flow.saveAvailability(resolved, v), saved.availability],
996
+ ['languages', Array.isArray(details.languages) && details.languages.length ? details.languages : null, (v) => flow.saveLanguages(resolved, v), saved.languages],
997
+ ['phone', details.phone, (v) => flow.savePhone(resolved, v), saved.phone],
998
+ ].filter(([, value]) => value && typeof value === 'object');
999
+ // Hub's async profile-completion recalculation saves the whole users row from a copy it read earlier, so a
1000
+ // phone written while it runs comes back NULL: the phone goes first, and is read back once the rest is saved.
1001
+ const phoneFirst = [...sections.filter(([name]) => name === 'phone'), ...sections.filter(([name]) => name !== 'phone')];
1002
+ const results = {};
1003
+ for (const [name, value, save] of phoneFirst) {
1004
+ const res = await save(value);
1005
+ if (!res.ok && res.reason === 'no-session') return { ok: false, reason: 'no-session', message: 'The talent is not signed in yet.' };
1006
+ const help = res.message || SECTION_HELP[res.reason];
1007
+ results[name] = res.ok ? { ok: true } : { ok: false, reason: res.reason, ...(help ? { message: help } : {}) };
1008
+ }
1009
+ if (results.phone && results.phone.ok) {
1010
+ const session = resolved.loadAuthSession();
1011
+ const stored = await fetchTelephone({ hubAccessToken: session && session.hubAccessToken }).catch(() => ({ ok: false }));
1012
+ if (stored.ok && !stored.telephone) {
1013
+ const again = await flow.savePhone(resolved, details.phone);
1014
+ results.phone = again.ok ? { ok: true } : { ok: false, reason: again.reason };
1015
+ }
1016
+ }
1017
+ trackSavedSteps(details, results);
1018
+ const confirmations = sections.filter(([name]) => results[name] && results[name].ok).map(([, , , text]) => text);
1019
+ const ok = Object.values(results).every((r) => r.ok);
1020
+ const report = keptReport(kept);
1021
+ if (report.line) queueNotice(report.line);
1022
+ const messages = [
1023
+ ok ? '' : 'Some sections were not saved: fix each one as its message says and call save_profile_details again with only those sections. Never show the talent these reasons.',
1024
+ report.fields.length ? `This account already existed, so these values were NOT saved: ${report.fields.join('; ')}. Never tell the talent they were saved; the fixed line saying so comes with the next question.` : '',
1025
+ ].filter(Boolean);
1026
+ return {
1027
+ ok,
1028
+ results,
1029
+ confirmations,
1030
+ ...(report.fields.length ? { kept } : {}),
1031
+ ...(messages.length ? { message: messages.join(' ') } : {}),
1032
+ };
1033
+ }
1034
+
1035
+ async function openWeb(args = {}) {
1036
+ const session = resolved.loadAuthSession();
1037
+ const cookie = session && resolved.sessionStatus(session) === 'active' ? session.cookie || null : null;
1038
+ let url = null;
1039
+ if (cookie || state.sessionToken) {
1040
+ const minted = await requestOneTimeToken({ cookie, sessionToken: state.sessionToken }, { endpoint: getOneTimeTokenEndpoint() });
1041
+ if (minted.ok) url = getOneTimeLoginUrl(minted.token);
1042
+ }
1043
+ const loggedIn = !!url;
1044
+ if (!url) url = flow.profileUrl(resolved);
1045
+ if (!url) return { ok: false, reason: 'no-endpoint' };
1046
+ // The address reaches the AI only when the talent needs a link: the AI comments on hosts it sees.
1047
+ const opened = args.open !== false && openBrowser(url) !== false;
1048
+ // Read right before the closing words, so they never state an import state that is already stale.
1049
+ const imp = await readImport();
1050
+ // A source that failed after the last signup_status gets its fixed line here, as there is no question left.
1051
+ const failed = DRAFT_SOURCES.filter((s) => imp.sources && imp.sources[s] && imp.sources[s].state === 'failed');
1052
+ noticeFailed(failed);
1053
+ // The flow ends here, so a fixed line no question carried is printed now.
1054
+ const unseen = takeNotices();
1055
+ const relay = unseen.length ? { relayVerbatim: unseen.join('\n\n') } : {};
1056
+ const importLine = failed.length
1057
+ ? `The import of ${failed.join(' and ')} failed and its fixed line is the only thing to say about it: never say or imply it was imported; if you mention the import, name only what did import.`
1058
+ : IMPORT_CLOSING[imp.state] || IMPORT_CLOSING.none;
1059
+ const where = opened
1060
+ ? `Shakers is open in their browser${loggedIn ? ', already signed in' : ' at the sign-in page, where they sign in'}`
1061
+ : `they open Shakers from \`link\` (give it to click, with no comment on the address${loggedIn ? '; it works once, for 2 minutes' : ''})`;
1062
+ const closing = `one short closing sentence of your own, in the talent's language, saying ${where}.`;
1063
+ const reply = unseen.length
1064
+ ? `Your reply has two parts and needs both: first relayVerbatim word for word as its own block, then ${closing}`
1065
+ : `End with ${closing}`;
1066
+ return {
1067
+ ok: true,
1068
+ opened,
1069
+ loggedIn,
1070
+ ...(opened ? {} : { link: url }),
1071
+ import: { state: imp.state },
1072
+ ...relay,
1073
+ message: `${reply}${opened ? ' If they say it did not open, call open_web with open:false and give them its link.' : ''} ${importLine}`,
1074
+ };
1075
+ }
1076
+
1077
+ function openLinkedinProfile() {
1078
+ openSoon(LINKEDIN_SELF_URL);
1079
+ return {
1080
+ ok: true,
1081
+ url: LINKEDIN_SELF_URL,
1082
+ relayVerbatim: copy().linkedinOpened,
1083
+ message: `${VERBATIM} The browser opens in a few seconds. If they say it did not open, give \`url\` as a link. When they paste the URL, check it is linkedin.com/in/<slug> (not /in/me, a feed or a search) and ask once more if not.`,
1084
+ };
1085
+ }
1086
+
1087
+ return [
1088
+ {
1089
+ name: 'signup_start',
1090
+ title: 'Start your Shakers sign-up',
1091
+ description: 'Starts your sign-up here in the chat and asks whether you send your CV or want it looked for on this computer.',
1092
+ guidance: "FIRST call when the talent asks to sign up or register on Shakers, before you write anything: pass `language` = the language of their message. It returns the welcome to show word for word and its one question (send me your CV, or shall I look for it on this computer?). Then follow its message: CV, LinkedIn, signup_email, signup_draft, signup_create_account, signup_status. Every text it and the next sign-up tools return in `say` or relayVerbatim is printed word for word, in that language.",
1093
+ inputSchema: WELCOME_SCHEMA,
1094
+ handler: signupStart,
1095
+ },
1096
+ {
1097
+ name: 'signup_email',
1098
+ title: 'Choose your account email',
1099
+ description: 'Asks which email your Shakers account will use.',
1100
+ guidance: 'Sign-up step after the CV and LinkedIn: asks which email to create the account with (the one they read most). Pass the best email you know, or none. Never comment on the email yourself.',
1101
+ inputSchema: EMAIL_SCHEMA,
1102
+ handler: signupEmail,
1103
+ },
1104
+ {
1105
+ name: 'signup_draft',
1106
+ title: 'Review your profile draft',
1107
+ description: 'Shows the profile draft prepared from your CV and LinkedIn, explains how your data is used and asks you to confirm it. Your account is not created until you confirm.',
1108
+ guidance: 'Sign-up step before the account: shows the talent the profile draft you prepared from their CV, LinkedIn and what you know, with the data notice and how the account window works, and asks them to confirm it. The window to create the account opens only after they confirm.',
1109
+ inputSchema: DRAFT_SCHEMA,
1110
+ handler: signupDraft,
1111
+ },
1112
+ {
1113
+ name: 'signup_create_account',
1114
+ title: 'Create your account',
1115
+ description: 'Once you confirm the draft, sends your CV, LinkedIn and draft to Shakers and opens a browser window where you create your account with Google or a password. Your password never goes through the chat.',
1116
+ guidance: "After the talent confirmed the draft: starts the profile import on Shakers and, a few seconds later, opens a window in the talent's browser where they create the account with Google or a password, or sign in if they already have one. No password ever goes through the chat. Returns next: signup_status.",
1117
+ inputSchema: CREATE_SCHEMA,
1118
+ handler: signupCreateAccount,
1119
+ },
1120
+ {
1121
+ name: 'signup_status',
1122
+ title: 'Sign-up progress',
1123
+ description: 'Shows whether your account is ready and how the import of your profile is going.',
1124
+ guidance: "State of the sign-up: account (waiting | created | existing | failed) and, once it exists, the profile import per source (linkedin, cv: running | done | failed). Pass waitSeconds (up to 25) to wait for a change. Follow `message`: it gives the remaining steps. Never show internal codes to the talent.",
1125
+ inputSchema: STATUS_SCHEMA,
1126
+ handler: signupStatus,
1127
+ },
1128
+ {
1129
+ name: 'update_existing_profile',
1130
+ title: 'Update your existing profile',
1131
+ description: 'If you already had an account, shows what your draft would add to the empty fields of your profile and asks before changing anything. It never overwrites what you already filled in.',
1132
+ guidance: 'When signup_status says the talent already had an account: shows what the confirmed draft would fill in the empty fields of their profile and the value each field they already had keeps, and asks whether to update it; on yes it imports their LinkedIn/CV filling only what is empty.',
1133
+ inputSchema: UPDATE_EXISTING_SCHEMA,
1134
+ handler: updateExistingProfile,
1135
+ },
1136
+ {
1137
+ name: 'import_profile',
1138
+ title: 'Import into your profile',
1139
+ description: 'Sends your LinkedIn, GitHub or website to Shakers to fill the empty parts of your profile, and a CV file from this computer only after asking you first. What you already wrote is kept.',
1140
+ guidance: "Import LinkedIn, CV, GitHub or website into the SIGNED-IN talent's profile, filling only what is empty: existing texts and languages are kept, lists are added without duplicates. Use it when the sign-up import failed for a source, or for a non-PDF CV. With cvPath the tool first asks the talent, naming the file (a dialog when the client has one, otherwise it returns `say` to print word for word as your whole reply); nothing is sent until they answer yes. After a `say`, call it again with the same arguments and answer set to the option they pick. Never answer for them. Takes up to ~90 s.",
1141
+ inputSchema: IMPORT_SCHEMA,
1142
+ handler: importProfileAsked,
1143
+ },
1144
+ {
1145
+ name: 'save_profile_details',
1146
+ title: 'Save your profile details',
1147
+ description: 'Saves the details you confirmed in your draft (work situation, rates, availability, location, languages and phone) to your Shakers profile. On an existing account it only fills empty fields.',
1148
+ guidance: "After the account exists, write the confirmed draft — work situation and motivation, hourly rate and annual target, availability and location, languages, phone — WITHOUT asking again: they can edit every field on the web. Each section is saved on its own; returns per-section results. On an account that already existed it only fills what the profile leaves empty, never overwrites a value the talent set.",
1149
+ inputSchema: buildDetailsSchema(deps.lang || 'en'),
1150
+ handler: saveProfileDetails,
1151
+ },
1152
+ {
1153
+ name: 'open_web',
1154
+ title: 'Open Shakers in your browser',
1155
+ description: 'Opens Shakers in your browser, already signed in.',
1156
+ guidance: "Open Shakers in the talent's browser already signed in (one-time link, valid 2 minutes, single use). Use it at the end of the sign-up, or when the onboarding interview has to be done on the web. Returns { opened, loggedIn }, plus `link` only when the browser was not opened.",
1157
+ inputSchema: { type: 'object', properties: { open: { type: 'boolean', description: 'false returns a fresh link without opening the browser: only when the talent says the browser did not open.' } } },
1158
+ handler: openWeb,
1159
+ },
1160
+ {
1161
+ name: 'open_linkedin_profile',
1162
+ title: 'Open your LinkedIn profile',
1163
+ description: 'Opens your own LinkedIn profile in your browser so you can copy its address.',
1164
+ guidance: "Open the talent's own LinkedIn profile in their browser (linkedin.com/in/me redirects to it) so they copy the address bar. Use it ONLY when the talent says they do not know where to find their LinkedIn URL, and only after showing them, word for word, the `linkedinOpening` text signup_start returned.",
1165
+ inputSchema: { type: 'object', properties: {} },
1166
+ handler: openLinkedinProfile,
1167
+ },
1168
+ ];
1169
+ }
1170
+
1171
+ module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, looksLikeWorkEmail, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };