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/README.en.md +10 -0
- package/README.md +10 -0
- package/bin/cli.js +38 -921
- package/package.json +2 -1
- package/src/brief.js +4 -11
- package/src/cli-args.js +51 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +383 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +70 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +45 -0
- package/src/commands/route-scan.js +201 -0
- package/src/debug.js +29 -0
- package/src/model-rules.js +38 -20
- package/src/parser.js +30 -3
- package/src/route-scan.js +20 -11
- package/src/session-cache.js +153 -0
- package/src/stdin-payload.js +106 -0
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 {
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
}
|