claude-token-saver 3.5.3 → 3.6.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/src/route-scan.js CHANGED
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
- import { homedir } from 'node:os';
22
+ import { userDataDir } from './paths.js';
23
23
  import { discoverSessionFiles } from './parser.js';
24
24
  import { collectSessionRecords } from './session-records.js';
25
25
 
@@ -68,36 +68,42 @@ const CATEGORIES = [
68
68
  {
69
69
  id: 'paste',
70
70
  label: '붙여넣은 화면·로그 질문',
71
+ labelEn: 'questions about pasted screens/logs',
71
72
  agent: 'haiku-explore',
72
73
  kw: null, // matched by length, see categorize()
73
74
  },
74
75
  {
75
76
  id: 'translate',
76
77
  label: '배치 번역·정형 텍스트 변환',
78
+ labelEn: 'batch translation / mechanical text transforms',
77
79
  agent: 'haiku-translate',
78
80
  kw: [[/번역|translate/i, 2], [/변환해|표로 정리|포맷팅/i, 1]],
79
81
  },
80
82
  {
81
83
  id: 'explore',
82
84
  label: '탐색·조회 (파일/값 찾기)',
85
+ labelEn: 'lookup (finding files/values)',
83
86
  agent: 'haiku-explore',
84
87
  kw: [[/grep|검색|search|find/i, 2], [/찾아|어디|위치|목록|살펴/i, 1]],
85
88
  },
86
89
  {
87
90
  id: 'read',
88
91
  label: '읽기·요약·설명',
92
+ labelEn: 'reading / summarizing / explaining',
89
93
  agent: 'haiku-explore',
90
94
  kw: [[/요약|summar|explain/i, 2], [/읽어|설명|정리해|보여줘|알려줘|뭐야|what/i, 1]],
91
95
  },
92
96
  {
93
97
  id: 'check',
94
98
  label: '상태 확인·검증',
99
+ labelEn: 'status checks / verification',
95
100
  agent: 'haiku-explore',
96
101
  kw: [[/확인|검증|verify|점검/i, 2], [/맞아\?|되나|됐나|됐어|되는지|괜찮|체크|check|status/i, 1]],
97
102
  },
98
103
  {
99
104
  id: 'run',
100
105
  label: '명령 실행 (빌드·테스트·git)',
106
+ labelEn: 'running commands (build/test/git)',
101
107
  agent: 'haiku-runner',
102
108
  kw: [[/git |commit|push|npm |pip|빌드해|빌드 돌/i, 2], [/실행|돌려|run |build|빌드|테스트|설치/i, 1]],
103
109
  },
@@ -153,16 +159,10 @@ function keywordScore(cat, text) {
153
159
  const SKIP_RE = /^(계속|이어서|continue|다음|proceed|진행|응|네|넵|ok|okay|yes|ㄱ+|고고)\b/i;
154
160
  const SKIP_PREFIX = ['<task-notification', '<system', '[Image:', '<local-command'];
155
161
 
156
- function stateDir() {
157
- if (process.platform === 'win32') {
158
- return join(process.env.APPDATA || homedir(), 'claude-token-saver');
159
- }
160
- if (process.platform === 'darwin') {
161
- return join(homedir(), 'Library', 'Application Support', 'claude-token-saver');
162
- }
163
- const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
164
- return join(xdg, 'claude-token-saver');
165
- }
162
+ // Single source of truth for the state dir (paths.js). A local copy used to
163
+ // live here and honored XDG_CONFIG_HOME on Linux only, so on macOS/Windows an
164
+ // XDG override split this cache away from config.json and the session cache.
165
+ const stateDir = userDataDir;
166
166
 
167
167
  export function routeScanCachePath() {
168
168
  return join(stateDir(), 'route-scan.json');
@@ -335,6 +335,7 @@ export async function runRouteScan({ days = 14 } = {}) {
335
335
  tier,
336
336
  category: cat.id,
337
337
  label: cat.label,
338
+ labelEn: cat.labelEn,
338
339
  agent: tier === 'T2' ? cat.agent : 'sonnet',
339
340
  project: projectDir,
340
341
  projectPath: '',
@@ -368,9 +369,15 @@ export async function runRouteScan({ days = 14 } = {}) {
368
369
  r.tier === g.tier && r.category === g.category &&
369
370
  (r.scope === 'global' || r.project === g.project));
370
371
 
372
+ // Both languages are computed at scan time and stored on the candidate, so
373
+ // switching `language` later re-renders (and promotes) correctly without
374
+ // waiting for a rescan.
371
375
  const ruleText = (g) => g.tier === 'T2'
372
376
  ? `"${g.label}" 유형의 단순 요청(예: "${g.example}")은 ${g.agent}(haiku) 서브에이전트로 위임한다 (설계 판단·배포·스토어 제출 같은 비가역 작업이 섞이면 위임하지 않음)`
373
377
  : `"${g.label}" 유형의 중간 난도 요청(예: "${g.example}")은 model: sonnet 서브에이전트로 위임한다 (설계 판단·비가역 작업·반복 에러 발생 시 메인 모델이 이어받음)`;
378
+ const ruleTextEn = (g) => g.tier === 'T2'
379
+ ? `Delegate simple "${g.labelEn}" requests (e.g. "${g.example}") to the ${g.agent} (haiku) subagent — never when the request mixes in design judgement or irreversible work like deploy/release/submission`
380
+ : `Delegate moderate "${g.labelEn}" requests (e.g. "${g.example}") to a model: sonnet subagent — hand back to the main model on design judgement, irreversible work, or repeated errors`;
374
381
 
375
382
  const candidates = [...groups.values()]
376
383
  .filter((g) => g.count >= MIN_RECURRENCE && !hasRule(g))
@@ -382,6 +389,7 @@ export async function runRouteScan({ days = 14 } = {}) {
382
389
  tier: g.tier,
383
390
  category: g.category,
384
391
  label: g.label,
392
+ labelEn: g.labelEn,
385
393
  agent: g.agent,
386
394
  project: g.project,
387
395
  // Real session cwd for the project (munged `project` is lossy) — lets
@@ -396,6 +404,7 @@ export async function runRouteScan({ days = 14 } = {}) {
396
404
  // the same category recurs across 2+ projects (then 'global').
397
405
  suggestedScope: 'project',
398
406
  rule: ruleText(g),
407
+ ruleEn: ruleTextEn(g),
399
408
  }));
400
409
 
401
410
  // Same category appearing in 2+ projects → suggest global for each.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Parsed-session cache — keyed by (path, mtimeMs, size).
3
+ *
4
+ * The statusline re-runs the whole report pipeline on every refresh
5
+ * (`refreshInterval=5` is what `install` configures), and without a cache
6
+ * that means re-reading every session JSONL in the window line by line.
7
+ * Measured on a 217MB / 226-file 30-day window: 3.0s per refresh, of which
8
+ * effectively all is spent re-parsing files that cannot have changed —
9
+ * between two refreshes only the CURRENT session's file grows.
10
+ *
11
+ * Session transcripts are append-only and never rewritten, so (mtimeMs, size)
12
+ * is a sound identity for "this file's parse result is still valid". A stale
13
+ * or corrupt cache is never fatal: every read path falls back to a full parse.
14
+ *
15
+ * Only the aggregate summary is stored, never the per-request array — that is
16
+ * internal to the parser's aggregation and no consumer reads it (see
17
+ * parseAllSessions, which drops it on the fresh-parse path too so cache hits
18
+ * and misses return identically shaped objects).
19
+ */
20
+
21
+ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { userDataDir } from './paths.js';
24
+ import { debug } from './debug.js';
25
+
26
+ const CACHE_PATH = join(userDataDir(), 'session-cache.json');
27
+ // Bump when the cached summary's shape changes — old entries are dropped
28
+ // wholesale rather than migrated.
29
+ const CACHE_VERSION = 1;
30
+ // Entries for transcripts this old are pruned on write. Keeps the file
31
+ // bounded without an existence check per entry (which would cost the syscalls
32
+ // the cache exists to avoid).
33
+ const PRUNE_AFTER_MS = 90 * 24 * 60 * 60 * 1000;
34
+
35
+ /**
36
+ * @typedef {object} SessionSummary
37
+ * @property {string|null} sessionId
38
+ * @property {string} filePath
39
+ * @property {Date|null} startTime
40
+ * @property {Date|null} endTime
41
+ * @property {number} requestCount
42
+ * @property {object} totals
43
+ * @property {number} maxContextPerRequest
44
+ * @property {string} model
45
+ * @property {string} [projectDir]
46
+ */
47
+
48
+ /** Dates don't survive JSON — store epoch ms, rehydrate on read. */
49
+ function serialize(session) {
50
+ return {
51
+ sessionId: session.sessionId,
52
+ startTime: session.startTime ? session.startTime.getTime() : null,
53
+ endTime: session.endTime ? session.endTime.getTime() : null,
54
+ requestCount: session.requestCount,
55
+ totals: session.totals,
56
+ maxContextPerRequest: session.maxContextPerRequest,
57
+ model: session.model,
58
+ };
59
+ }
60
+
61
+ function deserialize(stored, filePath, projectDir) {
62
+ return {
63
+ sessionId: stored.sessionId ?? null,
64
+ filePath,
65
+ projectDir,
66
+ startTime: typeof stored.startTime === 'number' ? new Date(stored.startTime) : null,
67
+ endTime: typeof stored.endTime === 'number' ? new Date(stored.endTime) : null,
68
+ requestCount: stored.requestCount || 0,
69
+ totals: stored.totals,
70
+ maxContextPerRequest: stored.maxContextPerRequest || 0,
71
+ model: stored.model || 'unknown',
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Load the cache into a plain object. Any problem (missing file, bad JSON,
77
+ * version bump) yields an empty cache — the caller just re-parses.
78
+ *
79
+ * @returns {{ entries: Record<string, {mtimeMs:number,size:number,s:object}> }}
80
+ */
81
+ export function loadCache() {
82
+ try {
83
+ if (!existsSync(CACHE_PATH)) return { entries: {} };
84
+ const data = JSON.parse(readFileSync(CACHE_PATH, 'utf8'));
85
+ if (!data || data.version !== CACHE_VERSION || !data.entries || typeof data.entries !== 'object') {
86
+ return { entries: {} };
87
+ }
88
+ return { entries: data.entries };
89
+ } catch (e) {
90
+ debug('session-cache:load', e);
91
+ return { entries: {} };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Look up a parsed session for a discovered file.
97
+ *
98
+ * @param {{entries:object}} cache
99
+ * @param {{path:string, size:number, mtime:number, projectDir:string}} file
100
+ * @returns {SessionSummary|null} null on miss
101
+ */
102
+ export function getCached(cache, file) {
103
+ const e = cache.entries[file.path];
104
+ if (!e || e.mtimeMs !== file.mtime || e.size !== file.size || !e.s) return null;
105
+ try {
106
+ return deserialize(e.s, file.path, file.projectDir);
107
+ } catch (err) {
108
+ debug('session-cache:deserialize', err);
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Record a freshly parsed session against the stat values observed BEFORE the
115
+ * parse. Keying on the pre-parse stat is deliberate: if the file grew while we
116
+ * were reading it, the entry is keyed to a size that will never be seen again,
117
+ * so the next run re-parses. The opposite (keying on the post-parse stat)
118
+ * would let a hit serve a summary that missed the tail.
119
+ */
120
+ export function putCached(cache, file, session) {
121
+ cache.entries[file.path] = {
122
+ mtimeMs: file.mtime,
123
+ size: file.size,
124
+ s: serialize(session),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Atomically persist the cache (tmp + rename), pruning long-dead transcripts.
130
+ * Concurrent statusline refreshes are expected — rename is atomic on POSIX and
131
+ * on Windows for same-volume replaces, so a reader never sees a partial file.
132
+ * Best-effort: a write failure just means the next run re-parses.
133
+ */
134
+ export function saveCache(cache) {
135
+ try {
136
+ const dir = userDataDir();
137
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
138
+ const cutoff = Date.now() - PRUNE_AFTER_MS;
139
+ const entries = {};
140
+ for (const [path, e] of Object.entries(cache.entries)) {
141
+ if (e && typeof e.mtimeMs === 'number' && e.mtimeMs >= cutoff) entries[path] = e;
142
+ }
143
+ const tmp = `${CACHE_PATH}.${process.pid}.tmp`;
144
+ writeFileSync(tmp, JSON.stringify({ version: CACHE_VERSION, entries }) + '\n');
145
+ renameSync(tmp, CACHE_PATH);
146
+ } catch (e) {
147
+ debug('session-cache:save', e); // best-effort — never block a report
148
+ }
149
+ }
150
+
151
+ export function sessionCachePath() {
152
+ return CACHE_PATH;
153
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Claude Code's statusline contract feeds this tool a JSON blob on stdin every
3
+ * refresh. These helpers own reading and interpreting that payload; everything
4
+ * else in the CLI takes the extracted values.
5
+ */
6
+
7
+ import { readFileSync } from 'node:fs';
8
+
9
+ /**
10
+ * Read the JSON blob Claude Code feeds the statusline command on stdin.
11
+ * Returns null when stdin is a TTY or empty (e.g. user invokes `--statusline`
12
+ * by hand) so callers can fall back to flag/env config.
13
+ *
14
+ * The blob shape (subset we consume):
15
+ * {
16
+ * "transcript_path": "...",
17
+ * "rate_limits": {
18
+ * "five_hour": { "used_percentage": 94, "resets_at": 1777099200 },
19
+ * "seven_day": { "used_percentage": 7, "resets_at": 1777521600 }
20
+ * }
21
+ * }
22
+ *
23
+ * extractCaps treats `rate_limits` as a generic object so any future window
24
+ * Anthropic adds (e.g. a Sonnet-only weekly bucket) flows through without code
25
+ * changes — known keys get curated labels, unknowns get derived ones.
26
+ */
27
+ export function readStdinJson() {
28
+ if (process.stdin.isTTY) return null;
29
+ try {
30
+ const raw = readFileSync(0, 'utf8');
31
+ if (!raw || !raw.trim()) return null;
32
+ return JSON.parse(raw);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function extractCaps(stdinJson) {
39
+ if (!stdinJson || !stdinJson.rate_limits || typeof stdinJson.rate_limits !== 'object') return null;
40
+ const windows = [];
41
+ for (const [key, value] of Object.entries(stdinJson.rate_limits)) {
42
+ if (!value || typeof value !== 'object') continue;
43
+ const usedPct = Number(value.used_percentage);
44
+ if (!Number.isFinite(usedPct)) continue;
45
+ const resetsAt = Number(value.resets_at);
46
+ windows.push({
47
+ key,
48
+ usedPct,
49
+ resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
50
+ });
51
+ }
52
+ return windows.length ? { windows } : null;
53
+ }
54
+
55
+ /**
56
+ * Pull the human-friendly model name out of Claude Code's stdin payload.
57
+ * `model.display_name` is the contract; fall back to `model.id` when it's
58
+ * absent. Returns null when nothing usable is in the JSON.
59
+ */
60
+ // Bedrock/litellm proxies pass model IDs like
61
+ // global.anthropic.claude-opus-4-7-20251001-v1:0
62
+ // anthropic.claude-sonnet-4-6-20250930-v1:0
63
+ // bedrock/anthropic.claude-haiku-4-5
64
+ // while Claude Code's `display_name` collapses these to a generic family
65
+ // label ("Opus 4", "Sonnet 4") that hides the actual minor version. Pull the
66
+ // version out of the id when we can spot it so the statusline shows the real
67
+ // model in use (Opus 4.7 vs 4.6 matters a lot for token budgeting).
68
+ export function bedrockDisplayFromId(id) {
69
+ if (typeof id !== 'string') return null;
70
+ const m = id.match(/claude[-_](opus|sonnet|haiku)[-_](\d+)[-_](\d+)/i);
71
+ if (!m) return null;
72
+ const family = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase();
73
+ return `${family} ${m[2]}.${m[3]}`;
74
+ }
75
+
76
+ /**
77
+ * Live context usage from Claude Code's stdin payload (`context_window`).
78
+ * More accurate than inferring from transcripts: it's the CURRENT session's
79
+ * real fill level, updated every refresh. Shape (subset):
80
+ * "context_window": { "context_window_size": 200000, "used_percentage": 68 }
81
+ */
82
+ export function extractContextUsage(stdinJson) {
83
+ const cw = stdinJson && stdinJson.context_window;
84
+ if (!cw || typeof cw !== 'object') return null;
85
+ const usedPct = Number(cw.used_percentage);
86
+ const size = Number(cw.context_window_size);
87
+ if (!Number.isFinite(usedPct)) return null;
88
+ return {
89
+ usedPct,
90
+ size: Number.isFinite(size) && size > 0 ? size : null,
91
+ };
92
+ }
93
+
94
+ export function extractModel(stdinJson) {
95
+ if (!stdinJson || !stdinJson.model) return null;
96
+ const m = stdinJson.model;
97
+ if (typeof m === 'string') return bedrockDisplayFromId(m) || m;
98
+ // Prefer the id when it carries a precise version (e.g. Bedrock IDs); fall
99
+ // back to display_name for the standard Claude Code path where display_name
100
+ // already says "Claude Opus 4.7".
101
+ const idDerived = bedrockDisplayFromId(m.id);
102
+ if (idDerived) return idDerived;
103
+ if (typeof m.display_name === 'string' && m.display_name) return m.display_name;
104
+ if (typeof m.id === 'string' && m.id) return m.id;
105
+ return null;
106
+ }