careermate 0.0.9 → 0.0.10

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.
@@ -0,0 +1,126 @@
1
+ import { coverLetterRepo, experienceRepo, projectRepo, jobRepo } from '@careermate/db';
2
+
3
+ export interface ReuseReference {
4
+ kind: 'experience' | 'project';
5
+ entity_id: string;
6
+ label: string;
7
+ cover_letters: Array<{ id: string; title: string; job_id: string | null; company: string | null; position: string | null }>;
8
+ reuse_count: number;
9
+ }
10
+
11
+ export interface DiffLine {
12
+ type: 'equal' | 'add' | 'remove';
13
+ text: string;
14
+ }
15
+
16
+ function containsNormalized(content: string, needle: string): boolean {
17
+ const normalize = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim();
18
+ const n = normalize(needle);
19
+ return n.length >= 2 && normalize(content).includes(n);
20
+ }
21
+
22
+ export function getExperienceReuseMap(): ReuseReference[] {
23
+ const letters = coverLetterRepo.list().map((letter) => coverLetterRepo.get(letter.id, true)!).filter(Boolean);
24
+ const jobs = new Map(jobRepo.list().map((job) => [job.id, job]));
25
+ const refs: ReuseReference[] = [];
26
+
27
+ for (const exp of experienceRepo.list()) {
28
+ const needles = [exp.company, exp.role].filter((v): v is string => !!v && v.trim().length >= 2);
29
+ const matches = letters.filter((letter) => {
30
+ const body = letter.current_content ?? '';
31
+ return needles.some((needle) => containsNormalized(body, needle));
32
+ });
33
+ refs.push({
34
+ kind: 'experience',
35
+ entity_id: exp.id,
36
+ label: [exp.company, exp.role].filter(Boolean).join(' · '),
37
+ cover_letters: matches.map((letter) => {
38
+ const job = letter.job_id ? jobs.get(letter.job_id) : null;
39
+ return {
40
+ id: letter.id,
41
+ title: letter.title,
42
+ job_id: letter.job_id,
43
+ company: job?.company ?? null,
44
+ position: job?.position ?? null,
45
+ };
46
+ }),
47
+ reuse_count: matches.length,
48
+ });
49
+ }
50
+
51
+ for (const project of projectRepo.list()) {
52
+ const matches = letters.filter((letter) => containsNormalized(letter.current_content ?? '', project.name));
53
+ refs.push({
54
+ kind: 'project',
55
+ entity_id: project.id,
56
+ label: project.name,
57
+ cover_letters: matches.map((letter) => {
58
+ const job = letter.job_id ? jobs.get(letter.job_id) : null;
59
+ return {
60
+ id: letter.id,
61
+ title: letter.title,
62
+ job_id: letter.job_id,
63
+ company: job?.company ?? null,
64
+ position: job?.position ?? null,
65
+ };
66
+ }),
67
+ reuse_count: matches.length,
68
+ });
69
+ }
70
+
71
+ return refs.sort((a, b) => b.reuse_count - a.reuse_count || a.label.localeCompare(b.label));
72
+ }
73
+
74
+ export function diffText(before: string, after: string): DiffLine[] {
75
+ const a = before.replace(/\r\n/g, '\n').split('\n');
76
+ const b = after.replace(/\r\n/g, '\n').split('\n');
77
+ const rows = a.length + 1;
78
+ const cols = b.length + 1;
79
+ const dp = Array.from({ length: rows }, () => new Uint32Array(cols));
80
+
81
+ for (let i = a.length - 1; i >= 0; i--) {
82
+ for (let j = b.length - 1; j >= 0; j--) {
83
+ dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
84
+ }
85
+ }
86
+
87
+ const result: DiffLine[] = [];
88
+ let i = 0;
89
+ let j = 0;
90
+ while (i < a.length && j < b.length) {
91
+ if (a[i] === b[j]) {
92
+ result.push({ type: 'equal', text: a[i]! });
93
+ i++;
94
+ j++;
95
+ } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
96
+ result.push({ type: 'remove', text: a[i]! });
97
+ i++;
98
+ } else {
99
+ result.push({ type: 'add', text: b[j]! });
100
+ j++;
101
+ }
102
+ }
103
+ while (i < a.length) result.push({ type: 'remove', text: a[i++]! });
104
+ while (j < b.length) result.push({ type: 'add', text: b[j++]! });
105
+ return result;
106
+ }
107
+
108
+ export function diffCoverLetterVersions(coverLetterId: string, beforeVersionId: string, afterVersionId: string) {
109
+ const letter = coverLetterRepo.get(coverLetterId, true);
110
+ if (!letter?.versions) return null;
111
+ const before = letter.versions.find((version) => version.id === beforeVersionId);
112
+ const after = letter.versions.find((version) => version.id === afterVersionId);
113
+ if (!before || !after) return null;
114
+ const lines = diffText(before.content, after.content);
115
+ return {
116
+ cover_letter_id: coverLetterId,
117
+ before: { id: before.id, version_no: before.version_no, source: before.source, created_at: before.created_at },
118
+ after: { id: after.id, version_no: after.version_no, source: after.source, created_at: after.created_at },
119
+ summary: {
120
+ added: lines.filter((line) => line.type === 'add').length,
121
+ removed: lines.filter((line) => line.type === 'remove').length,
122
+ unchanged: lines.filter((line) => line.type === 'equal').length,
123
+ },
124
+ lines,
125
+ };
126
+ }
@@ -19,7 +19,8 @@ const PRE_MIGRATE_KEEP = 5;
19
19
  * `VACUUM INTO` a destination path, with the guard both backup callers need. The
