sprag-cli 3.45.0 → 3.46.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +1 -1
- package/README.md +2 -2
- package/bin/cli.js +6 -1
- package/examples/statusline-command.ps1 +11 -5
- package/examples/statusline-command.sh +9 -4
- package/package.json +5 -3
- package/src/changelog.js +70 -0
- package/src/cli-name.js +25 -0
- package/src/commands/install.js +1 -0
- package/src/commands/route-scan.js +66 -7
- package/src/commands/uninstall.js +5 -4
- package/src/formatters/statusline.js +72 -67
- package/src/formatters/table.js +5 -4
- package/src/glyphs.js +17 -0
- package/src/hook-manager.js +3 -2
- package/src/installer.js +132 -16
- 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 +29 -16
- package/src/update-check.js +178 -1
package/src/formatters/table.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Terminal table formatter — zero dependencies.
|
|
3
3
|
*/
|
|
4
4
|
import { ISSUE_MESSAGES } from '../advice.js';
|
|
5
|
+
import { CLI_NAME } from '../cli-name.js';
|
|
5
6
|
import { formatResetIn, formatResetClock } from '../format-time.js';
|
|
6
7
|
import { labelForKey } from '../window-labels.js';
|
|
7
8
|
|
|
@@ -139,7 +140,7 @@ function renderSpikeSection(spikes, contextWindow) {
|
|
|
139
140
|
lines.push('');
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
|
-
lines.push(rbl(' ▶ 상세 처치법: ') + rb(
|
|
143
|
+
lines.push(rbl(' ▶ 상세 처치법: ') + rb(`${CLI_NAME} last`));
|
|
143
144
|
lines.push('');
|
|
144
145
|
return lines;
|
|
145
146
|
}
|
|
@@ -165,10 +166,10 @@ function renderCapWarnSection(caps) {
|
|
|
165
166
|
}
|
|
166
167
|
lines.push('');
|
|
167
168
|
lines.push(r(' Back up work before the cap hits:'));
|
|
168
|
-
lines.push(rb(
|
|
169
|
+
lines.push(rb(` ${CLI_NAME} handoff`));
|
|
169
170
|
lines.push(r(' (writes a HANDOFF-*.md so a fresh session can pick up.)'));
|
|
170
171
|
lines.push('');
|
|
171
|
-
lines.push(rbl(' ▶ 상세 처치법: ') + rb(
|
|
172
|
+
lines.push(rbl(' ▶ 상세 처치법: ') + rb(`${CLI_NAME} last`));
|
|
172
173
|
lines.push('');
|
|
173
174
|
return lines;
|
|
174
175
|
}
|
|
@@ -179,7 +180,7 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
|
|
|
179
180
|
// Header
|
|
180
181
|
lines.push('');
|
|
181
182
|
lines.push(` Claude Token Saver — Last ${options.days} day${options.days === 1 ? '' : 's'}`);
|
|
182
|
-
lines.push(` (
|
|
183
|
+
lines.push(` (${CLI_NAME} v${options.version || ''})`.trimEnd());
|
|
183
184
|
lines.push(` ${'═'.repeat(50)}`);
|
|
184
185
|
lines.push('');
|
|
185
186
|
|
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/hook-manager.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { readFile, writeFile, rm } from 'node:fs/promises';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
|
+
import { CLI_NAME } from './cli-name.js';
|
|
15
16
|
|
|
16
17
|
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
17
18
|
// Legacy copied-file location — removed on install/uninstall so machines that
|
|
@@ -48,7 +49,7 @@ export async function installHook({ threshold = 0.7 } = {}) {
|
|
|
48
49
|
hooks: [
|
|
49
50
|
{
|
|
50
51
|
type: 'command',
|
|
51
|
-
command:
|
|
52
|
+
command: `${CLI_NAME} --hook-run --threshold ${threshold}`,
|
|
52
53
|
timeout: 10,
|
|
53
54
|
},
|
|
54
55
|
],
|
|
@@ -59,7 +60,7 @@ export async function installHook({ threshold = 0.7 } = {}) {
|
|
|
59
60
|
// Clean up the legacy copy left by older versions.
|
|
60
61
|
await rm(LEGACY_HOOK_DEST, { force: true }).catch(() => {});
|
|
61
62
|
|
|
62
|
-
console.log(
|
|
63
|
+
console.log(`✓ Hook installed (PostToolUse → ${CLI_NAME} --hook-run)`);
|
|
63
64
|
console.log(` Settings updated: ${SETTINGS_PATH}`);
|
|
64
65
|
console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
|
|
65
66
|
console.log(` Stats file: ~/.claude/cache-stats.jsonl`);
|
package/src/installer.js
CHANGED
|
@@ -14,10 +14,64 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync, rmSync } from 'node:fs';
|
|
17
|
+
import { CLI_NAME, LEGACY_CLI_NAME } from './cli-name.js';
|
|
17
18
|
import { join } from 'node:path';
|
|
18
19
|
import { claudeUserDir, userDataDir } from './paths.js';
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The command every entry we write invokes.
|
|
23
|
+
*
|
|
24
|
+
* Both binaries ship in `package.json`'s `bin`, so either name works — but the
|
|
25
|
+
* package is `sprag-cli`, and a fresh install writing `claude-token-saver` into
|
|
26
|
+
* the user's settings puts a name they never typed in a file they read.
|
|
27
|
+
*
|
|
28
|
+
* The reverse direction is not symmetrical: entries ALREADY on disk carry the
|
|
29
|
+
* old name, so anything that recognises our own work has to accept both. That is
|
|
30
|
+
* what `isOurCommand` is for — narrowing it to the new name would make install
|
|
31
|
+
* miss an existing hook and add a second one beside it.
|
|
32
|
+
*/
|
|
33
|
+
const CLI = CLI_NAME;
|
|
34
|
+
const LEGACY_CLI = LEGACY_CLI_NAME;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a settings command belongs to this tool, under either name.
|
|
38
|
+
*
|
|
39
|
+
* The name has to sit where the executable goes, not just appear somewhere in the
|
|
40
|
+
* string. `sprag` is five characters, and `uninstall` deletes what this returns
|
|
41
|
+
* true for: a substring test claims `node ~/.claude/probe/sprag-probe.mjs` and
|
|
42
|
+
* anyone's `my-sprag-wrapper`, then removes them as ours. That is not
|
|
43
|
+
* hypothetical — the probe entry is on this machine.
|
|
44
|
+
*
|
|
45
|
+
* A wrapper is allowed in front, because the entry may legitimately be run
|
|
46
|
+
* through one: `npx sprag-cli brief --hook` and `/usr/local/bin/sprag doc2md`
|
|
47
|
+
* are both ours. So the executable is taken as the last path segment of the
|
|
48
|
+
* first token — or of the token after a runner like `npx` or `yarn dlx` — and
|
|
49
|
+
* matched whole, with `sprag-cli` accepted there since that is what npx resolves.
|
|
50
|
+
*
|
|
51
|
+
* The runner list follows `upgradeCommand`, which already prints npm, pnpm, bun
|
|
52
|
+
* and yarn instructions. Missing one is the duplicate-hook failure rather than
|
|
53
|
+
* the deletion one: an entry we do not recognise gets a second hook added beside
|
|
54
|
+
* it, and both then run on every prompt.
|
|
55
|
+
*/
|
|
56
|
+
const RUNNERS = /^(npx|bunx|pnpx)$/;
|
|
57
|
+
const RUNNER_SUBCOMMANDS = { pnpm: ['dlx', 'exec'], yarn: ['dlx', 'run'], bun: ['x', 'run'], npm: ['exec'] };
|
|
58
|
+
|
|
59
|
+
function isOurCommand(command) {
|
|
60
|
+
if (typeof command !== 'string') return false;
|
|
61
|
+
const tokens = command.trim().split(/\s+/).filter(Boolean);
|
|
62
|
+
if (!tokens.length) return false;
|
|
63
|
+
// `npx <pkg>` puts the package one token in, `yarn dlx <pkg>` two.
|
|
64
|
+
let i = 0;
|
|
65
|
+
if (RUNNERS.test(tokens[0])) i = 1;
|
|
66
|
+
else if (RUNNER_SUBCOMMANDS[tokens[0]]?.includes(tokens[1])) i = 2;
|
|
67
|
+
const token = tokens[i];
|
|
68
|
+
if (!token) return false;
|
|
69
|
+
// A path invokes the binary by its last segment; strip a Windows .cmd/.exe too.
|
|
70
|
+
const exe = token.split(/[/\\]/).pop().replace(/\.(cmd|exe|ps1)$/i, '');
|
|
71
|
+
return exe === CLI || exe === LEGACY_CLI || exe === `${CLI}-cli`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const STATUSLINE_COMMAND = `${CLI} --statusline --icon`;
|
|
21
75
|
const STATUSLINE_REFRESH_INTERVAL = 5;
|
|
22
76
|
|
|
23
77
|
const SKILL_BODY = `---
|
|
@@ -181,7 +235,7 @@ export function installStatusline({ force = false } = {}) {
|
|
|
181
235
|
}
|
|
182
236
|
|
|
183
237
|
const cur = settings.statusLine;
|
|
184
|
-
const targetsUs = cur &&
|
|
238
|
+
const targetsUs = !!cur && isOurCommand(cur.command);
|
|
185
239
|
|
|
186
240
|
if (!cur) {
|
|
187
241
|
settings.statusLine = {
|
|
@@ -221,7 +275,7 @@ export function installStatusline({ force = false } = {}) {
|
|
|
221
275
|
// candidates as session context (startup + /clear). Idempotent: skips when a
|
|
222
276
|
// sprag route-scan hook is already present; never touches other
|
|
223
277
|
// hooks the user configured.
|
|
224
|
-
const ROUTE_SCAN_HOOK_COMMAND =
|
|
278
|
+
const ROUTE_SCAN_HOOK_COMMAND = `${CLI} route-scan --hook`;
|
|
225
279
|
|
|
226
280
|
export function installSessionStartHook() {
|
|
227
281
|
const dir = claudeUserDir();
|
|
@@ -244,8 +298,11 @@ export function installSessionStartHook() {
|
|
|
244
298
|
return { path: file, action: 'skipped', reason: 'hooks.SessionStart is not an array — fix settings.json manually' };
|
|
245
299
|
}
|
|
246
300
|
const list = Array.isArray(settings.hooks.SessionStart) ? settings.hooks.SessionStart : [];
|
|
301
|
+
// Match the flag exactly. `route-scan --hook-delegated` contains
|
|
302
|
+
// `route-scan --hook` as a prefix, so a substring test would see that hook and
|
|
303
|
+
// conclude this one is already installed.
|
|
247
304
|
const already = list.some((m) =>
|
|
248
|
-
Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' &&
|
|
305
|
+
Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && /route-scan --hook(?![\w-])/.test(h.command)),
|
|
249
306
|
);
|
|
250
307
|
if (already) return { path: file, action: 'exists' };
|
|
251
308
|
|
|
@@ -263,7 +320,52 @@ export function installSessionStartHook() {
|
|
|
263
320
|
// conversation — the model cannot see statusline chips, so without this a
|
|
264
321
|
// change that happens mid-session goes unexplained until the user asks.
|
|
265
322
|
// Silent (no output, zero context cost) when nothing changed. Idempotent.
|
|
266
|
-
|
|
323
|
+
/**
|
|
324
|
+
* PostToolUse hook for Task/Agent. The statusline's `Routing saved` figure comes
|
|
325
|
+
* from a cached scan, and the gate in front of that scan holds a fresh
|
|
326
|
+
* delegation back an hour at best. That gate is there to skip scans that would
|
|
327
|
+
* produce identical output; a delegation is precisely the event that changes it,
|
|
328
|
+
* so this hook rescans on the spot. Detached, so the tool call does not wait.
|
|
329
|
+
*/
|
|
330
|
+
const ROUTE_SCAN_DELEGATED_HOOK_COMMAND = `${CLI} route-scan --hook-delegated`;
|
|
331
|
+
|
|
332
|
+
export function installDelegationHook() {
|
|
333
|
+
const dir = claudeUserDir();
|
|
334
|
+
const file = join(dir, 'settings.json');
|
|
335
|
+
mkdirSync(dir, { recursive: true });
|
|
336
|
+
|
|
337
|
+
let settings = {};
|
|
338
|
+
if (existsSync(file)) {
|
|
339
|
+
try {
|
|
340
|
+
settings = JSON.parse(readFileSync(file, 'utf8'));
|
|
341
|
+
} catch (e) {
|
|
342
|
+
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
settings.hooks = settings.hooks || {};
|
|
347
|
+
if (settings.hooks.PostToolUse !== undefined && !Array.isArray(settings.hooks.PostToolUse)) {
|
|
348
|
+
return { path: file, action: 'skipped', reason: 'hooks.PostToolUse is not an array — fix settings.json manually' };
|
|
349
|
+
}
|
|
350
|
+
const list = Array.isArray(settings.hooks.PostToolUse) ? settings.hooks.PostToolUse : [];
|
|
351
|
+
// Same exact-flag match as the SessionStart hook above, for the same reason:
|
|
352
|
+
// a substring test is what confused `--hook` with `--hook-delegated`, and a
|
|
353
|
+
// future `--hook-delegated-batch` would confuse this one in turn.
|
|
354
|
+
const already = list.some((m) =>
|
|
355
|
+
Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && /route-scan --hook-delegated(?![\w-])/.test(h.command)),
|
|
356
|
+
);
|
|
357
|
+
if (already) return { path: file, action: 'exists' };
|
|
358
|
+
|
|
359
|
+
list.push({
|
|
360
|
+
matcher: 'Task|Agent',
|
|
361
|
+
hooks: [{ type: 'command', command: ROUTE_SCAN_DELEGATED_HOOK_COMMAND, timeout: 10 }],
|
|
362
|
+
});
|
|
363
|
+
settings.hooks.PostToolUse = list;
|
|
364
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
365
|
+
return { path: file, action: 'created' };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const BRIEF_HOOK_COMMAND = `${CLI} brief --hook`;
|
|
267
369
|
|
|
268
370
|
export function installBriefHook() {
|
|
269
371
|
const dir = claudeUserDir();
|
|
@@ -305,7 +407,7 @@ export function installBriefHook() {
|
|
|
305
407
|
// drift surfaces only when a human reads the artifact. This hook closes that
|
|
306
408
|
// gap: it runs on the file the model just wrote, while it can still fix it.
|
|
307
409
|
// Installed by `korean on`, removed by `korean off`. Idempotent.
|
|
308
|
-
const KOREAN_LINT_HOOK_COMMAND =
|
|
410
|
+
const KOREAN_LINT_HOOK_COMMAND = `${CLI} korean --hook`;
|
|
309
411
|
// Bash belongs on this matcher because a heredoc, a `tee`, or a `sed -i` puts
|
|
310
412
|
// Korean on disk exactly like Write does. A subagent without the Write tool
|
|
311
413
|
// reaches for `cat > file` first, so leaving Bash off the list exempted every
|
|
@@ -388,8 +490,8 @@ export function removeKoreanLintHook() {
|
|
|
388
490
|
// workbook adding a minute on top. The row cap inside the converter is what
|
|
389
491
|
// actually bounds the work; the timeout is only a backstop.
|
|
390
492
|
// Installed by `doc2md on`, removed by `doc2md off`. Idempotent.
|
|
391
|
-
const DOC2MD_HOOK_COMMAND =
|
|
392
|
-
const DOC2MD_PROMPT_HOOK_COMMAND =
|
|
493
|
+
const DOC2MD_HOOK_COMMAND = `${CLI} doc2md --hook`;
|
|
494
|
+
const DOC2MD_PROMPT_HOOK_COMMAND = `${CLI} doc2md --hook-prompt`;
|
|
393
495
|
|
|
394
496
|
export function installDoc2mdHook() {
|
|
395
497
|
const dir = claudeUserDir();
|
|
@@ -528,7 +630,7 @@ export function uninstallAll({ purge = false } = {}) {
|
|
|
528
630
|
// Only our own statusline command goes. Someone else's stays exactly
|
|
529
631
|
// where they put it.
|
|
530
632
|
const cur = settings.statusLine;
|
|
531
|
-
if (cur &&
|
|
633
|
+
if (cur && isOurCommand(cur.command)) {
|
|
532
634
|
delete settings.statusLine;
|
|
533
635
|
result.removed.push('statusLine');
|
|
534
636
|
} else if (cur) {
|
|
@@ -538,15 +640,28 @@ export function uninstallAll({ purge = false } = {}) {
|
|
|
538
640
|
// Every event, every entry whose command is this CLI. Matching on the
|
|
539
641
|
// binary name rather than a list of exact commands means a hook added by
|
|
540
642
|
// an older version is still removed by a newer one.
|
|
643
|
+
//
|
|
644
|
+
// Removal is per hook, not per matcher group. Dropping whole groups was
|
|
645
|
+
// taking other tools' hooks with them whenever Claude Code had merged them
|
|
646
|
+
// into one group — which it does for entries sharing a matcher, so a user who
|
|
647
|
+
// installed us second lost the first tool's hook on uninstall. A group is
|
|
648
|
+
// deleted only once nothing of anyone else's is left in it.
|
|
649
|
+
const isOurs = (h) => isOurCommand(h?.command)
|
|
650
|
+
|| (typeof h?.command === 'string' && h.command.includes('cache-monitor-hook'));
|
|
541
651
|
for (const event of Object.keys(settings.hooks || {})) {
|
|
542
652
|
const list = settings.hooks[event];
|
|
543
653
|
if (!Array.isArray(list)) continue;
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
654
|
+
let touched = false;
|
|
655
|
+
const kept = [];
|
|
656
|
+
for (const m of list) {
|
|
657
|
+
if (!Array.isArray(m?.hooks)) { kept.push(m); continue; }
|
|
658
|
+
const hooks = m.hooks.filter((h) => !isOurs(h));
|
|
659
|
+
if (hooks.length === m.hooks.length) { kept.push(m); continue; }
|
|
660
|
+
touched = true;
|
|
661
|
+
// The group survives if it still carries someone else's hook.
|
|
662
|
+
if (hooks.length) kept.push({ ...m, hooks });
|
|
663
|
+
}
|
|
664
|
+
if (!touched) continue;
|
|
550
665
|
result.removed.push(`hooks.${event}`);
|
|
551
666
|
if (kept.length === 0) delete settings.hooks[event];
|
|
552
667
|
else settings.hooks[event] = kept;
|
|
@@ -604,7 +719,7 @@ export function migrateLegacyCacheMonitorHook() {
|
|
|
604
719
|
for (const h of m.hooks) {
|
|
605
720
|
if (typeof h?.command !== 'string' || !h.command.includes('cache-monitor-hook')) continue;
|
|
606
721
|
const th = h.command.match(/--threshold\s+([\d.]+)/);
|
|
607
|
-
h.command =
|
|
722
|
+
h.command = `${CLI} --hook-run --threshold ${th ? th[1] : '0.7'}`;
|
|
608
723
|
migrated = true;
|
|
609
724
|
}
|
|
610
725
|
}
|
|
@@ -623,6 +738,7 @@ export function installAll({ force = false } = {}) {
|
|
|
623
738
|
skill: installSkill({ force }),
|
|
624
739
|
statusline: installStatusline({ force }),
|
|
625
740
|
sessionStartHook: installSessionStartHook(),
|
|
741
|
+
delegationHook: installDelegationHook(),
|
|
626
742
|
briefHook: installBriefHook(),
|
|
627
743
|
cacheMonitorMigration: migrateLegacyCacheMonitorHook(),
|
|
628
744
|
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
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
27
|
+
import { CLI_NAME } from './cli-name.js';
|
|
27
28
|
import { join, dirname } from 'node:path';
|
|
28
29
|
import { fileURLToPath } from 'node:url';
|
|
29
30
|
import { createHash } from 'node:crypto';
|
|
@@ -242,43 +243,55 @@ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot()
|
|
|
242
243
|
const fixes = pending.filter((s) => s.kind === 'ratchet');
|
|
243
244
|
const lines = [];
|
|
244
245
|
if (lang === 'ko') {
|
|
245
|
-
lines.push(`[
|
|
246
|
-
lines.push('**이 세션의 첫 응답에서 다른 작업보다 먼저
|
|
247
|
-
lines.push(
|
|
248
|
-
lines.push('
|
|
246
|
+
lines.push(`[${CLI_NAME} seed] 아직 등록되지 않은 추천 룰이 ${pending.length}건 있습니다 (패키지 동봉 프리셋).`);
|
|
247
|
+
lines.push('**이 세션의 첫 응답에서 다른 작업보다 먼저 물어보십시오.** 사용자가 먼저 꺼낸 용건이 있으면 그 답변을 마친 직후에 이어서 묻고, 용건이 끝나기를 기다리지 마십시오. 사용자가 이 안내 문구에 대해 질문할 때까지 미루면 안 됩니다.');
|
|
248
|
+
lines.push(`첫 질문에서 다음 네 선택지를 **이 순서대로** 제시하십시오. ${pending.length}건을 한 건씩 답하는 것은 대부분의 사용자에게 과한 부담이므로, 일괄 등록이 맨 앞에 옵니다.`);
|
|
249
|
+
lines.push(' 1. 전부 글로벌로 등록 — `seed accept all --global`');
|
|
250
|
+
lines.push(' 2. 전부 이 프로젝트만 등록 — `seed accept all --project`');
|
|
251
|
+
lines.push(' 3. 한 건씩 보면서 결정 — 아래 목록을 순서대로 하나씩 묻고, 답을 받는 즉시 해당 명령을 실행합니다');
|
|
252
|
+
lines.push(' 4. 전부 등록하지 않음 — `seed skip all` (다시 묻지 않습니다)');
|
|
253
|
+
lines.push('3번을 고른 경우에만 한 건씩 진행하고, 그때도 적용 범위는 **글로벌(모든 프로젝트)을 먼저, 이 프로젝트만을 그다음**으로 제시하십시오.');
|
|
249
254
|
if (models.length > 0) {
|
|
250
255
|
lines.push(` · 모델 피팅 룰 ${models.length}건 — 등록하면 해당 유형 작업이 더 싼 서브에이전트로 위임됩니다.`);
|
|
251
256
|
for (const s of models) {
|
|
252
257
|
lines.push(` [${s.id}] ${s.tier} · "${s.label}" → ${s.agent}`);
|
|
253
|
-
|
|
258
|
+
// No wrapping quotes: the composed rule text already opens with the
|
|
259
|
+
// quoted work-type name, so adding a pair produced `""명령 실행 …`.
|
|
260
|
+
lines.push(` ${s.ruleText}`);
|
|
254
261
|
}
|
|
255
262
|
}
|
|
256
263
|
if (fixes.length > 0) {
|
|
257
264
|
lines.push(` · 래칫 룰 ${fixes.length}건 — 같은 실수를 반복하지 않도록 세션마다 읽히는 규칙입니다.`);
|
|
258
265
|
for (const s of fixes) lines.push(` [${s.id}] ${s.ruleText}`);
|
|
259
266
|
}
|
|
260
|
-
lines.push(
|
|
261
|
-
lines.push(
|
|
262
|
-
lines.push(
|
|
267
|
+
lines.push(` 일괄: ${CLI_NAME} seed accept all --global|--project # 적용 범위는 반드시 사용자에게 확인`);
|
|
268
|
+
lines.push(` 개별: ${CLI_NAME} seed accept <id> --global|--project`);
|
|
269
|
+
lines.push(` 거절: ${CLI_NAME} seed skip <id>|all # 다시 묻지 않습니다`);
|
|
263
270
|
} else {
|
|
264
|
-
lines.push(`[
|
|
265
|
-
lines.push('**Ask
|
|
266
|
-
lines.push(
|
|
267
|
-
lines.push('
|
|
271
|
+
lines.push(`[${CLI_NAME} seed] ${pending.length} recommended rule(s) from the bundled presets are not registered yet.`);
|
|
272
|
+
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.');
|
|
273
|
+
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.`);
|
|
274
|
+
lines.push(' 1. Register all, globally — `seed accept all --global`');
|
|
275
|
+
lines.push(' 2. Register all, this project only — `seed accept all --project`');
|
|
276
|
+
lines.push(' 3. Decide one at a time — walk the list below in order, running each command as soon as they answer');
|
|
277
|
+
lines.push(' 4. Register none — `seed skip all` (never offered again)');
|
|
278
|
+
lines.push('Only go rule-by-rule if they pick 3, and even then offer **global (all projects) first, this project only second**.');
|
|
268
279
|
if (models.length > 0) {
|
|
269
280
|
lines.push(` · ${models.length} model-fitting rule(s) — once registered, that kind of work goes to a cheaper subagent.`);
|
|
270
281
|
for (const s of models) {
|
|
271
282
|
lines.push(` [${s.id}] ${s.tier} · "${s.label}" → ${s.agent}`);
|
|
272
|
-
|
|
283
|
+
// No wrapping quotes: the composed rule text already opens with the
|
|
284
|
+
// quoted work-type name, so adding a pair produced `""명령 실행 …`.
|
|
285
|
+
lines.push(` ${s.ruleText}`);
|
|
273
286
|
}
|
|
274
287
|
}
|
|
275
288
|
if (fixes.length > 0) {
|
|
276
289
|
lines.push(` · ${fixes.length} ratchet rule(s) — read at the start of every session so the same mistake is not repeated.`);
|
|
277
290
|
for (const s of fixes) lines.push(` [${s.id}] ${s.ruleText}`);
|
|
278
291
|
}
|
|
279
|
-
lines.push(
|
|
280
|
-
lines.push(
|
|
281
|
-
lines.push(
|
|
292
|
+
lines.push(` all: ${CLI_NAME} seed accept all --global|--project # ALWAYS confirm the scope with the user`);
|
|
293
|
+
lines.push(` one: ${CLI_NAME} seed accept <id> --global|--project`);
|
|
294
|
+
lines.push(` decline: ${CLI_NAME} seed skip <id>|all # never offered again`);
|
|
282
295
|
}
|
|
283
296
|
return lines.join('\n');
|
|
284
297
|
}
|