sprag-cli 3.44.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 +3 -1
- package/README.md +6 -2
- package/bin/cli.js +23 -25
- package/package.json +2 -1
- package/presets/model-rules.json +50 -9
- package/src/changelog.js +34 -0
- package/src/commands/brief.js +13 -1
- package/src/commands/install.js +1 -0
- package/src/commands/mode.js +22 -2
- package/src/commands/route-scan.js +66 -7
- package/src/config.js +22 -3
- package/src/formatters/statusline.js +279 -119
- package/src/glyphs.js +95 -0
- package/src/installer.js +50 -1
- package/src/litellm-budget.js +16 -2
- package/src/model-rules.js +53 -17
- package/src/route-inject.js +94 -0
- package/src/route-scan.js +24 -1
- package/src/seed-rules.js +26 -14
- package/src/statusline-mode.js +95 -0
- package/src/update-check.js +138 -1
- package/src/window-labels.js +17 -7
package/src/glyphs.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statusline glyph sets, one per label mode.
|
|
3
|
+
*
|
|
4
|
+
* Why a narrow set exists at all: IntelliJ's terminal draws a character its
|
|
5
|
+
* configured font lacks through a fallback font, and the fallback's advance
|
|
6
|
+
* width does not match the cell grid. The mismatch is invisible on static text,
|
|
7
|
+
* but a segment whose value changes every second (the TTL countdown, a gauge
|
|
8
|
+
* percentage) repaints partially, and the leftover characters read as garbage:
|
|
9
|
+
* `Cache expires 4:54` renders as `4:545`, `$4.0K` as `$4.:0K`. Dragging a
|
|
10
|
+
* selection forces a full repaint and the line looks correct again, which is the
|
|
11
|
+
* tell that the buffer was always right and only the painting was off.
|
|
12
|
+
*
|
|
13
|
+
* Measured on this machine (JetBrainsMono-Regular.ttf, the IDE default):
|
|
14
|
+
* every emoji the icon set uses is absent from the font, while the gauge
|
|
15
|
+
* characters (█ ▉ … ░) are present — and the gauge is the one part of the line
|
|
16
|
+
* that never garbled. So the narrow set is chosen by font coverage, not by
|
|
17
|
+
* Unicode width: each glyph below is one JetBrains Mono actually ships.
|
|
18
|
+
*
|
|
19
|
+
* Per-window glyphs (the 5h star, the weekly grid, the budget box) live in
|
|
20
|
+
* src/window-labels.js instead, next to the labels they belong to.
|
|
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
|
+
*
|
|
29
|
+
* Width still matters as a second filter — a fullwidth glyph would occupy two
|
|
30
|
+
* cells — so every narrow glyph is also East Asian Width N/Na. The test suite
|
|
31
|
+
* enforces both properties.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Emoji set: what a terminal with proper emoji support should show. */
|
|
35
|
+
const ICON = {
|
|
36
|
+
capWarn: '🚨',
|
|
37
|
+
spike: '⚠',
|
|
38
|
+
upgrade: '⬆',
|
|
39
|
+
harness: '🅷',
|
|
40
|
+
korean: '✍️',
|
|
41
|
+
model: '🤖',
|
|
42
|
+
routing: '🔀',
|
|
43
|
+
doc: '📄',
|
|
44
|
+
hit: '🧠',
|
|
45
|
+
ttl: '⏳',
|
|
46
|
+
month: '💵',
|
|
47
|
+
ctx: '📦',
|
|
48
|
+
saved: '💰',
|
|
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: '▱',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Narrow set: every glyph is present in JetBrains Mono and one cell wide.
|
|
60
|
+
* Shapes were picked to stay distinguishable from each other at a glance, since
|
|
61
|
+
* compact mode shows the glyph with no label beside it.
|
|
62
|
+
*/
|
|
63
|
+
const NARROW = {
|
|
64
|
+
capWarn: '‼', // doubled bang reads as an alarm
|
|
65
|
+
spike: '⚠', // the one emoji-adjacent glyph the font does ship
|
|
66
|
+
upgrade: '↑',
|
|
67
|
+
harness: '⍟', // circled star: a score out of five
|
|
68
|
+
korean: '⌨', // an input/typing convention rather than a hand
|
|
69
|
+
model: '◈',
|
|
70
|
+
routing: '⇉', // sent down a different path
|
|
71
|
+
doc: '≣', // stacked lines: a page of text
|
|
72
|
+
hit: '◉', // bullseye: a cache hit
|
|
73
|
+
ttl: '◔', // a partly elapsed dial
|
|
74
|
+
month: '∑', // a running total
|
|
75
|
+
ctx: '◧', // a box filling up
|
|
76
|
+
saved: '✓',
|
|
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: '□',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const SETS = { icon: ICON, narrow: NARROW };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Glyphs for a label mode. Text mode never reads a glyph, but it resolves to the
|
|
88
|
+
* emoji set so a caller that asks anyway gets a complete object rather than
|
|
89
|
+
* undefined lookups.
|
|
90
|
+
*/
|
|
91
|
+
export function glyphsFor(mode) {
|
|
92
|
+
return SETS[mode] || ICON;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { ICON as ICON_GLYPHS, NARROW as NARROW_GLYPHS };
|
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/model-rules.js
CHANGED
|
@@ -94,10 +94,14 @@ export function budgetCapPhrase(rule, lang = userLanguage()) {
|
|
|
94
94
|
*/
|
|
95
95
|
export function composeRuleText(baseText, rule, lang = userLanguage()) {
|
|
96
96
|
const cap = budgetCapPhrase(rule, lang);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
// Only the cap. The stop condition and the same-tier skip used to repeat in
|
|
98
|
+
// every rule; they are identical across all of them, so they now live once in
|
|
99
|
+
// the file header's shared-clause section. A rule the model re-reads on every
|
|
100
|
+
// request should be one line it can match against, not four clauses it has to
|
|
101
|
+
// re-derive — three sentences per rule made delegating cost more thought than
|
|
102
|
+
// doing the work, and measured delegation ran at 3.6% of eligible episodes.
|
|
103
|
+
const clause = lang === 'ko' ? `상한 ${cap}` : `cap ${cap}`;
|
|
104
|
+
return `${baseText.replace(/\s*$/, '')} (${clause})`;
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
/**
|
|
@@ -109,16 +113,27 @@ export function composeRuleText(baseText, rule, lang = userLanguage()) {
|
|
|
109
113
|
*
|
|
110
114
|
* `g` carries { tier, label, labelEn, example, exampleEn, agent }.
|
|
111
115
|
*/
|
|
116
|
+
/**
|
|
117
|
+
* Trim an example to something a reader takes in at a glance. Scan-derived
|
|
118
|
+
* examples are raw user messages, and some ran long enough to bury the rule that
|
|
119
|
+
* followed them.
|
|
120
|
+
*/
|
|
121
|
+
const EXAMPLE_MAX = 30;
|
|
122
|
+
function shortExample(text) {
|
|
123
|
+
const one = String(text || '').replace(/\s+/g, ' ').trim();
|
|
124
|
+
return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
|
|
125
|
+
}
|
|
126
|
+
|
|
112
127
|
export function modelRuleBaseText(g, lang = userLanguage()) {
|
|
113
128
|
if (lang === 'ko') {
|
|
114
129
|
return g.tier === 'T2'
|
|
115
|
-
? `"${g.label}"
|
|
116
|
-
: `"${g.label}"
|
|
130
|
+
? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent)}에 위임`
|
|
131
|
+
: `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 model: sonnet에 위임`;
|
|
117
132
|
}
|
|
118
133
|
const example = g.exampleEn || g.example;
|
|
119
134
|
return g.tier === 'T2'
|
|
120
|
-
? `
|
|
121
|
-
: `
|
|
135
|
+
? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent)}`
|
|
136
|
+
: `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to a model: sonnet subagent`;
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
// See the note in route-scan.js — paths.js is the only place that resolves
|
|
@@ -214,6 +229,15 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
|
|
|
214
229
|
'(어떤 도구가 토큰을 아끼고 있는지 가시화):',
|
|
215
230
|
'`🔀 [sprag] 모델 피팅: "<유형>" → <agent> 위임`',
|
|
216
231
|
'',
|
|
232
|
+
'## 공통 조항 (룰마다 반복하지 않으므로 여기서 한 번만 읽으십시오)',
|
|
233
|
+
'',
|
|
234
|
+
'- 상한을 넘길 것 같거나 에러가 나면 그 자리에서 멈추고 진행분만 보고합니다.',
|
|
235
|
+
' 메인 모델이 이어받습니다.',
|
|
236
|
+
'- 세션 모델이 위임 목표와 같은 급이거나 그보다 낮으면 위임하지 않습니다.',
|
|
237
|
+
' 절감이 발생하지 않기 때문입니다.',
|
|
238
|
+
'- 설계 판단이나 되돌릴 수 없는 작업(배포·릴리스·머지·제출)이 섞여 있으면',
|
|
239
|
+
' 위임하지 않습니다.',
|
|
240
|
+
'',
|
|
217
241
|
'## 티어 판별 기준 (모든 룰에 공통 적용)',
|
|
218
242
|
'',
|
|
219
243
|
'- **도구 오케스트레이션**: 한 도구의 결과를 다음 호출의 입력으로 잇는 다단계',
|
|
@@ -262,6 +286,15 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
|
|
|
262
286
|
'visible which tool is saving tokens:',
|
|
263
287
|
'`🔀 [sprag] model fitting: "<category>" → delegated to <agent>`',
|
|
264
288
|
'',
|
|
289
|
+
'## Shared clauses (stated once here, not repeated per rule)',
|
|
290
|
+
'',
|
|
291
|
+
'- A run likely to exceed its cap, or hitting an error, stops there and reports',
|
|
292
|
+
' partial progress. The main model takes over.',
|
|
293
|
+
'- Skip delegation when the session model is already at or below the target',
|
|
294
|
+
' tier: there is nothing to save.',
|
|
295
|
+
'- Skip it when the request mixes in design judgement or irreversible work',
|
|
296
|
+
' (deploy, release, merge, submission).',
|
|
297
|
+
'',
|
|
265
298
|
'## Tier criteria (apply to every rule below)',
|
|
266
299
|
'',
|
|
267
300
|
'- **Tool orchestration**: a multi-step flow that feeds one tool result into',
|
|
@@ -329,8 +362,8 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
|
|
|
329
362
|
// Merged T2+T1 rules carry two caps, so they state both once rather than
|
|
330
363
|
// repeating the whole stop-condition per tier.
|
|
331
364
|
const mergedBudget = (t2, t1) => ko
|
|
332
|
-
?
|
|
333
|
-
: `
|
|
365
|
+
? `상한 haiku ${budgetCapPhrase(t2, 'ko')} / sonnet ${budgetCapPhrase(t1, 'ko')}`
|
|
366
|
+
: `cap haiku ${budgetCapPhrase(t2, 'en')} / sonnet ${budgetCapPhrase(t1, 'en')}`;
|
|
334
367
|
|
|
335
368
|
// A category can carry both a T2 (haiku) and a T1 (sonnet) rule. Tier is
|
|
336
369
|
// only known after an episode finishes, so two separate bullets give the
|
|
@@ -349,15 +382,18 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
|
|
|
349
382
|
if (t2 && t1) {
|
|
350
383
|
// Seeded rules carry a localized example; scan-produced ones only have the
|
|
351
384
|
// user's own prompt, so exampleEn is a preference, not a requirement.
|
|
352
|
-
const ex = (r) => (ko ? r.example : (r.exampleEn || r.example));
|
|
385
|
+
const ex = (r) => shortExample(ko ? r.example : (r.exampleEn || r.example));
|
|
386
|
+
// One line per category, stating only what differs between the two tiers.
|
|
387
|
+
// The exceptions this used to spell out (design judgement, irreversible
|
|
388
|
+
// work) are identical in every rule and now sit in the header's shared
|
|
389
|
+
// clauses, so a rule the model re-reads on every request is short enough to
|
|
390
|
+
// match at a glance.
|
|
353
391
|
const rule = ko
|
|
354
|
-
? `"${t2.label}"
|
|
355
|
-
`여러 단계·여러
|
|
356
|
-
`설계 판단이나 배포·릴리스·스토어 제출 같은 비가역 작업이 섞여 있을 때만 위임하지 않는다. ` +
|
|
392
|
+
? `"${t2.label}": 기본 ${agentPhrase(t2.agent)}(예: "${ex(t2)}"), ` +
|
|
393
|
+
`여러 단계·여러 파일이 얽히면 model: sonnet(예: "${ex(t1)}"). ` +
|
|
357
394
|
mergedBudget(t2, t1)
|
|
358
|
-
: `
|
|
359
|
-
`
|
|
360
|
-
`Keep it on the main model only when the request mixes in design judgement or irreversible work (deploy, release, store submission). ` +
|
|
395
|
+
: `"${t2.labelEn || t2.label}": ${agentPhraseEn(t2.agent)} by default (e.g. "${ex(t2)}"), ` +
|
|
396
|
+
`model: sonnet when it spans multiple steps or file edits (e.g. "${ex(t1)}"). ` +
|
|
361
397
|
mergedBudget(t2, t1);
|
|
362
398
|
lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)} -->`);
|
|
363
399
|
for (const r of group) {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a registered delegation rule into a per-request instruction.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: rules were registered and then not used. Measured on the
|
|
5
|
+
* author's logs, 337 of 1296 episodes were delegation-eligible and 12 were
|
|
6
|
+
* delegated (3.6%); five of ten registered rules had never fired. Rule wording
|
|
7
|
+
* was not the whole story — the session also carries a general instruction not
|
|
8
|
+
* to spawn subagents unless asked, and ratchet-model.md tries to overrule it in
|
|
9
|
+
* prose. Between two instructions that disagree, the cautious one wins.
|
|
10
|
+
*
|
|
11
|
+
* So this stops arguing in prose. The hook classifies the request that just
|
|
12
|
+
* arrived and states the matching rule as a fact about THIS request, which
|
|
13
|
+
* leaves the model nothing to resolve: no rule list to scan, no self-matching
|
|
14
|
+
* step, no conflict to weigh.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately silent when it cannot be sure:
|
|
17
|
+
* - the request text does not classify (about half of them, by measurement —
|
|
18
|
+
* difficulty mostly surfaces after the first tool call, and at prompt time
|
|
19
|
+
* there are no tool calls yet)
|
|
20
|
+
* - the text trips ESCALATE_RE (design, analysis, deploy, merge …), where the
|
|
21
|
+
* top tier is the right answer and a nudge downward would be wrong
|
|
22
|
+
* - no registered rule covers that category
|
|
23
|
+
* A hint that fires on the wrong request costs more than one that stays quiet.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { categorize, ESCALATE_RE } from './route-scan.js';
|
|
27
|
+
import { loadModelRules, budgetCapPhrase } from './model-rules.js';
|
|
28
|
+
import { agentPhrase, agentPhraseEn } from './agents.js';
|
|
29
|
+
import { userLanguage } from './config.js';
|
|
30
|
+
|
|
31
|
+
/** Shortest text worth classifying; below this a prompt is an ack, not a task. */
|
|
32
|
+
const MIN_LEN = 8;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} text the prompt just submitted
|
|
36
|
+
* @param {object} [opts]
|
|
37
|
+
* @param {Array} [opts.rules] registered rules (defaults to the stored registry)
|
|
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.
|
|
42
|
+
* @returns {string|null} the line to inject, or null to stay quiet
|
|
43
|
+
*/
|
|
44
|
+
export function routeHint(text, { rules, lang = userLanguage(), root } = {}) {
|
|
45
|
+
const t = String(text || '').trim();
|
|
46
|
+
if (t.length < MIN_LEN) return null;
|
|
47
|
+
// Judgement and irreversible work stay on the top tier. This check comes
|
|
48
|
+
// before classification because such a request often looks like a light
|
|
49
|
+
// "run" or "check" in its wording.
|
|
50
|
+
if (ESCALATE_RE.test(t)) return null;
|
|
51
|
+
|
|
52
|
+
const cat = categorize(t, null);
|
|
53
|
+
if (!cat) return null;
|
|
54
|
+
|
|
55
|
+
const all = rules || loadModelRules().rules || [];
|
|
56
|
+
const matched = all.filter((r) => r && r.category === cat.id);
|
|
57
|
+
if (!matched.length) return null;
|
|
58
|
+
|
|
59
|
+
const t2 = matched.find((r) => r.tier === 'T2');
|
|
60
|
+
const t1 = matched.find((r) => r.tier === 'T1');
|
|
61
|
+
const ko = lang === 'ko';
|
|
62
|
+
const label = ko ? (cat.label || cat.id) : (cat.labelEn || cat.label || cat.id);
|
|
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 });
|
|
69
|
+
|
|
70
|
+
let target;
|
|
71
|
+
if (t2 && t1) {
|
|
72
|
+
target = ko
|
|
73
|
+
? `기본 ${phrase(t2.agent)}, 여러 단계·여러 파일이 얽히면 model: sonnet`
|
|
74
|
+
: `${phrase(t2.agent)} by default, model: sonnet when it spans multiple steps or file edits`;
|
|
75
|
+
} else {
|
|
76
|
+
const only = t2 || t1;
|
|
77
|
+
target = ko ? phrase(only.agent) : phrase(only.agent);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const caps = [t2, t1].filter(Boolean).map((r) => budgetCapPhrase(r, lang));
|
|
81
|
+
const capText = caps.length === 2
|
|
82
|
+
? (ko ? `상한 haiku ${caps[0]} / sonnet ${caps[1]}` : `cap haiku ${caps[0]} / sonnet ${caps[1]}`)
|
|
83
|
+
: (ko ? `상한 ${caps[0]}` : `cap ${caps[0]}`);
|
|
84
|
+
|
|
85
|
+
// The marker line is the one in ratchet-model.md, so the user sees the same
|
|
86
|
+
// string whichever path decided to delegate.
|
|
87
|
+
return ko
|
|
88
|
+
? `[sprag] 이 요청은 사용자가 이미 승인한 위임 룰 "${label}" 에 해당합니다: ${target} 로 위임하십시오 (${capText}). `
|
|
89
|
+
+ `되묻지 말고 위임하고, 위임할 때 \`🔀 [sprag] 모델 피팅: "${label}" → <agent> 위임\` 을 먼저 표시하십시오. `
|
|
90
|
+
+ `상한을 넘길 것 같거나 에러가 나면 그 자리에서 멈추고 메인 모델이 이어받습니다.`
|
|
91
|
+
: `[sprag] This request matches a delegation rule the user already approved — "${label}": delegate to ${target} (${capText}). `
|
|
92
|
+
+ `Do not ask again; print \`🔀 [sprag] model fit: "${label}" → <agent>\` first. `
|
|
93
|
+
+ `If the run is likely to exceed its cap or hits an error, stop there and the main model takes over.`;
|
|
94
|
+
}
|
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
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statusline label-mode resolution — the single place that decides icon vs text.
|
|
3
|
+
*
|
|
4
|
+
* The real statusline path and the `--demo` path both route through here. They
|
|
5
|
+
* used to carry separate copies of the decision and the copies drifted: only
|
|
6
|
+
* the real path applied the IntelliJ guard, so inside IntelliJ
|
|
7
|
+
* `--statusline --demo` rendered emoji while the actual statusline rendered
|
|
8
|
+
* plain text. That gap makes our own deliberate downgrade look like a terminal
|
|
9
|
+
* bug, and it cost a full diagnosis session to rule out.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const JETBRAINS_TERMINAL = 'JetBrains-JediTerm';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* IntelliJ's built-in terminal sets this for every shell it opens, whether or
|
|
16
|
+
* not the Claude Code plugin is involved.
|
|
17
|
+
*/
|
|
18
|
+
export function isJetBrainsTerminal(env = process.env) {
|
|
19
|
+
return env.TERMINAL_EMULATOR === JETBRAINS_TERMINAL;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether the user has explicitly asked for icons inside IntelliJ regardless.
|
|
24
|
+
*
|
|
25
|
+
* Scope: this lifts the IntelliJ guard and nothing else. It is not a general
|
|
26
|
+
* "always icons" switch, and outside IntelliJ it is never consulted — there is
|
|
27
|
+
* no guard there to lift, and `icon` / `text` already own that choice. Letting
|
|
28
|
+
* it win everywhere would mean `sprag mode text` could no longer turn icons off
|
|
29
|
+
* while it was set, which is a worse trade than the narrow name.
|
|
30
|
+
*
|
|
31
|
+
* The guard below exists for the IntelliJ Claude Code *plugin*, whose
|
|
32
|
+
* statusline widget fuses consecutive frames at the character level when emoji
|
|
33
|
+
* are present ("59:548"). JediTerm itself renders our output cleanly, so a user
|
|
34
|
+
* who runs `claude` straight in the built-in terminal loses icons for a bug
|
|
35
|
+
* that cannot reach them. TERMINAL_EMULATOR alone cannot tell the two apart,
|
|
36
|
+
* so the escape hatch is explicit rather than inferred:
|
|
37
|
+
*
|
|
38
|
+
* SPRAG_ICON=force one-off, e.g. while checking a render
|
|
39
|
+
* sprag mode icon-force persisted, so wrappers that hardcode --icon
|
|
40
|
+
* do not have to be edited
|
|
41
|
+
*/
|
|
42
|
+
export function iconForceRequested({ cfg = {}, env = process.env } = {}) {
|
|
43
|
+
const raw = String(env.SPRAG_ICON ?? '').trim().toLowerCase();
|
|
44
|
+
if (raw === 'force') return true;
|
|
45
|
+
return cfg.iconForce === true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The user's stored label preference, as one of the three modes.
|
|
50
|
+
*
|
|
51
|
+
* `labels` is the current key. Configs written before it existed carry a boolean
|
|
52
|
+
* `icon` instead, so that is read as a fallback: absent means icons, since icons
|
|
53
|
+
* are the default for a fresh install.
|
|
54
|
+
*/
|
|
55
|
+
export function labelPreference(cfg = {}) {
|
|
56
|
+
if (cfg.labels === 'icon' || cfg.labels === 'narrow' || cfg.labels === 'text') return cfg.labels;
|
|
57
|
+
return cfg.icon === false ? 'text' : 'icon';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the label mode.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} opts
|
|
64
|
+
* @param {(name: string) => boolean} [opts.hasFlag] argv accessor
|
|
65
|
+
* @param {object} [opts.cfg] statuslineDefaults() result
|
|
66
|
+
* @param {object} [opts.env] environment to read
|
|
67
|
+
* @returns {{ mode: 'icon'|'narrow'|'text', reason: string }} `reason` names the rule
|
|
68
|
+
* that decided, so `sprag mode` can explain a downgrade instead of leaving
|
|
69
|
+
* the user to guess why `--icon` did nothing. One of `flag:--no-icon`,
|
|
70
|
+
* `flag:--text`, `flag:--narrow`, `intellij-narrow`, `intellij-forced`,
|
|
71
|
+
* `flag:--icon`, `config`.
|
|
72
|
+
*
|
|
73
|
+
* Precedence: explicit opt-out, then the IntelliJ guard (and the escape hatch
|
|
74
|
+
* out of it, which applies only there), then `--icon`, then persisted config.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveLabelMode({ hasFlag = () => false, cfg = {}, env = process.env } = {}) {
|
|
77
|
+
// An explicit opt-out wins everywhere. Nothing below should talk a user out
|
|
78
|
+
// of the mode they just asked for. The two flags are reported separately so
|
|
79
|
+
// the diagnostic line names the flag the user actually typed.
|
|
80
|
+
if (hasFlag('--no-icon')) return { mode: 'text', reason: 'flag:--no-icon' };
|
|
81
|
+
if (hasFlag('--text')) return { mode: 'text', reason: 'flag:--text' };
|
|
82
|
+
if (hasFlag('--narrow')) return { mode: 'narrow', reason: 'flag:--narrow' };
|
|
83
|
+
const pref = labelPreference(cfg);
|
|
84
|
+
if (isJetBrainsTerminal(env)) {
|
|
85
|
+
// Emoji on request, accepting that they garble here.
|
|
86
|
+
if (iconForceRequested({ cfg, env })) return { mode: 'icon', reason: 'intellij-forced' };
|
|
87
|
+
// Someone who asked for plain text gets plain text.
|
|
88
|
+
if (pref === 'text') return { mode: 'text', reason: 'config' };
|
|
89
|
+
// Otherwise narrow: the glyphs still mark each chip, and every one of them
|
|
90
|
+
// is a character the IDE font actually has, so the line does not garble.
|
|
91
|
+
return { mode: 'narrow', reason: 'intellij-narrow' };
|
|
92
|
+
}
|
|
93
|
+
if (hasFlag('--icon')) return { mode: 'icon', reason: 'flag:--icon' };
|
|
94
|
+
return { mode: pref, reason: 'config' };
|
|
95
|
+
}
|