shakers-cli 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +21 -6
  2. package/bin/ai-usage.js +31 -12
  3. package/bin/command-audit-worker.js +19 -0
  4. package/bin/login.js +4 -8
  5. package/bin/mcp.js +32 -21
  6. package/bin/register.js +12 -30
  7. package/bin/shakers-cli.js +15 -10
  8. package/package.json +1 -1
  9. package/src/account-marker.js +40 -0
  10. package/src/account-trace.js +23 -0
  11. package/src/agents-client.js +1 -0
  12. package/src/ai-usage-review.js +103 -0
  13. package/src/auth-client.js +7 -107
  14. package/src/auth-session-store.js +12 -11
  15. package/src/backend-request.js +3 -24
  16. package/src/cli-skill.js +77 -0
  17. package/src/command-audit.js +47 -4
  18. package/src/config.js +2 -16
  19. package/src/consent-flow.js +9 -3
  20. package/src/detect-repos.js +4 -8
  21. package/src/detectors.js +23 -0
  22. package/src/device-auth-store.js +2 -2
  23. package/src/device-login.js +11 -9
  24. package/src/env-paths.js +9 -1
  25. package/src/i18n.js +186 -102
  26. package/src/mcp-ai-usage-result-tool.js +38 -14
  27. package/src/mcp-ai-usage-tool.js +317 -172
  28. package/src/mcp-choice.js +171 -34
  29. package/src/mcp-detector.js +26 -2
  30. package/src/mcp-device-login-tools.js +40 -8
  31. package/src/mcp-install.js +292 -53
  32. package/src/mcp-instructions.js +43 -17
  33. package/src/mcp-onboarding-tools.js +1 -3
  34. package/src/mcp-prompts.js +8 -6
  35. package/src/mcp-register-tools.js +25 -14
  36. package/src/mcp-role-tools.js +59 -18
  37. package/src/mcp-server-name.js +7 -0
  38. package/src/mcp-server.js +35 -7
  39. package/src/mcp-signup-client.js +3 -6
  40. package/src/mcp-signup-flow.js +389 -0
  41. package/src/mcp-signup-tools.js +250 -133
  42. package/src/onboarding-flow.js +72 -146
  43. package/src/onboarding-funnel.js +46 -6
  44. package/src/opencode-sessions.js +92 -0
  45. package/src/profile-agents.js +26 -0
  46. package/src/render-report-text.js +1 -1
  47. package/src/report-gating.js +4 -2
  48. package/src/report-store.js +7 -2
  49. package/src/roles-flow.js +8 -5
  50. package/src/saved-positions-flow.js +3 -3
  51. package/src/scanner.js +104 -12
  52. package/src/session-gate.js +1 -4
  53. package/src/session-normalizers.js +114 -8
  54. package/src/session-refresh.js +5 -16
  55. package/src/session-scan.js +167 -22
  56. package/src/share-consent-state.js +2 -2
  57. package/src/share-payload.js +4 -1
  58. package/src/share.js +2 -2
  59. package/src/signup-client.js +12 -39
  60. package/src/signup-copy.js +545 -58
  61. package/src/skill-install.js +102 -0
  62. package/src/stdin-ask.js +13 -1
  63. package/src/tier-engine.js +14 -2
  64. package/src/trust-narration.js +32 -0
  65. package/src/usage-run-steps.js +68 -6
  66. package/src/usage-scan.js +11 -8
  67. package/src/brand-svg.js +0 -9
  68. package/src/inter-fonts.js +0 -6
  69. package/src/mcp-auth-form.js +0 -480
@@ -8,14 +8,13 @@ const { signupCopy, legalCopy, renderDraft, amount } = require('./signup-copy');
8
8
  const { fixSignupLanguage, signupLanguage } = require('./signup-language');
9
9
  const path = require('path');
10
10
  const { readLocalCv } = require('./cv-file');
11
+ const { extractLinks, mentionsName, MAX_CHARS: MAX_CV_CHARS } = require('./cv-text');
11
12
  const { emailFromHubToken } = require('./auth-session-store');
12
- const { askAfterTexts, askConsent, optionFor, queueNotice, takeNotices } = require('./mcp-choice');
13
+ const { askAfterTexts, askConsent, askRegistrationTerms, registrationTermsText, openQuestion, optionFor, queueNotice, takeNotices, resetQuestions, written } = require('./mcp-choice');
14
+ const { DRAFT_RULE } = require('./mcp-instructions');
13
15
  const { expandHome, tildify } = require('./env-paths');
14
16
  const { NOOP_FUNNEL, weeklyHours, rateTypes, workSituationValue } = require('./onboarding-funnel');
15
17
 
16
- // MCP sign-up: 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
18
  // The browser opens this long after the tool answers, so the AI's reply is on screen first.
20
19
  const BROWSER_OPEN_DELAY_MS = 4000;
21
20
  // Each signup_status call holds up to this long waiting for a change, so a higher value means fewer calls
@@ -23,6 +22,7 @@ const BROWSER_OPEN_DELAY_MS = 4000;
23
22
  // Claude Desktop puts on a tool call (progress notifications do not extend it there), with margin for the
24
23
  // round-trip.
25
24
  const MAX_WAIT_SECONDS = 45;
25
+ const MAX_USER_QUERY_CHARS = 20000;
26
26
  const IMPORT_POLL_MS = 3000;
27
27
  // Languages the profile importer writes in.
28
28
  const IMPORT_LANGUAGES = new Set(['es', 'en', 'it', 'pt', 'fr']);
@@ -35,6 +35,10 @@ const LINKEDIN_PROFILE_RE = /^(?:https?:\/\/)?(?:[a-z]{2,3}\.)?linkedin\.com\/in
35
35
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
36
36
  // Personal mailbox providers; any other domain may be the talent's employer, so the email question adds the work-email hint.
37
37
  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']);
38
+ // How the model passes the talent's free answer to the welcome.
39
+ const WELCOME_ANSWER = 'Then call signup_start again with answer = what the talent wrote, word for word, and, as it applies: cvText with the text of a CV they attached or pasted in the chat, cvPath when they give their CV as a path on this computer, linkedinUrl when they give their LinkedIn, search:true when they ask you to look for their CV here (it asks them before searching).';
40
+ // A CV attached in the chat reaches the model as its text, or as a path that exists only where the chat keeps its files.
41
+ const CV_TEXT_RULE = 'Never run commands, code or any other tool to read an attached file: pass the text of the CV you see in the chat as cvText.';
38
42
  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.';
39
43
  // What the model must send instead when a work-situation section is refused.
