careermate 0.0.5 → 0.0.6

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 (123) hide show
  1. package/AGENTS.md +9 -8
  2. package/INSTALL.md +16 -15
  3. package/README.md +24 -24
  4. package/THIRD_PARTY_NOTICES.md +2 -3
  5. package/bin/careermate.mjs +8 -1
  6. package/dist/career-os/README.md +8 -3
  7. package/dist/career-os/eop/career-description.md +136 -0
  8. package/dist/career-os/eop/cover-letter.md +7 -5
  9. package/dist/career-os/eop/fit-analysis.md +15 -5
  10. package/dist/career-os/eop/interview-prep.md +209 -21
  11. package/dist/career-os/eop/job-analysis.md +5 -3
  12. package/dist/career-os/eop/profile-extraction.md +5 -4
  13. package/dist/career-os/knowledge/cover-letter.md +4 -4
  14. package/dist/career-os/knowledge/fit-matching.md +8 -8
  15. package/dist/career-os/knowledge/interview-behavioral.md +76 -8
  16. package/dist/career-os/knowledge/interview-technical.md +106 -4
  17. package/dist/career-os/knowledge/linkedin-profile.md +1 -1
  18. package/dist/career-os/knowledge/networking-referrals.md +1 -1
  19. package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
  20. package/dist/career-os/knowledge/portfolio.md +3 -3
  21. package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
  22. package/dist/career-os/knowledge/verifiers/human-voice.md +2 -2
  23. package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  24. package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  25. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
  26. package/dist/doctor.mjs +678 -66
  27. package/dist/mcp.mjs +696 -66
  28. package/dist/migrate.mjs +52 -1
  29. package/dist/public/app.js +42 -16
  30. package/dist/public/i18n/en/applications.js +23 -0
  31. package/dist/public/i18n/en/common.js +22 -0
  32. package/dist/public/i18n/en/documents.js +133 -0
  33. package/dist/public/i18n/en/home.js +75 -0
  34. package/dist/public/i18n/en/interview.js +69 -0
  35. package/dist/public/i18n/en/jobs.js +152 -0
  36. package/dist/public/i18n/en/nav.js +18 -0
  37. package/dist/public/i18n/en/profile.js +201 -0
  38. package/dist/public/i18n/en/settings.js +127 -0
  39. package/dist/public/i18n/en/status.js +22 -0
  40. package/dist/public/i18n/en.js +17 -0
  41. package/dist/public/i18n/ko/applications.js +23 -0
  42. package/dist/public/i18n/ko/common.js +22 -0
  43. package/dist/public/i18n/ko/documents.js +133 -0
  44. package/dist/public/i18n/ko/home.js +63 -0
  45. package/dist/public/i18n/ko/interview.js +69 -0
  46. package/dist/public/i18n/ko/jobs.js +152 -0
  47. package/dist/public/i18n/ko/nav.js +18 -0
  48. package/dist/public/i18n/ko/profile.js +201 -0
  49. package/dist/public/i18n/ko/settings.js +127 -0
  50. package/dist/public/i18n/ko/status.js +23 -0
  51. package/dist/public/i18n/ko.js +18 -0
  52. package/dist/public/i18n.js +55 -0
  53. package/dist/public/index.html +1 -0
  54. package/dist/public/lib.js +24 -17
  55. package/dist/public/pages/applications.js +17 -21
  56. package/dist/public/pages/documents.js +153 -107
  57. package/dist/public/pages/home.js +88 -53
  58. package/dist/public/pages/interview.js +105 -51
  59. package/dist/public/pages/jobs.js +330 -106
  60. package/dist/public/pages/profile.js +157 -152
  61. package/dist/public/pages/settings.js +154 -102
  62. package/dist/public/styles.css +28 -1
  63. package/dist/web.mjs +494 -75
  64. package/docs/FAQ.md +23 -3
  65. package/docs/MCP_TOOLS.md +18 -11
  66. package/docs/START_WORKFLOW.md +2 -2
  67. package/docs/SUPPORTED_AI_APPS.md +13 -0
  68. package/docs/WORKFLOWS.md +77 -20
  69. package/docs/career-os/README.md +8 -3
  70. package/docs/career-os/eop/career-description.md +136 -0
  71. package/docs/career-os/eop/cover-letter.md +7 -5
  72. package/docs/career-os/eop/fit-analysis.md +15 -5
  73. package/docs/career-os/eop/interview-prep.md +209 -21
  74. package/docs/career-os/eop/job-analysis.md +5 -3
  75. package/docs/career-os/eop/profile-extraction.md +5 -4
  76. package/docs/career-os/knowledge/cover-letter.md +4 -4
  77. package/docs/career-os/knowledge/fit-matching.md +8 -8
  78. package/docs/career-os/knowledge/interview-behavioral.md +76 -8
  79. package/docs/career-os/knowledge/interview-technical.md +106 -4
  80. package/docs/career-os/knowledge/linkedin-profile.md +1 -1
  81. package/docs/career-os/knowledge/networking-referrals.md +1 -1
  82. package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
  83. package/docs/career-os/knowledge/portfolio.md +3 -3
  84. package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
  85. package/docs/career-os/knowledge/verifiers/human-voice.md +2 -2
  86. package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  87. package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  88. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
  89. package/package.json +2 -2
  90. package/packages/core/src/onboarding.ts +2 -1
  91. package/packages/core/src/services.ts +134 -17
  92. package/packages/core/src/summary.ts +59 -1
  93. package/packages/core/src/verify/lint.ts +4 -0
  94. package/packages/db/src/index.ts +1 -0
  95. package/packages/db/src/repositories.ts +0 -0
  96. package/packages/db/src/schema.ts +17 -0
  97. package/packages/exporters/src/index.ts +28 -17
  98. package/packages/knowledge/src/index.ts +46 -7
  99. package/packages/mcp-tools/src/tools.ts +246 -22
  100. package/packages/mcp-tools/src/update.ts +30 -15
  101. package/packages/parsers/src/documents.ts +12 -1
  102. package/packages/prompts/src/humanize.ts +46 -0
  103. package/packages/prompts/src/index.ts +5 -1
  104. package/packages/prompts/src/install.ts +4 -4
  105. package/packages/prompts/src/onboarding.ts +4 -4
  106. package/packages/prompts/src/system.ts +8 -7
  107. package/packages/shared/src/enums.ts +14 -4
  108. package/packages/shared/src/schemas.ts +41 -0
  109. package/packages/workflows/src/definitions.ts +45 -8
  110. package/scripts/init.ts +284 -13
  111. package/dist/site/analytics.js +0 -97
  112. package/dist/site/favicon.svg +0 -1
  113. package/dist/site/fonts/PretendardVariable.woff2 +0 -0
  114. package/dist/site/icon-180.png +0 -0
  115. package/dist/site/icon-512.png +0 -0
  116. package/dist/site/index.html +0 -465
  117. package/dist/site/landing.js +0 -163
  118. package/dist/site/llms-install.txt +0 -218
  119. package/dist/site/robots.txt +0 -4
  120. package/dist/site/sitemap.xml +0 -21
  121. package/dist/site/start.html +0 -356
  122. package/dist/site/style.css +0 -665
  123. package/dist/site/vercel.json +0 -20
