claude-distill 0.2.0 → 0.3.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 +43 -9
- package/lib/analyze.js +46 -0
- package/lib/gate.js +110 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -90,9 +90,12 @@ source ~/.zshrc
|
|
|
90
90
|
```
|
|
91
91
|
세션 끝
|
|
92
92
|
└─ Stop hook이 `claude-distill analyze --quiet` 자동 실행
|
|
93
|
+
├─ [재귀 가드] 자식 claude 세션이면 즉시 종료 (CLAUDE_DISTILL_CHILD)
|
|
94
|
+
├─ [중복 방지] 같은 슬라이스 이미 분석됐으면 종료
|
|
95
|
+
├─ [게이트 1 — 휴리스틱] 짧은 세션 / 도구 사용 0 / 에러 키워드 0 → 종료
|
|
96
|
+
├─ [게이트 2 — Haiku] (API key 있을 때) yes/no 1토큰 응답, no면 종료
|
|
93
97
|
├─ 마지막 user marker 이후 turn slice (보통 ~120 turns / ~85K chars)
|
|
94
|
-
├─
|
|
95
|
-
├─ JSON 응답 파싱
|
|
98
|
+
├─ Sonnet/Opus로 analyzer prompt 전달, JSON 응답 파싱
|
|
96
99
|
├─ confidence:high entry → ~/.claude/knowledge.md / gotchas.md 즉시 append
|
|
97
100
|
└─ medium / low → drop (사용자 손 안 가게)
|
|
98
101
|
|
|
@@ -101,6 +104,8 @@ source ~/.zshrc
|
|
|
101
104
|
└─ Claude가 자연스럽게 참조 — 같은 함정 안 빠짐
|
|
102
105
|
```
|
|
103
106
|
|
|
107
|
+
게이트 두 단계는 "대부분의 세션은 인사이트가 없다" 가정으로 본 추출 호출을 ~10× 줄입니다. 게이트가 차단된 세션은 dedup에 마킹돼 같은 슬라이스로 다시 호출돼도 즉시 종료.
|
|
108
|
+
|
|
104
109
|
---
|
|
105
110
|
|
|
106
111
|
## 카테고리
|
|
@@ -141,15 +146,28 @@ source ~/.zshrc
|
|
|
141
146
|
- `--mock` — claude CLI 호출 없이 가짜 entry 1건 (파이프라인 검증용)
|
|
142
147
|
- `--session=<file>` — 특정 jsonl 직접 지정
|
|
143
148
|
- `--quiet` — 출력 억제 (hook이 사용)
|
|
149
|
+
- `--no-gate` / `--force` — 휴리스틱/Haiku 게이트와 재귀 가드 모두 우회 (강제 분석)
|
|
150
|
+
|
|
151
|
+
환경변수:
|
|
152
|
+
- `ANTHROPIC_API_KEY` — 설정 시 Haiku 게이트 자동 활성 (~$0 가까이 게이트 비용)
|
|
153
|
+
- `CLAUDE_DISTILL_GATE_MODEL` — 게이트 모델 (기본 `claude-haiku-4-5-20251001`)
|
|
154
|
+
- `CLAUDE_DISTILL_MODEL` — 본 추출 모델 (기본 `claude-sonnet-4-6`)
|
|
155
|
+
- `CLAUDE_DISTILL_CHILD` — distill이 spawn한 자식 claude 표식. 수동 설정 불필요 (hook 무한 재귀 차단용 내부 플래그)
|
|
144
156
|
|
|
145
157
|
---
|
|
146
158
|
|
|
147
|
-
## 프라이버시
|
|
159
|
+
## 프라이버시 / 보안
|
|
160
|
+
|
|
161
|
+
**별도 서버 없음.** distill은 본인 머신 → Anthropic API 직통입니다. 중간에 어떤 third-party 서버도 없음 — 코드도 50KB 미만, [GitHub](https://github.com/parksubeom/claude-distill)에서 그대로 검수 가능.
|
|
148
162
|
|
|
149
|
-
-
|
|
150
|
-
-
|
|
151
|
-
-
|
|
152
|
-
-
|
|
163
|
+
- **transcript 전송 범위**: 본인의 `ANTHROPIC_API_KEY`로 본인 계정의 Claude API에만 전달. distill 운영자(저)에게도 안 감.
|
|
164
|
+
- **API key 저장**: distill은 key를 절대 파일에 안 씀. 본인이 `~/.zshrc` 등에 export한 환경변수만 읽음.
|
|
165
|
+
- **권한 범위**: transcript는 read-only, 결과 markdown 2개만 append. 코드/repo는 절대 안 건드림.
|
|
166
|
+
- **잔존물**: `~/.claude/.distill/analyzed.json`에는 transcript 내용이 아니라 SHA hash 12자만 저장 (중복 분석 방지용).
|
|
167
|
+
- **결과는 plain markdown**: 마음에 안 드는 entry는 줄 째로 삭제. 다음 세션부터 inject 안 됨.
|
|
168
|
+
- **완전 비활성화**: `~/.claude/settings.json`의 `hooks.Stop` 항목 삭제 또는 `npm uninstall -g claude-distill`.
|
|
169
|
+
|
|
170
|
+
**시크릿 우려**: transcript에 API key / 패스워드를 평문으로 입력한 적 있다면 분석 prompt에도 그게 들어갑니다. Anthropic API의 데이터 처리 정책 그대로 따르며, distill 자체는 그걸 디스크에 저장하지 않음. 민감한 transcript가 있는 세션은 `claude-distill analyze` 실행 전 `~/.claude/projects/<project>/`에서 해당 jsonl을 직접 삭제하면 됩니다.
|
|
153
171
|
|
|
154
172
|
---
|
|
155
173
|
|
|
@@ -159,7 +177,10 @@ source ~/.zshrc
|
|
|
159
177
|
A. 직접 작성 가능. `claude-distill`은 매일 쌓이는 자잘한 판례/사고를 자동으로 잡아내는 보조 도구. CLAUDE.md는 변하지 않는 보편 규칙용으로 그대로 쓰시면 됩니다.
|
|
160
178
|
|
|
161
179
|
**Q. 토큰 비용은?**
|
|
162
|
-
A.
|
|
180
|
+
A. 게이트가 본 추출의 90% 가량을 사전에 컷합니다 (v0.3+). 통과한 세션만 prompt ~85K chars (~20K tokens) input + 응답 ~2K tokens output → Sonnet 기준 세션당 약 $0.10. 그 외 세션은 휴리스틱(무료) / Haiku 게이트(1토큰 응답, 사실상 0원)에서 종료. `ANTHROPIC_API_KEY` 설정 시 Haiku 게이트가 켜져 비용 효율이 가장 좋음.
|
|
181
|
+
|
|
182
|
+
**Q. 게이트 때문에 인사이트를 놓치는 거 아닌가?**
|
|
183
|
+
A. 휴리스틱이 보수적이라 false negative 가능 (예: 도구 사용 0인 토론 세션). 그런 세션은 `claude-distill analyze --no-gate`로 강제 분석 가능. 게이트가 차단하는 패턴이 본인 워크플로와 안 맞으면 README의 [issue tracker](https://github.com/parksubeom/claude-distill/issues)에 알려주세요.
|
|
163
184
|
|
|
164
185
|
**Q. confidence:medium / low는 왜 drop?**
|
|
165
186
|
A. 노이즈 누적이 가장 큰 실패 패턴이라 보수적으로 시작. 향후 `--keep-medium` 옵션 추가 가능.
|
|
@@ -173,11 +194,24 @@ A. **됩니다.** transcript는 익스텐션도 같은 위치(`~/.claude/project
|
|
|
173
194
|
**Q. 다른 LLM 백엔드?**
|
|
174
195
|
A. 현재 `claude` CLI + Anthropic API 두 가지. `--backend=cli|api|auto` 옵션. 기본 `auto`는 `ANTHROPIC_API_KEY` 있으면 API 우선, 없으면 CLI fallback. OpenAI / 로컬 LLM 지원은 v0.3+.
|
|
175
196
|
|
|
197
|
+
**Q. 잠시 멈추거나 완전히 제거하려면?**
|
|
198
|
+
A. 일시 정지: `~/.claude/settings.json`의 `hooks.Stop` 배열에서 distill entry만 빼면 됨. 완전 제거: `npm uninstall -g claude-distill` 후 `~/.claude/CLAUDE.md` 끝의 `<!-- claude-distill auto-references -->` 블록 삭제. 누적된 `knowledge.md` / `gotchas.md`는 유지하고 싶으면 그대로 두거나, 완전 초기화하려면 삭제.
|
|
199
|
+
|
|
200
|
+
**Q. 잘못 누적된 entry는 어떻게 지워?**
|
|
201
|
+
A. `~/.claude/knowledge.md` 또는 `gotchas.md`에서 그 entry의 `### ...` 블록만 텍스트로 삭제. 다음 세션부터 inject 안 됨. plain markdown이라 자유 편집.
|
|
202
|
+
|
|
203
|
+
**Q. 회사 코드 / 시크릿이 transcript에 있어도 괜찮나?**
|
|
204
|
+
A. 분석은 본인의 Anthropic 계정으로만 흘러가지만 (별도 서버 없음 — 위 보안 섹션 참조), API 호출 자체가 회사 정책에 막혀있다면 distill도 못 씀. 의심되는 세션은 `~/.claude/projects/<project>/<session>.jsonl`을 직접 삭제하거나, 해당 세션은 `claude-distill analyze`가 실행되기 전에 hook을 잠시 끄면 됨.
|
|
205
|
+
|
|
206
|
+
**Q. 어떤 OS에서 됨?**
|
|
207
|
+
A. macOS / Linux / Windows (Node 18+ 설치돼있으면). 경로는 전부 `os.homedir()`로 동적 결정 — 하드코딩 없음. 테스트는 macOS 위주지만 OS-specific 코드는 없음.
|
|
208
|
+
|
|
176
209
|
---
|
|
177
210
|
|
|
178
211
|
## 상태
|
|
179
212
|
|
|
180
|
-
v0.
|
|
213
|
+
v0.3 — 게이트 도입 (휴리스틱 + Haiku) + Stop 훅 무한 재귀 가드. 본 추출 호출 ~10× 감소.
|
|
214
|
+
v0.2 — 자동 누적 모델로 재정비.
|
|
181
215
|
|
|
182
216
|
## License
|
|
183
217
|
|
package/lib/analyze.js
CHANGED
|
@@ -21,6 +21,10 @@ 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
|
+
|
|
26
|
+
// 자식 claude 프로세스 표식. distill→claude→Stop 훅→distill 무한 재귀 차단용.
|
|
27
|
+
const CHILD_ENV_KEY = 'CLAUDE_DISTILL_CHILD';
|
|
24
28
|
|
|
25
29
|
function parseFlags(args) {
|
|
26
30
|
const flags = {
|
|
@@ -30,6 +34,7 @@ function parseFlags(args) {
|
|
|
30
34
|
mock: false,
|
|
31
35
|
auto: true, // 기본값 — 자동 누적 (CLI 핵심)
|
|
32
36
|
backend: 'auto', // 'auto' | 'cli' | 'api' — auto는 ANTHROPIC_API_KEY 있으면 api, 없으면 cli
|
|
37
|
+
noGate: false, // 게이트 우회 (디버깅 / 강제 분석)
|
|
33
38
|
};
|
|
34
39
|
for (let i = 0; i < args.length; i++) {
|
|
35
40
|
const a = args[i];
|
|
@@ -37,6 +42,7 @@ function parseFlags(args) {
|
|
|
37
42
|
else if (a === '--dry-run') flags.dryRun = true;
|
|
38
43
|
else if (a === '--mock') flags.mock = true;
|
|
39
44
|
else if (a === '--no-auto') flags.auto = false;
|
|
45
|
+
else if (a === '--no-gate' || a === '--force') flags.noGate = true;
|
|
40
46
|
else if (a.startsWith('--backend=')) flags.backend = a.slice('--backend='.length);
|
|
41
47
|
else if (a === '--backend') flags.backend = args[++i];
|
|
42
48
|
else if (a.startsWith('--session=')) flags.session = a.slice('--session='.length);
|
|
@@ -83,6 +89,9 @@ function callClaudeCli(promptText) {
|
|
|
83
89
|
timeout: 120000,
|
|
84
90
|
maxBuffer: 16 * 1024 * 1024,
|
|
85
91
|
encoding: 'utf8',
|
|
92
|
+
// 자식 claude 세션이 끝나며 발사하는 Stop 훅이 또 distill을 부르는
|
|
93
|
+
// 무한 재귀를 차단. run() 시작부에서 이 env를 보고 즉시 종료.
|
|
94
|
+
env: { ...process.env, [CHILD_ENV_KEY]: '1' },
|
|
86
95
|
});
|
|
87
96
|
} catch (e) {
|
|
88
97
|
if (e.code === 'ENOENT') {
|
|
@@ -182,6 +191,16 @@ function writeDedupLog(set) {
|
|
|
182
191
|
|
|
183
192
|
async function run(args) {
|
|
184
193
|
const flags = parseFlags(args);
|
|
194
|
+
|
|
195
|
+
// ── 재귀 가드 ──────────────────────────────────────────────
|
|
196
|
+
// distill이 spawn한 자식 claude 프로세스가 끝나면 Stop 훅이 또 발사돼
|
|
197
|
+
// distill을 부른다. 그 자식 세션은 분석 대상이 아니므로 즉시 종료.
|
|
198
|
+
// (--force는 가드도 무시 — 진짜 자식 세션 디버깅용 escape hatch)
|
|
199
|
+
if (process.env[CHILD_ENV_KEY] === '1' && !flags.noGate) {
|
|
200
|
+
if (!flags.quiet) console.log('child claude session — analyze 스킵 (재귀 방지)');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
185
204
|
cfg.ensureStateDir();
|
|
186
205
|
|
|
187
206
|
let file = flags.session || transcript.latestSessionFile();
|
|
@@ -208,6 +227,33 @@ async function run(args) {
|
|
|
208
227
|
return;
|
|
209
228
|
}
|
|
210
229
|
|
|
230
|
+
// ── 게이트 1: 휴리스틱 (로컬, 무료) ───────────────────────
|
|
231
|
+
// 짧은 세션 / 도구 사용 0 / 에러 키워드 0 → 인사이트 후보 거의 없음.
|
|
232
|
+
// mock 모드와 --no-gate 는 우회.
|
|
233
|
+
if (!flags.mock && !flags.noGate) {
|
|
234
|
+
const h = gate.heuristicGate(t);
|
|
235
|
+
if (!h.pass) {
|
|
236
|
+
log(flags, 'heuristic 게이트 차단 (' + h.reason + ') — LLM 호출 스킵');
|
|
237
|
+
seen.add(dedupKey);
|
|
238
|
+
writeDedupLog(seen);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── 게이트 2: Haiku LLM yes/no (API 백엔드 한정, 매우 저렴) ─
|
|
244
|
+
// ANTHROPIC_API_KEY 있을 때만 동작. CLI 백엔드는 Haiku 호출이 또
|
|
245
|
+
// 새 세션을 만들어 무의미하므로 스킵.
|
|
246
|
+
if (!flags.mock && !flags.noGate && process.env.ANTHROPIC_API_KEY) {
|
|
247
|
+
const g = await gate.llmGate(t, { apiKey: process.env.ANTHROPIC_API_KEY });
|
|
248
|
+
if (!g.pass) {
|
|
249
|
+
log(flags, 'haiku 게이트 차단 (' + g.reason + ') — LLM 호출 스킵');
|
|
250
|
+
seen.add(dedupKey);
|
|
251
|
+
writeDedupLog(seen);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
log(flags, 'haiku 게이트 통과 (' + g.reason + ')');
|
|
255
|
+
}
|
|
256
|
+
|
|
211
257
|
// LLM 호출
|
|
212
258
|
let candidates;
|
|
213
259
|
if (flags.mock) {
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-distill",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|