20
20
  * data dir can be set via env, so `destPath` isn't fully trusted: reject NUL/newline
21
21
  * (can't be embedded safely in the SQL string literal) and single-quote-escape the
22
- * rest. Throws on an unsafe path so the caller can fall back to fs.copyFileSync.
22
+ * rest. A failure is intentionally surfaced: copying only the live main SQLite
23
+ * file while WAL is active can produce a stale or incomplete snapshot.
23
24
  */
24
25
  export function vacuumInto(db: DatabaseSync, destPath: string): void {
25
26
  if (/[\0\n\r]/.test(destPath)) throw new Error('backup path not safe for SQL literal');
@@ -28,10 +29,9 @@ export function vacuumInto(db: DatabaseSync, destPath: string): void {
28
29
 
29
30
  /**
30
31
  * Snapshot the DB into backups/ right before a schema upgrade runs. The DB is
31
- * the user's only copy, so even though each migration is transactional, a bad
32
- * migration shipped in an update must never be able to destroy data without a
33
- * restore point. Best-effort: a failed backup logs to stderr but does not block
34
- * boot (blocking would take both the dashboard and the MCP server down).
32
+ * the user's only copy, so a schema upgrade must not proceed without a restore
33
+ * point. Operators may explicitly opt out for emergency recovery with
34
+ * CAREERMATE_ALLOW_UNBACKED_MIGRATION=1.
35
35
  */
36
36
  function preMigrationBackup(db: DatabaseSync): void {
37
37
  try {
@@ -48,11 +48,9 @@ function preMigrationBackup(db: DatabaseSync): void {
48
48
  const dir = getBackupsDir();
49
49
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
50
50
  const dest = path.join(dir, `pre-migrate-v${from}-${stamp}.sqlite`);
51
- try {
52
- vacuumInto(db, dest);
53
- } catch {
54
- fs.copyFileSync(getDbPath(), dest); // WAL is checkpointed on close; copy is a coarse fallback
55
- }
51
+ // Never fall back to copying only the main file while WAL is active. If a
52
+ // consistent snapshot cannot be created, the migration must not start.
53
+ vacuumInto(db, dest);
56
54
 
57
55
  // Prune old snapshots (ISO stamp in the name → lexicographic sort == time sort).
58
56
  // fs.unlinkSync only — rmSync hard-crashes on Node 25/Windows.
@@ -70,9 +68,17 @@ function preMigrationBackup(db: DatabaseSync): void {
70
68
  }
71
69
  }
72
70
  } catch (err) {
73
- // stderr only — stdout is the MCP transport channel.
74
- console.error(
75
- `[careermate] 업그레이드 전 자동 백업 실패(마이그레이션은 계속 진행): ${err instanceof Error ? err.message : String(err)}`,
71
+ const reason = err instanceof Error ? err.message : String(err);
72
+ if (process.env.CAREERMATE_ALLOW_UNBACKED_MIGRATION === '1') {
73
+ console.error(
74
+ `[careermate] 업그레이드 전 자동 백업 실패 — 명시적 우회로 마이그레이션을 계속합니다: ${reason}`,
75
+ );
76
+ return;
77
+ }
78
+ throw new Error(
79
+ `데이터베이스 업그레이드 전 백업을 만들지 못해 시작을 중단했습니다. ` +
80
+ `데이터 위치를 확인하고 다시 시도하세요. 긴급 복구 시에만 ` +
81
+ `CAREERMATE_ALLOW_UNBACKED_MIGRATION=1을 설정할 수 있습니다. 원인: ${reason}`,
76
82
  );
77
83
  }
78
84
  }
@@ -80,13 +86,22 @@ function preMigrationBackup(db: DatabaseSync): void {
80
86
  export function getDb(): DatabaseSync {
81
87
  if (_db) return _db;
82
88
  const db = new DatabaseSync(getDbPath());
83
- db.exec('PRAGMA journal_mode = WAL;');
84
- db.exec('PRAGMA foreign_keys = ON;');
85
- db.exec('PRAGMA busy_timeout = 5000;');
86
- preMigrationBackup(db);
87
- migrate(db);
88
- _db = db;
89
- return db;
89
+ try {
90
+ db.exec('PRAGMA journal_mode = WAL;');
91
+ db.exec('PRAGMA foreign_keys = ON;');
92
+ db.exec('PRAGMA busy_timeout = 5000;');
93
+ preMigrationBackup(db);
94
+ migrate(db);
95
+ _db = db;
96
+ return db;
97
+ } catch (err) {
98
+ try {
99
+ db.close();
100
+ } catch {
101
+ /* best-effort */
102
+ }
103
+ throw err;
104
+ }
90
105
  }
91
106
 
92
107
  export function closeDb(): void {
@@ -99,19 +114,24 @@ export function closeDb(): void {
99
114
  let _inTx = false;
100
115
 
101
116
  /**
102
- * Run `fn` inside a single transaction so multi-statement writes are atomic:
103
- * any thrown error rolls back every statement instead of leaving a half-written
104
- * row. node:sqlite has no transaction() helper, so we drive BEGIN/COMMIT/ROLLBACK
105
- * directly. Re-entrant: a nested tx() call joins the outer transaction (SQLite
106
- * has no nested transactions), so only the outermost call commits or rolls back.
117
+ * Run `fn` inside a single synchronous transaction so multi-statement writes are
118
+ * atomic. Promise-returning callbacks are rejected because committing before an
119
+ * async callback settles would silently break transaction guarantees.
107
120
  */
108
121
  export function tx<T>(fn: () => T): T {
109
- if (_inTx) return fn();
122
+ if (_inTx) {
123
+ const nested = fn();
124
+ if (nested instanceof Promise) throw new TypeError('tx() does not accept async callbacks');
125
+ return nested;
126
+ }
110
127
  const db = getDb();
111
128
  db.exec('BEGIN');
112
129
  _inTx = true;
113
130
  try {
114
131
  const result = fn();
132
+ if (result instanceof Promise) {
133
+ throw new TypeError('tx() does not accept async callbacks');
134
+ }
115
135
  db.exec('COMMIT');
116
136
  return result;
117
137
  } catch (err) {
@@ -7,6 +7,8 @@ export * from './paths.ts';
7
7
  export * from './runtime.ts';
8
8
  export * from './connection.ts';
9
9
  export * from './settings.ts';
10
+ export * from './provenance.ts';
11
+ export * from './interview-retrospectives.ts';
10
12
  export { migrate, MIGRATIONS } from './schema.ts';
11
13
  export {
12
14
  profileRepo,
@@ -0,0 +1,149 @@
1
+ import { getDb } from './connection.ts';
2
+
3
+ export interface InterviewRetrospective {
4
+ id: string;
5
+ job_id: string;
6
+ interview_round: string | null;
7
+ occurred_at: string | null;
8
+ questions: string[];
9
+ strong_answers: string[];
10
+ weak_answers: string[];
11
+ follow_up_actions: string[];
12
+ notes: string | null;
13
+ created_at: string;
14
+ updated_at: string;
15
+ }
16
+
17
+ function ensureTable(): void {
18
+ getDb().exec(`
19
+ CREATE TABLE IF NOT EXISTS interview_retrospectives (
20
+ id TEXT PRIMARY KEY,
21
+ job_id TEXT NOT NULL,
22
+ interview_round TEXT,
23
+ occurred_at TEXT,
24
+ questions TEXT NOT NULL DEFAULT '[]',
25
+ strong_answers TEXT NOT NULL DEFAULT '[]',
26
+ weak_answers TEXT NOT NULL DEFAULT '[]',
27
+ follow_up_actions TEXT NOT NULL DEFAULT '[]',
28
+ notes TEXT,
29
+ created_at TEXT NOT NULL,
30
+ updated_at TEXT NOT NULL
31
+ );
32
+ CREATE INDEX IF NOT EXISTS idx_interview_retrospectives_job
33
+ ON interview_retrospectives(job_id, occurred_at DESC, updated_at DESC);
34
+ CREATE TRIGGER IF NOT EXISTS trg_interview_retrospectives_delete_job
35
+ AFTER DELETE ON jobs
36
+ BEGIN
37
+ DELETE FROM interview_retrospectives WHERE job_id = OLD.id;
38
+ END;
39
+ `);
40
+ }
41
+
42
+ function parseList(value: unknown): string[] {
43
+ if (typeof value !== 'string') return [];
44
+ try {
45
+ const parsed = JSON.parse(value);
46
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : [];
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ function map(row: any): InterviewRetrospective {
53
+ return {
54
+ id: row.id,
55
+ job_id: row.job_id,
56
+ interview_round: row.interview_round,
57
+ occurred_at: row.occurred_at,
58
+ questions: parseList(row.questions),
59
+ strong_answers: parseList(row.strong_answers),
60
+ weak_answers: parseList(row.weak_answers),
61
+ follow_up_actions: parseList(row.follow_up_actions),
62
+ notes: row.notes,
63
+ created_at: row.created_at,
64
+ updated_at: row.updated_at,
65
+ };
66
+ }
67
+
68
+ function id(): string {
69
+ return `retro_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
70
+ }
71
+
72
+ export const interviewRetrospectiveRepo = {
73
+ listAll(limit = 100): InterviewRetrospective[] {
74
+ ensureTable();
75
+ const safeLimit = Math.max(1, Math.min(500, Math.floor(limit)));
76
+ return (
77
+ getDb()
78
+ .prepare(`SELECT * FROM interview_retrospectives ORDER BY occurred_at DESC, updated_at DESC LIMIT ?`)
79
+ .all(safeLimit) as any[]
80
+ ).map(map);
81
+ },
82
+
83
+ listByJob(jobId: string): InterviewRetrospective[] {
84
+ ensureTable();
85
+ return (
86
+ getDb()
87
+ .prepare(`SELECT * FROM interview_retrospectives WHERE job_id = ? ORDER BY occurred_at DESC, updated_at DESC`)
88
+ .all(jobId) as any[]
89
+ ).map(map);
90
+ },
91
+
92
+ save(input: {
93
+ id?: string;
94
+ job_id: string;
95
+ interview_round?: string | null;
96
+ occurred_at?: string | null;
97
+ questions?: string[];
98
+ strong_answers?: string[];
99
+ weak_answers?: string[];
100
+ follow_up_actions?: string[];
101
+ notes?: string | null;
102
+ }): InterviewRetrospective {
103
+ ensureTable();
104
+ const now = new Date().toISOString();
105
+ const recordId = input.id ?? id();
106
+ const existing = getDb().prepare(`SELECT * FROM interview_retrospectives WHERE id = ?`).get(recordId) as any;
107
+ getDb()
108
+ .prepare(`
109
+ INSERT INTO interview_retrospectives (
110
+ id, job_id, interview_round, occurred_at, questions, strong_answers,
111
+ weak_answers, follow_up_actions, notes, created_at, updated_at
112
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
113
+ ON CONFLICT(id) DO UPDATE SET
114
+ job_id = excluded.job_id,
115
+ interview_round = excluded.interview_round,
116
+ occurred_at = excluded.occurred_at,
117
+ questions = excluded.questions,
118
+ strong_answers = excluded.strong_answers,
119
+ weak_answers = excluded.weak_answers,
120
+ follow_up_actions = excluded.follow_up_actions,
121
+ notes = excluded.notes,
122
+ updated_at = excluded.updated_at
123
+ `)
124
+ .run(
125
+ recordId,
126
+ input.job_id,
127
+ input.interview_round ?? existing?.interview_round ?? null,
128
+ input.occurred_at ?? existing?.occurred_at ?? null,
129
+ JSON.stringify(input.questions ?? parseList(existing?.questions)),
130
+ JSON.stringify(input.strong_answers ?? parseList(existing?.strong_answers)),
131
+ JSON.stringify(input.weak_answers ?? parseList(existing?.weak_answers)),
132
+ JSON.stringify(input.follow_up_actions ?? parseList(existing?.follow_up_actions)),
133
+ input.notes ?? existing?.notes ?? null,
134
+ existing?.created_at ?? now,
135
+ now,
136
+ );
137
+ return map(getDb().prepare(`SELECT * FROM interview_retrospectives WHERE id = ?`).get(recordId));
138
+ },
139
+
140
+ remove(retrospectiveId: string): boolean {
141
+ ensureTable();
142
+ return getDb().prepare(`DELETE FROM interview_retrospectives WHERE id = ?`).run(retrospectiveId).changes > 0;
143
+ },
144
+ };
145
+
146
+ // Keep the table available before settings import/export enumerates sqlite_master.
147
+ // This also installs the job-deletion cleanup trigger for databases created before
148
+ // interview retrospectives were introduced.
149
+ ensureTable();
@@ -0,0 +1,125 @@
1
+ import { getDb } from './connection.ts';
2
+
3
+ export type ProvenanceSource = 'human' | 'ai' | 'import' | 'document';
4
+
5
+ export interface EntityProvenance {
6
+ entity_type: string;
7
+ entity_id: string;
8
+ source: ProvenanceSource;
9
+ source_document_id: string | null;
10
+ source_excerpt: string | null;
11
+ verified_by_human: boolean;
12
+ verified_at: string | null;
13
+ created_at: string;
14
+ updated_at: string;
15
+ }
16
+
17
+ function ensureTable(): void {
18
+ getDb().exec(`
19
+ CREATE TABLE IF NOT EXISTS entity_provenance (
20
+ entity_type TEXT NOT NULL,
21
+ entity_id TEXT NOT NULL,
22
+ source TEXT NOT NULL CHECK(source IN ('human','ai','import','document')),
23
+ source_document_id TEXT,
24
+ source_excerpt TEXT,
25
+ verified_by_human INTEGER NOT NULL DEFAULT 0,
26
+ verified_at TEXT,
27
+ created_at TEXT NOT NULL,
28
+ updated_at TEXT NOT NULL,
29
+ PRIMARY KEY(entity_type, entity_id)
30
+ );
31
+ CREATE INDEX IF NOT EXISTS idx_entity_provenance_verified
32
+ ON entity_provenance(verified_by_human, updated_at DESC);
33
+ `);
34
+ }
35
+
36
+ function map(row: any): EntityProvenance {
37
+ return {
38
+ entity_type: row.entity_type,
39
+ entity_id: row.entity_id,
40
+ source: row.source,
41
+ source_document_id: row.source_document_id,
42
+ source_excerpt: row.source_excerpt,
43
+ verified_by_human: Number(row.verified_by_human) === 1,
44
+ verified_at: row.verified_at,
45
+ created_at: row.created_at,
46
+ updated_at: row.updated_at,
47
+ };
48
+ }
49
+
50
+ export const provenanceRepo = {
51
+ get(entityType: string, entityId: string): EntityProvenance | null {
52
+ ensureTable();
53
+ const row = getDb()
54
+ .prepare(`SELECT * FROM entity_provenance WHERE entity_type = ? AND entity_id = ?`)
55
+ .get(entityType, entityId);
56
+ return row ? map(row) : null;
57
+ },
58
+
59
+ listUnverified(): EntityProvenance[] {
60
+ ensureTable();
61
+ return (
62
+ getDb()
63
+ .prepare(`SELECT * FROM entity_provenance WHERE verified_by_human = 0 ORDER BY updated_at DESC`)
64
+ .all() as any[]
65
+ ).map(map);
66
+ },
67
+
68
+ save(input: {
69
+ entity_type: string;
70
+ entity_id: string;
71
+ source: ProvenanceSource;
72
+ source_document_id?: string | null;
73
+ source_excerpt?: string | null;
74
+ verified_by_human?: boolean;
75
+ }): EntityProvenance {
76
+ ensureTable();
77
+ const existing = this.get(input.entity_type, input.entity_id);
78
+ const now = new Date().toISOString();
79
+ const verified = input.verified_by_human ?? existing?.verified_by_human ?? input.source === 'human';
80
+ const verifiedAt = verified ? existing?.verified_at ?? now : null;
81
+ getDb()
82
+ .prepare(`
83
+ INSERT INTO entity_provenance (
84
+ entity_type, entity_id, source, source_document_id, source_excerpt,
85
+ verified_by_human, verified_at, created_at, updated_at
86
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
87
+ ON CONFLICT(entity_type, entity_id) DO UPDATE SET
88
+ source = excluded.source,
89
+ source_document_id = excluded.source_document_id,
90
+ source_excerpt = excluded.source_excerpt,
91
+ verified_by_human = excluded.verified_by_human,
92
+ verified_at = excluded.verified_at,
93
+ updated_at = excluded.updated_at
94
+ `)
95
+ .run(
96
+ input.entity_type,
97
+ input.entity_id,
98
+ input.source,
99
+ input.source_document_id ?? existing?.source_document_id ?? null,
100
+ input.source_excerpt ?? existing?.source_excerpt ?? null,
101
+ verified ? 1 : 0,
102
+ verifiedAt,
103
+ existing?.created_at ?? now,
104
+ now,
105
+ );
106
+ return this.get(input.entity_type, input.entity_id)!;
107
+ },
108
+
109
+ setHumanVerified(entityType: string, entityId: string, verified: boolean): EntityProvenance {
110
+ const existing = this.get(entityType, entityId);
111
+ return this.save({
112
+ entity_type: entityType,
113
+ entity_id: entityId,
114
+ source: existing?.source ?? 'human',
115
+ source_document_id: existing?.source_document_id,
116
+ source_excerpt: existing?.source_excerpt,
117
+ verified_by_human: verified,
118
+ });
119
+ },
120
+ };
121
+
122
+ // Backup import discovers allowed tables from sqlite_master before any provenance
123
+ // endpoint is called. Create this sidecar table when @careermate/db loads so a
124
+ // backup made on another machine is accepted on a completely fresh installation.
125
+ ensureTable();
@@ -1,67 +1,24 @@
1
1
  /**
2
- * @careermate/prompts — system.ts
2
+ * Global MCP operating rules.
3
3
  *
4
- * CAREERMATE_SYSTEM_PROMPT teaches an AI assistant (ChatGPT / Claude / Gemini)
5
- * HOW to behave as a good CareerMate operator. CareerMate itself contains no LLM;
6
- * the user's own AI does all reading, analysis, and writing through MCP tools.
7
- *
8
- * Written in Korean: it is read both by Korean users and by the AI on their behalf.
4
+ * Keep this prompt intentionally short. Detailed sequencing belongs in
5
+ * get_workflow_guide/context responses so clients do not receive duplicated or
6
+ * conflicting routing instructions on every turn.
9
7
  */
10
-
11
- export const CAREERMATE_SYSTEM_PROMPT = `당신은 사용자의 'CareerMate' 커리어 비서입니다.
12
-
13
- CareerMate는 사용자의 컴퓨터에서 동작하는 로컬 커리어 관리 도구이며, MCP(툴 호출)를 통해 사용자의 프로필·이력서·자기소개서·지원 공고·지원 현황 데이터를 읽고 씁니다. CareerMate 안에는 별도의 AI가 없습니다. 모든 분석·작성·요약은 '당신'(사용자의 AI)이 MCP 툴을 호출해 수행합니다.
14
-
15
- # 핵심 행동 원칙
16
-
17
- 1. 항상 맥락을 먼저 불러옵니다.
18
- - 공고를 분석하거나 자기소개서를 쓰기 전에는 반드시 \`get_application_context\`를 먼저 호출하세요. 이 한 번의 호출로 프로필, 대표 이력서, 경력(experiences), 프로젝트(projects), 스킬(skills), 기존 자기소개서, 최근 지원 현황, 대상 공고(job_id 전달 시), 이전 적합도 분석, 같은 회사/직무 관련 기록, 그리고 사용자의 글쓰기 선호(선호 문체 preferred_tone + 강조 포인트 emphasis_points)를 한꺼번에 받습니다.
19
- - 일반적인 상태 확인이 필요하면 \`get_onboarding_status\`, \`get_profile\`, \`get_resumes\`, \`get_cover_letters\`, \`list_recent_activity\` 등 읽기 툴을 적극 사용하세요.
20
- - 기억에 의존하지 말고, 매 작업 시작 시 실제 데이터를 다시 불러오세요.
21
-
22
- 2. 결과는 반드시 다시 저장합니다.
23
- - 분석·작성한 내용은 머릿속이나 대화창에만 남기지 말고 해당 저장 툴로 CareerMate에 기록하세요: 공고는 \`save_job_posting\`, 적합도 분석은 \`save_fit_analysis\`, 자기소개서 버전은 \`save_cover_letter_version\`, 경력기술서는 \`add_resume\`(kind=career_description), 면접 준비는 \`save_interview_prep\`, 상태 변경은 \`update_application_status\`.
24
- - 여러 문서를 한 번에 요청받아도 각 문서는 각자의 저장 툴로 따로 저장하세요. 경력기술서와 자기소개서를 한 턴에 함께 만들었더라도 한 저장 호출의 본문에 다른 문서를 이어붙이지 마세요(경력기술서=\`add_resume\` kind=career_description, 자기소개서=\`save_cover_letter_version\`로 각각 분리 저장).
25
- - 저장 후에는 무엇이 저장되었는지 사용자에게 간단히 알려주세요.
26
-
27
- 3. 데이터를 지어내지 않습니다.
28
- - 프로필·경력·성과·수치·회사명 등은 사용자가 제공했거나 CareerMate에 저장된 것만 사용하세요. 불확실하면 추측하지 말고 사용자에게 물어보세요.
29
- - 자기소개서에 검증되지 않은 경험이나 과장된 성과를 넣지 마세요. 빈칸이 있으면 "이 부분은 확인이 필요합니다"라고 표시하고 사용자에게 확인을 요청하세요.
30
-
31
- 4. 로컬-퍼스트 / 프라이버시를 존중합니다.
32
- - 모든 데이터는 사용자의 컴퓨터에만 저장됩니다. 데이터를 외부로 보내거나 다른 용도로 쓰지 마세요.
33
- - 민감 정보(연락처, 주소 등)는 꼭 필요한 작업에만 사용하고 불필요하게 노출하지 마세요.
34
-
35
- 5. 자기소개서는 작성 전 확인을 받습니다.
36
- - 자기소개서나 경력기술서를 새로 쓰거나 크게 고치기 전에, 어떤 공고/직무를 대상으로 어떤 톤과 강조점으로 쓸지 사용자에게 먼저 확인하세요.
37
- - 작성 후에는 \`save_cover_letter_version\`으로 버전을 저장하고, 변경 요약을 \`note\`에 남기세요. 사용자가 원하면 \`export_cover_letter\`로 내보내세요.
38
-
39
- 6. 사용자에게는 사용자의 언어로 쉽게 알립니다.
40
- - **응답 언어 = 사용자의 언어.** 사용자가 한국어로 말하면 한국어로, 영어 등 다른 언어로 말하면 그 언어로 답합니다. 이 지침·도구 응답·플레이북이 한국어로 적혀 있어도 그대로 복사하지 말고 사용자 언어로 옮겨 전달하세요(사실·수치·고유명사는 보존).
41
- - "MCP", "툴 호출", "job_id" 같은 기술 용어는 사용자에게 직접 노출하지 마세요. "공고를 저장했어요", "적합도를 분석했어요"처럼 일상적인 표현으로 진행 상황을 알리세요.
42
- - 도구 응답에 \`user_message\`가 있으면 사용자에게 보고할 때 그 내용을 우선 활용하되, 사용자가 한국어가 아닌 언어로 대화하면 한국어 원문을 그대로 복사하지 말고 사용자 언어로 옮겨 전달하세요. \`id\`, \`job_id\`, \`cover_letter_id\`, \`workflow_id\`, JSON 원문, 도구 이름은 내부 작업용으로만 쓰고 사용자에게 그대로 복사하지 마세요.
43
- - 도구 응답에 \`suggested_next_action\`이 있으면 다음 행동을 강요하지 말고 선택지로 제안하세요.
44
- - 무엇을 했고, 무엇을 알게 되었고, 다음에 무엇을 할 수 있는지 짧고 명확하게 전달하세요.
45
-
46
- 7. 다음 단계를 먼저 제안합니다.
47
- - 작업이 끝나면 자연스러운 다음 행동을 제안하세요. 예: 공고 분석 후 → "이 공고에 맞춘 자기소개서를 써드릴까요?", 경력기술서 저장 후 → "이 공고에 맞춰 자기소개서도 정리할까요?", 자기소개서 저장 후 → "지원 상태를 '지원 완료'로 바꿀까요?", 상태가 '서류 합격'이 되면 → "면접 준비를 도와드릴까요?".
48
- - 공고 분석 후에는 곧장 자기소개서를 쓰지 말고, 반드시 선택지를 보여주세요: "1. 네, 이 공고에 맞춘 자기소개서를 써줘" / "2. 아니요, 분석만 저장해줘". 사용자가 1번을 고른 경우에만 자기소개서 작성 흐름으로 이어갑니다.
49
- - \`get_onboarding_status\`의 next_steps나 \`open_dashboard\`/\`open_application\`을 활용해 사용자를 다음 행동으로 안내하세요.
50
-
51
- 8. 업데이트 요청은 명확하게 처리합니다.
52
- - 사용자가 "최신이야?", "업데이트 있어?"처럼 확인만 요청하면 \`check_for_update\`를 호출하고 결과를 쉬운 말로 알려주세요.
53
- - 사용자가 "업데이트해줘", "CareerMate 업데이트해줘", "최신으로 올려줘"처럼 적용을 명시하면 \`update_careermate\`를 바로 호출하세요. 이 도구가 최신 버전 확인도 함께 합니다.
54
- - 업데이트 성공 후에는 적용하려면 AI 앱 또는 MCP 연결을 재시작해야 한다고 안내하세요. 사용자 커리어 데이터는 로컬에 보존되고, npm에는 공개 버전 정보 확인/설치 요청만 나갑니다.
55
-
56
- 9. 글은 AI 티 안 나게 씁니다.
57
- - 자기소개서·자기 PR·지원 메일 등 사람이 쓴 듯한 글을 작성하기 직전에는 \`get_writing_style_guide\`를 호출해 글쓰기 규칙을 가져오고 그대로 적용하세요.
58
- - 번역투, "열정을 가지고/귀사의 발전에 기여" 같은 클리셰, 기계적 병렬(첫째·둘째·셋째), 상투적 연결어, 균일한 문장 리듬을 피하고 담백하고 구체적으로 씁니다. 단, 사실·수치·고유명사는 절대 바꾸거나 지어내지 않습니다. 이 AI-tell 목록은 한국어 글 기준이며, 다른 언어로 쓸 때는 같은 원리를 그 언어의 자연스러운 관습으로 적용합니다(산출물 언어·관행은 대상 공고·시장을 따름).
59
-
60
- 10. 전문가 절차로 만들고, 저장 전 당신이 자가검증합니다.
61
- - 공고 분석·자소서·경력기술서·면접 자료처럼 사용자 대면 결과를 만들기 전에는, 즉답하지 말고 \`get_workflow_guide\`(workflow_id 지정)를 먼저 호출해 그 작업의 전문가 실행 절차(EOP)와 적용할 플레이북·검증기 순서를 받으세요. 이어 \`get_playbook\`으로 도메인 지식을 받아 작성하고, save로 저장하기 직전 \`get_verifier\`로 검증 루브릭을 받아 **당신이 직접** 점검·수정한 뒤 저장합니다.
62
- - CareerMate는 슬롭 밀도·키워드 매칭처럼 셀 수 있는 항목만 보조로 돕고, 의미 판단(이 글이 질문에 답하는가·톤이 맞는가)은 당신이 합니다. 사실·수치·고유명사는 그대로 보존합니다.
63
-
64
- # 작업 방식 요약
65
- - 읽기(get_*) → 분석/작성 → 저장(save_*/update_*) → 사용자에게 쉬운 말로 보고 → 다음 단계 제안.
66
- - 8가지 지원 상태: 작성 중(draft), 지원 예정(planned), 지원 완료(applied), 서류 합격(document_passed), 면접 진행(interview), 최종 합격(final_passed), 불합격(rejected), 보류(on_hold).
67
- - 의심스러우면 멈추고 묻습니다. 사용자의 커리어가 걸린 일이므로 정확함이 속도보다 중요합니다.`;
8
+ export const CAREERMATE_SYSTEM_PROMPT = `당신은 사용자의 CareerMate 커리어 비서입니다. CareerMate는 사용자의 로컬 데이터베이스와 문서 저장소이며, 분석과 작성은 연결된 AI가 수행합니다.
9
+
10
+ # 불변 규칙
11
+ 1. 작업에 필요한 사용자·공고 컨텍스트만 먼저 조회합니다. 전체 데이터를 습관적으로 불러오지 않습니다.
12
+ 2. 회사, 역할, 기술, 기간, 수치, 성과를 만들거나 과장하지 않습니다. 공고 요구사항을 사용자 경험처럼 쓰지 않습니다.
13
+ 3. 사용자 확인 사실, AI가 구조화한 미확인 정보, AI의 추정을 명확히 구분합니다.
14
+ 4. 저장 전 사실·수치·기간·개인 기여·문서 간 일관성을 검증합니다. 불확실한 내용은 문장에 넣지 말고 최대 3~5개의 구체적인 확인 질문으로 반환합니다.
15
+ 5. 결과물에는 사용한 근거, 미확인 항목, 위험 또는 경고를 함께 반환합니다. 수치는 확인된 구조화 데이터나 source excerpt가 있을 때만 사용합니다.
16
+ 6. 서로 다른 문서는 각 전용 저장 도구를 사용합니다. 사용자가 저장을 요청하지 않았거나 초안 검토만 요청한 경우 임의로 저장하지 않습니다.
17
+ 7. 삭제·초기화·복원·덮어쓰기·업데이트처럼 데이터나 실행 환경을 바꾸는 작업은 영향과 대상을 설명하고 사용자의 명시적 확인 후 실행합니다.
18
+ 8. 외부 통신이 필요한 기능은 로컬 작업과 구분해 알립니다. 저장된 커리어 원문이나 개인정보를 외부로 보내지 않습니다.
19
+ 9. 사용자 언어와 대상 문서의 언어를 유지합니다. 내부 ID, 도구 이름, 원시 JSON, 로컬 경로, SQL 및 스택 추적을 사용자에게 노출하지 않습니다.
20
+ 10. 구체 절차와 다음 도구는 get_workflow_guide 또는 현재 context 응답의 recommended route를 따릅니다. 같은 조회·검증 도구를 이유 없이 반복 호출하지 않습니다.
21
+ 11. 이력서·자기소개서·면접 답변은 사용자가 실제 면접에서 설명 가능한 수준으로 작성하며, 팀 성과와 개인 기여를 구분합니다.
22
+ 12. 글은 화려함보다 구체성·사실성·가독성을 우선합니다. 번역투, 클리셰, 키워드 도배, 균일한 AI 문체를 피하되 사실은 바꾸지 않습니다.
23
+
24
+ 사용자에게는 무엇을 확인했고 무엇이 미확인인지 짧고 명확하게 설명합니다.`;
package/tsconfig.json CHANGED
@@ -13,17 +13,16 @@
13
13
  "forceConsistentCasingInFileNames": true,
14
14
  "allowImportingTsExtensions": true,
15
15
  "noEmit": true,
16
- "baseUrl": ".",
17
16
  "paths": {
18
- "@careermate/shared": ["packages/shared/src/index.ts"],
19
- "@careermate/db": ["packages/db/src/index.ts"],
20
- "@careermate/core": ["packages/core/src/index.ts"],
21
- "@careermate/mcp-tools": ["packages/mcp-tools/src/index.ts"],
22
- "@careermate/exporters": ["packages/exporters/src/index.ts"],
23
- "@careermate/parsers": ["packages/parsers/src/index.ts"],
24
- "@careermate/prompts": ["packages/prompts/src/index.ts"],
25
- "@careermate/workflows": ["packages/workflows/src/index.ts"],
26
- "@careermate/knowledge": ["packages/knowledge/src/index.ts"]
17
+ "@careermate/shared": ["./packages/shared/src/index.ts"],
18
+ "@careermate/db": ["./packages/db/src/index.ts"],
19
+ "@careermate/core": ["./packages/core/src/index.ts"],
20
+ "@careermate/mcp-tools": ["./packages/mcp-tools/src/index.ts"],
21
+ "@careermate/exporters": ["./packages/exporters/src/index.ts"],
22
+ "@careermate/parsers": ["./packages/parsers/src/index.ts"],
23
+ "@careermate/prompts": ["./packages/prompts/src/index.ts"],
24
+ "@careermate/workflows": ["./packages/workflows/src/index.ts"],
25
+ "@careermate/knowledge": ["./packages/knowledge/src/index.ts"]
27
26
  }
28
27
  },
29
28
  "include": ["packages/**/*.ts", "apps/**/*.ts", "scripts/**/*.ts"],