claude-token-saver 3.5.2 → 3.6.0

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.
@@ -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
+ }
@@ -39,6 +39,16 @@ const DELEGATION_TOOLS = new Set(['Task', 'Agent']);
39
39
  // classifier denials out of 142 is_error results (~25% of the numerator).
40
40
  const REJECTION_RE = /doesn't want to proceed|tool use was rejected|doesn't want to take this action|denied by the claude code auto mode classifier|permission for this action was denied|requires approval/i;
41
41
 
42
+ // Harness-mechanical / self-corrected tool errors also arrive as is_error
43
+ // tool_results, but they reflect edit-ordering mechanics and the agent's own
44
+ // immediate correction loop — not task difficulty. Counting them poisons the
45
+ // rule-health error rate the same way permission denials do (v3.4.2). Measured
46
+ // on a 14-day window they dominate the numerator (e.g. "File has not been read
47
+ // yet" was 7/65 real errors in one project, edit-races + Task-lifecycle several
48
+ // more). Kept NARROW on purpose: ambiguous shell failures ("Exit code N", "File
49
+ // does not exist" on a Read) stay counted — those are genuine difficulty signal.
50
+ 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;
51
+
42
52
  function toolResultText(content) {
43
53
  if (typeof content === 'string') return content;
44
54
  if (!Array.isArray(content)) return '';
@@ -46,8 +56,9 @@ function toolResultText(content) {
46
56
  }
47
57
 
48
58
  function isRealToolError(block) {
49
- return block && block.type === 'tool_result' && block.is_error &&
50
- !REJECTION_RE.test(toolResultText(block.content));
59
+ if (!block || block.type !== 'tool_result' || !block.is_error) return false;
60
+ const txt = toolResultText(block.content);
61
+ return !REJECTION_RE.test(txt) && !SELF_CORRECTED_RE.test(txt);
51
62
  }
52
63
 
53
64
  export async function collectSessionRecords(filePath, { includeContent = true } = {}) {
@@ -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
+ }