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 +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 +40 -6
- package/src/session-records.js +9 -1
- package/src/subagent-records.js +30 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-token-saver",
|
|
3
|
-
"version": "3.
|
|
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>');
|
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
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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();
|
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 } = {}) {
|
package/src/subagent-records.js
CHANGED
|
@@ -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.
|
|
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);
|