shakers-cli-beta 0.11.0 → 0.12.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.
@@ -8,7 +8,7 @@ const { signupCopy, legalCopy, renderDraft, amount } = require('./signup-copy');
8
8
  const { fixSignupLanguage, signupLanguage } = require('./signup-language');
9
9
  const { readLocalCv } = require('./cv-file');
10
10
  const { emailFromHubToken } = require('./auth-session-store');
11
- const { askAfterTexts, queueNotice, takeNotices } = require('./mcp-choice');
11
+ const { askAfterTexts, optionFor, queueNotice, takeNotices } = require('./mcp-choice');
12
12
 
13
13
  // MCP sign-up (ticket 39540): hub imports into an UNREGISTERED profile that the local window claims with a code kept only in memory.
14
14
 
@@ -36,6 +36,13 @@ const SECTION_HELP = {
36
36
  'bad-opinion': 'workSituation.opinion is required unless situation is FREELANCE: pick your best match from its enum.',
37
37
  'bad-motivation': 'workSituation.changeMotivators is required when situation is FREELANCE or opinion is WAS_FREELANCE_BEFORE or OPEN_TO_FREELANCE: pick your best match from its enum.',
38
38
  };
39
+ // What the closing words may say about the profile import, by the state read at that moment.
40
+ const IMPORT_CLOSING = {
41
+ done: 'The profile import finished: if you mention it, say it is done, never that it is pending.',
42
+ running: 'The profile import is still running: if you mention it, say it finishes on its own in a few minutes.',
43
+ failed: 'Say nothing more about the profile import than the fixed lines already shown.',
44
+ none: 'Say nothing about the state of the profile import.',
45
+ };
39
46
  const VERBATIM = 'Print relayVerbatim word for word now, as its own block, before any other tool call, with nothing of your own added to it.';
40
47
 
41
48
  function isPersonalEmail(email) {
@@ -134,7 +141,7 @@ const WELCOME_SCHEMA = {
134
141
  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." },
135
142
  firstName: { type: 'string', description: 'Only if you already know it: the welcome greets them by name.' },
136
143
  answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
137
- cvPath: { type: 'string', description: 'When the talent\'s answer (either option) already gives their CV as a path on this machine: that exact file is read, with no search and no request for the file.' },
144
+ 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.' },
138
145
  },
139
146
  required: ['language'],
140
147
  };
@@ -148,6 +155,9 @@ const EMAIL_SCHEMA = {
148
155
  },
149
156
  };
150
157
 
158
+ // In the order the data notice names them.
159
+ const DRAFT_SOURCES = ['linkedin', 'cv'];
160
+
151
161
  const DRAFT_SCHEMA = {
152
162
  type: 'object',
153
163
  description: 'Your draft of the profile, from the CV, LinkedIn and what you know. Every field is shown as you write it, in the talent\'s language.',
@@ -163,9 +173,10 @@ const DRAFT_SCHEMA = {
163
173
  hourlyRate: { type: 'number', description: `Euros per hour (part-time projects), at most ${MAX_HOURLY_RATE}: the talent's figure, else your estimate from role, seniority, stack and location.` },
164
174
  annualRate: { type: 'number', description: `Euros per year (full-time projects), at least ${MIN_ANNUAL_RATE}: the talent's figure, else your estimate.` },
165
175
  ratesFromTalent: { type: 'boolean', description: 'true only when the talent gave you both rates; omit it when you estimated them (they are shown as your estimate).' },
176
+ sources: { type: 'array', items: { type: 'string', enum: DRAFT_SOURCES }, description: "What the draft comes from: 'cv' when you read their CV, 'linkedin' only when you have their LinkedIn URL. The data notice names exactly these." },
166
177
  answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
167
178
  },
168
- required: ['name', 'role', 'hourlyRate', 'annualRate'],
179
+ required: ['name', 'role', 'hourlyRate', 'annualRate', 'sources'],
169
180
  };
170
181
 
