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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.21.1",
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>');
@@ -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,
@@ -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 neither bucket is decisive the id stays unresolved. An 'unknown' that
361
- * drops out of the aggregate beats a confident wrong answer that silently
362
- * re-tiers every run on that profile.
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.toolErrors > 0) d.errRuns += 1;
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();
@@ -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 } = {}) {