40
44
  const SECTION_HELP = {
@@ -71,6 +75,12 @@ function looksLikeWorkEmail(email) {
71
75
  return !isPersonalEmail(email) && !RESERVED_EMAIL_DOMAIN_RE.test(emailDomain(email));
72
76
  }
73
77
 
78
+ // The first LinkedIn address in what the talent wrote, if any.
79
+ function linkedinIn(text) {
80
+ const m = typeof text === 'string' ? /(?:https?:\/\/)?(?:[a-z]{2,3}\.)?linkedin\.com\/in\/[^\s<>()"']+/i.exec(text) : null;
81
+ return m ? m[0].replace(/[.,;:!?]+$/, '') : null;
82
+ }
83
+
74
84
  function isLinkedinProfileUrl(url) {
75
85
  const m = LINKEDIN_PROFILE_RE.exec(url);
76
86
  return !!m && m[1].toLowerCase() !== 'me';
@@ -162,22 +172,17 @@ const WELCOME_SCHEMA = {
162
172
  properties: {
163
173
  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." },
164
174
  firstName: { type: 'string', description: 'Only if you already know it: the welcome greets them by name.' },
165
- answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
166
- 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.' },
175
+ answer: { type: 'string', description: 'What the talent wrote in answer to the welcome, word for word.' },
176
+ 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. A file attached to the chat is not on this machine: send its text as cvText instead.' },
177
+ cvText: { type: 'string', description: `When the talent attached or pasted their CV in this chat: its text as you see it, word for word (at most ${MAX_CV_CHARS} characters). It is used as their CV with no question and no file read; never run commands or other tools to get it.` },
178
+ talentName: { type: 'string', description: "With cvText: the talent's full name as you know it (memory, chat), so a CV that names someone else is not used." },
179
+ linkedinUrl: { type: 'string', description: 'Their LinkedIn profile URL, when their first message or their answer gives it: it is never asked again.' },
180
+ search: { type: 'boolean', description: 'With answer: true when they ask you to look for their CV on this computer. It asks them before searching.' },
167
181
  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.' },
168
182
  },
169
183
  required: ['language'],
170
184
  };
171
185
 
172
- const EMAIL_SCHEMA = {
173
- type: 'object',
174
- properties: {
175
- 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.' },
176
- 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.' },
177
- answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
178
- },
179
- };
180
-
181
186
  // In the order the data notice names them.
182
187
  const DRAFT_SOURCES = ['linkedin', 'cv'];
183
188
  const DRAFT_FIELDS = ['name', 'role', 'city', 'country', 'situation', 'participation', 'yearsOfExperience', 'stack', 'languages', 'workMode', 'monthlyHours', 'hourlyRate', 'annualRate'];
@@ -214,6 +219,22 @@ const DRAFT_SCHEMA = {
214
219
  required: ['name', 'role', 'hourlyRate', 'annualRate', 'sources'],
215
220
  };
216
221
 
222
+ const { answer: _draftAnswer, ...DRAFT_PREVIEW_FIELDS } = DRAFT_SCHEMA.properties;
223
+
224
+ const EMAIL_SCHEMA = {
225
+ type: 'object',
226
+ properties: {
227
+ 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.' },
228
+ 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.' },
229
+ answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
230
+ preview: {
231
+ type: 'object',
232
+ description: 'Your draft of the profile (the fields of signup_draft), passed with the email question so that its answer hands over the whole draft for the talent\'s next turn. Nothing is sent to Shakers.',
233
+ properties: DRAFT_PREVIEW_FIELDS,
234
+ },
235
+ },
236
+ };
237
+
217
238
  const CREATE_SCHEMA = {
218
239
  type: 'object',
219
240
  properties: {
@@ -222,6 +243,8 @@ const CREATE_SCHEMA = {
222
243
  firstName: { type: 'string' },
223
244
  lastName: { type: 'string' },
224
245
  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.' },
246
+ termsAnswer: { type: 'string', description: 'The option the talent picked when you printed the terms question this tool returned. Never set it yourself.' },
247
+ newsletterAnswer: { type: 'string', description: 'The option the talent picked when you printed the newsletter question this tool returned. Never set it yourself.' },
225
248
  },
226
249
  };
227
250
 
@@ -229,7 +252,7 @@ const STATUS_SCHEMA = {
229
252
  type: 'object',
230
253
  properties: {
231
254
  waitSeconds: { type: 'number', description: `Wait up to this many seconds (max ${MAX_WAIT_SECONDS}) for the account or the import to change before answering.` },
232
- windowLink: { type: 'boolean', description: 'true only when the talent says the sign-up window did not open: returns the text with its link.' },
255
+ windowLink: { type: 'boolean', description: 'true only when the talent says the Shakers page did not open or lost the link: returns the text with the link and the code again.' },
233
256
  },
234
257
  };
235
258
 
@@ -301,10 +324,10 @@ function makeSignupTools(deps = {}) {
301
324
  startDeviceLogin = require('./device-login').startDeviceLogin,
302
325
  pollDeviceToken = require('./device-login').pollDeviceToken,
303
326
  finalizeDeviceSession = require('./device-login').finalizeDeviceSession,
304
- appendProviderParam = require('./device-login').appendProviderParam,
327
+ withDeviceParams = require('./device-login').withDeviceParams,
305
328
  deviceSessionRecord = require('./device-login').deviceSessionRecord,
306
329
  buildRegistrationContext = require('./signup-client').buildRegistrationContext,
307
- runLoopbackAuth = require('./mcp-auth-form').runLoopbackAuth,
330
+ buildRegistrationConsent = require('./signup-client').buildRegistrationConsent,
308
331
  checkOnboardingCompleted = require('./onboarding-availability').checkOnboardingCompleted,
309
332
  fetchPricingRate = (opts) => require('./rate-client').fetchPricingRate({}, opts),
310
333
  fetchAvailability = (opts) => require('./availability-client').fetchAvailability({}, opts),
@@ -319,7 +342,11 @@ function makeSignupTools(deps = {}) {
319
342
  const state = {
320
343
  claimCode: null,
321
344
  windowUrl: null,
345
+ userCode: null,
322
346
  windowOpenFailed: false,
347
+ consent: null,
348
+ newsletterConsent: false,
349
+ toldReady: false,
323
350
  sessionToken: null,
324
351
  email: null,
325
352
  draft: null,
@@ -335,6 +362,12 @@ function makeSignupTools(deps = {}) {
335
362
  reported: { account: null, import: null },
336
363
  failedSources: new Set(),
337
364
  cvPath: null,
365
+ // The text of a CV attached in the chat, sent with the account as part of what Shakers knows of the talent.
366
+ cvText: null,
367
+ linkedinUrl: null,
368
+ welcomed: false,
369
+ // What the talent can do on the web is said once, when it first opens.
370
+ toldWeb: false,
338
371
  wake: null,
339
372
  importStartedAt: null,
340
373
  importReported: new Set(),
@@ -345,8 +378,8 @@ function makeSignupTools(deps = {}) {
345
378
  const before = state.account.state;
346
379
  state.account = next;
347
380
  if ((next.state === 'created' || next.state === 'existing') && before !== next.state) {
348
- funnel.completed('account_creation', { auth_method: next.via, is_returning_account: next.state === 'existing' });
349
- funnel.identified();
381
+ funnel.completed('account_creation', { auth_attempts: 1, is_returning_account: next.state === 'existing' });
382
+ funnel.identified({ newAccount: next.state === 'created' });
350
383
  }
351
384
  if (next.state === 'failed' && before !== 'failed') {
352
385
  funnel.errored('account_creation', next.reason);
@@ -363,8 +396,7 @@ function makeSignupTools(deps = {}) {
363
396
  sleep(BROWSER_OPEN_DELAY_MS).then(() => { if (openBrowser(url) === false) onFailed(); }).catch(onFailed);
364
397
  }
365
398
 
366
- // Background device flow behind the window's Google button; the claim code is read when it finalizes.
367
- async function finishGoogle(authz, tokenEndpoint, authTokenEndpoint) {
399
+ async function finishDevice(authz, tokenEndpoint, authTokenEndpoint) {
368
400
  let interval = authz.interval;
369
401
  const deadline = now() + authz.expiresIn * 1000;
370
402
  while (now() < deadline) {
@@ -373,7 +405,7 @@ function makeSignupTools(deps = {}) {
373
405
  if (res.status === 'pending') continue;
374
406
  if (res.status === 'slow_down') { interval += 5; continue; }
375
407
  if (res.status !== 'authorized') {
376
- setAccount({ state: 'failed', reason: res.status === 'denied' || res.status === 'expired' ? `google-${res.status}` : (res.reason || 'google-failed') });
408
+ setAccount({ state: 'failed', reason: res.status === 'denied' || res.status === 'expired' ? `device-${res.status}` : (res.reason || 'device-failed') });
377
409
  return;
378
410
  }
379
411
  const fin = await finalizeDeviceSession({
@@ -381,7 +413,12 @@ function makeSignupTools(deps = {}) {
381
413
  isNewUser: res.isNewUser,
382
414
  authTokenEndpoint,
383
415
  completeRegistrationEndpoint: getCompleteRegistrationEndpoint(),
384
- registrationContext: buildRegistrationContext({ preferredLanguage: lang(), newsletterConsent: false, freelanceType: 'POTENTIAL_FREELANCE' }),
416
+ registrationContext: buildRegistrationContext({
417
+ preferredLanguage: lang(),
418
+ newsletterConsent: state.newsletterConsent,
419
+ freelanceType: flow.deriveFreelanceTypeFromSituation((state.draft && state.draft.situation) || '') || 'POTENTIAL_FREELANCE',
420
+ }),
421
+ consent: state.consent,
385
422
  claimCode: res.isNewUser ? state.claimCode : null,
386
423
  });
387
424
  if (!fin.ok) {
@@ -397,74 +434,67 @@ function makeSignupTools(deps = {}) {
397
434
  }
398
435
  state.sessionToken = res.accessToken;
399
436
  setAccount(res.isNewUser
400
- ? { state: 'created', via: 'google', email, claimed: fin.claimed === true }
401
- : { state: 'existing', via: 'google', email });
437
+ ? { state: 'created', email, claimed: fin.claimed === true }
438
+ : { state: 'existing', email });
402
439
  return;
403
440
  }
404
- setAccount({ state: 'failed', reason: 'google-expired' });
441
+ setAccount({ state: 'failed', reason: 'device-expired' });
405
442
  }
406
443
 
407
- async function onGoogle() {
444
+ async function startDevice() {
408
445
  const authorizeEndpoint = getDeviceAuthorizeEndpoint();
409
446
  const tokenEndpoint = getDeviceTokenEndpoint();
410
447
  const authTokenEndpoint = getAuthTokenEndpoint();
411
448
  if (!authorizeEndpoint || !tokenEndpoint || !authTokenEndpoint) return { ok: false, reason: 'no-endpoint' };
412
449
  const authz = await startDeviceLogin({ authorizeEndpoint });
413
450
  if (!authz.ok) return { ok: false, reason: authz.reason || 'authorize-failed' };
414
- finishGoogle(authz, tokenEndpoint, authTokenEndpoint).catch(() => setAccount({ state: 'failed', reason: 'google-failed' }));
415
- return { ok: true, url: appendProviderParam(authz.verificationUriComplete, 'google') };
451
+ state.windowUrl = withDeviceParams(authz.verificationUriComplete, { mode: 'register', surface: 'mcp' });
452
+ state.userCode = authz.userCode;
453
+ state.windowOpenFailed = false;
454
+ setAccount({ state: 'waiting' });
455
+ finishDevice(authz, tokenEndpoint, authTokenEndpoint).catch(() => setAccount({ state: 'failed', reason: 'device-failed' }));
456
+ openSoon(state.windowUrl, () => { state.windowOpenFailed = true; });
457
+ return { ok: true };
416
458
  }
417
459
 
418
- function openWindow(identity) {
419
- setAccount({ state: 'waiting' });
420
- let announce;
421
- const announced = new Promise((resolve) => { announce = resolve; });
422
- runLoopbackAuth(
423
- {
424
- mode: 'register',
425
- deps: resolved,
426
- lang: lang(),
427
- registerFields: {
428
- email: identity.email,
429
- name: identity.firstName,
430
- lastName: identity.lastName,
431
- preferredLanguage: lang(),
432
- claimCode: () => state.claimCode,
433
- },
434
- },
435
- {
436
- timeoutMs: SIGNUP_WINDOW_TIMEOUT_MS,
437
- openBrowser: (url) => openSoon(url, () => { state.windowOpenFailed = true; }),
438
- onGoogle,
439
- onUrl: (url) => { state.windowUrl = url; announce(url); },
440
- },
441
- ).then((outcome) => {
442
- if (outcome && outcome.viaSocial) return;
443
- if (!outcome || !outcome.ok) {
444
- setAccount({ state: 'failed', reason: (outcome && outcome.reason) || 'window-failed' });
445
- return;
446
- }
447
- setAccount(outcome.accountExists
448
- ? { state: 'existing', via: 'email', email: outcome.email }
449
- : { state: 'created', via: 'email', email: outcome.email, claimed: outcome.claimed === true });
450
- }, () => setAccount({ state: 'failed', reason: 'window-failed' }));
451
- return Promise.race([announced, sleep(2000).then(() => state.windowUrl)]);
460
+ // A CV the talent attached or pasted in the chat: its text is their CV, unless it names someone else.
461
+ function takeCvText(args) {
462
+ const text = nonEmpty(args.cvText);
463
+ if (!text) return null;
464
+ if (mentionsName(text, nonEmpty(args.talentName)) === false) {
465
+ return { ok: false, reason: 'cv-not-talent', message: 'This CV does not name the talent: it belongs to someone else. Do not use, quote or describe it; ask them for their own CV.' };
466
+ }
467
+ state.cvText = text.slice(0, MAX_CV_CHARS);
468
+ const linkedin = extractLinks(state.cvText).linkedin.find(isLinkedinProfileUrl);
469
+ if (linkedin && !state.linkedinUrl) state.linkedinUrl = linkedin;
470
+ return { ok: true };
452
471
  }
453
472
 
454
- // Step 1: welcome, value proposal, the three steps and one question (send me your CV, or shall I look for it here?).
473
+ function cvInChat() {
474
+ return {
475
+ ok: true,
476
+ step: 'cv-text',
477
+ message: `The CV the talent attached is in hand: build the draft from it with sources ['cv'] (and 'linkedin' too when you have their LinkedIn), and never read the file in any other way; its text also goes to Shakers with the account, after they confirm. ${linkedinLine()}`,
478
+ };
479
+ }
480
+
481
+ // Step 1: welcome, value proposal, the three steps and an open line (send me your CV and LinkedIn, or I look for the CV).
455
482
  async function signupStart(args = {}, ctx = {}) {
456
483
  // The LinkedIn question is a tool block, so a welcome still pending is read before it and the AI words nothing.
457
484
  if (args.linkedinMissing === true && !nonEmpty(args.answer)) {
485
+ if (state.linkedinUrl) return { ok: true, step: 'linkedin-given', linkedinUrl: state.linkedinUrl, message: `The talent already gave their LinkedIn: pass ${state.linkedinUrl} as linkedinUrl when the account is created, never ask for it, and call signup_email now.` };
458
486
  const c = copy();
487
+ const linkedin = openQuestion(c.linkedinAsk);
459
488
  return {
460
489
  ok: true,
461
490
  step: 'linkedin-ask',
462
- say: [...takeNotices(), c.linkedinAsk].join('\n\n'),
491
+ ...linkedin,
463
492
  linkedinOpening: c.linkedinOpening,
464
- 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.',
493
+ message: `${linkedin.message} 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.`,
465
494
  };
466
495
  }
467
- if (!nonEmpty(args.answer)) {
496
+ const answering = nonEmpty(args.answer);
497
+ if (!answering) {
468
498
  fixSignupLanguage(args.language);
469
499
  funnel.begin();
470
500
  funnel.viewed('start');
@@ -478,59 +508,91 @@ function makeSignupTools(deps = {}) {
478
508
  state.toldKept = new Set();
479
509
  state.toldExisting = false;
480
510
  state.cvPath = null;
481
- takeNotices();
511
+ state.cvText = null;
512
+ state.linkedinUrl = null;
513
+ state.welcomed = false;
514
+ state.toldWeb = false;
515
+ resetQuestions();
482
516
  }
483
- if (nonEmpty(args.cvPath)) state.cvPath = nonEmpty(args.cvPath);
517
+ const cvGiven = takeCvText(args);
518
+ if (cvGiven && !cvGiven.ok) return cvGiven;
519
+ if (nonEmpty(args.cvPath) && !cvGiven) state.cvPath = nonEmpty(args.cvPath);
520
+ // A LinkedIn in the first message or in the answer is kept, so the LinkedIn question is never asked.
521
+ const linkedinGiven = nonEmpty(args.linkedinUrl) || linkedinIn(args.answer);
522
+ const badLinkedin = linkedinGiven && !isLinkedinProfileUrl(linkedinGiven) ? `${INVALID_LINKEDIN_MESSAGE} ` : '';
523
+ if (linkedinGiven && !badLinkedin) state.linkedinUrl = linkedinGiven;
484
524
  const c = copy();
485
- 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.';
486
- // A CV path in the first message already answers the CV question: that CV is read, and the welcome
525
+ // A CV attached to the first message already answers the welcome, which rides on the next question block.
526
+ if (!answering && cvGiven) {
527
+ state.welcomed = true;
528
+ funnel.completed('start');
529
+ funnel.viewed('import_sources', { sources_offered: DRAFT_SOURCES });
530
+ queueNotice(c.welcome(nonEmpty(args.firstName)));
531
+ return { ...cvInChat(), language: lang() };
532
+ }
533
+ // A CV path in the first message already answers the welcome: that CV is read, and the welcome
487
534
  // rides on the next question block, because models skip a relay that comes before another tool call.
488
- if (!nonEmpty(args.answer) && state.cvPath) {
535
+ if (!answering && state.cvPath) {
489
536
  funnel.completed('start');
537
+ funnel.viewed('import_sources', { sources_offered: DRAFT_SOURCES });
490
538
  queueNotice(c.welcome(nonEmpty(args.firstName)));
491
539
  return {
492
540
  ok: true,
493
541
  step: 'cv-given',
494
542
  cvPath: state.cvPath,
495
543
  language: lang(),
496
- 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}`,
544
+ 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. ${linkedinLine()}`,
497
545
  };
498
546
  }
499
- const asked = await askAfterTexts(ctx, {
500
- tool: 'signup_start',
501
- texts: [c.welcome(nonEmpty(args.firstName))],
502
- question: c.welcomeQuestion,
503
- options: [c.cvAttach, c.cvSearch],
504
- answer: args.answer,
505
- });
506
- if (asked.reply) return { ...asked.reply, language: lang() };
547
+ // The tools load lazily, so the model reads this tool only after a first call: the welcome cannot be written before
548
+ // any call. It comes whole as `say` and ends with an open line, which the talent answers in their own words.
549
+ if (!answering || !state.welcomed) {
550
+ state.welcomed = true;
551
+ const open = openQuestion([c.welcome(nonEmpty(args.firstName)), c.welcomeQuestion].join('\n\n'));
552
+ return { ok: false, reason: 'answer-required', ...open, language: lang(), message: `${open.message} ${WELCOME_ANSWER}` };
553
+ }
507
554
  funnel.completed('start');
508
- // A CV path the talent gave wins over both answers: searching could read another candidate.
555
+ funnel.viewed('import_sources', { sources_offered: DRAFT_SOURCES });
556
+ if (cvGiven) return { ...cvInChat(), ...(badLinkedin ? { message: `${badLinkedin}${cvInChat().message}` } : {}) };
557
+ // A CV path the talent gave wins over a search: searching could read another candidate.
509
558
  const cvPath = state.cvPath;
510
559
  if (cvPath) {
511
560
  return {
512
561
  ok: true,
513
562
  step: 'cv-given',
514
563
  cvPath,
515
- 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}`,
564
+ message: `${badLinkedin}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. ${linkedinLine()}`,
516
565
  };
517
566
  }
518
- if (asked.answer === c.cvSearch) {
567
+ if (args.search === true) {
519
568
  return {
520
569
  ok: true,
521
570
  step: 'cv-search',
522
571
  cvNotFound: c.cvNotFound,
523
- 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}`,
572
+ message: `${badLinkedin}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. ${linkedinLine()}`,
573
+ };
574
+ }
575
+ if (state.linkedinUrl) {
576
+ return {
577
+ ok: true,
578
+ step: 'linkedin-given',
579
+ linkedinUrl: state.linkedinUrl,
580
+ message: `The talent gave their LinkedIn: pass ${state.linkedinUrl} as linkedinUrl when the account is created, never ask for it again. If their message also holds their CV (attached or pasted), call signup_start again with the same answer and cvText = its text; otherwise the draft comes from LinkedIn and what you know, with sources ['linkedin']. Ask nothing about the CV and call signup_email now.`,
524
581
  };
525
582
  }
526
583
  return {
527
584
  ok: true,
528
585
  step: 'cv-attach',
529
586
  relayVerbatim: c.cvAttachAsk,
530
- 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}`,
587
+ message: `${badLinkedin}If their message already holds the CV (attached or pasted), do not print relayVerbatim: call signup_start again with the same answer and cvText = its text. ${CV_TEXT_RULE} Otherwise ${VERBATIM} Then wait for the CV in the chat. ${linkedinLine()}`,
531
588
  };
532
589
  }
533
590
 
591
+ function linkedinLine() {
592
+ if (state.linkedinUrl) return `LinkedIn: the talent gave ${state.linkedinUrl}; pass it as linkedinUrl when the account is created, never ask for it, and call signup_email once you have the CV.`;
593
+ return '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.';
594
+ }
595
+
534
596
  // Step 2: which email the account is created with, the one they read most; a company domain gets the work-email hint.
535
597
  async function signupEmail(args = {}, ctx = {}) {
536
598
  const c = copy();
@@ -544,24 +606,29 @@ function makeSignupTools(deps = {}) {
544
606
  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.` };
545
607
  }
546
608
  const question = looksLikeWorkEmail(email) ? `${c.emailKnown(email)} ${c.emailWorkHint}` : c.emailKnown(email);
547
- const asked = await askAfterTexts(ctx, { tool: 'signup_email', texts: [], question, options: [c.emailYes, c.emailOther], answer: args.answer });
609
+ // With the draft at hand, a yes hands it over, so the talent reads it at the start of the turn that asks to confirm it.
610
+ const draft = args.preview && typeof args.preview === 'object' ? draftFrom(args.preview) : null;
611
+ const draftText = draft && draft.ok ? draftTexts(draft.draft, draft.sources, email) : null;
612
+ const nextSay = draftText ? { step: 'local_preview', ifAnswer: [c.emailYes], texts: draftText } : null;
613
+ const asked = await askAfterTexts(ctx, { tool: 'signup_email', texts: [], question, options: [c.emailYes, c.emailOther], answer: args.answer, nextSay });
548
614
  if (asked.reply) return asked.reply;
549
615
  if (asked.answer === c.emailYes) {
550
616
  state.email = email;
551
- return { ok: true, email, next: 'signup_draft', message: 'Email set. Now show the profile draft with signup_draft.' };
617
+ const handed = draftText && written(draftText.join('\n\n')) ? ' with the same draft and no answer: the talent read it at the start of your message, so it only returns the question to end your message with' : '';
618
+ return { ok: true, email, next: 'signup_draft', message: `Email set. Now show the profile draft with signup_draft${handed}.` };
552
619
  }
553
620
  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.` };
554
621
  }
555
622
 
556
623
  // A session this machine kept for the same email means the account exists, so the window will ask to sign in.
557
- function knownAccount() {
624
+ function knownAccount(email = state.email) {
558
625
  const stored = resolved.loadAuthSession();
559
- const email = stored && typeof stored.email === 'string' ? stored.email.toLowerCase() : null;
560
- return !!email && !!state.email && email === state.email.toLowerCase();
626
+ const kept = stored && typeof stored.email === 'string' ? stored.email.toLowerCase() : null;
627
+ return !!kept && !!email && kept === email.toLowerCase();
561
628
  }
562
629
 
563
- // Step 3: the draft, the data notice and how the window works; confirming it is what opens the window.
564
- async function signupDraft(args = {}, ctx = {}) {
630
+ // The draft as it will be shown and sent, or why it cannot be shown yet.
631
+ function draftFrom(args, answer) {
565
632
  const c = copy();
566
633
  const pricing = pricingFromRates(args);
567
634
  if (!pricing.ok) return pricing;
@@ -577,19 +644,38 @@ function makeSignupTools(deps = {}) {
577
644
  if (args.ratesFromTalent === true) draft.ratesFromTalent = true;
578
645
  const sources = Array.isArray(args.sources) ? DRAFT_SOURCES.filter((s) => args.sources.includes(s)) : [];
579
646
  // A confirmation of the printed block stands even if the model drops sources on the answer call.
580
- if (!sources.length && !optionFor(args.answer, [c.draftOk, c.draftChange])) {
647
+ if (!sources.length && !optionFor(answer, [c.draftOk, c.draftChange])) {
581
648
  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." };
582
649
  }
650
+ return { ok: true, draft, sources };
651
+ }
652
+
653
+ // The draft and the data notice: what the talent reads before confirming. How the account is created comes after.
654
+ function draftTexts(draft, sources, email) {
655
+ return [renderDraft(lang(), { ...draft, email }), legalCopy(lang()).signupNotice(sources)];
656
+ }
657
+
658
+ // Step 3: the draft and the data notice; confirming it leads to the terms and then to the Shakers page.
659
+ async function signupDraft(args = {}, ctx = {}) {
660
+ const c = copy();
661
+ const built = draftFrom(args, args.answer);
662
+ if (!built.ok) return built;
663
+ const { draft, sources } = built;
583
664
  if (!state.firstDraft) state.firstDraft = draft;
584
665
  trackDraftShown(draft);
666
+ // A confirmation leads to the terms question, whose text goes first in that turn; signing in to an account this machine already knows asks no terms.
667
+ const terms = knownAccount() ? null : { step: 'create_account', ifAnswer: [c.draftOk], texts: [registrationTermsText(lang())] };
585
668
  const asked = await askAfterTexts(ctx, {
586
669
  tool: 'signup_draft',
587
- texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice(sources), knownAccount() ? c.windowNoticeSignIn : c.windowNotice],
670
+ texts: draftTexts(draft, sources, state.email),
588
671
  question: c.draftQuestion,
589
672
  options: [c.draftOk, c.draftChange],
590
673
  answer: args.answer,
674
+ // What the confirmation is about: the draft the talent read, even if the model drops sources on the answer call.
675
+ about: { ...draft, email: state.email },
676
+ nextSay: terms,
591
677
  });
592
- if (asked.reply) return { ...asked.reply, message: `${asked.reply.message} Pass the same draft fields again.` };
678
+ if (asked.reply) return { ...asked.reply, message: `${asked.reply.message} ${DRAFT_RULE} Pass the same draft fields again.` };
593
679
  if (asked.answer === c.draftChange) {
594
680
  state.draftConfirmed = false;
595
681
  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.' };
@@ -600,7 +686,7 @@ function makeSignupTools(deps = {}) {
600
686
  return {
601
687
  ok: true,
602
688
  next: 'signup_create_account',
603
- message: 'Confirmed. Call signup_create_account now: the talent already read how the window works, so say nothing before it.',
689
+ message: 'Confirmed. Call signup_create_account now: it asks the terms and then explains how the account is created on the Shakers page, so say nothing about it before.',
604
690
  };
605
691
  }
606
692
 
@@ -619,8 +705,14 @@ function makeSignupTools(deps = {}) {
619
705
  }
620
706
  }
621
707
 
708
+ // A CV attached in the chat has no file to upload: its text travels in userQuery, within the field's limit.
709
+ function withCvText(userQuery) {
710
+ if (!state.cvText || (userQuery && userQuery.includes(state.cvText.slice(0, 200)))) return userQuery;
711
+ return [userQuery, state.cvText].filter(Boolean).join('\n\n').slice(0, MAX_USER_QUERY_CHARS);
712
+ }
713
+
622
714
  // Step 4: hub starts the import into an UNREGISTERED profile and the window opens to create the account (or sign in).
623
- async function signupCreateAccount(args = {}) {
715
+ async function signupCreateAccount(args = {}, ctx = {}) {
624
716
  const c = copy();
625
717
  if (!state.draftConfirmed) {
626
718
  return { ok: false, reason: 'draft-not-confirmed', message: 'Show the draft with signup_draft and get the talent\'s confirmation first.' };
@@ -635,7 +727,7 @@ function makeSignupTools(deps = {}) {
635
727
  };
636
728
  }
637
729
 
638
- const sources = { linkedinUrl: nonEmpty(args.linkedinUrl), cvPath: nonEmpty(args.cvPath), userQuery: nonEmpty(args.userQuery) };
730
+ const sources = { linkedinUrl: nonEmpty(args.linkedinUrl), cvPath: nonEmpty(args.cvPath), userQuery: withCvText(nonEmpty(args.userQuery)) };
639
731
  if (sources.linkedinUrl && !isLinkedinProfileUrl(sources.linkedinUrl)) return { ok: false, reason: 'invalid-linkedin-url', message: INVALID_LINKEDIN_MESSAGE };
640
732
  let cv = null;
641
733
  let cvNote = null;
@@ -649,6 +741,16 @@ function makeSignupTools(deps = {}) {
649
741
  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.' };
650
742
  }
651
743
 
744
+ if (!state.consent && !state.toldExisting) {
745
+ const terms = await askRegistrationTerms(ctx, { tool: 'signup_create_account', lang: lang(), termsAnswer: args.termsAnswer, newsletterAnswer: args.newsletterAnswer });
746
+ if (terms.reply) return terms.reply;
747
+ if (terms.declined) {
748
+ return { ok: false, reason: 'terms-declined', relayVerbatim: c.termsDeclined, message: `${VERBATIM} No account was created and nothing was sent. Only if the talent later says they accept, call signup_create_account again.` };
749
+ }
750
+ state.newsletterConsent = terms.newsletterConsent;
751
+ state.consent = buildRegistrationConsent({ newsletterConsent: terms.newsletterConsent });
752
+ }
753
+
652
754
  const identity = { email: state.email, firstName: nonEmpty(args.firstName), lastName: nonEmpty(args.lastName) };
653
755
  const language = IMPORT_LANGUAGES.has(lang()) ? lang() : 'en';
654
756
  const res = await requestMcpSignup(
@@ -665,19 +767,25 @@ function makeSignupTools(deps = {}) {
665
767
  const attempted = DRAFT_SOURCES.filter((source) => (source === 'cv' ? !!cv : !!sources.linkedinUrl));
666
768
  state.importStartedAt = Object.fromEntries(attempted.map((source) => [source, now()]));
667
769
  for (const source of attempted) funnel.track('source_import_started', 'import_sources', { source_type: source });
668
- funnel.completed('import_sources', { sources_attempted: attempted });
770
+ funnel.completed('import_sources', { sources_attempted: attempted, sources_connected: attempted, sources_connected_count: attempted.length });
669
771
  }
670
772
  if (!reused) {
671
- funnel.viewed('account_creation');
672
- await openWindow(identity);
773
+ funnel.viewed('account_creation', { auth_methods_offered: require('./auth-methods').methodIdsForFlow('register') });
774
+ const started = await startDevice();
775
+ if (!started.ok) {
776
+ setAccount({ state: 'failed', reason: started.reason });
777
+ return { ok: false, reason: started.reason, message: 'The Shakers sign-up page could not be prepared right now. Tell the talent plainly and offer to try again in a moment.' };
778
+ }
673
779
  }
780
+ // Only exists now (the link and the code), so it ends the turn; the talent comes back once the account is created.
781
+ const opening = openQuestion([state.toldExisting ? c.windowNoticeSignIn : c.windowNotice, c.windowLink(state.windowUrl, state.userCode), c.windowBack].join('\n\n'));
674
782
  return {
675
783
  ok: true,
676
784
  status: 'window-open',
677
785
  ...(cvNote ? { cvNote } : {}),
786
+ say: opening.say,
678
787
  next: 'signup_status',
679
- 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. ')
680
- + 'Say nothing about the window. Only if they say it did not open, call signup_status with windowLink:true. Now call signup_status (waitSeconds 45) until the account exists.',
788
+ message: `${opening.message} ${reused ? 'The Shakers page was already open; it now uses the new data. ' : 'The page opens in the talent\'s browser in a few seconds. '}Never invent or complete the code. When the talent comes back, call signup_status (waitSeconds 45) until the account exists.`,
681
789
  };
682
790
  }
683
791
 
@@ -714,7 +822,7 @@ function makeSignupTools(deps = {}) {
714
822
 
715
823
  function nextSteps(account) {
716
824
  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 45), then set_main_role without clusterId only when its next says so. Finally open_web.';
717
- 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).';
825
+ const aiUsage = 'Then ai_usage without consent: it returns one short question; its disclaimers come only after a yes, before any scan. After a yes, ai_usage_upload (waitSeconds 30) shows the talent what would be sent and asks; nothing is uploaded without their pick. That the account is ready comes with the next question: say nothing about it yourself.';
718
826
  if (account.state === 'existing') {
719
827
  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}`;
720
828
  }
@@ -738,8 +846,8 @@ function makeSignupTools(deps = {}) {
738
846
  noticeFailed(['linkedin']);
739
847
  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.');
740
848
  }
741
- if (account.state === 'waiting') lines.push('The talent is still in the sign-up window. Call signup_status again.');
742
- 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.');
849
+ if (account.state === 'waiting') lines.push('The talent is still on the Shakers sign-up page. Call signup_status again.');
850
+ if (account.state === 'failed') lines.push('The Shakers sign-up page ended without an account (denied, expired or failed). Ask the talent whether to try again; signup_create_account starts it again.');
743
851
  if (account.state === 'created' || account.state === 'existing') lines.push(nextSteps(account));
744
852
  return lines.join(' ');
745
853
  }
@@ -761,7 +869,11 @@ function makeSignupTools(deps = {}) {
761
869
  const account = { ...state.account };
762
870
  const out = { ok: true, account, import: imp };
763
871
  if (account.state === 'waiting' && (args.windowLink === true || state.windowOpenFailed) && state.windowUrl) {
764
- out.relayVerbatim = copy().windowLink(state.windowUrl);
872
+ out.relayVerbatim = copy().windowLink(state.windowUrl, state.userCode);
873
+ }
874
+ if ((account.state === 'created' || account.state === 'existing') && !state.toldReady) {
875
+ state.toldReady = true;
876
+ queueNotice(copy().accountReady(account.state === 'created'));
765
877
  }
766
878
  if (account.state === 'created' || account.state === 'existing') {
767
879
  let completed = null;
@@ -777,7 +889,7 @@ function makeSignupTools(deps = {}) {
777
889
  if (nonEmpty(args.cvPath)) {
778
890
  const file = path.resolve(expandHome(nonEmpty(args.cvPath)));
779
891
  const c = copy().consent;
780
- 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 });
892
+ const asked = await askConsent(ctx, { tool: 'import_profile', key: `import_profile:${file}`, question: c.cvSend(tildify(file)), yes: c.cvSendYes(path.basename(file)), no: c.no, answer: args.answer });
781
893
  if (asked.reply) return asked.reply;
782
894
  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.' };
783
895
  return importProfile({ ...args, cvPath: file });
@@ -861,7 +973,8 @@ function makeSignupTools(deps = {}) {
861
973
  state.toldKept = kept;
862
974
  const given = state.sources ? [state.sources.linkedinUrl && 'linkedin', state.sources.cvPath && 'cv'].filter(Boolean) : [];
863
975
  if (given.length) lines.push(c.existingLists(given));
864
- return `${state.toldExisting ? c.existingUpdateQuestion : c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
976
+ // The question goes last, after what would change: one question at the end of the message.
977
+ return { changes: lines.map((l) => `· ${l}`).join('\n'), question: state.toldExisting ? c.existingUpdateQuestion : c.existingTitle };
865
978
  }
866
979
 
867
980
  // The sections of save_profile_details an existing profile can take (only fields it leaves empty), and what it keeps instead.
@@ -925,8 +1038,9 @@ function makeSignupTools(deps = {}) {
925
1038
  const c = copy();
926
1039
  const session = resolved.loadAuthSession();
927
1040
  if (resolved.sessionStatus(session) !== 'active') return { ok: false, reason: 'no-session', message: 'The talent is not signed in yet: wait for signup_status.' };
928
- const question = await existingDiff();
929
- const asked = await askAfterTexts(ctx, { tool: 'update_existing_profile', texts: [], question, options: [c.existingYes, c.existingNo], answer: args.answer });
1041
+ const { changes, question } = await existingDiff();
1042
+ // The AI-usage question that follows is short; its disclaimers are handed over only with its yes.
1043
+ const asked = await askAfterTexts(ctx, { tool: 'update_existing_profile', texts: [changes], question, options: [c.existingYes, c.existingNo], answer: args.answer });
930
1044
  if (asked.reply) return asked.reply;
931
1045
  if (asked.answer === c.existingNo) {
932
1046
  state.keepProfile = true;
@@ -958,11 +1072,12 @@ function makeSignupTools(deps = {}) {
958
1072
  }
959
1073
 
960
1074
  function trackDraftShown(draft) {
961
- funnel.viewed('work_situation', { is_prefilled: true });
962
- funnel.viewed('availability');
1075
+ const prefilled = (step) => ({ is_prefilled: STEP_DRAFT_FIELDS[step].some((key) => draft[key] !== undefined) });
1076
+ funnel.viewed('work_situation', prefilled('work_situation'));
1077
+ funnel.viewed('availability', prefilled('availability'));
963
1078
  funnel.viewed('rates', { suggested_rate_shown: draft.ratesFromTalent !== true });
964
- funnel.viewed('languages');
965
- funnel.viewed('location');
1079
+ funnel.viewed('languages', prefilled('languages'));
1080
+ funnel.viewed('location', prefilled('location'));
966
1081
  funnel.viewed('profile_summary');
967
1082
  }
968
1083
 
@@ -978,7 +1093,8 @@ function makeSignupTools(deps = {}) {
978
1093
 
979
1094
  function trackSavedSteps(details, results) {
980
1095
  const saved = (section) => results[section] && results[section].ok === true;
981
- const step = (name, props) => funnel.completed(name, { ...props, is_prefilled: true, was_edited: wasEdited(name) });
1096
+ const draftTime = funnel.stepTime('profile_summary');
1097
+ const step = (name, props) => funnel.completed(name, { ...props, is_prefilled: true, was_edited: wasEdited(name), time_on_step_ms: draftTime });
982
1098
  if (saved('workSituation')) {
983
1099
  step('work_situation', { work_situation: workSituationValue(String(details.workSituation.situation || '').trim().toUpperCase()) });
984
1100
  }
@@ -996,7 +1112,7 @@ function makeSignupTools(deps = {}) {
996
1112
  }
997
1113
  if (saved('pricing')) {
998
1114
  const types = rateTypes(pricingFromRates(details.pricing).input);
999
- 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') });
1115
+ 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'), time_on_step_ms: draftTime });
1000
1116
  }
1001
1117
  if (saved('languages')) {
1002
1118
  const codes = details.languages.map((row) => String(row.language || '').trim().toLowerCase()).filter(Boolean);
@@ -1004,7 +1120,7 @@ function makeSignupTools(deps = {}) {
1004
1120
  }
1005
1121
  const sections = Object.entries(results).filter(([name]) => name !== 'phone');
1006
1122
  if (sections.length && sections.every(([, r]) => r.ok === true)) {
1007
- funnel.completed('profile_summary', { fields_edited_count: draftFieldsEdited() });
1123
+ funnel.completed('profile_summary', { fields_edited_count: draftFieldsEdited(), time_on_step_ms: draftTime });
1008
1124
  }
1009
1125
  funnel.flush();
1010
1126
  }
@@ -1062,10 +1178,10 @@ function makeSignupTools(deps = {}) {
1062
1178
 
1063
1179
  async function openWeb(args = {}) {
1064
1180
  const session = resolved.loadAuthSession();
1065
- const cookie = session && resolved.sessionStatus(session) === 'active' ? session.cookie || null : null;
1181
+ const sessionToken = state.sessionToken || (session && resolved.sessionStatus(session) === 'active' ? session.sessionToken || null : null);
1066
1182
  let url = null;
1067
- if (cookie || state.sessionToken) {
1068
- const minted = await requestOneTimeToken({ cookie, sessionToken: state.sessionToken }, { endpoint: getOneTimeTokenEndpoint() });
1183
+ if (sessionToken) {
1184
+ const minted = await requestOneTimeToken({ sessionToken }, { endpoint: getOneTimeTokenEndpoint() });
1069
1185
  if (minted.ok) url = getOneTimeLoginUrl(minted.token);
1070
1186
  }
1071
1187
  const loggedIn = !!url;
@@ -1078,8 +1194,9 @@ function makeSignupTools(deps = {}) {
1078
1194
  // A source that failed after the last signup_status gets its fixed line here, as there is no question left.
1079
1195
  const failed = DRAFT_SOURCES.filter((s) => imp.sources && imp.sources[s] && imp.sources[s].state === 'failed');
1080
1196
  noticeFailed(failed);
1081
- // The flow ends here, so a fixed line no question carried is printed now.
1082
- const unseen = takeNotices();
1197
+ // The flow ends here, so a fixed line no question carried is printed now, and what the talent can do on the web.
1198
+ const unseen = [...takeNotices(), ...(state.toldWeb ? [] : [copy().webNext])];
1199
+ state.toldWeb = true;
1083
1200
  const relay = unseen.length ? { relayVerbatim: unseen.join('\n\n') } : {};
1084
1201
  const importLine = failed.length
1085
1202
  ? `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.`
@@ -1116,8 +1233,8 @@ function makeSignupTools(deps = {}) {
1116
1233
  {
1117
1234
  name: 'signup_start',
1118
1235
  title: 'Start your Shakers sign-up',
1119
- description: 'Starts your sign-up here in the chat and asks whether you send your CV or want it looked for on this computer.',
1120
- 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.",
1236
+ description: 'Starts your sign-up here in the chat and asks for your CV and LinkedIn, or to look for your CV on this computer.',
1237
+ 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, and cvText when they attached or pasted their CV in the chat (its text as you see it; never run commands or other tools to read an attached file). It returns the welcome to show word for word, ending with an open line (send me your CV and your LinkedIn, or I look for your CV on this computer) that the talent answers in their own words, with no options. 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`, nextSay or relayVerbatim is written word for word, in that language.",
1121
1238
  inputSchema: WELCOME_SCHEMA,
1122
1239
  handler: signupStart,
1123
1240
  },
@@ -1125,7 +1242,7 @@ function makeSignupTools(deps = {}) {
1125
1242
  name: 'signup_email',
1126
1243
  title: 'Choose your account email',
1127
1244
  description: 'Asks which email your Shakers account will use.',
1128
- 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.',
1245
+ 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, and your draft of the profile in `preview`: a yes then hands the whole draft over as nextSay, to write at the start of the next turn. Never comment on the email yourself.',
1129
1246
  inputSchema: EMAIL_SCHEMA,
1130
1247
  handler: signupEmail,
1131
1248
  },
@@ -1133,15 +1250,15 @@ function makeSignupTools(deps = {}) {
1133
1250
  name: 'signup_draft',
1134
1251
  title: 'Review your profile draft',
1135
1252
  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.',
1136
- 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.',
1253
+ guidance: 'Sign-up step before the account: shows the talent, here in the chat, the profile draft you prepared from their CV, LinkedIn and what you know, every field that will be sent (estimated rates marked as such), with the data notice, and asks them to confirm it: that text comes as the nextSay of the email question, or as its own `say` when the email was typed or the draft changed. Never ask them to confirm without having written that whole draft in the same message. It is a local preview: nothing leaves this computer in this step, and nothing is created or sent until they confirm. How the account is created on the Shakers website comes after the confirmation and the terms.',
1137
1254
  inputSchema: DRAFT_SCHEMA,
1138
1255
  handler: signupDraft,
1139
1256
  },
1140
1257
  {
1141
1258
  name: 'signup_create_account',
1142
1259
  title: 'Create your account',
1143
- 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.',
1144
- 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.",
1260
+ description: 'Once you confirm the draft and accept the Terms, sends your CV, LinkedIn and draft to Shakers and opens a Shakers page (shakersworks.com) with a code, where you create your account with Google, LinkedIn or email. Your password never goes through the chat.',
1261
+ guidance: "After the talent confirmed the draft: first asks the talent to accept the Terms and Privacy Policy, whose text came as the nextSay of the confirmation, and whether they want the newsletter (a dialog when the client has one, otherwise a question; pass each pick back as answer, or as termsAnswer and newsletterAnswer, never your own). If they decline the terms nothing is created or sent. Then starts the profile import on Shakers and opens the Shakers sign-in page with a code in the talent's browser, returning as `say` how the account is created there, the link and the code: write it as your whole message and end your turn. They create the account there (Google, LinkedIn or email) or sign in if they already have one, check the code and approve. No password ever goes through the chat. Returns next: signup_status.",
1145
1262
  inputSchema: CREATE_SCHEMA,
1146
1263
  handler: signupCreateAccount,
1147
1264
  },
@@ -1166,7 +1283,7 @@ function makeSignupTools(deps = {}) {
1166
1283
  name: 'import_profile',
1167
1284
  title: 'Import into your profile',
1168
1285
  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.',
1169
- 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.",
1286
+ 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 the question to ask as its message says); nothing is sent until they answer yes. After asking it in the chat, call it again with the same arguments and answer set to the option they pick. Never answer for them. Takes up to ~90 s.",
1170
1287
  inputSchema: IMPORT_SCHEMA,
1171
1288
  handler: importProfileAsked,
1172
1289
  },
@@ -1197,4 +1314,4 @@ function makeSignupTools(deps = {}) {
1197
1314
  ];
1198
1315
  }
1199
1316
 
1200
- module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, looksLikeWorkEmail, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };
1317
+ module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, looksLikeWorkEmail, WELCOME_ANSWER, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };