claude-token-saver 2.18.0 → 3.0.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.en.md +49 -0
- package/README.md +49 -0
- package/bin/cli.js +233 -3
- package/package.json +1 -1
- package/src/frugon-export.js +211 -0
- package/src/harness.js +85 -0
- package/src/installer.js +37 -0
- package/src/route-scan.js +286 -0
package/README.en.md
CHANGED
|
@@ -82,6 +82,8 @@ Run these in your shell (inside Claude Code, the `/claude-token-saver` Skill is
|
|
|
82
82
|
| `claude-token-saver handoff` | Back work up to `HANDOFF-*.md` before a cap blocks you |
|
|
83
83
|
| `claude-token-saver mode [keywords...]` | Output config (`icon`/`text`, `en`/`ko`, `1h`–`30d` window, …) |
|
|
84
84
|
| `claude-token-saver harness ...` | 🅷 Harness management (below) |
|
|
85
|
+
| `claude-token-saver frugon` | Export sessions → [frugon](https://github.com/Rodiun/frugon)-compatible JSONL (model-routing savings analysis, below) |
|
|
86
|
+
| `claude-token-saver route-scan` | Detect recurring easy work on expensive models → propose haiku-delegation ratchet rules (below) |
|
|
85
87
|
| `claude-token-saver install` | Manually register Skill + statusline |
|
|
86
88
|
|
|
87
89
|
Switch output language with `mode ko` / `mode en` (English default; statusline chips stay symbolic).
|
|
@@ -114,11 +116,15 @@ claude-token-saver harness init # this project
|
|
|
114
116
|
claude-token-saver harness init --global # ~/.claude/CLAUDE.md — every project
|
|
115
117
|
claude-token-saver harness check # current score (global fallback honored)
|
|
116
118
|
claude-token-saver harness promote <N> --project|--global # warning #N → ratchet rule (scope required)
|
|
119
|
+
claude-token-saver harness promote "<rule text>" --project|--global # register your own hand-written rules the same way
|
|
120
|
+
claude-token-saver harness pull # pull your global ratchet rules into this project (dedupes)
|
|
121
|
+
claude-token-saver harness pull --harness # also pull the global harness block (5 sections)
|
|
117
122
|
claude-token-saver harness list / rm <N> # view / delete rules (auto .bak)
|
|
118
123
|
claude-token-saver harness off | on # toggle the 🅷 chip
|
|
119
124
|
```
|
|
120
125
|
|
|
121
126
|
- `promote` **requires** `--project`/`--global` in non-TTY contexts (scripts, LLM calls) — a scope choice is never silently made for the caller.
|
|
127
|
+
- `install` / `init` never auto-inject rules into a project. When you want the rules you've accumulated globally in a new project, pull them explicitly with `harness pull` (idempotent — re-running adds nothing twice).
|
|
122
128
|
- 🅷⚠ runtime warnings (`ratchet?` `no-evidence` `PEV-skip`) expire after 30 minutes, subdirectory sessions match their project correctly, and PEV-skip counts only mutating tools (Edit/Write/Bash) so read-only research sessions don't trip it (v2.16.0+).
|
|
123
129
|
|
|
124
130
|
<details>
|
|
@@ -133,6 +139,38 @@ The whole point of the ratchet is **one-direction accumulation**. Deleting rules
|
|
|
133
139
|
An auto `.bak` is kept, but **the session context that earned the rule its place is not recoverable.**
|
|
134
140
|
</details>
|
|
135
141
|
|
|
142
|
+
## 🔀 frugon integration — "which calls could a cheaper model handle?"
|
|
143
|
+
|
|
144
|
+
claude-token-saver catches cache/context waste; [frugon](https://github.com/Rodiun/frugon) (a local LLM cost analyzer) covers **model routing** — finding calls that never needed your most expensive model. The `frugon` subcommand bridges the two:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
claude-token-saver frugon # last 30 days → ./frugon-export.jsonl
|
|
148
|
+
claude-token-saver frugon --run # export + run frugon analyze immediately
|
|
149
|
+
claude-token-saver frugon --days 7 --project myproj --out logs.jsonl
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
- Converts `~/.claude/projects/` transcripts into the OpenAI-compatible JSONL frugon reads. **Analysis is fully local** — no logs or keys leave your machine (same principle frugon holds).
|
|
153
|
+
- **Cache-weighted tokens (default):** frugon doesn't know about prompt caching, so raw physical tokens would overstate your spend ~10x. By default the export folds in Anthropic's cache multipliers (read 0.1x · 5m write 1.25x · 1h write 2x) so frugon's dollar figures match your real bill. Use `--raw-tokens` for physical counts.
|
|
154
|
+
- Preserves the signals frugon's easy/hard router reads (prompt/completion tokens, conversation depth) plus the last user prompt and reply text for `--measure` quality sampling. Strip text with `--no-content`.
|
|
155
|
+
- Install frugon with `pipx install frugon` (if models show as unpriced, run `frugon update`).
|
|
156
|
+
|
|
157
|
+
## 🔀 route-scan — "this recurring task could run on haiku"
|
|
158
|
+
|
|
159
|
+
The practical follow-through of the frugon integration. It applies frugon-style difficulty analysis at the **episode (user request) level** to your session history, finds easy work your expensive model (opus/fable) keeps doing, and proposes promoting it into a haiku-subagent delegation rule. Fully local, zero token cost.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
claude-token-saver route-scan # scan (24h cache) + print candidates
|
|
163
|
+
claude-token-saver harness promote R1 --project # promote candidate R1 to a ratchet rule
|
|
164
|
+
claude-token-saver route-scan dismiss 1 # not interested — won't resurface
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
How it works (session-boundary calibration, NOT a real-time router):
|
|
168
|
+
1. `install` registers a SessionStart hook that injects the cached scan results as session context on startup and `/clear` (the scan itself refreshes in the background, once a day).
|
|
169
|
+
2. When a recurring (≥3×) easy pattern exists, the statusline shows a `🅷⚠ route? R1` chip and Claude asks you whether to register it, and at which scope (`--project`/`--global`).
|
|
170
|
+
3. Promoted rules accumulate in ratchet.md, so **from the next session on, the main model delegates that work type to a haiku subagent automatically**.
|
|
171
|
+
|
|
172
|
+
Recommended companion setup: create `model: haiku` subagents under `~/.claude/agents/` (e.g. haiku-explore / haiku-runner / haiku-translate) so the rules are immediately actionable.
|
|
173
|
+
|
|
136
174
|
## Spike issue codes
|
|
137
175
|
|
|
138
176
|
| Code | Meaning |
|
|
@@ -213,6 +251,17 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
|
|
|
213
251
|
|
|
214
252
|
## Release notes
|
|
215
253
|
|
|
254
|
+
### v3.0.0 (2026-07-13)
|
|
255
|
+
- **Major bump** — with v2.19's frugon integration and v2.20's route-scan, the product's character shifted from "after-the-fact token monitor" to "a routing layer that pushes recurring easy work down to cheaper models", so this ships as a major. No breaking changes (every existing command and setting remains compatible).
|
|
256
|
+
- **route-scan promote fix** — `harness promote R<N> --project` now writes the rule into the `.claude/ratchet.md` of the project the candidate was **detected in** (previously it landed in whatever directory the CLI ran from). The scan stores each candidate's real session path (`projectPath`); promoting a foreign-project candidate from a pre-3.0 cache without that field is refused with a pointer to `route-scan --refresh`.
|
|
257
|
+
- **New `harness pull`** — explicitly pull your global ratchet rules into a project (`--harness` also pulls the global harness block). Dedupes by rule text, so re-running is idempotent. `install`/`init` still never auto-inject anything — pulling is always opt-in.
|
|
258
|
+
|
|
259
|
+
### v2.20.0 (2026-07-13)
|
|
260
|
+
- **route-scan**: detect recurring easy work on expensive models → `🅷⚠ route? R<N>` chip + SessionStart hook context injection + `harness promote R<N> --project|--global` to promote haiku-delegation ratchet rules.
|
|
261
|
+
|
|
262
|
+
### v2.19.0 (2026-07-12)
|
|
263
|
+
- **frugon integration**: `claude-token-saver frugon` — export session transcripts as [frugon](https://github.com/Rodiun/frugon)-compatible JSONL for model-routing savings analysis (`--run` to analyze immediately; cache-weighted tokens by default).
|
|
264
|
+
|
|
216
265
|
### v2.18.0 (2026-07-02)
|
|
217
266
|
- **1M-context warning re-scoped** — current models (Fable 5, Opus 4.6–4.8, Sonnet 5) all default to a 1M window with no long-context premium since Opus 4.7, so the "1M mode ON = expensive" framing is retired. The warning is now a **usage signal**: `⚠ 1M ON` → `⚠ Ctx 200k+` (a single request actually exceeded 200k), and remediation is reordered to lead with `/compact`/`/clear` + `/effort` instead of "disable 1M". The incorrect "long-context pricing kicks in past 200k" copy is fixed.
|
|
218
267
|
- **📦 segment shows live usage** — reads `context_window.used_percentage` from Claude Code's stdin and renders `📦 Ctx 68% of 1M`, colored by fill (green <70 / yellow 70–89 / red 90+). Falls back to transcript-inferred size when stdin is absent (1M now yellow, not red).
|
package/README.md
CHANGED
|
@@ -82,6 +82,8 @@ Claude 안에서 `/claude-token-saver` Skill을 실행하거나 칩 문구를
|
|
|
82
82
|
| `claude-token-saver handoff` | 작업 상태를 `HANDOFF-*.md`로 백업 (캡 임박 시) |
|
|
83
83
|
| `claude-token-saver mode [keywords...]` | 출력 설정 (`icon`/`text`, `ko`/`en`, `1h`~`30d` 윈도 등) |
|
|
84
84
|
| `claude-token-saver harness ...` | 🅷 Harness 관리 (아래 참고) |
|
|
85
|
+
| `claude-token-saver frugon` | 세션 기록 → [frugon](https://github.com/Rodiun/frugon) 호환 JSONL 내보내기 (모델 라우팅 절감 분석, 아래 참고) |
|
|
86
|
+
| `claude-token-saver route-scan` | 상위 모델이 반복 처리한 easy 작업 감지 → haiku 위임 랫쳇 룰 제안 (아래 참고) |
|
|
85
87
|
| `claude-token-saver install` | Skill·statusline 수동 등록 |
|
|
86
88
|
|
|
87
89
|
출력 언어는 `mode ko` / `mode en`으로 전환합니다 (기본 영어, statusline 칩은 항상 기호). 전체 옵션은 [영문 README](./README.en.md#options) 참고.
|
|
@@ -95,11 +97,15 @@ claude-token-saver harness init # 이 프로젝트에 셋업
|
|
|
95
97
|
claude-token-saver harness init --global # ~/.claude/CLAUDE.md — 모든 프로젝트 적용
|
|
96
98
|
claude-token-saver harness check # 현재 점수 (글로벌 fallback 인정)
|
|
97
99
|
claude-token-saver harness promote <N> --project|--global # 경고 #N → ratchet 룰 (스코프 필수)
|
|
100
|
+
claude-token-saver harness promote "<룰 텍스트>" --project|--global # 내가 직접 정의한 룰도 같은 명령으로 등록
|
|
101
|
+
claude-token-saver harness pull # 글로벌 랫쳇 룰을 이 프로젝트로 가져오기 (중복 자동 스킵)
|
|
102
|
+
claude-token-saver harness pull --harness # 글로벌 하네스 블록(5개 섹션)까지 함께 가져오기
|
|
98
103
|
claude-token-saver harness list / rm <N> # 룰 조회 / 삭제 (자동 .bak)
|
|
99
104
|
claude-token-saver harness off | on # 🅷 표시 토글
|
|
100
105
|
```
|
|
101
106
|
|
|
102
107
|
- `promote`는 non-TTY(스크립트·LLM 호출)에서 `--project`/`--global` 플래그가 **필수** — 스코프가 묻지 않고 결정되는 사고를 막기 위한 설계입니다.
|
|
108
|
+
- 설치(`install`)나 `init`은 프로젝트에 룰을 자동 주입하지 않습니다. 글로벌에 쌓아둔 룰을 새 프로젝트에서 쓰고 싶을 때만 `harness pull`로 명시적으로 가져오세요 (재실행해도 중복 없음).
|
|
103
109
|
- 🅷⚠ 런타임 경고(`ratchet?` `no-evidence` `PEV-skip`)는 30분 후 자동 만료되고, 하위 디렉터리 세션도 프로젝트에 올바르게 매칭됩니다. PEV-skip은 변경성 도구(Edit/Write/Bash)만 카운트해 읽기 위주 세션에서는 발동하지 않습니다 (v2.16.0+).
|
|
104
110
|
|
|
105
111
|
<details>
|
|
@@ -114,6 +120,38 @@ ratchet의 가치는 **한 방향 누적**에 있습니다. 룰을 가볍게 지
|
|
|
114
120
|
삭제 시 `.bak`이 남지만 **그 룰이 박힌 세션 컨텍스트(왜)는 복원되지 않습니다.**
|
|
115
121
|
</details>
|
|
116
122
|
|
|
123
|
+
## 🔀 frugon 연계 — "어떤 호출을 싼 모델로 내릴 수 있나"
|
|
124
|
+
|
|
125
|
+
claude-token-saver가 캐시·컨텍스트 낭비를 잡는다면, [frugon](https://github.com/Rodiun/frugon)(로컬 LLM 비용 분석기)은 **모델 라우팅** 절감 — 굳이 비싼 모델이 필요 없는 호출 찾기 — 을 다룹니다. `frugon` 서브커맨드가 둘을 연결합니다:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
claude-token-saver frugon # 최근 30일 세션 → ./frugon-export.jsonl
|
|
129
|
+
claude-token-saver frugon --run # 내보내기 + frugon analyze 바로 실행
|
|
130
|
+
claude-token-saver frugon --days 7 --project myproj --out logs.jsonl
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- `~/.claude/projects/`의 transcript를 frugon이 읽는 OpenAI 호환 JSONL로 변환합니다. **분석은 전부 로컬** — 로그도 키도 밖으로 나가지 않습니다 (frugon의 원칙과 동일).
|
|
134
|
+
- **캐시 가중 토큰(기본):** frugon은 프롬프트 캐싱을 모르기 때문에 물리 토큰을 그대로 주면 비용이 ~10배 과대평가됩니다. 기본값은 캐시 read 0.1x · 5m write 1.25x · 1h write 2x를 접어 넣은 유효 토큰이라 frugon의 달러 견적이 실제 청구액과 일치합니다. 물리 토큰이 필요하면 `--raw-tokens`.
|
|
135
|
+
- frugon의 easy/hard 분류가 쓰는 신호(프롬프트·응답 토큰, 대화 깊이)와 `--measure` 품질 검증에 쓰는 마지막 유저 프롬프트·응답 텍스트를 보존합니다. 텍스트를 빼고 싶으면 `--no-content`.
|
|
136
|
+
- frugon 설치: `pipx install frugon` (모델이 unpriced로 나오면 `frugon update`).
|
|
137
|
+
|
|
138
|
+
## 🔀 route-scan — "이 반복 작업, haiku로 내려도 됩니다"
|
|
139
|
+
|
|
140
|
+
frugon 연계의 실전 버전입니다. frugon식 난이도 분석을 **에피소드(사용자 요청) 단위**로 세션 기록에 적용해, 상위 모델(opus/fable)이 반복 처리해 온 easy 작업을 찾아 haiku 서브에이전트 위임 룰로 승격하도록 제안합니다. 전 과정 로컬, 토큰 비용 0.
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
claude-token-saver route-scan # 스캔 (24h 캐시) + 후보 출력
|
|
144
|
+
claude-token-saver harness promote R1 --project # 후보 R1을 랫쳇 룰로 등록
|
|
145
|
+
claude-token-saver route-scan dismiss 1 # 관심 없으면 무시 (재스캔에도 안 뜸)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
동작 구조 (실시간 라우팅이 아니라 **세션 경계 캘리브레이션**):
|
|
149
|
+
1. `install` 시 SessionStart 훅이 등록되어, 새 세션 시작·`/clear` 때 캐시된 스캔 결과를 세션 컨텍스트로 주입합니다 (스캔 자체는 백그라운드에서 일 1회).
|
|
150
|
+
2. 반복(≥3회) easy 패턴이 있으면 statusline에 `🅷⚠ route? R1` 칩이 뜨고, Claude가 등록 여부와 scope(`--project`/`--global`)를 물어봅니다.
|
|
151
|
+
3. 등록된 룰은 ratchet.md에 쌓여 **다음 세션부터 메인 모델이 해당 유형을 haiku 서브에이전트로 자동 위임**합니다.
|
|
152
|
+
|
|
153
|
+
권장 사전 준비: `~/.claude/agents/`에 `model: haiku` 서브에이전트(예: haiku-explore / haiku-runner / haiku-translate)를 만들어 두면 룰이 바로 실행 가능해집니다.
|
|
154
|
+
|
|
117
155
|
## 토큰 급증 원인 코드
|
|
118
156
|
|
|
119
157
|
| 코드 | 의미 |
|
|
@@ -170,6 +208,17 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
|
|
|
170
208
|
|
|
171
209
|
## 릴리스 노트
|
|
172
210
|
|
|
211
|
+
### v3.0.0 (2026-07-13)
|
|
212
|
+
- **메이저 승격** — v2.19 frugon 연계 + v2.20 route-scan으로 "사후 토큰 모니터링 도구"에서 "반복 easy 작업을 싼 모델로 내려보내는 라우팅 계층"으로 제품 성격이 바뀌어 메이저 버전을 올립니다. Breaking change는 없습니다 (기존 명령·설정 전부 호환).
|
|
213
|
+
- **route-scan promote 교정** — `harness promote R<N> --project`가 이제 후보가 **감지된 프로젝트**의 `.claude/ratchet.md`에 룰을 기록합니다 (이전에는 CLI를 실행한 디렉터리에 기록되는 버그). 스캔이 후보에 실제 세션 경로(`projectPath`)를 저장하며, 이 필드가 없는 구버전 캐시에서 다른 프로젝트 후보를 승격하려 하면 `route-scan --refresh`를 안내하고 중단합니다.
|
|
214
|
+
- **`harness pull` 신설** — 글로벌 랫쳇 룰을 프로젝트로 명시적으로 가져옵니다 (`--harness`로 글로벌 하네스 블록까지). 룰 텍스트 기준 중복 자동 스킵이라 재실행해도 안전(멱등). 설치·init은 계속 아무것도 자동 주입하지 않습니다 — 가져오기는 항상 opt-in.
|
|
215
|
+
|
|
216
|
+
### v2.20.0 (2026-07-13)
|
|
217
|
+
- **route-scan**: 상위 모델이 반복 처리한 easy 작업 감지 → `🅷⚠ route? R<N>` 칩 + SessionStart 훅 컨텍스트 주입 + `harness promote R<N> --project|--global`로 haiku 위임 랫쳇 룰 승격.
|
|
218
|
+
|
|
219
|
+
### v2.19.0 (2026-07-12)
|
|
220
|
+
- **frugon 연계**: `claude-token-saver frugon` — 세션 transcript를 [frugon](https://github.com/Rodiun/frugon) 호환 JSONL로 내보내 모델 라우팅 절감 분석 (`--run`으로 즉시 분석, 캐시 가중 토큰 기본).
|
|
221
|
+
|
|
173
222
|
### v2.18.0 (2026-07-02)
|
|
174
223
|
- **1M 컨텍스트 경고 의미 재정의** — 현재 모델(Fable 5, Opus 4.6~4.8, Sonnet 5)은 전부 1M 윈도가 기본이고 Opus 4.7부터 장기 컨텍스트 프리미엄도 없어, "1M 모드 ON = 비쌈" 프레임을 폐기했습니다. 경고는 이제 **실사용 신호**입니다: `⚠ 1M ON` → `⚠ Ctx 200k+`(단일 요청이 실제로 200k 초과), 처방도 "1M 끄기" 우선에서 "`/compact`/`/clear` + `/effort` 점검" 우선으로 재정렬. 잘못된 "200k 초과 시 장기 요금 적용" 문구 정정.
|
|
175
224
|
- **📦 세그먼트가 실시간 사용률 표시** — Claude Code stdin의 `context_window.used_percentage`를 사용해 `📦 Ctx 68% of 1M` 형태로 렌더 (사용률 기준 녹 <70 / 황 70–89 / 적 90+). stdin이 없으면 기존 크기 추론으로 폴백하되 1M은 빨강 대신 노랑.
|
package/bin/cli.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* npx claude-token-saver --format json # JSON output
|
|
10
10
|
* npx claude-token-saver --format csv # CSV output
|
|
11
11
|
* npx claude-token-saver --project myproj # filter by project
|
|
12
|
+
* npx claude-token-saver route-scan # detect recurring easy work → haiku-delegation candidates
|
|
13
|
+
* npx claude-token-saver frugon # export transcripts → frugon JSONL (model-routing analysis)
|
|
14
|
+
* npx claude-token-saver frugon --run # export + run `frugon analyze`
|
|
12
15
|
* npx claude-token-saver --install-hook # install PostToolUse hook
|
|
13
16
|
* npx claude-token-saver --uninstall-hook # remove hook
|
|
14
17
|
* npx claude-token-saver --hook-run # internal: called by hook
|
|
@@ -398,6 +401,7 @@ async function main() {
|
|
|
398
401
|
};
|
|
399
402
|
const r = installAll({ force });
|
|
400
403
|
print('skill', r.skill);
|
|
404
|
+
print('SessionStart hook (route-scan)', r.sessionStartHook);
|
|
401
405
|
{
|
|
402
406
|
const s = r.statusline;
|
|
403
407
|
const verb = s.action === 'exists' ? 'already configured (refreshInterval=5)'
|
|
@@ -462,11 +466,173 @@ async function main() {
|
|
|
462
466
|
return;
|
|
463
467
|
}
|
|
464
468
|
|
|
469
|
+
// Subcommand: route-scan — detect recurring easy work on expensive models
|
|
470
|
+
// and propose model-delegation ratchet rules. Zero token cost, fully local.
|
|
471
|
+
// claude-token-saver route-scan # scan (24h cache) + print candidates
|
|
472
|
+
// claude-token-saver route-scan --refresh # force rescan
|
|
473
|
+
// claude-token-saver route-scan --days 30 # wider lookback
|
|
474
|
+
// claude-token-saver route-scan --hook # SessionStart hook mode (context injection)
|
|
475
|
+
// claude-token-saver route-scan dismiss <N> # mute candidate R<N>
|
|
476
|
+
// Promote a candidate to a ratchet rule (scope is always explicit):
|
|
477
|
+
// claude-token-saver harness promote R<N> --project|--global
|
|
478
|
+
if (args[0] === 'route-scan') {
|
|
479
|
+
const rs = await import('../src/route-scan.js');
|
|
480
|
+
const { userLanguage } = await import('../src/config.js');
|
|
481
|
+
const lang = userLanguage();
|
|
482
|
+
|
|
483
|
+
if (args[1] === 'dismiss') {
|
|
484
|
+
const n = parseInt(args[2], 10);
|
|
485
|
+
if (!Number.isFinite(n)) {
|
|
486
|
+
console.error('Usage: claude-token-saver route-scan dismiss <N> # N from `route? R<N>`');
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|
|
489
|
+
const cand = rs.resolveCandidate(n);
|
|
490
|
+
if (!cand) {
|
|
491
|
+
console.error(`No route candidate R${n}. Run: claude-token-saver route-scan`);
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
console.log(lang === 'ko'
|
|
495
|
+
? `R${n} 무시 처리: ${cand.label} (${cand.project}) — 재스캔에도 다시 뜨지 않습니다.`
|
|
496
|
+
: `Dismissed R${n}: ${cand.label} (${cand.project}) — won't resurface on rescans.`);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// --hook: SessionStart hook mode. Never scans inline (session start must
|
|
501
|
+
// stay fast) — reads the cache, kicks a detached refresh when stale, and
|
|
502
|
+
// prints delegation-candidate context for the new session.
|
|
503
|
+
if (hasFlag('--hook')) {
|
|
504
|
+
let cache = rs.readRouteScan();
|
|
505
|
+
if (!rs.isCacheFresh(cache)) {
|
|
506
|
+
try {
|
|
507
|
+
const { spawn } = await import('node:child_process');
|
|
508
|
+
spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
|
|
509
|
+
{ detached: true, stdio: 'ignore' }).unref();
|
|
510
|
+
} catch { /* refresh is best-effort; stale cache still usable below */ }
|
|
511
|
+
}
|
|
512
|
+
const open = rs.openCandidates(cache);
|
|
513
|
+
if (open.length === 0) return; // silent — nothing to inject
|
|
514
|
+
const lines = [];
|
|
515
|
+
lines.push(`[claude-token-saver route-scan] 최근 ${cache.days}일 세션에서 상위 모델(opus/fable)이 처리한 반복 easy 작업이 감지되었습니다:`);
|
|
516
|
+
for (const c of open) {
|
|
517
|
+
lines.push(` R${c.id} (×${c.count}, ${c.project}): ${c.label} → ${c.agent} 위임 권장 (scope 제안: ${c.suggestedScope})`);
|
|
518
|
+
lines.push(` 예시: "${c.example}"`);
|
|
519
|
+
}
|
|
520
|
+
lines.push('이 패턴을 랫쳇 룰로 등록하면 다음 세션부터 자동 위임됩니다. 적절한 시점에 사용자에게 등록 여부와 scope를 물어본 뒤 실행하세요:');
|
|
521
|
+
lines.push(' claude-token-saver harness promote R<N> --project|--global # scope는 반드시 사용자에게 확인');
|
|
522
|
+
lines.push(' claude-token-saver route-scan dismiss <N> # 사용자가 원치 않으면');
|
|
523
|
+
console.log(lines.join('\n'));
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const days = parseFloat(getArg('--days') || '14');
|
|
528
|
+
let cache = rs.readRouteScan();
|
|
529
|
+
if (hasFlag('--refresh') || !rs.isCacheFresh(cache) || (cache && cache.days !== days)) {
|
|
530
|
+
cache = await rs.runRouteScan({ days });
|
|
531
|
+
}
|
|
532
|
+
if (hasFlag('--quiet')) return;
|
|
533
|
+
if (hasFlag('--json')) {
|
|
534
|
+
console.log(JSON.stringify(cache, null, 2));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const easyPct = cache.totalEpisodes ? Math.round(cache.easyEpisodes / cache.totalEpisodes * 100) : 0;
|
|
538
|
+
console.log(lang === 'ko'
|
|
539
|
+
? `route-scan — 최근 ${cache.days}일: 에피소드 ${cache.totalEpisodes}건 중 easy ${cache.easyEpisodes}건 (${easyPct}%) [스캔: ${cache.scannedAt}]`
|
|
540
|
+
: `route-scan — last ${cache.days}d: ${cache.easyEpisodes}/${cache.totalEpisodes} episodes easy (${easyPct}%) [scanned: ${cache.scannedAt}]`);
|
|
541
|
+
const open = rs.openCandidates(cache);
|
|
542
|
+
if (open.length === 0) {
|
|
543
|
+
console.log(lang === 'ko'
|
|
544
|
+
? '위임 후보 없음 (반복 3회 미만이거나 이미 처리됨).'
|
|
545
|
+
: 'No delegation candidates (below recurrence threshold or already resolved).');
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
console.log(lang === 'ko' ? '\n위임 후보:' : '\nDelegation candidates:');
|
|
549
|
+
for (const c of open) {
|
|
550
|
+
console.log(` R${c.id} ×${c.count} ${c.label} → ${c.agent} [${c.project}] (scope 제안: ${c.suggestedScope})`);
|
|
551
|
+
console.log(` 예시: "${c.example}"`);
|
|
552
|
+
console.log(` 룰: ${c.rule}`);
|
|
553
|
+
}
|
|
554
|
+
console.log('');
|
|
555
|
+
console.log(lang === 'ko' ? '등록 / 무시:' : 'Promote / dismiss:');
|
|
556
|
+
console.log(' claude-token-saver harness promote R<N> --project|--global');
|
|
557
|
+
console.log(' claude-token-saver route-scan dismiss <N>');
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Subcommand: frugon — export Claude Code transcripts to the JSONL format
|
|
562
|
+
// frugon (local LLM cost analyzer, github.com/Rodiun/frugon) analyzes, so
|
|
563
|
+
// users can see which calls could route to a cheaper model.
|
|
564
|
+
// claude-token-saver frugon # export last 30 days → ./frugon-export.jsonl
|
|
565
|
+
// claude-token-saver frugon --days 7 # narrower window
|
|
566
|
+
// claude-token-saver frugon --project myproj # filter by project dir substring
|
|
567
|
+
// claude-token-saver frugon --out PATH # custom output path
|
|
568
|
+
// claude-token-saver frugon --raw-tokens # physical token counts (no cache weighting)
|
|
569
|
+
// claude-token-saver frugon --no-content # strip prompt/reply text (counts only)
|
|
570
|
+
// claude-token-saver frugon --run # run `frugon analyze` on the export
|
|
571
|
+
if (args[0] === 'frugon') {
|
|
572
|
+
const { exportFrugonLogs } = await import('../src/frugon-export.js');
|
|
573
|
+
const { userLanguage } = await import('../src/config.js');
|
|
574
|
+
const lang = userLanguage();
|
|
575
|
+
const days = parseFloat(getArg('--days') || '30');
|
|
576
|
+
const outPath = getArg('--out') || 'frugon-export.jsonl';
|
|
577
|
+
const cacheWeighted = !hasFlag('--raw-tokens');
|
|
578
|
+
const includeContent = !hasFlag('--no-content');
|
|
579
|
+
const res = await exportFrugonLogs({
|
|
580
|
+
days,
|
|
581
|
+
projectFilter: getArg('--project') || undefined,
|
|
582
|
+
outPath,
|
|
583
|
+
cacheWeighted,
|
|
584
|
+
includeContent,
|
|
585
|
+
});
|
|
586
|
+
if (res.records === 0) {
|
|
587
|
+
console.log(lang === 'ko'
|
|
588
|
+
? `최근 ${days}일 내 세션 기록이 없습니다 (~/.claude/projects).`
|
|
589
|
+
: `No session records in the last ${days} days (~/.claude/projects).`);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
console.log(lang === 'ko'
|
|
593
|
+
? `frugon 로그 내보내기 완료: ${res.outPath}`
|
|
594
|
+
: `frugon log export complete: ${res.outPath}`);
|
|
595
|
+
console.log(` ${res.records} calls / ${res.sessions} sessions / last ${days}d`);
|
|
596
|
+
const byModel = Object.entries(res.models).sort((a, b) => b[1] - a[1]);
|
|
597
|
+
for (const [model, count] of byModel) console.log(` ${model}: ${count}`);
|
|
598
|
+
console.log(cacheWeighted
|
|
599
|
+
? (lang === 'ko'
|
|
600
|
+
? ' prompt_tokens는 캐시 가중치 적용값 (read 0.1x, 5m write 1.25x, 1h write 2x) — frugon 비용이 실제 청구액과 일치. 해제: --raw-tokens'
|
|
601
|
+
: ' prompt_tokens are cache-weighted (read 0.1x, 5m write 1.25x, 1h write 2x) so frugon costs match your real bill. Disable: --raw-tokens')
|
|
602
|
+
: (lang === 'ko'
|
|
603
|
+
? ' prompt_tokens는 물리 토큰 수 (캐시 가중치 없음 — frugon 비용이 실제보다 크게 나옴)'
|
|
604
|
+
: ' prompt_tokens are raw physical counts (no cache weighting — frugon will overstate cost)'));
|
|
605
|
+
if (hasFlag('--run')) {
|
|
606
|
+
const { spawnSync } = await import('node:child_process');
|
|
607
|
+
console.log('');
|
|
608
|
+
const run = spawnSync('frugon', ['analyze', res.outPath], { stdio: 'inherit' });
|
|
609
|
+
if (run.error && run.error.code === 'ENOENT') {
|
|
610
|
+
console.error(lang === 'ko'
|
|
611
|
+
? 'frugon이 PATH에 없습니다. 설치: pipx install frugon (또는 pip install frugon)'
|
|
612
|
+
: 'frugon not found on PATH. Install: pipx install frugon (or pip install frugon)');
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
if (typeof run.status === 'number' && run.status !== 0) process.exit(run.status);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
console.log('');
|
|
619
|
+
console.log(lang === 'ko' ? '다음 단계:' : 'Next step:');
|
|
620
|
+
console.log(` frugon analyze ${res.outPath}`);
|
|
621
|
+
console.log(lang === 'ko'
|
|
622
|
+
? ' (frugon 미설치 시: pipx install frugon — 분석은 전부 로컬에서 실행됩니다)'
|
|
623
|
+
: ' (if frugon is not installed: pipx install frugon — analysis runs fully local)');
|
|
624
|
+
console.log(lang === 'ko'
|
|
625
|
+
? ' (unpriced 모델이 나오면: frugon update 로 가격표를 갱신하세요)'
|
|
626
|
+
: ' (if models show as unpriced: run `frugon update` to refresh the pricing table)');
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
465
630
|
// Subcommand: harness — manage the project's CLAUDE.md harness rules.
|
|
466
631
|
// claude-token-saver harness init # write CLAUDE.md (5 sections) + ratchet.md
|
|
467
632
|
// claude-token-saver harness uninit # remove harness block from CLAUDE.md (backup kept)
|
|
468
633
|
// claude-token-saver harness check # show 🅷 N/5 + which sections are missing
|
|
469
634
|
// claude-token-saver harness promote "<rule>" # append a rule to ratchet.md
|
|
635
|
+
// claude-token-saver harness pull [--harness] # copy global ratchet rules (+block) into this project
|
|
470
636
|
// claude-token-saver harness off | on # toggle the statusline 🅷 segment
|
|
471
637
|
if (args[0] === 'harness') {
|
|
472
638
|
const sub = args[1];
|
|
@@ -485,7 +651,7 @@ async function main() {
|
|
|
485
651
|
}
|
|
486
652
|
return dflt;
|
|
487
653
|
};
|
|
488
|
-
const { harnessInit, harnessUninit, harnessStatus, harnessPromote, harnessListRules, harnessRmRule, findProjectRoot } =
|
|
654
|
+
const { harnessInit, harnessUninit, harnessStatus, harnessPromote, harnessPull, harnessListRules, harnessRmRule, findProjectRoot } =
|
|
489
655
|
await import('../src/harness.js');
|
|
490
656
|
const { HARNESS_SECTIONS } = await import('../src/harness-templates.js');
|
|
491
657
|
const { loadConfig, saveConfig } = await import('../src/config.js');
|
|
@@ -579,6 +745,28 @@ async function main() {
|
|
|
579
745
|
}
|
|
580
746
|
rule = `반복 감지 ×${cand.count}: ${cand.pattern} — TODO: 원인·예방책 한 줄로`;
|
|
581
747
|
}
|
|
748
|
+
// R-prefixed arg → route-scan delegation candidate (statusline `route? R<N>`).
|
|
749
|
+
// The rule text is pre-generated by the scan; promoting also resolves the
|
|
750
|
+
// candidate so the chip stops and rescans don't resurface it.
|
|
751
|
+
let routeCandidateId = null;
|
|
752
|
+
let routeCandidate = null;
|
|
753
|
+
if (/^[Rr]\d+$/.test(raw)) {
|
|
754
|
+
const n = parseInt(raw.slice(1), 10);
|
|
755
|
+
const rs = await import('../src/route-scan.js');
|
|
756
|
+
const cand = (rs.openCandidates(rs.readRouteScan()) || []).find((c) => c.id === n);
|
|
757
|
+
if (!cand) {
|
|
758
|
+
console.error(`No open route candidate R${n}. Run: claude-token-saver route-scan`);
|
|
759
|
+
process.exit(1);
|
|
760
|
+
}
|
|
761
|
+
rule = cand.rule;
|
|
762
|
+
routeCandidateId = n;
|
|
763
|
+
routeCandidate = cand;
|
|
764
|
+
if (!scope) {
|
|
765
|
+
console.error(`Route candidate R${n} requires an explicit scope (suggested: --${cand.suggestedScope}).`);
|
|
766
|
+
console.error('Ask the user, then pass --project or --global.');
|
|
767
|
+
process.exit(1);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
582
770
|
// Scope resolution: explicit flag wins. Otherwise prompt interactively
|
|
583
771
|
// when running on a TTY; in non-TTY (CI/scripts) require an explicit
|
|
584
772
|
// flag so the choice is never silently made for the caller.
|
|
@@ -604,9 +792,31 @@ async function main() {
|
|
|
604
792
|
process.exit(1);
|
|
605
793
|
}
|
|
606
794
|
}
|
|
607
|
-
|
|
795
|
+
// Route candidates were detected in a specific project's sessions — a
|
|
796
|
+
// --project rule must land in THAT project's ratchet.md, not the cwd's.
|
|
797
|
+
// The candidate carries the real session cwd (projectPath); older cached
|
|
798
|
+
// scans predate that field, so fall back to verifying the cwd matches.
|
|
799
|
+
let promoteRoot;
|
|
800
|
+
if (routeCandidate && scope === 'project') {
|
|
801
|
+
const rs = await import('../src/route-scan.js');
|
|
802
|
+
if (routeCandidate.projectPath) {
|
|
803
|
+
promoteRoot = findProjectRoot(routeCandidate.projectPath);
|
|
804
|
+
} else if (rs.mungeProjectPath(findProjectRoot()) !== routeCandidate.project) {
|
|
805
|
+
console.error(`Route candidate R${routeCandidateId} was detected in another project (${routeCandidate.project}),`);
|
|
806
|
+
console.error('but this cached scan predates project-path tracking.');
|
|
807
|
+
console.error('Re-scan to capture it, then promote again:');
|
|
808
|
+
console.error(' claude-token-saver route-scan --refresh');
|
|
809
|
+
process.exit(1);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const r = harnessPromote(rule, promoteRoot ? { scope, root: promoteRoot } : { scope });
|
|
608
813
|
console.log(`Appended to ${r.path} [${r.scope}]:`);
|
|
609
814
|
console.log(` - ${rule}`);
|
|
815
|
+
if (routeCandidateId !== null) {
|
|
816
|
+
const rs = await import('../src/route-scan.js');
|
|
817
|
+
rs.resolveCandidate(routeCandidateId);
|
|
818
|
+
console.log(`(route candidate R${routeCandidateId} resolved — 다음 세션부터 자동 위임 룰로 적용됩니다)`);
|
|
819
|
+
}
|
|
610
820
|
if (/^\d+$/.test(raw)) {
|
|
611
821
|
console.log('\n👉 ratchet.md를 열어 TODO 부분을 실제 룰로 다듬어주세요.');
|
|
612
822
|
}
|
|
@@ -659,6 +869,26 @@ async function main() {
|
|
|
659
869
|
return;
|
|
660
870
|
}
|
|
661
871
|
|
|
872
|
+
if (sub === 'pull') {
|
|
873
|
+
// Pull the user's GLOBAL ratchet rules (and optionally the global
|
|
874
|
+
// harness block) into this project. Explicitly opt-in — install/init
|
|
875
|
+
// never auto-injects rules into a project.
|
|
876
|
+
const includeBlock = args.includes('--harness');
|
|
877
|
+
const r = harnessPull({ includeBlock });
|
|
878
|
+
console.log(`Pull global → project (${r.root})`);
|
|
879
|
+
if (r.added.length) {
|
|
880
|
+
console.log(`✅ ${r.added.length} rule(s) pulled into .claude/ratchet.md:`);
|
|
881
|
+
for (const t of r.added) console.log(` - ${t}`);
|
|
882
|
+
} else {
|
|
883
|
+
console.log('No new rules to pull.');
|
|
884
|
+
}
|
|
885
|
+
if (r.skippedRules) console.log(` (${r.skippedRules} already present — skipped)`);
|
|
886
|
+
for (const f of r.wrote.filter((w) => w.includes('CLAUDE.md'))) console.log(`✅ ${f}`);
|
|
887
|
+
for (const f of r.skipped) console.log(` skip: ${f}`);
|
|
888
|
+
if (!includeBlock) console.log('\n글로벌 하네스 블록(CLAUDE.md 5개 섹션)까지 가져오려면: claude-token-saver harness pull --harness');
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
662
892
|
if (sub === 'list' || sub === 'ls') {
|
|
663
893
|
const wantGlobal = hasFlag('--global');
|
|
664
894
|
const wantProject = hasFlag('--project') || !wantGlobal;
|
|
@@ -722,7 +952,7 @@ async function main() {
|
|
|
722
952
|
}
|
|
723
953
|
|
|
724
954
|
console.error(`Unknown harness subcommand: ${sub}`);
|
|
725
|
-
console.error('Usage: claude-token-saver harness [check|init|uninit [--purge-ratchet]|promote "<rule>"|list|rm <N>|off|on]');
|
|
955
|
+
console.error('Usage: claude-token-saver harness [check|init|uninit [--purge-ratchet]|promote "<rule>"|pull [--harness]|list|rm <N>|off|on]');
|
|
726
956
|
process.exit(1);
|
|
727
957
|
}
|
|
728
958
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* frugon export — convert Claude Code session transcripts into the
|
|
3
|
+
* OpenAI-compatible JSONL log format frugon analyzes.
|
|
4
|
+
* (frugon: local LLM cost analyzer — github.com/Rodiun/frugon)
|
|
5
|
+
*
|
|
6
|
+
* One output line per API call:
|
|
7
|
+
* {
|
|
8
|
+
* "model": "claude-opus-4-8",
|
|
9
|
+
* "timestamp": "2026-07-12T02:11:05.123Z",
|
|
10
|
+
* "usage": { "prompt_tokens": 1234, "completion_tokens": 56 },
|
|
11
|
+
* "request": { "messages": [ ...stubs..., { "role": "user", "content": "<last user prompt>" } ] },
|
|
12
|
+
* "response": { "choices": [ { "message": { "role": "assistant", "content": "<reply>" } } ] }
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* Design notes (kept in sync with frugon 0.2.x internals):
|
|
16
|
+
* - frugon prefers the usage block for token counts, so message content is
|
|
17
|
+
* never re-tokenized — stubs with empty content are safe.
|
|
18
|
+
* - frugon's easy/hard difficulty score reads prompt_tokens, completion_tokens
|
|
19
|
+
* and conversation depth (len(messages) - 1, saturating at 6 turns). We emit
|
|
20
|
+
* up to MAX_STUB_MESSAGES role-alternating stubs so depth survives the
|
|
21
|
+
* export without duplicating the whole conversation into every record.
|
|
22
|
+
* - frugon has no notion of prompt caching: every prompt token is priced at
|
|
23
|
+
* the base input rate. Claude Code sessions are cache-read heavy (~90%+),
|
|
24
|
+
* so raw totals would overstate spend ~10x. By default we fold Anthropic's
|
|
25
|
+
* cache multipliers (5m write 1.25x, 1h write 2x, read 0.1x) into an
|
|
26
|
+
* "effective" prompt_tokens so frugon's dollar figures match reality.
|
|
27
|
+
* Pass cacheWeighted: false for raw physical token counts.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
31
|
+
import { createInterface } from 'node:readline';
|
|
32
|
+
import { discoverSessionFiles } from './parser.js';
|
|
33
|
+
|
|
34
|
+
// Depth cap: frugon's turn signal saturates at 6 turns (len(messages)-1 >= 6),
|
|
35
|
+
// so 7 messages carry the maximum-depth signal at minimum size.
|
|
36
|
+
const MAX_STUB_MESSAGES = 7;
|
|
37
|
+
|
|
38
|
+
// Anthropic cache multipliers relative to the base input rate — uniform
|
|
39
|
+
// across model tiers (see src/cost.js PRICING).
|
|
40
|
+
const CACHE_WEIGHTS = { write5m: 1.25, write1h: 2, read: 0.1 };
|
|
41
|
+
|
|
42
|
+
/** Strip context-window suffixes like "[1m]" so frugon's pricing table matches. */
|
|
43
|
+
export function normalizeModelId(model) {
|
|
44
|
+
return String(model || 'unknown').replace(/\[[^\]]*\]$/, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Effective prompt tokens: what the call *costs* expressed in base-rate
|
|
49
|
+
* input tokens, so frugon (which prices all prompt tokens at the input rate)
|
|
50
|
+
* reproduces the real cache-discounted spend.
|
|
51
|
+
*/
|
|
52
|
+
export function effectivePromptTokens(r) {
|
|
53
|
+
const tracked = (r.ephemeral5mTokens || 0) + (r.ephemeral1hTokens || 0);
|
|
54
|
+
const untracked = Math.max(0, (r.cacheCreationTokens || 0) - tracked);
|
|
55
|
+
return Math.round(
|
|
56
|
+
(r.inputTokens || 0) +
|
|
57
|
+
((r.ephemeral5mTokens || 0) + untracked) * CACHE_WEIGHTS.write5m +
|
|
58
|
+
(r.ephemeral1hTokens || 0) * CACHE_WEIGHTS.write1h +
|
|
59
|
+
(r.cacheReadTokens || 0) * CACHE_WEIGHTS.read,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Raw physical prompt tokens (input + cache writes + cache reads). */
|
|
64
|
+
export function rawPromptTokens(r) {
|
|
65
|
+
return (r.inputTokens || 0) + (r.cacheCreationTokens || 0) + (r.cacheReadTokens || 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Extract plain text from a Claude transcript message content field. */
|
|
69
|
+
function contentText(content) {
|
|
70
|
+
if (typeof content === 'string') return content;
|
|
71
|
+
if (!Array.isArray(content)) return '';
|
|
72
|
+
return content
|
|
73
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
74
|
+
.map((b) => b.text)
|
|
75
|
+
.join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse one session transcript into frugon records.
|
|
80
|
+
* Deduplicates by requestId (last-write-wins, matching parser.js) while
|
|
81
|
+
* tracking the conversation depth and last user prompt at each call.
|
|
82
|
+
*/
|
|
83
|
+
export async function collectSessionRecords(filePath, { cacheWeighted = true, includeContent = true } = {}) {
|
|
84
|
+
const records = new Map();
|
|
85
|
+
let depth = 0;
|
|
86
|
+
let lastUserText = '';
|
|
87
|
+
let lastCwd = '';
|
|
88
|
+
|
|
89
|
+
const rl = createInterface({
|
|
90
|
+
input: createReadStream(filePath, { encoding: 'utf8' }),
|
|
91
|
+
crlfDelay: Infinity,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
for await (const line of rl) {
|
|
95
|
+
let entry;
|
|
96
|
+
try {
|
|
97
|
+
entry = JSON.parse(line);
|
|
98
|
+
} catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const msg = entry.message;
|
|
103
|
+
if (typeof entry.cwd === 'string' && entry.cwd) lastCwd = entry.cwd;
|
|
104
|
+
if (entry.type === 'user' && msg) {
|
|
105
|
+
depth += 1;
|
|
106
|
+
const text = contentText(msg.content);
|
|
107
|
+
if (text) lastUserText = text;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (entry.type !== 'assistant' || !msg) continue;
|
|
111
|
+
depth += 1;
|
|
112
|
+
|
|
113
|
+
if (!msg.usage || !msg.id) continue;
|
|
114
|
+
// "<synthetic>" is Claude Code's placeholder for locally-generated
|
|
115
|
+
// entries (e.g. error stubs) — no real API call, nothing to price.
|
|
116
|
+
if (msg.model === '<synthetic>') continue;
|
|
117
|
+
const usage = msg.usage;
|
|
118
|
+
const reqId = entry.requestId || msg.id;
|
|
119
|
+
const r = {
|
|
120
|
+
inputTokens: usage.input_tokens || 0,
|
|
121
|
+
cacheCreationTokens: usage.cache_creation_input_tokens || 0,
|
|
122
|
+
cacheReadTokens: usage.cache_read_input_tokens || 0,
|
|
123
|
+
ephemeral5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens || 0,
|
|
124
|
+
ephemeral1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens || 0,
|
|
125
|
+
outputTokens: usage.output_tokens || 0,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
records.set(reqId, {
|
|
129
|
+
model: normalizeModelId(msg.model),
|
|
130
|
+
timestamp: entry.timestamp || null,
|
|
131
|
+
prompt_tokens: cacheWeighted ? effectivePromptTokens(r) : rawPromptTokens(r),
|
|
132
|
+
completion_tokens: r.outputTokens,
|
|
133
|
+
depth,
|
|
134
|
+
userText: includeContent ? lastUserText : '',
|
|
135
|
+
assistantText: includeContent ? contentText(msg.content) : '',
|
|
136
|
+
cwd: lastCwd,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return [...records.values()];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Build the frugon JSONL object for one collected record. */
|
|
144
|
+
export function toFrugonRecord(rec) {
|
|
145
|
+
const msgCount = Math.max(1, Math.min(rec.depth, MAX_STUB_MESSAGES));
|
|
146
|
+
const messages = [];
|
|
147
|
+
for (let i = 0; i < msgCount - 1; i++) {
|
|
148
|
+
messages.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: '' });
|
|
149
|
+
}
|
|
150
|
+
messages.push({ role: 'user', content: rec.userText || '' });
|
|
151
|
+
|
|
152
|
+
const out = {
|
|
153
|
+
model: rec.model,
|
|
154
|
+
request: { messages },
|
|
155
|
+
response: {
|
|
156
|
+
choices: [{ message: { role: 'assistant', content: rec.assistantText || '' } }],
|
|
157
|
+
},
|
|
158
|
+
usage: {
|
|
159
|
+
prompt_tokens: rec.prompt_tokens,
|
|
160
|
+
completion_tokens: rec.completion_tokens,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
if (rec.timestamp) out.timestamp = rec.timestamp;
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Export Claude Code transcripts to a frugon-compatible JSONL file.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} options
|
|
171
|
+
* days lookback window (default 30)
|
|
172
|
+
* projectFilter substring match on the project dir name
|
|
173
|
+
* outPath output JSONL path
|
|
174
|
+
* cacheWeighted fold cache pricing into prompt_tokens (default true)
|
|
175
|
+
* includeContent include user prompt / assistant reply text (default true)
|
|
176
|
+
* @returns {Promise<{records:number, sessions:number, models:Object, outPath:string}>}
|
|
177
|
+
*/
|
|
178
|
+
export async function exportFrugonLogs({
|
|
179
|
+
days = 30,
|
|
180
|
+
projectFilter,
|
|
181
|
+
outPath,
|
|
182
|
+
cacheWeighted = true,
|
|
183
|
+
includeContent = true,
|
|
184
|
+
} = {}) {
|
|
185
|
+
const files = await discoverSessionFiles({ days, projectFilter });
|
|
186
|
+
const models = {};
|
|
187
|
+
let recordCount = 0;
|
|
188
|
+
let sessionCount = 0;
|
|
189
|
+
|
|
190
|
+
const stream = createWriteStream(outPath, { encoding: 'utf8' });
|
|
191
|
+
for (const f of files) {
|
|
192
|
+
let recs;
|
|
193
|
+
try {
|
|
194
|
+
recs = await collectSessionRecords(f.path, { cacheWeighted, includeContent });
|
|
195
|
+
} catch {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (recs.length === 0) continue;
|
|
199
|
+
sessionCount += 1;
|
|
200
|
+
for (const rec of recs) {
|
|
201
|
+
stream.write(JSON.stringify(toFrugonRecord(rec)) + '\n');
|
|
202
|
+
models[rec.model] = (models[rec.model] || 0) + 1;
|
|
203
|
+
recordCount += 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
await new Promise((resolve, reject) => {
|
|
207
|
+
stream.end((err) => (err ? reject(err) : resolve()));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return { records: recordCount, sessions: sessionCount, models, outPath };
|
|
211
|
+
}
|
package/src/harness.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
harnessRatchetMdInitial,
|
|
20
20
|
appendRatchetRule,
|
|
21
21
|
} from './harness-templates.js';
|
|
22
|
+
import { routeWarningForStatusline } from './route-scan.js';
|
|
22
23
|
|
|
23
24
|
const require = createRequire(import.meta.url);
|
|
24
25
|
function readHarnessState() {
|
|
@@ -276,6 +277,82 @@ export function harnessPromote(ruleText, { root = findProjectRoot(), scope = 'pr
|
|
|
276
277
|
return { path: rmPath, root, scope };
|
|
277
278
|
}
|
|
278
279
|
|
|
280
|
+
/**
|
|
281
|
+
* harness pull — copy the user's GLOBAL ratchet rules (~/.claude/ratchet.md)
|
|
282
|
+
* into the current project's .claude/ratchet.md, on demand. Opt-in by design:
|
|
283
|
+
* install/init never auto-injects rules; this is the explicit "땡겨오기" verb.
|
|
284
|
+
*
|
|
285
|
+
* Rules are deduped by text (ignoring the leading YYYY-MM-DD stamp) so
|
|
286
|
+
* repeated pulls are idempotent. With `includeBlock`, the global CLAUDE.md
|
|
287
|
+
* harness block (including any user customizations) is also copied into the
|
|
288
|
+
* project CLAUDE.md — replacing the project's block if one exists.
|
|
289
|
+
*
|
|
290
|
+
* Returns { root, added, skippedRules, wrote, skipped }.
|
|
291
|
+
*/
|
|
292
|
+
export function harnessPull({ root = findProjectRoot(), includeBlock = false } = {}) {
|
|
293
|
+
const result = { root, added: [], skippedRules: 0, wrote: [], skipped: [] };
|
|
294
|
+
const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}:\s*/, '').trim();
|
|
295
|
+
|
|
296
|
+
// 1) Ratchet rules: global → project, dedup by rule text.
|
|
297
|
+
const globalRules = harnessListRules({ scope: 'global' }).rules;
|
|
298
|
+
const projPath = ratchetMdPath(root);
|
|
299
|
+
let content = existsSync(projPath)
|
|
300
|
+
? readFileSync(projPath, 'utf8')
|
|
301
|
+
: harnessRatchetMdInitial();
|
|
302
|
+
const have = new Set(
|
|
303
|
+
harnessListRules({ root, scope: 'project' }).rules.map((r) => stripDate(r.text)),
|
|
304
|
+
);
|
|
305
|
+
for (const g of globalRules) {
|
|
306
|
+
const key = stripDate(g.text);
|
|
307
|
+
if (have.has(key)) {
|
|
308
|
+
result.skippedRules += 1;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
content = appendRatchetRule(content, key);
|
|
312
|
+
have.add(key);
|
|
313
|
+
result.added.push(key);
|
|
314
|
+
}
|
|
315
|
+
if (result.added.length) {
|
|
316
|
+
mkdirSync(dirname(projPath), { recursive: true });
|
|
317
|
+
writeFileSync(projPath, content);
|
|
318
|
+
result.wrote.push(projPath);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 2) Harness block (opt-in): copy the global block as-is so user edits to
|
|
322
|
+
// the global sections travel with it.
|
|
323
|
+
if (includeBlock) {
|
|
324
|
+
const gPath = globalClaudeMdPath();
|
|
325
|
+
const blockRe = new RegExp(
|
|
326
|
+
`${escapeRe(HARNESS_BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(HARNESS_BLOCK_END)}\\n?`,
|
|
327
|
+
'm',
|
|
328
|
+
);
|
|
329
|
+
const gContent = existsSync(gPath) ? readFileSync(gPath, 'utf8') : '';
|
|
330
|
+
const m = gContent.match(blockRe);
|
|
331
|
+
if (!m) {
|
|
332
|
+
result.skipped.push(`${gPath} (no global harness block to pull)`);
|
|
333
|
+
} else {
|
|
334
|
+
const block = m[0];
|
|
335
|
+
const pPath = claudeMdPath(root);
|
|
336
|
+
if (existsSync(pPath)) {
|
|
337
|
+
const pc = readFileSync(pPath, 'utf8');
|
|
338
|
+
if (pc.includes(HARNESS_BLOCK_BEGIN)) {
|
|
339
|
+
writeFileSync(pPath, pc.replace(blockRe, block));
|
|
340
|
+
result.wrote.push(`${pPath} (harness block replaced with global copy)`);
|
|
341
|
+
} else {
|
|
342
|
+
const sep = pc.endsWith('\n') ? '\n' : '\n\n';
|
|
343
|
+
writeFileSync(pPath, pc + sep + block);
|
|
344
|
+
result.wrote.push(`${pPath} (harness block appended from global)`);
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
writeFileSync(pPath, block);
|
|
348
|
+
result.wrote.push(pPath);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
|
|
279
356
|
/**
|
|
280
357
|
* harness list — return numbered ratchet rules from .claude/ratchet.md.
|
|
281
358
|
* Numbering is 1-based and matches `harness rm <N>`.
|
|
@@ -362,6 +439,14 @@ export function harnessStatusForStatusline(cfg, { root } = {}) {
|
|
|
362
439
|
else if (state.pevSkip) warning = 'PEV-skip';
|
|
363
440
|
}
|
|
364
441
|
}
|
|
442
|
+
// Lowest precedence: route-scan delegation candidate (`route? R<N>`).
|
|
443
|
+
// Session-quality warnings above always win — routing is an optimization
|
|
444
|
+
// nudge, not a correctness signal. Cheap: one small cached-JSON read.
|
|
445
|
+
if (!warning) {
|
|
446
|
+
try {
|
|
447
|
+
warning = routeWarningForStatusline(projectRoot);
|
|
448
|
+
} catch { /* scan cache unreadable — stay silent */ }
|
|
449
|
+
}
|
|
365
450
|
return { ...status, warning };
|
|
366
451
|
}
|
|
367
452
|
|
package/src/installer.js
CHANGED
|
@@ -202,10 +202,47 @@ export function installStatusline({ force = false } = {}) {
|
|
|
202
202
|
return { path: file, action: 'updated', reason: 'replaced previous statusLine' };
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// Registers the SessionStart hook that surfaces route-scan delegation
|
|
206
|
+
// candidates as session context (startup + /clear). Idempotent: skips when a
|
|
207
|
+
// claude-token-saver route-scan hook is already present; never touches other
|
|
208
|
+
// hooks the user configured.
|
|
209
|
+
const ROUTE_SCAN_HOOK_COMMAND = 'claude-token-saver route-scan --hook';
|
|
210
|
+
|
|
211
|
+
export function installSessionStartHook() {
|
|
212
|
+
const dir = claudeUserDir();
|
|
213
|
+
const file = join(dir, 'settings.json');
|
|
214
|
+
mkdirSync(dir, { recursive: true });
|
|
215
|
+
|
|
216
|
+
let settings = {};
|
|
217
|
+
if (existsSync(file)) {
|
|
218
|
+
try {
|
|
219
|
+
settings = JSON.parse(readFileSync(file, 'utf8'));
|
|
220
|
+
} catch (e) {
|
|
221
|
+
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
settings.hooks = settings.hooks || {};
|
|
226
|
+
const list = Array.isArray(settings.hooks.SessionStart) ? settings.hooks.SessionStart : [];
|
|
227
|
+
const already = list.some((m) =>
|
|
228
|
+
Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('route-scan --hook')),
|
|
229
|
+
);
|
|
230
|
+
if (already) return { path: file, action: 'exists' };
|
|
231
|
+
|
|
232
|
+
list.push({
|
|
233
|
+
matcher: 'startup|clear',
|
|
234
|
+
hooks: [{ type: 'command', command: ROUTE_SCAN_HOOK_COMMAND, timeout: 10 }],
|
|
235
|
+
});
|
|
236
|
+
settings.hooks.SessionStart = list;
|
|
237
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
238
|
+
return { path: file, action: 'created' };
|
|
239
|
+
}
|
|
240
|
+
|
|
205
241
|
export function installAll({ force = false } = {}) {
|
|
206
242
|
return {
|
|
207
243
|
skill: installSkill({ force }),
|
|
208
244
|
statusline: installStatusline({ force }),
|
|
245
|
+
sessionStartHook: installSessionStartHook(),
|
|
209
246
|
legacy: removeLegacyCommand(),
|
|
210
247
|
};
|
|
211
248
|
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* route-scan — detect recurring "easy" work running on expensive models and
|
|
3
|
+
* propose model-delegation ratchet rules.
|
|
4
|
+
*
|
|
5
|
+
* Runs the frugon-style difficulty idea at the EPISODE level (one user
|
|
6
|
+
* request = the consecutive API calls it triggered), because per-call scoring
|
|
7
|
+
* saturates on Claude Code's large session contexts. An episode is easy when
|
|
8
|
+
* the whole request finished in few calls with little generation — exactly
|
|
9
|
+
* the work a haiku subagent could take.
|
|
10
|
+
*
|
|
11
|
+
* Fully local, zero token cost. Results are cached (24h) so the SessionStart
|
|
12
|
+
* hook can read them without re-parsing a month of transcripts.
|
|
13
|
+
*
|
|
14
|
+
* Pipeline position (per design discussion): frugon/this scan is NOT a
|
|
15
|
+
* real-time router — it is a session-boundary calibrator that feeds the
|
|
16
|
+
* existing ratchet promote flow (`harness promote R<N> --project|--global`).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { discoverSessionFiles } from './parser.js';
|
|
23
|
+
import { collectSessionRecords } from './frugon-export.js';
|
|
24
|
+
|
|
25
|
+
// Episode is "easy" when the whole user request finished within these bounds.
|
|
26
|
+
// Calibrated on real data (2026-07): 27% of episodes, 3-6% of tokens.
|
|
27
|
+
export const EASY_MAX_CALLS = 6;
|
|
28
|
+
export const EASY_MAX_OUT_TOKENS = 1500;
|
|
29
|
+
// A pattern must recur this often before we nag about it.
|
|
30
|
+
export const MIN_RECURRENCE = 3;
|
|
31
|
+
// Cache is fresh for a day — the SessionStart hook never rescans inline.
|
|
32
|
+
export const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
// Category → recommended haiku subagent. First match wins; order matters
|
|
35
|
+
// (translate before read: "번역해줘" also matches the read keywords).
|
|
36
|
+
const CATEGORIES = [
|
|
37
|
+
{
|
|
38
|
+
id: 'translate',
|
|
39
|
+
label: '배치 번역·정형 텍스트 변환',
|
|
40
|
+
agent: 'haiku-translate',
|
|
41
|
+
re: /번역|translate|변환해|표로 정리|포맷팅/i,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'explore',
|
|
45
|
+
label: '탐색·조회 (파일/값 찾기)',
|
|
46
|
+
agent: 'haiku-explore',
|
|
47
|
+
re: /grep|검색|찾아|search|find|어디|위치|목록|살펴/i,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'read',
|
|
51
|
+
label: '읽기·요약·설명',
|
|
52
|
+
agent: 'haiku-explore',
|
|
53
|
+
re: /읽어|요약|설명|정리해|summar|explain|보여줘|알려줘|뭐야|what/i,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'run',
|
|
57
|
+
label: '명령 실행 (빌드·테스트·git)',
|
|
58
|
+
agent: 'haiku-runner',
|
|
59
|
+
re: /git |commit|push|실행|돌려|run |build|빌드|테스트|npm |pip|설치/i,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'check',
|
|
63
|
+
label: '상태 확인·검증',
|
|
64
|
+
agent: 'haiku-explore',
|
|
65
|
+
re: /확인|맞아\?|되나|됐나|괜찮|체크|check|verify|status|점검/i,
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// Episodes that are not user-delegable requests: bare continuations, injected
|
|
70
|
+
// notifications, image pastes. These are easy but there is nothing to route.
|
|
71
|
+
const SKIP_RE = /^(계속|이어서|continue|다음|proceed|진행|응|네|넵|ok|okay|yes|ㄱ+|고고)\b/i;
|
|
72
|
+
const SKIP_PREFIX = ['<task-notification', '<system', '[Image:', '<local-command'];
|
|
73
|
+
|
|
74
|
+
function stateDir() {
|
|
75
|
+
if (process.platform === 'win32') {
|
|
76
|
+
return join(process.env.APPDATA || homedir(), 'claude-token-saver');
|
|
77
|
+
}
|
|
78
|
+
if (process.platform === 'darwin') {
|
|
79
|
+
return join(homedir(), 'Library', 'Application Support', 'claude-token-saver');
|
|
80
|
+
}
|
|
81
|
+
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
82
|
+
return join(xdg, 'claude-token-saver');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function routeScanCachePath() {
|
|
86
|
+
return join(stateDir(), 'route-scan.json');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Munge an absolute path the way Claude Code names project dirs. */
|
|
90
|
+
export function mungeProjectPath(p) {
|
|
91
|
+
return String(p).replace(/[^a-zA-Z0-9-]/g, '-');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function categorize(text) {
|
|
95
|
+
for (const c of CATEGORIES) if (c.re.test(text)) return c;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isSkippable(text) {
|
|
100
|
+
if (!text) return true;
|
|
101
|
+
if (SKIP_RE.test(text.trim())) return true;
|
|
102
|
+
return SKIP_PREFIX.some((p) => text.startsWith(p));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Group a session's records into episodes (consecutive same trigger prompt). */
|
|
106
|
+
function toEpisodes(records) {
|
|
107
|
+
const episodes = [];
|
|
108
|
+
let cur = null;
|
|
109
|
+
for (const r of records) {
|
|
110
|
+
const text = (r.userText || '').trim();
|
|
111
|
+
if (!cur || cur.text !== text) {
|
|
112
|
+
cur = { text, calls: 0, out: 0, models: new Set(), cwd: '' };
|
|
113
|
+
episodes.push(cur);
|
|
114
|
+
}
|
|
115
|
+
cur.calls += 1;
|
|
116
|
+
cur.out += r.completion_tokens;
|
|
117
|
+
cur.models.add(r.model);
|
|
118
|
+
if (!cur.cwd && r.cwd) cur.cwd = r.cwd;
|
|
119
|
+
}
|
|
120
|
+
return episodes;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isExpensiveModel(model) {
|
|
124
|
+
return !/haiku/i.test(model);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Scan transcripts and build delegation candidates.
|
|
129
|
+
* Returns the cache object (also written to disk).
|
|
130
|
+
*/
|
|
131
|
+
export async function runRouteScan({ days = 14 } = {}) {
|
|
132
|
+
const files = await discoverSessionFiles({ days });
|
|
133
|
+
const groups = new Map(); // "category|project" → aggregate
|
|
134
|
+
let totalEpisodes = 0;
|
|
135
|
+
let easyEpisodes = 0;
|
|
136
|
+
|
|
137
|
+
for (const f of files) {
|
|
138
|
+
let records;
|
|
139
|
+
try {
|
|
140
|
+
// Raw counts are irrelevant here (we classify by output size), and
|
|
141
|
+
// content is required for categorization.
|
|
142
|
+
records = await collectSessionRecords(f.path, { cacheWeighted: false, includeContent: true });
|
|
143
|
+
} catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
for (const ep of toEpisodes(records)) {
|
|
147
|
+
if (!ep.text) continue;
|
|
148
|
+
totalEpisodes += 1;
|
|
149
|
+
const easy = ep.calls <= EASY_MAX_CALLS && ep.out <= EASY_MAX_OUT_TOKENS;
|
|
150
|
+
if (!easy) continue;
|
|
151
|
+
easyEpisodes += 1;
|
|
152
|
+
if (isSkippable(ep.text)) continue;
|
|
153
|
+
if (![...ep.models].some(isExpensiveModel)) continue; // already cheap
|
|
154
|
+
const cat = categorize(ep.text);
|
|
155
|
+
if (!cat) continue;
|
|
156
|
+
const key = `${cat.id}|${f.projectDir}`;
|
|
157
|
+
const g = groups.get(key) || {
|
|
158
|
+
category: cat.id,
|
|
159
|
+
label: cat.label,
|
|
160
|
+
agent: cat.agent,
|
|
161
|
+
project: f.projectDir,
|
|
162
|
+
projectPath: '',
|
|
163
|
+
count: 0,
|
|
164
|
+
models: new Set(),
|
|
165
|
+
example: '',
|
|
166
|
+
};
|
|
167
|
+
g.count += 1;
|
|
168
|
+
for (const m of ep.models) g.models.add(m);
|
|
169
|
+
if (!g.projectPath && ep.cwd) g.projectPath = ep.cwd;
|
|
170
|
+
if (!g.example || (ep.text.length < g.example.length && ep.text.length > 10)) {
|
|
171
|
+
g.example = ep.text.slice(0, 80).replace(/\s+/g, ' ');
|
|
172
|
+
}
|
|
173
|
+
groups.set(key, g);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Keep prior dismissed/promoted signatures across rescans.
|
|
178
|
+
const prev = readRouteScan();
|
|
179
|
+
const resolved = new Set(prev?.resolved || []);
|
|
180
|
+
|
|
181
|
+
const candidates = [...groups.values()]
|
|
182
|
+
.filter((g) => g.count >= MIN_RECURRENCE)
|
|
183
|
+
.sort((a, b) => b.count - a.count)
|
|
184
|
+
.slice(0, 5)
|
|
185
|
+
.map((g, i) => ({
|
|
186
|
+
id: i + 1,
|
|
187
|
+
signature: `${g.category}|${g.project}`,
|
|
188
|
+
category: g.category,
|
|
189
|
+
label: g.label,
|
|
190
|
+
agent: g.agent,
|
|
191
|
+
project: g.project,
|
|
192
|
+
// Real session cwd for the project (munged `project` is lossy) — lets
|
|
193
|
+
// `harness promote R<N> --project` write the rule into the project the
|
|
194
|
+
// pattern was detected in, not whatever directory the CLI runs from.
|
|
195
|
+
projectPath: g.projectPath || null,
|
|
196
|
+
count: g.count,
|
|
197
|
+
models: [...g.models],
|
|
198
|
+
example: g.example,
|
|
199
|
+
// Concentrated in one project dir → project rule; the scan groups by
|
|
200
|
+
// project already, so scope suggestion is per-candidate 'project' unless
|
|
201
|
+
// the same category recurs across 2+ projects (then 'global').
|
|
202
|
+
suggestedScope: 'project',
|
|
203
|
+
rule: `"${g.label}" 유형의 단순 요청(예: "${g.example}")은 ${g.agent}(haiku) 서브에이전트로 위임한다`,
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
// Same category appearing in 2+ projects → suggest global for each.
|
|
207
|
+
const catProjects = new Map();
|
|
208
|
+
for (const c of candidates) {
|
|
209
|
+
catProjects.set(c.category, (catProjects.get(c.category) || 0) + 1);
|
|
210
|
+
}
|
|
211
|
+
for (const c of candidates) {
|
|
212
|
+
if ((catProjects.get(c.category) || 0) >= 2) c.suggestedScope = 'global';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const cache = {
|
|
216
|
+
scannedAt: new Date().toISOString(),
|
|
217
|
+
days,
|
|
218
|
+
totalEpisodes,
|
|
219
|
+
easyEpisodes,
|
|
220
|
+
candidates,
|
|
221
|
+
resolved: [...resolved],
|
|
222
|
+
};
|
|
223
|
+
try {
|
|
224
|
+
const dir = stateDir();
|
|
225
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
226
|
+
writeFileSync(routeScanCachePath(), JSON.stringify(cache, null, 2) + '\n');
|
|
227
|
+
} catch {
|
|
228
|
+
// best-effort — scan results are still returned
|
|
229
|
+
}
|
|
230
|
+
return cache;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Read the cached scan (null when absent/corrupt). */
|
|
234
|
+
export function readRouteScan() {
|
|
235
|
+
try {
|
|
236
|
+
return JSON.parse(readFileSync(routeScanCachePath(), 'utf8'));
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function isCacheFresh(cache) {
|
|
243
|
+
if (!cache?.scannedAt) return false;
|
|
244
|
+
const ts = Date.parse(cache.scannedAt);
|
|
245
|
+
return Number.isFinite(ts) && Date.now() - ts < CACHE_TTL_MS;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Candidates not yet promoted/dismissed. */
|
|
249
|
+
export function openCandidates(cache) {
|
|
250
|
+
if (!cache?.candidates) return [];
|
|
251
|
+
const resolved = new Set(cache.resolved || []);
|
|
252
|
+
return cache.candidates.filter((c) => !resolved.has(c.signature));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Mark a candidate resolved (promoted or dismissed) so the chip stops and
|
|
257
|
+
* rescans don't resurface it. Returns the candidate or null.
|
|
258
|
+
*/
|
|
259
|
+
export function resolveCandidate(id) {
|
|
260
|
+
const cache = readRouteScan();
|
|
261
|
+
if (!cache) return null;
|
|
262
|
+
const cand = (cache.candidates || []).find((c) => c.id === id);
|
|
263
|
+
if (!cand) return null;
|
|
264
|
+
cache.resolved = [...new Set([...(cache.resolved || []), cand.signature])];
|
|
265
|
+
try {
|
|
266
|
+
writeFileSync(routeScanCachePath(), JSON.stringify(cache, null, 2) + '\n');
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
return cand;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Statusline helper — cheapest possible check (one small JSON read).
|
|
275
|
+
* Returns `route? #N` for the top open candidate relevant to this project
|
|
276
|
+
* (its own project dir, or a global-scoped suggestion), else null.
|
|
277
|
+
*/
|
|
278
|
+
export function routeWarningForStatusline(projectRoot) {
|
|
279
|
+
const cache = readRouteScan();
|
|
280
|
+
if (!cache) return null;
|
|
281
|
+
const open = openCandidates(cache);
|
|
282
|
+
if (open.length === 0) return null;
|
|
283
|
+
const munged = mungeProjectPath(projectRoot || '');
|
|
284
|
+
const hit = open.find((c) => c.project === munged || c.suggestedScope === 'global');
|
|
285
|
+
return hit ? `route? R${hit.id}` : null;
|
|
286
|
+
}
|