claude-token-saver 3.39.0 → 3.40.1

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.ko.md CHANGED
@@ -1,17 +1,30 @@
1
- [English](./README.md) · **한국어**
1
+ <div align="center">
2
2
 
3
- [![npm](https://img.shields.io/npm/v/claude-token-saver.svg)](https://www.npmjs.com/package/claude-token-saver)
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rootstudioyaml/sprag/main/site/assets/logo/sprag-lockup.svg">
5
+ <img alt="Sprag" src="https://raw.githubusercontent.com/rootstudioyaml/sprag/main/site/assets/logo/sprag-lockup-light.svg" width="220">
6
+ </picture>
4
7
 
5
- 🌐 **[프로젝트 소개 페이지](https://rootstudioyaml.github.io/claude-token-saver/)**
8
+ **AI 코딩 에이전트용 품질 래칫. 같은 실수는 두 번 없습니다.**
6
9
 
7
- # claude-token-saver
10
+ [![npm](https://img.shields.io/npm/v/sprag-cli.svg?label=sprag-cli)](https://www.npmjs.com/package/sprag-cli)
11
+ [![downloads](https://img.shields.io/npm/dm/claude-token-saver.svg)](https://www.npmjs.com/package/claude-token-saver)
12
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
13
+
14
+ [sprag.io](https://sprag.io) · [벤치마크](./docs/BENCHMARK.md) · [English](./README.md)
15
+
16
+ </div>
17
+
18
+ ---
19
+
20
+ # Sprag (claude-token-saver)
8
21
 
9
22
  **아낀 돈을 두 줄로 보여 줍니다.** 비싼 모델이 반복하던 쉬운 작업을 싼 모델로 내려보내고, 모델이 읽지 못하는 문서를 Markdown 으로 바꿉니다. 두 절감액 모두 추정이 아니라 원장 기록입니다. 의존성 0, 설치 한 줄.
10
23
 
11
24
  ![statusline 예시. 첫 줄은 라우팅 절감액, 둘째 줄은 문서 변환 절감액, 셋째 줄은 진단 칩입니다](./docs/statusline.png)
12
25
 
13
26
  ```bash
14
- npm i -g claude-token-saver
27
+ npm i -g sprag-cli # or: npm i -g claude-token-saver (same package, old name)
15
28
  ```
16
29
 
17
30
  숫자 네 개가 이 도구의 전부입니다.
@@ -395,7 +408,7 @@ Claude Code의 output style로도 같은 일을 할 수 있지만, output style
395
408
 
396
409
  **이전** (지침 꺼짐)
397
410
 
398
- > 렌더 실패 원인 좁히기 3단계: 먼저 `hn:still --check`로 스틸이 명세보다 최신인지(FRESH/STALE) 확인 STALE이면 재생성 안 해서 옛 자산으로 렌더 시도한 게 원인. 다음 caption-blocks가 최신 나레이션과 단어열 일치하는지 직접 대조 TTS 재생성 후 blocks 재생성 빠지면 자막·음성 어긋나 렌더 깨짐.
411
+ > 렌더 실패 원인 좁히기 3단계: 먼저 `hn:still --check`로 스틸이 명세보다 최신인지(FRESH/STALE) 확인합니다. STALE이면 재생성 안 해서 옛 자산으로 렌더 시도한 게 원인. 다음 caption-blocks가 최신 나레이션과 단어열 일치하는지 직접 대조합니다. TTS 재생성 후 blocks 재생성 빠지면 자막·음성 어긋나 렌더 깨짐.
399
412
 
400
413
  **이후** (지침 켜짐)
401
414
 
@@ -413,7 +426,7 @@ Claude Code의 output style로도 같은 일을 할 수 있지만, output style
413
426
 
414
427
  세 층이 더해집니다.
415
428
 
416
- - **번역투**: 이중 피동, 일본어에서 온 굳은 표현, '~에 있어서', 영어 have를 직역한 '가지다' 남용. 기계가 판정할 수 있는 것은 쓰기 시점 검사(아래)에도 함께 들어갑니다.
429
+ - **번역투**: 이중 피동, 일본어에서 온 굳은 표현, `~에 있어서`, 영어 have를 직역한 '가지다' 남용. 기계가 판정할 수 있는 것은 쓰기 시점 검사(아래)에도 함께 들어갑니다.
417
430
  - **상투 패턴**: 자동으로 붙는 수식어("다양한", "핵심적인"), 표지판 문장, 수사적 질문 뒤 즉답, 근거 없는 긍정 마무리.
418
431
  - **응집성**: 문장이 이어지는 방식이라 정규식으로는 검사하지 못합니다. 이 절의 방향을 정한 연구 결과가 있습니다. 접속어·지시어 같은 표층 연결 장치는 글의 품질과 상관이 없거나 오히려 부적 상관이고, 앞 문장이 내놓은 정보를 다음 문장이 받아 풀어 주는 상술형 연결만 정적 상관을 보였습니다. 그래서 지침은 연결이 어색할 때 접속어를 더하지 말고 정보의 배열(아는 것 먼저, 새 것 나중)을 고치라고 말합니다.
419
432
 
@@ -503,7 +516,7 @@ claude-token-saver doc2md install-converter # 설치를 미리 끝내 두고
503
516
  몇 가지는 의도적으로 하지 않습니다.
504
517
 
505
518
  - **이미지는 변환하지 않습니다.** markitdown 이 빈 결과를 돌려주고, OCR 은 실측에서 리소스 이름을 틀리게 읽었습니다(`c5.xlarge` 를 `c.xlarge` 로). 이름 자체가 내용인 문서에서는 텍스트가 없느니만 못합니다. 모델이 이미지는 직접 읽습니다.
506
- - **변환기가 없으면 조용히 실패하지 않습니다.** 설치 명령을 한 번 안내한 뒤 원본 `Read` 를 그대로 통과시킵니다. 매번 알리면 그것대로 방해가 되고, 아무 말도 하지 않으면 고장을 숨기게 됩니다. `doc2md` 를 인자 없이 실행하면 변환기와 훅 등록 상태를 한 번에 확인할 수 있습니다.
519
+ - **변환기가 없어도 알리지 않고 지나가지 않습니다.** 설치 명령을 한 번 안내한 뒤 원본 `Read` 를 그대로 통과시킵니다. 매번 알리면 그것대로 방해가 되고, 아무 말도 하지 않으면 고장을 숨기게 됩니다. `doc2md` 를 인자 없이 실행하면 변환기와 훅 등록 상태를 한 번에 확인할 수 있습니다.
507
520
  - **변환본은 프로젝트 안에 남기지 않습니다.** 도구의 상태 디렉터리 아래 권한 `0700` 으로 저장하므로 `.gitignore` 에 무엇을 추가할 필요가 없습니다. 파일명이 급여·계약·개인정보 같은 패턴에 걸리면 아예 변환하지 않습니다.
508
521
  - **압축 폭탄은 막습니다.** pptx·xlsx·docx 는 zip 컨테이너입니다. 선언된 크기를 먼저 걸러 내고, 선언은 조작될 수 있으므로 실제 해제 바이트도 상한과 대조합니다.
509
522
  - **엑셀은 행 수로 자릅니다.** 변환 시간은 파일 크기가 아니라 행 수를 따릅니다(실측: PDF 6.3MB 0.9초, 엑셀 5.8MB 47.75초). 5만 행을 넘으면 앞부분만 변환하고, **잘랐다는 사실과 전체 행 수를 안내에 함께 적습니다.**
@@ -610,7 +623,7 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
610
623
 
611
624
  ## 피드백
612
625
 
613
- 버그 제보와 기능 제안은 GitHub 이슈로 받습니다: https://github.com/rootstudioyaml/claude-token-saver/issues
626
+ 버그 제보와 기능 제안은 GitHub 이슈로 받습니다: https://github.com/rootstudioyaml/sprag/issues
614
627
 
615
628
  브라우저나 GitHub 로그인이 어려운 환경(사내망, 세션 도중)이라면 터미널에서 바로 제출할 수 있고, Claude에게 대신 제출해 달라고 요청해도 됩니다.
616
629
 
package/README.md CHANGED
@@ -1,17 +1,30 @@
1
- **English** · [한국어](./README.ko.md)
1
+ <div align="center">
2
2
 
3
- [![npm](https://img.shields.io/npm/v/claude-token-saver.svg)](https://www.npmjs.com/package/claude-token-saver)
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rootstudioyaml/sprag/main/site/assets/logo/sprag-lockup.svg">
5
+ <img alt="Sprag" src="https://raw.githubusercontent.com/rootstudioyaml/sprag/main/site/assets/logo/sprag-lockup-light.svg" width="220">
6
+ </picture>
4
7
 
5
- 🌐 **[Project page](https://rootstudioyaml.github.io/claude-token-saver/)**
8
+ **A quality ratchet for AI coding agents. Mistakes never repeat.**
6
9
 
7
- # claude-token-saver
10
+ [![npm](https://img.shields.io/npm/v/sprag-cli.svg?label=sprag-cli)](https://www.npmjs.com/package/sprag-cli)
11
+ [![downloads](https://img.shields.io/npm/dm/claude-token-saver.svg)](https://www.npmjs.com/package/claude-token-saver)
12
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
13
+
14
+ [sprag.io](https://sprag.io) · [Benchmark](./docs/BENCHMARK.md) · [한국어](./README.ko.md)
15
+
16
+ </div>
17
+
18
+ ---
19
+
20
+ # Sprag (claude-token-saver)
8
21
 
9
22
  **Shows what it saved, on two lines.** It moves the easy work your expensive model keeps repeating onto cheaper ones, and turns documents the model cannot read into Markdown. Both figures are ledger entries rather than estimates, and whichever saved more takes the top line. Zero dependencies, one-line install.
10
23
 
11
24
  ![statusline example — routing savings on row 1, document conversion savings on row 2, diagnostics on row 3](./docs/statusline.png)
12
25
 
13
26
  ```bash
14
- npm i -g claude-token-saver
27
+ npm i -g sprag-cli # or: npm i -g claude-token-saver (same package, old name)
15
28
  ```
16
29
 
17
30
  Four numbers are the whole pitch.
@@ -731,7 +744,7 @@ The full history moved to [CHANGELOG.md](./CHANGELOG.md) (Korean; version headin
731
744
 
732
745
  ## Feedback
733
746
 
734
- Found a bug, or want a feature? Open an issue: https://github.com/rootstudioyaml/claude-token-saver/issues
747
+ Found a bug, or want a feature? Open an issue: https://github.com/rootstudioyaml/sprag/issues
735
748
 
736
749
  No browser or GitHub login handy (corporate network, mid-session)? Submit straight from the terminal — or ask Claude to do it for you:
737
750
 
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.39.0",
3
+ "version": "3.40.1",
4
4
  "description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet — post-hoc session analysis, no realtime router, no extra LLM calls.",
5
5
  "type": "module",
6
6
  "bin": {
7
+ "sprag": "bin/cli.js",
7
8
  "claude-token-saver": "bin/cli.js"
8
9
  },
9
10
  "scripts": {
@@ -48,11 +49,11 @@
48
49
  "license": "MIT",
49
50
  "repository": {
50
51
  "type": "git",
51
- "url": "git+https://github.com/rootstudioyaml/claude-token-saver.git"
52
+ "url": "git+https://github.com/rootstudioyaml/sprag.git"
52
53
  },
53
- "homepage": "https://github.com/rootstudioyaml/claude-token-saver#readme",
54
+ "homepage": "https://sprag.io",
54
55
  "bugs": {
55
- "url": "https://github.com/rootstudioyaml/claude-token-saver/issues"
56
+ "url": "https://github.com/rootstudioyaml/sprag/issues"
56
57
  },
57
58
  "author": "DeepPulse (https://www.youtube.com/@DeepPulseKR)"
58
59
  }
@@ -86,6 +86,7 @@ export async function run({ args, hasFlag }) {
86
86
  console.error(`${file}: ${e.message}`);
87
87
  continue;
88
88
  }
89
+ if (lint.isHtmlFile(file)) text = lint.stripHtml(text);
89
90
  const findings = lint.lintKoreanText(text, { code: !lint.isProseFile(file) });
90
91
  total += findings.length;
91
92
  if (findings.length) console.log(lint.formatFindings(file, findings));
@@ -35,12 +35,21 @@
35
35
  // Documents. Under the `prose` scope these are the only files checked.
36
36
  const PROSE_EXTENSIONS = new Set(['.md', '.mdx', '.markdown', '.txt', '.rst', '.adoc']);
37
37
 
38
+ // HTML is a document a reader receives, so it is checked under BOTH scopes,
39
+ // like prose — but through `stripHtml` first, so tags, comments, and CSS never
40
+ // trip the rules. Script bodies are kept: on a static page the Korean UI copy
41
+ // (i18n dictionaries, template strings) lives exactly there, and that gap is
42
+ // how an em dash shipped to a real landing page unchecked (2026-09-13).
43
+ const HTML_EXTENSIONS = new Set(['.html', '.htm', '.xhtml', '.vue', '.svelte']);
44
+
38
45
  // Only two kinds of path are skipped: installed dependencies and VCS
39
46
  // internals, neither of which anyone in this session wrote. Build output is
40
47
  // deliberately NOT on this list. Generated artifacts are the files a reader
41
48
  // actually receives, so exempting `dist/` or `build/` would exempt the very
42
49
  // documents the check exists for.
43
50
  const SKIP_PATH = /(^|[\\/])(node_modules|\.git|.*\.min\.[a-z]+|.*-lock\.json|.*\.lock)([\\/]|$)/i;
51
+ // This module and the hook copy that embeds it quote every banned form.
52
+ const SELF_PATH = /(^|[\\/])(korean-lint\.cjs|cache-monitor-hook\.cjs)$/i;
44
53
  const BINARY_EXTENSIONS = new Set([
45
54
  '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.pdf', '.zip', '.gz', '.tar',
46
55
  '.mp3', '.mp4', '.wav', '.mov', '.woff', '.woff2', '.ttf', '.otf', '.map', '.bin',
@@ -71,6 +80,18 @@ const METAPHOR_LEXICON = [
71
80
  { re: /벽에 부딪|길을 열|문을 열/, fix: '무엇이 막히고 무엇이 가능해지는지 그대로 적습니다' },
72
81
  { re: /갈림길|갈리는 지점/, fix: "'결정이 나뉘는 조건'으로 바꿉니다" },
73
82
  { re: /깨어나|잠들어 있/, fix: "'동작을 시작하다'·'실행되지 않고 있다'로 바꿉니다" },
83
+ // Added 2026-09-13. "fails loudly / fails silently" reads as idiom in English
84
+ // but lands as figurative vocabulary in Korean: a failure has no volume.
85
+ // 0 hits across 400 Korean files in yaml-sns-agent, so the confirm-request is
86
+ // not noise.
87
+ {
88
+ re: /(?:시끄럽게|시끄러운|조용히|조용한)\s*[가-힣]*(?:실패|틀리|드러나|어긋)|(?:실패|오류|버그)[가은는이]?\s*(?:시끄럽|시끄러운|조용하|조용한)/,
89
+ fix: "무엇으로 실패를 확인하는지 적습니다. '종료 코드로 바로 드러나는가'·'검증 없이 지나가는가'",
90
+ },
91
+ {
92
+ re: /안전망/,
93
+ fix: "'검증 수단'·'확인 장치'처럼 무엇이 실패를 잡아내는지 그대로 적습니다",
94
+ },
74
95
  ];
75
96
 
76
97
  // Guidance 3.2/3.3: literal renderings of English noun phrases.
@@ -111,9 +132,13 @@ const NOMINAL_ENDING = /(?:음|함|됨|임|점|론|양|성|화)\.$/;
111
132
  function isLintTarget(filePath, scope = 'all') {
112
133
  if (!filePath) return false;
113
134
  if (SKIP_PATH.test(filePath)) return false;
135
+ // The rule table has to spell every banned form out, so this file always
136
+ // matches its own lexicon. Checking it reports the rules, not a breach.
137
+ if (SELF_PATH.test(filePath)) return false;
114
138
  const dot = filePath.lastIndexOf('.');
115
139
  const ext = dot === -1 ? '' : filePath.slice(dot).toLowerCase();
116
140
  if (PROSE_EXTENSIONS.has(ext)) return true;
141
+ if (HTML_EXTENSIONS.has(ext)) return true;
117
142
  if (scope !== 'all') return false;
118
143
  if (!ext) return false;
119
144
  return !BINARY_EXTENSIONS.has(ext);
@@ -124,6 +149,26 @@ function isProseFile(filePath) {
124
149
  return isLintTarget(filePath, 'prose');
125
150
  }
126
151
 
152
+ function isHtmlFile(filePath) {
153
+ const dot = String(filePath).lastIndexOf('.');
154
+ const ext = dot === -1 ? '' : String(filePath).slice(dot).toLowerCase();
155
+ return HTML_EXTENSIONS.has(ext);
156
+ }
157
+
158
+ /**
159
+ * Blank out the parts of an HTML document no reader sees — comments, CSS, and
160
+ * the tags themselves — while keeping every newline, so finding line numbers
161
+ * still point into the original file. Script bodies stay: that is where the
162
+ * page's Korean copy lives on a static site.
163
+ */
164
+ function stripHtml(text) {
165
+ const blank = (m) => m.replace(/[^\n]/g, ' ');
166
+ return String(text)
167
+ .replace(/<!--[\s\S]*?-->/g, blank)
168
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, blank)
169
+ .replace(/<[^>]+>/g, blank);
170
+ }
171
+
127
172
  /** Drop fenced code blocks and inline code so snippets never trip the rules. */
128
173
  function stripCode(text) {
129
174
  const out = [];
@@ -226,9 +271,12 @@ function lintToolUse(context, { scope = 'all' } = {}) {
226
271
  const filePath = toolInput && typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
227
272
  if (!isLintTarget(filePath, scope)) return null;
228
273
 
229
- const text = writtenTextOf(toolName, toolInput);
274
+ let text = writtenTextOf(toolName, toolInput);
230
275
  if (!text || !hasKorean(text)) return null;
231
276
 
277
+ // Edits arrive as fragments, so tag-stripping only applies when the payload
278
+ // is a whole document; a fragment is still linted, just without stripping.
279
+ if (isHtmlFile(filePath) && toolName === 'Write') text = stripHtml(text);
232
280
  const findings = lintKoreanText(text, { code: !isProseFile(filePath) });
233
281
  if (findings.length === 0) return null;
234
282
  return { filePath, findings };
@@ -246,6 +294,8 @@ module.exports = {
246
294
  METAPHOR_LEXICON,
247
295
  isLintTarget,
248
296
  isProseFile,
297
+ isHtmlFile,
298
+ stripHtml,
249
299
  lintKoreanText,
250
300
  writtenTextOf,
251
301
  lintToolUse,