careermate 0.0.12 → 0.0.14

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.
package/AGENTS.md CHANGED
@@ -16,7 +16,7 @@ CareerMate는 사용자 PC에만 데이터를 두는 **로컬 커리어 작업
16
16
  - **공고 분석·자소서 작성 전에는 반드시 `get_application_context`를 먼저 호출하라.** 한 번의 호출로 프로필·대표 이력서·경력·스킬·기존 자소서·대상 공고·선호 문체·강조 포인트를 모두 돌려준다. 맥락 없이 작성하지 마라.
17
17
  - **결과는 다시 CareerMate에 저장하라.** 분석 → `save_fit_analysis`, 자소서 → `save_cover_letter_version`, 경력기술서 → `add_resume`(`kind=career_description`), 공고 → `save_job_posting`, 면접 자료 → `save_interview_prep`. 커리어 데이터는 흩어진 파일이 아니라 CareerMate 안에 둔다. 임의의 로컬 파일 생성은 최소화하라(사용자가 파일을 원하면 자소서는 `export_cover_letter`, 이력서·경력기술서는 `export_resume`, 프로필은 `export_profile`로 `~/.careermate/exports`에 내보낸다).
18
18
  - **여러 문서를 한 번에 요청받아도 각 문서는 각자의 저장 도구로 따로 저장하라.** "경력기술서랑 자기소개서 둘 다 써줘"처럼 한 턴에 여러 문서를 만들었더라도, 한 저장 호출의 본문(content)에 다른 문서를 이어붙이지 마라 — 경력기술서는 `add_resume`(`kind=career_description`), 자기소개서는 `save_cover_letter_version`으로 **각각 따로** 저장한다(작성은 한 답변에 둘 다 보여줘도 무방하나, 저장은 문서별 도구로 분리). 한 문서 본문에 다른 문서가 섞이면 대시보드에 통째로 붙어 등록된다.
19
- - **공고 찾기는 사용자 직무 기준으로.** 직무 조건은 짐작하지 말고 `find_job_categories`로 사용자 직무(말하지 않았으면 프로필의 희망 직무)에 맞는 리멤버 분류·사람인 코드를 찾는다. 사용자가 자주 볼 직무라고 확인하면 `set_job_search_preferences`로 저장해 두고, 이후 "오늘 올라온 공고 보여줘"는 `search_jobs`를 조건 없이 `posted_since: "today"`로 부른다. 원티드·점핏은 게시일을 주지 않아 CareerMate가 처음 본 날로 거르므로 첫 검색에선 전부 새 공고로 보인다는 점을 알려라. 개발 직군만이 아니라 모든 직군에 같은 흐름을 쓴다.
19
+ - **공고 찾기는 사용자 직무 기준으로.** 직무 조건은 짐작하지 말고 `find_job_categories`로 사용자 직무(말하지 않았으면 프로필의 희망 직무)에 맞는 리멤버 분류·사람인 코드를 찾는다. 사용자가 자주 볼 직무라고 확인하면 `set_job_search_preferences`로 저장해 두고, 이후 "오늘 올라온 공고 보여줘"는 `search_jobs`를 조건 없이 `posted_since: "today"`로 부른다. 원티드·점핏은 게시일을 주지 않아 CareerMate가 처음 본 날로 거르므로 첫 검색에선 전부 새 공고로 보인다는 점을 알려라. 사용자가 "5년차"처럼 경력을 말하면 `years`(신입은 0)로 거른다. 개발 직군만이 아니라 모든 직군에 같은 흐름을 쓴다.
20
20
  - **도구 응답은 사람 말로 바꿔라.** 응답에 `user_message`가 있으면 그 내용을 우선 활용하되 **사용자의 언어로 옮겨** 전달하고(한국어 원문을 그대로 복사하지 않는다), `suggested_next_action`은 선택지로 제안한다. `id`, `job_id`, `cover_letter_id`, `workflow_id`, JSON 원문, 도구 이름은 내부 작업용으로만 쓰고 사용자에게 그대로 복사하지 않는다.
21
21
  - **공고 분석 뒤에는 자기소개서를 곧장 쓰지 말고 선택지를 보여줘라.** 적합도 분석을 저장하고 쉬운 말로 설명한 뒤 "이 공고에 맞춘 자기소개서를 써드릴까요?"라고 묻고, `1. 네, 이 공고에 맞춘 자기소개서를 써줘` / `2. 아니요, 분석만 저장해줘`처럼 고르게 한다. 사용자가 1번을 고른 경우에만 자소서 작성·저장 흐름으로 이어간다. **1번(쓰기)을 고르면 작성 전에, 회사가 지정한 문항·양식·글자수 제한이 있으면 먼저 달라고 요청하라** — 한국 자소서는 문항형(회사별 항목·글자수)이 많아 문항·양식이 결과 질을 좌우한다. 공고·맥락에 없으면 사용자에게 문항 텍스트·제출 양식·글자수(상·하한, 공백포함 여부)를 묻고, 끝내 없으면 일반 양식으로 진행한다고 알린다. 받은 문항·양식·글자수는 작성에 반영하고 가능하면 공고에 함께 저장한다.