Binary file
@@ -283,6 +283,23 @@ export const MIGRATIONS: string[] = [
283
283
  COMMIT;
284
284
  PRAGMA foreign_keys=ON;
285
285
  `,
286
+
287
+ // v3 — per-job timeline entries. Document/cover-letter refs live in payload
288
+ // snapshots instead of FKs so deleting a document does not erase the history.
289
+ `
290
+ CREATE TABLE IF NOT EXISTS application_timeline_events (
291
+ id TEXT PRIMARY KEY,
292
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
293
+ type TEXT NOT NULL,
294
+ title TEXT NOT NULL,
295
+ summary TEXT,
296
+ payload TEXT NOT NULL DEFAULT '{}',
297
+ occurred_at TEXT NOT NULL,
298
+ created_at TEXT NOT NULL
299
+ );
300
+
301
+ CREATE INDEX IF NOT EXISTS idx_timeline_job ON application_timeline_events(job_id, occurred_at ASC);
302
+ `,
286
303
  ];
287
304
 
288
305
  export function migrate(db: DatabaseSync): { from: number; to: number } {
@@ -284,23 +284,34 @@ export function interviewPrepToMarkdown(
284
284
  ? joinBlocks(['## 1분 자기소개', prep.self_introduction.trim()])
285
285
  : '';
286
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
- : '';
287
+ const renderQuestion = (q: InterviewPrepRecord['questions'][number], idx: number, level: string) =>
288
+ joinBlocks([
289
+ `${level} Q${idx + 1}. ${q.question}`,
290
+ q.intent?.trim() ? kv('의도', q.intent) : '',
291
+ q.answer_outline?.trim()
292
+ ? joinBlocks(['**답변 가이드**', q.answer_outline.trim()])
293
+ : '',
294
+ q.followups?.length
295
+ ? joinBlocks(['**예상 꼬리 질문**', bulletList(q.followups)])
296
+ : '',
297
+ ]);
298
+
299
+ let questionsBlock = '';
300
+ if (prep.questions?.length) {
301
+ const technical = prep.questions.filter((q) => (q.category ?? 'technical') === 'technical');
302
+ const behavioral = prep.questions.filter((q) => q.category === 'behavioral');
303
+ // Split into 기술 / 인성·컬처핏 sub-sections only when both tracks are present;
304
+ // a single-track sheet stays flat (backward-compatible output).
305
+ questionsBlock = behavioral.length
306
+ ? joinBlocks([
307
+ '## 예상 질문',
308
+ technical.length
309
+ ? joinBlocks(['### 기술 면접', ...technical.map((q, i) => renderQuestion(q, i, '####'))])
310
+ : '',
311
+ joinBlocks(['### 인성·컬처핏 면접', ...behavioral.map((q, i) => renderQuestion(q, i, '####'))]),
312
+ ])
313
+ : joinBlocks(['## 예상 질문', ...prep.questions.map((q, i) => renderQuestion(q, i, '###'))]);
314
+ }
304
315
 
305
316
  const starBlock = prep.star_guides?.length
306
317
  ? joinBlocks([
@@ -58,12 +58,13 @@ export const VERIFIER_IDS = [
58
58
  ] as const;
59
59
  export type VerifierId = (typeof VERIFIER_IDS)[number];
60
60
 
61
- /** 5 per-feature execution procedures — docs/career-os/eop/<feature>.md. */
61
+ /** Per-feature execution procedures — docs/career-os/eop/<feature>.md. */
62
62
  export const EOP_FEATURES = [
63
63
  'fit-analysis',
64
64
  'job-analysis',
65
65
  'profile-extraction',
66
66
  'cover-letter',
67
+ 'career-description',
67
68
  'interview-prep',
68
69
  ] as const;
69
70
  export type EopFeature = (typeof EOP_FEATURES)[number];
@@ -95,7 +96,7 @@ export function getEop(feature: EopFeature): string {
95
96
 
96
97
  /** A goal → which EOP, expert playbooks, and verifiers to apply, in order. */
97
98
  export interface CareerRoute {
98
- eop?: EopFeature;
99
+ eop?: EopFeature | EopFeature[];
99
100
  expertSequence: ExpertDomain[];
100
101
  verifierSequence: VerifierId[];
101
102
  loop?: 'draft_verify_revise';
@@ -109,7 +110,7 @@ export const CAREER_ROUTES: Record<string, CareerRoute> = {
109
110
  verifierSequence: ['truthfulness', 'consistency'],
110
111
  },
111
112
  analyze_job: {
112
- eop: 'job-analysis',
113
+ eop: ['job-analysis', 'fit-analysis'],
113
114
  expertSequence: ['fit-matching', 'company-research'],
114
115
  verifierSequence: ['truthfulness', 'consistency', 'responsiveness-on-target'],
115
116
  loop: 'draft_verify_revise',
@@ -120,12 +121,47 @@ export const CAREER_ROUTES: Record<string, CareerRoute> = {
120
121
  verifierSequence: ['human-voice', 'truthfulness', 'ats-compat', 'consistency'],
121
122
  loop: 'draft_verify_revise',
122
123
  },
124
+ write_career_description: {
125
+ eop: 'career-description',
126
+ expertSequence: ['resume', 'ats'],
127
+ verifierSequence: ['truthfulness', 'consistency', 'ats-compat'],
128
+ loop: 'draft_verify_revise',
129
+ },
123
130
  prepare_interview: {
124
131
  eop: 'interview-prep',
125
- expertSequence: ['interview-behavioral', 'interview-technical', 'company-research'],
132
+ expertSequence: ['recruiter-screen', 'interview-behavioral', 'interview-technical', 'company-research'],
126
133
  verifierSequence: ['truthfulness', 'consistency'],
127
134
  loop: 'draft_verify_revise',
128
135
  },
136
+ // R7 A2: manage_application_status had no route → rejection-triage / offer-evaluation /
137
+ // salary-negotiation were orphaned (served only on-demand via get_playbook). Route the
138
+ // decision-stage playbooks so they are pushed when the user manages application outcomes.
139
+ // R7 A4: A2 routed the decision-stage playbooks, but the EOP-free lifecycle domains
140
+ // onboarding-first-90-days and networking-referrals were still on-demand-only. Anchor them
141
+ // here (the only EOP-free lifecycle route). NOTE: networking's strongest lever is *before*
142
+ // applied (pre-referral, networking-referrals §2) — this route is the surfacing point, not
143
+ // the recommended timing, so surface networking early when the user is pre-application.
144
+ // linkedin-profile/portfolio stay on-demand pending a dedicated personal-brand workflow id
145
+ // (FIXES.md / STATUS §7.9 follow-up — onboarding EOP is résumé-import-only, so brand-
146
+ // creation playbooks would blur its stage meaning). Additive wiring (A1/A2 precedent).
147
+ manage_application_status: {
148
+ expertSequence: [
149
+ 'rejection-triage-iteration',
150
+ 'offer-evaluation-decision',
151
+ 'salary-negotiation',
152
+ 'onboarding-first-90-days',
153
+ 'networking-referrals',
154
+ ],
155
+ verifierSequence: ['truthfulness', 'consistency'],
156
+ },
157
+ // R7 후속: linkedin-profile·portfolio는 onboarding(이력서 임포트)과 단계 의미가
158
+ // 달라(개인 브랜드 자산 *생성*) 전용 워크플로우로 분리(consensus 권고). networking은
159
+ // manage_application_status에 이미 정박. 저장은 add_resume(kind=portfolio); linkedin
160
+ // 전용 저장/검증은 Phase B.
161
+ build_personal_brand: {
162
+ expertSequence: ['linkedin-profile', 'portfolio'],
163
+ verifierSequence: ['truthfulness', 'human-voice', 'consistency'],
164
+ },
129
165
  };
130
166
 
131
167
  export function getRoute(id: string): CareerRoute | undefined {
@@ -142,12 +178,15 @@ export function renderRouteGuide(id: string): string | undefined {
142
178
  const route = CAREER_ROUTES[id];
143
179
  if (!route) return undefined;
144
180
  const lines: string[] = [];
145
- if (route.eop) {
181
+ const eops = route.eop ? (Array.isArray(route.eop) ? route.eop : [route.eop]) : [];
182
+ if (eops.length) {
146
183
  lines.push('---', '');
147
184
  lines.push('## 전문가 실행 절차 (EOP) — 즉답하지 말고 이 절차를 따르세요');
148
185
  lines.push('');
149
- lines.push(getEop(route.eop));
150
- lines.push('');
186
+ for (const e of eops) {
187
+ lines.push(getEop(e));
188
+ lines.push('');
189
+ }
151
190
  }
152
191
  lines.push('## 이 작업에 적용할 전문가 플레이북');
153
192
  lines.push('작성 직전, 아래 도메인의 깊은 지식을 받아 적용하세요:');
@@ -21,6 +21,7 @@ import {
21
21
  JobInputSchema,
22
22
  FitAnalysisInputSchema,
23
23
  CoverLetterVersionInputSchema,
24
+ ApplicationSubmissionSchema,
24
25
  InterviewPrepInputSchema,
25
26
  DocumentInputSchema,
26
27
  APPLICATION_STATUSES,
@@ -63,7 +64,7 @@ import {
63
64
  previewCoverLetter,
64
65
  summarizeReport,
65
66
  } from '@careermate/core';
66
- import { HUMANIZE_WRITING_GUIDE } from '@careermate/prompts';
67
+ import { getWritingStyleGuide } from '@careermate/prompts';
67
68
  import { getWorkflow, renderWorkflowMarkdown, WORKFLOWS } from '@careermate/workflows';
68
69
  import {
69
70
  getPlaybook,
@@ -75,7 +76,14 @@ import {
75
76
  type ExpertDomain,
76
77
  type VerifierId,
77
78
  } from '@careermate/knowledge';
78
- import { coverLetterToMarkdown, coverLetterToHtml } from '@careermate/exporters';
79
+ import {
80
+ coverLetterToMarkdown,
81
+ coverLetterToHtml,
82
+ resumeToMarkdown,
83
+ resumeToHtml,
84
+ profileToMarkdown,
85
+ profileToHtml,
86
+ } from '@careermate/exporters';
79
87
  import { extractDocument } from '@careermate/parsers';
80
88
  import { ok, fail, type ToolDef } from './result.ts';
81
89
  import { resolveDashboardUrl, openInBrowser, openInFileManager } from './bridge.ts';
@@ -93,15 +101,28 @@ function listInboxFiles(dir: string): { name: string; path: string }[] {
93
101
  } catch {
94
102
  return [];
95
103
  }
104
+ // Resolve the inbox itself once so we can confine listed files to it: a symlink
105
+ // dropped in the inbox whose real target escapes the inbox (e.g. → ~/.ssh/id_rsa
106
+ // or a private doc elsewhere) must not be listed or later read by read_inbox.
107
+ let realDir: string;
108
+ try {
109
+ realDir = fs.realpathSync.native(dir);
110
+ } catch {
111
+ realDir = dir;
112
+ }
96
113
  const SKIP = /^[.~]|^(thumbs\.db|desktop\.ini)$/i;
97
114
  const out: { name: string; path: string }[] = [];
98
115
  for (const name of names) {
99
116
  if (SKIP.test(name)) continue;
100
117
  const p = path.join(dir, name);
101
118
  try {
102
- if (fs.statSync(p).isFile()) out.push({ name, path: p });
119
+ if (!fs.statSync(p).isFile()) continue;
120
+ const real = fs.realpathSync.native(p);
121
+ const rel = path.relative(realDir, real);
122
+ if (rel.startsWith('..') || path.isAbsolute(rel)) continue; // escapes the inbox — skip
123
+ out.push({ name, path: p });
103
124
  } catch {
104
- /* unreadable entry — skip */
125
+ /* unreadable entry / broken link — skip */
105
126
  }
106
127
  }
107
128
  return out;
@@ -125,13 +146,15 @@ async function mapWithLimit<T, R>(items: T[], limit: number, fn: (item: T, index
125
146
  return out;
126
147
  }
127
148
 
128
- /** Statuses at/after which interview prep is unlocked (mirror of INTERVIEW_UNLOCK_STATUSES). */
149
+ /** Statuses at/after which interview prep is recommended as a next step. */
129
150
  const INTERVIEW_UNLOCKED = ['document_passed', 'interview', 'final_passed'];
130
151
 
131
152
  /**
132
153
  * Pick the recommended Career-OS route from the loaded context, so the AI is
133
154
  * nudged onto the expert procedure for what it's most likely about to do.
134
- * Returns a get_workflow_guide workflow_id, or null when there's no target job.
155
+ * Returns a get_workflow_guide workflow_id for job-centered tasks, or null when
156
+ * there's no target job. Non-job tasks such as write_career_description are
157
+ * selected from the user's explicit request via get_workflow_guide.
135
158
  */
136
159
  function pickRoute(ctx: ReturnType<typeof getApplicationContext>): string | null {
137
160
  if (!ctx.job) return null;
@@ -190,9 +213,9 @@ export const TOOLS: ToolDef[] = [
190
213
  /* --------------------------------------------------------- inbox (파일 인입) */
191
214
  {
192
215
  name: 'open_inbox',
193
- title: '이력서 인입 폴더 열기',
216
+ title: '문서 인입 폴더 열기',
194
217
  description:
195
- 'CLI 클라이언트는 파일 첨부가 안 되므로, 사용자가 기존 이력서·경력기술서 파일(hwp/hwpx/docx/pdf 등)을 끌어다 넣을 수 있는 로컬 인입 폴더(~/.careermate/inbox)를 보장하고 파일 탐색기로 엽니다. 온보딩에서 "기존에 작성해 둔 이력서 파일이 있다"고 사용자가 답했을 때만 호출하세요 — 묻지도 않고 폴더를 자동으로 열지 마세요. 파일 경로를 직접 알려주는 사용자에게는 이 도구 대신 read_document를 쓰세요. 호출하면 폴더 경로와 현재 폴더에 들어 있는 파일 목록을 돌려줍니다. 사용자가 파일을 다 넣었다고 하면 read_inbox로 본문을 읽으세요.',
218
+ 'CLI 클라이언트는 파일 첨부가 안 되므로, 사용자가 기존 이력서·경력기술서·포트폴리오·자기소개서 파일(hwp/hwpx/docx/pdf 등)을 끌어다 넣을 수 있는 로컬 인입 폴더(~/.careermate/inbox)를 보장하고 파일 탐색기로 엽니다. 온보딩에서 "기존에 작성해 둔 문서 파일이 있다"고 사용자가 답했을 때만 호출하세요 — 묻지도 않고 폴더를 자동으로 열지 마세요. 파일 경로를 직접 알려주는 사용자에게는 이 도구 대신 read_document를 쓰세요. 호출하면 폴더 경로와 현재 폴더에 들어 있는 파일 목록을 돌려줍니다. 사용자가 파일을 다 넣었다고 하면 read_inbox로 본문을 읽으세요.',
196
219
  inputSchema: {},
197
220
  handler: () => {
198
221
  const dir = getInboxDir();
@@ -200,7 +223,7 @@ export const TOOLS: ToolDef[] = [
200
223
  const files = listInboxFiles(dir);
201
224
  const list = files.length ? files.map((f) => `- ${f.name}`).join('\n') : '(아직 비어 있음)';
202
225
  return ok(
203
- `이력서 인입 폴더를 열었습니다:\n${dir}\n\n여기에 이력서·경력기술서 파일을 넣고 "다 넣었어"라고 알려주세요. (hwp/hwpx/docx는 자동으로 텍스트를 추출하고, pdf·이미지는 제가 직접 읽습니다.)\n\n현재 파일:\n${list}`,
226
+ `문서 인입 폴더를 열었습니다:\n${dir}\n\n여기에 이력서·경력기술서·포트폴리오·기존 자기소개서 파일을 넣고 "다 넣었어"라고 알려주세요. (hwp/hwpx/docx는 자동으로 텍스트를 추출하고, pdf·이미지는 제가 직접 읽습니다.)\n\n현재 파일:\n${list}`,
204
227
  { dir, files },
205
228
  );
206
229
  },
@@ -209,7 +232,7 @@ export const TOOLS: ToolDef[] = [
209
232
  name: 'read_inbox',
210
233
  title: '인입 폴더 파일 읽기',
211
234
  description:
212
- '인입 폴더(~/.careermate/inbox)에 사용자가 넣어 둔 문서들의 본문을 한 번에 읽어 돌려줍니다. open_inbox로 폴더를 안내한 뒤 사용자가 "다 넣었어"라고 하면 호출하세요. docx/hwp/hwpx/텍스트는 텍스트를 추출하고, pdf·이미지는 추출하지 않고 파일 경로를 돌려주니 AI 클라이언트의 파일 읽기 기능으로 그 경로를 직접 열어 읽으세요. 읽은 내용을 구조화해 save_profile·add_resume 등 알맞은 도구로 저장하세요(파일 내용을 새 파일로 다시 쓰지 마세요). filename을 주면 해당 파일만 읽습니다.',
235
+ '인입 폴더(~/.careermate/inbox)에 사용자가 넣어 둔 문서들의 본문을 한 번에 읽어 돌려줍니다. open_inbox로 폴더를 안내한 뒤 사용자가 "다 넣었어"라고 하면 호출하세요. docx/hwp/hwpx/텍스트는 텍스트를 추출하고, pdf·이미지는 추출하지 않고 파일 경로를 돌려주니 AI 클라이언트의 파일 읽기 기능으로 그 경로를 직접 열어 읽으세요. 읽은 내용을 구조화해 프로필은 save_profile, 이력서·경력기술서·포트폴리오는 add_resume, 기존 자기소개서는 save_cover_letter_version으로 저장하세요(파일 내용을 새 파일로 다시 쓰지 마세요). filename을 주면 해당 파일만 읽습니다.',
213
236
  inputSchema: {
214
237
  filename: z.string().optional().describe('특정 파일만 읽을 때 파일명(대소문자 무시). 생략하면 폴더 안 모든 파일을 읽습니다.'),
215
238
  max_chars: z.number().optional().describe('파일별 반환 텍스트 최대 길이(초과 시 잘라냄). 기본 20000자'),
@@ -252,7 +275,7 @@ export const TOOLS: ToolDef[] = [
252
275
  )
253
276
  .join('\n');
254
277
  return ok(
255
- `인입 폴더 파일 ${files.length}건을 읽었습니다:\n${summary}\n\n추출된 내용을 구조화해 save_profile·add_resume 등으로 저장하세요.`,
278
+ `인입 폴더 파일 ${files.length}건을 읽었습니다:\n${summary}\n\n추출된 내용을 구조화해 프로필은 save_profile, 이력서·경력기술서·포트폴리오는 add_resume, 기존 자기소개서는 save_cover_letter_version으로 저장하세요.`,
256
279
  { dir, files },
257
280
  );
258
281
  },
@@ -325,7 +348,7 @@ export const TOOLS: ToolDef[] = [
325
348
  name: 'add_resume',
326
349
  title: '이력서/경력기술서 추가',
327
350
  description:
328
- '이력서, 경력기술서, 포트폴리오 등 문서를 저장합니다. content에는 사용자가 업로드/붙여넣은 텍스트(또는 정리한 Markdown)를 넣으세요. kind 기본값은 resume입니다. is_primary=true로 대표 문서를 지정하면 get_application_context의 primary_resume로 노출됩니다.',
351
+ '이력서, 경력기술서, 포트폴리오 등 문서를 저장합니다. content에는 사용자가 업로드/붙여넣은 텍스트 또는 AI가 전문가 절차로 정리한 Markdown을 넣으세요. 경력기술서를 작성해 저장할 때는 kind=career_description, source=ai를 사용합니다. kind 기본값은 resume입니다. is_primary=true로 대표 문서를 지정하면 get_application_context의 primary_resume로 노출됩니다.',
329
352
  // Reuse the shared (length-bounded, whitespace-rejecting) Document schema so
330
353
  // the MCP ingestion path — the primary route for résumé text — inherits the
331
354
  // same caps as the web API. kind stays optional here (defaults to resume).
@@ -349,6 +372,24 @@ export const TOOLS: ToolDef[] = [
349
372
  return ok(`문서 ${docs.length}건`, docs);
350
373
  },
351
374
  },
375
+ {
376
+ name: 'delete_resume',
377
+ title: '이력서/문서 삭제',
378
+ description:
379
+ '저장된 이력서·경력기술서·포트폴리오 등 문서를 삭제합니다. 사용자가 명확히 삭제를 요청했을 때만 사용하세요. 먼저 get_resumes로 목록을 확인하고 어떤 문서인지 사용자와 확인한 뒤 호출합니다. confirm에는 DELETE를 넣으세요.',
380
+ inputSchema: {
381
+ document_id: z.string(),
382
+ confirm: z.string().describe('삭제 확인값. 정확히 DELETE를 넣어야 삭제됩니다.'),
383
+ },
384
+ handler: (args) => {
385
+ if (args?.confirm !== DELETE_CONFIRMATION) return fail('삭제하려면 confirm에 DELETE를 넣어 다시 호출하세요.');
386
+ const doc = documentRepo.get(args.document_id);
387
+ if (!doc) return fail('문서를 찾을 수 없습니다.');
388
+ if (!documentRepo.remove(doc.id)) return fail('문서를 삭제하지 못했습니다.');
389
+ const user_message = `'${doc.title}' 문서를 삭제했어요.`;
390
+ return ok(user_message, { deleted: true, document_id: doc.id, user_message });
391
+ },
392
+ },
352
393
 
353
394
  /* ----------------------------------------------------------- experiences */
354
395
  {
@@ -424,6 +465,60 @@ export const TOOLS: ToolDef[] = [
424
465
  return ok(`기술 ${list.length}건`, list);
425
466
  },
426
467
  },
468
+ {
469
+ name: 'delete_experience',
470
+ title: '경력 항목 삭제',
471
+ description:
472
+ '구조화된 경력(직장) 항목 하나를 삭제합니다. 잘못 추출/입력된 경력을 지울 때 사용하세요. 먼저 get_experiences로 목록과 id를 확인하고 사용자와 확인한 뒤 호출합니다. (내용 수정은 add_experience로 같은 항목을 다시 저장하면 갱신됩니다.) confirm에는 DELETE를 넣으세요.',
473
+ inputSchema: {
474
+ experience_id: z.string(),
475
+ confirm: z.string().describe('삭제 확인값. 정확히 DELETE를 넣어야 삭제됩니다.'),
476
+ },
477
+ handler: (args) => {
478
+ if (args?.confirm !== DELETE_CONFIRMATION) return fail('삭제하려면 confirm에 DELETE를 넣어 다시 호출하세요.');
479
+ const exp = experienceRepo.get(args.experience_id);
480
+ if (!exp) return fail('해당 경력 항목을 찾을 수 없습니다.');
481
+ if (!experienceRepo.remove(exp.id)) return fail('경력 항목을 삭제하지 못했습니다.');
482
+ const user_message = `'${exp.company}' 경력을 삭제했어요.`;
483
+ return ok(user_message, { deleted: true, experience_id: exp.id, user_message });
484
+ },
485
+ },
486
+ {
487
+ name: 'delete_project',
488
+ title: '프로젝트 항목 삭제',
489
+ description:
490
+ '구조화된 프로젝트 항목 하나를 삭제합니다. 잘못 추출/입력된 프로젝트를 지울 때 사용하세요. 먼저 get_projects로 목록과 id를 확인하고 사용자와 확인한 뒤 호출합니다. (내용 수정은 add_project로 같은 항목을 다시 저장하면 갱신됩니다.) confirm에는 DELETE를 넣으세요.',
491
+ inputSchema: {
492
+ project_id: z.string(),
493
+ confirm: z.string().describe('삭제 확인값. 정확히 DELETE를 넣어야 삭제됩니다.'),
494
+ },
495
+ handler: (args) => {
496
+ if (args?.confirm !== DELETE_CONFIRMATION) return fail('삭제하려면 confirm에 DELETE를 넣어 다시 호출하세요.');
497
+ const proj = projectRepo.get(args.project_id);
498
+ if (!proj) return fail('해당 프로젝트 항목을 찾을 수 없습니다.');
499
+ if (!projectRepo.remove(proj.id)) return fail('프로젝트 항목을 삭제하지 못했습니다.');
500
+ const user_message = `'${proj.name}' 프로젝트를 삭제했어요.`;
501
+ return ok(user_message, { deleted: true, project_id: proj.id, user_message });
502
+ },
503
+ },
504
+ {
505
+ name: 'delete_skill',
506
+ title: '기술스택 항목 삭제',
507
+ description:
508
+ '구조화된 기술스택 항목 하나를 삭제합니다. 잘못 추출/입력된 기술을 지울 때 사용하세요. 먼저 get_skills로 목록과 id를 확인하고 사용자와 확인한 뒤 호출합니다. (내용 수정은 add_skill로 같은 항목을 다시 저장하면 갱신됩니다.) confirm에는 DELETE를 넣으세요.',
509
+ inputSchema: {
510
+ skill_id: z.string(),
511
+ confirm: z.string().describe('삭제 확인값. 정확히 DELETE를 넣어야 삭제됩니다.'),
512
+ },
513
+ handler: (args) => {
514
+ if (args?.confirm !== DELETE_CONFIRMATION) return fail('삭제하려면 confirm에 DELETE를 넣어 다시 호출하세요.');
515
+ const skill = skillRepo.get(args.skill_id);
516
+ if (!skill) return fail('해당 기술 항목을 찾을 수 없습니다.');
517
+ if (!skillRepo.remove(skill.id)) return fail('기술 항목을 삭제하지 못했습니다.');
518
+ const user_message = `'${skill.name}' 기술을 삭제했어요.`;
519
+ return ok(user_message, { deleted: true, skill_id: skill.id, user_message });
520
+ },
521
+ },
427
522
 
428
523
  /* ---------------------------------------------------------- cover letters */
429
524
  {
@@ -513,7 +608,7 @@ export const TOOLS: ToolDef[] = [
513
608
  {
514
609
  name: 'get_job_posting',
515
610
  title: '채용공고 상세 조회',
516
- description: '공고 1건의 상세를 조회합니다. 공고 정보와 함께 적합도 분석, 지원 상태, 연결된 자기소개서, 면접 준비 자료를 한 번에 돌려줍니다.',
611
+ description: '공고 1건의 상세를 조회합니다. 공고 정보와 함께 적합도 분석, 지원 상태, 자기소개서, 면접 준비 자료를 한 번에 돌려줍니다.',
517
612
  inputSchema: { job_id: z.string() },
518
613
  readOnly: true,
519
614
  handler: (args) => {
@@ -586,13 +681,47 @@ export const TOOLS: ToolDef[] = [
586
681
  // 여기에 추천 경로를 실어 전문가 절차로 유도한다. 스키마 변경 없이 반환 JSON만 확장.
587
682
  const recommended_route = pickRoute(ctx);
588
683
  const route = recommended_route ? getRoute(recommended_route) : undefined;
684
+ // 오늘 날짜 주입: LLM은 현재 날짜를 신뢰성 있게 모른다.
685
+ const today = new Date().toISOString().slice(0, 10);
686
+ // 재직기간/연차는 LLM이 산술하면 회귀하므로(검증됨) 서버가 결정적으로 계산해 제공한다.
687
+ // 재직중 경력의 확정 연차 = updated_at(정보 입력·확인 시점) − start_date — today가 아님(아직 재직
688
+ // 중인지 미확인이라 today로 계산하면 과대 산정·명시 연차와 충돌). 종료 경력 = end_date − start_date.
689
+ // data_age_months = 재직중 정보가 얼마나 오래됐나(크면 '현재도 재직 중인지 확인 필요').
690
+ const ym = (s: string | null | undefined): number | null => {
691
+ const m = s ? /^(\d{4})-(\d{2})/.exec(s) : null;
692
+ return m ? Number(m[1]) * 12 + Number(m[2]) : null;
693
+ };
694
+ const monthsBetween = (a: string | null | undefined, b: string | null | undefined): number | null => {
695
+ const x = ym(a), y = ym(b);
696
+ return x != null && y != null ? y - x : null;
697
+ };
698
+ const tenure = ctx.experiences
699
+ .map((e) => {
700
+ if (!e.start_date) return null;
701
+ const asOf = e.is_current ? (e.updated_at ?? today) : e.end_date;
702
+ const months = monthsBetween(e.start_date, asOf);
703
+ if (months == null || months < 0) return null;
704
+ return {
705
+ company: e.company,
706
+ is_current: e.is_current,
707
+ start_date: e.start_date,
708
+ as_of: (asOf ?? '').slice(0, 7), // 재직중=updated_at(정보 확인 시점) / 종료=end_date
709
+ months,
710
+ years: Math.round((months / 12) * 10) / 10,
711
+ data_age_months: e.is_current ? monthsBetween(e.updated_at, today) : null,
712
+ };
713
+ })
714
+ .filter((t): t is NonNullable<typeof t> => t != null);
589
715
  const enriched = {
590
716
  ...ctx,
717
+ today,
718
+ tenure,
591
719
  recommended_route,
592
720
  verifier_sequence: route?.verifierSequence ?? [],
593
721
  next_tool: recommended_route ? 'get_workflow_guide' : null,
594
722
  };
595
723
  const summary = [
724
+ `오늘 날짜: ${today}. 경력 연차·재직기간은 직접 계산하지 말고 제공된 tenure[](서버 계산: 재직중=정보확인일 기준 연차, data_age_months 크면 현재 재직 확인 필요)를 그대로 쓰세요.`,
596
725
  `프로필: ${ctx.profile?.name ?? '미입력'}`,
597
726
  `이력서 ${ctx.resumes.length} · 경력 ${ctx.experiences.length} · 프로젝트 ${ctx.projects.length} · 스킬 ${ctx.skills.length} · 자소서 ${ctx.cover_letters.length}`,
598
727
  ctx.job ? `대상 공고: ${ctx.job.company} · ${ctx.job.position}` : '대상 공고 없음',
@@ -656,10 +785,11 @@ export const TOOLS: ToolDef[] = [
656
785
  job_id: z.string(),
657
786
  status: z.enum(APPLICATION_STATUSES),
658
787
  note: z.string().optional(),
788
+ submission: ApplicationSubmissionSchema.optional(),
659
789
  },
660
790
  handler: (args) => {
661
791
  try {
662
- const res = updateApplicationStatus(args.job_id, args.status, args.note);
792
+ const res = updateApplicationStatus(args.job_id, args.status, args.note, args.submission);
663
793
  const user_message =
664
794
  `지원 상태를 '${APPLICATION_STATUS_LABELS[args.status as keyof typeof APPLICATION_STATUS_LABELS]}'(으)로 바꿨어요.` +
665
795
  (res.hint ? `\n\n${res.hint}` : '');
@@ -687,12 +817,19 @@ export const TOOLS: ToolDef[] = [
687
817
  name: 'save_interview_prep',
688
818
  title: '면접 준비 자료 저장',
689
819
  description:
690
- '예상 면접 질문, 꼬리 질문, STAR 답변 가이드, 1분 자기소개 초안 등 면접 준비 자료를 저장합니다. 보통 지원 상태가 서류 합격으로 바뀐 뒤, 해당 공고/직무/자소서를 근거로 당신(AI)이 생성한 자료를 저장하는 데 사용합니다. job_id는 필수입니다. 같은 공고에 다시 저장하면 갱신됩니다.',
820
+ '예상 면접 질문, 꼬리 질문, STAR 답변 가이드, 1분 자기소개 초안 등 해당 공고 기준 면접 준비 자료를 저장합니다. 공고/직무/자기소개서를 근거로 당신(AI)이 생성한 자료를 저장하는 데 사용합니다. ' +
821
+ "질문은 두 갈래로 준비하세요 — 각 질문의 category를 'technical'(기술·직무 1차 면접) 또는 'behavioral'(인성·컬처핏·임원 2차 면접, 자기소개서·프로필 기반)로 지정합니다. 가능하면 두 갈래 모두 채워 한 번에 저장하세요(대시보드가 기술/인성·컬처핏 탭으로 나눠 보여줍니다). " +
822
+ 'job_id는 필수입니다. 같은 공고에 다시 저장하면 갱신됩니다.',
691
823
  inputSchema: InterviewPrepInputSchema.shape,
692
824
  handler: (args) => {
693
825
  try {
694
826
  const prep = saveInterviewPrep(args);
695
- const user_message = `면접 준비 자료를 저장했어요. 예상 질문 ${prep.questions.length}개가 준비되어 있습니다.`;
827
+ const tech = prep.questions.filter((q) => (q.category ?? 'technical') === 'technical').length;
828
+ const behavioral = prep.questions.filter((q) => q.category === 'behavioral').length;
829
+ const user_message =
830
+ `면접 준비 자료를 저장했어요. 예상 질문 ${prep.questions.length}개` +
831
+ (behavioral > 0 ? ` (기술 ${tech}개 · 인성·컬처핏 ${behavioral}개)` : '') +
832
+ '가 준비되어 있습니다.';
696
833
  return ok(user_message, { ...prep, user_message });
697
834
  } catch (e) {
698
835
  return fail(e instanceof Error ? e.message : '저장 실패');
@@ -731,6 +868,68 @@ export const TOOLS: ToolDef[] = [
731
868
  });
732
869
  },
733
870
  },
871
+ {
872
+ name: 'export_resume',
873
+ title: '이력서/경력기술서 내보내기',
874
+ description:
875
+ '저장된 이력서·경력기술서 등 문서를 Markdown 또는 인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로와 본문을 돌려줍니다. 사용자가 공고에 맞춰 정리한 이력서를 먼저 add_resume로 저장한 뒤, "이력서 파일로 받고 싶어"라고 할 때 사용하세요. document_id는 get_resumes로 확인할 수 있습니다. 대시보드에서 직접 다운로드할 수도 있습니다.',
876
+ inputSchema: {
877
+ document_id: z.string(),
878
+ format: z.enum(['md', 'html']).optional().describe('기본 md'),
879
+ },
880
+ handler: (args) => {
881
+ const doc = documentRepo.get(args.document_id);
882
+ if (!doc) return fail('문서를 찾을 수 없습니다.');
883
+ const profile = profileRepo.get();
884
+ const format = args.format ?? 'md';
885
+ const result = format === 'html' ? resumeToHtml(doc, profile) : resumeToMarkdown(doc, profile);
886
+ const dir = getExportsDir();
887
+ const filePath = path.join(dir, result.filename);
888
+ try {
889
+ fs.writeFileSync(filePath, result.content, 'utf8');
890
+ } catch (e) {
891
+ return fail(`파일 저장 실패: ${e instanceof Error ? e.message : e}`);
892
+ }
893
+ return ok(`'${doc.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${filePath}`, {
894
+ path: filePath,
895
+ filename: result.filename,
896
+ content: result.content,
897
+ });
898
+ },
899
+ },
900
+ {
901
+ name: 'export_profile',
902
+ title: '프로필을 이력서로 내보내기',
903
+ description:
904
+ '저장된 프로필·경력·프로젝트·보유 기술을 한 장의 이력서 형식으로 모아 Markdown 또는 인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로와 본문을 돌려줍니다. 사용자가 "내 프로필을 이력서로 뽑아줘"처럼 구조화된 데이터 전체를 이력서로 받고 싶을 때 사용하세요. 특정 공고 맞춤 이력서는 add_resume로 정리·저장한 뒤 export_resume를 쓰세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
905
+ inputSchema: {
906
+ format: z.enum(['md', 'html']).optional().describe('기본 md'),
907
+ },
908
+ handler: (args) => {
909
+ const profile = profileRepo.get();
910
+ if (!profile) return fail('프로필이 없습니다. 먼저 프로필을 저장하세요.');
911
+ const format = args.format ?? 'md';
912
+ const experiences = experienceRepo.list();
913
+ const projects = projectRepo.list();
914
+ const skills = skillRepo.list();
915
+ const result =
916
+ format === 'html'
917
+ ? profileToHtml(profile, experiences, projects, skills)
918
+ : profileToMarkdown(profile, experiences, projects, skills);
919
+ const dir = getExportsDir();
920
+ const filePath = path.join(dir, result.filename);
921
+ try {
922
+ fs.writeFileSync(filePath, result.content, 'utf8');
923
+ } catch (e) {
924
+ return fail(`파일 저장 실패: ${e instanceof Error ? e.message : e}`);
925
+ }
926
+ return ok(`프로필을 이력서(${format.toUpperCase()})로 내보냈습니다.\n저장 위치: ${filePath}`, {
927
+ path: filePath,
928
+ filename: result.filename,
929
+ content: result.content,
930
+ });
931
+ },
932
+ },
734
933
 
