sprag-cli 3.40.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/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- package/src/window-labels.js +64 -0
package/src/brief.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brief — UserPromptSubmit hook: per-session, change-triggered briefing.
|
|
3
|
+
*
|
|
4
|
+
* The statusline can only show chips (`ctx 82%`, `route? R1`, `rule-health`),
|
|
5
|
+
* and the model cannot see the statusline at all — so a mid-session state
|
|
6
|
+
* change is invisible to the conversation unless a hook injects it. This
|
|
7
|
+
* module runs on every prompt submit, compares the CURRENT session's state
|
|
8
|
+
* against what was already briefed for that session, and emits a short
|
|
9
|
+
* briefing instruction only when something NEW crossed a threshold. No
|
|
10
|
+
* change → completely silent (zero context cost).
|
|
11
|
+
*
|
|
12
|
+
* Per-session by design (user requirement): context size is a property of
|
|
13
|
+
* one session's transcript, so both the measurement (from this session's
|
|
14
|
+
* transcript_path) and the "already briefed" markers are keyed by session_id.
|
|
15
|
+
*
|
|
16
|
+
* State: <stateDir>/brief-state.json
|
|
17
|
+
* { sessions: { [session_id]: { ts, ctxTier, briefed: [signature] } } }
|
|
18
|
+
* Sessions untouched for 7 days are pruned on every write.
|
|
19
|
+
*
|
|
20
|
+
* Division of labor with the SessionStart hook: SessionStart prints the
|
|
21
|
+
* session-start snapshot and records the signatures it actually briefed via
|
|
22
|
+
* seedSessionBriefed(); this hook emits anything NOT in that record. A
|
|
23
|
+
* candidate that lands after the SessionStart read (e.g. the detached rescan
|
|
24
|
+
* finishing between session start and the first prompt) is therefore briefed
|
|
25
|
+
* on the next prompt instead of being lost for the whole session.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { userDataDir } from './paths.js';
|
|
31
|
+
import { resolveModelId, isOneMillionModel, effectiveWindow } from './compact-window.js';
|
|
32
|
+
|
|
33
|
+
// Context tiers as a fraction of the session's context window. Tier 1 warns
|
|
34
|
+
// (compaction/cost territory ahead), tier 2 urges wrapping up. A session only
|
|
35
|
+
// ever hears about each tier once, and only on upward crossings.
|
|
36
|
+
export const CTX_TIERS = [
|
|
37
|
+
{ tier: 1, pct: 0.8 },
|
|
38
|
+
{ tier: 2, pct: 0.95 },
|
|
39
|
+
];
|
|
40
|
+
// Requests above this input size can only exist on a 1M window. Used as a
|
|
41
|
+
// fallback signal only — a 1M session that has not yet grown past 250k is
|
|
42
|
+
// still a 1M session, so the configured model decides first (see sessionCtx).
|
|
43
|
+
const WINDOW_1M_MIN_INPUT = 250_000;
|
|
44
|
+
const PRUNE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
45
|
+
const TAIL_BYTES = 256 * 1024;
|
|
46
|
+
|
|
47
|
+
// See the note in route-scan.js — paths.js is the only place that resolves
|
|
48
|
+
// this, so an XDG_CONFIG_HOME override moves every state file together.
|
|
49
|
+
const stateDir = userDataDir;
|
|
50
|
+
|
|
51
|
+
export function briefStatePath() {
|
|
52
|
+
return join(stateDir(), 'brief-state.json');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function loadState() {
|
|
56
|
+
try {
|
|
57
|
+
const s = JSON.parse(readFileSync(briefStatePath(), 'utf8'));
|
|
58
|
+
return s && typeof s.sessions === 'object' ? s : { sessions: {} };
|
|
59
|
+
} catch {
|
|
60
|
+
return { sessions: {} };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function saveState(state, now) {
|
|
65
|
+
for (const [id, s] of Object.entries(state.sessions)) {
|
|
66
|
+
if (!s?.ts || now - s.ts > PRUNE_MS) delete state.sessions[id];
|
|
67
|
+
}
|
|
68
|
+
const dir = stateDir();
|
|
69
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
70
|
+
writeFileSync(briefStatePath(), JSON.stringify(state) + '\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The window a tier crossing should be measured against.
|
|
75
|
+
*
|
|
76
|
+
* Two corrections over "infer from the biggest request seen":
|
|
77
|
+
* - The configured model decides the ceiling. A 1M session that has not yet
|
|
78
|
+
* grown past 250k is still a 1M session; judging it against 200k fired the
|
|
79
|
+
* 80% warning at 160k, less than a fifth of the real window.
|
|
80
|
+
* - `autoCompactWindow` lowers that ceiling. Once compaction is pinned at
|
|
81
|
+
* 400k, 400k — not 1M — is where the session actually turns over, so that
|
|
82
|
+
* is the number a "you are at 80%" warning has to mean.
|
|
83
|
+
*
|
|
84
|
+
* `observedMax` stays as a floor: it proves a 1M window even when the model id
|
|
85
|
+
* is unreadable (env override, settings we do not resolve).
|
|
86
|
+
*/
|
|
87
|
+
export function ctxWindowFor(observedMax = 0, root = process.cwd()) {
|
|
88
|
+
let modelWindow = observedMax > WINDOW_1M_MIN_INPUT ? 1_000_000 : 200_000;
|
|
89
|
+
let window = modelWindow;
|
|
90
|
+
let compactCapped = false;
|
|
91
|
+
try {
|
|
92
|
+
const { model } = resolveModelId(root);
|
|
93
|
+
if (isOneMillionModel(model)) modelWindow = window = 1_000_000;
|
|
94
|
+
const cap = effectiveWindow(root).value;
|
|
95
|
+
if (cap !== null && cap < window) {
|
|
96
|
+
window = cap;
|
|
97
|
+
compactCapped = true;
|
|
98
|
+
}
|
|
99
|
+
} catch { /* settings unreadable — the observed-size fallback still holds */ }
|
|
100
|
+
return { window, modelWindow, compactCapped };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Last request's input size for THIS session, from the transcript tail.
|
|
105
|
+
* Reads at most TAIL_BYTES — prompt-submit hooks must stay fast.
|
|
106
|
+
*/
|
|
107
|
+
export function sessionCtx(transcriptPath, { root = process.cwd() } = {}) {
|
|
108
|
+
let size;
|
|
109
|
+
try { size = statSync(transcriptPath).size; } catch { return null; }
|
|
110
|
+
const start = Math.max(0, size - TAIL_BYTES);
|
|
111
|
+
const buf = Buffer.alloc(size - start);
|
|
112
|
+
let fd;
|
|
113
|
+
try {
|
|
114
|
+
fd = openSync(transcriptPath, 'r');
|
|
115
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
} finally {
|
|
119
|
+
if (fd !== undefined) try { closeSync(fd); } catch { /* already closed */ }
|
|
120
|
+
}
|
|
121
|
+
const lines = buf.toString('utf8').split('\n');
|
|
122
|
+
let input = null;
|
|
123
|
+
let maxInput = 0;
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
if (!line.includes('"usage"')) continue;
|
|
126
|
+
let e; try { e = JSON.parse(line); } catch { continue; }
|
|
127
|
+
const u = e?.message?.usage;
|
|
128
|
+
if (!u) continue;
|
|
129
|
+
const total = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
130
|
+
if (total > 0) { input = total; maxInput = Math.max(maxInput, total); }
|
|
131
|
+
}
|
|
132
|
+
if (input == null) return null;
|
|
133
|
+
const { window, modelWindow, compactCapped } = ctxWindowFor(maxInput, root);
|
|
134
|
+
return { input, window, modelWindow, compactCapped, pct: input / window };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function ctxTierOf(pct) {
|
|
138
|
+
let t = 0;
|
|
139
|
+
for (const { tier, pct: p } of CTX_TIERS) if (pct >= p) t = tier;
|
|
140
|
+
return t;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const fmtK = (n) => `${Math.round(n / 1000)}k`;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Called by the SessionStart hook after it prints the session-start snapshot:
|
|
147
|
+
* records the signatures it actually briefed so runBrief suppresses exactly
|
|
148
|
+
* those and nothing more. Signature format matches runBrief:
|
|
149
|
+
* `route|<sig>` / `health|<sig>|<scope>`.
|
|
150
|
+
*/
|
|
151
|
+
export function seedSessionBriefed(sessionId, signatures, now = Date.now()) {
|
|
152
|
+
if (!sessionId || !Array.isArray(signatures) || signatures.length === 0) return;
|
|
153
|
+
const state = loadState();
|
|
154
|
+
const s = state.sessions[sessionId] || { ctxTier: 0, briefed: [] };
|
|
155
|
+
s.briefed = [...new Set([...(s.briefed || []), ...signatures])];
|
|
156
|
+
s.ts = now;
|
|
157
|
+
state.sessions[sessionId] = s;
|
|
158
|
+
saveState(state, now);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Compute the briefing for one prompt-submit event. Returns the text to
|
|
163
|
+
* inject, or null when nothing new happened. Mutates + persists state.
|
|
164
|
+
*
|
|
165
|
+
* Route/rule-health signatures already briefed by the SessionStart hook
|
|
166
|
+
* (recorded via seedSessionBriefed) are skipped; every other fresh signature
|
|
167
|
+
* is emitted, including on the session's first event.
|
|
168
|
+
*/
|
|
169
|
+
export async function runBrief({ sessionId, transcriptPath, now = Date.now() }) {
|
|
170
|
+
if (!sessionId) return null;
|
|
171
|
+
const state = loadState();
|
|
172
|
+
const s = state.sessions[sessionId] || { ctxTier: 0, briefed: [] };
|
|
173
|
+
const items = [];
|
|
174
|
+
|
|
175
|
+
// ── context tier crossing (per-session) ──
|
|
176
|
+
const ctx = transcriptPath ? sessionCtx(transcriptPath) : null;
|
|
177
|
+
if (ctx) {
|
|
178
|
+
const tier = ctxTierOf(ctx.pct);
|
|
179
|
+
// The tier is not monotonic: auto-compaction drops the live context back to
|
|
180
|
+
// a fraction of the window, which starts a new fill cycle. Holding the old
|
|
181
|
+
// high-water tier meant a session that compacted at 80% was never warned
|
|
182
|
+
// again — it silently refilled to the cap with no signal at all.
|
|
183
|
+
if (tier < (s.ctxTier || 0)) s.ctxTier = tier;
|
|
184
|
+
if (tier > (s.ctxTier || 0)) {
|
|
185
|
+
// Name both denominators when they differ. Claude Code's own UI counts
|
|
186
|
+
// against the model window, so a bare "400k 창의 80%" reads as wrong to
|
|
187
|
+
// anyone looking at a statusline that says 32% of 1M — same session,
|
|
188
|
+
// two different windows, no way to reconcile them from the text alone.
|
|
189
|
+
const winLabel = ctx.window >= 1_000_000 ? '1M' : fmtK(ctx.window);
|
|
190
|
+
const modelPct = Math.round((ctx.input / ctx.modelWindow) * 100);
|
|
191
|
+
const modelLabel = ctx.modelWindow >= 1_000_000 ? '1M' : fmtK(ctx.modelWindow);
|
|
192
|
+
const capNote = ctx.compactCapped ? `, 화면의 ${modelLabel} 창 기준으로는 ${modelPct}%` : '';
|
|
193
|
+
// With autoCompactWindow set, crossing the threshold means compaction is
|
|
194
|
+
// about to run on its own. Telling the user to start a new session there
|
|
195
|
+
// would be advice for a problem the setting already handles.
|
|
196
|
+
items.push(tier === 2
|
|
197
|
+
? (ctx.compactCapped
|
|
198
|
+
? `이 세션의 컨텍스트가 자동 압축 창(${winLabel})의 95%를 넘었습니다(직전 요청 입력 ${fmtK(ctx.input)}${capNote}). 곧 자동 압축이 돌아 이전 대화가 요약으로 바뀝니다 — 지금 단계를 마무리하고 이어서 할 일은 파일에 적어두면 압축 뒤에도 안전합니다.`
|
|
199
|
+
: `이 세션의 컨텍스트가 ${winLabel} 창의 95%를 넘었습니다(직전 요청 입력 ${fmtK(ctx.input)}). 곧 자동 압축으로 맥락 손실이 생길 수 있으니, 진행 중인 작업을 일단락하고 새 세션을 시작하는 편이 좋습니다.`)
|
|
200
|
+
: (ctx.compactCapped
|
|
201
|
+
? `이 세션의 컨텍스트가 자동 압축 창(${winLabel})의 80%를 넘었습니다(직전 요청 입력 ${fmtK(ctx.input)}${capNote}). 설정해 둔 압축 지점이 가까워졌습니다 — 압축은 알아서 돌아가니 새 세션을 서두를 필요는 없고, 여기까지의 결정과 다음 할 일만 파일에 남겨두면 됩니다.`
|
|
202
|
+
: `이 세션의 컨텍스트가 ${winLabel} 창의 80%를 넘었습니다(직전 요청 입력 ${fmtK(ctx.input)}). 이후 요청은 비용이 커지는 구간입니다 — 작업이 일단락되면 새 세션 시작을 권합니다.`));
|
|
203
|
+
s.ctxTier = tier;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── mid-session route-scan / rule-health changes (global state, briefed
|
|
208
|
+
// at most once per session per signature) ──
|
|
209
|
+
try {
|
|
210
|
+
const rs = await import('./route-scan.js');
|
|
211
|
+
const mr = await import('./model-rules.js');
|
|
212
|
+
const briefed = new Set(s.briefed || []);
|
|
213
|
+
const fresh = [];
|
|
214
|
+
for (const c of rs.openCandidates(rs.readRouteScan())) {
|
|
215
|
+
const sig = `route|${c.signature}`;
|
|
216
|
+
if (briefed.has(sig)) continue;
|
|
217
|
+
briefed.add(sig);
|
|
218
|
+
fresh.push(['route', c]);
|
|
219
|
+
}
|
|
220
|
+
for (const r of mr.loadModelRules().rules) {
|
|
221
|
+
if (r.status !== 'review') continue;
|
|
222
|
+
const sig = `health|${r.signature}|${r.scope}`;
|
|
223
|
+
if (briefed.has(sig)) continue;
|
|
224
|
+
briefed.add(sig);
|
|
225
|
+
fresh.push(['health', r]);
|
|
226
|
+
}
|
|
227
|
+
for (const [kind, x] of fresh) {
|
|
228
|
+
items.push(kind === 'route'
|
|
229
|
+
? `새 위임 후보가 감지되었습니다 — "${x.label}" 유형 ${x.count}회 반복(statusline의 route? R${x.id} 칩). 등록: claude-token-saver harness promote R${x.id} --project|--global (적용 범위는 사용자에게 확인) / 무시: route-scan dismiss ${x.id}`
|
|
230
|
+
: `승인된 위임 룰의 최근 에러율이 기준(20%)을 넘었습니다 — "${x.label}" (${x.healthSource === 'delegated' ? `위임 실행 ${x.delegatedRuns}건 중 에러율 ${Math.round((x.delegatedErrRate || 0) * 100)}%` : `유사 에피소드 기준 에러율 ${Math.round((x.errRate || 0) * 100)}%`}, statusline의 rule-health 칩). 조건 좁히기/제거를 사용자와 상의하세요: claude-token-saver route-scan rules`);
|
|
231
|
+
}
|
|
232
|
+
s.briefed = [...briefed];
|
|
233
|
+
} catch { /* caches unreadable — ctx briefing above still applies */ }
|
|
234
|
+
|
|
235
|
+
// ── auto-compact window misconfigured on a 1M model (once per session) ──
|
|
236
|
+
// Config defect, not a usage trend: on a 1M window compaction only fires
|
|
237
|
+
// past ~800k, so every request until then re-bills a context the session
|
|
238
|
+
// never needed. 200k sessions are exempt (their window is already <= 200k).
|
|
239
|
+
try {
|
|
240
|
+
const cw = await import('./compact-window.js');
|
|
241
|
+
const st = cw.compactWindowStatus({ root: process.cwd() });
|
|
242
|
+
if (!st.ok) {
|
|
243
|
+
const sig = `compact-window|${st.reason}`;
|
|
244
|
+
const briefed = new Set(s.briefed || []);
|
|
245
|
+
if (!briefed.has(sig)) {
|
|
246
|
+
briefed.add(sig);
|
|
247
|
+
s.briefed = [...briefed];
|
|
248
|
+
const now = st.window ? `현재 ${fmtK(st.window)}` : '현재 미설정';
|
|
249
|
+
items.push(`1M 컨텍스트 모델(${st.model})인데 autoCompactWindow가 ${now}입니다 — 자동 압축이 80만 토큰 근처에서야 걸려 그전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 1M은 너무 크니 ${fmtK(st.recommendedMin)}~${fmtK(st.recommendedMax)} 범위를 권장합니다(그 범위 안이면 경고하지 않습니다). 1M 창 자체는 그대로 두고 압축 시점만 앞당깁니다. 등록: claude-token-saver compact-window set --global|--project [--value ${fmtK(st.recommendedMax)}] (기본 ${fmtK(st.recommended)}, 적용 범위는 사용자에게 확인) / 끄기: compact-window off`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} catch { /* settings unreadable — other briefings above still apply */ }
|
|
253
|
+
|
|
254
|
+
s.ts = now;
|
|
255
|
+
state.sessions[sessionId] = s;
|
|
256
|
+
saveState(state, now);
|
|
257
|
+
|
|
258
|
+
if (items.length === 0) return null;
|
|
259
|
+
const lines = [
|
|
260
|
+
'[claude-token-saver 브리핑] 아래 상태 변화를 사용자에게 알려주세요. 진행 중인 답변 흐름을 끊지 말고, 답변 말미에 `※ [claude-token-saver]` 라벨을 달아 각 항목을 1~2줄로 요약해 전달하면 됩니다 (이 브리핑은 항목당 한 번만 주입됩니다):',
|
|
261
|
+
];
|
|
262
|
+
for (const it of items) lines.push(`- ${it}`);
|
|
263
|
+
return lines.join('\n');
|
|
264
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statusline snapshot cache — the rate-limit numbers and model name only flow
|
|
3
|
+
* through stdin from Claude Code's statusline contract, but we want the table
|
|
4
|
+
* view (`claude-token-saver --days N`) to surface the same data. So
|
|
5
|
+
* whenever the statusline path sees them it writes them here, and the table
|
|
6
|
+
* path reads them back if its own stdin was empty.
|
|
7
|
+
*
|
|
8
|
+
* Stale data is worse than missing data — if the saved snapshot is older
|
|
9
|
+
* than `maxAgeMs` the loader returns null and the table view stays quiet.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { userDataDir } from './paths.js';
|
|
15
|
+
|
|
16
|
+
const CACHE_PATH = join(userDataDir(), 'last-caps.json');
|
|
17
|
+
|
|
18
|
+
function ensureDir() {
|
|
19
|
+
const dir = userDataDir();
|
|
20
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Persist whatever subset of statusline state we have right now. A null/empty
|
|
25
|
+
* snapshot is a no-op so callers don't have to guard.
|
|
26
|
+
*
|
|
27
|
+
* @param {{ caps?: object|null, model?: string|null }|null} snapshot
|
|
28
|
+
*/
|
|
29
|
+
export function persistSnapshot(snapshot) {
|
|
30
|
+
if (!snapshot) return;
|
|
31
|
+
const hasCaps = !!snapshot.caps;
|
|
32
|
+
const hasModel = typeof snapshot.model === 'string' && snapshot.model.length > 0;
|
|
33
|
+
if (!hasCaps && !hasModel) return;
|
|
34
|
+
try {
|
|
35
|
+
ensureDir();
|
|
36
|
+
const payload = {
|
|
37
|
+
capturedAt: Date.now(),
|
|
38
|
+
caps: snapshot.caps || null,
|
|
39
|
+
model: snapshot.model || null,
|
|
40
|
+
};
|
|
41
|
+
writeFileSync(CACHE_PATH, JSON.stringify(payload) + '\n');
|
|
42
|
+
} catch {
|
|
43
|
+
// best-effort cache, never blocks the statusline
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* v2.3 wrote `caps` as `{ fiveHour, sevenDay }`; v2.4+ writes `{ windows: [...] }`.
|
|
49
|
+
* Convert on read so a returning user's stale snapshot still feeds the table view
|
|
50
|
+
* until the next statusline refresh overwrites it.
|
|
51
|
+
*/
|
|
52
|
+
function normalizeCaps(caps) {
|
|
53
|
+
if (!caps || typeof caps !== 'object') return null;
|
|
54
|
+
if (Array.isArray(caps.windows)) return caps;
|
|
55
|
+
const windows = [];
|
|
56
|
+
if (caps.fiveHour && typeof caps.fiveHour === 'object') {
|
|
57
|
+
windows.push({ key: 'five_hour', usedPct: caps.fiveHour.usedPct, resetsAt: caps.fiveHour.resetsAt ?? null });
|
|
58
|
+
}
|
|
59
|
+
if (caps.sevenDay && typeof caps.sevenDay === 'object') {
|
|
60
|
+
windows.push({ key: 'seven_day', usedPct: caps.sevenDay.usedPct, resetsAt: caps.sevenDay.resetsAt ?? null });
|
|
61
|
+
}
|
|
62
|
+
return windows.length ? { windows } : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} [opts]
|
|
67
|
+
* @param {number} [opts.maxAgeMs=5*60*1000] - drop snapshots older than this.
|
|
68
|
+
* @returns {{ caps: object|null, model: string|null }|null}
|
|
69
|
+
*/
|
|
70
|
+
export function loadRecentSnapshot({ maxAgeMs = 5 * 60 * 1000 } = {}) {
|
|
71
|
+
try {
|
|
72
|
+
if (!existsSync(CACHE_PATH)) return null;
|
|
73
|
+
const raw = readFileSync(CACHE_PATH, 'utf8');
|
|
74
|
+
const data = JSON.parse(raw);
|
|
75
|
+
if (!data || typeof data.capturedAt !== 'number') return null;
|
|
76
|
+
if (Date.now() - data.capturedAt > maxAgeMs) return null;
|
|
77
|
+
return {
|
|
78
|
+
caps: normalizeCaps(data.caps),
|
|
79
|
+
model: data.model || null,
|
|
80
|
+
};
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/cli-args.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* argv accessors shared by every subcommand.
|
|
3
|
+
*
|
|
4
|
+
* `createArgs(argv)` binds them to one argument list, so a subcommand module
|
|
5
|
+
* takes them as a parameter instead of reaching for a module-level global —
|
|
6
|
+
* which also makes them directly testable.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function createArgs(argv) {
|
|
10
|
+
const args = argv;
|
|
11
|
+
|
|
12
|
+
function getArg(name) {
|
|
13
|
+
const idx = args.indexOf(name);
|
|
14
|
+
if (idx !== -1) return args[idx + 1];
|
|
15
|
+
const prefix = `${name}=`;
|
|
16
|
+
const eq = args.find((a) => a.startsWith(prefix));
|
|
17
|
+
if (eq) return eq.slice(prefix.length);
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hasFlag(name) {
|
|
22
|
+
return args.includes(name);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read a numeric option, rejecting garbage instead of letting NaN flow into
|
|
27
|
+
* the report window. `--days abc` used to render as `NaNd` on the statusline
|
|
28
|
+
* and as a bare "no session data" in the table view — a typo that looked
|
|
29
|
+
* exactly like "you have no logs".
|
|
30
|
+
*
|
|
31
|
+
* @param {string} name flag name, e.g. '--days'
|
|
32
|
+
* @param {object} [opts]
|
|
33
|
+
* @param {number} [opts.dflt] value when the flag is absent
|
|
34
|
+
* @param {number} [opts.min] inclusive lower bound
|
|
35
|
+
* @param {number} [opts.max] inclusive upper bound
|
|
36
|
+
* @throws {Error} on a non-numeric or out-of-range value
|
|
37
|
+
*/
|
|
38
|
+
function numArg(name, { dflt, min, max } = {}) {
|
|
39
|
+
const raw = getArg(name);
|
|
40
|
+
if (raw === undefined) return dflt;
|
|
41
|
+
const n = parseFloat(raw);
|
|
42
|
+
if (!Number.isFinite(n)) {
|
|
43
|
+
throw new Error(`${name} expects a number, got "${raw}"`);
|
|
44
|
+
}
|
|
45
|
+
if (min !== undefined && n < min) throw new Error(`${name} must be >= ${min}, got ${n}`);
|
|
46
|
+
if (max !== undefined && n > max) throw new Error(`${name} must be <= ${max}, got ${n}`);
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { args, getArg, hasFlag, numArg };
|
|
51
|
+
}
|
package/src/cohesion.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cohesion — English sentence-connection guidance injected at session start.
|
|
3
|
+
*
|
|
4
|
+
* The Korean supplement's cohesion section turned out to be language-neutral:
|
|
5
|
+
* given-before-new ordering, one referent per pronoun, subject consistency,
|
|
6
|
+
* bridging instead of leaping, merging choppy sentences. English-only users
|
|
7
|
+
* never run `korean on`, so those rules never reached them. This module ships
|
|
8
|
+
* the same principles as a standalone English block.
|
|
9
|
+
*
|
|
10
|
+
* Off by default for the same reason the Korean guidance is opt-in: a
|
|
11
|
+
* token-saving tool has no business silently billing ~0.5k tokens a session.
|
|
12
|
+
* Enable with `claude-token-saver cohesion on`.
|
|
13
|
+
*
|
|
14
|
+
* When the Korean guidance is enabled, this block is NOT injected even if
|
|
15
|
+
* enabled: the Korean supplement already carries the cohesion rules, and
|
|
16
|
+
* injecting the same principles twice bills them twice.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
20
|
+
import { join, dirname } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
23
|
+
|
|
24
|
+
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
|
|
26
|
+
export const COHESION_PATH = join(packageRoot, 'presets', 'cohesion', 'cohesion-en.md');
|
|
27
|
+
|
|
28
|
+
/** Whether session-start injection is enabled. Off unless the user asked. */
|
|
29
|
+
export function cohesionEnabled(cfg = loadConfig()) {
|
|
30
|
+
return cfg?.cohesion?.enabled === true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function setCohesionEnabled(enabled) {
|
|
34
|
+
const cfg = loadConfig();
|
|
35
|
+
cfg.cohesion = { ...(cfg.cohesion || {}), enabled: !!enabled };
|
|
36
|
+
saveConfig(cfg);
|
|
37
|
+
return cfg.cohesion;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The guidance text with the provenance comment stripped, or null. */
|
|
41
|
+
export function cohesionText() {
|
|
42
|
+
try {
|
|
43
|
+
if (!existsSync(COHESION_PATH)) return null;
|
|
44
|
+
const raw = readFileSync(COHESION_PATH, 'utf8');
|
|
45
|
+
const body = raw.replace(/^<!--[\s\S]*?-->\s*/, '').trim();
|
|
46
|
+
return body || null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Block to inject at session start, or null when disabled, unavailable, or
|
|
54
|
+
* redundant (Korean guidance on — its supplement already carries these rules).
|
|
55
|
+
*/
|
|
56
|
+
export async function cohesionInjection({ cfg = loadConfig() } = {}) {
|
|
57
|
+
if (!cohesionEnabled(cfg)) return null;
|
|
58
|
+
try {
|
|
59
|
+
const { koreanStyleEnabled } = await import('./korean-style.js');
|
|
60
|
+
if (koreanStyleEnabled(cfg)) return null;
|
|
61
|
+
} catch { /* korean module unavailable: inject normally */ }
|
|
62
|
+
const text = cohesionText();
|
|
63
|
+
if (!text) return null;
|
|
64
|
+
return [
|
|
65
|
+
'[claude-token-saver cohesion] Follow this guidance for English prose in this session.',
|
|
66
|
+
'The user enabled it in claude-token-saver.',
|
|
67
|
+
'',
|
|
68
|
+
text,
|
|
69
|
+
].join('\n');
|
|
70
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: route-scan — detect recurring easy work on expensive models
|
|
3
|
+
* and propose model-delegation ratchet rules. Zero token cost, fully local.
|
|
4
|
+
* claude-token-saver route-scan # scan (24h cache) + print candidates
|
|
5
|
+
* claude-token-saver route-scan --refresh # force rescan
|
|
6
|
+
* claude-token-saver route-scan --days 30 # wider lookback
|
|
7
|
+
* claude-token-saver route-scan --hook # SessionStart hook mode (context injection)
|
|
8
|
+
* claude-token-saver route-scan dismiss <N> # mute candidate R<N>
|
|
9
|
+
* Promote a candidate to a ratchet rule (scope is always explicit):
|
|
10
|
+
* claude-token-saver harness promote R<N> --project|--global
|
|
11
|
+
* brief --hook — UserPromptSubmit hook mode: per-session, change-triggered
|
|
12
|
+
* briefing of state the statusline can only chip (ctx tier crossings,
|
|
13
|
+
* mid-session route/rule-health changes). Silent when nothing changed.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readStdinJson } from '../stdin-payload.js';
|
|
17
|
+
import { debug } from '../debug.js';
|
|
18
|
+
|
|
19
|
+
export async function run({ hasFlag }) {
|
|
20
|
+
if (!hasFlag('--hook')) {
|
|
21
|
+
console.error('Usage: claude-token-saver brief --hook (UserPromptSubmit hook mode)');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
const ctx = readStdinJson() || {};
|
|
25
|
+
try {
|
|
26
|
+
const { runBrief } = await import('../brief.js');
|
|
27
|
+
const out = await runBrief({ sessionId: ctx.session_id, transcriptPath: ctx.transcript_path });
|
|
28
|
+
if (out) console.log(out);
|
|
29
|
+
} catch (e) { debug('brief:hook', e); /* briefing is best-effort — never block a prompt */ }
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: cohesion — English sentence-connection guidance for every session.
|
|
3
|
+
* claude-token-saver cohesion on # inject at session start, all projects
|
|
4
|
+
* claude-token-saver cohesion off # stop injecting
|
|
5
|
+
* claude-token-saver cohesion status # current state and cost
|
|
6
|
+
* claude-token-saver cohesion show # print the guidance itself
|
|
7
|
+
*
|
|
8
|
+
* The English sibling of `korean on`, carrying only the language-neutral
|
|
9
|
+
* cohesion rules (given-before-new, one referent per pronoun, subject
|
|
10
|
+
* consistency, bridging, merging choppy sentences). No lint: every clause
|
|
11
|
+
* needs judgement, so nothing here is machine-checkable.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export async function run({ args }) {
|
|
15
|
+
const sub = args[1] || 'status';
|
|
16
|
+
const co = await import('../cohesion.js');
|
|
17
|
+
const { userLanguage } = await import('../config.js');
|
|
18
|
+
const lang = userLanguage();
|
|
19
|
+
|
|
20
|
+
if (sub === 'on' || sub === 'off') {
|
|
21
|
+
co.setCohesionEnabled(sub === 'on');
|
|
22
|
+
if (sub === 'on') {
|
|
23
|
+
console.log(lang === 'ko'
|
|
24
|
+
? '✍️ cohesion on: 다음 세션부터 영어 문장 연결 지침이 주입됩니다 (약 0.5k 토큰/세션, 세션 시작 1회).'
|
|
25
|
+
: '✍️ cohesion on — English cohesion guidance will be injected from the next session (~0.5k tokens per session, once at session start).');
|
|
26
|
+
const { koreanStyleEnabled } = await import('../korean-style.js');
|
|
27
|
+
if (koreanStyleEnabled()) {
|
|
28
|
+
console.log(lang === 'ko'
|
|
29
|
+
? '참고: korean 지침이 켜져 있는 동안에는 같은 원칙이 이미 들어가므로 이 블록은 주입되지 않습니다.'
|
|
30
|
+
: 'Note: while the Korean guidance is on, its supplement already carries these rules, so this block is not injected.');
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
console.log(lang === 'ko' ? 'cohesion off: 더 이상 주입하지 않습니다.' : 'cohesion off — no longer injected.');
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (sub === 'show') {
|
|
39
|
+
const text = co.cohesionText();
|
|
40
|
+
if (!text) {
|
|
41
|
+
console.error('cohesion guidance file missing: ' + co.COHESION_PATH);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
console.log(text);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// status (default)
|
|
49
|
+
const enabled = co.cohesionEnabled();
|
|
50
|
+
const { koreanStyleEnabled } = await import('../korean-style.js');
|
|
51
|
+
const suppressed = enabled && koreanStyleEnabled();
|
|
52
|
+
if (lang === 'ko') {
|
|
53
|
+
console.log(`cohesion: ${enabled ? 'on' : 'off'}${suppressed ? ' (korean 지침이 켜져 있어 주입은 생략됨)' : ''}`);
|
|
54
|
+
console.log('영어 산문의 문장 연결 지침을 세션 시작에 주입합니다. 켜기: claude-token-saver cohesion on');
|
|
55
|
+
} else {
|
|
56
|
+
console.log(`cohesion: ${enabled ? 'on' : 'off'}${suppressed ? ' (suppressed while the Korean guidance is on — it already carries these rules)' : ''}`);
|
|
57
|
+
console.log('Injects English cohesion guidance at session start. Enable with: claude-token-saver cohesion on');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: compact-window — audit / pin Claude Code's `autoCompactWindow`.
|
|
3
|
+
* claude-token-saver compact-window # status
|
|
4
|
+
* claude-token-saver compact-window set --global # pin 500k in ~/.claude/settings.json
|
|
5
|
+
* claude-token-saver compact-window set --project # pin 500k in <root>/.claude/settings.json
|
|
6
|
+
* claude-token-saver compact-window set --global --value 600k
|
|
7
|
+
* claude-token-saver compact-window off | on # toggle the statusline warning
|
|
8
|
+
*
|
|
9
|
+
* Scope is deliberately explicit for `set`: writing a global settings.json is
|
|
10
|
+
* not something to guess at, and the non-TTY hook environment cannot prompt.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export async function run({ args, hasFlag }) {
|
|
14
|
+
const sub = args[1];
|
|
15
|
+
const cw = await import('../compact-window.js');
|
|
16
|
+
const { findProjectRoot } = await import('../harness.js');
|
|
17
|
+
const { loadConfig, saveConfig, userLanguage } = await import('../config.js');
|
|
18
|
+
const lang = userLanguage();
|
|
19
|
+
const root = findProjectRoot();
|
|
20
|
+
const ko = lang === 'ko';
|
|
21
|
+
const fmt = (n) => (n === null || n === undefined ? '-' : `${Math.round(n / 1000)}k`);
|
|
22
|
+
|
|
23
|
+
if (sub === 'off' || sub === 'on') {
|
|
24
|
+
const cfg = loadConfig();
|
|
25
|
+
cfg.compactWindow = cfg.compactWindow || {};
|
|
26
|
+
cfg.compactWindow.enabled = sub === 'on';
|
|
27
|
+
saveConfig(cfg);
|
|
28
|
+
console.log(`Statusline compact-window warning: ${sub}`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (sub === 'set') {
|
|
33
|
+
const scope = hasFlag('--global') ? 'global' : hasFlag('--project') ? 'project' : null;
|
|
34
|
+
if (!scope) {
|
|
35
|
+
console.error(ko
|
|
36
|
+
? '적용 범위를 명시하세요 (사용자에게 먼저 확인): --global (~/.claude/settings.json) 또는 --project (<root>/.claude/settings.json)'
|
|
37
|
+
: 'Scope required: --global (~/.claude/settings.json) or --project (<root>/.claude/settings.json)');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const argv = args.slice(2);
|
|
41
|
+
const i = argv.indexOf('--value');
|
|
42
|
+
const eq = argv.find((a) => a.startsWith('--value='));
|
|
43
|
+
const raw = i !== -1 && argv[i + 1] ? argv[i + 1] : (eq ? eq.slice('--value='.length) : null);
|
|
44
|
+
const r = cw.setAutoCompactWindow({ root, scope, value: raw ?? cw.RECOMMENDED_WINDOW });
|
|
45
|
+
if (!r.ok) { console.error(`❌ ${r.error}`); process.exit(1); }
|
|
46
|
+
console.log(`✅ autoCompactWindow = ${r.value} (${fmt(r.value)}) → ${r.path} [${r.scope}]`);
|
|
47
|
+
if (r.previous !== null && r.previous !== undefined) console.log(` previous: ${r.previous}`);
|
|
48
|
+
if (r.backup) console.log(` backup: ${r.backup}`);
|
|
49
|
+
// The env var beats settings.json, so a stale export silently defeats the
|
|
50
|
+
// write we just made — say so instead of letting the user wonder.
|
|
51
|
+
if (process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW) {
|
|
52
|
+
console.log(ko
|
|
53
|
+
? `\n⚠ 셸에 CLAUDE_CODE_AUTO_COMPACT_WINDOW=${process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW} 가 설정돼 있어 settings.json보다 우선합니다. unset 하세요.`
|
|
54
|
+
: `\n⚠ CLAUDE_CODE_AUTO_COMPACT_WINDOW=${process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW} is exported and takes precedence over settings.json. Unset it.`);
|
|
55
|
+
}
|
|
56
|
+
console.log(ko ? '\n새 세션부터 적용됩니다.' : '\nApplies from the next session.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!sub || sub === 'status' || sub === 'check') {
|
|
61
|
+
const s = cw.compactWindowStatus({ root });
|
|
62
|
+
console.log(`model: ${s.model || '(not set — Claude Code default)'}${s.modelSource ? ` [${s.modelSource}]` : ''}`);
|
|
63
|
+
console.log(`window: ${s.is1m ? '1M context' : '200k context'}`);
|
|
64
|
+
console.log(`autoCompactWindow: ${s.window === null ? '(unset)' : `${s.window} (${fmt(s.window)})`}${s.windowSource ? ` [${s.windowSource}${s.windowPath ? ` → ${s.windowPath}` : ''}]` : ''}`);
|
|
65
|
+
if (s.ok && s.reason === 'not-1m') {
|
|
66
|
+
console.log(ko
|
|
67
|
+
? '\n✅ 200k 컨텍스트라 이 설정은 영향이 없습니다 (워닝 대상 아님).'
|
|
68
|
+
: '\n✅ 200k context — this setting changes nothing here (not warned).');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (s.ok) {
|
|
72
|
+
console.log(ko
|
|
73
|
+
? `\n✅ 압축 창이 권장 범위(${fmt(s.recommendedMin)}~${fmt(s.recommendedMax)}) 상한 이하입니다.`
|
|
74
|
+
: `\n✅ Compaction window is at or below the top of the recommended ${fmt(s.recommendedMin)}–${fmt(s.recommendedMax)} band.`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
console.log(ko
|
|
78
|
+
? `\n⚠ 1M 컨텍스트인데 autoCompactWindow가 ${s.reason === 'unset' ? '설정되지 않았습니다' : `${fmt(s.window)}로 너무 큽니다`} — 자동 압축이 80만 토큰 근처에서야 걸립니다.`
|
|
79
|
+
: `\n⚠ 1M context with autoCompactWindow ${s.reason === 'unset' ? 'unset' : `at ${fmt(s.window)}`} — compaction only fires near 800k.`);
|
|
80
|
+
console.log(ko
|
|
81
|
+
? ` 그 전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 1M은 너무 크니 ${fmt(s.recommendedMin)}~${fmt(s.recommendedMax)} 범위를 권장합니다 (기본값 ${fmt(s.recommended)}, --value로 조절). 1M 창 자체는 그대로 두고 압축 시점만 앞당깁니다.`
|
|
82
|
+
: ` Until then every request re-bills the whole context. 1M is too large — pick something in ${fmt(s.recommendedMin)}–${fmt(s.recommendedMax)} (default ${fmt(s.recommended)}, override with --value). The 1M window itself stays.`);
|
|
83
|
+
console.log(`\n claude-token-saver compact-window set --global (~/.claude/settings.json, ${fmt(s.recommended)})`);
|
|
84
|
+
console.log(` claude-token-saver compact-window set --project (<root>/.claude/settings.json, ${fmt(s.recommended)})`);
|
|
85
|
+
console.log(` claude-token-saver compact-window set --global --value ${fmt(s.recommendedMax)}`);
|
|
86
|
+
console.log(ko ? ' (적용 범위는 사용자에게 먼저 확인할 것)' : ' (confirm the scope with the user first)');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.error(`Unknown compact-window subcommand: ${sub}`);
|
|
91
|
+
console.error('Usage: claude-token-saver compact-window [status|set --global|--project [--value 500k]|off|on]');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|