shakers-cli 0.17.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 (59) hide show
  1. package/README.md +15 -5
  2. package/bin/command-audit-worker.js +19 -0
  3. package/bin/login.js +4 -8
  4. package/bin/mcp.js +29 -19
  5. package/bin/register.js +10 -29
  6. package/bin/shakers-cli.js +14 -9
  7. package/package.json +1 -1
  8. package/src/agents-client.js +1 -0
  9. package/src/ai-usage-review.js +5 -3
  10. package/src/auth-client.js +7 -107
  11. package/src/auth-session-store.js +6 -12
  12. package/src/backend-request.js +1 -24
  13. package/src/cli-skill.js +77 -0
  14. package/src/command-audit.js +47 -4
  15. package/src/config.js +0 -16
  16. package/src/consent-flow.js +7 -2
  17. package/src/detect-repos.js +4 -8
  18. package/src/detectors.js +23 -0
  19. package/src/device-auth-store.js +2 -2
  20. package/src/device-login.js +11 -9
  21. package/src/env-paths.js +9 -1
  22. package/src/i18n.js +162 -88
  23. package/src/mcp-ai-usage-result-tool.js +6 -4
  24. package/src/mcp-ai-usage-tool.js +72 -46
  25. package/src/mcp-choice.js +171 -34
  26. package/src/mcp-detector.js +26 -2
  27. package/src/mcp-device-login-tools.js +40 -8
  28. package/src/mcp-install.js +15 -11
  29. package/src/mcp-instructions.js +43 -17
  30. package/src/mcp-onboarding-tools.js +1 -3
  31. package/src/mcp-prompts.js +6 -4
  32. package/src/mcp-register-tools.js +25 -14
  33. package/src/mcp-role-tools.js +35 -17
  34. package/src/mcp-server-name.js +7 -0
  35. package/src/mcp-server.js +35 -7
  36. package/src/mcp-signup-client.js +3 -6
  37. package/src/mcp-signup-flow.js +389 -0
  38. package/src/mcp-signup-tools.js +237 -126
  39. package/src/onboarding-flow.js +30 -125
  40. package/src/opencode-sessions.js +92 -0
  41. package/src/profile-agents.js +26 -0
  42. package/src/saved-positions-flow.js +3 -3
  43. package/src/scanner.js +104 -12
  44. package/src/session-gate.js +1 -4
  45. package/src/session-normalizers.js +114 -8
  46. package/src/session-refresh.js +5 -16
  47. package/src/session-scan.js +167 -22
  48. package/src/share-payload.js +4 -1
  49. package/src/share.js +2 -2
  50. package/src/signup-client.js +12 -39
  51. package/src/signup-copy.js +380 -68
  52. package/src/skill-install.js +102 -0
  53. package/src/tier-engine.js +14 -2
  54. package/src/trust-narration.js +32 -0
  55. package/src/usage-run-steps.js +12 -3
  56. package/src/usage-scan.js +3 -0
  57. package/src/brand-svg.js +0 -9
  58. package/src/inter-fonts.js +0 -6
  59. package/src/mcp-auth-form.js +0 -484
@@ -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,10 +362,15 @@ 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(),
341
- authAttempts: undefined,
342
374
  };
343
375
  currentSignup = state;
344
376
 