22
22
  - **표준 워크플로우를 따르라(핵심 6 + 선택 1)**: onboarding, analyze_job, write_cover_letter, write_career_description, manage_application_status, prepare_interview — 그리고 사용자가 LinkedIn·포트폴리오 같은 자기표현 자산을 요청하면 `build_personal_brand`. 순서가 헷갈리면 `get_workflow_guide`를 호출한다.
package/dist/doctor.mjs CHANGED
@@ -86991,7 +86991,7 @@ var ApplicationContextSchema = external_exports.object({
86991
86991
  });
86992
86992
 
86993
86993
  // packages/shared/src/build-info.ts
86994
- var APP_VERSION = true ? "0.0.12" : "0.0.0-dev";
86994
+ var APP_VERSION = true ? "0.0.14" : "0.0.0-dev";
86995
86995
  var BUNDLED = true;
86996
86996
 
86997
86997
  // packages/shared/src/index.ts
@@ -89181,7 +89181,7 @@ var wanted = {
89181
89181
  const url2 = `https://www.wanted.co.kr/api/v4/jobs?country=kr&locations=all&years=-1&job_sort=job.latest_order&limit=${limit}&offset=0&query=${encodeURIComponent(query)}`;
89182
89182
  const j = wantedSchema.parse(await getJson(url2, timeoutMs));
89183
89183
  return j.data.map((r) => {
89184
- const annual = r.annual_from || r.annual_to ? `${r.annual_from ?? 0}~${r.annual_to ?? "?"}\uB144` : void 0;
89184
+ const annual = r.annual_from || r.annual_to ? r.annual_to == null || r.annual_to >= 100 ? `${r.annual_from ?? 0}\uB144 \uC774\uC0C1` : `${r.annual_from ?? 0}~${r.annual_to}\uB144` : void 0;
89185
89185
  return {
89186
89186
  source: "wanted",
89187
89187
  company: r.company.name,
@@ -89201,7 +89201,7 @@ var jumpit = {
89201
89201
  async search(query, limit, timeoutMs) {
89202
89202
  const positions = [];
89203
89203
  for (let page = 1; positions.length < limit && page <= Math.ceil(limit / 16); page++) {
89204
- const url2 = `https://jumpit-api.saramin.co.kr/api/positions?sort=rsp_rate&page=${page}&keyword=${encodeURIComponent(query)}`;
89204
+ const url2 = `https://jumpit-api.saramin.co.kr/api/positions?sort=relation&page=${page}&keyword=${encodeURIComponent(query)}`;
89205
89205
  const got = jumpitSchema.parse(await getJson(url2, timeoutMs)).result.positions;
89206
89206
  positions.push(...got);
89207
89207
  if (got.length < 16) break;
@@ -89413,8 +89413,13 @@ async function searchJobs(query, opts = {}) {
89413
89413
  }
89414
89414
  results = dedupeAcrossBoards(results);
89415
89415
  let filtered_out;
89416
- if (opts.postedSince) {
89417
- const kept = results.filter((r) => (r.posted_at ?? r.first_seen_at ?? today) >= opts.postedSince);
89416
+ const years = opts.years;
89417
+ if (opts.postedSince || years != null) {
89418
+ const kept = results.filter((r) => {
89419
+ if (opts.postedSince && (r.posted_at ?? r.first_seen_at ?? today) < opts.postedSince) return false;
89420
+ const range2 = years != null ? experienceRange(r.experience) : null;
89421
+ return !range2 || range2[0] <= years && years <= range2[1];
89422
+ });
89418
89423
  filtered_out = results.length - kept.length;
89419
89424
  results = kept;
89420
89425
  }
@@ -89425,7 +89430,18 @@ async function searchJobs(query, opts = {}) {
89425
89430
  ...filtered_out !== void 0 ? { filtered_out } : {}
89426
89431
  };
89427
89432
  }
89428
- var normCompany = (s) => s.replace(/\(주\)|㈜|주식회사|\(유\)|\(재\)/g, "").toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
89433
+ function experienceRange(text) {
89434
+ const t = (text ?? "").replace(/\s/g, "");
89435
+ if (!t) return null;
89436
+ if (/무관/.test(t)) return [0, Infinity];
89437
+ const m = t.match(/(\d+)?~(\d+)년/);
89438
+ if (m) return [m[1] ? +m[1] : 0, +m[2]];
89439
+ const min2 = t.match(/(\d+)년(이상|↑)/);
89440
+ if (min2) return [/신입/.test(t) ? 0 : +min2[1], Infinity];
89441
+ if (/신입/.test(t)) return /경력/.test(t) ? [0, Infinity] : [0, 0];
89442
+ return null;
89443
+ }
89444
+ var normCompany = (s) => s.replace(/\([^)]*\)|㈜|주식회사/g, "").toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
89429
89445
  var normTitle = (title, company) => {
89430
89446
  const c = normCompany(company);
89431
89447
  return title.replace(/\[([^\]]*)\]/g, (tag, inner) => {
@@ -110103,7 +110119,8 @@ ${retro_prompt}` : "");
110103
110119
  limit: external_exports.number().optional().describe("\uC0AC\uC774\uD2B8\uBCC4 \uCD5C\uB300 \uAC74\uC218(\uAE30\uBCF8 10, posted_since\uAC00 \uC788\uC73C\uBA74 50, \uCD5C\uB300 100). \uC624\uB298 \uACF5\uACE0\uAC00 limit\uB9CC\uD07C \uAF49 \uCC28\uC11C \uB3CC\uC544\uC624\uBA74 \uB354 \uC788\uC744 \uC218 \uC788\uC73C\uB2C8 \uB298\uB824\uC11C \uB2E4\uC2DC \uAC80\uC0C9"),
110104
110120
  remember_categories: external_exports.array(external_exports.string()).max(20).optional().describe('\uB9AC\uBA64\uBC84 \uC9C1\uBB34(find_job_categories \uACB0\uACFC): "\uC0C1\uC704>\uD558\uC704" \uB610\uB294 \uC0C1\uC704\uB9CC. \uC608: ["\uC7AC\uBB34\xB7\uD68C\uACC4\xB7\uC138\uBB34>\uD68C\uACC4\uACB0\uC0B0"]'),
110105
110121
  saramin_categories: external_exports.array(external_exports.string().regex(/^\d+$/)).max(20).optional().describe('\uC0AC\uB78C\uC778 \uC9C1\uBB34 \uCF54\uB4DC(find_job_categories \uACB0\uACFC). \uC608: ["322"]'),
110106
- posted_since: external_exports.string().refine((s) => s === "today" || isYmd(s), '"today" \uB610\uB294 \uC2E4\uC81C \uB0A0\uC9DC(YYYY-MM-DD)').optional().describe('"today" \uB610\uB294 YYYY-MM-DD. \uADF8\uB0A0 \uC774\uD6C4 \uACF5\uACE0\uB9CC')
110122
+ posted_since: external_exports.string().refine((s) => s === "today" || isYmd(s), '"today" \uB610\uB294 \uC2E4\uC81C \uB0A0\uC9DC(YYYY-MM-DD)').optional().describe('"today" \uB610\uB294 YYYY-MM-DD. \uADF8\uB0A0 \uC774\uD6C4 \uACF5\uACE0\uB9CC'),
110123
+ years: external_exports.number().int().min(0).max(40).optional().describe('\uC0AC\uC6A9\uC790 \uACBD\uB825 \uC5F0\uCC28(\uC2E0\uC785 0). \uACF5\uACE0\uC758 \uACBD\uB825 \uBC94\uC704\uC5D0 \uC774 \uC5F0\uCC28\uAC00 \uB4E4\uC5B4\uAC00\uB294 \uAC83\uB9CC \uB0A8\uAE40. "5\uB144\uCC28"\uBA74 5. \uC0AC\uC6A9\uC790\uAC00 \uB9D0\uD558\uC9C0 \uC54A\uC558\uC73C\uBA74 \uD504\uB85C\uD544 \uACBD\uB825\uC73C\uB85C \uACC4\uC0B0\uD574 \uB123\uB418 \uD655\uC778. \uACBD\uB825 \uD45C\uAE30\uB97C \uC77D\uC744 \uC218 \uC5C6\uB294 \uACF5\uACE0\uB294 \uB0A8\uAE40')
110107
110124
  },
110108
110125
  // 공고를 가져오기만 한다. 처음 본 날짜 기록은 사용자 데이터가 아닌 로컬 캐시라 readOnly로 둔다.
110109
110126
  readOnly: true,
@@ -110129,18 +110146,19 @@ ${retro_prompt}` : "");
110129
110146
  rememberCategories,
110130
110147
  saraminCategories,
110131
110148
  postedSince: args.posted_since === "today" ? today : args.posted_since,
110149
+ years: args.years,
110132
110150
  today
110133
110151
  });
110134
110152
  const failed = res.providers.filter((p) => !p.ok);
110135
110153
  const okCount = res.results.length;
110136
110154
  const parts = [`'${res.query || "\uC9C1\uBB34 \uC870\uAC74"}' \uAC80\uC0C9 \uACB0\uACFC ${okCount}\uAC74${usedSaved ? "(\uC800\uC7A5\uB41C \uAC80\uC0C9 \uC870\uAC74 \uC0AC\uC6A9)" : ""}`];
110137
- if (res.filtered_out) parts.push(`(\uAC8C\uC2DC\uC77C \uAE30\uC900 ${res.filtered_out}\uAC74 \uC81C\uC678)`);
110155
+ if (res.filtered_out) parts.push(`(\uAC8C\uC2DC\uC77C\xB7\uACBD\uB825 \uC870\uAC74\uC73C\uB85C ${res.filtered_out}\uAC74 \uC81C\uC678)`);
110138
110156
  if (failed.length) parts.push(`(\uC77C\uBD80 \uC0AC\uC774\uD2B8 \uC751\uB2F5 \uC2E4\uD328: ${failed.map((f) => `${f.source}: ${f.error ?? f.status}`).join("; ")})`);
110139
110157
  if (okCount === 0) parts.push("- \uD0A4\uC6CC\uB4DC\uB098 \uC9C1\uBB34 \uC870\uAC74\uC744 \uBC14\uAFB8\uAC70\uB098, \uACF5\uACE0 URL/\uBCF8\uBB38\uC744 \uC8FC\uC2DC\uBA74 \uC9C1\uC811 \uC800\uC7A5\uD574 \uB4DC\uB9B4\uAC8C\uC694.");
110140
110158
  return ok(parts.join(" "), {
110141
110159
  ...res,
110142
110160
  used_saved_preferences: usedSaved,
110143
- conditions: { keyword, remember_categories: rememberCategories, saramin_categories: saraminCategories }
110161
+ conditions: { keyword, remember_categories: rememberCategories, saramin_categories: saraminCategories, years: args.years }
110144
110162
  });
110145
110163
  }
110146
110164
  },
package/dist/mcp.mjs CHANGED
@@ -103447,7 +103447,7 @@ var ApplicationContextSchema = external_exports.object({
103447
103447
  });
103448
103448
 
103449
103449
  // packages/shared/src/build-info.ts
103450
- var APP_VERSION = true ? "0.0.12" : "0.0.0-dev";
103450
+ var APP_VERSION = true ? "0.0.14" : "0.0.0-dev";
103451
103451
  var BUNDLED = true;
103452
103452
 
103453
103453
  // packages/shared/src/index.ts
@@ -105637,7 +105637,7 @@ var wanted = {
105637
105637
  const url2 = `https://www.wanted.co.kr/api/v4/jobs?country=kr&locations=all&years=-1&job_sort=job.latest_order&limit=${limit}&offset=0&query=${encodeURIComponent(query)}`;
105638
105638
  const j = wantedSchema.parse(await getJson(url2, timeoutMs));
105639
105639
  return j.data.map((r) => {
105640
- const annual = r.annual_from || r.annual_to ? `${r.annual_from ?? 0}~${r.annual_to ?? "?"}\uB144` : void 0;
105640
+ const annual = r.annual_from || r.annual_to ? r.annual_to == null || r.annual_to >= 100 ? `${r.annual_from ?? 0}\uB144 \uC774\uC0C1` : `${r.annual_from ?? 0}~${r.annual_to}\uB144` : void 0;
105641
105641
  return {
105642
105642
  source: "wanted",
105643
105643
  company: r.company.name,
@@ -105657,7 +105657,7 @@ var jumpit = {
105657
105657
  async search(query, limit, timeoutMs) {
105658
105658
  const positions = [];
105659
105659
  for (let page = 1; positions.length < limit && page <= Math.ceil(limit / 16); page++) {
105660
- const url2 = `https://jumpit-api.saramin.co.kr/api/positions?sort=rsp_rate&page=${page}&keyword=${encodeURIComponent(query)}`;
105660
+ const url2 = `https://jumpit-api.saramin.co.kr/api/positions?sort=relation&page=${page}&keyword=${encodeURIComponent(query)}`;
105661
105661
  const got = jumpitSchema.parse(await getJson(url2, timeoutMs)).result.positions;
105662
105662
  positions.push(...got);
105663
105663
  if (got.length < 16) break;
@@ -105869,8 +105869,13 @@ async function searchJobs(query, opts = {}) {
105869
105869
  }
105870
105870
  results = dedupeAcrossBoards(results);
105871
105871
  let filtered_out;
105872
- if (opts.postedSince) {
105873
- const kept = results.filter((r) => (r.posted_at ?? r.first_seen_at ?? today) >= opts.postedSince);
105872
+ const years = opts.years;
105873
+ if (opts.postedSince || years != null) {
105874
+ const kept = results.filter((r) => {
105875
+ if (opts.postedSince && (r.posted_at ?? r.first_seen_at ?? today) < opts.postedSince) return false;
105876
+ const range2 = years != null ? experienceRange(r.experience) : null;
105877
+ return !range2 || range2[0] <= years && years <= range2[1];
105878
+ });
105874
105879
  filtered_out = results.length - kept.length;
105875
105880
  results = kept;
105876
105881
  }
@@ -105881,7 +105886,18 @@ async function searchJobs(query, opts = {}) {
105881
105886
  ...filtered_out !== void 0 ? { filtered_out } : {}
105882
105887
  };
105883
105888
  }
105884
- var normCompany = (s) => s.replace(/\(주\)|㈜|주식회사|\(유\)|\(재\)/g, "").toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
105889
+ function experienceRange(text) {
105890
+ const t = (text ?? "").replace(/\s/g, "");
105891
+ if (!t) return null;
105892
+ if (/무관/.test(t)) return [0, Infinity];
105893
+ const m = t.match(/(\d+)?~(\d+)년/);
105894
+ if (m) return [m[1] ? +m[1] : 0, +m[2]];
105895
+ const min2 = t.match(/(\d+)년(이상|↑)/);
105896
+ if (min2) return [/신입/.test(t) ? 0 : +min2[1], Infinity];
105897
+ if (/신입/.test(t)) return /경력/.test(t) ? [0, Infinity] : [0, 0];
105898
+ return null;
105899
+ }
105900
+ var normCompany = (s) => s.replace(/\([^)]*\)|㈜|주식회사/g, "").toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
105885
105901
  var normTitle = (title, company) => {
105886
105902
  const c = normCompany(company);
105887
105903
  return title.replace(/\[([^\]]*)\]/g, (tag, inner) => {
@@ -126571,7 +126587,8 @@ ${retro_prompt}` : "");
126571
126587
  limit: external_exports.number().optional().describe("\uC0AC\uC774\uD2B8\uBCC4 \uCD5C\uB300 \uAC74\uC218(\uAE30\uBCF8 10, posted_since\uAC00 \uC788\uC73C\uBA74 50, \uCD5C\uB300 100). \uC624\uB298 \uACF5\uACE0\uAC00 limit\uB9CC\uD07C \uAF49 \uCC28\uC11C \uB3CC\uC544\uC624\uBA74 \uB354 \uC788\uC744 \uC218 \uC788\uC73C\uB2C8 \uB298\uB824\uC11C \uB2E4\uC2DC \uAC80\uC0C9"),
126572
126588
  remember_categories: external_exports.array(external_exports.string()).max(20).optional().describe('\uB9AC\uBA64\uBC84 \uC9C1\uBB34(find_job_categories \uACB0\uACFC): "\uC0C1\uC704>\uD558\uC704" \uB610\uB294 \uC0C1\uC704\uB9CC. \uC608: ["\uC7AC\uBB34\xB7\uD68C\uACC4\xB7\uC138\uBB34>\uD68C\uACC4\uACB0\uC0B0"]'),
126573
126589
  saramin_categories: external_exports.array(external_exports.string().regex(/^\d+$/)).max(20).optional().describe('\uC0AC\uB78C\uC778 \uC9C1\uBB34 \uCF54\uB4DC(find_job_categories \uACB0\uACFC). \uC608: ["322"]'),
126574
- posted_since: external_exports.string().refine((s) => s === "today" || isYmd(s), '"today" \uB610\uB294 \uC2E4\uC81C \uB0A0\uC9DC(YYYY-MM-DD)').optional().describe('"today" \uB610\uB294 YYYY-MM-DD. \uADF8\uB0A0 \uC774\uD6C4 \uACF5\uACE0\uB9CC')
126590
+ posted_since: external_exports.string().refine((s) => s === "today" || isYmd(s), '"today" \uB610\uB294 \uC2E4\uC81C \uB0A0\uC9DC(YYYY-MM-DD)').optional().describe('"today" \uB610\uB294 YYYY-MM-DD. \uADF8\uB0A0 \uC774\uD6C4 \uACF5\uACE0\uB9CC'),
126591
+ years: external_exports.number().int().min(0).max(40).optional().describe('\uC0AC\uC6A9\uC790 \uACBD\uB825 \uC5F0\uCC28(\uC2E0\uC785 0). \uACF5\uACE0\uC758 \uACBD\uB825 \uBC94\uC704\uC5D0 \uC774 \uC5F0\uCC28\uAC00 \uB4E4\uC5B4\uAC00\uB294 \uAC83\uB9CC \uB0A8\uAE40. "5\uB144\uCC28"\uBA74 5. \uC0AC\uC6A9\uC790\uAC00 \uB9D0\uD558\uC9C0 \uC54A\uC558\uC73C\uBA74 \uD504\uB85C\uD544 \uACBD\uB825\uC73C\uB85C \uACC4\uC0B0\uD574 \uB123\uB418 \uD655\uC778. \uACBD\uB825 \uD45C\uAE30\uB97C \uC77D\uC744 \uC218 \uC5C6\uB294 \uACF5\uACE0\uB294 \uB0A8\uAE40')
126575
126592
  },
126576
126593
  // 공고를 가져오기만 한다. 처음 본 날짜 기록은 사용자 데이터가 아닌 로컬 캐시라 readOnly로 둔다.
126577
126594
  readOnly: true,
@@ -126597,18 +126614,19 @@ ${retro_prompt}` : "");
126597
126614
  rememberCategories,
126598
126615
  saraminCategories,
126599
126616
  postedSince: args.posted_since === "today" ? today : args.posted_since,
126617
+ years: args.years,
126600
126618
  today
126601
126619
  });
126602
126620
  const failed = res.providers.filter((p) => !p.ok);
126603
126621
  const okCount = res.results.length;
126604
126622
  const parts = [`'${res.query || "\uC9C1\uBB34 \uC870\uAC74"}' \uAC80\uC0C9 \uACB0\uACFC ${okCount}\uAC74${usedSaved ? "(\uC800\uC7A5\uB41C \uAC80\uC0C9 \uC870\uAC74 \uC0AC\uC6A9)" : ""}`];
126605
- if (res.filtered_out) parts.push(`(\uAC8C\uC2DC\uC77C \uAE30\uC900 ${res.filtered_out}\uAC74 \uC81C\uC678)`);
126623
+ if (res.filtered_out) parts.push(`(\uAC8C\uC2DC\uC77C\xB7\uACBD\uB825 \uC870\uAC74\uC73C\uB85C ${res.filtered_out}\uAC74 \uC81C\uC678)`);
126606
126624
  if (failed.length) parts.push(`(\uC77C\uBD80 \uC0AC\uC774\uD2B8 \uC751\uB2F5 \uC2E4\uD328: ${failed.map((f) => `${f.source}: ${f.error ?? f.status}`).join("; ")})`);
126607
126625
  if (okCount === 0) parts.push("- \uD0A4\uC6CC\uB4DC\uB098 \uC9C1\uBB34 \uC870\uAC74\uC744 \uBC14\uAFB8\uAC70\uB098, \uACF5\uACE0 URL/\uBCF8\uBB38\uC744 \uC8FC\uC2DC\uBA74 \uC9C1\uC811 \uC800\uC7A5\uD574 \uB4DC\uB9B4\uAC8C\uC694.");
126608
126626
  return ok(parts.join(" "), {
126609
126627
  ...res,
126610
126628
  used_saved_preferences: usedSaved,
126611
- conditions: { keyword, remember_categories: rememberCategories, saramin_categories: saraminCategories }
126629
+ conditions: { keyword, remember_categories: rememberCategories, saramin_categories: saraminCategories, years: args.years }
126612
126630
  });
126613
126631
  }
126614
126632
  },
package/dist/web.mjs CHANGED
@@ -55283,7 +55283,7 @@ var ApplicationContextSchema = external_exports.object({
55283
55283
  });
55284
55284
 
55285
55285
  // packages/shared/src/build-info.ts
55286
- var APP_VERSION = true ? "0.0.12" : "0.0.0-dev";
55286
+ var APP_VERSION = true ? "0.0.14" : "0.0.0-dev";
55287
55287
  var BUNDLED = true;
55288
55288
 
55289
55289
  // packages/shared/src/index.ts
@@ -57491,7 +57491,7 @@ var wanted = {
57491
57491
  const url2 = `https://www.wanted.co.kr/api/v4/jobs?country=kr&locations=all&years=-1&job_sort=job.latest_order&limit=${limit}&offset=0&query=${encodeURIComponent(query)}`;
57492
57492
  const j = wantedSchema.parse(await getJson(url2, timeoutMs));
57493
57493
  return j.data.map((r) => {
57494
- const annual = r.annual_from || r.annual_to ? `${r.annual_from ?? 0}~${r.annual_to ?? "?"}\uB144` : void 0;
57494
+ const annual = r.annual_from || r.annual_to ? r.annual_to == null || r.annual_to >= 100 ? `${r.annual_from ?? 0}\uB144 \uC774\uC0C1` : `${r.annual_from ?? 0}~${r.annual_to}\uB144` : void 0;
57495
57495
  return {
57496
57496
  source: "wanted",
57497
57497
  company: r.company.name,
@@ -57511,7 +57511,7 @@ var jumpit = {
57511
57511
  async search(query, limit, timeoutMs) {
57512
57512
  const positions = [];
57513
57513
  for (let page = 1; positions.length < limit && page <= Math.ceil(limit / 16); page++) {
57514
- const url2 = `https://jumpit-api.saramin.co.kr/api/positions?sort=rsp_rate&page=${page}&keyword=${encodeURIComponent(query)}`;
57514
+ const url2 = `https://jumpit-api.saramin.co.kr/api/positions?sort=relation&page=${page}&keyword=${encodeURIComponent(query)}`;
57515
57515
  const got = jumpitSchema.parse(await getJson(url2, timeoutMs)).result.positions;
57516
57516
  positions.push(...got);
57517
57517
  if (got.length < 16) break;
package/docs/MCP_TOOLS.md CHANGED
@@ -141,7 +141,7 @@ AI 클라이언트가 연결되면 이 도구를 가장 먼저 호출해 상태
141
141
 
142
142
  | 도구 | 목적 | 주요 입력 | 반환 | 언제 호출 | 읽기전용 |
143
143
  | --- | --- | --- | --- | --- | --- |
144
- | `search_jobs` | 키워드나 직무 조건으로 무인증 공개 잡보드(원티드·점핏·리멤버·사람인)에서 공고를 *가져옴*(저장은 안 함). 여러 사이트의 같은 공고는 한 줄로 합침 | `keyword?`, `sources?`, `limit?`(사이트별, 기본 10, posted_since가 있으면 50, 최대 100), `remember_categories?`(["재무·회계·세무>회계결산"]), `saramin_categories?`(["322"]), `posted_since?`("today"·YYYY-MM-DD). keyword와 직무 조건을 모두 비우면 저장된 검색 조건 사용 | `{ query, results[](회사·직무·url·지역·경력·기술·마감·posted_at·first_seen_at·also_on, 사람인은 category_codes), providers[], filtered_out?, conditions, used_saved_preferences }` | "○○ 공고 찾아줘", "오늘 올라온 공고 보여줘". 결과를 보여주고 **고른 것만** save_job_posting. 원티드·점핏은 게시일이 없어 처음 본 날로 거름 | ✅ |
144
+ | `search_jobs` | 키워드나 직무 조건으로 무인증 공개 잡보드(원티드·점핏·리멤버·사람인)에서 공고를 *가져옴*(저장은 안 함). 여러 사이트의 같은 공고는 한 줄로 합침 | `keyword?`, `sources?`, `limit?`(사이트별, 기본 10, posted_since가 있으면 50, 최대 100), `remember_categories?`(["재무·회계·세무>회계결산"]), `saramin_categories?`(["322"]), `posted_since?`("today"·YYYY-MM-DD), `years?`(사용자 경력 연차, 공고 경력 범위에 들어가는 것만). keyword와 직무 조건을 모두 비우면 저장된 검색 조건 사용 | `{ query, results[](회사·직무·url·지역·경력·기술·마감·posted_at·first_seen_at·also_on, 사람인은 category_codes), providers[], filtered_out?, conditions, used_saved_preferences }` | "○○ 공고 찾아줘", "오늘 올라온 공고 보여줘". 결과를 보여주고 **고른 것만** save_job_posting. 원티드·점핏은 게시일이 없어 처음 본 날로 거름 | ✅ |
145
145
  | `find_job_categories` | 직무 이름을 리멤버 분류("상위>하위")와 사람인 직무 코드로 변환(내장 분류표, 외부 통신 없음). 모든 직군 | `query`(쉼표로 여러 개), `limit?`(기본 20) | `{ query, remember[], saramin[](code·name·group·kind) }` 관련도순 | search_jobs·set_job_search_preferences에 직무 조건을 넣기 전 | ✅ |
146
146
  | `get_job_search_preferences` | 저장된 공고 검색 조건 조회 | 없음 | `{ keyword, remember_categories, saramin_categories, updated_at }` | "내 검색 조건 뭐야?" | ✅ |
147
147
  | `set_job_search_preferences` | 자주 볼 공고 검색 조건 저장(준 필드만 갱신). 리멤버 분류는 내장 분류표로 검증 | `keyword?`, `remember_categories?`, `saramin_categories?` | 저장된 조건 | 사용자 직무를 find_job_categories로 찾고 확인받은 뒤 | ✏️ |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "careermate",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "type": "module",
5
5
  "description": "CareerMate — an MCP-first, local-first career workspace. Your career database and documents stay on your machine; optional discovery and update features may use the internet without sending stored career data.",
6
6
  "license": "MIT",
@@ -50,7 +50,7 @@ export interface JobSearchResponse {
50
50
  query: string;
51
51
  results: JobSearchResult[];
52
52
  providers: ProviderOutcome[];
53
- /** Results dropped by `postedSince`. */
53
+ /** Results dropped by `postedSince` and `years`. */
54
54
  filtered_out?: number;
55
55
  }
56
56
 
@@ -64,6 +64,8 @@ export interface JobSearchOptions {
64
64
  saraminCategories?: string[];
65
65
  /** Keep only postings dated on/after this day (YYYY-MM-DD): posted_at, else first_seen_at. */
66
66
  postedSince?: string;
67
+ /** User's years of experience: keep postings whose range covers it (unknown ranges stay). */
68
+ years?: number;
67
69
  /** Record first-seen dates in the local DB (default true; tests turn it off). */
68
70
  trackSeen?: boolean;
69
71
  /** "Today" for first-seen/postedSince, injectable for tests. */
@@ -170,9 +172,12 @@ const wanted: Provider = {
170
172
  `&job_sort=job.latest_order&limit=${limit}&offset=0&query=${encodeURIComponent(query)}`;
171
173
  const j = wantedSchema.parse(await getJson(url, timeoutMs));
172
174
  return j.data.map((r) => {
175
+ // Wanted uses 100 for "no upper bound".
173
176
  const annual =
174
177
  r.annual_from || r.annual_to
175
- ? `${r.annual_from ?? 0}~${r.annual_to ?? '?'}년`
178
+ ? r.annual_to == null || r.annual_to >= 100
179
+ ? `${r.annual_from ?? 0}년 이상`
180
+ : `${r.annual_from ?? 0}~${r.annual_to}년`
176
181
  : undefined;
177
182
  return {
178
183
  source: 'wanted',
@@ -196,7 +201,7 @@ const jumpit: Provider = {
196
201
  const positions: z.infer<typeof jumpitSchema>['result']['positions'] = [];
197
202
  for (let page = 1; positions.length < limit && page <= Math.ceil(limit / 16); page++) {
198
203
  const url =
199
- `https://jumpit-api.saramin.co.kr/api/positions?sort=rsp_rate&page=${page}&keyword=${encodeURIComponent(query)}`;
204
+ `https://jumpit-api.saramin.co.kr/api/positions?sort=relation&page=${page}&keyword=${encodeURIComponent(query)}`;
200
205
  const got = jumpitSchema.parse(await getJson(url, timeoutMs)).result.positions;
201
206
  positions.push(...got);
202
207
  if (got.length < 16) break;
@@ -447,8 +452,13 @@ export async function searchJobs(query: string, opts: JobSearchOptions = {}): Pr
447
452
  }
448
453
  results = dedupeAcrossBoards(results);
449
454
  let filtered_out: number | undefined;
450
- if (opts.postedSince) {
451
- const kept = results.filter((r) => (r.posted_at ?? r.first_seen_at ?? today) >= opts.postedSince!);
455
+ const years = opts.years;
456
+ if (opts.postedSince || years != null) {
457
+ const kept = results.filter((r) => {
458
+ if (opts.postedSince && (r.posted_at ?? r.first_seen_at ?? today) < opts.postedSince) return false;
459
+ const range = years != null ? experienceRange(r.experience) : null;
460
+ return !range || (range[0] <= years! && years! <= range[1]);
461
+ });
452
462
  filtered_out = results.length - kept.length;
453
463
  results = kept;
454
464
  }
@@ -461,8 +471,25 @@ export async function searchJobs(query: string, opts: JobSearchOptions = {}): Pr
461
471
  };
462
472
  }
463
473
 
474
+ /**
475
+ * Years a posting accepts, from each board's wording: "3~10년", "경력 3~10년", "5년 이상",
476
+ * "경력5년↑", "~3년", "신입", "신입·경력", "경력무관". Unreadable text gives null.
477
+ */
478
+ export function experienceRange(text: string | undefined): [number, number] | null {
479
+ const t = (text ?? '').replace(/\s/g, '');
480
+ if (!t) return null;
481
+ if (/무관/.test(t)) return [0, Infinity];
482
+ const m = t.match(/(\d+)?~(\d+)년/);
483
+ if (m) return [m[1] ? +m[1] : 0, +m[2]];
484
+ const min = t.match(/(\d+)년(이상|↑)/);
485
+ if (min) return [/신입/.test(t) ? 0 : +min[1], Infinity];
486
+ if (/신입/.test(t)) return /경력/.test(t) ? [0, Infinity] : [0, 0];
487
+ return null;
488
+ }
489
+
490
+ // Any "(...)" goes: "(주)" and brand notes like "힐링페이퍼(강남언니)" vs "(주)힐링페이퍼".
464
491
  const normCompany = (s: string) =>
465
- s.replace(/\(주\)|㈜|주식회사|\(유\)|\(재\)/g, '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
492
+ s.replace(/\([^)]*\)|㈜|주식회사/g, '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
466
493
  /** Drop only a "[company]" tag (Remember prefixes titles with it); "[신입]", "[서울]" and the like stay. */
467
494
  const normTitle = (title: string, company: string) => {
468
495
  const c = normCompany(company);
@@ -1110,6 +1110,13 @@ export const TOOLS: ToolDef[] = [
1110
1110
  .refine((s) => s === 'today' || isYmd(s), '"today" 또는 실제 날짜(YYYY-MM-DD)')
1111
1111
  .optional()
1112
1112
  .describe('"today" 또는 YYYY-MM-DD. 그날 이후 공고만'),
1113
+ years: z
1114
+ .number()
1115
+ .int()
1116
+ .min(0)
1117
+ .max(40)
1118
+ .optional()
1119
+ .describe('사용자 경력 연차(신입 0). 공고의 경력 범위에 이 연차가 들어가는 것만 남김. "5년차"면 5. 사용자가 말하지 않았으면 프로필 경력으로 계산해 넣되 확인. 경력 표기를 읽을 수 없는 공고는 남김'),
1113
1120
  },
1114
1121
  // 공고를 가져오기만 한다. 처음 본 날짜 기록은 사용자 데이터가 아닌 로컬 캐시라 readOnly로 둔다.
1115
1122
  readOnly: true,
@@ -1135,18 +1142,19 @@ export const TOOLS: ToolDef[] = [
1135
1142
  rememberCategories,
1136
1143
  saraminCategories,
1137
1144
  postedSince: args.posted_since === 'today' ? today : args.posted_since,
1145
+ years: args.years,
1138
1146
  today,
1139
1147
  });
1140
1148
  const failed = res.providers.filter((p) => !p.ok);
1141
1149
  const okCount = res.results.length;
1142
1150
  const parts = [`'${res.query || '직무 조건'}' 검색 결과 ${okCount}건${usedSaved ? '(저장된 검색 조건 사용)' : ''}`];
1143
- if (res.filtered_out) parts.push(`(게시일 기준 ${res.filtered_out}건 제외)`);
1151
+ if (res.filtered_out) parts.push(`(게시일·경력 조건으로 ${res.filtered_out}건 제외)`);
1144
1152
  if (failed.length) parts.push(`(일부 사이트 응답 실패: ${failed.map((f) => `${f.source}: ${f.error ?? f.status}`).join('; ')})`);
1145
1153
  if (okCount === 0) parts.push('- 키워드나 직무 조건을 바꾸거나, 공고 URL/본문을 주시면 직접 저장해 드릴게요.');
1146
1154
  return ok(parts.join(' '), {
1147
1155
  ...res,
1148
1156
  used_saved_preferences: usedSaved,
1149
- conditions: { keyword, remember_categories: rememberCategories, saramin_categories: saraminCategories },
1157
+ conditions: { keyword, remember_categories: rememberCategories, saramin_categories: saraminCategories, years: args.years },
1150
1158
  });
1151
1159
  },
1152
1160
  },