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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: seed — register the bundled starter rules, one answer at a time.
|
|
3
|
+
*
|
|
4
|
+
* claude-token-saver seed # what is still pending
|
|
5
|
+
* claude-token-saver seed accept <id> --global # register one rule
|
|
6
|
+
* claude-token-saver seed accept <id> --project # ... into this project only
|
|
7
|
+
* claude-token-saver seed accept all --global # when the user says "all of them"
|
|
8
|
+
* claude-token-saver seed skip <id> | skip all # never offer it again
|
|
9
|
+
* claude-token-saver seed reset # make every preset pending again
|
|
10
|
+
*
|
|
11
|
+
* Scope is explicit on purpose, exactly as `harness promote` requires it: the
|
|
12
|
+
* hook environment is non-TTY, so the model has to ask the user and pass the
|
|
13
|
+
* flag rather than let a default decide where a rule lands.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export async function run({ args, hasFlag }) {
|
|
17
|
+
const sub = args[1];
|
|
18
|
+
const seed = await import('../seed-rules.js');
|
|
19
|
+
const { userLanguage } = await import('../config.js');
|
|
20
|
+
const { findProjectRoot } = await import('../harness.js');
|
|
21
|
+
const lang = userLanguage();
|
|
22
|
+
const ko = lang === 'ko';
|
|
23
|
+
const root = findProjectRoot();
|
|
24
|
+
|
|
25
|
+
const scopeFlag = () => (hasFlag('--global') ? 'global' : hasFlag('--project') ? 'project' : null);
|
|
26
|
+
|
|
27
|
+
if (sub === 'reset') {
|
|
28
|
+
const n = seed.resetSeeds();
|
|
29
|
+
console.log(ko
|
|
30
|
+
? `기록된 응답 ${n}건을 지웠습니다 — 프리셋 전체가 다시 제안 대상이 됩니다.`
|
|
31
|
+
: `Cleared ${n} recorded answer(s) — every preset is pending again.`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (sub === 'accept' || sub === 'skip') {
|
|
36
|
+
const ids = args.slice(2).filter((a) => !a.startsWith('-'));
|
|
37
|
+
if (ids.length === 0) {
|
|
38
|
+
console.error(ko
|
|
39
|
+
? 'id를 지정하십시오 (목록: claude-token-saver seed). 전체는 `all`.'
|
|
40
|
+
: 'Pass an id (list them with `claude-token-saver seed`), or `all`.');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const pending = seed.pendingSeeds({ lang, root });
|
|
44
|
+
const targets = ids.includes('all') ? pending.map((s) => s.id) : ids;
|
|
45
|
+
|
|
46
|
+
if (sub === 'skip') {
|
|
47
|
+
for (const id of targets) {
|
|
48
|
+
const r = seed.skipSeed(id, { lang, root });
|
|
49
|
+
console.log(r
|
|
50
|
+
? (ko ? `건너뜀: ${id}` : `skipped: ${id}`)
|
|
51
|
+
: (ko ? `대기 중인 프리셋이 아닙니다: ${id}` : `not a pending preset: ${id}`));
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const scope = scopeFlag();
|
|
57
|
+
if (!scope) {
|
|
58
|
+
console.error(ko
|
|
59
|
+
? '적용 범위를 명시하십시오 (사용자에게 먼저 확인): --global 또는 --project'
|
|
60
|
+
: 'Scope required (confirm it with the user first): --global or --project');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
for (const id of targets) {
|
|
64
|
+
const r = await seed.acceptSeed(id, { scope, root, lang });
|
|
65
|
+
if (!r) {
|
|
66
|
+
console.log(ko ? `대기 중인 프리셋이 아닙니다: ${id}` : `not a pending preset: ${id}`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
console.log(ko
|
|
70
|
+
? `✅ 등록: ${id} [${scope === 'global' ? '전체 프로젝트' : '이 프로젝트'}]`
|
|
71
|
+
: `✅ registered: ${id} [${scope}]`);
|
|
72
|
+
console.log(` ${r.rule}`);
|
|
73
|
+
for (const p of r.paths || (r.path ? [r.path] : [])) console.log(` → ${p}`);
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Default: status. Lists what is pending and what was already answered, so a
|
|
79
|
+
// user who said no can see that they did and reverse it with `reset`.
|
|
80
|
+
const pending = seed.pendingSeeds({ lang, root });
|
|
81
|
+
const { decided } = seed.loadSeedState();
|
|
82
|
+
const answered = Object.entries(decided);
|
|
83
|
+
console.log(ko
|
|
84
|
+
? `seed — 동봉 프리셋 중 대기 ${pending.length}건, 응답 완료 ${answered.length}건`
|
|
85
|
+
: `seed — ${pending.length} preset(s) pending, ${answered.length} already answered`);
|
|
86
|
+
for (const s of pending) {
|
|
87
|
+
const head = s.kind === 'model'
|
|
88
|
+
? `[${s.id}] ${s.tier} · "${s.label}" → ${s.agent}`
|
|
89
|
+
: `[${s.id}] ${ko ? '랫쳇 룰' : 'ratchet rule'}`;
|
|
90
|
+
console.log(` ${head}`);
|
|
91
|
+
console.log(` ${s.ruleText}`);
|
|
92
|
+
}
|
|
93
|
+
if (answered.length > 0) {
|
|
94
|
+
console.log(ko ? ' 응답 기록:' : ' answered:');
|
|
95
|
+
for (const [id, d] of answered) {
|
|
96
|
+
console.log(` ${id}: ${d.action}${d.scope ? ` (${d.scope})` : ''} — ${d.at}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (pending.length > 0) {
|
|
100
|
+
console.log('');
|
|
101
|
+
console.log(ko
|
|
102
|
+
? '등록: claude-token-saver seed accept <id> --global|--project · 거절: seed skip <id>'
|
|
103
|
+
: 'register: claude-token-saver seed accept <id> --global|--project · decline: seed skip <id>');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: uninstall — take the integration back out of ~/.claude.
|
|
3
|
+
*
|
|
4
|
+
* claude-token-saver uninstall # hooks, statusline, skill
|
|
5
|
+
* claude-token-saver uninstall --purge # the above plus recorded state
|
|
6
|
+
*
|
|
7
|
+
* Recorded savings are kept by default. Someone removing an integration is
|
|
8
|
+
* usually not asking to throw away the ledger that says what it saved, and
|
|
9
|
+
* that data cannot be reconstructed once the transcripts age out.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export async function run({ hasFlag, args = [] }) {
|
|
13
|
+
const { uninstallAll } = await import('../installer.js');
|
|
14
|
+
const { userLanguage } = await import('../config.js');
|
|
15
|
+
const lang = userLanguage();
|
|
16
|
+
const purge = hasFlag('--purge') || args.includes('--purge');
|
|
17
|
+
|
|
18
|
+
const r = uninstallAll({ purge });
|
|
19
|
+
|
|
20
|
+
if (r.action === 'skipped') {
|
|
21
|
+
console.error(lang === 'ko'
|
|
22
|
+
? `✗ ${r.path} 를 읽지 못해 아무것도 지우지 않았습니다: ${r.reason}`
|
|
23
|
+
: `✗ nothing removed — ${r.path} could not be read: ${r.reason}`);
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (r.removed.length === 0) {
|
|
29
|
+
console.log(lang === 'ko' ? '설치된 항목이 없습니다.' : 'Nothing was installed.');
|
|
30
|
+
} else {
|
|
31
|
+
console.log(lang === 'ko' ? `✓ 제거했습니다 (${r.path}):` : `✓ removed (${r.path}):`);
|
|
32
|
+
for (const item of r.removed) console.log(` - ${item}`);
|
|
33
|
+
}
|
|
34
|
+
for (const item of r.kept) {
|
|
35
|
+
console.log(lang === 'ko' ? ` 유지: ${item}` : ` kept: ${item}`);
|
|
36
|
+
}
|
|
37
|
+
if (!purge) {
|
|
38
|
+
console.log(lang === 'ko'
|
|
39
|
+
? ' 기록된 절감액과 설정까지 지우려면 `claude-token-saver uninstall --purge` 를 실행하십시오.'
|
|
40
|
+
: ' To remove recorded savings and settings too: `claude-token-saver uninstall --purge`.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: update-check — everything around "is a newer version out".
|
|
3
|
+
*
|
|
4
|
+
* claude-token-saver update-check # print the cached answer
|
|
5
|
+
* claude-token-saver update-check --refresh # ask the registry now
|
|
6
|
+
* claude-token-saver update-check --refresh --quiet
|
|
7
|
+
* # what the detached background child runs
|
|
8
|
+
* claude-token-saver update-check --dismiss # stop offering this version at session start
|
|
9
|
+
*
|
|
10
|
+
* The statusline never calls the --refresh path directly; it spawns this
|
|
11
|
+
* command detached so the render itself stays offline and instant.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export async function run({ hasFlag, version }) {
|
|
15
|
+
const {
|
|
16
|
+
updateStatus,
|
|
17
|
+
refreshUpdateState,
|
|
18
|
+
dismissUpdate,
|
|
19
|
+
upgradeCommand,
|
|
20
|
+
updateCheckDisabled,
|
|
21
|
+
updateStatePath,
|
|
22
|
+
} = await import('../update-check.js');
|
|
23
|
+
const { userLanguage } = await import('../config.js');
|
|
24
|
+
const lang = userLanguage();
|
|
25
|
+
const quiet = hasFlag('--quiet');
|
|
26
|
+
|
|
27
|
+
if (hasFlag('--refresh')) {
|
|
28
|
+
const res = await refreshUpdateState(version);
|
|
29
|
+
if (quiet) return;
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
console.error(lang === 'ko'
|
|
32
|
+
? `최신 버전을 확인하지 못했습니다: ${res.error}`
|
|
33
|
+
: `Could not reach the registry: ${res.error}`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (hasFlag('--dismiss')) {
|
|
39
|
+
const s = updateStatus(version);
|
|
40
|
+
if (s.latest) dismissUpdate(s.latest);
|
|
41
|
+
console.log(lang === 'ko'
|
|
42
|
+
? `${s.latest || '최신'} 버전 안내를 끕니다. 그 다음 버전이 나오면 다시 안내합니다.`
|
|
43
|
+
: `Muted the ${s.latest || 'latest'} notice. The next release after it will ask again.`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (updateCheckDisabled()) {
|
|
48
|
+
console.log(lang === 'ko'
|
|
49
|
+
? `현재 버전 v${version} — 업데이트 확인이 환경 변수로 꺼져 있습니다 (CTS_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER).`
|
|
50
|
+
: `v${version} — update checks are disabled by env var (CTS_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER).`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const s = updateStatus(version);
|
|
55
|
+
if (s.available) {
|
|
56
|
+
console.log(lang === 'ko'
|
|
57
|
+
? `새 버전이 있습니다: v${version} → ${s.latest}`
|
|
58
|
+
: `Update available: v${version} → ${s.latest}`);
|
|
59
|
+
console.log(lang === 'ko'
|
|
60
|
+
? `업그레이드: ${upgradeCommand()} (또는 claude-token-saver upgrade)`
|
|
61
|
+
: `Upgrade with: ${upgradeCommand()} (or: claude-token-saver upgrade)`);
|
|
62
|
+
if (s.dismissed) {
|
|
63
|
+
console.log(lang === 'ko'
|
|
64
|
+
? '이 버전은 사용자가 한 번 넘긴 상태라, 세션 시작 시에는 다시 묻지 않습니다.'
|
|
65
|
+
: 'This version was dismissed, so the session-start offer stays quiet for it.');
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
console.log(lang === 'ko'
|
|
70
|
+
? `최신 버전을 쓰고 있습니다 (v${version}).`
|
|
71
|
+
: `Up to date (v${version}).`);
|
|
72
|
+
if (!s.latest) {
|
|
73
|
+
console.log(lang === 'ko'
|
|
74
|
+
? `아직 확인된 기록이 없습니다. 지금 확인하려면: claude-token-saver update-check --refresh (기록 파일: ${updateStatePath()})`
|
|
75
|
+
: `No check has completed yet. Run: claude-token-saver update-check --refresh (state: ${updateStatePath()})`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: upgrade — install the latest release with the package manager
|
|
3
|
+
* that put this copy on disk.
|
|
4
|
+
*
|
|
5
|
+
* claude-token-saver upgrade # refresh the check, then install
|
|
6
|
+
* claude-token-saver upgrade --print # show the command, run nothing
|
|
7
|
+
*
|
|
8
|
+
* This is the command the model runs after the user says yes to the
|
|
9
|
+
* session-start offer, so it prints the exact command it is about to execute
|
|
10
|
+
* before executing it: an install that writes outside the project should never
|
|
11
|
+
* be a black box.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
function runCommand(cmd) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
// Shell form: the upgrade command is a fixed string we composed ourselves
|
|
19
|
+
// (no user input reaches it), and `npm install -g` needs the user's PATH
|
|
20
|
+
// resolution to find the same npm that installed us.
|
|
21
|
+
const child = spawn(cmd, { shell: true, stdio: 'inherit' });
|
|
22
|
+
child.on('close', (code) => resolve(code === null ? 1 : code));
|
|
23
|
+
child.on('error', () => resolve(1));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function run({ hasFlag, version }) {
|
|
28
|
+
const { updateStatus, refreshUpdateState, upgradeCommand } = await import('../update-check.js');
|
|
29
|
+
const { userLanguage } = await import('../config.js');
|
|
30
|
+
const lang = userLanguage();
|
|
31
|
+
const cmd = upgradeCommand();
|
|
32
|
+
|
|
33
|
+
if (hasFlag('--print')) {
|
|
34
|
+
console.log(cmd);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Refresh first so the "already latest" answer is trustworthy rather than up
|
|
39
|
+
// to 24h stale — an upgrade is a deliberate, interactive action, so one
|
|
40
|
+
// network round trip is fine here.
|
|
41
|
+
await refreshUpdateState(version);
|
|
42
|
+
const s = updateStatus(version);
|
|
43
|
+
if (!s.available) {
|
|
44
|
+
console.log(lang === 'ko'
|
|
45
|
+
? `이미 최신 버전입니다 (v${version}). 설치할 것이 없습니다.`
|
|
46
|
+
: `Already on the latest version (v${version}). Nothing to install.`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(lang === 'ko'
|
|
51
|
+
? `v${version} → ${s.latest} 로 업그레이드합니다.`
|
|
52
|
+
: `Upgrading v${version} → ${s.latest}.`);
|
|
53
|
+
console.log(`$ ${cmd}`);
|
|
54
|
+
const code = await runCommand(cmd);
|
|
55
|
+
if (code !== 0) {
|
|
56
|
+
console.error(lang === 'ko'
|
|
57
|
+
? `업그레이드 명령이 종료 코드 ${code}로 실패했습니다. 위 출력을 확인하고, 권한 문제라면 sudo 없이 설치되는 전역 경로인지 점검하십시오.`
|
|
58
|
+
: `Upgrade command failed with exit code ${code}. Check the output above; if it is a permissions error, verify your global install prefix.`);
|
|
59
|
+
process.exit(code);
|
|
60
|
+
}
|
|
61
|
+
// The freshly installed copy is a different file on disk, so this process
|
|
62
|
+
// still reports the old version. Refresh the cache against the new latest so
|
|
63
|
+
// the statusline chip clears on the next render instead of lingering.
|
|
64
|
+
await refreshUpdateState(s.latest);
|
|
65
|
+
console.log(lang === 'ko'
|
|
66
|
+
? `설치가 끝났습니다. 새 셸에서 claude-token-saver --version 으로 ${s.latest} 인지 확인하십시오.`
|
|
67
|
+
: `Done. In a fresh shell, run claude-token-saver --version to confirm ${s.latest}.`);
|
|
68
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact-window — auto-compact window audit for 1M-context sessions.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code compacts when context usage approaches
|
|
5
|
+
* `min(settings.autoCompactWindow, model max context)`. On a 1M window the
|
|
6
|
+
* default lets a session grow past 800k before compaction ever fires, so every
|
|
7
|
+
* later request re-bills a context most sessions never needed. Pinning
|
|
8
|
+
* `autoCompactWindow` somewhere in 400k–700k keeps 2–3.5x the headroom of a
|
|
9
|
+
* 200k session for the genuinely large ones while capping the runaway tail.
|
|
10
|
+
*
|
|
11
|
+
* The recommendation is a range, not a number: 400k is the floor where the
|
|
12
|
+
* saving is worth the extra compactions, and in practice long sessions often
|
|
13
|
+
* want more room than that. Anything at or below 700k is left alone — only an
|
|
14
|
+
* unset window, or one above 700k, is a real config defect.
|
|
15
|
+
*
|
|
16
|
+
* 200k sessions are exempt by design: their window is already <= 200k, so the
|
|
17
|
+
* setting changes nothing and a warning would be pure noise.
|
|
18
|
+
*
|
|
19
|
+
* Sources, highest precedence first (mirrors Claude Code's own resolution):
|
|
20
|
+
* 1. env CLAUDE_CODE_AUTO_COMPACT_WINDOW ("200000" | "200k" | "200")
|
|
21
|
+
* 2. <root>/.claude/settings.local.json
|
|
22
|
+
* 3. <root>/.claude/settings.json
|
|
23
|
+
* 4. ~/.claude/settings.json
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
27
|
+
import { join, dirname } from 'node:path';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { debug } from './debug.js';
|
|
30
|
+
|
|
31
|
+
// Claude Code clamps autoCompactWindow to [100k, 1M] (settings schema:
|
|
32
|
+
// `.int().min(1e5).max(1e6)`), so anything outside that range is not a value
|
|
33
|
+
// the app would honor.
|
|
34
|
+
export const MIN_WINDOW = 100_000;
|
|
35
|
+
export const MAX_WINDOW = 1_000_000;
|
|
36
|
+
// Recommended band. Warn only outside it on the high side (or when unset) —
|
|
37
|
+
// a window below RECOMMENDED_MIN is a deliberate, more aggressive choice and
|
|
38
|
+
// costs nothing, so it stays silent.
|
|
39
|
+
export const RECOMMENDED_MIN = 400_000;
|
|
40
|
+
export const RECOMMENDED_MAX = 700_000;
|
|
41
|
+
// What `compact-window set` writes when no --value is given: middle of the band.
|
|
42
|
+
export const RECOMMENDED_WINDOW = 500_000;
|
|
43
|
+
|
|
44
|
+
// A model id whose context is the 1M variant. Claude Code spells it as a
|
|
45
|
+
// suffix on the model id (`claude-opus-5[1m]`); the beta header form
|
|
46
|
+
// (`context-1m`) shows up in SDK/env configs.
|
|
47
|
+
const ONE_M_RE = /\[1m\]|context-1m|1m-context/i;
|
|
48
|
+
|
|
49
|
+
export function userSettingsPath() {
|
|
50
|
+
return join(homedir(), '.claude', 'settings.json');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function projectSettingsPaths(root) {
|
|
54
|
+
return [
|
|
55
|
+
join(root, '.claude', 'settings.local.json'),
|
|
56
|
+
join(root, '.claude', 'settings.json'),
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readJson(path) {
|
|
61
|
+
try {
|
|
62
|
+
if (!existsSync(path)) return null;
|
|
63
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
debug('compact-window:read', e);
|
|
66
|
+
return null; // malformed settings — treat as "nothing configured"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse the value forms Claude Code accepts for a window size: a plain token
|
|
72
|
+
* count, a `200k`/`1M` shorthand, or the bare-hundreds shorthand (`200` = 200k)
|
|
73
|
+
* its /config parser documents. Returns null when unparseable.
|
|
74
|
+
*/
|
|
75
|
+
export function parseWindow(raw) {
|
|
76
|
+
if (typeof raw === 'number') return Number.isFinite(raw) ? Math.round(raw) : null;
|
|
77
|
+
if (typeof raw !== 'string') return null;
|
|
78
|
+
const s = raw.trim().toLowerCase();
|
|
79
|
+
const m = s.match(/^(\d+(?:\.\d+)?)\s*([km]?)$/);
|
|
80
|
+
if (!m) return null;
|
|
81
|
+
const n = parseFloat(m[1]);
|
|
82
|
+
if (!Number.isFinite(n)) return null;
|
|
83
|
+
if (m[2] === 'k') return Math.round(n * 1_000);
|
|
84
|
+
if (m[2] === 'm') return Math.round(n * 1_000_000);
|
|
85
|
+
// Bare number: below the legal minimum it can only be the `200` = 200k form.
|
|
86
|
+
return n < MIN_WINDOW ? Math.round(n * 1_000) : Math.round(n);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The model id this project would launch with. Same precedence as the settings
|
|
91
|
+
* lookup, with ANTHROPIC_MODEL on top (Claude Code reads it as an override).
|
|
92
|
+
*/
|
|
93
|
+
export function resolveModelId(root) {
|
|
94
|
+
if (process.env.ANTHROPIC_MODEL) return { model: process.env.ANTHROPIC_MODEL, source: 'env' };
|
|
95
|
+
for (const p of projectSettingsPaths(root)) {
|
|
96
|
+
const j = readJson(p);
|
|
97
|
+
if (j && typeof j.model === 'string') return { model: j.model, source: 'project', path: p };
|
|
98
|
+
}
|
|
99
|
+
const g = readJson(userSettingsPath());
|
|
100
|
+
if (g && typeof g.model === 'string') return { model: g.model, source: 'global', path: userSettingsPath() };
|
|
101
|
+
return { model: null, source: null };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isOneMillionModel(model) {
|
|
105
|
+
return typeof model === 'string' && ONE_M_RE.test(model);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Effective autoCompactWindow, with the source that won. */
|
|
109
|
+
export function effectiveWindow(root) {
|
|
110
|
+
const env = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
|
111
|
+
if (env) {
|
|
112
|
+
const v = parseWindow(env);
|
|
113
|
+
if (v !== null) return { value: v, source: 'env' };
|
|
114
|
+
}
|
|
115
|
+
for (const p of projectSettingsPaths(root)) {
|
|
116
|
+
const j = readJson(p);
|
|
117
|
+
const v = j ? parseWindow(j.autoCompactWindow) : null;
|
|
118
|
+
if (v !== null) return { value: v, source: 'project', path: p };
|
|
119
|
+
}
|
|
120
|
+
const gp = userSettingsPath();
|
|
121
|
+
const g = readJson(gp);
|
|
122
|
+
const gv = g ? parseWindow(g.autoCompactWindow) : null;
|
|
123
|
+
if (gv !== null) return { value: gv, source: 'global', path: gp };
|
|
124
|
+
return { value: null, source: null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Full audit result. `ok` is true when there is nothing to warn about — either
|
|
129
|
+
* the session is not on a 1M model (exempt), or the window is already pinned at
|
|
130
|
+
* or below the top of the recommended band (700k).
|
|
131
|
+
*
|
|
132
|
+
* `reason` names why it is not ok: 'unset' (no autoCompactWindow anywhere) or
|
|
133
|
+
* 'too-large' (set, but above 700k — still lets context run away).
|
|
134
|
+
*/
|
|
135
|
+
export function compactWindowStatus({ root = process.cwd() } = {}) {
|
|
136
|
+
const { model, source: modelSource } = resolveModelId(root);
|
|
137
|
+
const is1m = isOneMillionModel(model);
|
|
138
|
+
const win = effectiveWindow(root);
|
|
139
|
+
const base = {
|
|
140
|
+
model,
|
|
141
|
+
modelSource,
|
|
142
|
+
is1m,
|
|
143
|
+
window: win.value,
|
|
144
|
+
windowSource: win.source,
|
|
145
|
+
windowPath: win.path || null,
|
|
146
|
+
recommended: RECOMMENDED_WINDOW,
|
|
147
|
+
recommendedMin: RECOMMENDED_MIN,
|
|
148
|
+
recommendedMax: RECOMMENDED_MAX,
|
|
149
|
+
};
|
|
150
|
+
if (!is1m) return { ...base, ok: true, reason: 'not-1m' };
|
|
151
|
+
if (win.value === null) return { ...base, ok: false, reason: 'unset' };
|
|
152
|
+
if (win.value > RECOMMENDED_MAX) return { ...base, ok: false, reason: 'too-large' };
|
|
153
|
+
return { ...base, ok: true, reason: 'configured' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Statusline chip text, or null when there is nothing to say. Kept to the same
|
|
158
|
+
* short shape as the other 🅷⚠ warnings so the line stays single-width.
|
|
159
|
+
*/
|
|
160
|
+
export function compactWindowWarningForStatusline(root = process.cwd(), cfg = null) {
|
|
161
|
+
if (cfg && cfg.compactWindow && cfg.compactWindow.enabled === false) return null;
|
|
162
|
+
try {
|
|
163
|
+
const s = compactWindowStatus({ root });
|
|
164
|
+
return s.ok ? null : 'compact-window?';
|
|
165
|
+
} catch (e) {
|
|
166
|
+
debug('compact-window:statusline', e);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Write `autoCompactWindow` into a settings.json, preserving every other key.
|
|
173
|
+
* Scope 'global' → ~/.claude/settings.json, 'project' → <root>/.claude/settings.json.
|
|
174
|
+
* A `.bak` is written first whenever the file already exists.
|
|
175
|
+
*
|
|
176
|
+
* Returns { path, scope, value, previous, backup }.
|
|
177
|
+
*/
|
|
178
|
+
export function setAutoCompactWindow({ root = process.cwd(), scope = 'global', value = RECOMMENDED_WINDOW } = {}) {
|
|
179
|
+
const v = parseWindow(value);
|
|
180
|
+
if (v === null || v < MIN_WINDOW || v > MAX_WINDOW) {
|
|
181
|
+
return { ok: false, error: `Invalid window: ${value} (expected ${MIN_WINDOW}–${MAX_WINDOW} tokens, e.g. 400000 or 400k)` };
|
|
182
|
+
}
|
|
183
|
+
const path = scope === 'global' ? userSettingsPath() : join(root, '.claude', 'settings.json');
|
|
184
|
+
let json = {};
|
|
185
|
+
let backup = null;
|
|
186
|
+
if (existsSync(path)) {
|
|
187
|
+
const raw = readFileSync(path, 'utf8');
|
|
188
|
+
try {
|
|
189
|
+
json = JSON.parse(raw);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
debug('compact-window:parse', e);
|
|
192
|
+
// Refuse to overwrite a file we cannot parse — silently replacing a
|
|
193
|
+
// malformed settings.json would drop every other setting in it.
|
|
194
|
+
return { ok: false, error: `${path} is not valid JSON — fix it first (nothing was written).` };
|
|
195
|
+
}
|
|
196
|
+
backup = `${path}.bak`;
|
|
197
|
+
writeFileSync(backup, raw);
|
|
198
|
+
} else {
|
|
199
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
200
|
+
}
|
|
201
|
+
const previous = parseWindow(json.autoCompactWindow);
|
|
202
|
+
json.autoCompactWindow = v;
|
|
203
|
+
writeFileSync(path, JSON.stringify(json, null, 2) + '\n');
|
|
204
|
+
return { ok: true, path, scope, value: v, previous, backup };
|
|
205
|
+
}
|