claude-token-saver 3.21.1 → 3.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.21.1",
3
+ "version": "3.22.1",
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
@@ -22,7 +22,9 @@ import { join } from 'node:path';
22
22
  import { userDataDir } from './paths.js';
23
23
  import { discoverSessionFiles } from './parser.js';
24
24
  import { collectSessionRecords } from './session-records.js';
25
- import { collectSubagentRuns, indexRuns, runsForEpisode } from './subagent-records.js';
25
+ import {
26
+ collectSubagentRuns, indexRuns, exactRunsForEpisode, fallbackRunsForEpisode,
27
+ } from './subagent-records.js';
26
28
  import { estimateCost, modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from './cost.js';
27
29
  import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
28
30
  import { agentPhrase, agentPhraseEn } from './agents.js';
@@ -280,6 +282,18 @@ export function dominantModel(counts) {
280
282
  return best;
281
283
  }
282
284
 
285
+ // A delegated run counts as failed on error DENSITY, not on the presence of a
286
+ // single is_error. Binary counting scored a 218-call run that finished its task
287
+ // identically to one that died on its first call. The floor keeps very short
288
+ // runs honest: 1 error in 3 calls is still a failure.
289
+ export const DELEGATED_ERR_DENSITY = 0.1;
290
+
291
+ export function isFailedRun(run) {
292
+ if (!run || !(run.toolErrors > 0)) return false;
293
+ const calls = run.calls > 0 ? run.calls : 1;
294
+ return run.toolErrors / calls > DELEGATED_ERR_DENSITY;
295
+ }
296
+
283
297
  export function worthDelegating(tier, rank) {
284
298
  const target = TIER_TARGET_RANK[tier];
285
299
  return target !== undefined && rank > target;
@@ -480,10 +494,12 @@ export async function runRouteScan({ days = 14 } = {}) {
480
494
  // T2 rule firing, a sonnet run a T1 one. Runs that were not a downgrade
481
495
  // (same tier or higher) carry no delegation saving and are skipped.
482
496
  const delegatedStats = new Map(); // "tier|category|project" → outcome aggregate
497
+ let unresolvedRuns = 0; // delegated runs dropped for an unpriceable model id
498
+ const unresolvedModels = new Set();
483
499
  const bumpDelegated = (key, run, saved) => {
484
500
  const d = delegatedStats.get(key) || { runs: 0, errRuns: 0, outTokens: 0, savedUsd: 0 };
485
501
  d.runs += 1;
486
- if (run.toolErrors > 0) d.errRuns += 1;
502
+ if (isFailedRun(run)) d.errRuns += 1;
487
503
  d.outTokens += run.out || 0;
488
504
  d.savedUsd += saved;
489
505
  delegatedStats.set(key, d);
@@ -520,10 +536,18 @@ export async function runRouteScan({ days = 14 } = {}) {
520
536
  (r.scope === 'global' || r.project === projectDir));
521
537
  for (const [sessionPath, index] of runIndexBySession) {
522
538
  const used = new Set();
523
- for (const { ep, projectDir, sessionPath: epSession } of all) {
524
- if (epSession !== sessionPath) continue;
525
- if (!ep.delegationToolUseIds.length && index.unjoined.length === 0) continue;
526
- const runs = runsForEpisode(index, ep, used);
539
+ const eps = all.filter((x) => x.sessionPath === sessionPath);
540
+ // Two passes over the session, not one per episode: every exact tool_use
541
+ // join is settled first, so the timestamp fallback can only ever claim a
542
+ // run that no episode was able to prove was its own.
543
+ const runsByEp = new Map();
544
+ for (const item of eps) runsByEp.set(item, exactRunsForEpisode(index, item.ep, used));
545
+ for (const item of eps) {
546
+ runsByEp.get(item).push(...fallbackRunsForEpisode(index, item.ep, used));
547
+ }
548
+ for (const item of eps) {
549
+ const { ep, projectDir } = item;
550
+ const runs = runsByEp.get(item);
527
551
  if (runs.length === 0) continue;
528
552
  const cat = categorize(ep.text, ep.tools);
529
553
  if (!cat) continue;
@@ -537,6 +561,14 @@ export async function runRouteScan({ days = 14 } = {}) {
537
561
  }
538
562
  for (const run of runs) {
539
563
  const runTier = tierForRank(modelRank(run.model));
564
+ // Dropping unresolved model ids is deliberate (see cost.js) — pricing
565
+ // a gateway id as Sonnet would poison every number here. But dropping
566
+ // them SILENTLY is what made a whole tier of rules report zero
567
+ // delegations with no way to tell why, so keep a count to surface.
568
+ if (!isRecognizedModelId(run.model)) {
569
+ unresolvedRuns += 1;
570
+ if (run.model) unresolvedModels.add(run.model);
571
+ }
540
572
  if (!runTier || !worthDelegating(runTier, mainRank)) continue;
541
573
  const saved = runSaving(run, mainModel);
542
574
  bumpDelegated(`${runTier}|${cat.id}|${projectDir}`, run, saved);
@@ -641,6 +673,8 @@ export async function runRouteScan({ days = 14 } = {}) {
641
673
  thresholds,
642
674
  candidates,
643
675
  resolved: [...resolved],
676
+ unresolvedRuns,
677
+ unresolvedModels: [...unresolvedModels].slice(0, 5),
644
678
  };
645
679
  try {
646
680
  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 } = {}) {
@@ -150,6 +150,10 @@ export async function collectSubagentRuns(sessionPath) {
150
150
  * with the un-joinable ones kept aside for the timestamp fallback (a run
151
151
  * whose .meta.json is missing or predates toolUseId still happened, and
152
152
  * dropping it would silently under-count a rule's real error rate).
153
+ *
154
+ * `unjoined` is kept for callers that want only the no-id runs; the fallback
155
+ * itself works off `all` minus whatever the exact pass claimed, because a run
156
+ * CAN carry an id that no parent episode holds (see fallbackRunsForEpisode).
153
157
  */
154
158
  export function indexRuns(runs) {
155
159
  const byToolUse = new Map();
@@ -168,13 +172,38 @@ export function indexRuns(runs) {
168
172
  * (an episode's span can overlap a neighbouring episode's runs).
169
173
  */
170
174
  export function runsForEpisode(index, ep, used) {
175
+ return [...exactRunsForEpisode(index, ep, used), ...fallbackRunsForEpisode(index, ep, used)];
176
+ }
177
+
178
+ /** Runs this episode's own Task calls spawned. No guessing. */
179
+ export function exactRunsForEpisode(index, ep, used) {
171
180
  const out = [];
172
181
  for (const id of ep.delegationToolUseIds || []) {
173
182
  const r = index.byToolUse.get(id);
174
183
  if (r && !used.has(r.path)) { used.add(r.path); out.push(r); }
175
184
  }
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * Runs that no episode's tool_use ids claimed, matched by start time falling
190
+ * inside this episode's span.
191
+ *
192
+ * The pool is every unclaimed run, not just the ones without a toolUseId. A
193
+ * nested delegation — a subagent spawning its own subagent — records the
194
+ * SIBLING's tool_use id, which appears in no parent transcript and therefore
195
+ * matches nothing. Gating the fallback on "has no toolUseId" made having one
196
+ * disqualify the run from the only path that could still attribute it, so
197
+ * every spawnDepth >= 2 run was dropped forever (measured: 3 of 3 over 14
198
+ * days, ~$3.42 of savings and three runs of rule-health evidence).
199
+ *
200
+ * Run session-wide AFTER every episode's exact join, so a timestamp guess
201
+ * cannot take a run that another episode can prove is its own.
202
+ */
203
+ export function fallbackRunsForEpisode(index, ep, used) {
204
+ const out = [];
176
205
  if (ep.startedAt === null || ep.endedAt === null) return out;
177
- for (const r of index.unjoined) {
206
+ for (const r of index.all) {
178
207
  if (used.has(r.path) || r.startedAt === null) continue;
179
208
  if (r.startedAt >= ep.startedAt && r.startedAt <= ep.endedAt) {
180
209
  used.add(r.path);