claude-distill 0.3.0 → 0.4.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 CHANGED
@@ -12,6 +12,13 @@
12
12
 
13
13
  사용자가 하는 일: `claude-distill init` **한 번. 끝.**
14
14
 
15
+ 부담 없는 이유:
16
+ - **별도 서버 / 계정 없음** — 본인 머신 → Anthropic API 직통. distill 운영자한테도 transcript 안 감
17
+ - **의존성 0개, 50KB 미만** — [GitHub](https://github.com/parksubeom/claude-distill)에서 코드 그대로 검수
18
+ - **세션당 비용 ~$0** — 4단 게이트로 본 추출 호출 ~10× 컷 (휴리스틱 → Haiku → dedup hash → 재귀 가드)
19
+ - **Plain markdown 결과** — 마음에 안 드는 entry는 그 줄 삭제. UI / DB / 락인 없음
20
+ - **한국어 / 영어 자동 누적** — transcript 언어 자동 감지, 별도 설정 불필요
21
+
15
22
  ---
16
23
 
17
24
  ## 어떤 게 자동으로 누적되나
@@ -152,10 +159,30 @@ source ~/.zshrc
152
159
  - `ANTHROPIC_API_KEY` — 설정 시 Haiku 게이트 자동 활성 (~$0 가까이 게이트 비용)
153
160
  - `CLAUDE_DISTILL_GATE_MODEL` — 게이트 모델 (기본 `claude-haiku-4-5-20251001`)
154
161
  - `CLAUDE_DISTILL_MODEL` — 본 추출 모델 (기본 `claude-sonnet-4-6`)
162
+ - `CLAUDE_DISTILL_LANG` — 누적 언어 강제 (`ko` / `en`). 미설정 시 transcript 한글 비율로 자동 감지
155
163
  - `CLAUDE_DISTILL_CHILD` — distill이 spawn한 자식 claude 표식. 수동 설정 불필요 (hook 무한 재귀 차단용 내부 플래그)
156
164
 
157
165
  ---
158
166
 
167
+ ## 언어 (i18n) — 한국어 / 영어
168
+
169
+ `knowledge.md` / `gotchas.md`는 한국어 또는 영어로 누적할 수 있습니다. 헤더 / 필드 라벨 (`상황` / `함정` / `근거` … vs `Context` / `Trap` / `Basis` …) 과 entry 본문이 모두 해당 언어로 작성됩니다. 카테고리 키 (`api_quirk`, `trade_off_decision` 등) 는 머신 식별자라 영어 enum 그대로 유지.
170
+
171
+ **우선순위**:
172
+ 1. `claude-distill analyze --lang=ko|en` (명시)
173
+ 2. `CLAUDE_DISTILL_LANG=ko|en` 환경변수
174
+ 3. **transcript 자동 감지** — 세션 turn에서 한글 음절 비율이 5% 넘으면 `ko`, 아니면 `en`
175
+ 4. `process.env.LANG` (예: `ko_KR.UTF-8` → `ko`)
176
+ 5. fallback: `en`
177
+
178
+ 대부분의 사용자는 **3번 자동 감지로 충분** — 한국어로 코딩하는 세션은 한국어로, 영어 세션은 영어로 자연스럽게 누적됩니다. 별도 설정 없이.
179
+
180
+ 영어 위주로 쓰다 한국어 entry가 섞이는 게 싫다면 `~/.zshrc`에 `export CLAUDE_DISTILL_LANG=en` 으로 고정.
181
+
182
+ > **혼재 주의**: 기존 markdown이 영어로 쌓여있을 때 locale을 `ko`로 바꾸면, 같은 파일에 영/한 entry가 섞입니다. plain markdown이라 사용자가 직접 정리 가능 (헤더 / 라벨 일괄 치환). v0.4 첫 cut은 단일 파일 정책 — 언어별 파일 분리 (`knowledge.ko.md`)는 추후 검토.
183
+
184
+ ---
185
+
159
186
  ## 프라이버시 / 보안
160
187
 
161
188
  **별도 서버 없음.** distill은 본인 머신 → Anthropic API 직통입니다. 중간에 어떤 third-party 서버도 없음 — 코드도 50KB 미만, [GitHub](https://github.com/parksubeom/claude-distill)에서 그대로 검수 가능.
@@ -210,6 +237,7 @@ A. macOS / Linux / Windows (Node 18+ 설치돼있으면). 경로는 전부 `os.h
210
237
 
211
238
  ## 상태
212
239
 
240
+ v0.4 — i18n. `knowledge.md` / `gotchas.md`를 한국어 / 영어로 자동 누적 (transcript 언어 감지 또는 `CLAUDE_DISTILL_LANG`).
213
241
  v0.3 — 게이트 도입 (휴리스틱 + Haiku) + Stop 훅 무한 재귀 가드. 본 추출 호출 ~10× 감소.
214
242
  v0.2 — 자동 누적 모델로 재정비.
215
243
 
package/bin/distill.js CHANGED
@@ -21,7 +21,12 @@ const help = `claude-distill — Knowledge + Gotchas auto-accumulator for Claude
21
21
  claude-distill where 모든 파일 경로 / 존재 여부
22
22
 
23
23
  수동 분석 (보통 Hook이 자동으로 함):
24
- claude-distill analyze [--no-auto] [--mock] [--quiet]
24
+ claude-distill analyze [--no-auto] [--mock] [--quiet] [--lang=ko|en]
25
+
26
+ 언어 (knowledge.md / gotchas.md 누적 언어):
27
+ --lang=ko|en 명시 (가장 우선)
28
+ CLAUDE_DISTILL_LANG=... 환경변수
29
+ (없으면) transcript 한글 비율로 자동 감지 → process.env.LANG → 'en'
25
30
 
26
31
  결과는 plain markdown:
27
32
  ~/.claude/knowledge.md 판례 (전역)
package/lib/analyze.js CHANGED
@@ -22,6 +22,7 @@ const cfg = require('./config');
22
22
  const transcript = require('./transcript');
23
23
  const store = require('./store');
24
24
  const gate = require('./gate');
25
+ const localeMod = require('./locale');
25
26
 
26
27
  // 자식 claude 프로세스 표식. distill→claude→Stop 훅→distill 무한 재귀 차단용.
27
28
  const CHILD_ENV_KEY = 'CLAUDE_DISTILL_CHILD';
@@ -35,6 +36,7 @@ function parseFlags(args) {
35
36
  auto: true, // 기본값 — 자동 누적 (CLI 핵심)
36
37
  backend: 'auto', // 'auto' | 'cli' | 'api' — auto는 ANTHROPIC_API_KEY 있으면 api, 없으면 cli
37
38
  noGate: false, // 게이트 우회 (디버깅 / 강제 분석)
39
+ lang: null, // 'ko' | 'en' | null — null이면 env / transcript 자동 감지
38
40
  };
39
41
  for (let i = 0; i < args.length; i++) {
40
42
  const a = args[i];
@@ -47,6 +49,8 @@ function parseFlags(args) {
47
49
  else if (a === '--backend') flags.backend = args[++i];
48
50
  else if (a.startsWith('--session=')) flags.session = a.slice('--session='.length);
49
51
  else if (a === '--session') flags.session = args[++i];
52
+ else if (a.startsWith('--lang=')) flags.lang = a.slice('--lang='.length);
53
+ else if (a === '--lang') flags.lang = args[++i];
50
54
  }
51
55
  return flags;
52
56
  }
@@ -62,10 +66,11 @@ function existingForDedup() {
62
66
  return out;
63
67
  }
64
68
 
65
- function buildAnalyzerPrompt(promptText, existing, t) {
69
+ function buildAnalyzerPrompt(promptText, existing, t, locale) {
66
70
  const exBlock = `<existing>\n# knowledge.md\n${existing.knowledge}\n\n# gotchas.md\n${existing.gotchas}\n</existing>`;
67
71
  const txBlock = `<transcript file="${t.source}" total_lines="${t.totalLines}" slice_from="${t.sliceFrom}">\n${JSON.stringify(t.turns, null, 2)}\n</transcript>`;
68
- return `${promptText}\n\n---\n\n${exBlock}\n\n${txBlock}\n\nReturn the JSON array now. No prose, no markdown fences.`;
72
+ const langDirective = localeMod.promptDirective(locale);
73
+ return `${promptText}\n\n---\n\n${exBlock}\n\n${txBlock}${langDirective}\n\nReturn the JSON array now. No prose, no markdown fences.`;
69
74
  }
70
75
 
71
76
  function mockExtract() {
@@ -227,6 +232,11 @@ async function run(args) {
227
232
  return;
228
233
  }
229
234
 
235
+ // Locale 결정 — transcript가 로드된 뒤(자동 감지에 필요). flag → env →
236
+ // transcript Hangul 비율 → process.env.LANG → 'en'.
237
+ const locale = localeMod.resolveLocale({ flag: flags.lang, transcript: t });
238
+ log(flags, 'locale: ' + locale + (flags.lang ? ' (--lang)' : process.env.CLAUDE_DISTILL_LANG ? ' (env)' : ' (auto)'));
239
+
230
240
  // ── 게이트 1: 휴리스틱 (로컬, 무료) ───────────────────────
231
241
  // 짧은 세션 / 도구 사용 0 / 에러 키워드 0 → 인사이트 후보 거의 없음.
232
242
  // mock 모드와 --no-gate 는 우회.
@@ -261,7 +271,7 @@ async function run(args) {
261
271
  } else {
262
272
  const prompt = fs.readFileSync(cfg.PROMPT_FILE, 'utf8');
263
273
  const existing = existingForDedup();
264
- const fullPrompt = buildAnalyzerPrompt(prompt, existing, t);
274
+ const fullPrompt = buildAnalyzerPrompt(prompt, existing, t, locale);
265
275
  if (flags.dryRun) {
266
276
  log(flags, '[--dry-run] prompt 준비됨 (' + fullPrompt.length + ' chars). LLM 호출 생략.');
267
277
  return;
@@ -299,7 +309,7 @@ async function run(args) {
299
309
  },
300
310
  };
301
311
  try {
302
- const dest = store.appendEntry(annotated);
312
+ const dest = store.appendEntry(annotated, locale);
303
313
  appended++;
304
314
  log(flags, ' ✓ ' + (c.title || c.id) + ' → ' + dest);
305
315
  } catch (err) {
package/lib/locale.js ADDED
@@ -0,0 +1,141 @@
1
+ // Locale resolution + label tables for ko / en.
2
+ //
3
+ // 우선순위:
4
+ // 1. --lang=ko|en 플래그
5
+ // 2. CLAUDE_DISTILL_LANG 환경변수
6
+ // 3. transcript 자동 감지 (Hangul 비율)
7
+ // 4. process.env.LANG
8
+ // 5. fallback 'en'
9
+ //
10
+ // 지원 범위는 의도적으로 ko / en 둘로만 제한. 추가 언어는 LABELS 테이블 + detector
11
+ // 분기만 늘리면 되지만 v0.4 첫 cut에선 두 언어로 시작.
12
+
13
+ const SUPPORTED = ['ko', 'en'];
14
+ const DEFAULT = 'en';
15
+
16
+ const LABELS = {
17
+ en: {
18
+ knowledgeHeader:
19
+ '# Knowledge — case law\n\n' +
20
+ '> Judgment calls worth remembering. Maintained by [claude-distill](https://github.com/parksubeom/claude-distill).\n\n',
21
+ gotchasHeader:
22
+ '# Gotchas — incident reports\n\n' +
23
+ '> Mistakes worth not repeating. Maintained by [claude-distill](https://github.com/parksubeom/claude-distill).\n\n',
24
+ category: 'Category',
25
+ confidence: 'Confidence',
26
+ date: 'Date',
27
+ source: 'Source',
28
+ sessionWord: 'session',
29
+ projectWord: 'project',
30
+ cmdsWord: 'cmds',
31
+ contextLabel: 'Context',
32
+ symptomLabel: 'Symptom',
33
+ insightLabel: 'Insight',
34
+ trapLabel: 'Trap',
35
+ basisLabel: 'Basis',
36
+ applicationLabel: 'Application',
37
+ tagsLabel: 'Tags',
38
+ untitled: '(untitled)',
39
+ },
40
+ ko: {
41
+ knowledgeHeader:
42
+ '# 판례 — Knowledge\n\n' +
43
+ '> 다음에도 기억할 만한 결정. [claude-distill](https://github.com/parksubeom/claude-distill)이 자동 누적합니다.\n\n',
44
+ gotchasHeader:
45
+ '# 사고 보고서 — Gotchas\n\n' +
46
+ '> 다시 빠지지 말아야 할 함정. [claude-distill](https://github.com/parksubeom/claude-distill)이 자동 누적합니다.\n\n',
47
+ category: '카테고리',
48
+ confidence: '신뢰도',
49
+ date: '날짜',
50
+ source: '출처',
51
+ sessionWord: '세션',
52
+ projectWord: '프로젝트',
53
+ cmdsWord: '명령',
54
+ contextLabel: '상황',
55
+ symptomLabel: '증상',
56
+ insightLabel: '인사이트',
57
+ trapLabel: '함정',
58
+ basisLabel: '근거',
59
+ applicationLabel: '적용',
60
+ tagsLabel: '태그',
61
+ untitled: '(제목 없음)',
62
+ },
63
+ };
64
+
65
+ function normalize(v) {
66
+ if (!v) return null;
67
+ const s = String(v).toLowerCase().trim();
68
+ if (s.startsWith('ko')) return 'ko';
69
+ if (s.startsWith('en')) return 'en';
70
+ return null;
71
+ }
72
+
73
+ // transcript의 turns를 stringify해서 한글 음절 (U+AC00–U+D7A3) 비율을 계산.
74
+ // 시그널이 약하면 (글자 자체가 적으면) null 반환 — 호출자가 다음 우선순위로.
75
+ function detectFromTranscript(t) {
76
+ if (!t || !Array.isArray(t.turns) || t.turns.length === 0) return null;
77
+ let kor = 0;
78
+ let letters = 0;
79
+ for (const turn of t.turns) {
80
+ const s = JSON.stringify(turn);
81
+ for (let i = 0; i < s.length; i++) {
82
+ const code = s.charCodeAt(i);
83
+ const isHangul = code >= 0xAC00 && code <= 0xD7A3;
84
+ const isLatin = (code >= 0x41 && code <= 0x5A) || (code >= 0x61 && code <= 0x7A);
85
+ if (isHangul) kor++;
86
+ if (isHangul || isLatin) letters++;
87
+ }
88
+ }
89
+ if (letters < 200) return null;
90
+ // 한글 음절은 글자 하나당 정보량이 라틴 알파벳보다 크므로 5%만 넘어도
91
+ // 실제로는 한국어 위주의 세션. 임계값 보수적으로 0.05.
92
+ return kor / letters > 0.05 ? 'ko' : 'en';
93
+ }
94
+
95
+ function resolveLocale({ flag, transcript } = {}) {
96
+ const fromFlag = normalize(flag);
97
+ if (fromFlag) return fromFlag;
98
+ const fromEnv = normalize(process.env.CLAUDE_DISTILL_LANG);
99
+ if (fromEnv) return fromEnv;
100
+ const fromTranscript = detectFromTranscript(transcript);
101
+ if (fromTranscript) return fromTranscript;
102
+ const fromSysLang = normalize(process.env.LANG);
103
+ if (fromSysLang) return fromSysLang;
104
+ return DEFAULT;
105
+ }
106
+
107
+ function labels(locale) {
108
+ return LABELS[SUPPORTED.includes(locale) ? locale : DEFAULT];
109
+ }
110
+
111
+ // LLM에 출력 언어를 강제하는 directive. JSON 식별자 필드는 enum 그대로
112
+ // 유지하라고 명시 — 카테고리 키가 번역되면 store.js의 isGotcha 분기 등이 깨짐.
113
+ function promptDirective(locale) {
114
+ if (locale === 'ko') {
115
+ return [
116
+ '',
117
+ '---',
118
+ '',
119
+ 'OUTPUT LANGUAGE: 모든 entry의 자연어 필드(title, context, insight, basis, application)와 tags 배열은 **한국어**로 작성하세요.',
120
+ '단, 식별자 필드(type, category, confidence)는 명세된 영어 enum 값 그대로 유지 — 번역 금지.',
121
+ 'JSON 키 이름과 구조도 그대로. 한국어로 표현하기 어려운 기술 용어는 영어 그대로 두어도 됩니다.',
122
+ ].join('\n');
123
+ }
124
+ return [
125
+ '',
126
+ '---',
127
+ '',
128
+ 'OUTPUT LANGUAGE: Write the natural-language fields (title, context, insight, basis, application) and tags array in **English**.',
129
+ 'Keep identifier fields (type, category, confidence) as their specified English enum values — do not translate.',
130
+ 'Do not change JSON key names or structure.',
131
+ ].join('\n');
132
+ }
133
+
134
+ module.exports = {
135
+ SUPPORTED,
136
+ DEFAULT,
137
+ resolveLocale,
138
+ detectFromTranscript,
139
+ labels,
140
+ promptDirective,
141
+ };
package/lib/store.js CHANGED
@@ -5,6 +5,7 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const cfg = require('./config');
8
+ const locale = require('./locale');
8
9
 
9
10
  function resolveTarget(entry) {
10
11
  const isGotcha = entry.type === 'gotcha';
@@ -16,61 +17,53 @@ function resolveTarget(entry) {
16
17
  return isGotcha ? cfg.GLOBAL_GOTCHAS : cfg.GLOBAL_KNOWLEDGE;
17
18
  }
18
19
 
19
- function ensureHeader(file, isGotcha) {
20
+ function ensureHeader(file, isGotcha, lang) {
20
21
  if (fs.existsSync(file)) return;
21
- const header = isGotcha
22
- ? `# Gotchas incident reports
23
-
24
- > Mistakes worth not repeating. Maintained by [claude-distill](https://github.com/parksubeom/claude-distill).
25
-
26
- `
27
- : `# Knowledge — case law
28
-
29
- > Judgment calls worth remembering. Maintained by [claude-distill](https://github.com/parksubeom/claude-distill).
30
-
31
- `;
22
+ const L = locale.labels(lang);
23
+ const header = isGotcha ? L.gotchasHeader : L.knowledgeHeader;
32
24
  fs.mkdirSync(path.dirname(file), { recursive: true });
33
25
  fs.writeFileSync(file, header);
34
26
  }
35
27
 
36
- function formatEntry(e) {
37
- const title = e.title || '(untitled)';
28
+ function formatEntry(e, lang) {
29
+ const L = locale.labels(lang);
30
+ const title = e.title || L.untitled;
38
31
  const date = (e.source && e.source.timestamp) ? e.source.timestamp.slice(0, 10) : new Date().toISOString().slice(0, 10);
39
32
  const sessionId = (e.source && e.source.sessionId) || 'unknown';
40
33
  const project = (e.source && e.source.project) || 'unknown';
41
34
  const cmds = (e.source && e.source.relatedCommands && e.source.relatedCommands.length)
42
- ? ' · cmds: ' + e.source.relatedCommands.join(', ')
35
+ ? ' · ' + L.cmdsWord + ': ' + e.source.relatedCommands.join(', ')
43
36
  : '';
44
37
  const tags = (e.tags || []).map((t) => '`' + t + '`').join(' · ');
45
38
  const conf = e.confidence || 'medium';
46
- const symptomLabel = e.type === 'gotcha' ? 'Symptom' : 'Context';
47
- const insightLabel = e.type === 'gotcha' ? 'Trap' : 'Insight';
39
+ const symptomLabel = e.type === 'gotcha' ? L.symptomLabel : L.contextLabel;
40
+ const insightLabel = e.type === 'gotcha' ? L.trapLabel : L.insightLabel;
48
41
 
49
42
  return [
50
43
  `## ${title}`,
51
- `**Category**: \`${e.category}\` · **Confidence**: ${conf} · **Date**: ${date}`,
52
- `**Source**: session \`${sessionId.slice(0, 8)}\` · project \`${project}\`${cmds}`,
44
+ `**${L.category}**: \`${e.category}\` · **${L.confidence}**: ${conf} · **${L.date}**: ${date}`,
45
+ `**${L.source}**: ${L.sessionWord} \`${sessionId.slice(0, 8)}\` · ${L.projectWord} \`${project}\`${cmds}`,
53
46
  '',
54
47
  `**${symptomLabel}**: ${e.context || ''}`,
55
48
  '',
56
49
  `**${insightLabel}**: ${e.insight || ''}`,
57
50
  '',
58
- `**Basis**: ${e.basis || ''}`,
51
+ `**${L.basisLabel}**: ${e.basis || ''}`,
59
52
  '',
60
- `**Application**: ${e.application || ''}`,
53
+ `**${L.applicationLabel}**: ${e.application || ''}`,
61
54
  '',
62
- `**Tags**: ${tags}`,
55
+ `**${L.tagsLabel}**: ${tags}`,
63
56
  '',
64
57
  '---',
65
58
  '',
66
59
  ].join('\n');
67
60
  }
68
61
 
69
- function appendEntry(entry) {
62
+ function appendEntry(entry, lang) {
70
63
  const file = resolveTarget(entry);
71
64
  const isGotcha = entry.type === 'gotcha';
72
- ensureHeader(file, isGotcha);
73
- fs.appendFileSync(file, formatEntry(entry));
65
+ ensureHeader(file, isGotcha, lang);
66
+ fs.appendFileSync(file, formatEntry(entry, lang));
74
67
  return file;
75
68
  }
76
69
 
package/lib/where.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const cfg = require('./config');
6
+ const localeMod = require('./locale');
6
7
 
7
8
  const CLAUDE_MD = path.join(cfg.CLAUDE_DIR, 'CLAUDE.md');
8
9
 
@@ -21,6 +22,11 @@ function run() {
21
22
  console.log(' analyzed.json ' + exists(path.join(cfg.STATE_DIR, 'analyzed.json')) + ' 중복 분석 방지 로그');
22
23
  console.log('');
23
24
  console.log('extract prompt ' + exists(cfg.PROMPT_FILE) + ' ' + cfg.PROMPT_FILE);
25
+ console.log('');
26
+ const envLang = process.env.CLAUDE_DISTILL_LANG || '(unset)';
27
+ const resolved = localeMod.resolveLocale();
28
+ console.log('locale (env) · CLAUDE_DISTILL_LANG=' + envLang);
29
+ console.log('locale (resolved) · ' + resolved + ' (transcript 자동 감지가 우선 — 세션 분석 시점에 결정)');
24
30
  }
25
31
 
26
32
  module.exports = { run };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-distill",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Distill knowledge and gotchas from Claude Code session transcripts. Hook-based feedback loop: session → extract → review → accumulate.",
5
5
  "bin": {
6
6
  "claude-distill": "bin/distill.js",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "repository": {
41
41
  "type": "git",
42
- "url": "https://github.com/parksubeom/claude-distill.git"
42
+ "url": "git+https://github.com/parksubeom/claude-distill.git"
43
43
  },
44
44
  "files": [
45
45
  "bin",