careermate 0.1.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 (124) hide show
  1. package/README.md +256 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/apps/mcp/src/index.ts +66 -0
  4. package/apps/web/DESIGN_GUIDE.md +105 -0
  5. package/apps/web/UI_CONTRACT.md +44 -0
  6. package/apps/web/public/app.js +118 -0
  7. package/apps/web/public/fonts/PretendardVariable.woff2 +0 -0
  8. package/apps/web/public/index.html +41 -0
  9. package/apps/web/public/lib.js +282 -0
  10. package/apps/web/public/pages/applications.js +98 -0
  11. package/apps/web/public/pages/documents.js +446 -0
  12. package/apps/web/public/pages/home.js +263 -0
  13. package/apps/web/public/pages/interview.js +230 -0
  14. package/apps/web/public/pages/jobs.js +494 -0
  15. package/apps/web/public/pages/profile.js +576 -0
  16. package/apps/web/public/pages/settings.js +233 -0
  17. package/apps/web/public/styles.css +426 -0
  18. package/apps/web/src/exports.ts +68 -0
  19. package/apps/web/src/http.ts +180 -0
  20. package/apps/web/src/index.ts +49 -0
  21. package/apps/web/src/info.ts +50 -0
  22. package/apps/web/src/routes.ts +350 -0
  23. package/apps/web/src/security.ts +102 -0
  24. package/apps/web/src/server.ts +141 -0
  25. package/apps/web/src/settings.ts +88 -0
  26. package/bin/careermate.mjs +74 -0
  27. package/dist/careermate.mcpb +0 -0
  28. package/dist/install-page/index.html +474 -0
  29. package/dist/install-page/style.css +391 -0
  30. package/dist/install-page/vercel.json +20 -0
  31. package/dist/mcp-smoke.err +3 -0
  32. package/dist/mcp.mjs +23704 -0
  33. package/dist/mcpb-stage/README.md +219 -0
  34. package/dist/mcpb-stage/dist/install-page/index.html +434 -0
  35. package/dist/mcpb-stage/dist/install-page/style.css +407 -0
  36. package/dist/mcpb-stage/dist/install-page/vercel.json +20 -0
  37. package/dist/mcpb-stage/dist/mcp.mjs +23704 -0
  38. package/dist/mcpb-stage/dist/public/app.js +118 -0
  39. package/dist/mcpb-stage/dist/public/fonts/PretendardVariable.woff2 +0 -0
  40. package/dist/mcpb-stage/dist/public/index.html +41 -0
  41. package/dist/mcpb-stage/dist/public/lib.js +282 -0
  42. package/dist/mcpb-stage/dist/public/pages/applications.js +98 -0
  43. package/dist/mcpb-stage/dist/public/pages/documents.js +446 -0
  44. package/dist/mcpb-stage/dist/public/pages/home.js +263 -0
  45. package/dist/mcpb-stage/dist/public/pages/interview.js +230 -0
  46. package/dist/mcpb-stage/dist/public/pages/jobs.js +494 -0
  47. package/dist/mcpb-stage/dist/public/pages/profile.js +576 -0
  48. package/dist/mcpb-stage/dist/public/pages/settings.js +233 -0
  49. package/dist/mcpb-stage/dist/public/styles.css +420 -0
  50. package/dist/mcpb-stage/dist/web.mjs +7240 -0
  51. package/dist/mcpb-stage/manifest.json +40 -0
  52. package/dist/public/app.js +118 -0
  53. package/dist/public/fonts/PretendardVariable.woff2 +0 -0
  54. package/dist/public/index.html +41 -0
  55. package/dist/public/lib.js +282 -0
  56. package/dist/public/pages/applications.js +98 -0
  57. package/dist/public/pages/documents.js +446 -0
  58. package/dist/public/pages/home.js +263 -0
  59. package/dist/public/pages/interview.js +230 -0
  60. package/dist/public/pages/jobs.js +494 -0
  61. package/dist/public/pages/profile.js +576 -0
  62. package/dist/public/pages/settings.js +233 -0
  63. package/dist/public/styles.css +426 -0
  64. package/dist/web.mjs +7240 -0
  65. package/docs/ARCHITECTURE.md +208 -0
  66. package/docs/CHANGES_V1.md +103 -0
  67. package/docs/DATA_MODEL.md +460 -0
  68. package/docs/DECISIONS.md +277 -0
  69. package/docs/DEMO.md +242 -0
  70. package/docs/INSTALL.md +148 -0
  71. package/docs/INSTALL_AND_USAGE.md +99 -0
  72. package/docs/MCP_TOOLS.md +233 -0
  73. package/docs/ROADMAP.md +134 -0
  74. package/docs/START_WORKFLOW.md +125 -0
  75. package/docs/SUPPORTED_AI_APPS.md +60 -0
  76. package/docs/TODO.md +57 -0
  77. package/docs/UX_NOTES.md +247 -0
  78. package/docs/WORKFLOWS.md +200 -0
  79. package/install-page/index.html +474 -0
  80. package/install-page/style.css +391 -0
  81. package/install-page/vercel.json +20 -0
  82. package/package.json +68 -0
  83. package/packages/core/src/context.ts +74 -0
  84. package/packages/core/src/index.ts +8 -0
  85. package/packages/core/src/onboarding.ts +81 -0
  86. package/packages/core/src/services.ts +146 -0
  87. package/packages/core/src/summary.ts +104 -0
  88. package/packages/db/src/connection.ts +46 -0
  89. package/packages/db/src/index.ts +22 -0
  90. package/packages/db/src/paths.ts +41 -0
  91. package/packages/db/src/repositories.ts +828 -0
  92. package/packages/db/src/runtime.ts +58 -0
  93. package/packages/db/src/schema.ts +189 -0
  94. package/packages/exporters/src/html.ts +113 -0
  95. package/packages/exporters/src/index.ts +364 -0
  96. package/packages/exporters/src/markdown.ts +178 -0
  97. package/packages/mcp-tools/src/bridge.ts +83 -0
  98. package/packages/mcp-tools/src/index.ts +8 -0
  99. package/packages/mcp-tools/src/result.ts +49 -0
  100. package/packages/mcp-tools/src/tools.ts +455 -0
  101. package/packages/parsers/src/html.ts +86 -0
  102. package/packages/parsers/src/index.ts +228 -0
  103. package/packages/parsers/src/keywords.ts +151 -0
  104. package/packages/prompts/src/humanize.ts +59 -0
  105. package/packages/prompts/src/index.ts +82 -0
  106. package/packages/prompts/src/install.ts +43 -0
  107. package/packages/prompts/src/onboarding.ts +35 -0
  108. package/packages/prompts/src/system.ts +53 -0
  109. package/packages/shared/src/enums.ts +103 -0
  110. package/packages/shared/src/index.ts +18 -0
  111. package/packages/shared/src/schemas.ts +398 -0
  112. package/packages/workflows/src/definitions.ts +107 -0
  113. package/packages/workflows/src/index.ts +39 -0
  114. package/scripts/build-dist.mjs +62 -0
  115. package/scripts/build-mcpb.mjs +70 -0
  116. package/scripts/doctor.ts +81 -0
  117. package/scripts/init.ts +342 -0
  118. package/scripts/mcp-probe.ts +55 -0
  119. package/scripts/migrate.ts +6 -0
  120. package/scripts/run.mjs +33 -0
  121. package/scripts/seed.ts +129 -0
  122. package/scripts/test.ts +117 -0
  123. package/scripts/ui-smoke.ts +73 -0
  124. package/tsconfig.json +29 -0