@@ -346,7 +378,7 @@ function makeSignupTools(deps = {}) {
346
378
  const before = state.account.state;
347
379
  state.account = next;
348
380
  if ((next.state === 'created' || next.state === 'existing') && before !== next.state) {
349
- funnel.completed('account_creation', { auth_method: next.via, auth_attempts: state.authAttempts, is_returning_account: next.state === 'existing' });
381
+ funnel.completed('account_creation', { auth_attempts: 1, is_returning_account: next.state === 'existing' });
350
382
  funnel.identified({ newAccount: next.state === 'created' });
351
383
  }
352
384
  if (next.state === 'failed' && before !== 'failed') {
@@ -364,8 +396,7 @@ function makeSignupTools(deps = {}) {
364
396
  sleep(BROWSER_OPEN_DELAY_MS).then(() => { if (openBrowser(url) === false) onFailed(); }).catch(onFailed);
365
397
  }
366
398
 
367
- // Background device flow behind the window's Google button; the claim code is read when it finalizes.
368
- async function finishGoogle(authz, tokenEndpoint, authTokenEndpoint) {
399
+ async function finishDevice(authz, tokenEndpoint, authTokenEndpoint) {
369
400
  let interval = authz.interval;
370
401
  const deadline = now() + authz.expiresIn * 1000;
371
402
  while (now() < deadline) {
@@ -374,7 +405,7 @@ function makeSignupTools(deps = {}) {
374
405
  if (res.status === 'pending') continue;
375
406
  if (res.status === 'slow_down') { interval += 5; continue; }
376
407
  if (res.status !== 'authorized') {
377
- 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') });
378
409
  return;
379
410
  }
380
411
  const fin = await finalizeDeviceSession({
@@ -382,7 +413,12 @@ function makeSignupTools(deps = {}) {
382
413
  isNewUser: res.isNewUser,
383
414
  authTokenEndpoint,
384
415
  completeRegistrationEndpoint: getCompleteRegistrationEndpoint(),
385
- 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,
386
422
  claimCode: res.isNewUser ? state.claimCode : null,
387
423
  });
388
424
  if (!fin.ok) {
@@ -398,75 +434,67 @@ function makeSignupTools(deps = {}) {
398
434
  }
399
435
  state.sessionToken = res.accessToken;
400
436
  setAccount(res.isNewUser
401
- ? { state: 'created', via: 'google', email, claimed: fin.claimed === true }
402
- : { state: 'existing', via: 'google', email });
437
+ ? { state: 'created', email, claimed: fin.claimed === true }
438
+ : { state: 'existing', email });
403
439
  return;
404
440
  }
405
- setAccount({ state: 'failed', reason: 'google-expired' });
441
+ setAccount({ state: 'failed', reason: 'device-expired' });
406
442
  }
407
443
 
408
- async function onGoogle() {
444
+ async function startDevice() {
409
445
  const authorizeEndpoint = getDeviceAuthorizeEndpoint();
410
446
  const tokenEndpoint = getDeviceTokenEndpoint();
411
447
  const authTokenEndpoint = getAuthTokenEndpoint();
412
448
  if (!authorizeEndpoint || !tokenEndpoint || !authTokenEndpoint) return { ok: false, reason: 'no-endpoint' };
413
449
  const authz = await startDeviceLogin({ authorizeEndpoint });
414
450
  if (!authz.ok) return { ok: false, reason: authz.reason || 'authorize-failed' };
415
- finishGoogle(authz, tokenEndpoint, authTokenEndpoint).catch(() => setAccount({ state: 'failed', reason: 'google-failed' }));
416
- 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 };
417
458
  }
418
459
 
419
- function openWindow(identity) {
420
- setAccount({ state: 'waiting' });
421
- let announce;
422
- const announced = new Promise((resolve) => { announce = resolve; });
423
- runLoopbackAuth(
424
- {
425
- mode: 'register',
426
- deps: resolved,
427
- lang: lang(),
428
- registerFields: {
429
- email: identity.email,
430
- name: identity.firstName,
431
- lastName: identity.lastName,
432
- preferredLanguage: lang(),
433
- claimCode: () => state.claimCode,
434
- },
435
- },
436
- {
437
- timeoutMs: SIGNUP_WINDOW_TIMEOUT_MS,
438
- openBrowser: (url) => openSoon(url, () => { state.windowOpenFailed = true; }),
439
- onGoogle,
440
- onUrl: (url) => { state.windowUrl = url; announce(url); },
441
- },
442
- ).then((outcome) => {
443
- if (outcome && outcome.ok) state.authAttempts = outcome.attempts;
444
- if (outcome && outcome.viaSocial) return;
445
- if (!outcome || !outcome.ok) {
446
- setAccount({ state: 'failed', reason: (outcome && outcome.reason) || 'window-failed' });
447
- return;
448
- }
449
- setAccount(outcome.accountExists
450
- ? { state: 'existing', via: 'email', email: outcome.email }
451
- : { state: 'created', via: 'email', email: outcome.email, claimed: outcome.claimed === true });
452
- }, () => setAccount({ state: 'failed', reason: 'window-failed' }));
453
- 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 };
454
471
  }
455
472
 
456
- // 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).
457
482
  async function signupStart(args = {}, ctx = {}) {
458
483
  // The LinkedIn question is a tool block, so a welcome still pending is read before it and the AI words nothing.
459
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.` };
460
486
  const c = copy();
487
+ const linkedin = openQuestion(c.linkedinAsk);
461
488
  return {
462
489
  ok: true,
463
490
  step: 'linkedin-ask',
464
- say: [...takeNotices(), c.linkedinAsk].join('\n\n'),
491
+ ...linkedin,
465
492
  linkedinOpening: c.linkedinOpening,
466
- 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.`,
467
494
  };
468
495
  }
469
- if (!nonEmpty(args.answer)) {
496
+ const answering = nonEmpty(args.answer);
497
+ if (!answering) {
470
498
  fixSignupLanguage(args.language);
471
499
  funnel.begin();
472
500
  funnel.viewed('start');
@@ -480,14 +508,31 @@ function makeSignupTools(deps = {}) {
480
508
  state.toldKept = new Set();
481
509
  state.toldExisting = false;
482
510
  state.cvPath = null;
483
- takeNotices();
511
+ state.cvText = null;
512
+ state.linkedinUrl = null;
513
+ state.welcomed = false;
514
+ state.toldWeb = false;
515
+ resetQuestions();
484
516
  }
485
- 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;
486
524
  const c = copy();
487
- 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.';
488
- // 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
489
534
  // rides on the next question block, because models skip a relay that comes before another tool call.
490
- if (!nonEmpty(args.answer) && state.cvPath) {
535
+ if (!answering && state.cvPath) {
491
536
  funnel.completed('start');
492
537
  funnel.viewed('import_sources', { sources_offered: DRAFT_SOURCES });
493
538
  queueNotice(c.welcome(nonEmpty(args.firstName)));
@@ -496,45 +541,58 @@ function makeSignupTools(deps = {}) {
496
541
  step: 'cv-given',
497
542
  cvPath: state.cvPath,
498
543
  language: lang(),
499
- 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()}`,
500
545
  };
501
546
  }
502
- const asked = await askAfterTexts(ctx, {
503
- tool: 'signup_start',
504
- texts: [c.welcome(nonEmpty(args.firstName))],
505
- question: c.welcomeQuestion,
506
- options: [c.cvAttach, c.cvSearch],
507
- answer: args.answer,
508
- });
509
- 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
+ }
510
554
  funnel.completed('start');
511
555
  funnel.viewed('import_sources', { sources_offered: DRAFT_SOURCES });
512
- // A CV path the talent gave wins over both answers: searching could read another candidate.
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.
513
558
  const cvPath = state.cvPath;
514
559
  if (cvPath) {
515
560
  return {
516
561
  ok: true,
517
562
  step: 'cv-given',
518
563
  cvPath,
519
- 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()}`,
520
565
  };
521
566
  }
522
- if (asked.answer === c.cvSearch) {
567
+ if (args.search === true) {
523
568
  return {
524
569
  ok: true,
525
570
  step: 'cv-search',
526
571
  cvNotFound: c.cvNotFound,
527
- 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.`,
528
581
  };
529
582
  }
530
583
  return {
531
584
  ok: true,
532
585
  step: 'cv-attach',
533
586
  relayVerbatim: c.cvAttachAsk,
534
- 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()}`,
535
588
  };
536
589
  }
537
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
+
538
596
  // Step 2: which email the account is created with, the one they read most; a company domain gets the work-email hint.
539
597
  async function signupEmail(args = {}, ctx = {}) {
540
598
  const c = copy();
@@ -548,24 +606,29 @@ function makeSignupTools(deps = {}) {
548
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.` };
549
607
  }
550
608
  const question = looksLikeWorkEmail(email) ? `${c.emailKnown(email)} ${c.emailWorkHint}` : c.emailKnown(email);
551
- 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 });
552
614
  if (asked.reply) return asked.reply;
553
615
  if (asked.answer === c.emailYes) {
554
616
  state.email = email;
555
- 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}.` };
556
619
  }
557
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.` };
558
621
  }
559
622
 
560
623
  // A session this machine kept for the same email means the account exists, so the window will ask to sign in.
561
- function knownAccount() {
624
+ function knownAccount(email = state.email) {
562
625
  const stored = resolved.loadAuthSession();
563
- const email = stored && typeof stored.email === 'string' ? stored.email.toLowerCase() : null;
564
- 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();
565
628
  }
566
629
 
567
- // Step 3: the draft, the data notice and how the window works; confirming it is what opens the window.
568
- 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) {
569
632
  const c = copy();
570
633
  const pricing = pricingFromRates(args);
571
634
  if (!pricing.ok) return pricing;
@@ -581,19 +644,38 @@ function makeSignupTools(deps = {}) {
581
644
  if (args.ratesFromTalent === true) draft.ratesFromTalent = true;
582
645
  const sources = Array.isArray(args.sources) ? DRAFT_SOURCES.filter((s) => args.sources.includes(s)) : [];
583
646
  // A confirmation of the printed block stands even if the model drops sources on the answer call.
584
- if (!sources.length && !optionFor(args.answer, [c.draftOk, c.draftChange])) {
647
+ if (!sources.length && !optionFor(answer, [c.draftOk, c.draftChange])) {
585
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." };
586
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;
587
664
  if (!state.firstDraft) state.firstDraft = draft;
588
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())] };
589
668
  const asked = await askAfterTexts(ctx, {
590
669
  tool: 'signup_draft',
591
- texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice(sources), knownAccount() ? c.windowNoticeSignIn : c.windowNotice],
670
+ texts: draftTexts(draft, sources, state.email),
592
671
  question: c.draftQuestion,
593
672
  options: [c.draftOk, c.draftChange],
594
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,
595
677
  });
596
- 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.` };
597
679
  if (asked.answer === c.draftChange) {
598
680
  state.draftConfirmed = false;
599
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.' };
@@ -604,7 +686,7 @@ function makeSignupTools(deps = {}) {
604
686
  return {
605
687
  ok: true,
606
688
  next: 'signup_create_account',
607
- 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.',
608
690
  };
609
691
  }
610
692
 
@@ -623,8 +705,14 @@ function makeSignupTools(deps = {}) {
623
705
  }
624
706
  }
625
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
+
626
714
  // Step 4: hub starts the import into an UNREGISTERED profile and the window opens to create the account (or sign in).
627
- async function signupCreateAccount(args = {}) {
715
+ async function signupCreateAccount(args = {}, ctx = {}) {
628
716
  const c = copy();
629
717
  if (!state.draftConfirmed) {
630
718
  return { ok: false, reason: 'draft-not-confirmed', message: 'Show the draft with signup_draft and get the talent\'s confirmation first.' };
@@ -639,7 +727,7 @@ function makeSignupTools(deps = {}) {
639
727
  };
640
728
  }
641
729
 
642
- 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)) };
643
731
  if (sources.linkedinUrl && !isLinkedinProfileUrl(sources.linkedinUrl)) return { ok: false, reason: 'invalid-linkedin-url', message: INVALID_LINKEDIN_MESSAGE };
644
732
  let cv = null;
645
733
  let cvNote = null;
@@ -653,6 +741,16 @@ function makeSignupTools(deps = {}) {
653
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.' };
654
742
  }
655
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
+
656
754
  const identity = { email: state.email, firstName: nonEmpty(args.firstName), lastName: nonEmpty(args.lastName) };
657
755
  const language = IMPORT_LANGUAGES.has(lang()) ? lang() : 'en';
658
756
  const res = await requestMcpSignup(
@@ -672,16 +770,22 @@ function makeSignupTools(deps = {}) {
672
770
  funnel.completed('import_sources', { sources_attempted: attempted, sources_connected: attempted, sources_connected_count: attempted.length });
673
771
  }
674
772
  if (!reused) {
675
- funnel.viewed('account_creation', { auth_methods_offered: require('./mcp-auth-form').WINDOW_METHODS.register });
676
- 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
+ }
677
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'));
678
782
  return {
679
783
  ok: true,
680
784
  status: 'window-open',
681
785
  ...(cvNote ? { cvNote } : {}),
786
+ say: opening.say,
682
787
  next: 'signup_status',
683
- 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. ')
684
- + '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.`,
685
789
  };
686
790
  }
687
791
 
@@ -718,7 +822,7 @@ function makeSignupTools(deps = {}) {
718
822
 
719
823
  function nextSteps(account) {
720
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.';
721
- 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). After a yes, ai_usage_upload (waitSeconds 30) shows the talent what would be sent and asks; nothing is uploaded without their pick.';
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.';
722
826
  if (account.state === 'existing') {
723
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}`;
724
828
  }
@@ -742,8 +846,8 @@ function makeSignupTools(deps = {}) {
742
846
  noticeFailed(['linkedin']);
743
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.');
744
848
  }
745
- if (account.state === 'waiting') lines.push('The talent is still in the sign-up window. Call signup_status again.');
746
- 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.');
747
851
  if (account.state === 'created' || account.state === 'existing') lines.push(nextSteps(account));
748
852
  return lines.join(' ');
749
853
  }
@@ -765,7 +869,11 @@ function makeSignupTools(deps = {}) {
765
869
  const account = { ...state.account };
766
870
  const out = { ok: true, account, import: imp };
767
871
  if (account.state === 'waiting' && (args.windowLink === true || state.windowOpenFailed) && state.windowUrl) {
768
- 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'));
769
877
  }
770
878
  if (account.state === 'created' || account.state === 'existing') {
771
879
  let completed = null;
@@ -781,7 +889,7 @@ function makeSignupTools(deps = {}) {
781
889
  if (nonEmpty(args.cvPath)) {
782
890
  const file = path.resolve(expandHome(nonEmpty(args.cvPath)));
783
891
  const c = copy().consent;
784
- 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 });
785
893
  if (asked.reply) return asked.reply;
786
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.' };
787
895
  return importProfile({ ...args, cvPath: file });
@@ -865,7 +973,8 @@ function makeSignupTools(deps = {}) {
865
973
  state.toldKept = kept;
866
974
  const given = state.sources ? [state.sources.linkedinUrl && 'linkedin', state.sources.cvPath && 'cv'].filter(Boolean) : [];
867
975
  if (given.length) lines.push(c.existingLists(given));
868
- 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 };
869
978
  }
870
979
 
871
980
  // The sections of save_profile_details an existing profile can take (only fields it leaves empty), and what it keeps instead.
@@ -929,8 +1038,9 @@ function makeSignupTools(deps = {}) {
929
1038
  const c = copy();
930
1039
  const session = resolved.loadAuthSession();
931
1040
  if (resolved.sessionStatus(session) !== 'active') return { ok: false, reason: 'no-session', message: 'The talent is not signed in yet: wait for signup_status.' };
932
- const question = await existingDiff();
933
- 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 });
934
1044
  if (asked.reply) return asked.reply;
935
1045
  if (asked.answer === c.existingNo) {
936
1046
  state.keepProfile = true;
@@ -1068,10 +1178,10 @@ function makeSignupTools(deps = {}) {
1068
1178
 
1069
1179
  async function openWeb(args = {}) {
1070
1180
  const session = resolved.loadAuthSession();
1071
- 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);
1072
1182
  let url = null;
1073
- if (cookie || state.sessionToken) {
1074
- const minted = await requestOneTimeToken({ cookie, sessionToken: state.sessionToken }, { endpoint: getOneTimeTokenEndpoint() });
1183
+ if (sessionToken) {
1184
+ const minted = await requestOneTimeToken({ sessionToken }, { endpoint: getOneTimeTokenEndpoint() });
1075
1185
  if (minted.ok) url = getOneTimeLoginUrl(minted.token);
1076
1186
  }
1077
1187
  const loggedIn = !!url;
@@ -1084,8 +1194,9 @@ function makeSignupTools(deps = {}) {
1084
1194
  // A source that failed after the last signup_status gets its fixed line here, as there is no question left.
1085
1195
  const failed = DRAFT_SOURCES.filter((s) => imp.sources && imp.sources[s] && imp.sources[s].state === 'failed');
1086
1196
  noticeFailed(failed);
1087
- // The flow ends here, so a fixed line no question carried is printed now.
1088
- 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;
1089
1200
  const relay = unseen.length ? { relayVerbatim: unseen.join('\n\n') } : {};
1090
1201
  const importLine = failed.length
1091
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.`
@@ -1122,8 +1233,8 @@ function makeSignupTools(deps = {}) {
1122
1233
  {
1123
1234
  name: 'signup_start',
1124
1235
  title: 'Start your Shakers sign-up',
1125
- description: 'Starts your sign-up here in the chat and asks whether you send your CV or want it looked for on this computer.',
1126
- 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.",
1127
1238
  inputSchema: WELCOME_SCHEMA,
1128
1239
  handler: signupStart,
1129
1240
  },
@@ -1131,7 +1242,7 @@ function makeSignupTools(deps = {}) {
1131
1242
  name: 'signup_email',
1132
1243
  title: 'Choose your account email',
1133
1244
  description: 'Asks which email your Shakers account will use.',
1134
- 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.',
1135
1246
  inputSchema: EMAIL_SCHEMA,
1136
1247
  handler: signupEmail,
1137
1248
  },
@@ -1139,15 +1250,15 @@ function makeSignupTools(deps = {}) {
1139
1250
  name: 'signup_draft',
1140
1251
  title: 'Review your profile draft',
1141
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.',
1142
- 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.',
1143
1254
  inputSchema: DRAFT_SCHEMA,
1144
1255
  handler: signupDraft,
1145
1256
  },
1146
1257
  {
1147
1258
  name: 'signup_create_account',
1148
1259
  title: 'Create your account',
1149
- 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.',
1150
- 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.",
1151
1262
  inputSchema: CREATE_SCHEMA,
1152
1263
  handler: signupCreateAccount,
1153
1264
  },
@@ -1172,7 +1283,7 @@ function makeSignupTools(deps = {}) {
1172
1283
  name: 'import_profile',
1173
1284
  title: 'Import into your profile',
1174
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.',
1175
- 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.",
1176
1287
  inputSchema: IMPORT_SCHEMA,
1177
1288
  handler: importProfileAsked,
1178
1289
  },
@@ -1203,4 +1314,4 @@ function makeSignupTools(deps = {}) {
1203
1314
  ];
1204
1315
  }
1205
1316
 
1206
- 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 };