@juicedresume/mcp 0.3.2 → 0.4.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 (2) hide show
  1. package/dist/index.js +180 -61
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1415,15 +1415,29 @@ function bigrams(toks) {
1415
1415
  return out;
1416
1416
  }
1417
1417
  function resumeText(resume) {
1418
- const parts = [];
1419
- parts.push(resume.personal.title);
1418
+ const parts = [resume.personal.title];
1420
1419
  for (const sec of resume.sections) {
1420
+ if (sec.visible === false) continue;
1421
1421
  for (const it of sec.items) {
1422
- parts.push(it.body || "");
1423
- parts.push(it.description || "");
1424
- parts.push(it.skills || "");
1425
- parts.push(it.jobTitle || it.degree || it.name || it.title || "");
1426
- parts.push(it.employer || it.school || it.publisher || it.institution || "");
1422
+ if (it.visible === false) continue;
1423
+ parts.push(
1424
+ it.body || "",
1425
+ it.description || "",
1426
+ it.skills || "",
1427
+ it.category || "",
1428
+ it.jobTitle || "",
1429
+ it.degree || "",
1430
+ it.field || "",
1431
+ it.name || "",
1432
+ it.title || "",
1433
+ it.role || "",
1434
+ it.employer || "",
1435
+ it.school || "",
1436
+ it.publisher || "",
1437
+ it.institution || "",
1438
+ it.issuer || "",
1439
+ it.location || ""
1440
+ );
1427
1441
  }
1428
1442
  }
1429
1443
  return parts.join(" ").replace(/<[^>]+>/g, " ");
@@ -1433,16 +1447,16 @@ function tailorToJob(resume, jdText) {
1433
1447
  const resumeFullText = resumeText(resume).toLowerCase();
1434
1448
  const resumeToks = new Set(tokens(resumeFullText));
1435
1449
  const resumeNorm = new Set([...resumeToks].map((t) => normalizeSkill(t).toLowerCase()));
1436
- function resumeHas(keyword) {
1450
+ function matchKind(keyword) {
1437
1451
  const k = keyword.toLowerCase();
1438
1452
  if (k.includes(" ")) {
1439
- return new RegExp(`\\b${escapeRe(k)}\\b`).test(resumeFullText);
1453
+ return new RegExp(`\\b${escapeRe(k)}\\b`).test(resumeFullText) ? "exact" : "none";
1440
1454
  }
1441
- if (resumeToks.has(k)) return true;
1455
+ if (resumeToks.has(k)) return "exact";
1442
1456
  const canon = normalizeSkill(keyword).toLowerCase();
1443
- if (resumeNorm.has(canon)) return true;
1444
- if (canon.includes(" ") && new RegExp(`\\b${escapeRe(canon)}\\b`).test(resumeFullText)) return true;
1445
- return false;
1457
+ if (resumeNorm.has(canon)) return "variant";
1458
+ if (canon.includes(" ") && new RegExp(`\\b${escapeRe(canon)}\\b`).test(resumeFullText)) return "variant";
1459
+ return "none";
1446
1460
  }
1447
1461
  const jdGrams = /* @__PURE__ */ new Set([...jdToks, ...bigrams(jdToks)]);
1448
1462
  const skillNames = new Map(SKILLS.map((s) => [s.name.toLowerCase(), s]));
@@ -1460,10 +1474,20 @@ function tailorToJob(resume, jdText) {
1460
1474
  const matched = [];
1461
1475
  const missing = [];
1462
1476
  const kindMult = (kind) => kind === "hard" ? 2 : kind === "soft" ? 1.3 : 1;
1477
+ const VARIANT_CREDIT = 0.6;
1463
1478
  for (const [k, meta] of jdSkills) {
1464
1479
  const w = meta.importance * kindMult(meta.kind);
1465
- if (resumeHas(k)) {
1480
+ const kind = matchKind(k);
1481
+ if (kind === "exact") {
1466
1482
  matched.push({ keyword: k, kind: meta.kind, count: w });
1483
+ } else if (kind === "variant") {
1484
+ matched.push({
1485
+ keyword: k,
1486
+ kind: meta.kind,
1487
+ count: w * VARIANT_CREDIT,
1488
+ partial: true,
1489
+ note: `You use a variant of "${k}". ATS keyword search is usually exact-match, so add the exact term "${k}" too.`
1490
+ });
1467
1491
  } else {
1468
1492
  missing.push({ keyword: k, kind: meta.kind, importance: w });
1469
1493
  }
@@ -1483,9 +1507,71 @@ function tailorToJob(resume, jdText) {
1483
1507
  missing,
1484
1508
  jdSkills: jdSkills.size,
1485
1509
  resumeSkills: resumeToks.size,
1486
- titleMatch
1510
+ titleMatch,
1511
+ flags: jdKnockoutFlags(resume, jdText)
1487
1512
  };
1488
1513
  }
1514
+ function tailorMMYYYY(d) {
1515
+ if (!d) return null;
1516
+ const m = d.match(/^(\d{1,2})\/(\d{4})$/);
1517
+ if (m) return parseInt(m[2], 10) * 12 + parseInt(m[1], 10);
1518
+ const mn = d.match(/^([A-Za-z]+)\s+(\d{4})$/);
1519
+ if (mn) {
1520
+ const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
1521
+ const i = months.findIndex((x) => mn[1].toLowerCase().startsWith(x));
1522
+ if (i >= 0) return parseInt(mn[2], 10) * 12 + (i + 1);
1523
+ }
1524
+ const y = d.match(/^(\d{4})$/);
1525
+ if (y) return parseInt(y[1], 10) * 12;
1526
+ return null;
1527
+ }
1528
+ function resumeYears(resume) {
1529
+ const exp = resume.sections.find((s) => s.type === "experience");
1530
+ if (!exp) return 0;
1531
+ const now = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
1532
+ let months = 0;
1533
+ for (const it of exp.items || []) {
1534
+ if (it.visible === false) continue;
1535
+ const s = tailorMMYYYY(it.startDate || "");
1536
+ const e = it.current ? now : tailorMMYYYY(it.endDate || "");
1537
+ if (s && e && e >= s) months += e - s;
1538
+ }
1539
+ return Math.round(months / 12 * 10) / 10;
1540
+ }
1541
+ function jdKnockoutFlags(resume, jdText) {
1542
+ const flags = [];
1543
+ const degreeRequired = /\b(bachelor|master|b\.?s\.?|m\.?s\.?|b\.?a\.?|ph\.?d|mba|degree)\b[^.\n]{0,40}\b(required|require|must|minimum|mandatory)\b/i.test(jdText) || /\b(required|must have|minimum|mandatory)\b[^.\n]{0,40}\b(bachelor|master|degree|b\.?s\.?|m\.?s\.?|ph\.?d|diploma)\b/i.test(jdText);
1544
+ if (degreeRequired) {
1545
+ const eduSec = resume.sections.find((s) => s.type === "education");
1546
+ const hasDegree = (eduSec?.items || []).some((it) => it.visible !== false && String(it.degree || "").trim());
1547
+ if (!hasDegree) {
1548
+ flags.push({
1549
+ id: "K4.missing-required-degree",
1550
+ severity: "risk",
1551
+ title: "This job states a degree requirement",
1552
+ detail: "The description asks for a specific degree, and no completed degree is listed on your resume. A stated degree requirement is often a hard knockout filter.",
1553
+ suggestion: "Add your degree in the Education section, or if you have equivalent experience, be ready to address the gap in a cover note.",
1554
+ requirement: "degree"
1555
+ });
1556
+ }
1557
+ }
1558
+ const yoe = [...jdText.matchAll(/(\d{1,2})\s*\+?\s*(?:to|-|–|—)?\s*\d{0,2}\s*years?(?:['’]?s?)?\s*(?:of\s+)?(?:experience|exp\b)/gi)].map((m) => parseInt(m[1], 10)).filter((n) => n >= 1 && n <= 20);
1559
+ if (yoe.length) {
1560
+ const required = Math.min(...yoe);
1561
+ const have = resumeYears(resume);
1562
+ if (have + 0.5 < required) {
1563
+ flags.push({
1564
+ id: "K5.yoe-below-requirement",
1565
+ severity: "risk",
1566
+ title: `Asks for ${required}+ years; your resume shows about ${have}`,
1567
+ detail: "Years of experience is one of the most common automated and recruiter filters. A year or more below the stated minimum is a frequent screen-out.",
1568
+ suggestion: "Make sure every relevant role has dates so your full tenure is counted, and include earlier or concurrent experience if it applies.",
1569
+ requirement: `${required}+ years`
1570
+ });
1571
+ }
1572
+ }
1573
+ return flags;
1574
+ }
1489
1575
 
1490
1576
  // ../../packages/scoring/src/v2-additions.ts
1491
1577
  function stripHtml(s) {
@@ -1531,41 +1617,6 @@ function firstBulletStrongest(resume) {
1531
1617
  )
1532
1618
  };
1533
1619
  }
1534
- function dateFormatConsistency(resume) {
1535
- const exp = resume.sections.find((s) => s.type === "experience");
1536
- const edu = resume.sections.find((s) => s.type === "education");
1537
- const dates = [];
1538
- [...exp?.items || [], ...edu?.items || []].forEach((it) => {
1539
- if (it.startDate) dates.push(it.startDate);
1540
- if (it.endDate && !it.current) dates.push(it.endDate);
1541
- });
1542
- if (dates.length < 2) return null;
1543
- const MM_YYYY = /^\d{1,2}\/\d{4}$/;
1544
- const MMM_YYYY = /^[A-Za-z]+\s+\d{4}$/;
1545
- const YYYY = /^\d{4}$/;
1546
- const ABBREV = /['']\d{2}|\d{1,2}\/\d{2}|\b[A-Z][a-z]{2}'\d{2}\b/;
1547
- const formats = /* @__PURE__ */ new Set();
1548
- for (const d of dates) {
1549
- if (ABBREV.test(d)) formats.add("abbrev");
1550
- else if (MM_YYYY.test(d)) formats.add("mm/yyyy");
1551
- else if (MMM_YYYY.test(d)) formats.add("month-yyyy");
1552
- else if (YYYY.test(d)) formats.add("yyyy");
1553
- else formats.add("other");
1554
- }
1555
- const hasAbbrev = formats.has("abbrev");
1556
- const inconsistent = formats.size > 1;
1557
- const score = hasAbbrev ? 0.2 : inconsistent ? 0.55 : 1;
1558
- return {
1559
- dim: "style",
1560
- check: passSoft(
1561
- "C9.date-format",
1562
- "Date format is consistent",
1563
- score,
1564
- hasAbbrev ? "Date abbreviations like '21 or Jan'24 break ATS years-of-experience calculation." : inconsistent ? `You're mixing date formats (${[...formats].join(", ")}). Pick one and apply consistently.` : "Dates are formatted consistently \u2014 ATS parses them cleanly.",
1565
- "Use MM/YYYY (06/2024) or Month YYYY (June 2024) \u2014 consistently across every entry."
1566
- )
1567
- };
1568
- }
1569
1620
  function targetTitle(resume) {
1570
1621
  const title = resume.personal?.title?.trim() || "";
1571
1622
  const score = title.length >= 3 ? 1 : 0;
@@ -1626,7 +1677,6 @@ function threeLocationsRule(resume) {
1626
1677
  function additionalChecks(resume) {
1627
1678
  return [
1628
1679
  firstBulletStrongest(resume),
1629
- dateFormatConsistency(resume),
1630
1680
  targetTitle(resume),
1631
1681
  dobAgePenalty(resume),
1632
1682
  threeLocationsRule(resume)
@@ -2642,7 +2692,8 @@ function atsChecks(resume, b) {
2642
2692
  const allText = b.bullets.map((x) => x.text).join(" ");
2643
2693
  const isSingleCol = !isMultiColumnTemplate(s.template);
2644
2694
  const isSafeFont = SAFE_FONTS.has(s.fontHeading) && SAFE_FONTS.has(s.fontBody);
2645
- const fontSizeOK = s.fontSize >= 9.5 && s.fontSize <= 12;
2695
+ const fontSizeIdeal = s.fontSize >= 10 && s.fontSize <= 12;
2696
+ const fontSizeAcceptable = s.fontSize >= 9.5 && s.fontSize <= 12.5;
2646
2697
  const safeBullet = SAFE_BULLET_GLYPHS.has(s.bulletGlyph === "disc" ? "\u2022" : s.bulletGlyph === "dash" ? "-" : "");
2647
2698
  const emailOK = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(p.email);
2648
2699
  const phoneOK = !p.phone || /(\+?\d[\d\s().-]{6,})/.test(p.phone);
@@ -2684,10 +2735,14 @@ function atsChecks(resume, b) {
2684
2735
  // single-column), but ~1 in 10 still scrambles reading order and columns
2685
2736
  // cost human skimmability. A single, evidence-scaled ding — the overall
2686
2737
  // score also takes a small proportional multi-column haircut (see below).
2687
- isSingleCol ? 1 : 0.55,
2688
- isSingleCol ? "Single-column layout \u2014 every ATS parses this cleanly." : "Multi-column template. Modern parsers usually handle columns, but a minority still scramble reading order, and columns are harder for a recruiter to skim. Single-column is the zero-risk choice.",
2738
+ // ~0.85 reflects the real ~90% correct column-extraction rate reported by
2739
+ // Textkernel, so the within-dimension ding stays light; the small overall
2740
+ // multi-column haircut in scoreResume carries the actual score effect
2741
+ // (we deliberately don't double-penalize with a 0.55 here).
2742
+ isSingleCol ? 1 : 0.85,
2743
+ isSingleCol ? "Single-column layout. Every ATS parses this cleanly." : "Multi-column template. Modern parsers handle columns about 90% of the time, but a minority scramble reading order, and columns are harder for a recruiter to skim.",
2689
2744
  [],
2690
- !isSingleCol ? "For maximum ATS safety, switch to a single-column template (Jake, Harvard, Classic) in the Templates tab. Not required, but it removes all parse risk." : void 0,
2745
+ !isSingleCol ? "Exporting via the ATS PDF button always outputs a single-column, parse-clean file, so you can keep this template on screen and still submit a safe PDF. Or switch to a single-column template (Jake, Harvard, Classic) for a single source." : void 0,
2691
2746
  "soft"
2692
2747
  ),
2693
2748
  mk(
@@ -2776,10 +2831,10 @@ function atsChecks(resume, b) {
2776
2831
  mk(
2777
2832
  "E11.font-size",
2778
2833
  "Body font size 10\u201312pt",
2779
- fontSizeOK ? 1 : 0.5,
2780
- fontSizeOK ? `Font size ${s.fontSize}pt is in range.` : `Font size ${s.fontSize}pt is outside the 10\u201312pt range.`,
2834
+ fontSizeIdeal ? 1 : fontSizeAcceptable ? 0.75 : 0.5,
2835
+ fontSizeIdeal ? `Font size ${s.fontSize}pt is in the ideal 10\u201312pt band.` : fontSizeAcceptable ? `Font size ${s.fontSize}pt is readable but outside the ideal 10\u201312pt band.` : `Font size ${s.fontSize}pt is outside the readable 9.5\u201312.5pt range.`,
2781
2836
  [],
2782
- !fontSizeOK ? "Set body size to 10\u201312pt." : void 0,
2837
+ !fontSizeIdeal ? "Set body size to 10\u201312pt for the best balance of density and legibility." : void 0,
2783
2838
  void 0,
2784
2839
  0.5
2785
2840
  ),
@@ -2841,7 +2896,7 @@ function skillsChecks(resume, b) {
2841
2896
  stuffed.length === 0 ? 1 : 1 - Math.min(1, stuffed.length / 3),
2842
2897
  stuffed.length === 0 ? "No token over-repeated." : `Over-repeated: ${stuffed.slice(0, 4).map(([k, v]) => `${k}\xD7${v}`).join(", ")}.`,
2843
2898
  [],
2844
- stuffed.length ? "Diversify language \u2014 repeating a keyword more than 5\xD7 looks like stuffing." : void 0
2899
+ stuffed.length ? "Diversify your language. Repeating the same keyword more than 8 times reads as stuffing, and frequency past the first mention does not help ATS ranking anyway." : void 0
2845
2900
  ),
2846
2901
  mk(
2847
2902
  "F5.acronym-expansion",
@@ -2928,7 +2983,9 @@ function polishChecks(resume, b) {
2928
2983
  hasHiddenText ? "Hidden or invisible text (white-on-white, zero-size) or an AI-instruction string was detected. ATS convert your file to plain text, so these become visible and read as gaming the system." : "No hidden text or prompt-injection tricks detected.",
2929
2984
  [],
2930
2985
  hasHiddenText ? "Remove any white/invisible text, zero-size fonts, or 'rate me highly'-style instructions. They backfire once the ATS strips formatting." : void 0,
2931
- void 0,
2986
+ // Hard severity: hidden text / prompt-injection is the one trick that
2987
+ // genuinely backfires, so a positive detection caps the overall score.
2988
+ "hard",
2932
2989
  2
2933
2990
  )
2934
2991
  ];
@@ -2972,6 +3029,52 @@ function experienceYears(resume) {
2972
3029
  }
2973
3030
  return Math.floor(months / 12);
2974
3031
  }
3032
+ function computeFlags(resume) {
3033
+ const flags = [];
3034
+ const p = resume.personal;
3035
+ const emailOK = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(p.email);
3036
+ const hasPhone = !!(p.phone && /(\+?\d[\d\s().-]{6,})/.test(p.phone));
3037
+ const nameOK = !!(p.fullName && p.fullName.trim().length >= 2);
3038
+ if (!(nameOK && (emailOK || hasPhone))) {
3039
+ flags.push({
3040
+ id: "K1.contact-unparseable",
3041
+ severity: "block",
3042
+ title: "Contact details can't be parsed",
3043
+ detail: !nameOK ? "No full name is set. ATS build the candidate record from a name plus an email or phone, so without a name the whole resume can be dropped." : "No valid email or phone is set. Most parsers reject a record that has neither.",
3044
+ suggestion: "Add your full name and at least one of a valid email or phone."
3045
+ });
3046
+ }
3047
+ const expSec = resume.sections.find((s) => s.type === "experience");
3048
+ const expItems = (expSec?.items || []).filter((it) => it.visible !== false);
3049
+ const datelessRoles = expItems.filter((it) => !parseMMYYYY(it.startDate || ""));
3050
+ if (expItems.length > 0 && datelessRoles.length > 0) {
3051
+ flags.push({
3052
+ id: "K2.role-without-dates",
3053
+ severity: "risk",
3054
+ title: `${datelessRoles.length} role${datelessRoles.length === 1 ? "" : "s"} missing dates`,
3055
+ detail: "A role with no start date can't be ordered or counted toward years of experience by a parser, and years-of-experience filters are a top recruiter screen.",
3056
+ suggestion: "Add a start and end date (MM/YYYY or Month YYYY) to every role."
3057
+ });
3058
+ }
3059
+ const nowMonths = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
3060
+ const ranges = expItems.map((it) => ({ start: parseMMYYYY(it.startDate || ""), end: it.current ? nowMonths : parseMMYYYY(it.endDate || it.startDate || "") })).filter((r) => r.start && r.end);
3061
+ ranges.sort((a, b) => b.start - a.start);
3062
+ let maxGap = 0;
3063
+ for (let i = 0; i < ranges.length - 1; i++) {
3064
+ const gap = ranges[i].start - ranges[i + 1].end;
3065
+ if (gap > maxGap) maxGap = gap;
3066
+ }
3067
+ if (maxGap > 6) {
3068
+ flags.push({
3069
+ id: "K3.employment-gap",
3070
+ severity: "risk",
3071
+ title: `About a ${maxGap}-month employment gap`,
3072
+ detail: "Some employers screen for gaps over 6 months. A gap is legitimate, but an unexplained one lowers callback odds.",
3073
+ suggestion: "Add a one-line note (contract, study, caregiving) or a short bridging entry so the gap does not read as unexplained."
3074
+ });
3075
+ }
3076
+ return flags;
3077
+ }
2975
3078
  function aggregate(checks, weight) {
2976
3079
  if (!checks.length) return { score: 0, weighted: 0 };
2977
3080
  let wsum = 0, wtot = 0;
@@ -3001,7 +3104,7 @@ var LABELS = {
3001
3104
  skills: "Skills & Keywords",
3002
3105
  polish: "Completeness & Polish"
3003
3106
  };
3004
- function scoreResume(resume) {
3107
+ function scoreResume(resume, opts) {
3005
3108
  const b = gather(resume);
3006
3109
  const dims = [
3007
3110
  { dim: "impact", checks: impactChecks(resume, b) },
@@ -3036,6 +3139,21 @@ function scoreResume(resume) {
3036
3139
  }
3037
3140
  const hardFail = dimensions.some((d) => d.checks.some((c) => c.severity === "hard" && c.status === "fail"));
3038
3141
  if (hardFail) overall = Math.min(overall, 60);
3142
+ const parseFindings = opts?.parseFindings ?? [];
3143
+ const parseHard = parseFindings.some((f) => f.severity === "hard");
3144
+ const parseSoftCount = parseFindings.filter((f) => f.severity === "soft").length;
3145
+ if (parseHard) overall = Math.min(overall, 55);
3146
+ if (parseSoftCount) overall = Math.max(0, overall - Math.min(12, parseSoftCount * 4));
3147
+ const flags = [
3148
+ ...computeFlags(resume),
3149
+ ...parseFindings.map((f) => ({
3150
+ id: f.id,
3151
+ severity: f.severity === "hard" ? "block" : "risk",
3152
+ title: f.title,
3153
+ detail: f.detail,
3154
+ suggestion: f.suggestion ?? null
3155
+ }))
3156
+ ];
3039
3157
  const sortedDims = [...dimensions].sort((a, b2) => a.score - b2.score);
3040
3158
  const worst = sortedDims[0];
3041
3159
  const best = sortedDims[sortedDims.length - 1];
@@ -3048,6 +3166,7 @@ function scoreResume(resume) {
3048
3166
  summary: `Strongest: ${best.label} (${best.score}). Focus area: ${worst.label} (${worst.score}).`,
3049
3167
  priorities,
3050
3168
  hardFails: dimensions.flatMap((d) => d.checks.filter((c) => c.severity === "hard" && c.status === "fail").map((c) => ({ ...c, dim: d.dimension }))),
3169
+ flags,
3051
3170
  stats: {
3052
3171
  totalBullets: b.bullets.length,
3053
3172
  experienceBullets: b.expBullets.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juicedresume/mcp",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Model Context Protocol server for JuicedResume — score, tailor, and edit your resume from any MCP client (Claude Code, Claude Desktop, Cursor).",
5
5
  "keywords": [
6
6
  "mcp",