claude-distill 0.2.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 +71 -9
- package/bin/distill.js +6 -1
- package/lib/analyze.js +60 -4
- package/lib/gate.js +110 -0
- package/lib/locale.js +141 -0
- package/lib/store.js +18 -25
- package/lib/where.js +6 -0
- package/package.json +2 -2
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
|
## 어떤 게 자동으로 누적되나
|
|
@@ -90,9 +97,12 @@ source ~/.zshrc
|
|
|
90
97
|
```
|
|
91
98
|
세션 끝
|
|
92
99
|
└─ Stop hook이 `claude-distill analyze --quiet` 자동 실행
|
|
100
|
+
├─ [재귀 가드] 자식 claude 세션이면 즉시 종료 (CLAUDE_DISTILL_CHILD)
|
|
101
|
+
├─ [중복 방지] 같은 슬라이스 이미 분석됐으면 종료
|
|
102
|
+
├─ [게이트 1 — 휴리스틱] 짧은 세션 / 도구 사용 0 / 에러 키워드 0 → 종료
|
|
103
|
+
├─ [게이트 2 — Haiku] (API key 있을 때) yes/no 1토큰 응답, no면 종료
|
|
93
104
|
├─ 마지막 user marker 이후 turn slice (보통 ~120 turns / ~85K chars)
|
|
94
|
-
├─
|
|
95
|
-
├─ JSON 응답 파싱
|
|
105
|
+
├─ Sonnet/Opus로 analyzer prompt 전달, JSON 응답 파싱
|
|
96
106
|
├─ confidence:high entry → ~/.claude/knowledge.md / gotchas.md 즉시 append
|
|
97
107
|
└─ medium / low → drop (사용자 손 안 가게)
|
|
98
108
|
|
|
@@ -101,6 +111,8 @@ source ~/.zshrc
|
|
|
101
111
|
└─ Claude가 자연스럽게 참조 — 같은 함정 안 빠짐
|
|
102
112
|
```
|
|
103
113
|
|
|
114
|
+
게이트 두 단계는 "대부분의 세션은 인사이트가 없다" 가정으로 본 추출 호출을 ~10× 줄입니다. 게이트가 차단된 세션은 dedup에 마킹돼 같은 슬라이스로 다시 호출돼도 즉시 종료.
|
|
115
|
+
|
|
104
116
|
---
|
|
105
117
|
|
|
106
118
|
## 카테고리
|
|
@@ -141,15 +153,48 @@ source ~/.zshrc
|
|
|
141
153
|
- `--mock` — claude CLI 호출 없이 가짜 entry 1건 (파이프라인 검증용)
|
|
142
154
|
- `--session=<file>` — 특정 jsonl 직접 지정
|
|
143
155
|
- `--quiet` — 출력 억제 (hook이 사용)
|
|
156
|
+
- `--no-gate` / `--force` — 휴리스틱/Haiku 게이트와 재귀 가드 모두 우회 (강제 분석)
|
|
157
|
+
|
|
158
|
+
환경변수:
|
|
159
|
+
- `ANTHROPIC_API_KEY` — 설정 시 Haiku 게이트 자동 활성 (~$0 가까이 게이트 비용)
|
|
160
|
+
- `CLAUDE_DISTILL_GATE_MODEL` — 게이트 모델 (기본 `claude-haiku-4-5-20251001`)
|
|
161
|
+
- `CLAUDE_DISTILL_MODEL` — 본 추출 모델 (기본 `claude-sonnet-4-6`)
|
|
162
|
+
- `CLAUDE_DISTILL_LANG` — 누적 언어 강제 (`ko` / `en`). 미설정 시 transcript 한글 비율로 자동 감지
|
|
163
|
+
- `CLAUDE_DISTILL_CHILD` — distill이 spawn한 자식 claude 표식. 수동 설정 불필요 (hook 무한 재귀 차단용 내부 플래그)
|
|
164
|
+
|
|
165
|
+
---
|
|
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`)는 추후 검토.
|
|
144
183
|
|
|
145
184
|
---
|
|
146
185
|
|
|
147
|
-
## 프라이버시
|
|
186
|
+
## 프라이버시 / 보안
|
|
187
|
+
|
|
188
|
+
**별도 서버 없음.** distill은 본인 머신 → Anthropic API 직통입니다. 중간에 어떤 third-party 서버도 없음 — 코드도 50KB 미만, [GitHub](https://github.com/parksubeom/claude-distill)에서 그대로 검수 가능.
|
|
148
189
|
|
|
149
|
-
-
|
|
150
|
-
-
|
|
151
|
-
-
|
|
152
|
-
-
|
|
190
|
+
- **transcript 전송 범위**: 본인의 `ANTHROPIC_API_KEY`로 본인 계정의 Claude API에만 전달. distill 운영자(저)에게도 안 감.
|
|
191
|
+
- **API key 저장**: distill은 key를 절대 파일에 안 씀. 본인이 `~/.zshrc` 등에 export한 환경변수만 읽음.
|
|
192
|
+
- **권한 범위**: transcript는 read-only, 결과 markdown 2개만 append. 코드/repo는 절대 안 건드림.
|
|
193
|
+
- **잔존물**: `~/.claude/.distill/analyzed.json`에는 transcript 내용이 아니라 SHA hash 12자만 저장 (중복 분석 방지용).
|
|
194
|
+
- **결과는 plain markdown**: 마음에 안 드는 entry는 줄 째로 삭제. 다음 세션부터 inject 안 됨.
|
|
195
|
+
- **완전 비활성화**: `~/.claude/settings.json`의 `hooks.Stop` 항목 삭제 또는 `npm uninstall -g claude-distill`.
|
|
196
|
+
|
|
197
|
+
**시크릿 우려**: transcript에 API key / 패스워드를 평문으로 입력한 적 있다면 분석 prompt에도 그게 들어갑니다. Anthropic API의 데이터 처리 정책 그대로 따르며, distill 자체는 그걸 디스크에 저장하지 않음. 민감한 transcript가 있는 세션은 `claude-distill analyze` 실행 전 `~/.claude/projects/<project>/`에서 해당 jsonl을 직접 삭제하면 됩니다.
|
|
153
198
|
|
|
154
199
|
---
|
|
155
200
|
|
|
@@ -159,7 +204,10 @@ source ~/.zshrc
|
|
|
159
204
|
A. 직접 작성 가능. `claude-distill`은 매일 쌓이는 자잘한 판례/사고를 자동으로 잡아내는 보조 도구. CLAUDE.md는 변하지 않는 보편 규칙용으로 그대로 쓰시면 됩니다.
|
|
160
205
|
|
|
161
206
|
**Q. 토큰 비용은?**
|
|
162
|
-
A.
|
|
207
|
+
A. 게이트가 본 추출의 90% 가량을 사전에 컷합니다 (v0.3+). 통과한 세션만 prompt ~85K chars (~20K tokens) input + 응답 ~2K tokens output → Sonnet 기준 세션당 약 $0.10. 그 외 세션은 휴리스틱(무료) / Haiku 게이트(1토큰 응답, 사실상 0원)에서 종료. `ANTHROPIC_API_KEY` 설정 시 Haiku 게이트가 켜져 비용 효율이 가장 좋음.
|
|
208
|
+
|
|
209
|
+
**Q. 게이트 때문에 인사이트를 놓치는 거 아닌가?**
|
|
210
|
+
A. 휴리스틱이 보수적이라 false negative 가능 (예: 도구 사용 0인 토론 세션). 그런 세션은 `claude-distill analyze --no-gate`로 강제 분석 가능. 게이트가 차단하는 패턴이 본인 워크플로와 안 맞으면 README의 [issue tracker](https://github.com/parksubeom/claude-distill/issues)에 알려주세요.
|
|
163
211
|
|
|
164
212
|
**Q. confidence:medium / low는 왜 drop?**
|
|
165
213
|
A. 노이즈 누적이 가장 큰 실패 패턴이라 보수적으로 시작. 향후 `--keep-medium` 옵션 추가 가능.
|
|
@@ -173,11 +221,25 @@ A. **됩니다.** transcript는 익스텐션도 같은 위치(`~/.claude/project
|
|
|
173
221
|
**Q. 다른 LLM 백엔드?**
|
|
174
222
|
A. 현재 `claude` CLI + Anthropic API 두 가지. `--backend=cli|api|auto` 옵션. 기본 `auto`는 `ANTHROPIC_API_KEY` 있으면 API 우선, 없으면 CLI fallback. OpenAI / 로컬 LLM 지원은 v0.3+.
|
|
175
223
|
|
|
224
|
+
**Q. 잠시 멈추거나 완전히 제거하려면?**
|
|
225
|
+
A. 일시 정지: `~/.claude/settings.json`의 `hooks.Stop` 배열에서 distill entry만 빼면 됨. 완전 제거: `npm uninstall -g claude-distill` 후 `~/.claude/CLAUDE.md` 끝의 `<!-- claude-distill auto-references -->` 블록 삭제. 누적된 `knowledge.md` / `gotchas.md`는 유지하고 싶으면 그대로 두거나, 완전 초기화하려면 삭제.
|
|
226
|
+
|
|
227
|
+
**Q. 잘못 누적된 entry는 어떻게 지워?**
|
|
228
|
+
A. `~/.claude/knowledge.md` 또는 `gotchas.md`에서 그 entry의 `### ...` 블록만 텍스트로 삭제. 다음 세션부터 inject 안 됨. plain markdown이라 자유 편집.
|
|
229
|
+
|
|
230
|
+
**Q. 회사 코드 / 시크릿이 transcript에 있어도 괜찮나?**
|
|
231
|
+
A. 분석은 본인의 Anthropic 계정으로만 흘러가지만 (별도 서버 없음 — 위 보안 섹션 참조), API 호출 자체가 회사 정책에 막혀있다면 distill도 못 씀. 의심되는 세션은 `~/.claude/projects/<project>/<session>.jsonl`을 직접 삭제하거나, 해당 세션은 `claude-distill analyze`가 실행되기 전에 hook을 잠시 끄면 됨.
|
|
232
|
+
|
|
233
|
+
**Q. 어떤 OS에서 됨?**
|
|
234
|
+
A. macOS / Linux / Windows (Node 18+ 설치돼있으면). 경로는 전부 `os.homedir()`로 동적 결정 — 하드코딩 없음. 테스트는 macOS 위주지만 OS-specific 코드는 없음.
|
|
235
|
+
|
|
176
236
|
---
|
|
177
237
|
|
|
178
238
|
## 상태
|
|
179
239
|
|
|
180
|
-
v0.
|
|
240
|
+
v0.4 — i18n. `knowledge.md` / `gotchas.md`를 한국어 / 영어로 자동 누적 (transcript 언어 감지 또는 `CLAUDE_DISTILL_LANG`).
|
|
241
|
+
v0.3 — 게이트 도입 (휴리스틱 + Haiku) + Stop 훅 무한 재귀 가드. 본 추출 호출 ~10× 감소.
|
|
242
|
+
v0.2 — 자동 누적 모델로 재정비.
|
|
181
243
|
|
|
182
244
|
## License
|
|
183
245
|
|
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
|
@@ -21,6 +21,11 @@ const crypto = require('crypto');
|
|
|
21
21
|
const cfg = require('./config');
|
|
22
22
|
const transcript = require('./transcript');
|
|
23
23
|
const store = require('./store');
|
|
24
|
+
const gate = require('./gate');
|
|
25
|
+
const localeMod = require('./locale');
|
|
26
|
+
|
|
27
|
+
// 자식 claude 프로세스 표식. distill→claude→Stop 훅→distill 무한 재귀 차단용.
|
|
28
|
+
const CHILD_ENV_KEY = 'CLAUDE_DISTILL_CHILD';
|
|
24
29
|
|
|
25
30
|
function parseFlags(args) {
|
|
26
31
|
const flags = {
|
|
@@ -30,6 +35,8 @@ function parseFlags(args) {
|
|
|
30
35
|
mock: false,
|
|
31
36
|
auto: true, // 기본값 — 자동 누적 (CLI 핵심)
|
|
32
37
|
backend: 'auto', // 'auto' | 'cli' | 'api' — auto는 ANTHROPIC_API_KEY 있으면 api, 없으면 cli
|
|
38
|
+
noGate: false, // 게이트 우회 (디버깅 / 강제 분석)
|
|
39
|
+
lang: null, // 'ko' | 'en' | null — null이면 env / transcript 자동 감지
|
|
33
40
|
};
|
|
34
41
|
for (let i = 0; i < args.length; i++) {
|
|
35
42
|
const a = args[i];
|
|
@@ -37,10 +44,13 @@ function parseFlags(args) {
|
|
|
37
44
|
else if (a === '--dry-run') flags.dryRun = true;
|
|
38
45
|
else if (a === '--mock') flags.mock = true;
|
|
39
46
|
else if (a === '--no-auto') flags.auto = false;
|
|
47
|
+
else if (a === '--no-gate' || a === '--force') flags.noGate = true;
|
|
40
48
|
else if (a.startsWith('--backend=')) flags.backend = a.slice('--backend='.length);
|
|
41
49
|
else if (a === '--backend') flags.backend = args[++i];
|
|
42
50
|
else if (a.startsWith('--session=')) flags.session = a.slice('--session='.length);
|
|
43
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];
|
|
44
54
|
}
|
|
45
55
|
return flags;
|
|
46
56
|
}
|
|
@@ -56,10 +66,11 @@ function existingForDedup() {
|
|
|
56
66
|
return out;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
|
-
function buildAnalyzerPrompt(promptText, existing, t) {
|
|
69
|
+
function buildAnalyzerPrompt(promptText, existing, t, locale) {
|
|
60
70
|
const exBlock = `<existing>\n# knowledge.md\n${existing.knowledge}\n\n# gotchas.md\n${existing.gotchas}\n</existing>`;
|
|
61
71
|
const txBlock = `<transcript file="${t.source}" total_lines="${t.totalLines}" slice_from="${t.sliceFrom}">\n${JSON.stringify(t.turns, null, 2)}\n</transcript>`;
|
|
62
|
-
|
|
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.`;
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
function mockExtract() {
|
|
@@ -83,6 +94,9 @@ function callClaudeCli(promptText) {
|
|
|
83
94
|
timeout: 120000,
|
|
84
95
|
maxBuffer: 16 * 1024 * 1024,
|
|
85
96
|
encoding: 'utf8',
|
|
97
|
+
// 자식 claude 세션이 끝나며 발사하는 Stop 훅이 또 distill을 부르는
|
|
98
|
+
// 무한 재귀를 차단. run() 시작부에서 이 env를 보고 즉시 종료.
|
|
99
|
+
env: { ...process.env, [CHILD_ENV_KEY]: '1' },
|
|
86
100
|
});
|
|
87
101
|
} catch (e) {
|
|
88
102
|
if (e.code === 'ENOENT') {
|
|
@@ -182,6 +196,16 @@ function writeDedupLog(set) {
|
|
|
182
196
|
|
|
183
197
|
async function run(args) {
|
|
184
198
|
const flags = parseFlags(args);
|
|
199
|
+
|
|
200
|
+
// ── 재귀 가드 ──────────────────────────────────────────────
|
|
201
|
+
// distill이 spawn한 자식 claude 프로세스가 끝나면 Stop 훅이 또 발사돼
|
|
202
|
+
// distill을 부른다. 그 자식 세션은 분석 대상이 아니므로 즉시 종료.
|
|
203
|
+
// (--force는 가드도 무시 — 진짜 자식 세션 디버깅용 escape hatch)
|
|
204
|
+
if (process.env[CHILD_ENV_KEY] === '1' && !flags.noGate) {
|
|
205
|
+
if (!flags.quiet) console.log('child claude session — analyze 스킵 (재귀 방지)');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
185
209
|
cfg.ensureStateDir();
|
|
186
210
|
|
|
187
211
|
let file = flags.session || transcript.latestSessionFile();
|
|
@@ -208,6 +232,38 @@ async function run(args) {
|
|
|
208
232
|
return;
|
|
209
233
|
}
|
|
210
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
|
+
|
|
240
|
+
// ── 게이트 1: 휴리스틱 (로컬, 무료) ───────────────────────
|
|
241
|
+
// 짧은 세션 / 도구 사용 0 / 에러 키워드 0 → 인사이트 후보 거의 없음.
|
|
242
|
+
// mock 모드와 --no-gate 는 우회.
|
|
243
|
+
if (!flags.mock && !flags.noGate) {
|
|
244
|
+
const h = gate.heuristicGate(t);
|
|
245
|
+
if (!h.pass) {
|
|
246
|
+
log(flags, 'heuristic 게이트 차단 (' + h.reason + ') — LLM 호출 스킵');
|
|
247
|
+
seen.add(dedupKey);
|
|
248
|
+
writeDedupLog(seen);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── 게이트 2: Haiku LLM yes/no (API 백엔드 한정, 매우 저렴) ─
|
|
254
|
+
// ANTHROPIC_API_KEY 있을 때만 동작. CLI 백엔드는 Haiku 호출이 또
|
|
255
|
+
// 새 세션을 만들어 무의미하므로 스킵.
|
|
256
|
+
if (!flags.mock && !flags.noGate && process.env.ANTHROPIC_API_KEY) {
|
|
257
|
+
const g = await gate.llmGate(t, { apiKey: process.env.ANTHROPIC_API_KEY });
|
|
258
|
+
if (!g.pass) {
|
|
259
|
+
log(flags, 'haiku 게이트 차단 (' + g.reason + ') — LLM 호출 스킵');
|
|
260
|
+
seen.add(dedupKey);
|
|
261
|
+
writeDedupLog(seen);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
log(flags, 'haiku 게이트 통과 (' + g.reason + ')');
|
|
265
|
+
}
|
|
266
|
+
|
|
211
267
|
// LLM 호출
|
|
212
268
|
let candidates;
|
|
213
269
|
if (flags.mock) {
|
|
@@ -215,7 +271,7 @@ async function run(args) {
|
|
|
215
271
|
} else {
|
|
216
272
|
const prompt = fs.readFileSync(cfg.PROMPT_FILE, 'utf8');
|
|
217
273
|
const existing = existingForDedup();
|
|
218
|
-
const fullPrompt = buildAnalyzerPrompt(prompt, existing, t);
|
|
274
|
+
const fullPrompt = buildAnalyzerPrompt(prompt, existing, t, locale);
|
|
219
275
|
if (flags.dryRun) {
|
|
220
276
|
log(flags, '[--dry-run] prompt 준비됨 (' + fullPrompt.length + ' chars). LLM 호출 생략.');
|
|
221
277
|
return;
|
|
@@ -253,7 +309,7 @@ async function run(args) {
|
|
|
253
309
|
},
|
|
254
310
|
};
|
|
255
311
|
try {
|
|
256
|
-
const dest = store.appendEntry(annotated);
|
|
312
|
+
const dest = store.appendEntry(annotated, locale);
|
|
257
313
|
appended++;
|
|
258
314
|
log(flags, ' ✓ ' + (c.title || c.id) + ' → ' + dest);
|
|
259
315
|
} catch (err) {
|
package/lib/gate.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Two-stage gate that decides whether a transcript is worth analyzing.
|
|
2
|
+
// Cuts main-LLM calls by ~10× — most sessions never reach extract.
|
|
3
|
+
//
|
|
4
|
+
// 1. heuristicGate(t) — pure local checks (turn count, tool_use, error keywords)
|
|
5
|
+
// 2. llmGate(t, opts) — Haiku yes/no, only if API key available
|
|
6
|
+
//
|
|
7
|
+
// Both fail-open: if anything goes wrong they return pass:true so we don't
|
|
8
|
+
// silently lose insights to a flaky filter.
|
|
9
|
+
|
|
10
|
+
const ERROR_RE = /\b(error|failed|EACCES|EPERM|ENOENT|exception|cannot|undefined is not|null is not|panic:|fatal:|FATAL|throw |throws |traceback|stack trace|denied|forbidden|EADDRINUSE|EBUSY|timed out|timeout)\b/i;
|
|
11
|
+
|
|
12
|
+
const MIN_TURNS = 8;
|
|
13
|
+
|
|
14
|
+
function stringifyContent(c) {
|
|
15
|
+
if (typeof c === 'string') return c;
|
|
16
|
+
if (Array.isArray(c)) {
|
|
17
|
+
return c.map((x) => {
|
|
18
|
+
if (x.type === 'text') return x.text || '';
|
|
19
|
+
if (x.type === 'tool_use') return `[tool:${x.name}]`;
|
|
20
|
+
if (x.type === 'tool_result') return x.text_preview || '';
|
|
21
|
+
return `[${x.type}]`;
|
|
22
|
+
}).join(' ');
|
|
23
|
+
}
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function heuristicGate(t) {
|
|
28
|
+
if (!t || !Array.isArray(t.turns) || t.turns.length < MIN_TURNS) {
|
|
29
|
+
return { pass: false, reason: `too_short(${t && t.turns ? t.turns.length : 0})` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let toolUseCount = 0;
|
|
33
|
+
let textBlob = '';
|
|
34
|
+
|
|
35
|
+
for (const turn of t.turns) {
|
|
36
|
+
if (Array.isArray(turn.content)) {
|
|
37
|
+
for (const c of turn.content) {
|
|
38
|
+
if (c.type === 'tool_use') toolUseCount++;
|
|
39
|
+
if (c.type === 'tool_result' && typeof c.text_preview === 'string') textBlob += '\n' + c.text_preview;
|
|
40
|
+
if (c.type === 'text' && typeof c.text === 'string') textBlob += '\n' + c.text;
|
|
41
|
+
}
|
|
42
|
+
} else if (typeof turn.content === 'string') {
|
|
43
|
+
textBlob += '\n' + turn.content;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (toolUseCount === 0) return { pass: false, reason: 'no_tool_use' };
|
|
48
|
+
if (!ERROR_RE.test(textBlob)) return { pass: false, reason: 'no_error_keywords' };
|
|
49
|
+
|
|
50
|
+
return { pass: true, reason: 'ok', toolUseCount };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildGateSample(t) {
|
|
54
|
+
const lines = [];
|
|
55
|
+
lines.push(`Total turns: ${t.turns.length}`);
|
|
56
|
+
const firstUser = t.turns.find((x) => x.type === 'user');
|
|
57
|
+
if (firstUser) {
|
|
58
|
+
lines.push(`First user request: ${stringifyContent(firstUser.content).slice(0, 500)}`);
|
|
59
|
+
}
|
|
60
|
+
const last = t.turns.slice(-5);
|
|
61
|
+
for (const x of last) {
|
|
62
|
+
lines.push('---');
|
|
63
|
+
lines.push(`[${x.type}] ${stringifyContent(x.content).slice(0, 400)}`);
|
|
64
|
+
}
|
|
65
|
+
return lines.join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Haiku gate. Returns { pass, reason }. Fail-open on network/HTTP errors.
|
|
69
|
+
async function llmGate(t, opts) {
|
|
70
|
+
const apiKey = (opts && opts.apiKey) || process.env.ANTHROPIC_API_KEY;
|
|
71
|
+
if (!apiKey) return { pass: true, reason: 'no_api_key_skip_gate' };
|
|
72
|
+
const model = (opts && opts.model) || process.env.CLAUDE_DISTILL_GATE_MODEL || 'claude-haiku-4-5-20251001';
|
|
73
|
+
|
|
74
|
+
const sample = buildGateSample(t);
|
|
75
|
+
const prompt = [
|
|
76
|
+
'Below is the tail of a developer coding session with Claude Code.',
|
|
77
|
+
'Does it contain a non-trivial lesson, gotcha, judgment call, or insight worth recording for future sessions?',
|
|
78
|
+
'Skip routine work, simple Q&A, and trivial fixes.',
|
|
79
|
+
'Reply with exactly "yes" or "no" — nothing else.',
|
|
80
|
+
'',
|
|
81
|
+
sample,
|
|
82
|
+
].join('\n');
|
|
83
|
+
|
|
84
|
+
let res;
|
|
85
|
+
try {
|
|
86
|
+
res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
'x-api-key': apiKey,
|
|
90
|
+
'anthropic-version': '2023-06-01',
|
|
91
|
+
'content-type': 'application/json',
|
|
92
|
+
},
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
model,
|
|
95
|
+
max_tokens: 4,
|
|
96
|
+
messages: [{ role: 'user', content: prompt }],
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
} catch (e) {
|
|
100
|
+
return { pass: true, reason: 'gate_network_error' };
|
|
101
|
+
}
|
|
102
|
+
if (!res.ok) return { pass: true, reason: 'gate_http_' + res.status };
|
|
103
|
+
let data;
|
|
104
|
+
try { data = await res.json(); } catch { return { pass: true, reason: 'gate_bad_json' }; }
|
|
105
|
+
const block = (data.content || []).find((c) => c.type === 'text');
|
|
106
|
+
const text = ((block && block.text) || '').toLowerCase().trim();
|
|
107
|
+
return { pass: text.startsWith('y'), reason: 'haiku:' + (text || 'empty').slice(0, 16) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { heuristicGate, llmGate, MIN_TURNS };
|
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
|
|
22
|
-
|
|
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
|
|
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
|
-
? ' ·
|
|
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' ?
|
|
47
|
-
const insightLabel = e.type === 'gotcha' ?
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
51
|
+
`**${L.basisLabel}**: ${e.basis || ''}`,
|
|
59
52
|
'',
|
|
60
|
-
|
|
53
|
+
`**${L.applicationLabel}**: ${e.application || ''}`,
|
|
61
54
|
'',
|
|
62
|
-
|
|
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
|
+
"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",
|