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.
- package/README.md +256 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/apps/mcp/src/index.ts +66 -0
- package/apps/web/DESIGN_GUIDE.md +105 -0
- package/apps/web/UI_CONTRACT.md +44 -0
- package/apps/web/public/app.js +118 -0
- package/apps/web/public/fonts/PretendardVariable.woff2 +0 -0
- package/apps/web/public/index.html +41 -0
- package/apps/web/public/lib.js +282 -0
- package/apps/web/public/pages/applications.js +98 -0
- package/apps/web/public/pages/documents.js +446 -0
- package/apps/web/public/pages/home.js +263 -0
- package/apps/web/public/pages/interview.js +230 -0
- package/apps/web/public/pages/jobs.js +494 -0
- package/apps/web/public/pages/profile.js +576 -0
- package/apps/web/public/pages/settings.js +233 -0
- package/apps/web/public/styles.css +426 -0
- package/apps/web/src/exports.ts +68 -0
- package/apps/web/src/http.ts +180 -0
- package/apps/web/src/index.ts +49 -0
- package/apps/web/src/info.ts +50 -0
- package/apps/web/src/routes.ts +350 -0
- package/apps/web/src/security.ts +102 -0
- package/apps/web/src/server.ts +141 -0
- package/apps/web/src/settings.ts +88 -0
- package/bin/careermate.mjs +74 -0
- package/dist/careermate.mcpb +0 -0
- package/dist/install-page/index.html +474 -0
- package/dist/install-page/style.css +391 -0
- package/dist/install-page/vercel.json +20 -0
- package/dist/mcp-smoke.err +3 -0
- package/dist/mcp.mjs +23704 -0
- package/dist/mcpb-stage/README.md +219 -0
- package/dist/mcpb-stage/dist/install-page/index.html +434 -0
- package/dist/mcpb-stage/dist/install-page/style.css +407 -0
- package/dist/mcpb-stage/dist/install-page/vercel.json +20 -0
- package/dist/mcpb-stage/dist/mcp.mjs +23704 -0
- package/dist/mcpb-stage/dist/public/app.js +118 -0
- package/dist/mcpb-stage/dist/public/fonts/PretendardVariable.woff2 +0 -0
- package/dist/mcpb-stage/dist/public/index.html +41 -0
- package/dist/mcpb-stage/dist/public/lib.js +282 -0
- package/dist/mcpb-stage/dist/public/pages/applications.js +98 -0
- package/dist/mcpb-stage/dist/public/pages/documents.js +446 -0
- package/dist/mcpb-stage/dist/public/pages/home.js +263 -0
- package/dist/mcpb-stage/dist/public/pages/interview.js +230 -0
- package/dist/mcpb-stage/dist/public/pages/jobs.js +494 -0
- package/dist/mcpb-stage/dist/public/pages/profile.js +576 -0
- package/dist/mcpb-stage/dist/public/pages/settings.js +233 -0
- package/dist/mcpb-stage/dist/public/styles.css +420 -0
- package/dist/mcpb-stage/dist/web.mjs +7240 -0
- package/dist/mcpb-stage/manifest.json +40 -0
- package/dist/public/app.js +118 -0
- package/dist/public/fonts/PretendardVariable.woff2 +0 -0
- package/dist/public/index.html +41 -0
- package/dist/public/lib.js +282 -0
- package/dist/public/pages/applications.js +98 -0
- package/dist/public/pages/documents.js +446 -0
- package/dist/public/pages/home.js +263 -0
- package/dist/public/pages/interview.js +230 -0
- package/dist/public/pages/jobs.js +494 -0
- package/dist/public/pages/profile.js +576 -0
- package/dist/public/pages/settings.js +233 -0
- package/dist/public/styles.css +426 -0
- package/dist/web.mjs +7240 -0
- package/docs/ARCHITECTURE.md +208 -0
- package/docs/CHANGES_V1.md +103 -0
- package/docs/DATA_MODEL.md +460 -0
- package/docs/DECISIONS.md +277 -0
- package/docs/DEMO.md +242 -0
- package/docs/INSTALL.md +148 -0
- package/docs/INSTALL_AND_USAGE.md +99 -0
- package/docs/MCP_TOOLS.md +233 -0
- package/docs/ROADMAP.md +134 -0
- package/docs/START_WORKFLOW.md +125 -0
- package/docs/SUPPORTED_AI_APPS.md +60 -0
- package/docs/TODO.md +57 -0
- package/docs/UX_NOTES.md +247 -0
- package/docs/WORKFLOWS.md +200 -0
- package/install-page/index.html +474 -0
- package/install-page/style.css +391 -0
- package/install-page/vercel.json +20 -0
- package/package.json +68 -0
- package/packages/core/src/context.ts +74 -0
- package/packages/core/src/index.ts +8 -0
- package/packages/core/src/onboarding.ts +81 -0
- package/packages/core/src/services.ts +146 -0
- package/packages/core/src/summary.ts +104 -0
- package/packages/db/src/connection.ts +46 -0
- package/packages/db/src/index.ts +22 -0
- package/packages/db/src/paths.ts +41 -0
- package/packages/db/src/repositories.ts +828 -0
- package/packages/db/src/runtime.ts +58 -0
- package/packages/db/src/schema.ts +189 -0
- package/packages/exporters/src/html.ts +113 -0
- package/packages/exporters/src/index.ts +364 -0
- package/packages/exporters/src/markdown.ts +178 -0
- package/packages/mcp-tools/src/bridge.ts +83 -0
- package/packages/mcp-tools/src/index.ts +8 -0
- package/packages/mcp-tools/src/result.ts +49 -0
- package/packages/mcp-tools/src/tools.ts +455 -0
- package/packages/parsers/src/html.ts +86 -0
- package/packages/parsers/src/index.ts +228 -0
- package/packages/parsers/src/keywords.ts +151 -0
- package/packages/prompts/src/humanize.ts +59 -0
- package/packages/prompts/src/index.ts +82 -0
- package/packages/prompts/src/install.ts +43 -0
- package/packages/prompts/src/onboarding.ts +35 -0
- package/packages/prompts/src/system.ts +53 -0
- package/packages/shared/src/enums.ts +103 -0
- package/packages/shared/src/index.ts +18 -0
- package/packages/shared/src/schemas.ts +398 -0
- package/packages/workflows/src/definitions.ts +107 -0
- package/packages/workflows/src/index.ts +39 -0
- package/scripts/build-dist.mjs +62 -0
- package/scripts/build-mcpb.mjs +70 -0
- package/scripts/doctor.ts +81 -0
- package/scripts/init.ts +342 -0
- package/scripts/mcp-probe.ts +55 -0
- package/scripts/migrate.ts +6 -0
- package/scripts/run.mjs +33 -0
- package/scripts/seed.ts +129 -0
- package/scripts/test.ts +117 -0
- package/scripts/ui-smoke.ts +73 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @careermate/exporters — turn CareerMate records into downloadable documents.
|
|
3
|
+
*
|
|
4
|
+
* Every exporter returns an {@link ExportResult} (`{ filename, mimeType, content }`).
|
|
5
|
+
* Markdown exporters emit `text/markdown`; HTML exporters emit a standalone,
|
|
6
|
+
* print-optimized document (`text/html`) the user can "Print → Save as PDF".
|
|
7
|
+
*
|
|
8
|
+
* Filenames are slugified and ASCII-fallback safe; they carry NO timestamp —
|
|
9
|
+
* the caller is expected to add one if desired.
|
|
10
|
+
*/
|
|
11
|
+
import type {
|
|
12
|
+
CoverLetterRecord,
|
|
13
|
+
DocumentRecord,
|
|
14
|
+
ExperienceRecord,
|
|
15
|
+
InterviewPrepRecord,
|
|
16
|
+
JobRecord,
|
|
17
|
+
ProfileRecord,
|
|
18
|
+
ProjectRecord,
|
|
19
|
+
SkillRecord,
|
|
20
|
+
} from '@careermate/shared';
|
|
21
|
+
import { DOCUMENT_KIND_LABELS } from '@careermate/shared';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
bulletList,
|
|
25
|
+
dateRange,
|
|
26
|
+
joinBlocks,
|
|
27
|
+
kv,
|
|
28
|
+
slugify,
|
|
29
|
+
} from './markdown.ts';
|
|
30
|
+
import { escapeHtml, toPrintableHtml } from './html.ts';
|
|
31
|
+
|
|
32
|
+
/** The uniform shape every exporter returns. */
|
|
33
|
+
export interface ExportResult {
|
|
34
|
+
/** Safe, slugified base filename including extension (no timestamp). */
|
|
35
|
+
filename: string;
|
|
36
|
+
/** MIME type, e.g. `text/markdown` or `text/html`. */
|
|
37
|
+
mimeType: string;
|
|
38
|
+
/** The document body. */
|
|
39
|
+
content: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ExportOptions = {
|
|
43
|
+
job?: JobRecord | null;
|
|
44
|
+
profile?: ProfileRecord | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const MD_MIME = 'text/markdown';
|
|
48
|
+
const HTML_MIME = 'text/html';
|
|
49
|
+
|
|
50
|
+
/* ------------------------------------------------------------- cover letters */
|
|
51
|
+
|
|
52
|
+
/** Build the shared markdown body for a cover letter (used by MD + HTML). */
|
|
53
|
+
function coverLetterBody(cl: CoverLetterRecord, opts?: ExportOptions): string {
|
|
54
|
+
const job = opts?.job ?? null;
|
|
55
|
+
const profile = opts?.profile ?? null;
|
|
56
|
+
|
|
57
|
+
const meta: string[] = [];
|
|
58
|
+
if (profile?.name) meta.push(kv('지원자', profile.name));
|
|
59
|
+
if (job) meta.push(kv('지원 회사', job.company), kv('지원 직무', job.position));
|
|
60
|
+
|
|
61
|
+
const content = (cl.current_content ?? '').trim() || '_(내용 없음)_';
|
|
62
|
+
|
|
63
|
+
return joinBlocks([
|
|
64
|
+
`# ${cl.title}`,
|
|
65
|
+
meta.length ? meta.filter(Boolean).join('\n\n') : '',
|
|
66
|
+
meta.length ? '---' : '',
|
|
67
|
+
content,
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Cover letter → Markdown. */
|
|
72
|
+
export function coverLetterToMarkdown(
|
|
73
|
+
cl: CoverLetterRecord,
|
|
74
|
+
opts?: ExportOptions,
|
|
75
|
+
): ExportResult {
|
|
76
|
+
return {
|
|
77
|
+
filename: `${slugify(cl.title, 'cover-letter')}.md`,
|
|
78
|
+
mimeType: MD_MIME,
|
|
79
|
+
content: coverLetterBody(cl, opts),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Cover letter → standalone, print-optimized HTML (our PDF strategy). */
|
|
84
|
+
export function coverLetterToHtml(
|
|
85
|
+
cl: CoverLetterRecord,
|
|
86
|
+
opts?: ExportOptions,
|
|
87
|
+
): ExportResult {
|
|
88
|
+
return {
|
|
89
|
+
filename: `${slugify(cl.title, 'cover-letter')}.html`,
|
|
90
|
+
mimeType: HTML_MIME,
|
|
91
|
+
content: toPrintableHtml(cl.title, coverLetterBody(cl, opts)),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* -------------------------------------------------------------------- resume */
|
|
96
|
+
|
|
97
|
+
/** A stored resume/career document → Markdown. */
|
|
98
|
+
export function resumeToMarkdown(
|
|
99
|
+
doc: DocumentRecord,
|
|
100
|
+
profile?: ProfileRecord | null,
|
|
101
|
+
): ExportResult {
|
|
102
|
+
const kindLabel = DOCUMENT_KIND_LABELS[doc.kind] ?? doc.kind;
|
|
103
|
+
|
|
104
|
+
const header: string[] = [];
|
|
105
|
+
if (profile?.name) header.push(kv('이름', profile.name));
|
|
106
|
+
if (profile?.email) header.push(kv('이메일', profile.email));
|
|
107
|
+
if (profile?.phone) header.push(kv('연락처', profile.phone));
|
|
108
|
+
|
|
109
|
+
const body = joinBlocks([
|
|
110
|
+
`# ${doc.title}`,
|
|
111
|
+
`_${kindLabel}_`,
|
|
112
|
+
header.length ? header.filter(Boolean).join('\n\n') : '',
|
|
113
|
+
header.length ? '---' : '',
|
|
114
|
+
(doc.content ?? '').trim() || '_(내용 없음)_',
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
filename: `${slugify(doc.title, doc.kind)}.md`,
|
|
119
|
+
mimeType: MD_MIME,
|
|
120
|
+
content: body,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A stored resume/career document → print-optimized HTML. */
|
|
125
|
+
export function resumeToHtml(
|
|
126
|
+
doc: DocumentRecord,
|
|
127
|
+
profile?: ProfileRecord | null,
|
|
128
|
+
): ExportResult {
|
|
129
|
+
const md = resumeToMarkdown(doc, profile);
|
|
130
|
+
return {
|
|
131
|
+
filename: `${slugify(doc.title, doc.kind)}.html`,
|
|
132
|
+
mimeType: HTML_MIME,
|
|
133
|
+
content: toPrintableHtml(doc.title, md.content),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* ------------------------------------------------------------------- profile */
|
|
138
|
+
|
|
139
|
+
/** Full resume-style profile export → Markdown. */
|
|
140
|
+
export function profileToMarkdown(
|
|
141
|
+
profile: ProfileRecord,
|
|
142
|
+
experiences: ExperienceRecord[] = [],
|
|
143
|
+
projects: ProjectRecord[] = [],
|
|
144
|
+
skills: SkillRecord[] = [],
|
|
145
|
+
): ExportResult {
|
|
146
|
+
const name = profile.name?.trim() || '이력서';
|
|
147
|
+
|
|
148
|
+
// Header block.
|
|
149
|
+
const contact: string[] = [];
|
|
150
|
+
if (profile.email) contact.push(profile.email);
|
|
151
|
+
if (profile.phone) contact.push(profile.phone);
|
|
152
|
+
if (profile.location) contact.push(profile.location);
|
|
153
|
+
const links = (profile.links ?? [])
|
|
154
|
+
.filter((l) => l.url)
|
|
155
|
+
.map((l) => `[${l.label || l.url}](${l.url})`);
|
|
156
|
+
|
|
157
|
+
const headerBlocks = joinBlocks([
|
|
158
|
+
`# ${name}`,
|
|
159
|
+
profile.headline ? `## ${profile.headline}` : '',
|
|
160
|
+
contact.length ? contact.join(' · ') : '',
|
|
161
|
+
links.length ? links.join(' · ') : '',
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
// Summary.
|
|
165
|
+
const summaryBlock = profile.summary?.trim()
|
|
166
|
+
? joinBlocks(['## 소개', profile.summary.trim()])
|
|
167
|
+
: '';
|
|
168
|
+
|
|
169
|
+
// Desired roles / conditions.
|
|
170
|
+
const desiredLines: string[] = [];
|
|
171
|
+
if (profile.desired_roles?.length) {
|
|
172
|
+
desiredLines.push(kv('희망 직무', profile.desired_roles.join(', ')));
|
|
173
|
+
}
|
|
174
|
+
if (profile.desired_conditions?.trim()) {
|
|
175
|
+
desiredLines.push(kv('희망 조건', profile.desired_conditions));
|
|
176
|
+
}
|
|
177
|
+
const desiredBlock = desiredLines.length
|
|
178
|
+
? joinBlocks(['## 희망 사항', desiredLines.filter(Boolean).join('\n\n')])
|
|
179
|
+
: '';
|
|
180
|
+
|
|
181
|
+
// Experience.
|
|
182
|
+
const expBlocks = experiences.length
|
|
183
|
+
? joinBlocks([
|
|
184
|
+
'## 경력',
|
|
185
|
+
...experiences.map((e) => {
|
|
186
|
+
const range = dateRange(e.start_date, e.end_date, e.is_current);
|
|
187
|
+
const titleParts = [e.company, e.role].filter(Boolean).join(' — ');
|
|
188
|
+
const subParts = [e.employment_type, range].filter(Boolean).join(' · ');
|
|
189
|
+
return joinBlocks([
|
|
190
|
+
`### ${titleParts}`,
|
|
191
|
+
subParts ? `_${subParts}_` : '',
|
|
192
|
+
e.description?.trim() ? e.description.trim() : '',
|
|
193
|
+
e.achievements?.length ? bulletList(e.achievements) : '',
|
|
194
|
+
e.tech?.length ? kv('기술', e.tech.join(', ')) : '',
|
|
195
|
+
]);
|
|
196
|
+
}),
|
|
197
|
+
])
|
|
198
|
+
: '';
|
|
199
|
+
|
|
200
|
+
// Projects.
|
|
201
|
+
const projBlocks = projects.length
|
|
202
|
+
? joinBlocks([
|
|
203
|
+
'## 프로젝트',
|
|
204
|
+
...projects.map((p) => {
|
|
205
|
+
const range = dateRange(p.start_date, p.end_date);
|
|
206
|
+
const subParts = [p.role, range].filter(Boolean).join(' · ');
|
|
207
|
+
return joinBlocks([
|
|
208
|
+
`### ${p.name}`,
|
|
209
|
+
subParts ? `_${subParts}_` : '',
|
|
210
|
+
p.description?.trim() ? p.description.trim() : '',
|
|
211
|
+
p.highlights?.length ? bulletList(p.highlights) : '',
|
|
212
|
+
p.tech?.length ? kv('기술', p.tech.join(', ')) : '',
|
|
213
|
+
p.url ? kv('링크', p.url) : '',
|
|
214
|
+
]);
|
|
215
|
+
}),
|
|
216
|
+
])
|
|
217
|
+
: '';
|
|
218
|
+
|
|
219
|
+
// Skills — grouped by category when present.
|
|
220
|
+
let skillsBlock = '';
|
|
221
|
+
if (skills.length) {
|
|
222
|
+
const groups = new Map<string, string[]>();
|
|
223
|
+
for (const s of skills) {
|
|
224
|
+
const cat = s.category?.trim() || '기타';
|
|
225
|
+
const label = s.level?.trim() ? `${s.name} (${s.level.trim()})` : s.name;
|
|
226
|
+
if (!groups.has(cat)) groups.set(cat, []);
|
|
227
|
+
groups.get(cat)!.push(label);
|
|
228
|
+
}
|
|
229
|
+
const lines = [...groups.entries()].map(
|
|
230
|
+
([cat, items]) => kv(cat, items.join(', ')),
|
|
231
|
+
);
|
|
232
|
+
skillsBlock = joinBlocks(['## 보유 기술', lines.filter(Boolean).join('\n\n')]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const content = joinBlocks([
|
|
236
|
+
headerBlocks,
|
|
237
|
+
'---',
|
|
238
|
+
summaryBlock,
|
|
239
|
+
desiredBlock,
|
|
240
|
+
expBlocks,
|
|
241
|
+
projBlocks,
|
|
242
|
+
skillsBlock,
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
filename: `${slugify(name, 'resume')}-resume.md`,
|
|
247
|
+
mimeType: MD_MIME,
|
|
248
|
+
content,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Full resume-style profile export → print-optimized HTML. */
|
|
253
|
+
export function profileToHtml(
|
|
254
|
+
profile: ProfileRecord,
|
|
255
|
+
experiences: ExperienceRecord[] = [],
|
|
256
|
+
projects: ProjectRecord[] = [],
|
|
257
|
+
skills: SkillRecord[] = [],
|
|
258
|
+
): ExportResult {
|
|
259
|
+
const md = profileToMarkdown(profile, experiences, projects, skills);
|
|
260
|
+
const title = profile.name?.trim() || '이력서';
|
|
261
|
+
return {
|
|
262
|
+
filename: `${slugify(title, 'resume')}-resume.html`,
|
|
263
|
+
mimeType: HTML_MIME,
|
|
264
|
+
content: toPrintableHtml(title, md.content),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* ------------------------------------------------------------ interview prep */
|
|
269
|
+
|
|
270
|
+
/** Interview prep record → Markdown study sheet. */
|
|
271
|
+
export function interviewPrepToMarkdown(
|
|
272
|
+
prep: InterviewPrepRecord,
|
|
273
|
+
job?: JobRecord | null,
|
|
274
|
+
): ExportResult {
|
|
275
|
+
const titleBase = job ? `${job.company} ${job.position} 면접 준비` : '면접 준비';
|
|
276
|
+
|
|
277
|
+
const meta: string[] = [];
|
|
278
|
+
if (job) {
|
|
279
|
+
meta.push(kv('회사', job.company), kv('직무', job.position));
|
|
280
|
+
if (job.deadline) meta.push(kv('마감', job.deadline));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const introBlock = prep.self_introduction?.trim()
|
|
284
|
+
? joinBlocks(['## 1분 자기소개', prep.self_introduction.trim()])
|
|
285
|
+
: '';
|
|
286
|
+
|
|
287
|
+
const questionsBlock = prep.questions?.length
|
|
288
|
+
? joinBlocks([
|
|
289
|
+
'## 예상 질문',
|
|
290
|
+
...prep.questions.map((q, idx) =>
|
|
291
|
+
joinBlocks([
|
|
292
|
+
`### Q${idx + 1}. ${q.question}`,
|
|
293
|
+
q.intent?.trim() ? kv('의도', q.intent) : '',
|
|
294
|
+
q.answer_outline?.trim()
|
|
295
|
+
? joinBlocks(['**답변 가이드**', q.answer_outline.trim()])
|
|
296
|
+
: '',
|
|
297
|
+
q.followups?.length
|
|
298
|
+
? joinBlocks(['**예상 꼬리 질문**', bulletList(q.followups)])
|
|
299
|
+
: '',
|
|
300
|
+
]),
|
|
301
|
+
),
|
|
302
|
+
])
|
|
303
|
+
: '';
|
|
304
|
+
|
|
305
|
+
const starBlock = prep.star_guides?.length
|
|
306
|
+
? joinBlocks([
|
|
307
|
+
'## STAR 정리',
|
|
308
|
+
...prep.star_guides.map((s) =>
|
|
309
|
+
joinBlocks([
|
|
310
|
+
`### ${s.question}`,
|
|
311
|
+
kv('Situation', s.situation),
|
|
312
|
+
kv('Task', s.task),
|
|
313
|
+
kv('Action', s.action),
|
|
314
|
+
kv('Result', s.result),
|
|
315
|
+
]),
|
|
316
|
+
),
|
|
317
|
+
])
|
|
318
|
+
: '';
|
|
319
|
+
|
|
320
|
+
const notesBlock = prep.notes?.trim()
|
|
321
|
+
? joinBlocks(['## 메모', prep.notes.trim()])
|
|
322
|
+
: '';
|
|
323
|
+
|
|
324
|
+
const content = joinBlocks([
|
|
325
|
+
`# ${titleBase}`,
|
|
326
|
+
meta.length ? meta.filter(Boolean).join('\n\n') : '',
|
|
327
|
+
meta.length ? '---' : '',
|
|
328
|
+
introBlock,
|
|
329
|
+
questionsBlock,
|
|
330
|
+
starBlock,
|
|
331
|
+
notesBlock,
|
|
332
|
+
]);
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
filename: `${slugify(titleBase, 'interview-prep')}.md`,
|
|
336
|
+
mimeType: MD_MIME,
|
|
337
|
+
content,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Interview prep record → print-optimized HTML. */
|
|
342
|
+
export function interviewPrepToHtml(
|
|
343
|
+
prep: InterviewPrepRecord,
|
|
344
|
+
job?: JobRecord | null,
|
|
345
|
+
): ExportResult {
|
|
346
|
+
const md = interviewPrepToMarkdown(prep, job);
|
|
347
|
+
const title = job ? `${job.company} ${job.position} 면접 준비` : '면접 준비';
|
|
348
|
+
return {
|
|
349
|
+
filename: `${slugify(title, 'interview-prep')}.html`,
|
|
350
|
+
mimeType: HTML_MIME,
|
|
351
|
+
content: toPrintableHtml(title, md.content),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* ----------------------------------------------------------------- re-exports */
|
|
356
|
+
|
|
357
|
+
export {
|
|
358
|
+
escapeHtml,
|
|
359
|
+
toPrintableHtml,
|
|
360
|
+
} from './html.ts';
|
|
361
|
+
export {
|
|
362
|
+
markdownToHtml,
|
|
363
|
+
slugify,
|
|
364
|
+
} from './markdown.ts';
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @careermate/exporters — Markdown rendering helpers.
|
|
3
|
+
*
|
|
4
|
+
* Pure, dependency-free utilities for turning CareerMate records into Markdown
|
|
5
|
+
* documents and for converting that Markdown into print-ready HTML. The markdown
|
|
6
|
+
* renderer here is intentionally tiny: it covers the constructs our own exporters
|
|
7
|
+
* emit (headings, bold, lists, paragraphs, line breaks) and nothing more.
|
|
8
|
+
*/
|
|
9
|
+
import { escapeHtml } from './html.ts';
|
|
10
|
+
|
|
11
|
+
/* ----------------------------------------------------------- slug / filenames */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Make a filesystem-safe, ASCII-leaning slug from arbitrary (often Korean) text.
|
|
15
|
+
* Non-ASCII runs collapse to `-`; the result is lowercased and trimmed of
|
|
16
|
+
* leading/trailing separators. Falls back to `document` when nothing survives.
|
|
17
|
+
*/
|
|
18
|
+
export function slugify(input: string | null | undefined, fallback = 'document'): string {
|
|
19
|
+
const raw = (input ?? '').normalize('NFKD');
|
|
20
|
+
// Keep ASCII alphanumerics; everything else becomes a separator.
|
|
21
|
+
const slug = raw
|
|
22
|
+
.replace(/[^\x00-\x7F]+/g, '-') // drop non-ASCII (e.g. Hangul) -> separator
|
|
23
|
+
.replace(/[^a-zA-Z0-9]+/g, '-') // remaining punctuation/space -> separator
|
|
24
|
+
.replace(/-+/g, '-')
|
|
25
|
+
.replace(/^-+|-+$/g, '')
|
|
26
|
+
.toLowerCase();
|
|
27
|
+
return slug || fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* ----------------------------------------------------------- markdown helpers */
|
|
31
|
+
|
|
32
|
+
/** Join non-empty lines/blocks with blank lines between them. */
|
|
33
|
+
export function joinBlocks(blocks: (string | null | undefined | false)[]): string {
|
|
34
|
+
return blocks
|
|
35
|
+
.filter((b): b is string => typeof b === 'string' && b.trim().length > 0)
|
|
36
|
+
.join('\n\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Render a markdown bullet list from string items (empty/whitespace skipped). */
|
|
40
|
+
export function bulletList(items: (string | null | undefined)[]): string {
|
|
41
|
+
const lines = items
|
|
42
|
+
.filter((i): i is string => typeof i === 'string' && i.trim().length > 0)
|
|
43
|
+
.map((i) => `- ${i.trim()}`);
|
|
44
|
+
return lines.join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `**label**: value` style key/value line; returns '' if value empty. */
|
|
48
|
+
export function kv(label: string, value: string | null | undefined): string {
|
|
49
|
+
if (!value || !value.trim()) return '';
|
|
50
|
+
return `**${label}**: ${value.trim()}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Render a date range like `2021-03 ~ 현재` / `2021-03 ~ 2023-06`. */
|
|
54
|
+
export function dateRange(
|
|
55
|
+
start: string | null | undefined,
|
|
56
|
+
end: string | null | undefined,
|
|
57
|
+
current = false,
|
|
58
|
+
): string {
|
|
59
|
+
const s = (start ?? '').trim();
|
|
60
|
+
const e = current ? '현재' : (end ?? '').trim();
|
|
61
|
+
if (!s && !e) return '';
|
|
62
|
+
if (!s) return e;
|
|
63
|
+
if (!e) return s;
|
|
64
|
+
return `${s} ~ ${e}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ------------------------------------------------------ tiny markdown → HTML */
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Convert inline markdown (bold, links, line breaks) within already
|
|
71
|
+
* HTML-escaped text. Order matters: escaping happens before this so user
|
|
72
|
+
* angle-brackets are safe; we only re-introduce the tags we generate.
|
|
73
|
+
*/
|
|
74
|
+
function renderInline(escaped: string): string {
|
|
75
|
+
let out = escaped;
|
|
76
|
+
// **bold**
|
|
77
|
+
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
78
|
+
// [label](url) — url is already escaped; guard against javascript: schemes.
|
|
79
|
+
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label: string, url: string) => {
|
|
80
|
+
const safe = /^(https?:|mailto:|#|\/)/i.test(url.trim()) ? url.trim() : '#';
|
|
81
|
+
return `<a href="${safe}">${label}</a>`;
|
|
82
|
+
});
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Minimal Markdown → HTML renderer. Supports:
|
|
88
|
+
* - ATX headings `#`..`######`
|
|
89
|
+
* - unordered lists (`-`, `*`, `+`)
|
|
90
|
+
* - ordered lists (`1.`)
|
|
91
|
+
* - horizontal rule `---`
|
|
92
|
+
* - bold + links inline
|
|
93
|
+
* - blank-line separated paragraphs, single newline -> <br>
|
|
94
|
+
*
|
|
95
|
+
* Anything else is treated as paragraph text. Input is HTML-escaped first, so
|
|
96
|
+
* raw HTML in the markdown is rendered as literal text (safe by default).
|
|
97
|
+
*/
|
|
98
|
+
export function markdownToHtml(md: string): string {
|
|
99
|
+
const src = (md ?? '').replace(/\r\n?/g, '\n');
|
|
100
|
+
const lines = src.split('\n');
|
|
101
|
+
const html: string[] = [];
|
|
102
|
+
|
|
103
|
+
let i = 0;
|
|
104
|
+
let paragraph: string[] = [];
|
|
105
|
+
|
|
106
|
+
const flushParagraph = () => {
|
|
107
|
+
if (paragraph.length === 0) return;
|
|
108
|
+
const text = paragraph
|
|
109
|
+
.map((l) => renderInline(escapeHtml(l)))
|
|
110
|
+
.join('<br>\n');
|
|
111
|
+
html.push(`<p>${text}</p>`);
|
|
112
|
+
paragraph = [];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
while (i < lines.length) {
|
|
116
|
+
const line = lines[i];
|
|
117
|
+
const trimmed = line.trim();
|
|
118
|
+
|
|
119
|
+
// Blank line -> paragraph boundary.
|
|
120
|
+
if (trimmed === '') {
|
|
121
|
+
flushParagraph();
|
|
122
|
+
i++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Horizontal rule.
|
|
127
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
|
128
|
+
flushParagraph();
|
|
129
|
+
html.push('<hr>');
|
|
130
|
+
i++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Heading.
|
|
135
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(trimmed);
|
|
136
|
+
if (heading) {
|
|
137
|
+
flushParagraph();
|
|
138
|
+
const level = heading[1].length;
|
|
139
|
+
const content = renderInline(escapeHtml(heading[2].trim()));
|
|
140
|
+
html.push(`<h${level}>${content}</h${level}>`);
|
|
141
|
+
i++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Unordered list block.
|
|
146
|
+
if (/^[-*+]\s+/.test(trimmed)) {
|
|
147
|
+
flushParagraph();
|
|
148
|
+
const items: string[] = [];
|
|
149
|
+
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
|
|
150
|
+
const item = lines[i].trim().replace(/^[-*+]\s+/, '');
|
|
151
|
+
items.push(`<li>${renderInline(escapeHtml(item))}</li>`);
|
|
152
|
+
i++;
|
|
153
|
+
}
|
|
154
|
+
html.push(`<ul>\n${items.join('\n')}\n</ul>`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Ordered list block.
|
|
159
|
+
if (/^\d+\.\s+/.test(trimmed)) {
|
|
160
|
+
flushParagraph();
|
|
161
|
+
const items: string[] = [];
|
|
162
|
+
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
|
163
|
+
const item = lines[i].trim().replace(/^\d+\.\s+/, '');
|
|
164
|
+
items.push(`<li>${renderInline(escapeHtml(item))}</li>`);
|
|
165
|
+
i++;
|
|
166
|
+
}
|
|
167
|
+
html.push(`<ol>\n${items.join('\n')}\n</ol>`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Otherwise: accumulate into the current paragraph.
|
|
172
|
+
paragraph.push(line.trim());
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
flushParagraph();
|
|
176
|
+
|
|
177
|
+
return html.join('\n');
|
|
178
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge from the MCP process to the local dashboard process.
|
|
3
|
+
*
|
|
4
|
+
* `open_dashboard` / `open_application` need the running web server's URL. The
|
|
5
|
+
* web server publishes it to a runtime file (see @careermate/db runtime). If the
|
|
6
|
+
* server isn't running we start it (detached) and wait for it to register.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { readRuntimeInfo, isProcessAlive } from '@careermate/db';
|
|
12
|
+
|
|
13
|
+
// 번들 빌드(esbuild)에서 true로 치환된다. tsx 개발 실행에서는 정의되지 않아 typeof로 안전 분기.
|
|
14
|
+
declare const __BUNDLED__: boolean | undefined;
|
|
15
|
+
const BUNDLED = typeof __BUNDLED__ !== 'undefined' && __BUNDLED__;
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
// packages/mcp-tools/src -> repo root (dev). 번들에서는 __dirname이 dist/ 이다.
|
|
19
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
20
|
+
|
|
21
|
+
export function projectRoot(): string {
|
|
22
|
+
return REPO_ROOT;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function liveServer() {
|
|
26
|
+
const info = readRuntimeInfo();
|
|
27
|
+
if (info && isProcessAlive(info.pid)) return info;
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Start the dashboard server detached and wait until it publishes its address. */
|
|
32
|
+
export async function ensureServer(timeoutMs = 12000): Promise<{ url: string } | null> {
|
|
33
|
+
const existing = liveServer();
|
|
34
|
+
if (existing) return { url: existing.url };
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// prod(번들): dist/web.mjs를 플레인 node로 실행. dev: tsx로 소스 실행.
|
|
38
|
+
const [bin, args, cwd] = BUNDLED
|
|
39
|
+
? [process.execPath, ['--experimental-sqlite', path.join(__dirname, 'web.mjs')], __dirname]
|
|
40
|
+
: [process.execPath, ['--no-warnings', '--experimental-sqlite', '--import', 'tsx', 'apps/web/src/index.ts'], REPO_ROOT];
|
|
41
|
+
const child = spawn(bin, args, {
|
|
42
|
+
cwd,
|
|
43
|
+
detached: true,
|
|
44
|
+
stdio: 'ignore',
|
|
45
|
+
env: { ...process.env, CAREERMATE_NO_OPEN: '1' },
|
|
46
|
+
});
|
|
47
|
+
child.unref();
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const start = Date.now();
|
|
53
|
+
while (Date.now() - start < timeoutMs) {
|
|
54
|
+
const info = liveServer();
|
|
55
|
+
if (info) return { url: info.url };
|
|
56
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function openInBrowser(url: string): void {
|
|
62
|
+
const platform = process.platform;
|
|
63
|
+
try {
|
|
64
|
+
if (platform === 'win32') {
|
|
65
|
+
spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
|
|
66
|
+
} else if (platform === 'darwin') {
|
|
67
|
+
spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
|
|
68
|
+
} else {
|
|
69
|
+
spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
/* best-effort */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Resolve the dashboard URL, starting the server if needed. */
|
|
77
|
+
export async function resolveDashboardUrl(hash = ''): Promise<{ url: string; running: boolean }> {
|
|
78
|
+
const live = liveServer();
|
|
79
|
+
if (live) return { url: live.url + hash, running: true };
|
|
80
|
+
const started = await ensureServer();
|
|
81
|
+
if (started) return { url: started.url + hash, running: true };
|
|
82
|
+
return { url: 'http://127.0.0.1:4319' + hash, running: false };
|
|
83
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @careermate/mcp-tools — the CareerMate tool registry + the conversion helpers
|
|
3
|
+
* the MCP server (apps/mcp) uses to expose them. Kept separate from the server
|
|
4
|
+
* so tools can also be unit-tested or reused in other transports later.
|
|
5
|
+
*/
|
|
6
|
+
export { TOOLS, EXPORTS_LOCATION } from './tools.ts';
|
|
7
|
+
export { ok, fail, toCallToolResult, type ToolDef, type ToolResult } from './result.ts';
|
|
8
|
+
export { resolveDashboardUrl, openInBrowser, ensureServer, projectRoot } from './bridge.ts';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool result shape shared by every CareerMate MCP tool, plus the conversion to
|
|
3
|
+
* the MCP CallToolResult the SDK expects.
|
|
4
|
+
*
|
|
5
|
+
* We always embed the structured payload as pretty JSON inside the text content
|
|
6
|
+
* (so *any* model/client can read it) AND expose it as `structuredContent` for
|
|
7
|
+
* clients that support it. No output schema is declared, so structuredContent is
|
|
8
|
+
* passed through without validation.
|
|
9
|
+
*/
|
|
10
|
+
import type { ZodRawShape } from 'zod';
|
|
11
|
+
|
|
12
|
+
export interface ToolResult {
|
|
13
|
+
text: string;
|
|
14
|
+
data?: unknown;
|
|
15
|
+
isError?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ToolDef {
|
|
19
|
+
name: string;
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
inputSchema: ZodRawShape;
|
|
23
|
+
/** Hint flags for clients (readOnly tools don't mutate). */
|
|
24
|
+
readOnly?: boolean;
|
|
25
|
+
handler: (args: any) => ToolResult | Promise<ToolResult>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ok(text: string, data?: unknown): ToolResult {
|
|
29
|
+
return { text, data };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function fail(text: string): ToolResult {
|
|
33
|
+
return { text, isError: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Convert a ToolResult into the MCP SDK's CallToolResult. */
|
|
37
|
+
export function toCallToolResult(r: ToolResult) {
|
|
38
|
+
const blocks: { type: 'text'; text: string }[] = [{ type: 'text', text: r.text }];
|
|
39
|
+
if (r.data !== undefined) {
|
|
40
|
+
blocks.push({ type: 'text', text: '```json\n' + JSON.stringify(r.data, null, 2) + '\n```' });
|
|
41
|
+
}
|
|
42
|
+
const isPlainObject =
|
|
43
|
+
typeof r.data === 'object' && r.data !== null && !Array.isArray(r.data);
|
|
44
|
+
return {
|
|
45
|
+
content: blocks,
|
|
46
|
+
...(isPlainObject ? { structuredContent: r.data as Record<string, unknown> } : {}),
|
|
47
|
+
...(r.isError ? { isError: true } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|