735
934
  /* ----------------------------------------------------------- dashboard ui */
736
935
  {
@@ -780,10 +979,18 @@ export const TOOLS: ToolDef[] = [
780
979
  name: 'get_workflow_guide',
781
980
  title: '워크플로우 가이드',
782
981
  description:
783
- '커리어 작업(공고 분석·자소서·면접 준비 등)을 시작하기 직전에 호출하세요. 표준 워크플로우의 단계별 안내에 더해, 그 작업의 "전문가 실행 절차(EOP)"와 적용할 전문가 플레이북·저장 전 검증기 순서를 한 번에 돌려줍니다. 안내된 대로 get_playbook으로 도메인 지식을, 저장 직전 get_verifier로 검증 루브릭을 받아 적용하세요. workflow_id를 생략하면 전체 목록을 돌려줍니다.',
982
+ '커리어 작업(공고 분석·자소서·경력기술서·면접 준비 등)을 시작하기 직전에 호출하세요. 표준 워크플로우의 단계별 안내에 더해, 그 작업의 "전문가 실행 절차(EOP)"와 적용할 전문가 플레이북·저장 전 검증기 순서를 한 번에 돌려줍니다. 안내된 대로 get_playbook으로 도메인 지식을, 저장 직전 get_verifier로 검증 루브릭을 받아 적용하세요. workflow_id를 생략하면 전체 목록을 돌려줍니다.',
784
983
  inputSchema: {
785
984
  workflow_id: z
786
- .enum(['onboarding', 'analyze_job', 'write_cover_letter', 'manage_application_status', 'prepare_interview'])
985
+ .enum([
986
+ 'onboarding',
987
+ 'analyze_job',
988
+ 'write_cover_letter',
989
+ 'write_career_description',
990
+ 'manage_application_status',
991
+ 'prepare_interview',
992
+ 'build_personal_brand',
993
+ ])
787
994
  .optional(),
788
995
  },
789
996
  readOnly: true,
@@ -899,10 +1106,27 @@ export const TOOLS: ToolDef[] = [
899
1106
  name: 'get_writing_style_guide',
900
1107
  title: 'AI 티 안 나는 글쓰기 가이드',
901
1108
  description:
902
- '자기소개서·자기 PR·지원 메일 등 사람이 쓴 듯한 글을 작성하기 직전에 호출하세요. "AI가 쓴 티"가 나는 한국어 문장 습관(번역투, 클리셰, 기계적 병렬, 상투적 연결어, 균일한 문장 리듬 등)을 제거하는 작성 규칙과 저장 전 자가 점검 항목을 돌려줍니다. 사실·수치·고유명사는 그대로 두고 문장 결만 다듬도록 안내합니다. write_cover_letter / analyze_job 워크플로우에서 자기소개서를 쓸 때 반드시 함께 적용하세요.',
903
- inputSchema: {},
1109
+ '자기소개서·자기 PR·지원 메일 등 사람이 쓴 듯한 글을 작성하기 직전에 호출하세요. "AI가 쓴 티"가 나는 한국어 문장 습관(번역투, 클리셰, 기계적 병렬, 상투적 연결어, 균일한 문장 리듬 등)을 제거하는 작성 규칙과 저장 전 자가 점검 항목을 돌려줍니다. 사실·수치·고유명사는 그대로 두고 문장 결만 다듬도록 안내합니다. 경력기술서·이력서·포트폴리오를 쓸 때는 doc_type을 함께 주면 불릿·번호 성과 같은 구조를 산문으로 평탄화하지 않도록 구조 보존 보정 지침을 더해 돌려줍니다. write_cover_letter / write_career_description / analyze_job 워크플로우에서 글을 쓸 때 반드시 함께 적용하세요.',
1110
+ inputSchema: {
1111
+ doc_type: z
1112
+ .enum([
1113
+ 'cover_letter',
1114
+ 'self_pr',
1115
+ 'application_email',
1116
+ 'career_description',
1117
+ 'resume',
1118
+ 'portfolio',
1119
+ ])
1120
+ .optional()
1121
+ .describe(
1122
+ '작성할 문서 유형. career_description·resume·portfolio는 불릿·구조를 보존하는 보정을 더해 돌려줍니다. 생략하면 자기소개서 등 산문형 기준.',
1123
+ ),
1124
+ },
904
1125
  readOnly: true,
905
- handler: () => ok('AI 티 안 나는 글쓰기 가이드를 적용해 작성하세요.', { guide: HUMANIZE_WRITING_GUIDE }),
1126
+ handler: (args) =>
1127
+ ok('AI 티 안 나는 글쓰기 가이드를 적용해 작성하세요.', {
1128
+ guide: getWritingStyleGuide(args?.doc_type),
1129
+ }),
906
1130
  },
907
1131
 
908
1132
  /* ----------------------------------------------------------------- update */
@@ -914,7 +1138,7 @@ export const TOOLS: ToolDef[] = [
914
1138
  inputSchema: {},
915
1139
  readOnly: true,
916
1140
  handler: async () => {
917
- const u = await getUpdateStatusAsync();
1141
+ const u = await getUpdateStatusAsync(true); // "지금 확인" — 신선한 캐시 무시하고 재조회
918
1142
  if (u.latest === null) return ok('업데이트 확인에 실패했습니다(오프라인이거나 일시적 오류). 잠시 후 다시 시도하세요.', u);
919
1143
  const user_message = u.update_available
920
1144
  ? `새 버전 v${u.latest}이 나왔어요. 현재 버전은 v${u.current}입니다. 원하시면 지금 업데이트할 수 있습니다.`