claude-token-saver 3.22.0 → 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.22.0",
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": {
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';
@@ -534,10 +536,18 @@ export async function runRouteScan({ days = 14 } = {}) {
534
536
  (r.scope === 'global' || r.project === projectDir));
535
537
  for (const [sessionPath, index] of runIndexBySession) {
536
538
  const used = new Set();
537
- for (const { ep, projectDir, sessionPath: epSession } of all) {
538
- if (epSession !== sessionPath) continue;
539
- if (!ep.delegationToolUseIds.length && index.unjoined.length === 0) continue;
540
- 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);
541
551
  if (runs.length === 0) continue;
542
552
  const cat = categorize(ep.text, ep.tools);
543
553
  if (!cat) continue;
@@ -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);