@@ -0,0 +1,455 @@
1
+ /**
2
+ * CareerMate MCP tools.
3
+ *
4
+ * Design principle (per product spec): these are NOT thin CRUD wrappers. Each
5
+ * tool maps to a real task an AI assistant performs on the user's behalf, and
6
+ * every description tells the AI *when* and *how* to use it within the larger
7
+ * workflow. The star tool is `get_application_context` — one call returns
8
+ * everything needed to analyze a posting or write a cover letter.
9
+ *
10
+ * No analysis or writing happens here: CareerMate stores data and serves it
11
+ * back. The reasoning is done by the user's ChatGPT/Claude/Gemini.
12
+ */
13
+ import { z } from 'zod';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import {
17
+ ProfileInputSchema,
18
+ JobInputSchema,
19
+ FitAnalysisInputSchema,
20
+ CoverLetterVersionInputSchema,
21
+ InterviewPrepInputSchema,
22
+ APPLICATION_STATUSES,
23
+ APPLICATION_STATUS_LABELS,
24
+ DOCUMENT_KINDS,
25
+ CONTENT_SOURCES,
26
+ } from '@careermate/shared';
27
+ import {
28
+ profileRepo,
29
+ documentRepo,
30
+ coverLetterRepo,
31
+ jobRepo,
32
+ fitRepo,
33
+ applicationRepo,
34
+ interviewRepo,
35
+ getExportsDir,
36
+ getDataDir,
37
+ } from '@careermate/db';
38
+ import {
39
+ getOnboardingStatus,
40
+ getApplicationContext,
41
+ saveProfile,
42
+ addResume,
43
+ saveJobPosting,
44
+ saveFitAnalysis,
45
+ saveCoverLetterVersion,
46
+ updateApplicationStatus,
47
+ saveInterviewPrep,
48
+ listRecentActivity,
49
+ jobWithMeta,
50
+ } from '@careermate/core';
51
+ import { ONBOARDING_PROMPT, HUMANIZE_WRITING_GUIDE } from '@careermate/prompts';
52
+ import { getWorkflow, renderWorkflowMarkdown, WORKFLOWS } from '@careermate/workflows';
53
+ import { coverLetterToMarkdown, coverLetterToHtml } from '@careermate/exporters';
54
+ import { cleanJobPosting } from '@careermate/parsers';
55
+ import { ok, fail, type ToolDef } from './result.ts';
56
+ import { resolveDashboardUrl, openInBrowser } from './bridge.ts';
57
+
58
+ const STATUS_LIST = APPLICATION_STATUSES.map((s) => `${s}(${APPLICATION_STATUS_LABELS[s]})`).join(', ');
59
+
60
+ export const TOOLS: ToolDef[] = [
61
+ /* ------------------------------------------------------------ onboarding */
62
+ {
63
+ name: 'get_onboarding_status',
64
+ title: '온보딩 상태 확인',
65
+ description:
66
+ 'CareerMate에 어떤 정보가 준비되어 있는지(프로필·이력서·경력·스킬·자소서·공고) 확인하고, 사용자가 지금 해야 할 다음 단계를 돌려줍니다. AI 클라이언트가 연결된 직후 가장 먼저 호출하세요. 프로필 완성도(0~100)도 포함됩니다.',
67
+ inputSchema: {},
68
+ readOnly: true,
69
+ handler: () => {
70
+ const s = getOnboardingStatus();
71
+ const lines = s.next_steps.map((t, i) => `${i + 1}. ${t}`).join('\n');
72
+ return ok(
73
+ `온보딩 ${s.completed ? '완료됨' : '진행 필요'} · 프로필 완성도 ${s.profile_completeness}%\n\n다음 단계:\n${lines}`,
74
+ s,
75
+ );
76
+ },
77
+ },
78
+ {
79
+ name: 'start_onboarding',
80
+ title: '온보딩 시작 안내',
81
+ description:
82
+ '가이드형 온보딩을 시작합니다. 현재 상태와 함께, AI가 사용자를 어떻게 안내하면 되는지(프로필 입력 → 이력서/자소서 추가 → 대시보드 열기)에 대한 단계별 지침을 돌려줍니다. 사용자가 "시작하자/설정 도와줘"라고 할 때 사용하세요.',
83
+ inputSchema: {},
84
+ readOnly: true,
85
+ handler: () => {
86
+ const s = getOnboardingStatus();
87
+ return ok(
88
+ `온보딩을 시작합니다. 아래 지침을 따라 사용자를 안내하세요.\n\n${ONBOARDING_PROMPT}`,
89
+ { status: s, guide: ONBOARDING_PROMPT },
90
+ );
91
+ },
92
+ },
93
+
94
+ /* --------------------------------------------------------------- profile */
95
+ {
96
+ name: 'get_profile',
97
+ title: '프로필 조회',
98
+ description: '사용자의 기본 프로필(이름, 한 줄 소개, 요약, 희망 직무/조건, 선호 문체, 강조 포인트, 링크)을 조회합니다.',
99
+ inputSchema: {},
100
+ readOnly: true,
101
+ handler: () => {
102
+ const p = profileRepo.get();
103
+ return p ? ok(`프로필: ${p.name ?? '(이름 없음)'}`, p) : ok('아직 저장된 프로필이 없습니다. save_profile로 먼저 저장하세요.', null);
104
+ },
105
+ },
106
+ {
107
+ name: 'save_profile',
108
+ title: '프로필 저장',
109
+ description:
110
+ '사용자 프로필을 저장합니다. 전달한 필드만 갱신되고 나머지는 유지됩니다(부분 저장 안전). 이력서를 구조화한 뒤 이름/한 줄 소개/희망 직무/선호 문체/강조 포인트 등을 채워 저장하세요. 선호 문체(preferred_tone)와 강조 포인트(emphasis_points)는 이후 자기소개서 작성 품질에 직접 영향을 줍니다.',
111
+ inputSchema: ProfileInputSchema.shape,
112
+ handler: (args) => {
113
+ const p = saveProfile(args);
114
+ return ok(`프로필을 저장했습니다: ${p.name ?? ''}`.trim(), p);
115
+ },
116
+ },
117
+ {
118
+ name: 'update_profile',
119
+ title: '프로필 부분 수정',
120
+ description: '기존 프로필을 유지하면서 일부 필드만 수정합니다. save_profile과 동일하게 동작하며, 한두 개 항목만 바꿀 때 의미를 분명히 하기 위해 사용합니다.',
121
+ inputSchema: ProfileInputSchema.partial().shape,
122
+ handler: (args) => {
123
+ const p = saveProfile(args);
124
+ return ok('프로필을 수정했습니다.', p);
125
+ },
126
+ },
127
+
128
+ /* --------------------------------------------------------------- resumes */
129
+ {
130
+ name: 'add_resume',
131
+ title: '이력서/경력기술서 추가',
132
+ description:
133
+ '이력서, 경력기술서, 포트폴리오 등 문서를 저장합니다. content에는 사용자가 업로드/붙여넣은 텍스트(또는 정리한 Markdown)를 넣으세요. kind 기본값은 resume입니다. is_primary=true로 대표 문서를 지정하면 get_application_context의 primary_resume로 노출됩니다.',
134
+ inputSchema: {
135
+ title: z.string().describe('문서 제목'),
136
+ content: z.string().describe('본문 (Markdown/일반 텍스트)'),
137
+ kind: z.enum(DOCUMENT_KINDS).optional().describe('resume(이력서)/career_description(경력기술서)/portfolio/other'),
138
+ source: z.enum(CONTENT_SOURCES).optional(),
139
+ is_primary: z.boolean().optional().describe('대표 문서 여부'),
140
+ tags: z.array(z.string()).optional(),
141
+ },
142
+ handler: (args) => {
143
+ const doc = addResume(args);
144
+ return ok(`'${doc.title}' 문서를 저장했습니다.`, doc);
145
+ },
146
+ },
147
+ {
148
+ name: 'get_resumes',
149
+ title: '이력서 목록 조회',
150
+ description: '저장된 이력서/경력기술서 등 문서를 조회합니다. kind로 종류를 필터링할 수 있습니다.',
151
+ inputSchema: { kind: z.enum(DOCUMENT_KINDS).optional() },
152
+ readOnly: true,
153
+ handler: (args) => {
154
+ const docs = documentRepo.list(args?.kind);
155
+ return ok(`문서 ${docs.length}건`, docs);
156
+ },
157
+ },
158
+
159
+ /* ---------------------------------------------------------- cover letters */
160
+ {
161
+ name: 'add_cover_letter',
162
+ title: '자기소개서 추가',
163
+ description:
164
+ '기존 자기소개서를 등록합니다. content를 함께 주면 첫 번째 버전(v1)으로 저장됩니다. 사용자가 이미 쓴 자소서를 학습용으로 보관할 때 사용하세요. 새 공고용 자소서를 "생성"해서 저장할 때는 save_cover_letter_version을 사용하는 편이 버전 관리에 좋습니다.',
165
+ inputSchema: {
166
+ title: z.string(),
167
+ content: z.string().optional(),
168
+ job_id: z.string().optional(),
169
+ is_primary: z.boolean().optional(),
170
+ note: z.string().optional(),
171
+ source: z.enum(CONTENT_SOURCES).optional(),
172
+ },
173
+ handler: (args) => {
174
+ if (args.content) {
175
+ const { coverLetter, version } = saveCoverLetterVersion({
176
+ title: args.title,
177
+ job_id: args.job_id,
178
+ content: args.content,
179
+ note: args.note,
180
+ source: args.source ?? 'manual',
181
+ });
182
+ if (args.is_primary) coverLetterRepo.setPrimary(coverLetter.id);
183
+ return ok(`'${coverLetter.title}' 자기소개서를 저장했습니다 (v${version.version_no}).`, coverLetter);
184
+ }
185
+ const cl = coverLetterRepo.create({ title: args.title, job_id: args.job_id ?? null, is_primary: args.is_primary });
186
+ return ok(`'${cl.title}' 자기소개서를 생성했습니다.`, cl);
187
+ },
188
+ },
189
+ {
190
+ name: 'get_cover_letters',
191
+ title: '자기소개서 목록 조회',
192
+ description: '저장된 자기소개서를 조회합니다. job_id를 주면 해당 공고에 연결된 자소서만, 없으면 전체를 돌려줍니다. 각 항목은 현재 버전 내용과 버전 수를 포함합니다.',
193
+ inputSchema: { job_id: z.string().optional() },
194
+ readOnly: true,
195
+ handler: (args) => {
196
+ const list = args?.job_id ? coverLetterRepo.listByJob(args.job_id) : coverLetterRepo.list();
197
+ return ok(`자기소개서 ${list.length}건`, list);
198
+ },
199
+ },
200
+ {
201
+ name: 'save_cover_letter_version',
202
+ title: '자기소개서 버전 저장',
203
+ description:
204
+ '자기소개서의 새 버전을 저장합니다. CareerMate 자소서 작성 워크플로우의 핵심 저장 단계입니다. cover_letter_id를 주면 기존 자소서에 새 버전을 추가하고, 없으면 새 자소서를 만들어 v1로 저장합니다(이때 title 권장). note에 이 버전의 변경 요약(예: "지원동기 보강")을 남기면 사용자가 대시보드에서 버전 히스토리를 이해하기 쉽습니다. job_id로 공고에 연결하면 지원 항목과 자동 연결됩니다.',
205
+ inputSchema: CoverLetterVersionInputSchema.shape,
206
+ handler: (args) => {
207
+ const { coverLetter, version } = saveCoverLetterVersion(args);
208
+ return ok(`'${coverLetter.title}' v${version.version_no}을 저장했습니다.`, { coverLetter, version });
209
+ },
210
+ },
211
+
212
+ /* ------------------------------------------------------------------ jobs */
213
+ {
214
+ name: 'save_job_posting',
215
+ title: '채용공고 저장',
216
+ description:
217
+ '채용공고를 저장합니다(있으면 갱신). company와 position은 필수입니다. 공고 원문은 description에, 핵심 자격요건/우대사항은 requirements 배열에, 핵심 키워드는 keywords에 정리해 넣으면 이후 적합도 분석/자소서 작성에 활용됩니다. 같은 url이면 중복 생성 없이 갱신됩니다. 저장 시 지원(application) 항목이 자동으로 생성됩니다.',
218
+ inputSchema: JobInputSchema.shape,
219
+ handler: (args) => {
220
+ const { job } = saveJobPosting(args);
221
+ return ok(`'${job.company} · ${job.position}' 공고를 저장했습니다. (job_id: ${job.id})`, job);
222
+ },
223
+ },
224
+ {
225
+ name: 'get_job_posting',
226
+ title: '채용공고 상세 조회',
227
+ description: '공고 1건의 상세를 조회합니다. 공고 정보와 함께 적합도 분석, 지원 상태, 연결된 자기소개서, 면접 준비 자료를 한 번에 돌려줍니다.',
228
+ inputSchema: { job_id: z.string() },
229
+ readOnly: true,
230
+ handler: (args) => {
231
+ const job = jobRepo.get(args.job_id);
232
+ if (!job) return fail(`공고를 찾을 수 없습니다: ${args.job_id}`);
233
+ return ok(`${job.company} · ${job.position}`, {
234
+ job: jobWithMeta(job),
235
+ fit: fitRepo.getByJob(job.id),
236
+ application: applicationRepo.getByJob(job.id),
237
+ cover_letters: coverLetterRepo.listByJob(job.id),
238
+ interview: interviewRepo.getByJob(job.id),
239
+ });
240
+ },
241
+ },
242
+ {
243
+ name: 'list_jobs',
244
+ title: '저장된 공고 목록',
245
+ description: '저장된 모든 채용공고를 지원 상태와 적합도 점수와 함께 목록으로 돌려줍니다.',
246
+ inputSchema: {},
247
+ readOnly: true,
248
+ handler: () => {
249
+ const jobs = jobRepo.list().map(jobWithMeta);
250
+ return ok(`공고 ${jobs.length}건`, jobs);
251
+ },
252
+ },
253
+ {
254
+ name: 'parse_job_posting',
255
+ title: '공고 텍스트 정리',
256
+ description:
257
+ '붙여넣은 공고 원문(또는 페이지 텍스트)을 정리해 회사명/직무/마감일 추정과 기술 키워드를 추출합니다. save_job_posting 전에 빠르게 구조화할 때 보조적으로 사용하세요. (분석/판단은 AI가 직접 하고, 이 도구는 텍스트 정리만 합니다.)',
258
+ inputSchema: { raw: z.string().describe('공고 원문 텍스트') },
259
+ readOnly: true,
260
+ handler: (args) => {
261
+ const cleaned = cleanJobPosting(args.raw ?? '');
262
+ return ok('공고 텍스트를 정리했습니다.', cleaned);
263
+ },
264
+ },
265
+
266
+ /* ----------------------------------------------------- application context */
267
+ {
268
+ name: 'get_application_context',
269
+ title: '지원 맥락 한 번에 가져오기 ⭐',
270
+ description:
271
+ '★ 가장 중요한 도구. 공고를 분석하거나 자기소개서를 작성하기 전에 반드시 먼저 호출하세요. 한 번의 호출로 다음을 모두 돌려줍니다: 사용자 프로필, 대표 이력서, 전체 경력, 프로젝트, 기술스택, 기존 자기소개서, 최근 지원 이력, (job_id를 주면) 대상 공고와 이전 적합도 분석, 같은 회사/직무 관련 이전 기록, 그리고 사용자의 선호 문체·강조 포인트. 이 데이터를 근거로 분석/작성하고, 결과는 save_fit_analysis / save_cover_letter_version으로 다시 저장하세요. CareerMate는 데이터를 제공할 뿐, 분석/작성은 당신(AI)이 수행합니다.',
272
+ inputSchema: { job_id: z.string().optional().describe('특정 공고 기준으로 맥락을 모을 때') },
273
+ readOnly: true,
274
+ handler: (args) => {
275
+ const ctx = getApplicationContext({ job_id: args?.job_id });
276
+ const summary = [
277
+ `프로필: ${ctx.profile?.name ?? '미입력'}`,
278
+ `이력서 ${ctx.resumes.length} · 경력 ${ctx.experiences.length} · 프로젝트 ${ctx.projects.length} · 스킬 ${ctx.skills.length} · 자소서 ${ctx.cover_letters.length}`,
279
+ ctx.job ? `대상 공고: ${ctx.job.company} · ${ctx.job.position}` : '대상 공고 없음',
280
+ ctx.writing_preferences.preferred_tone ? `선호 문체: ${ctx.writing_preferences.preferred_tone}` : '',
281
+ '💡 자기소개서를 쓸 때는 get_writing_style_guide로 AI 티 안 나는 글쓰기 규칙을 함께 적용하세요.',
282
+ ].filter(Boolean).join('\n');
283
+ return ok(summary, ctx);
284
+ },
285
+ },
286
+ {
287
+ name: 'save_fit_analysis',
288
+ title: '적합도 분석 결과 저장',
289
+ description:
290
+ '공고와 사용자 정보를 비교해 당신(AI)이 도출한 적합도 분석을 저장합니다. job_id는 필수이며 먼저 save_job_posting으로 공고가 저장되어 있어야 합니다. score(0~100), summary, strengths(강점), gaps(보완 필요), matched/missing_keywords, recommendations(자소서·지원 전략 제안)를 채워 저장하세요. 같은 공고에 다시 저장하면 갱신됩니다.',
291
+ inputSchema: FitAnalysisInputSchema.shape,
292
+ handler: (args) => {
293
+ try {
294
+ const fit = saveFitAnalysis(args);
295
+ return ok(`적합도 분석을 저장했습니다${fit.score != null ? ` (${fit.score}점)` : ''}.`, fit);
296
+ } catch (e) {
297
+ return fail(e instanceof Error ? e.message : '저장 실패');
298
+ }
299
+ },
300
+ },
301
+
302
+ /* ------------------------------------------------- application status flow */
303
+ {
304
+ name: 'update_application_status',
305
+ title: '지원 상태 변경',
306
+ description: `지원 상태를 변경합니다. 가능한 상태: ${STATUS_LIST}. '서류 합격(document_passed)' 이상으로 바뀌면 면접 준비를 제안하는 힌트를 함께 돌려줍니다. note에 변경 사유를 남길 수 있습니다.`,
307
+ inputSchema: {
308
+ job_id: z.string(),
309
+ status: z.enum(APPLICATION_STATUSES),
310
+ note: z.string().optional(),
311
+ },
312
+ handler: (args) => {
313
+ try {
314
+ const res = updateApplicationStatus(args.job_id, args.status, args.note);
315
+ return ok(
316
+ `상태를 '${APPLICATION_STATUS_LABELS[args.status as keyof typeof APPLICATION_STATUS_LABELS]}'(으)로 변경했습니다.${res.hint ? `\n\n💡 ${res.hint}` : ''}`,
317
+ res,
318
+ );
319
+ } catch (e) {
320
+ return fail(e instanceof Error ? e.message : '변경 실패');
321
+ }
322
+ },
323
+ },
324
+
325
+ /* ---------------------------------------------------------- interview prep */
326
+ {
327
+ name: 'save_interview_prep',
328
+ title: '면접 준비 자료 저장',
329
+ description:
330
+ '예상 면접 질문, 꼬리 질문, STAR 답변 가이드, 1분 자기소개 초안 등 면접 준비 자료를 저장합니다. 보통 지원 상태가 서류 합격으로 바뀐 뒤, 해당 공고/직무/자소서를 근거로 당신(AI)이 생성한 자료를 저장하는 데 사용합니다. job_id는 필수입니다. 같은 공고에 다시 저장하면 갱신됩니다.',
331
+ inputSchema: InterviewPrepInputSchema.shape,
332
+ handler: (args) => {
333
+ try {
334
+ const prep = saveInterviewPrep(args);
335
+ return ok(`면접 준비 자료를 저장했습니다 (질문 ${prep.questions.length}개).`, prep);
336
+ } catch (e) {
337
+ return fail(e instanceof Error ? e.message : '저장 실패');
338
+ }
339
+ },
340
+ },
341
+
342
+ /* --------------------------------------------------------------- exports */
343
+ {
344
+ name: 'export_cover_letter',
345
+ title: '자기소개서 내보내기',
346
+ description:
347
+ '자기소개서를 Markdown 또는 인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로와 본문을 돌려줍니다. 사용자가 "자소서 파일로 받고 싶어"라고 할 때 사용하세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
348
+ inputSchema: {
349
+ cover_letter_id: z.string(),
350
+ format: z.enum(['md', 'html']).optional().describe('기본 md'),
351
+ },
352
+ handler: (args) => {
353
+ const cl = coverLetterRepo.get(args.cover_letter_id, true);
354
+ if (!cl) return fail('자기소개서를 찾을 수 없습니다.');
355
+ const job = cl.job_id ? jobRepo.get(cl.job_id) : null;
356
+ const profile = profileRepo.get();
357
+ const format = args.format ?? 'md';
358
+ const result = format === 'html' ? coverLetterToHtml(cl, { job, profile }) : coverLetterToMarkdown(cl, { job, profile });
359
+ const dir = getExportsDir();
360
+ const filePath = path.join(dir, result.filename);
361
+ try {
362
+ fs.writeFileSync(filePath, result.content, 'utf8');
363
+ } catch (e) {
364
+ return fail(`파일 저장 실패: ${e instanceof Error ? e.message : e}`);
365
+ }
366
+ return ok(`'${cl.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${filePath}`, {
367
+ path: filePath,
368
+ filename: result.filename,
369
+ content: result.content,
370
+ });
371
+ },
372
+ },
373
+
374
+ /* ----------------------------------------------------------- dashboard ui */
375
+ {
376
+ name: 'open_dashboard',
377
+ title: '대시보드 열기',
378
+ description:
379
+ '로컬 CareerMate 대시보드를 사용자의 브라우저에서 엽니다. 서버가 실행 중이 아니면 자동으로 시작합니다. 저장 결과를 사용자가 눈으로 확인하게 하고 싶을 때 호출하세요(예: 적합도 분석/자소서 저장 후).',
380
+ inputSchema: {},
381
+ handler: async () => {
382
+ const { url, running } = await resolveDashboardUrl();
383
+ if (running) openInBrowser(url);
384
+ return ok(
385
+ running ? `대시보드를 열었습니다: ${url}` : `대시보드 주소: ${url} (서버가 아직 실행 중이 아니면 프로젝트 폴더에서 npm start 를 실행하세요).`,
386
+ { url, running },
387
+ );
388
+ },
389
+ },
390
+ {
391
+ name: 'open_application',
392
+ title: '지원/공고 페이지 열기',
393
+ description: '특정 공고의 지원 상세 페이지를 브라우저에서 엽니다. 적합도·자소서·면접 준비를 사용자가 바로 확인하도록 안내할 때 사용하세요.',
394
+ inputSchema: { job_id: z.string() },
395
+ handler: async (args) => {
396
+ const job = jobRepo.get(args.job_id);
397
+ if (!job) return fail('공고를 찾을 수 없습니다.');
398
+ const { url, running } = await resolveDashboardUrl(`/#/jobs/${args.job_id}`);
399
+ if (running) openInBrowser(url);
400
+ return ok(running ? `'${job.company} · ${job.position}' 페이지를 열었습니다: ${url}` : `페이지 주소: ${url}`, { url, running });
401
+ },
402
+ },
403
+
404
+ /* ------------------------------------------------------------- activity */
405
+ {
406
+ name: 'list_recent_activity',
407
+ title: '최근 활동 조회',
408
+ description: '최근 활동 내역(공고 저장, 적합도 분석, 자소서 버전, 상태 변경, 면접 준비 등)을 돌려줍니다. 사용자가 "최근에 뭐 했지?"라고 묻거나, 작업을 이어서 진행할 맥락이 필요할 때 사용하세요.',
409
+ inputSchema: { limit: z.number().optional().describe('기본 20') },
410
+ readOnly: true,
411
+ handler: (args) => {
412
+ const acts = listRecentActivity(args?.limit ?? 20);
413
+ return ok(`최근 활동 ${acts.length}건`, acts);
414
+ },
415
+ },
416
+
417
+ /* ------------------------------------------------------- workflow guide */
418
+ {
419
+ name: 'get_workflow_guide',
420
+ title: '워크플로우 가이드',
421
+ description:
422
+ 'CareerMate의 표준 워크플로우(온보딩, 공고 분석, 자소서 작성, 지원 상태 관리, 면접 준비)의 단계별 안내를 돌려줍니다. 어떤 순서로 어떤 도구를 호출해야 할지 확인할 때 사용하세요. workflow_id를 생략하면 전체 목록을 돌려줍니다.',
423
+ inputSchema: {
424
+ workflow_id: z
425
+ .enum(['onboarding', 'analyze_job', 'write_cover_letter', 'manage_application_status', 'prepare_interview'])
426
+ .optional(),
427
+ },
428
+ readOnly: true,
429
+ handler: (args) => {
430
+ if (args?.workflow_id) {
431
+ const wf = getWorkflow(args.workflow_id);
432
+ if (!wf) return fail('해당 워크플로우를 찾을 수 없습니다.');
433
+ return ok(renderWorkflowMarkdown(args.workflow_id) ?? wf.title, wf);
434
+ }
435
+ return ok(
436
+ `사용 가능한 워크플로우 ${WORKFLOWS.length}개`,
437
+ WORKFLOWS.map((w) => ({ id: w.id, title: w.title, trigger: w.trigger })),
438
+ );
439
+ },
440
+ },
441
+
442
+ /* --------------------------------------------------- writing style guide */
443
+ {
444
+ name: 'get_writing_style_guide',
445
+ title: 'AI 티 안 나는 글쓰기 가이드',
446
+ description:
447
+ '자기소개서·자기 PR·지원 메일 등 사람이 쓴 듯한 글을 작성하기 직전에 호출하세요. "AI가 쓴 티"가 나는 한국어 문장 습관(번역투, 클리셰, 기계적 병렬, 상투적 연결어, 균일한 문장 리듬 등)을 제거하는 작성 규칙과 저장 전 자가 점검 항목을 돌려줍니다. 사실·수치·고유명사는 그대로 두고 문장 결만 다듬도록 안내합니다. write_cover_letter / analyze_job 워크플로우에서 자기소개서를 쓸 때 반드시 함께 적용하세요.',
448
+ inputSchema: {},
449
+ readOnly: true,
450
+ handler: () => ok('AI 티 안 나는 글쓰기 가이드를 적용해 작성하세요.', { guide: HUMANIZE_WRITING_GUIDE }),
451
+ },
452
+ ];
453
+
454
+ /** Where exported files land (surfaced for messaging). */
455
+ export const EXPORTS_LOCATION = (): string => getDataDir();
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @careermate/parsers — HTML → plain text.
3
+ *
4
+ * A tiny, dependency-free HTML stripper. It is not a full parser; it is a
5
+ * best-effort cleaner that removes scripts/styles, converts block boundaries to
6
+ * newlines, decodes common entities, and collapses excess whitespace. It must
7
+ * never throw, even on malformed markup.
8
+ */
9
+
10
+ /** Named entities we bother to decode (the common ones in job postings). */
11
+ const NAMED_ENTITIES: Record<string, string> = {
12
+ amp: '&',
13
+ lt: '<',
14
+ gt: '>',
15
+ quot: '"',
16
+ apos: "'",
17
+ nbsp: ' ',
18
+ middot: '·',
19
+ hellip: '…',
20
+ mdash: '—',
21
+ ndash: '–',
22
+ bull: '•',
23
+ copy: '©',
24
+ reg: '®',
25
+ trade: '™',
26
+ };
27
+
28
+ /** Decode numeric (`&#123;` / `&#x7B;`) and a handful of named entities. */
29
+ export function decodeEntities(input: string): string {
30
+ return input.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, body: string) => {
31
+ if (body[0] === '#') {
32
+ const isHex = body[1] === 'x' || body[1] === 'X';
33
+ const code = parseInt(body.slice(isHex ? 2 : 1), isHex ? 16 : 10);
34
+ if (Number.isFinite(code) && code > 0 && code <= 0x10ffff) {
35
+ try {
36
+ return String.fromCodePoint(code);
37
+ } catch {
38
+ return match;
39
+ }
40
+ }
41
+ return match;
42
+ }
43
+ const named = NAMED_ENTITIES[body.toLowerCase()];
44
+ return named !== undefined ? named : match;
45
+ });
46
+ }
47
+
48
+ /** Block-level tags whose boundaries should become line breaks. */
49
+ const BLOCK_TAG = /<\/?(p|div|section|article|header|footer|li|ul|ol|tr|table|h[1-6]|blockquote|pre)\b[^>]*>/gi;
50
+
51
+ /**
52
+ * Convert an HTML string to readable plain text. Best-effort and total:
53
+ * any weird input simply yields whatever survives the regex passes.
54
+ */
55
+ export function stripHtml(html: string): string {
56
+ if (!html) return '';
57
+ let s = String(html);
58
+
59
+ // Remove script/style/head blocks entirely (including content).
60
+ s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ');
61
+ s = s.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ');
62
+ s = s.replace(/<!--[\s\S]*?-->/g, ' ');
63
+
64
+ // Line breaks and horizontal rules -> newline.
65
+ s = s.replace(/<br\b[^>]*>/gi, '\n');
66
+ s = s.replace(/<hr\b[^>]*>/gi, '\n');
67
+
68
+ // Block tag boundaries -> newline (helps keep list items / paragraphs apart).
69
+ s = s.replace(BLOCK_TAG, '\n');
70
+
71
+ // Strip every remaining tag.
72
+ s = s.replace(/<\/?[^>]+>/g, ' ');
73
+
74
+ // Decode entities after tags are gone.
75
+ s = decodeEntities(s);
76
+
77
+ // Normalize whitespace: collapse spaces, trim lines, cap blank-line runs.
78
+ s = s.replace(/[ \t\f\v]+/g, ' ');
79
+ s = s
80
+ .split('\n')
81
+ .map((line) => line.trim())
82
+ .join('\n');
83
+ s = s.replace(/\n{3,}/g, '\n\n');
84
+
85
+ return s.trim();
86
+ }