sprag-cli 3.45.0 → 3.46.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.ko.md +1 -1
- package/README.md +2 -2
- package/bin/cli.js +6 -1
- package/package.json +2 -1
- package/src/changelog.js +34 -0
- package/src/commands/install.js +1 -0
- package/src/commands/route-scan.js +66 -7
- package/src/formatters/statusline.js +72 -67
- package/src/glyphs.js +17 -0
- package/src/installer.js +50 -1
- package/src/litellm-budget.js +16 -2
- package/src/route-inject.js +10 -2
- package/src/route-scan.js +24 -1
- package/src/seed-rules.js +26 -14
- package/src/update-check.js +138 -1
package/README.ko.md
CHANGED
|
@@ -66,7 +66,7 @@ gemini-2.5-pro 57.9% $734 ▰▰▰▰▰▰▰▰▰▰▰
|
|
|
66
66
|
|
|
67
67
|
```
|
|
68
68
|
🔀 라우팅 절감 $2.09 | fable→sonnet 1회 $0.72 · opus→haiku 1회 $0.57
|
|
69
|
-
🚨 5H
|
|
69
|
+
🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
문제가 생기면 경고 칩이 줄 맨 앞으로 옵니다. `🚨 5H/7D NN%`, `⚠ Ctx 500k+`, `⚠ Cache miss`, `⚠ Input spike`, `⚠ Output heavy`, `⚠ Call surge`, `⚠ Rebuild churn`, `⚠ 5m TTL` 여덟 가지이며, 칩 문구를 그대로 말하면 Skill이 원인 코드와 조치 순서를 꺼내 줍니다. 세그먼트별 의미와 색 기준은 [statusline 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/STATUSLINE.md)에 정리했습니다.
|
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ gemini-2.5-pro 57.9% $734 ▰▰▰▰▰▰▰▰▰▰▰
|
|
|
72
72
|
|
|
73
73
|
```
|
|
74
74
|
🔀 라우팅 절감 $2.09 | fable→sonnet 1회 $0.72 · opus→haiku 1회 $0.57
|
|
75
|
-
🚨 5H
|
|
75
|
+
🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
문제가 생기면 경고 칩이 줄 맨 앞으로 옵니다. `🚨 5H/7D NN%`, `⚠ Ctx 500k+`, `⚠ Cache miss`, `⚠ Input spike`, `⚠ Output heavy`, `⚠ Call surge`, `⚠ Rebuild churn`, `⚠ 5m TTL` 여덟 가지이며, 칩 문구를 그대로 말하면 Skill이 원인 코드와 조치 순서를 꺼내 줍니다. 세그먼트별 의미와 색 기준은 [statusline 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/STATUSLINE.md)에 정리했습니다.
|
|
@@ -211,7 +211,7 @@ The measured −18.6% comes from the harness and ratchet; routing and conversion
|
|
|
211
211
|
|
|
212
212
|
```
|
|
213
213
|
🔀 Routing saved $2.09 | fable→sonnet 1× $0.72 · opus→haiku 1× $0.57
|
|
214
|
-
🚨 5H
|
|
214
|
+
🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
|
|
215
215
|
```
|
|
216
216
|
|
|
217
217
|
When something is wrong the warning chip leads the line: `🚨 5H/7D NN%`, `⚠ Ctx 500k+`, `⚠ Cache miss`, `⚠ Input spike`, `⚠ Output heavy`, `⚠ Call surge`, `⚠ Rebuild churn`, `⚠ 5m TTL`. Say the chip wording inside Claude and the Skill surfaces the root-cause code and the fix. Segment-by-segment meanings live in [the statusline reference](./docs/STATUSLINE.md).
|
package/bin/cli.js
CHANGED
|
@@ -325,7 +325,12 @@ async function main() {
|
|
|
325
325
|
console.log('예산 캐시가 비어 있습니다. `sprag litellm-budget --refresh` 로 먼저 조회하십시오.');
|
|
326
326
|
return;
|
|
327
327
|
}
|
|
328
|
-
|
|
328
|
+
// Same resolver the statusline uses: this report draws a gauge too, and a
|
|
329
|
+
// glyph the terminal's font lacks garbles here exactly as it does there.
|
|
330
|
+
const { resolveLabelMode } = await import('../src/statusline-mode.js');
|
|
331
|
+
const { statuslineDefaults } = await import('../src/config.js');
|
|
332
|
+
const { mode: labelMode } = resolveLabelMode({ hasFlag, cfg: statuslineDefaults() });
|
|
333
|
+
for (const line of formatBudgetReport(state, new Date(), labelMode)) console.log(line);
|
|
329
334
|
return;
|
|
330
335
|
}
|
|
331
336
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.46.0",
|
|
4
4
|
"description": "Sprag - a quality ratchet harness for AI coding agents: model-fitting delegation, ratchet rules, doc2md, Korean style gate. Same tool as claude-token-saver, under its new name.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"postinstall": "node bin/cli.js install || true",
|
|
14
14
|
"publish:legacy": "node scripts/publish-legacy.mjs",
|
|
15
15
|
"sync:ko": "node scripts/sync-readme-ko.mjs",
|
|
16
|
+
"release:notes": "node scripts/release-notes.mjs",
|
|
16
17
|
"prepublishOnly": "node scripts/sync-readme-ko.mjs"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
package/src/changelog.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* changelog — read one version's section out of CHANGELOG.md.
|
|
3
|
+
*
|
|
4
|
+
* Separate from scripts/release-notes.mjs, which is a CLI: importing that file
|
|
5
|
+
* runs its argument parsing and exits, so the extraction it depends on cannot be
|
|
6
|
+
* tested through it. The boundary logic is the part worth pinning — a section
|
|
7
|
+
* pulled one line short drops the release's last item, and one line long carries
|
|
8
|
+
* the previous release's first, and the upgrade offer shows either without
|
|
9
|
+
* complaint.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The body of one version's section, heading excluded.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} changelog - CHANGELOG.md contents
|
|
16
|
+
* @param {string} version - e.g. "3.45.0"
|
|
17
|
+
* @returns {string|null} the section text, or null when that version has none
|
|
18
|
+
*/
|
|
19
|
+
export function sectionFor(changelog, version) {
|
|
20
|
+
const lines = String(changelog).split(/\r?\n/);
|
|
21
|
+
// The version has to match to its end: without the boundary, "3.42.0" would
|
|
22
|
+
// also match the "### v3.42.7" heading and publish the wrong notes.
|
|
23
|
+
const head = new RegExp(`^### v${String(version).replace(/\./g, '\\.')}(\\s|$|\\()`);
|
|
24
|
+
const start = lines.findIndex((l) => head.test(l));
|
|
25
|
+
if (start < 0) return null;
|
|
26
|
+
const out = [];
|
|
27
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
28
|
+
// The next version heading ends this section, and so does a shallower one:
|
|
29
|
+
// "## All releases" is a container, not part of any release.
|
|
30
|
+
if (/^### v/.test(lines[i]) || /^## /.test(lines[i])) break;
|
|
31
|
+
out.push(lines[i]);
|
|
32
|
+
}
|
|
33
|
+
return out.join('\n').trim();
|
|
34
|
+
}
|
package/src/commands/install.js
CHANGED
|
@@ -71,6 +71,7 @@ export async function run({ hasFlag }) {
|
|
|
71
71
|
const r = installAll({ force });
|
|
72
72
|
print('skill', r.skill);
|
|
73
73
|
print('SessionStart hook (route-scan)', r.sessionStartHook);
|
|
74
|
+
print('PostToolUse hook (delegation rescan)', r.delegationHook);
|
|
74
75
|
print('UserPromptSubmit hook (brief)', r.briefHook);
|
|
75
76
|
{
|
|
76
77
|
let s = r.statusline;
|
|
@@ -174,11 +174,27 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
174
174
|
const scan = rs.readRouteScan();
|
|
175
175
|
if (scan?.unresolvedRuns > 0) {
|
|
176
176
|
const ids = (scan.unresolvedModels || []).join(', ');
|
|
177
|
+
// Unlike the statusline chip, this listing reports even a single one: the
|
|
178
|
+
// user ran this command to find out why a rule reads as never fired, and
|
|
179
|
+
// there is room here to answer properly. But a gateway's opaque profile
|
|
180
|
+
// ARN is normally resolved by learning from observed runs, which needs
|
|
181
|
+
// MIN_VOTES of them — so below that, the honest advice is to wait rather
|
|
182
|
+
// than to go edit a config file.
|
|
183
|
+
const { MIN_VOTES } = await import('../model-alias.js');
|
|
184
|
+
const learning = scan.unresolvedRuns < MIN_VOTES;
|
|
185
|
+
const remedyKo = learning
|
|
186
|
+
? `\n 위임이 ${MIN_VOTES}건 이상 쌓이면 자동으로 해석될 수 있습니다. 그때까지 기다렸다가 다시 확인하고,`
|
|
187
|
+
+ '\n 게이트웨이가 모델 이름을 아예 알려 주지 않는 환경이라면 profile-map.json 의 modelAliases 에'
|
|
188
|
+
+ '\n 해당 ID 를 매핑한 뒤 route-scan --refresh 를 실행하십시오.'
|
|
189
|
+
: '\n profile-map.json 의 modelAliases 에 해당 ID 를 매핑한 뒤 route-scan --refresh 를 실행하십시오.';
|
|
190
|
+
const remedyEn = learning
|
|
191
|
+
? `\n These may resolve on their own once ${MIN_VOTES} or more delegated runs accumulate — check again then.`
|
|
192
|
+
+ '\n If the gateway never reports a model name at all, map the id under modelAliases in'
|
|
193
|
+
+ '\n profile-map.json and run route-scan --refresh.'
|
|
194
|
+
: '\n Map it under modelAliases in profile-map.json, then run route-scan --refresh.';
|
|
177
195
|
console.log(lang === 'ko'
|
|
178
|
-
? `\n⚠ 해석되지 않은 모델 ID 때문에 위임 ${scan.unresolvedRuns}건이 집계에서 제외됐습니다${ids ? ` (${ids})` : ''}.`
|
|
179
|
-
|
|
180
|
-
: `\n⚠ ${scan.unresolvedRuns} delegated run(s) were excluded — unpriceable model id${ids ? ` (${ids})` : ''}.`
|
|
181
|
-
+ '\n Map it under modelAliases in profile-map.json, then run route-scan --refresh.');
|
|
196
|
+
? `\n⚠ 해석되지 않은 모델 ID 때문에 위임 ${scan.unresolvedRuns}건이 집계에서 제외됐습니다${ids ? ` (${ids})` : ''}.` + remedyKo
|
|
197
|
+
: `\n⚠ ${scan.unresolvedRuns} delegated run(s) were excluded — unpriceable model id${ids ? ` (${ids})` : ''}.` + remedyEn);
|
|
182
198
|
}
|
|
183
199
|
console.log(lang === 'ko'
|
|
184
200
|
? '\n제거: sprag route-scan rules rm <N>'
|
|
@@ -203,6 +219,33 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
203
219
|
return;
|
|
204
220
|
}
|
|
205
221
|
|
|
222
|
+
// --hook-delegated: PostToolUse hook for Task/Agent. A delegation just
|
|
223
|
+
// finished, which is the one event guaranteed to change what a scan
|
|
224
|
+
// produces — its savings and its error rate both move. Kicks a detached
|
|
225
|
+
// refresh so the statusline shows the new figure on its next render
|
|
226
|
+
// instead of waiting out the hourly gate, and prints nothing: a tool
|
|
227
|
+
// result is not a place for our commentary.
|
|
228
|
+
if (hasFlag('--hook-delegated')) {
|
|
229
|
+
const payload = readStdinJson() || {};
|
|
230
|
+
// Claude Code matches the hook by tool name already, but the payload is
|
|
231
|
+
// the only thing that proves it: a mis-registered matcher would otherwise
|
|
232
|
+
// have us rescanning after every Read.
|
|
233
|
+
const tool = payload.tool_name;
|
|
234
|
+
if (tool !== 'Task' && tool !== 'Agent') return;
|
|
235
|
+
try {
|
|
236
|
+
const cache = rs.readRouteScan();
|
|
237
|
+
if (!(await rs.shouldRescan(cache, { afterDelegation: true }))) return;
|
|
238
|
+
const { spawn } = await import('node:child_process');
|
|
239
|
+
spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
|
|
240
|
+
{ detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
241
|
+
} catch (e) {
|
|
242
|
+
// A missed refresh only means the figure lags, which is what happened
|
|
243
|
+
// before this hook existed. Never fail the tool call over it.
|
|
244
|
+
debug('route-scan:delegated-refresh', e);
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
206
249
|
// --hook: SessionStart hook mode. Never scans inline (session start must
|
|
207
250
|
// stay fast) — reads the cache, kicks a detached refresh when stale, and
|
|
208
251
|
// prints delegation-candidate context for the new session.
|
|
@@ -299,16 +342,32 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
299
342
|
// `dismissed` is the whole point of asking once: a user who said no to
|
|
300
343
|
// this version must not be asked again every `/clear`.
|
|
301
344
|
if (u && u.available && !u.dismissed) {
|
|
345
|
+
// What the release adds, when we managed to read it. "A newer version
|
|
346
|
+
// exists" is not a reason to upgrade; the reason is in this list, and
|
|
347
|
+
// without it the user is being asked to decide on no information.
|
|
348
|
+
const notes = Array.isArray(u.highlights) ? u.highlights : [];
|
|
349
|
+
const koNotes = notes.length
|
|
350
|
+
? [' 이 버전에서 새로 생긴 것:', ...notes.map((n) => ` · ${n}`)]
|
|
351
|
+
: [];
|
|
352
|
+
const enNotes = notes.length
|
|
353
|
+
? [' What this release adds:', ...notes.map((n) => ` · ${n}`)]
|
|
354
|
+
: [];
|
|
302
355
|
updateBlock = lang === 'ko'
|
|
303
356
|
? [
|
|
304
357
|
`[sprag update] 새 버전이 나와 있습니다: v${u.current} → ${u.latest}.`,
|
|
305
|
-
|
|
306
|
-
|
|
358
|
+
...koNotes,
|
|
359
|
+
notes.length
|
|
360
|
+
? ' 위 내용을 사용자에게 보여 주고 업그레이드할지 물어보십시오. 승낙하면 아래 명령을 실행하고, 묻지 않고 설치하지는 마십시오.'
|
|
361
|
+
: ' 사용자에게 지금 업그레이드할지 물어보고, 승낙하면 아래 명령을 실행하십시오. 묻지 않고 설치하지는 마십시오.',
|
|
362
|
+
' sprag upgrade # 업그레이드 실행',
|
|
307
363
|
' sprag update-check --dismiss # 사용자가 원치 않으면 (다음 버전이 나올 때까지 다시 묻지 않습니다)',
|
|
308
364
|
].join('\n')
|
|
309
365
|
: [
|
|
310
366
|
`[sprag update] A newer version is available: v${u.current} → ${u.latest}.`,
|
|
311
|
-
|
|
367
|
+
...enNotes,
|
|
368
|
+
notes.length
|
|
369
|
+
? ' Show that list to the user and ask whether to upgrade. Run the command below only if they agree; do not install without asking.'
|
|
370
|
+
: 'Ask the user whether to upgrade now, and run the command below only if they agree. Do not install without asking.',
|
|
312
371
|
' sprag upgrade # perform the upgrade',
|
|
313
372
|
' sprag update-check --dismiss # if they decline (stays quiet until a newer release)',
|
|
314
373
|
].join('\n');
|
|
@@ -23,6 +23,7 @@ import { labelForKey } from '../window-labels.js';
|
|
|
23
23
|
import { harnessStatusForStatusline } from '../harness.js';
|
|
24
24
|
import { loadConfig } from '../config.js';
|
|
25
25
|
import { koreanStyleEnabled } from '../korean-style.js';
|
|
26
|
+
import { MIN_VOTES } from '../model-alias.js';
|
|
26
27
|
|
|
27
28
|
// The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
|
|
28
29
|
// next to each other — terminal palettes set them with unbalanced perceptual
|
|
@@ -67,88 +68,86 @@ function formatPct(v) {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
|
-
* Render
|
|
71
|
-
* the same Unicode "Block Elements" density family — `█` (100%) → `▓` (75%)
|
|
72
|
-
* → `▒` (50%) → `░` (25%) — so the fill→empty boundary reads as one smooth
|
|
73
|
-
* gradient instead of an awkward step.
|
|
71
|
+
* Render the gauge for `pct` (0..100) as a row of separate ticks.
|
|
74
72
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
73
|
+
* The bar used to be six cells of one continuous block, with the boundary cell
|
|
74
|
+
* subdivided into eighths for sub-cell precision. That precision had no reader:
|
|
75
|
+
* every chip that draws a gauge prints the exact percentage immediately after it
|
|
76
|
+
* (`▰▰▰▱▱▱ 26%`), so the bar's job is the glance, not the number. What the solid
|
|
77
|
+
* run cost instead was legibility — adjacent full cells fuse into one shape, and
|
|
78
|
+
* a shape has no quantity you can read off it. Separate ticks do: twelve of them
|
|
79
|
+
* are countable at a glance and land on a familiar 1/12 grid.
|
|
80
80
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
81
|
+
* Two ends get special treatment, because they are the two readings that change
|
|
82
|
+
* a decision. A full row means the limit is reached, so nothing below 100% may
|
|
83
|
+
* draw one; conversely anything above zero draws at least one tick, so "barely
|
|
84
|
+
* started" never looks like "untouched". Rounding alone gets both wrong: it fills
|
|
85
|
+
* the last cell from 96% and leaves the first empty until 5%.
|
|
86
|
+
*
|
|
87
|
+
* Glyphs come from the mode's set (src/glyphs.js) rather than being fixed here.
|
|
88
|
+
* ▰▱ is the shape we want, but JetBrains Mono ships neither character, and a
|
|
89
|
+
* missing glyph is drawn by a fallback font whose advance width does not match
|
|
90
|
+
* the cell grid — which is the whole reason narrow mode exists. So narrow draws
|
|
91
|
+
* the same segmented bar with ■□, which that font does have.
|
|
92
|
+
*
|
|
93
|
+
* @param {number} pct
|
|
94
|
+
* @param {string} [mode] label mode; selects the tick glyphs
|
|
95
|
+
* @returns {{ filled: string, empty: string }} the two halves, so a caller can
|
|
96
|
+
* color them separately
|
|
84
97
|
*/
|
|
85
|
-
export function gaugeParts(pct) {
|
|
86
|
-
const
|
|
98
|
+
export function gaugeParts(pct, mode = 'icon') {
|
|
99
|
+
const CELLS = 12;
|
|
100
|
+
const g = glyphsFor(mode);
|
|
87
101
|
const clamped = Math.max(0, Math.min(100, pct));
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// cell height and 10% of its width inset — so a bar mixing them looks visibly
|
|
100
|
-
// misaligned, the empty half riding high and shifted inward. ▒ shares the box
|
|
101
|
-
// with the filled glyphs and still reads clearly lighter than █.
|
|
102
|
-
const step = Math.round(remainder * 8);
|
|
103
|
-
// A boundary cell rounded up to 8/8 is a full cell, not a ninth glyph.
|
|
104
|
-
const extra = step === 8 ? 1 : 0;
|
|
105
|
-
const totalFull = Math.min(cells, fullCells + extra);
|
|
106
|
-
const partial = totalFull < cells && extra === 0 ? EIGHTHS[step] : '';
|
|
107
|
-
const usedCells = totalFull + (partial ? 1 : 0);
|
|
108
|
-
const empty = '▒'.repeat(Math.max(0, cells - usedCells));
|
|
109
|
-
return { filled: '█'.repeat(totalFull) + partial, empty };
|
|
102
|
+
let ticks = Math.round((clamped / 100) * CELLS);
|
|
103
|
+
// A full row is reserved for an actual 100%: at the cap the gauge alone has to
|
|
104
|
+
// say so, and 96% rounding up to twelve would spend that signal on a window
|
|
105
|
+
// that still has room.
|
|
106
|
+
if (ticks >= CELLS && clamped < 100) ticks = CELLS - 1;
|
|
107
|
+
// And any nonzero usage earns a tick, so an empty row means untouched.
|
|
108
|
+
if (ticks === 0 && clamped > 0) ticks = 1;
|
|
109
|
+
return {
|
|
110
|
+
filled: g.gaugeFull.repeat(ticks),
|
|
111
|
+
empty: g.gaugeEmpty.repeat(CELLS - ticks),
|
|
112
|
+
};
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
/**
|
|
113
116
|
* The bar as one string, for callers that render it in a single color.
|
|
114
117
|
* @param {number} pct
|
|
118
|
+
* @param {string} [mode] label mode; selects the tick glyphs
|
|
115
119
|
* @returns {string}
|
|
116
120
|
*/
|
|
117
|
-
export function gaugeBar(pct) {
|
|
118
|
-
const { filled, empty } = gaugeParts(pct);
|
|
121
|
+
export function gaugeBar(pct, mode = 'icon') {
|
|
122
|
+
const { filled, empty } = gaugeParts(pct, mode);
|
|
119
123
|
return filled + empty;
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
/**
|
|
123
|
-
* The bar with the unused
|
|
127
|
+
* The bar with the unused ticks dimmed.
|
|
124
128
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* text after the bar keeps the color
|
|
129
|
+
* The hollow tick already distinguishes the two halves by shape, so color is not
|
|
130
|
+
* carrying that distinction the way it had to when both halves were the same
|
|
131
|
+
* solid block. It still helps: graying the remainder keeps the filled run as the
|
|
132
|
+
* only thing at full brightness, which is what the eye should land on. The
|
|
133
|
+
* segment tone is reinstated at the end so the text after the bar keeps the color
|
|
134
|
+
* it started in.
|
|
130
135
|
*
|
|
131
136
|
* @param {number} pct
|
|
132
137
|
* @param {(code: string) => string} c - color emitter (returns '' when color is off)
|
|
133
138
|
* @param {string} tone - the segment's own color code
|
|
134
139
|
* @param {boolean} color - whether ANSI is allowed. Passed explicitly rather
|
|
135
140
|
* than inferred from `c` emitting nothing: that inference reads the emitter's
|
|
136
|
-
* current implementation, and a caller that wraps or replaces `c` would
|
|
137
|
-
*
|
|
141
|
+
* current implementation, and a caller that wraps or replaces `c` would change
|
|
142
|
+
* behaviour here as a side effect nobody asked for.
|
|
143
|
+
* @param {string} [mode] label mode; selects the tick glyphs
|
|
138
144
|
* @returns {string}
|
|
139
145
|
*/
|
|
140
|
-
function gaugeColored(pct, c, tone, color) {
|
|
141
|
-
const { filled, empty } = gaugeParts(pct);
|
|
146
|
+
function gaugeColored(pct, c, tone, color, mode = 'icon') {
|
|
147
|
+
const { filled, empty } = gaugeParts(pct, mode);
|
|
142
148
|
if (!empty) return filled;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
// as noise beside a solid fill — the two halves look like different materials
|
|
146
|
-
// rather than two states of one bar. One glyph in two colors reads as a track
|
|
147
|
-
// filling up, and it sidesteps the glyph-box mismatch between the shades and
|
|
148
|
-
// the blocks entirely. Without color there is nothing but texture to tell them
|
|
149
|
-
// apart, so the shading stays.
|
|
150
|
-
const track = color ? '█'.repeat(empty.length) : empty;
|
|
151
|
-
return `${filled}${c(RESET)}${c(GRAY)}${track}${c(RESET)}${c(tone)}`;
|
|
149
|
+
if (!color) return filled + empty;
|
|
150
|
+
return `${filled}${c(RESET)}${c(GRAY)}${empty}${c(RESET)}${c(tone)}`;
|
|
152
151
|
}
|
|
153
152
|
|
|
154
153
|
/**
|
|
@@ -266,11 +265,9 @@ function buildHarnessSeg(c, isIcon, g) {
|
|
|
266
265
|
* the wall-clock reset time rides along in the same `🔄 HH:MM` shape as the
|
|
267
266
|
* always-on usage segments.
|
|
268
267
|
*/
|
|
269
|
-
function buildCapWarnSeg(capWarn, c, isIcon, g, color) {
|
|
270
|
-
// `color`
|
|
271
|
-
//
|
|
272
|
-
// anyway, because a change to the cap-warn threshold or the bar width would
|
|
273
|
-
// otherwise start drawing the wrong track with nothing to catch it.
|
|
268
|
+
function buildCapWarnSeg(capWarn, c, isIcon, g, color, mode) {
|
|
269
|
+
// `color` and `mode` are both for the gauge: the first decides whether the
|
|
270
|
+
// unused ticks are dimmed, the second which pair of tick glyphs to draw.
|
|
274
271
|
if (!capWarn) return null;
|
|
275
272
|
const pct = Math.round(capWarn.usedPct);
|
|
276
273
|
const clock = formatResetClock(capWarn.resetsAt);
|
|
@@ -281,7 +278,7 @@ function buildCapWarnSeg(capWarn, c, isIcon, g, color) {
|
|
|
281
278
|
// Gauge keeps shape parity with the always-on usage segment — the
|
|
282
279
|
// cap-warn is just the same gauge "filled to alarm". Visual continuity
|
|
283
280
|
// helps the eye understand "this is the 5H bar I was watching, just red now."
|
|
284
|
-
const bar = gaugeColored(pct, c, RED, color);
|
|
281
|
+
const bar = gaugeColored(pct, c, RED, color, mode);
|
|
285
282
|
return `${c(BOLD)}${c(RED)}${g.capWarn} ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
|
|
286
283
|
}
|
|
287
284
|
return `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
|
|
@@ -301,7 +298,7 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
|
|
|
301
298
|
const isIcon = mode === 'icon' || mode === 'narrow';
|
|
302
299
|
const g = glyphsFor(mode);
|
|
303
300
|
const segs = [];
|
|
304
|
-
const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon, g, color);
|
|
301
|
+
const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon, g, color, mode);
|
|
305
302
|
if (capSeg) segs.push(capSeg);
|
|
306
303
|
const versionSeg = buildVersionSeg(version, update, c, isIcon, false, g);
|
|
307
304
|
if (versionSeg && update && update.available) segs.push(versionSeg);
|
|
@@ -443,10 +440,18 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
443
440
|
// identical, so users in the second case had no reason to suspect anything
|
|
444
441
|
// was wrong. The count gets a chip; the explanation stays in `route-scan
|
|
445
442
|
// rules`, where there is room for it.
|
|
443
|
+
//
|
|
444
|
+
// But only once there are enough of them to mean anything. A gateway reports
|
|
445
|
+
// an opaque profile ARN, and the fix for that is learned from observed runs —
|
|
446
|
+
// which needs MIN_VOTES of them before it can conclude anything. Below that
|
|
447
|
+
// threshold an unresolved run is not a problem, it is the first day of a
|
|
448
|
+
// working mechanism, and warning about it makes a fresh install look broken.
|
|
449
|
+
// Tied to MIN_VOTES rather than a literal so the two cannot drift: the chip
|
|
450
|
+
// appears exactly when learning has had its chance and still came up short.
|
|
446
451
|
const unresolvedRuns = Number(data.unresolvedRuns) || 0;
|
|
447
452
|
const delegateSeg = delegationSaved > 0
|
|
448
453
|
? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
|
|
449
|
-
: (unresolvedRuns
|
|
454
|
+
: (unresolvedRuns >= MIN_VOTES
|
|
450
455
|
? `${c(YELLOW)}${g.routing} ${unresolvedRuns} unresolved${c(RESET)}`
|
|
451
456
|
: null);
|
|
452
457
|
|
|
@@ -750,7 +755,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
750
755
|
const src = info.source === 'user' ? ' (user)' : '';
|
|
751
756
|
if (isIcon) {
|
|
752
757
|
const labelPart = labels.usageLabel ? `${labels.usageLabel}${src} ` : '';
|
|
753
|
-
const bar = gaugeColored(pct, c, tone, color);
|
|
758
|
+
const bar = gaugeColored(pct, c, tone, color, mode);
|
|
754
759
|
const winGlyph = mode === 'narrow' ? (labels.narrowIcon || labels.icon) : labels.icon;
|
|
755
760
|
return `${c(tone)}${winGlyph} ${labelPart}${bar} ${pct}%${money}${tail}${c(RESET)}`;
|
|
756
761
|
}
|
|
@@ -787,7 +792,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
787
792
|
}
|
|
788
793
|
}
|
|
789
794
|
|
|
790
|
-
const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon, g, color);
|
|
795
|
+
const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon, g, color, mode);
|
|
791
796
|
|
|
792
797
|
// Warning chip leads — a glance at the statusline catches "something's wrong"
|
|
793
798
|
// before parsing any numbers. Healthy states have no chip and look unchanged.
|
package/src/glyphs.js
CHANGED
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
* Per-window glyphs (the 5h star, the weekly grid, the budget box) live in
|
|
20
20
|
* src/window-labels.js instead, next to the labels they belong to.
|
|
21
21
|
*
|
|
22
|
+
* The gauge cells live here too, for the same reason the chip glyphs do. The
|
|
23
|
+
* shapes differ by mode rather than only the vocabulary: ▰▱ read as a row of
|
|
24
|
+
* separate ticks, which is what a gauge is, but JetBrains Mono ships neither
|
|
25
|
+
* (verified via its cmap: U+25B0 and U+25B1 are absent, U+25A0 and U+25A1 are
|
|
26
|
+
* present). So the narrow set draws the same segmented shape with ■□ instead of
|
|
27
|
+
* falling back to a font whose advance width would garble the line.
|
|
28
|
+
*
|
|
22
29
|
* Width still matters as a second filter — a fullwidth glyph would occupy two
|
|
23
30
|
* cells — so every narrow glyph is also East Asian Width N/Na. The test suite
|
|
24
31
|
* enforces both properties.
|
|
@@ -40,6 +47,12 @@ const ICON = {
|
|
|
40
47
|
ctx: '📦',
|
|
41
48
|
saved: '💰',
|
|
42
49
|
reset: '🔄',
|
|
50
|
+
// Gauge cells. Separate ticks rather than one continuous block: the bar is a
|
|
51
|
+
// count of how much of a budget is gone, and ticks are countable where a solid
|
|
52
|
+
// run is not. Precision is not this bar's job — an exact percentage is printed
|
|
53
|
+
// immediately after it in every chip that draws one.
|
|
54
|
+
gaugeFull: '▰',
|
|
55
|
+
gaugeEmpty: '▱',
|
|
43
56
|
};
|
|
44
57
|
|
|
45
58
|
/**
|
|
@@ -62,6 +75,10 @@ const NARROW = {
|
|
|
62
75
|
ctx: '◧', // a box filling up
|
|
63
76
|
saved: '✓',
|
|
64
77
|
reset: '↩', // the window turning over
|
|
78
|
+
// Same segmented shape as the icon set, in the two glyphs this font has.
|
|
79
|
+
// ▰▱ (U+25B0/25B1) are absent from JetBrains Mono; ■□ (U+25A0/25A1) are there.
|
|
80
|
+
gaugeFull: '■',
|
|
81
|
+
gaugeEmpty: '□',
|
|
65
82
|
};
|
|
66
83
|
|
|
67
84
|
const SETS = { icon: ICON, narrow: NARROW };
|
package/src/installer.js
CHANGED
|
@@ -244,8 +244,11 @@ export function installSessionStartHook() {
|
|
|
244
244
|
return { path: file, action: 'skipped', reason: 'hooks.SessionStart is not an array — fix settings.json manually' };
|
|
245
245
|
}
|
|
246
246
|
const list = Array.isArray(settings.hooks.SessionStart) ? settings.hooks.SessionStart : [];
|
|
247
|
+
// Match the flag exactly. `route-scan --hook-delegated` contains
|
|
248
|
+
// `route-scan --hook` as a prefix, so a substring test would see that hook and
|
|
249
|
+
// conclude this one is already installed.
|
|
247
250
|
const already = list.some((m) =>
|
|
248
|
-
Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' &&
|
|
251
|
+
Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && /route-scan --hook(?![\w-])/.test(h.command)),
|
|
249
252
|
);
|
|
250
253
|
if (already) return { path: file, action: 'exists' };
|
|
251
254
|
|
|
@@ -263,6 +266,51 @@ export function installSessionStartHook() {
|
|
|
263
266
|
// conversation — the model cannot see statusline chips, so without this a
|
|
264
267
|
// change that happens mid-session goes unexplained until the user asks.
|
|
265
268
|
// Silent (no output, zero context cost) when nothing changed. Idempotent.
|
|
269
|
+
/**
|
|
270
|
+
* PostToolUse hook for Task/Agent. The statusline's `Routing saved` figure comes
|
|
271
|
+
* from a cached scan, and the gate in front of that scan holds a fresh
|
|
272
|
+
* delegation back an hour at best. That gate is there to skip scans that would
|
|
273
|
+
* produce identical output; a delegation is precisely the event that changes it,
|
|
274
|
+
* so this hook rescans on the spot. Detached, so the tool call does not wait.
|
|
275
|
+
*/
|
|
276
|
+
const ROUTE_SCAN_DELEGATED_HOOK_COMMAND = 'claude-token-saver route-scan --hook-delegated';
|
|
277
|
+
|
|
278
|
+
export function installDelegationHook() {
|
|
279
|
+
const dir = claudeUserDir();
|
|
280
|
+
const file = join(dir, 'settings.json');
|
|
281
|
+
mkdirSync(dir, { recursive: true });
|
|
282
|
+
|
|
283
|
+
let settings = {};
|
|
284
|
+
if (existsSync(file)) {
|
|
285
|
+
try {
|
|
286
|
+
settings = JSON.parse(readFileSync(file, 'utf8'));
|
|
287
|
+
} catch (e) {
|
|
288
|
+
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
settings.hooks = settings.hooks || {};
|
|
293
|
+
if (settings.hooks.PostToolUse !== undefined && !Array.isArray(settings.hooks.PostToolUse)) {
|
|
294
|
+
return { path: file, action: 'skipped', reason: 'hooks.PostToolUse is not an array — fix settings.json manually' };
|
|
295
|
+
}
|
|
296
|
+
const list = Array.isArray(settings.hooks.PostToolUse) ? settings.hooks.PostToolUse : [];
|
|
297
|
+
// Same exact-flag match as the SessionStart hook above, for the same reason:
|
|
298
|
+
// a substring test is what confused `--hook` with `--hook-delegated`, and a
|
|
299
|
+
// future `--hook-delegated-batch` would confuse this one in turn.
|
|
300
|
+
const already = list.some((m) =>
|
|
301
|
+
Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && /route-scan --hook-delegated(?![\w-])/.test(h.command)),
|
|
302
|
+
);
|
|
303
|
+
if (already) return { path: file, action: 'exists' };
|
|
304
|
+
|
|
305
|
+
list.push({
|
|
306
|
+
matcher: 'Task|Agent',
|
|
307
|
+
hooks: [{ type: 'command', command: ROUTE_SCAN_DELEGATED_HOOK_COMMAND, timeout: 10 }],
|
|
308
|
+
});
|
|
309
|
+
settings.hooks.PostToolUse = list;
|
|
310
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
311
|
+
return { path: file, action: 'created' };
|
|
312
|
+
}
|
|
313
|
+
|
|
266
314
|
const BRIEF_HOOK_COMMAND = 'claude-token-saver brief --hook';
|
|
267
315
|
|
|
268
316
|
export function installBriefHook() {
|
|
@@ -623,6 +671,7 @@ export function installAll({ force = false } = {}) {
|
|
|
623
671
|
skill: installSkill({ force }),
|
|
624
672
|
statusline: installStatusline({ force }),
|
|
625
673
|
sessionStartHook: installSessionStartHook(),
|
|
674
|
+
delegationHook: installDelegationHook(),
|
|
626
675
|
briefHook: installBriefHook(),
|
|
627
676
|
cacheMonitorMigration: migrateLegacyCacheMonitorHook(),
|
|
628
677
|
legacy: removeLegacyCommand(),
|
package/src/litellm-budget.js
CHANGED
|
@@ -23,6 +23,7 @@ import { join } from 'node:path';
|
|
|
23
23
|
import { homedir } from 'node:os';
|
|
24
24
|
import { userDataDir } from './paths.js';
|
|
25
25
|
import { gaugeBar, formatMoney } from './formatters/statusline.js';
|
|
26
|
+
import { labelForKey } from './window-labels.js';
|
|
26
27
|
import { formatResetClock } from './format-time.js';
|
|
27
28
|
import { cliEntryPath } from './update-check.js';
|
|
28
29
|
import { debug } from './debug.js';
|
|
@@ -155,11 +156,16 @@ export function budgetWindow(env = process.env) {
|
|
|
155
156
|
* 같은 모양(비주얼 바 + 백분율 + 금액)을 쓰되, 통계선에서는 폭이 부족해 생략하는
|
|
156
157
|
* 잔여 금액과 예산 출처, 캐시 신선도를 함께 적습니다.
|
|
157
158
|
*
|
|
159
|
+
* `mode` 는 통계선과 같은 판정 결과를 받습니다. 이 보고서도 결국 터미널에
|
|
160
|
+
* 출력되므로, IntelliJ 에서 실행하면 폰트에 없는 글리프가 대체 폰트로 그려져
|
|
161
|
+
* 통계선과 똑같이 어긋납니다. 게이지 눈금과 예산 아이콘을 함께 모드에 맞춥니다.
|
|
162
|
+
*
|
|
158
163
|
* @param {object} state - readBudgetState() 가 돌려준 캐시 상태
|
|
159
164
|
* @param {Date} [now]
|
|
165
|
+
* @param {string} [mode] - 라벨 모드 ('icon' | 'narrow' | 'text')
|
|
160
166
|
* @returns {string[]} 출력할 줄들
|
|
161
167
|
*/
|
|
162
|
-
export function formatBudgetReport(state, now = new Date()) {
|
|
168
|
+
export function formatBudgetReport(state, now = new Date(), mode = 'icon') {
|
|
163
169
|
const max = Number(state?.maxBudget);
|
|
164
170
|
const spend = Number(state?.spend);
|
|
165
171
|
if (!Number.isFinite(max) || max <= 0) {
|
|
@@ -169,8 +175,16 @@ export function formatBudgetReport(state, now = new Date()) {
|
|
|
169
175
|
const used = Number.isFinite(spend) ? Math.max(0, spend) : 0;
|
|
170
176
|
const left = Math.max(0, max - used);
|
|
171
177
|
const pct = Math.min(100, (used / max) * 100);
|
|
178
|
+
const win = labelForKey('litellm_budget');
|
|
179
|
+
// 통계선의 text 모드는 게이지를 아예 그리지 않지만 이 보고서는 그립니다. text 는
|
|
180
|
+
// 이모지를 피하려고 고른 모드이므로, 그 선택을 존중해 눈금도 폰트 커버리지가
|
|
181
|
+
// 확인된 narrow 쪽 글리프를 씁니다. 라벨은 아이콘 없이 이름만 적습니다.
|
|
182
|
+
const bare = mode === 'text';
|
|
183
|
+
const tickMode = mode === 'icon' ? 'icon' : 'narrow';
|
|
184
|
+
const icon = bare ? '' : (mode === 'narrow' ? (win.narrowIcon || win.icon) : win.icon);
|
|
185
|
+
const head = bare ? 'budget' : `${icon} budget`;
|
|
172
186
|
const lines = [
|
|
173
|
-
|
|
187
|
+
`${head} ${gaugeBar(pct, tickMode)} ${Math.round(pct)}% ${formatMoney(used)}/${formatMoney(max)}`,
|
|
174
188
|
` 사용 ${formatMoney(used)} · 잔여 ${formatMoney(left)} (${(100 - pct).toFixed(1)}%)`,
|
|
175
189
|
];
|
|
176
190
|
const resetMs = Number(state?.budgetResetAt);
|
package/src/route-inject.js
CHANGED
|
@@ -36,9 +36,12 @@ const MIN_LEN = 8;
|
|
|
36
36
|
* @param {object} [opts]
|
|
37
37
|
* @param {Array} [opts.rules] registered rules (defaults to the stored registry)
|
|
38
38
|
* @param {string} [opts.lang] 'ko' | 'en'
|
|
39
|
+
* @param {string} [opts.root] project root used to look for a subagent's .md.
|
|
40
|
+
* The hint names the agent when that file exists and only its model tier when
|
|
41
|
+
* it does not, so a caller that needs a predictable phrase pins this.
|
|
39
42
|
* @returns {string|null} the line to inject, or null to stay quiet
|
|
40
43
|
*/
|
|
41
|
-
export function routeHint(text, { rules, lang = userLanguage() } = {}) {
|
|
44
|
+
export function routeHint(text, { rules, lang = userLanguage(), root } = {}) {
|
|
42
45
|
const t = String(text || '').trim();
|
|
43
46
|
if (t.length < MIN_LEN) return null;
|
|
44
47
|
// Judgement and irreversible work stay on the top tier. This check comes
|
|
@@ -57,7 +60,12 @@ export function routeHint(text, { rules, lang = userLanguage() } = {}) {
|
|
|
57
60
|
const t1 = matched.find((r) => r.tier === 'T1');
|
|
58
61
|
const ko = lang === 'ko';
|
|
59
62
|
const label = ko ? (cat.label || cat.id) : (cat.labelEn || cat.label || cat.id);
|
|
60
|
-
|
|
63
|
+
// `root` is threaded through to agentPhrase, which names the subagent only
|
|
64
|
+
// when its .md actually exists on this machine. A caller that pins it can
|
|
65
|
+
// therefore get a deterministic phrase; a test that does not pin it reads
|
|
66
|
+
// whatever the developer happens to have installed.
|
|
67
|
+
const raw = ko ? agentPhrase : agentPhraseEn;
|
|
68
|
+
const phrase = (name) => raw(name, root === undefined ? undefined : { root });
|
|
61
69
|
|
|
62
70
|
let target;
|
|
63
71
|
if (t2 && t1) {
|
package/src/route-scan.js
CHANGED
|
@@ -67,6 +67,18 @@ export const RESCAN_MIN_INTERVAL_MS = 60 * 60 * 1000; // never more than ho
|
|
|
67
67
|
export const RESCAN_BIG_DELTA_BYTES = 5 * 1024 * 1024; // this much new data → rescan now
|
|
68
68
|
export const RESCAN_MAX_AGE_MS = 24 * 60 * 60 * 1000; // any new data + a day old → rescan
|
|
69
69
|
|
|
70
|
+
// One event is exempt from all three: a delegation that just finished. The
|
|
71
|
+
// clauses above exist to skip scans that would produce identical output, and a
|
|
72
|
+
// delegation is the one thing guaranteed to change it — savings and the rule's
|
|
73
|
+
// own error rate both move. Its transcript is nowhere near 5MB, so the gate
|
|
74
|
+
// would otherwise hold the figure back an hour at best and a day at worst,
|
|
75
|
+
// which is exactly the window in which the user is asking whether delegating
|
|
76
|
+
// paid off. Measured on this machine: a scan is ~0.5s over 14 days / 67MB, and
|
|
77
|
+
// delegations run ~1.6×/day, so the exemption costs well under a second a day.
|
|
78
|
+
// A short floor still applies, so a fan-out of parallel agents finishing
|
|
79
|
+
// together triggers one scan rather than one per agent.
|
|
80
|
+
export const RESCAN_AFTER_DELEGATION_MS = 30 * 1000;
|
|
81
|
+
|
|
70
82
|
// Categories → recommended subagent. Classification is behavior-first with
|
|
71
83
|
// weighted keyword scoring as fallback — see categorize().
|
|
72
84
|
const PASTE_MIN_LEN = 400;
|
|
@@ -740,12 +752,23 @@ export function readRouteScan() {
|
|
|
740
752
|
/**
|
|
741
753
|
* Data-driven rescan gate (see constants above). Cheap: one stat() per
|
|
742
754
|
* transcript file (~32 files on measured data) — a few milliseconds.
|
|
755
|
+
*
|
|
756
|
+
* @param {object|null} cache - readRouteScan() result
|
|
757
|
+
* @param {object} [opts]
|
|
758
|
+
* @param {number} [opts.days] - analysis window
|
|
759
|
+
* @param {boolean} [opts.afterDelegation] - a delegation just finished, so this
|
|
760
|
+
* scan is known to produce different numbers. Skips the byte and age clauses
|
|
761
|
+
* and applies only the anti-thrash floor.
|
|
762
|
+
* @returns {Promise<boolean>}
|
|
743
763
|
*/
|
|
744
|
-
export async function shouldRescan(cache, { days = 14 } = {}) {
|
|
764
|
+
export async function shouldRescan(cache, { days = 14, afterDelegation = false } = {}) {
|
|
745
765
|
if (!cache?.scannedAt) return true;
|
|
746
766
|
const ts = Date.parse(cache.scannedAt);
|
|
747
767
|
if (!Number.isFinite(ts)) return true;
|
|
748
768
|
const age = Date.now() - ts;
|
|
769
|
+
// A delegation just finished, so the numbers this scan produces are known to
|
|
770
|
+
// differ. Only the anti-thrash floor applies — see RESCAN_AFTER_DELEGATION_MS.
|
|
771
|
+
if (afterDelegation) return age >= RESCAN_AFTER_DELEGATION_MS;
|
|
749
772
|
if (age < RESCAN_MIN_INTERVAL_MS) return false;
|
|
750
773
|
|
|
751
774
|
let total = 0;
|
package/src/seed-rules.js
CHANGED
|
@@ -243,42 +243,54 @@ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot()
|
|
|
243
243
|
const lines = [];
|
|
244
244
|
if (lang === 'ko') {
|
|
245
245
|
lines.push(`[claude-token-saver seed] 아직 등록되지 않은 추천 룰이 ${pending.length}건 있습니다 (패키지 동봉 프리셋).`);
|
|
246
|
-
lines.push('**이 세션의 첫 응답에서 다른 작업보다 먼저
|
|
247
|
-
lines.push(
|
|
248
|
-
lines.push('
|
|
246
|
+
lines.push('**이 세션의 첫 응답에서 다른 작업보다 먼저 물어보십시오.** 사용자가 먼저 꺼낸 용건이 있으면 그 답변을 마친 직후에 이어서 묻고, 용건이 끝나기를 기다리지 마십시오. 사용자가 이 안내 문구에 대해 질문할 때까지 미루면 안 됩니다.');
|
|
247
|
+
lines.push(`첫 질문에서 다음 네 선택지를 **이 순서대로** 제시하십시오. ${pending.length}건을 한 건씩 답하는 것은 대부분의 사용자에게 과한 부담이므로, 일괄 등록이 맨 앞에 옵니다.`);
|
|
248
|
+
lines.push(' 1. 전부 글로벌로 등록 — `seed accept all --global`');
|
|
249
|
+
lines.push(' 2. 전부 이 프로젝트만 등록 — `seed accept all --project`');
|
|
250
|
+
lines.push(' 3. 한 건씩 보면서 결정 — 아래 목록을 순서대로 하나씩 묻고, 답을 받는 즉시 해당 명령을 실행합니다');
|
|
251
|
+
lines.push(' 4. 전부 등록하지 않음 — `seed skip all` (다시 묻지 않습니다)');
|
|
252
|
+
lines.push('3번을 고른 경우에만 한 건씩 진행하고, 그때도 적용 범위는 **글로벌(모든 프로젝트)을 먼저, 이 프로젝트만을 그다음**으로 제시하십시오.');
|
|
249
253
|
if (models.length > 0) {
|
|
250
254
|
lines.push(` · 모델 피팅 룰 ${models.length}건 — 등록하면 해당 유형 작업이 더 싼 서브에이전트로 위임됩니다.`);
|
|
251
255
|
for (const s of models) {
|
|
252
256
|
lines.push(` [${s.id}] ${s.tier} · "${s.label}" → ${s.agent}`);
|
|
253
|
-
|
|
257
|
+
// No wrapping quotes: the composed rule text already opens with the
|
|
258
|
+
// quoted work-type name, so adding a pair produced `""명령 실행 …`.
|
|
259
|
+
lines.push(` ${s.ruleText}`);
|
|
254
260
|
}
|
|
255
261
|
}
|
|
256
262
|
if (fixes.length > 0) {
|
|
257
263
|
lines.push(` · 래칫 룰 ${fixes.length}건 — 같은 실수를 반복하지 않도록 세션마다 읽히는 규칙입니다.`);
|
|
258
264
|
for (const s of fixes) lines.push(` [${s.id}] ${s.ruleText}`);
|
|
259
265
|
}
|
|
260
|
-
lines.push('
|
|
261
|
-
lines.push('
|
|
262
|
-
lines.push('
|
|
266
|
+
lines.push(' 일괄: claude-token-saver seed accept all --global|--project # 적용 범위는 반드시 사용자에게 확인');
|
|
267
|
+
lines.push(' 개별: claude-token-saver seed accept <id> --global|--project');
|
|
268
|
+
lines.push(' 거절: claude-token-saver seed skip <id>|all # 다시 묻지 않습니다');
|
|
263
269
|
} else {
|
|
264
270
|
lines.push(`[claude-token-saver seed] ${pending.length} recommended rule(s) from the bundled presets are not registered yet.`);
|
|
265
|
-
lines.push('**Ask
|
|
266
|
-
lines.push(
|
|
267
|
-
lines.push('
|
|
271
|
+
lines.push('**Ask in your very first reply of this session, before anything else.** If the user opened with their own request, answer it and then ask right away; do not wait for their task to finish, and never wait until they ask about this notice.');
|
|
272
|
+
lines.push(`Offer these four choices, **in this order**. Answering ${pending.length} rules one by one is more than most users want to do, so registering them all comes first.`);
|
|
273
|
+
lines.push(' 1. Register all, globally — `seed accept all --global`');
|
|
274
|
+
lines.push(' 2. Register all, this project only — `seed accept all --project`');
|
|
275
|
+
lines.push(' 3. Decide one at a time — walk the list below in order, running each command as soon as they answer');
|
|
276
|
+
lines.push(' 4. Register none — `seed skip all` (never offered again)');
|
|
277
|
+
lines.push('Only go rule-by-rule if they pick 3, and even then offer **global (all projects) first, this project only second**.');
|
|
268
278
|
if (models.length > 0) {
|
|
269
279
|
lines.push(` · ${models.length} model-fitting rule(s) — once registered, that kind of work goes to a cheaper subagent.`);
|
|
270
280
|
for (const s of models) {
|
|
271
281
|
lines.push(` [${s.id}] ${s.tier} · "${s.label}" → ${s.agent}`);
|
|
272
|
-
|
|
282
|
+
// No wrapping quotes: the composed rule text already opens with the
|
|
283
|
+
// quoted work-type name, so adding a pair produced `""명령 실행 …`.
|
|
284
|
+
lines.push(` ${s.ruleText}`);
|
|
273
285
|
}
|
|
274
286
|
}
|
|
275
287
|
if (fixes.length > 0) {
|
|
276
288
|
lines.push(` · ${fixes.length} ratchet rule(s) — read at the start of every session so the same mistake is not repeated.`);
|
|
277
289
|
for (const s of fixes) lines.push(` [${s.id}] ${s.ruleText}`);
|
|
278
290
|
}
|
|
279
|
-
lines.push('
|
|
280
|
-
lines.push('
|
|
281
|
-
lines.push('
|
|
291
|
+
lines.push(' all: claude-token-saver seed accept all --global|--project # ALWAYS confirm the scope with the user');
|
|
292
|
+
lines.push(' one: claude-token-saver seed accept <id> --global|--project');
|
|
293
|
+
lines.push(' decline: claude-token-saver seed skip <id>|all # never offered again');
|
|
282
294
|
}
|
|
283
295
|
return lines.join('\n');
|
|
284
296
|
}
|
package/src/update-check.js
CHANGED
|
@@ -10,7 +10,16 @@
|
|
|
10
10
|
*
|
|
11
11
|
* State lives next to the other user-data files:
|
|
12
12
|
* { checkedAt: <ms>, latest: "3.25.0", current: "3.24.0",
|
|
13
|
-
* dismissedVersion: "3.25.0"|undefined
|
|
13
|
+
* dismissedVersion: "3.25.0"|undefined,
|
|
14
|
+
* highlights: ["…", "…"]|undefined, highlightsFor: "3.25.0"|undefined }
|
|
15
|
+
*
|
|
16
|
+
* `highlights` is what the new version actually adds, read from the GitHub
|
|
17
|
+
* release for that tag. A bare "v3.24 → v3.25 is available" gives the user
|
|
18
|
+
* nothing to weigh: the answer to "should I upgrade" is in what changed, and
|
|
19
|
+
* asking them to go find that themselves is how an upgrade notice becomes
|
|
20
|
+
* something to dismiss. The release body is fetched on the same detached,
|
|
21
|
+
* once-a-day pass as the version check, and its absence never blocks the
|
|
22
|
+
* notice — a version number alone is still worth saying.
|
|
14
23
|
*
|
|
15
24
|
* Opt out with CTS_NO_UPDATE_CHECK=1 or NO_UPDATE_NOTIFIER (the de-facto
|
|
16
25
|
* standard env var — anyone who set it for other CLIs meant us too).
|
|
@@ -102,6 +111,9 @@ export function updateStatus(currentVersion) {
|
|
|
102
111
|
// means "ask me again in five minutes", forever.
|
|
103
112
|
dismissed: available && s.dismissedVersion === latest,
|
|
104
113
|
stale: age >= CHECK_INTERVAL_MS,
|
|
114
|
+
// Only when they describe THIS version. A cache written for an earlier
|
|
115
|
+
// release would otherwise sell the wrong upgrade.
|
|
116
|
+
highlights: Array.isArray(s.highlights) && s.highlightsFor === latest ? s.highlights : [],
|
|
105
117
|
};
|
|
106
118
|
}
|
|
107
119
|
|
|
@@ -143,6 +155,116 @@ export function cliEntryPath() {
|
|
|
143
155
|
return join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'cli.js');
|
|
144
156
|
}
|
|
145
157
|
|
|
158
|
+
// Release notes come from the repository's own releases, because the registry
|
|
159
|
+
// does not carry them: `/latest` answers with package metadata and no changelog
|
|
160
|
+
// of any kind. Unauthenticated reads are allowed at 60/hour, and this runs once
|
|
161
|
+
// a day behind the same interval as the version check, so the budget is ample.
|
|
162
|
+
const RELEASES_API = 'https://api.github.com/repos/rootstudioyaml/sprag/releases/tags';
|
|
163
|
+
const MAX_HIGHLIGHTS = 3;
|
|
164
|
+
const MAX_HIGHLIGHT_LEN = 110;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The headline items of a release body, as short single lines.
|
|
168
|
+
*
|
|
169
|
+
* Release notes are written for people reading a web page: headings, prose
|
|
170
|
+
* paragraphs, links, and bold runs. A session-start notice has one line per
|
|
171
|
+
* item and no room for any of that, so this keeps only what reads as an item
|
|
172
|
+
* of a list and strips it to plain text. Anything that survives is a sentence
|
|
173
|
+
* the user can judge an upgrade by; anything that does not is left behind
|
|
174
|
+
* rather than truncated into nonsense.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} body - the release body as published
|
|
177
|
+
* @returns {string[]} at most MAX_HIGHLIGHTS lines, or [] when nothing fits
|
|
178
|
+
*/
|
|
179
|
+
export function releaseHighlights(body) {
|
|
180
|
+
const text = String(body || '');
|
|
181
|
+
if (!text.trim()) return [];
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
184
|
+
const line = raw.trim();
|
|
185
|
+
// Bullets only. A heading names a section, and a prose paragraph in a
|
|
186
|
+
// release body is usually the rationale rather than the change itself.
|
|
187
|
+
const m = /^[-*+]\s+(.*)$/.exec(line);
|
|
188
|
+
if (!m) continue;
|
|
189
|
+
// Markup goes first, so the sentence split below sees the text a reader
|
|
190
|
+
// would: `**Bash writes are checked.**` is one sentence, not a bold run.
|
|
191
|
+
// One exception has to be caught before that: a stop followed straight by a
|
|
192
|
+
// code span (`…checked.\`install\` migrates…`) is a sentence boundary, and
|
|
193
|
+
// stripping the backtick first leaves a lowercase letter the split ignores.
|
|
194
|
+
// A space there is what the author meant, and it costs nothing if not.
|
|
195
|
+
let item = m[1]
|
|
196
|
+
.replace(/([.。])(`)/g, '$1 $2')
|
|
197
|
+
.replace(/`([^`]*)`/g, '$1') // code spans read fine as plain text
|
|
198
|
+
.replace(/\*\*([^*]*)\*\*/g, '$1') // bold
|
|
199
|
+
.replace(/\*([^*]*)\*/g, '$1') // italics
|
|
200
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links keep their label
|
|
201
|
+
.replace(/\s+/g, ' ')
|
|
202
|
+
.trim();
|
|
203
|
+
if (!item) continue;
|
|
204
|
+
// Release notes lead with the change and follow with the reasoning, often
|
|
205
|
+
// for several sentences. The first sentence is the change, so that is what
|
|
206
|
+
// a one-line notice keeps — cutting at a character count instead lands
|
|
207
|
+
// mid-clause and reads as a broken string rather than a short one.
|
|
208
|
+
//
|
|
209
|
+
// The break does not require a space after the stop: "…checked.The matcher…"
|
|
210
|
+
// is a typo a release body can carry, and requiring one there merged the two
|
|
211
|
+
// sentences into a single over-long line. A capital letter or a Hangul
|
|
212
|
+
// syllable directly after the stop is enough to say a new sentence started;
|
|
213
|
+
// a digit is not, so "0.5s" and "v3.45.0" stay intact.
|
|
214
|
+
//
|
|
215
|
+
// Abbreviations are excluded by name. "Fixed e.g. the matcher" would
|
|
216
|
+
// otherwise end after two words, which is worse than a long line: it reads
|
|
217
|
+
// as a complete thought and is not one. This list covers what actually turns
|
|
218
|
+
// up in release prose rather than trying to be exhaustive.
|
|
219
|
+
// `etc.` is deliberately absent: it ends a sentence as often as it continues
|
|
220
|
+
// one, so either choice is wrong half the time and the wrong one here merges
|
|
221
|
+
// two sentences rather than truncating one. Merging is the milder failure,
|
|
222
|
+
// and the length cap below bounds it.
|
|
223
|
+
const ABBREV = /\b(?:e\.g|i\.e|vs|cf|Dr|Mr|Ms|St|approx|no)\.$/i;
|
|
224
|
+
const parts = item.split(/(?<=[.。])(?:\s+|(?=[A-Z가-힣]))/);
|
|
225
|
+
let first = parts[0];
|
|
226
|
+
for (let k = 1; k < parts.length && ABBREV.test(first); k += 1) {
|
|
227
|
+
// The stop belonged to an abbreviation, so the sentence continues.
|
|
228
|
+
first = `${first} ${parts[k]}`;
|
|
229
|
+
}
|
|
230
|
+
item = first || item;
|
|
231
|
+
// Some first sentences are themselves a paragraph. Those get cut, but at a
|
|
232
|
+
// word boundary and with the ellipsis that says so.
|
|
233
|
+
if (item.length > MAX_HIGHLIGHT_LEN) {
|
|
234
|
+
const cut = item.slice(0, MAX_HIGHLIGHT_LEN);
|
|
235
|
+
const space = cut.lastIndexOf(' ');
|
|
236
|
+
item = (space > 40 ? cut.slice(0, space) : cut).replace(/[,;:—\-]$/, '').trim() + '…';
|
|
237
|
+
}
|
|
238
|
+
out.push(item);
|
|
239
|
+
if (out.length >= MAX_HIGHLIGHTS) break;
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Fetch the highlights for one version. Returns [] on any failure: the notice
|
|
246
|
+
* is worth showing with a bare version number, so a missing release, a rate
|
|
247
|
+
* limit, or an offline machine must not cost the user the notice itself.
|
|
248
|
+
*/
|
|
249
|
+
export async function fetchHighlights(version, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
252
|
+
try {
|
|
253
|
+
const res = await fetch(`${RELEASES_API}/v${version}`, {
|
|
254
|
+
signal: controller.signal,
|
|
255
|
+
headers: { accept: 'application/vnd.github+json' },
|
|
256
|
+
});
|
|
257
|
+
if (!res.ok) throw new Error(`releases responded ${res.status}`);
|
|
258
|
+
const body = await res.json();
|
|
259
|
+
return releaseHighlights(body && body.body);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
debug('update-check:highlights', e);
|
|
262
|
+
return [];
|
|
263
|
+
} finally {
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
146
268
|
/**
|
|
147
269
|
* Actually hit the registry and persist the answer. Only the detached child
|
|
148
270
|
* and the explicit `update-check --refresh` command call this.
|
|
@@ -168,6 +290,21 @@ export async function refreshUpdateState(currentVersion) {
|
|
|
168
290
|
if (prev.dismissedVersion && isNewer(latest, prev.dismissedVersion)) {
|
|
169
291
|
delete next.dismissedVersion;
|
|
170
292
|
}
|
|
293
|
+
// What the new version adds, so the notice can answer "why would I".
|
|
294
|
+
// Only worth a second request when there is actually an upgrade to describe,
|
|
295
|
+
// and only when we do not already hold the notes for that exact version.
|
|
296
|
+
if (isNewer(latest, currentVersion) && prev.highlightsFor !== latest) {
|
|
297
|
+
const highlights = await fetchHighlights(latest);
|
|
298
|
+
if (highlights.length) {
|
|
299
|
+
next.highlights = highlights;
|
|
300
|
+
next.highlightsFor = latest;
|
|
301
|
+
} else {
|
|
302
|
+
// Drop notes belonging to an older version rather than showing them
|
|
303
|
+
// against this one.
|
|
304
|
+
delete next.highlights;
|
|
305
|
+
delete next.highlightsFor;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
171
308
|
writeUpdateState(next);
|
|
172
309
|
return { ok: true, latest };
|
|
173
310
|
} catch (e) {
|