171
182
  const CREATE_SCHEMA = {
@@ -275,6 +286,8 @@ function makeSignupTools(deps = {}) {
275
286
  draft: null,
276
287
  draftConfirmed: false,
277
288
  keepProfile: false,
289
+ // Fields the update question already told the talent they keep.
290
+ toldKept: new Set(),
278
291
  sources: null,
279
292
  account: { state: 'not-started' },
280
293
  reported: { account: null, import: null },
@@ -393,11 +406,26 @@ function makeSignupTools(deps = {}) {
393
406
  state.draftConfirmed = false;
394
407
  state.email = null;
395
408
  state.failedSources.clear();
409
+ state.toldKept = new Set();
396
410
  state.cvPath = null;
397
411
  takeNotices();
398
412
  }
399
413
  if (nonEmpty(args.cvPath)) state.cvPath = nonEmpty(args.cvPath);
400
414
  const c = copy();
415
+ const linkedin = `LinkedIn: use the URL in the CV (read_cv returns links.linkedin). Only if it has none, ask \`linkedinAsk\` word for word; only if they say they do not know where to find it, say \`linkedinOpening\` and call open_linkedin_profile. Then call signup_email.`;
416
+ // A CV path in the first message already answers the CV question: welcome only, then that CV.
417
+ if (!nonEmpty(args.answer) && state.cvPath) {
418
+ return {
419
+ ok: true,
420
+ step: 'cv-given',
421
+ cvPath: state.cvPath,
422
+ relayVerbatim: c.welcome(nonEmpty(args.firstName)),
423
+ linkedinAsk: c.linkedinAsk,
424
+ linkedinOpening: c.linkedinOpening,
425
+ language: lang(),
426
+ message: `${VERBATIM} Then read_cv cvPath with talentConsent:true, asking nothing about the CV. ${linkedin}`,
427
+ };
428
+ }
401
429
  const asked = await askAfterTexts(ctx, {
402
430
  tool: 'signup_start',
403
431
  texts: [c.welcome(nonEmpty(args.firstName))],
@@ -406,7 +434,6 @@ function makeSignupTools(deps = {}) {
406
434
  answer: args.answer,
407
435
  });
408
436
  if (asked.reply) return { ...asked.reply, language: lang() };
409
- const linkedin = `LinkedIn: use the URL in the CV (read_cv returns links.linkedin). Only if it has none, ask \`linkedinAsk\` word for word; only if they say they do not know where to find it, say \`linkedinOpening\` and call open_linkedin_profile. Then call signup_email.`;
410
437
  // A CV path the talent gave wins over both answers: searching could read another candidate.
411
438
  const cvPath = state.cvPath;
412
439
  if (cvPath) {
@@ -461,6 +488,13 @@ function makeSignupTools(deps = {}) {
461
488
  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.` };
462
489
  }
463
490
 
491
+ // A session this machine kept for the same email means the account exists, so the window will ask to sign in.
492
+ function knownAccount() {
493
+ const stored = resolved.loadAuthSession();
494
+ const email = stored && typeof stored.email === 'string' ? stored.email.toLowerCase() : null;
495
+ return !!email && !!state.email && email === state.email.toLowerCase();
496
+ }
497
+
464
498
  // Step 3: the draft, the data notice and how the window works; confirming it is what opens the window.
465
499
  async function signupDraft(args = {}, ctx = {}) {
466
500
  const c = copy();
@@ -475,9 +509,14 @@ function makeSignupTools(deps = {}) {
475
509
  return { ok: false, reason: 'draft-rates-required', message: 'The draft needs both rates: if the talent gave them, send them with ratesFromTalent:true; otherwise estimate hourlyRate (€/h) and annualRate (€/year) from role, seniority, stack and location, and send them without it. Then call signup_draft again.' };
476
510
  }
477
511
  if (args.ratesFromTalent === true) draft.ratesFromTalent = true;
512
+ const sources = Array.isArray(args.sources) ? DRAFT_SOURCES.filter((s) => args.sources.includes(s)) : [];
513
+ // A confirmation of the printed block stands even if the model drops sources on the answer call.
514
+ if (!sources.length && !optionFor(args.answer, [c.draftOk, c.draftChange])) {
515
+ 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." };
516
+ }
478
517
  const asked = await askAfterTexts(ctx, {
479
518
  tool: 'signup_draft',
480
- texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice, c.windowNotice],
519
+ texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice(sources), knownAccount() ? c.windowNoticeSignIn : c.windowNotice],
481
520
  question: c.draftQuestion,
482
521
  options: [c.draftOk, c.draftChange],
483
522
  answer: args.answer,
@@ -684,17 +723,31 @@ function makeSignupTools(deps = {}) {
684
723
  };
685
724
  }
686
725
 
687
- // Fill-empty only: what of the confirmed draft would land in an existing profile, plus the import that only adds.
726
+ // Fill-empty only: per draft field, what it fills or the value the profile keeps, plus the import that only adds.
688
727
  async function existingDiff() {
689
728
  const c = copy();
690
729
  const draft = state.draft || {};
691
730
  const current = await currentProfile();
692
731
  const lines = [];
732
+ const kept = new Set();
693
733
  const has = (v) => v !== undefined && v !== null && v !== '';
694
- if (current.pricing && current.pricing.hourly === null && has(draft.hourlyRate)) lines.push(c.existingRate(c.existingEmpty, money(draft.hourlyRate, c.perHour)));
695
- if (current.pricing && current.pricing.annual === null && has(draft.annualRate)) lines.push(c.existingAnnual(c.existingEmpty, money(draft.annualRate, c.perYear)));
696
- if (current.availability && !has(current.availability.monthlyHours) && has(draft.monthlyHours)) lines.push(c.existingHours(c.existingEmpty, c.hours(draft.monthlyHours)));
697
- lines.push(c.existingLists);
734
+ // An unreadable section (undefined) counts as set, as fillEmpty never writes it.
735
+ const field = (key, now, filled, label, shown) => {
736
+ if (!has(draft[key])) return;
737
+ if (now === null) { lines.push(filled); return; }
738
+ kept.add(key);
739
+ lines.push(c.existingStays(label, now === undefined ? null : shown(now), shown(draft[key])));
740
+ };
741
+ const pricing = current.pricing || { hourly: undefined, annual: undefined };
742
+ const hours = current.availability ? (has(current.availability.monthlyHours) ? current.availability.monthlyHours : null) : undefined;
743
+ field('hourlyRate', pricing.hourly, c.existingRate(c.existingEmpty, money(draft.hourlyRate, c.perHour)), c.hourlyRateLabel, (v) => money(v, c.perHour));
744
+ field('annualRate', pricing.annual, c.existingAnnual(c.existingEmpty, money(draft.annualRate, c.perYear)), c.annualRateLabel, (v) => money(v, c.perYear));
745
+ field('monthlyHours', hours, c.existingHours(c.existingEmpty, c.hours(draft.monthlyHours)), c.keptLabels.availability, (v) => c.hours(v));
746
+ if (has(draft.languages) && current.languageCount !== 0) { kept.add('languages'); lines.push(c.existingStays(c.keptLabels.languages)); }
747
+ if (kept.size) lines.push(c.existingStaysNote);
748
+ state.toldKept = kept;
749
+ const given = state.sources ? [state.sources.linkedinUrl && 'linkedin', state.sources.cvPath && 'cv'].filter(Boolean) : [];
750
+ if (given.length) lines.push(c.existingLists(given));
698
751
  return `${c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
699
752
  }
700
753
 
@@ -731,25 +784,25 @@ function makeSignupTools(deps = {}) {
731
784
  return { details: out, kept };
732
785
  }
733
786
 
734
- // What the existing profile kept, for the model per field and for the talent as one fixed line.
787
+ // What the existing profile kept, for the model per field and for the talent as one fixed line without what the update question already told.
735
788
  function keptReport(kept) {
736
789
  const c = copy();
737
790
  const fields = [];
738
791
  const items = [];
739
- const value = (v, shown) => (v === null || v === undefined ? null : shown(v));
792
+ const told = (key) => state.toldKept.has(key);
740
793
  if (kept.workSituation) { fields.push('workSituation: not changed (the profile keeps what it had)'); items.push(c.keptLabels.workSituation); }
741
794
  for (const [key, label, unit] of [['hourlyRate', c.hourlyRateLabel, c.perHour], ['annualRate', c.annualRateLabel, c.perYear]]) {
742
795
  const k = kept.pricing && kept.pricing[key];
743
796
  if (!k) continue;
744
797
  fields.push(k.kept === null ? `${key}: not changed (the profile keeps what it had, not ${k.requested})` : `${key}: kept existing value ${k.kept} (not changed, not ${k.requested})`);
745
- items.push(k.kept === null ? label : `${label}: ${money(k.kept, unit)}`);
798
+ if (!told(key)) items.push(k.kept === null ? label : `${label}: ${money(k.kept, unit)}`);
746
799
  }
747
800
  for (const [key, k] of Object.entries(kept.availability || {})) {
748
801
  fields.push(k.kept === null ? `${key}: not changed (the profile keeps what it had)` : `${key}: kept existing value ${Array.isArray(k.kept) ? k.kept.join('/') : k.kept} (not changed, not ${Array.isArray(k.requested) ? k.requested.join('/') : k.requested})`);
749
802
  }
750
803
  const hours = kept.availability && kept.availability.monthlyHours;
751
- if (hours) items.push(hours.kept === null ? c.keptLabels.availability : `${c.keptLabels.availability}: ${c.hours(hours.kept)}`);
752
- if (kept.languages) { fields.push('languages: not changed (the profile already had languages)'); items.push(c.keptLabels.languages); }
804
+ if (hours && !told('monthlyHours')) items.push(hours.kept === null ? c.keptLabels.availability : `${c.keptLabels.availability}: ${c.hours(hours.kept)}`);
805
+ if (kept.languages) { fields.push('languages: not changed (the profile already had languages)'); if (!told('languages')) items.push(c.keptLabels.languages); }
753
806
  if (kept.phone) { fields.push('phone: not changed (the profile keeps what it had)'); items.push(c.keptLabels.phone); }
754
807
  return { fields, line: items.length ? c.existingUnchanged(items) : null };
755
808
  }
@@ -849,14 +902,18 @@ function makeSignupTools(deps = {}) {
849
902
  const unseen = takeNotices();
850
903
  const relay = unseen.length ? { relayVerbatim: unseen.join('\n\n') } : {};
851
904
  const relayFirst = unseen.length ? `${VERBATIM} ` : '';
905
+ // Read right before the closing words, so they never state an import state that is already stale.
906
+ const imp = await readImport();
907
+ const importLine = IMPORT_CLOSING[imp.state] || IMPORT_CLOSING.none;
852
908
  if (opened) {
853
909
  return {
854
910
  ok: true,
855
911
  opened: true,
856
912
  loggedIn,
913
+ import: { state: imp.state },
857
914
  ...relay,
858
915
  message: relayFirst + (loggedIn ? 'Shakers opened in the talent\'s browser, signed in.' : 'Shakers opened in the talent\'s browser at the sign-in page; they sign in there.')
859
- + ' If they say it did not open, call open_web with open:false and give them its link.',
916
+ + ` If they say it did not open, call open_web with open:false and give them its link. ${importLine}`,
860
917
  };
861
918
  }
862
919
  return {
@@ -864,8 +921,9 @@ function makeSignupTools(deps = {}) {
864
921
  opened: false,
865
922
  loggedIn,
866
923
  link: url,
924
+ import: { state: imp.state },
867
925
  ...relay,
868
- message: relayFirst + 'Give the talent `link` to click, with no comment on the address.' + (loggedIn ? ' It works once, for 2 minutes.' : ''),
926
+ message: `${relayFirst}Give the talent \`link\` to click, with no comment on the address.${loggedIn ? ' It works once, for 2 minutes.' : ''} ${importLine}`,
869
927
  };
870
928
  }
871
929
 
@@ -912,7 +970,7 @@ function makeSignupTools(deps = {}) {
912
970
  },
913
971
  {
914
972
  name: 'update_existing_profile',
915
- description: 'When signup_status says the talent already had an account: shows what the confirmed draft would fill in the empty fields of their profile and asks whether to update it; on yes it imports their LinkedIn/CV filling only what is empty.',
973
+ description: 'When signup_status says the talent already had an account: shows what the confirmed draft would fill in the empty fields of their profile and the value each field they already had keeps, and asks whether to update it; on yes it imports their LinkedIn/CV filling only what is empty.',
916
974
  inputSchema: UPDATE_EXISTING_SCHEMA,
917
975
  handler: updateExistingProfile,
918
976
  },
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // AI-profile client (hub `works/me/ai-profile`, talent Bearer). The "My work
3
+ // AI-profile client. The "My work
4
4
  // with AI" read model: setup/usage tier+level, 3x3 cell, vision, howIWork.
5
5
 
6
6
  const { postJsonWithTimeout } = require('./backend-request');
@@ -87,14 +87,12 @@ async function getMapped(endpoint, hubAccessToken, timeoutMs, map) {
87
87
  return { ok: true, ...map(data) };
88
88
  }
89
89
 
90
- // `works/me/profile` -> headline (basic profile text).
91
90
  async function fetchMeProfile(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
92
91
  const getEndpoint = deps.getMeProfileEndpoint || require('./config').getMeProfileEndpoint;
93
92
  const req = deps.getMapped || getMapped;
94
93
  return req(getEndpoint(), hubAccessToken, timeoutMs, (d) => ({ headline: str(d && d.headline) }));
95
94
  }
96
95
 
97
- // `works/talents/me/profile` -> completion %, freelance type, onboarding status.
98
96
  async function fetchTalentMeProfile(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
99
97
  const getEndpoint = deps.getTalentMeProfileEndpoint || require('./config').getTalentMeProfileEndpoint;
100
98
  const req = deps.getMapped || getMapped;
@@ -105,7 +103,6 @@ async function fetchTalentMeProfile(deps = {}, { hubAccessToken, timeoutMs = DEF
105
103
  }));
106
104
  }
107
105
 
108
- // `works/talents/me/work-details/languages` -> count + codes.
109
106
  async function fetchLanguages(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
110
107
  const getEndpoint = deps.getLanguagesEndpoint || require('./config').getLanguagesEndpoint;
111
108
  const req = deps.getMapped || getMapped;
@@ -1,7 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // Pricing-rate client (hub `works/talents/me/work-details/pricing-rate`, talent
4
- // Bearer). READ-ONLY here: setting the rate is a financial PUT, left to register.
3
+ // Pricing-rate client. READ-ONLY here: setting the rate is a financial PUT, left to register.
5
4
 
6
5
  const { postJsonWithTimeout } = require('./backend-request');
7
6
  const { reasonForError } = require('./onboarding-client');
@@ -43,7 +43,6 @@ function normalizeRole(r) {
43
43
  };
44
44
  }
45
45
 
46
- // GET works/certifications/me/available-roles -> { roles:[{clusterId,name,category,proposalKind}] }.
47
46
  async function requestAvailableRoles({ hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
48
47
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
49
48
  if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
@@ -60,7 +59,6 @@ async function requestAvailableRoles({ hubAccessToken } = {}, { endpoint, timeou
60
59
  return { ok: true, roles: rows.map(normalizeRole).filter(Boolean) };
61
60
  }
62
61
 
63
- // GET works/talents/me/work-details/assigned-clusters -> the talent's GROWTH/RECOMMENDED roles.
64
62
  async function requestAssignedClusters({ hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
65
63
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
66
64
  if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
@@ -77,7 +75,6 @@ async function requestAssignedClusters({ hubAccessToken } = {}, { endpoint, time
77
75
  return { ok: true, clusters: rows.map(normalizeRole).filter(Boolean) };
78
76
  }
79
77
 
80
- // POST works/talents/me/work-details/assigned-clusters { clusterId } -> adds a GROWTH role.
81
78
  async function requestAddGrowthRole({ clusterId, hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
82
79
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
83
80
  if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
@@ -92,7 +89,6 @@ async function requestAddGrowthRole({ clusterId, hubAccessToken } = {}, { endpoi
92
89
  return { ok: true, clusterId };
93
90
  }
94
91
 
95
- // PATCH works/certifications/me/main-role { clusterId } -> sets the preferred main role.
96
92
  async function requestSetMainRole({ clusterId, hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
97
93
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
98
94
  if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // Saved-positions client (hub `works/saved-positions`, talent Bearer). Both
3
+ // Saved-positions client. Both
4
4
  // operations are idempotent server-side, so no read-before-write is needed.
5
5
 
6
6
  const { postJsonWithTimeout } = require('./backend-request');
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // Position detail client (hub `works/positions/:id/detail`, talent Bearer).
3
+ // Position detail client.
4
4
  // A "project" here is the Position + its parent project's fields.
5
5
 
6
6
  const { postJsonWithTimeout } = require('./backend-request');
@@ -7,28 +7,28 @@ const SIGNUP_LANGUAGES = ['es', 'en', 'it', 'pt'];
7
7
  // Legal texts. PENDING legal/GDPR validation: not final copy. The CLI onboarding reads the es/en ones too.
8
8
  const LEGAL = {
9
9
  es: {
10
- signupNotice: 'Antes de crear tu cuenta: guardamos los datos que me des (tu LinkedIn, tu CV y lo que sé de ti) y podemos contactarte. Te completo el perfil automáticamente y cualquier campo lo puedes cambiar después en la web. La entrevista de onboarding la puedes hacer luego aquí mismo en el chat o en la web.',
10
+ signupNotice: (sources) => `Antes de crear tu cuenta: guardamos los datos que me des (${listOf('es', [...named(sources, COPY.es.importSources), 'lo que sé de ti'])}) y podemos contactarte. Te completo el perfil automáticamente y cualquier campo lo puedes cambiar después en la web. La entrevista de onboarding la puedes hacer luego aquí mismo en el chat o en la web.`,
11
11
  usageInfoAccessed: 'Se accede a: la estructura de tus herramientas de IA y a extractos anonimizados de tu actividad en este equipo. Tu código y tus prompts en crudo NUNCA salen de tu máquina.',
12
12
  usageGoalDuration: 'Objetivo: medir cómo trabajas con IA para tu perfil. Duración: ~1-2 minutos.',
13
13
  interviewInfoAccessed: 'Se accede a: tus respuestas en esta entrevista y tu perfil importado. La conduce el servidor de Shakers.',
14
14
  interviewGoalDuration: 'Objetivo: conocer tu rol, tu dedicación y cómo usas la IA. Duración: ~5 minutos.',
15
15
  },
16
16
  en: {
17
- signupNotice: 'Before we create your account: we store the data you give me (your LinkedIn, your CV and what I know about you) and may contact you. I fill in your profile automatically and you can change any field later on the web. You can do the onboarding interview afterwards right here in the chat or on the web.',
17
+ signupNotice: (sources) => `Before we create your account: we store the data you give me (${listOf('en', [...named(sources, COPY.en.importSources), 'what I know about you'])}) and may contact you. I fill in your profile automatically and you can change any field later on the web. You can do the onboarding interview afterwards right here in the chat or on the web.`,
18
18
  usageInfoAccessed: 'Accessed: the structure of your AI tools and redacted excerpts of your activity on this machine. Your raw code and prompts NEVER leave your machine.',
19
19
  usageGoalDuration: 'Goal: measure how you work with AI for your profile. Duration: ~1-2 minutes.',
20
20
  interviewInfoAccessed: 'Accessed: your answers in this interview and your imported profile. It is conducted by the Shakers server.',
21
21
  interviewGoalDuration: 'Goal: learn your role, your dedication and how you use AI. Duration: ~5 minutes.',
22
22
  },
23
23
  it: {
24
- signupNotice: 'Prima di creare il tuo account: conserviamo i dati che mi dai (il tuo LinkedIn, il tuo CV e quello che so di te) e possiamo contattarti. Completo il tuo profilo in automatico e puoi cambiare qualsiasi campo in seguito sul sito. Il colloquio di onboarding puoi farlo dopo sul sito.',
24
+ signupNotice: (sources) => `Prima di creare il tuo account: conserviamo i dati che mi dai (${listOf('it', [...named(sources, COPY.it.importSources), 'quello che so di te'])}) e possiamo contattarti. Completo il tuo profilo in automatico e puoi cambiare qualsiasi campo in seguito sul sito. Il colloquio di onboarding puoi farlo dopo sul sito.`,
25
25
  usageInfoAccessed: 'Accesso a: la struttura dei tuoi strumenti di IA ed estratti anonimizzati della tua attività su questo computer. Il tuo codice e i tuoi prompt originali NON escono MAI dalla tua macchina.',
26
26
  usageGoalDuration: "Obiettivo: misurare come lavori con l'IA per il tuo profilo. Durata: ~1-2 minuti.",
27
27
  interviewInfoAccessed: 'Accesso a: le tue risposte in questo colloquio e il tuo profilo importato. Lo conduce il server di Shakers.',
28
28
  interviewGoalDuration: "Obiettivo: conoscere il tuo ruolo, la tua disponibilità e come usi l'IA. Durata: ~5 minuti.",
29
29
  },
30
30
  pt: {
31
- signupNotice: 'Antes de criar a sua conta: guardamos os dados que me der (o seu LinkedIn, o seu CV e o que sei sobre si) e podemos contactá-lo. Preencho o seu perfil automaticamente e pode alterar qualquer campo depois na web. A entrevista de onboarding pode fazê-la depois na web.',
31
+ signupNotice: (sources) => `Antes de criar a sua conta: guardamos os dados que me der (${listOf('pt', [...named(sources, COPY.pt.importSources), 'o que sei sobre si'])}) e podemos contactá-lo. Preencho o seu perfil automaticamente e pode alterar qualquer campo depois na web. A entrevista de onboarding pode fazê-la depois na web.`,
32
32
  usageInfoAccessed: 'Acede-se a: a estrutura das suas ferramentas de IA e excertos anonimizados da sua atividade neste computador. O seu código e os seus prompts em bruto NUNCA saem da sua máquina.',
33
33
  usageGoalDuration: 'Objetivo: medir como trabalha com IA para o seu perfil. Duração: ~1-2 minutos.',
34
34
  interviewInfoAccessed: 'Acede-se a: as suas respostas nesta entrevista e o seu perfil importado. É conduzida pelo servidor da Shakers.',
@@ -38,6 +38,16 @@ const LEGAL = {
38
38
 
39
39
  const LOCALES = { es: 'es-ES', en: 'en-GB', it: 'it-IT', pt: 'pt-PT' };
40
40
 
41
+ // "a, b and c" in the talent's language.
42
+ function listOf(lang, items) {
43
+ return new Intl.ListFormat(LOCALES[lang], { type: 'conjunction' }).format(items);
44
+ }
45
+
46
+ // The names of only the sources the talent actually gave (cv, linkedin), from a per-language table.
47
+ function named(sources, names) {
48
+ return (sources || []).map((s) => names[s]).filter(Boolean);
49
+ }
50
+
41
51
  function joinParts(parts) {
42
52
  return parts.filter((p) => p !== null && p !== undefined && String(p).trim() !== '').join(' · ');
43
53
  }
@@ -69,14 +79,17 @@ const COPY = {
69
79
  draftQuestion: '¿Está bien o cambio algo?',
70
80
  draftOk: 'Todo correcto',
71
81
  draftChange: 'Quiero cambiar algo',
72
- windowNotice: 'Cuando confirmes, te abro una ventana en el navegador para crear tu cuenta y guardar todo esto. Allí eliges cómo entrar:\n· Con Google\n· Con email y contraseña\n\nLa contraseña la escribes allí directamente, nunca pasa por este chat. En esa misma pantalla aceptas los términos y la política de privacidad. Cuando termines, vuelve aquí y seguimos.',
82
+ windowNotice: 'Cuando me confirmes, solo faltará crear tu cuenta para guardar todo esto y que puedas entrar en Shakers cuando quieras.\n\nTe abro una ventana en el navegador, elige cómo entrar:\n· Con Google\n· Con email y contraseña\n\nLa contraseña la escribes allí directamente, nunca pasa por este chat. En esa misma pantalla aceptas los términos y la política de privacidad.\n\n👉 Mira tu navegador. Cuando termines, vuelve aquí y seguimos.',
83
+ windowNoticeSignIn: 'Cuando me confirmes, solo faltará entrar en la cuenta que ya tienes en Shakers con este email para guardar todo esto.\n\nTe abro una ventana en el navegador, elige cómo entrar:\n· Con Google\n· Con email y contraseña\n\nLa contraseña la escribes allí directamente, nunca pasa por este chat.\n\n👉 Mira tu navegador. Cuando termines, vuelve aquí y seguimos.',
73
84
  windowLink: (link) => `Si no se te ha abierto la ventana, entra desde aquí: ${link}`,
74
85
  existingTitle: 'He visto que ya tienes cuenta. ¿Actualizo tu perfil con esto?',
75
86
  existingRate: (from, to) => `Tarifa por hora: ${from} → ${to}`,
76
87
  existingAnnual: (from, to) => `Tarifa anual: ${from} → ${to}`,
77
88
  existingHours: (from, to) => `Disponibilidad: ${from} → ${to}`,
78
89
  existingEmpty: 'sin indicar',
79
- existingLists: 'Añado lo nuevo de tu CV y tu LinkedIn (experiencias, proyectos, formación) y relleno lo que tengas vacío, sin borrar nada.',
90
+ existingStays: (label, kept, requested) => (kept ? `${label}: se queda en ${kept}, no ${requested}` : `${label}: sin cambios`),
91
+ existingStaysNote: 'Lo que ya tenías relleno no lo cambio desde aquí: si quieres otros valores, cámbialos en la web.',
92
+ existingLists: (sources) => `Añado lo nuevo de ${listOf('es', named(sources, COPY.es.importSources))} (experiencias, proyectos, formación) y relleno lo que tengas vacío, sin borrar nada.`,
80
93
  existingYes: 'Sí, actualízalo',
81
94
  existingNo: 'No, déjalo como está',
82
95
  existingKept: 'Perfecto, dejo tu perfil como está.',
@@ -139,14 +152,17 @@ const COPY = {
139
152
  draftQuestion: 'Does it look right, or shall I change something?',
140
153
  draftOk: 'All correct',
141
154
  draftChange: 'I want to change something',
142
- windowNotice: "When you confirm, I'll open a window in your browser to create your account and save all this. There you choose how to sign in:\n· With Google\n· With email and password\n\nYou type the password there directly; it never goes through this chat. On that same screen you accept the terms and the privacy policy. When you're done, come back here and we'll carry on.",
155
+ windowNotice: "Once you confirm, all that's left is to create your account to save all this and so you can get into Shakers whenever you like.\n\nI'll open a window in your browser; choose how to sign in:\n· With Google\n· With email and password\n\nYou type the password there directly; it never goes through this chat. On that same screen you accept the terms and the privacy policy.\n\n👉 Check your browser. When you're done, come back here and we'll carry on.",
156
+ windowNoticeSignIn: "Once you confirm, all that's left is to sign in to the account you already have on Shakers with this email to save all this.\n\nI'll open a window in your browser; choose how to sign in:\n· With Google\n· With email and password\n\nYou type the password there directly; it never goes through this chat.\n\n👉 Check your browser. When you're done, come back here and we'll carry on.",
143
157
  windowLink: (link) => `If the window did not open, use this link: ${link}`,
144
158
  existingTitle: 'I see you already have an account. Shall I update your profile with this?',
145
159
  existingRate: (from, to) => `Hourly rate: ${from} → ${to}`,
146
160
  existingAnnual: (from, to) => `Annual rate: ${from} → ${to}`,
147
161
  existingHours: (from, to) => `Availability: ${from} → ${to}`,
148
162
  existingEmpty: 'not set',
149
- existingLists: 'I add what is new from your CV and LinkedIn (experience, projects, education) and fill in what is empty, without deleting anything.',
163
+ existingStays: (label, kept, requested) => (kept ? `${label}: stays at ${kept}, not ${requested}` : `${label}: no change`),
164
+ existingStaysNote: 'I do not change from here what you had already filled in: if you want other values, change them on the web.',
165
+ existingLists: (sources) => `I add what is new from ${listOf('en', named(sources, COPY.en.importSources))} (experience, projects, education) and fill in what is empty, without deleting anything.`,
150
166
  existingYes: 'Yes, update it',
151
167
  existingNo: 'No, leave it as it is',
152
168
  existingKept: 'Sure, I am leaving your profile as it is.',
@@ -209,14 +225,17 @@ const COPY = {
209
225
  draftQuestion: 'Va bene o cambio qualcosa?',
210
226
  draftOk: 'Tutto corretto',
211
227
  draftChange: 'Voglio cambiare qualcosa',
212
- windowNotice: "Quando confermi, ti apro una finestra nel browser per creare il tuo account e salvare tutto questo. Lì scegli come entrare:\n· Con Google\n· Con email e password\n\nLa password la scrivi direttamente lì, non passa mai da questa chat. Nella stessa schermata accetti i termini e l'informativa sulla privacy. Quando hai finito, torna qui e continuiamo.",
228
+ windowNotice: "Quando mi confermi, mancherà solo creare il tuo account per salvare tutto questo e poter entrare in Shakers quando vuoi.\n\nTi apro una finestra nel browser, scegli come entrare:\n· Con Google\n· Con email e password\n\nLa password la scrivi direttamente lì, non passa mai da questa chat. Nella stessa schermata accetti i termini e l'informativa sulla privacy.\n\n👉 Guarda il tuo browser. Quando hai finito, torna qui e continuiamo.",
229
+ windowNoticeSignIn: "Quando mi confermi, mancherà solo entrare nell'account che hai già su Shakers con questa email per salvare tutto questo.\n\nTi apro una finestra nel browser, scegli come entrare:\n· Con Google\n· Con email e password\n\nLa password la scrivi direttamente lì, non passa mai da questa chat.\n\n👉 Guarda il tuo browser. Quando hai finito, torna qui e continuiamo.",
213
230
  windowLink: (link) => `Se la finestra non si è aperta, entra da qui: ${link}`,
214
231
  existingTitle: 'Ho visto che hai già un account. Aggiorno il tuo profilo con questi dati?',
215
232
  existingRate: (from, to) => `Tariffa oraria: ${from} → ${to}`,
216
233
  existingAnnual: (from, to) => `Tariffa annuale: ${from} → ${to}`,
217
234
  existingHours: (from, to) => `Disponibilità: ${from} → ${to}`,
218
235
  existingEmpty: 'non indicata',
219
- existingLists: 'Aggiungo le novità del tuo CV e di LinkedIn (esperienze, progetti, formazione) e compilo quello che hai vuoto, senza cancellare nulla.',
236
+ existingStays: (label, kept, requested) => (kept ? `${label}: resta ${kept}, non ${requested}` : `${label}: nessuna modifica`),
237
+ existingStaysNote: 'Quello che avevi già compilato non lo cambio da qui: se vuoi altri valori, cambiali sul sito.',
238
+ existingLists: (sources) => `Aggiungo le novità ${listOf('it', named(sources, { cv: 'del tuo CV', linkedin: 'del tuo LinkedIn' }))} (esperienze, progetti, formazione) e compilo quello che hai vuoto, senza cancellare nulla.`,
220
239
  existingYes: 'Sì, aggiornalo',
221
240
  existingNo: 'No, lascialo così',
222
241
  existingKept: 'Perfetto, lascio il tuo profilo così com’è.',
@@ -279,14 +298,17 @@ const COPY = {
279
298
  draftQuestion: 'Está bem ou mudo alguma coisa?',
280
299
  draftOk: 'Está tudo certo',
281
300
  draftChange: 'Quero mudar algo',
282
- windowNotice: 'Quando confirmar, abro uma janela no seu navegador para criar a sua conta e guardar tudo isto. Lá escolhe como entrar:\n· Com Google\n· Com email e palavra-passe\n\nEscreve a palavra-passe diretamente lá; nunca passa por este chat. Nesse mesmo ecrã aceita os termos e a política de privacidade. Quando terminar, volte aqui e continuamos.',
301
+ windowNotice: 'Quando me confirmar, só faltará criar a sua conta para guardar tudo isto e poder entrar na Shakers quando quiser.\n\nAbro-lhe uma janela no navegador, escolha como entrar:\n· Com Google\n· Com email e palavra-passe\n\nEscreve a palavra-passe diretamente lá; nunca passa por este chat. Nesse mesmo ecrã aceita os termos e a política de privacidade.\n\n👉 Veja o seu navegador. Quando terminar, volte aqui e continuamos.',
302
+ windowNoticeSignIn: 'Quando me confirmar, só faltará entrar na conta que já tem na Shakers com este email para guardar tudo isto.\n\nAbro-lhe uma janela no navegador, escolha como entrar:\n· Com Google\n· Com email e palavra-passe\n\nEscreve a palavra-passe diretamente lá; nunca passa por este chat.\n\n👉 Veja o seu navegador. Quando terminar, volte aqui e continuamos.',
283
303
  windowLink: (link) => `Se a janela não abriu, entre por aqui: ${link}`,
284
304
  existingTitle: 'Vi que já tem conta. Atualizo o seu perfil com isto?',
285
305
  existingRate: (from, to) => `Tarifa por hora: ${from} → ${to}`,
286
306
  existingAnnual: (from, to) => `Tarifa anual: ${from} → ${to}`,
287
307
  existingHours: (from, to) => `Disponibilidade: ${from} → ${to}`,
288
308
  existingEmpty: 'sem indicar',
289
- existingLists: 'Acrescento o que há de novo no seu CV e no LinkedIn (experiências, projetos, formação) e preencho o que tiver vazio, sem apagar nada.',
309
+ existingStays: (label, kept, requested) => (kept ? `${label}: fica em ${kept}, não ${requested}` : `${label}: sem alterações`),
310
+ existingStaysNote: 'O que já tinha preenchido não o altero daqui: se quiser outros valores, altere-os na web.',
311
+ existingLists: (sources) => `Acrescento o que há de novo ${listOf('pt', named(sources, { cv: 'no seu CV', linkedin: 'no seu LinkedIn' }))} (experiências, projetos, formação) e preencho o que tiver vazio, sem apagar nada.`,
290
312
  existingYes: 'Sim, atualize',
291
313
  existingNo: 'Não, deixe como está',
292
314
  existingKept: 'Perfeito, deixo o seu perfil como está.',