claude-token-saver 3.21.1 → 3.22.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/package.json +1 -1
- package/src/commands/route-scan.js +12 -0
- package/src/korean-style.js +3 -1
- package/src/model-alias.js +17 -3
- package/src/route-scan.js +25 -1
- package/src/session-records.js +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-token-saver",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.22.0",
|
|
4
4
|
"description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet — post-hoc session analysis, no realtime router, no extra LLM calls.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -107,6 +107,18 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
107
107
|
// what the model reads.
|
|
108
108
|
console.log(` ${mr.composeRuleText(r.rule, r, lang)}`);
|
|
109
109
|
});
|
|
110
|
+
// Runs on a model id we cannot price never reach the aggregate, so a
|
|
111
|
+
// rule whose tier runs entirely on such an id reads as "never fired".
|
|
112
|
+
// Say so here rather than leaving the zero unexplained.
|
|
113
|
+
const scan = rs.readRouteScan();
|
|
114
|
+
if (scan?.unresolvedRuns > 0) {
|
|
115
|
+
const ids = (scan.unresolvedModels || []).join(', ');
|
|
116
|
+
console.log(lang === 'ko'
|
|
117
|
+
? `\n⚠ 해석되지 않은 모델 ID 때문에 위임 ${scan.unresolvedRuns}건이 집계에서 제외됐습니다${ids ? ` (${ids})` : ''}.`
|
|
118
|
+
+ '\n profile-map.json 의 modelAliases 에 해당 ID 를 매핑한 뒤 route-scan --refresh 를 실행하십시오.'
|
|
119
|
+
: `\n⚠ ${scan.unresolvedRuns} delegated run(s) were excluded — unpriceable model id${ids ? ` (${ids})` : ''}.`
|
|
120
|
+
+ '\n Map it under modelAliases in profile-map.json, then run route-scan --refresh.');
|
|
121
|
+
}
|
|
110
122
|
console.log(lang === 'ko'
|
|
111
123
|
? '\n제거: claude-token-saver route-scan rules rm <N>'
|
|
112
124
|
: '\nRemove with: claude-token-saver route-scan rules rm <N>');
|
package/src/korean-style.js
CHANGED
|
@@ -123,7 +123,9 @@ export function koreanStyleInjection({ cfg = loadConfig() } = {}) {
|
|
|
123
123
|
if (!text) return null;
|
|
124
124
|
return [
|
|
125
125
|
'[claude-token-saver korean-style] 이 세션에서 한국어를 출력할 때는 아래 지침을 따르십시오.',
|
|
126
|
-
'이 지침은 사용자가 claude-token-saver에 설정한
|
|
126
|
+
'이 지침은 사용자가 claude-token-saver에 설정한 것입니다.',
|
|
127
|
+
'적용 대상: 대화 답변, 그리고 새로 작성하거나 수정하는 마크다운·문서·보고서의 한국어 산문을 모두 포함합니다.',
|
|
128
|
+
'적용 예외: 원문을 그대로 옮기는 인용, 코드, 코드 주석, 그리고 프로젝트의 기존 표기 관례를 따라야 하는 커밋 메시지와 로그 문자열입니다.',
|
|
127
129
|
`(출처: ${KOREAN_STYLE_SOURCE})`,
|
|
128
130
|
'',
|
|
129
131
|
text,
|
package/src/model-alias.js
CHANGED
|
@@ -357,9 +357,13 @@ function topRole(tally) {
|
|
|
357
357
|
* against 1,702 times as a subagent, and those 16 inferred 'main' votes beat
|
|
358
358
|
* the single explicit 'haiku' one at exactly the 80% line.
|
|
359
359
|
*
|
|
360
|
-
* When
|
|
361
|
-
*
|
|
362
|
-
*
|
|
360
|
+
* When a thin explicit bucket vetoes a decisive inference, the explicit role
|
|
361
|
+
* is adopted ('explicit-veto'): a stated `Task(model: ...)` is the stronger
|
|
362
|
+
* evidence, and it is the only evidence left once the inference is rejected.
|
|
363
|
+
*
|
|
364
|
+
* When neither bucket says anything usable the id stays unresolved. An
|
|
365
|
+
* 'unknown' that drops out of the aggregate beats a confident wrong answer
|
|
366
|
+
* that silently re-tiers every run on that profile.
|
|
363
367
|
*/
|
|
364
368
|
export function tallyVotes(votes, { minVotes = MIN_VOTES, minAgreement = MIN_AGREEMENT } = {}) {
|
|
365
369
|
const learned = {};
|
|
@@ -385,6 +389,16 @@ export function tallyVotes(votes, { minVotes = MIN_VOTES, minAgreement = MIN_AGR
|
|
|
385
389
|
// flag, so probably the session model" observations.
|
|
386
390
|
role = inferred.role;
|
|
387
391
|
source = 'inferred';
|
|
392
|
+
} else if (explicit.total > 0
|
|
393
|
+
&& explicit.top / explicit.total >= minAgreement
|
|
394
|
+
&& inferred.role
|
|
395
|
+
&& !rolesAgree(explicit.role, inferred.role)) {
|
|
396
|
+
// The veto above is only coherent if we then believe what did the
|
|
397
|
+
// vetoing. Leaving the id unresolved instead drops every delegated run
|
|
398
|
+
// on that profile out of the aggregate, which is how a haiku profile
|
|
399
|
+
// that doubles as somebody's session model reported zero delegations.
|
|
400
|
+
role = explicit.role;
|
|
401
|
+
source = 'explicit-veto';
|
|
388
402
|
}
|
|
389
403
|
|
|
390
404
|
learned[pid] = {
|
package/src/route-scan.js
CHANGED
|
@@ -280,6 +280,18 @@ export function dominantModel(counts) {
|
|
|
280
280
|
return best;
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// A delegated run counts as failed on error DENSITY, not on the presence of a
|
|
284
|
+
// single is_error. Binary counting scored a 218-call run that finished its task
|
|
285
|
+
// identically to one that died on its first call. The floor keeps very short
|
|
286
|
+
// runs honest: 1 error in 3 calls is still a failure.
|
|
287
|
+
export const DELEGATED_ERR_DENSITY = 0.1;
|
|
288
|
+
|
|
289
|
+
export function isFailedRun(run) {
|
|
290
|
+
if (!run || !(run.toolErrors > 0)) return false;
|
|
291
|
+
const calls = run.calls > 0 ? run.calls : 1;
|
|
292
|
+
return run.toolErrors / calls > DELEGATED_ERR_DENSITY;
|
|
293
|
+
}
|
|
294
|
+
|
|
283
295
|
export function worthDelegating(tier, rank) {
|
|
284
296
|
const target = TIER_TARGET_RANK[tier];
|
|
285
297
|
return target !== undefined && rank > target;
|
|
@@ -480,10 +492,12 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
480
492
|
// T2 rule firing, a sonnet run a T1 one. Runs that were not a downgrade
|
|
481
493
|
// (same tier or higher) carry no delegation saving and are skipped.
|
|
482
494
|
const delegatedStats = new Map(); // "tier|category|project" → outcome aggregate
|
|
495
|
+
let unresolvedRuns = 0; // delegated runs dropped for an unpriceable model id
|
|
496
|
+
const unresolvedModels = new Set();
|
|
483
497
|
const bumpDelegated = (key, run, saved) => {
|
|
484
498
|
const d = delegatedStats.get(key) || { runs: 0, errRuns: 0, outTokens: 0, savedUsd: 0 };
|
|
485
499
|
d.runs += 1;
|
|
486
|
-
if (run
|
|
500
|
+
if (isFailedRun(run)) d.errRuns += 1;
|
|
487
501
|
d.outTokens += run.out || 0;
|
|
488
502
|
d.savedUsd += saved;
|
|
489
503
|
delegatedStats.set(key, d);
|
|
@@ -537,6 +551,14 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
537
551
|
}
|
|
538
552
|
for (const run of runs) {
|
|
539
553
|
const runTier = tierForRank(modelRank(run.model));
|
|
554
|
+
// Dropping unresolved model ids is deliberate (see cost.js) — pricing
|
|
555
|
+
// a gateway id as Sonnet would poison every number here. But dropping
|
|
556
|
+
// them SILENTLY is what made a whole tier of rules report zero
|
|
557
|
+
// delegations with no way to tell why, so keep a count to surface.
|
|
558
|
+
if (!isRecognizedModelId(run.model)) {
|
|
559
|
+
unresolvedRuns += 1;
|
|
560
|
+
if (run.model) unresolvedModels.add(run.model);
|
|
561
|
+
}
|
|
540
562
|
if (!runTier || !worthDelegating(runTier, mainRank)) continue;
|
|
541
563
|
const saved = runSaving(run, mainModel);
|
|
542
564
|
bumpDelegated(`${runTier}|${cat.id}|${projectDir}`, run, saved);
|
|
@@ -641,6 +663,8 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
641
663
|
thresholds,
|
|
642
664
|
candidates,
|
|
643
665
|
resolved: [...resolved],
|
|
666
|
+
unresolvedRuns,
|
|
667
|
+
unresolvedModels: [...unresolvedModels].slice(0, 5),
|
|
644
668
|
};
|
|
645
669
|
try {
|
|
646
670
|
const dir = stateDir();
|
package/src/session-records.js
CHANGED
|
@@ -55,6 +55,14 @@ const REJECTION_RE = /doesn't want to proceed|tool use was rejected|doesn't want
|
|
|
55
55
|
// does not exist" on a Read) stay counted — those are genuine difficulty signal.
|
|
56
56
|
const SELF_CORRECTED_RE = /File has not been read yet|has been modified since read|String to replace not found|is not running \(status:|<tool_use_error>Blocked:/i;
|
|
57
57
|
|
|
58
|
+
// Environment constraints — a sandbox without `curl`/`wc`, a corporate proxy
|
|
59
|
+
// timing a fetch out — are not task difficulty either. The agent routinely
|
|
60
|
+
// routes around them and finishes: one 218-turn run that produced a 9,870-char
|
|
61
|
+
// sourced report was scored a failure on two `command not found` results.
|
|
62
|
+
// NARROW on purpose: only the shell's own "this binary is absent" wording and
|
|
63
|
+
// curl's transport-timeout exit, never a generic non-zero exit.
|
|
64
|
+
const ENVIRONMENT_RE = /command not found|curl: \(28\)|Operation timed out after|ETIMEDOUT|ENOTFOUND|getaddrinfo/i;
|
|
65
|
+
|
|
58
66
|
function toolResultText(content) {
|
|
59
67
|
if (typeof content === 'string') return content;
|
|
60
68
|
if (!Array.isArray(content)) return '';
|
|
@@ -64,7 +72,7 @@ function toolResultText(content) {
|
|
|
64
72
|
function isRealToolError(block) {
|
|
65
73
|
if (!block || block.type !== 'tool_result' || !block.is_error) return false;
|
|
66
74
|
const txt = toolResultText(block.content);
|
|
67
|
-
return !REJECTION_RE.test(txt) && !SELF_CORRECTED_RE.test(txt);
|
|
75
|
+
return !REJECTION_RE.test(txt) && !SELF_CORRECTED_RE.test(txt) && !ENVIRONMENT_RE.test(txt);
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
export async function collectSessionRecords(filePath, { includeContent = true } = {}) {
|