claude-token-saver 3.0.0 → 3.2.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.
- package/README.en.md +37 -25
- package/README.md +37 -25
- package/bin/cli.js +102 -110
- package/package.json +2 -1
- package/presets/ratchet-rules.md +16 -0
- package/src/harness-templates.js +4 -0
- package/src/harness.js +38 -58
- package/src/model-rules.js +174 -0
- package/src/route-scan.js +206 -58
- package/src/session-records.js +112 -0
- package/src/frugon-export.js +0 -211
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-records — parse a Claude Code session transcript into per-API-call
|
|
3
|
+
* records (model, tokens, depth, triggering user prompt, session cwd).
|
|
4
|
+
*
|
|
5
|
+
* This is the shared substrate for episode-level analysis (route-scan and the
|
|
6
|
+
* 3.x tier-classification work): one record per API call, deduplicated by
|
|
7
|
+
* requestId (last-write-wins, matching parser.js).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createReadStream } from 'node:fs';
|
|
11
|
+
import { createInterface } from 'node:readline';
|
|
12
|
+
|
|
13
|
+
/** Strip context-window suffixes like "[1m]" so model ids compare cleanly. */
|
|
14
|
+
export function normalizeModelId(model) {
|
|
15
|
+
return String(model || 'unknown').replace(/\[[^\]]*\]$/, '');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Extract plain text from a Claude transcript message content field. */
|
|
19
|
+
function contentText(content) {
|
|
20
|
+
if (typeof content === 'string') return content;
|
|
21
|
+
if (!Array.isArray(content)) return '';
|
|
22
|
+
return content
|
|
23
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
24
|
+
.map((b) => b.text)
|
|
25
|
+
.join('\n');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse one session transcript into call records.
|
|
30
|
+
* @returns {Promise<Array<{model, timestamp, prompt_tokens, completion_tokens, depth, userText, assistantText, cwd}>>}
|
|
31
|
+
*/
|
|
32
|
+
const MUTATING_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash']);
|
|
33
|
+
const DELEGATION_TOOLS = new Set(['Task', 'Agent']);
|
|
34
|
+
|
|
35
|
+
export async function collectSessionRecords(filePath, { includeContent = true } = {}) {
|
|
36
|
+
const records = new Map();
|
|
37
|
+
let depth = 0;
|
|
38
|
+
let lastUserText = '';
|
|
39
|
+
let lastCwd = '';
|
|
40
|
+
let lastRecord = null;
|
|
41
|
+
|
|
42
|
+
const rl = createInterface({
|
|
43
|
+
input: createReadStream(filePath, { encoding: 'utf8' }),
|
|
44
|
+
crlfDelay: Infinity,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
for await (const line of rl) {
|
|
48
|
+
let entry;
|
|
49
|
+
try {
|
|
50
|
+
entry = JSON.parse(line);
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const msg = entry.message;
|
|
56
|
+
if (typeof entry.cwd === 'string' && entry.cwd) lastCwd = entry.cwd;
|
|
57
|
+
if (entry.type === 'user' && msg) {
|
|
58
|
+
depth += 1;
|
|
59
|
+
const text = contentText(msg.content);
|
|
60
|
+
if (text) lastUserText = text;
|
|
61
|
+
// Tool errors arrive as tool_result blocks in the user entry that
|
|
62
|
+
// follows the assistant call — attribute them to that call's record.
|
|
63
|
+
if (lastRecord && Array.isArray(msg.content)) {
|
|
64
|
+
for (const b of msg.content) {
|
|
65
|
+
if (b && b.type === 'tool_result' && b.is_error) lastRecord.toolErrors += 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (entry.type !== 'assistant' || !msg) continue;
|
|
71
|
+
depth += 1;
|
|
72
|
+
|
|
73
|
+
if (!msg.usage || !msg.id) continue;
|
|
74
|
+
// "<synthetic>" is Claude Code's placeholder for locally-generated
|
|
75
|
+
// entries (e.g. error stubs) — no real API call, nothing to record.
|
|
76
|
+
if (msg.model === '<synthetic>') continue;
|
|
77
|
+
const usage = msg.usage;
|
|
78
|
+
const reqId = entry.requestId || msg.id;
|
|
79
|
+
|
|
80
|
+
let mutatingToolCalls = 0;
|
|
81
|
+
let delegationCalls = 0;
|
|
82
|
+
if (Array.isArray(msg.content)) {
|
|
83
|
+
for (const b of msg.content) {
|
|
84
|
+
if (!b || b.type !== 'tool_use') continue;
|
|
85
|
+
if (MUTATING_TOOLS.has(b.name)) mutatingToolCalls += 1;
|
|
86
|
+
if (DELEGATION_TOOLS.has(b.name)) delegationCalls += 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const prev = records.get(reqId);
|
|
90
|
+
|
|
91
|
+
lastRecord = {
|
|
92
|
+
model: normalizeModelId(msg.model),
|
|
93
|
+
timestamp: entry.timestamp || null,
|
|
94
|
+
prompt_tokens:
|
|
95
|
+
(usage.input_tokens || 0) +
|
|
96
|
+
(usage.cache_creation_input_tokens || 0) +
|
|
97
|
+
(usage.cache_read_input_tokens || 0),
|
|
98
|
+
completion_tokens: usage.output_tokens || 0,
|
|
99
|
+
depth,
|
|
100
|
+
userText: includeContent ? lastUserText : '',
|
|
101
|
+
assistantText: includeContent ? contentText(msg.content) : '',
|
|
102
|
+
cwd: lastCwd,
|
|
103
|
+
// Entries of the same request accumulate tool blocks and errors.
|
|
104
|
+
mutatingToolCalls: (prev?.mutatingToolCalls || 0) + mutatingToolCalls,
|
|
105
|
+
delegationCalls: (prev?.delegationCalls || 0) + delegationCalls,
|
|
106
|
+
toolErrors: prev?.toolErrors || 0,
|
|
107
|
+
};
|
|
108
|
+
records.set(reqId, lastRecord);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return [...records.values()];
|
|
112
|
+
}
|
package/src/frugon-export.js
DELETED
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* frugon export — convert Claude Code session transcripts into the
|
|
3
|
-
* OpenAI-compatible JSONL log format frugon analyzes.
|
|
4
|
-
* (frugon: local LLM cost analyzer — github.com/Rodiun/frugon)
|
|
5
|
-
*
|
|
6
|
-
* One output line per API call:
|
|
7
|
-
* {
|
|
8
|
-
* "model": "claude-opus-4-8",
|
|
9
|
-
* "timestamp": "2026-07-12T02:11:05.123Z",
|
|
10
|
-
* "usage": { "prompt_tokens": 1234, "completion_tokens": 56 },
|
|
11
|
-
* "request": { "messages": [ ...stubs..., { "role": "user", "content": "<last user prompt>" } ] },
|
|
12
|
-
* "response": { "choices": [ { "message": { "role": "assistant", "content": "<reply>" } } ] }
|
|
13
|
-
* }
|
|
14
|
-
*
|
|
15
|
-
* Design notes (kept in sync with frugon 0.2.x internals):
|
|
16
|
-
* - frugon prefers the usage block for token counts, so message content is
|
|
17
|
-
* never re-tokenized — stubs with empty content are safe.
|
|
18
|
-
* - frugon's easy/hard difficulty score reads prompt_tokens, completion_tokens
|
|
19
|
-
* and conversation depth (len(messages) - 1, saturating at 6 turns). We emit
|
|
20
|
-
* up to MAX_STUB_MESSAGES role-alternating stubs so depth survives the
|
|
21
|
-
* export without duplicating the whole conversation into every record.
|
|
22
|
-
* - frugon has no notion of prompt caching: every prompt token is priced at
|
|
23
|
-
* the base input rate. Claude Code sessions are cache-read heavy (~90%+),
|
|
24
|
-
* so raw totals would overstate spend ~10x. By default we fold Anthropic's
|
|
25
|
-
* cache multipliers (5m write 1.25x, 1h write 2x, read 0.1x) into an
|
|
26
|
-
* "effective" prompt_tokens so frugon's dollar figures match reality.
|
|
27
|
-
* Pass cacheWeighted: false for raw physical token counts.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { createReadStream, createWriteStream } from 'node:fs';
|
|
31
|
-
import { createInterface } from 'node:readline';
|
|
32
|
-
import { discoverSessionFiles } from './parser.js';
|
|
33
|
-
|
|
34
|
-
// Depth cap: frugon's turn signal saturates at 6 turns (len(messages)-1 >= 6),
|
|
35
|
-
// so 7 messages carry the maximum-depth signal at minimum size.
|
|
36
|
-
const MAX_STUB_MESSAGES = 7;
|
|
37
|
-
|
|
38
|
-
// Anthropic cache multipliers relative to the base input rate — uniform
|
|
39
|
-
// across model tiers (see src/cost.js PRICING).
|
|
40
|
-
const CACHE_WEIGHTS = { write5m: 1.25, write1h: 2, read: 0.1 };
|
|
41
|
-
|
|
42
|
-
/** Strip context-window suffixes like "[1m]" so frugon's pricing table matches. */
|
|
43
|
-
export function normalizeModelId(model) {
|
|
44
|
-
return String(model || 'unknown').replace(/\[[^\]]*\]$/, '');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Effective prompt tokens: what the call *costs* expressed in base-rate
|
|
49
|
-
* input tokens, so frugon (which prices all prompt tokens at the input rate)
|
|
50
|
-
* reproduces the real cache-discounted spend.
|
|
51
|
-
*/
|
|
52
|
-
export function effectivePromptTokens(r) {
|
|
53
|
-
const tracked = (r.ephemeral5mTokens || 0) + (r.ephemeral1hTokens || 0);
|
|
54
|
-
const untracked = Math.max(0, (r.cacheCreationTokens || 0) - tracked);
|
|
55
|
-
return Math.round(
|
|
56
|
-
(r.inputTokens || 0) +
|
|
57
|
-
((r.ephemeral5mTokens || 0) + untracked) * CACHE_WEIGHTS.write5m +
|
|
58
|
-
(r.ephemeral1hTokens || 0) * CACHE_WEIGHTS.write1h +
|
|
59
|
-
(r.cacheReadTokens || 0) * CACHE_WEIGHTS.read,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Raw physical prompt tokens (input + cache writes + cache reads). */
|
|
64
|
-
export function rawPromptTokens(r) {
|
|
65
|
-
return (r.inputTokens || 0) + (r.cacheCreationTokens || 0) + (r.cacheReadTokens || 0);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Extract plain text from a Claude transcript message content field. */
|
|
69
|
-
function contentText(content) {
|
|
70
|
-
if (typeof content === 'string') return content;
|
|
71
|
-
if (!Array.isArray(content)) return '';
|
|
72
|
-
return content
|
|
73
|
-
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
74
|
-
.map((b) => b.text)
|
|
75
|
-
.join('\n');
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Parse one session transcript into frugon records.
|
|
80
|
-
* Deduplicates by requestId (last-write-wins, matching parser.js) while
|
|
81
|
-
* tracking the conversation depth and last user prompt at each call.
|
|
82
|
-
*/
|
|
83
|
-
export async function collectSessionRecords(filePath, { cacheWeighted = true, includeContent = true } = {}) {
|
|
84
|
-
const records = new Map();
|
|
85
|
-
let depth = 0;
|
|
86
|
-
let lastUserText = '';
|
|
87
|
-
let lastCwd = '';
|
|
88
|
-
|
|
89
|
-
const rl = createInterface({
|
|
90
|
-
input: createReadStream(filePath, { encoding: 'utf8' }),
|
|
91
|
-
crlfDelay: Infinity,
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
for await (const line of rl) {
|
|
95
|
-
let entry;
|
|
96
|
-
try {
|
|
97
|
-
entry = JSON.parse(line);
|
|
98
|
-
} catch {
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const msg = entry.message;
|
|
103
|
-
if (typeof entry.cwd === 'string' && entry.cwd) lastCwd = entry.cwd;
|
|
104
|
-
if (entry.type === 'user' && msg) {
|
|
105
|
-
depth += 1;
|
|
106
|
-
const text = contentText(msg.content);
|
|
107
|
-
if (text) lastUserText = text;
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
if (entry.type !== 'assistant' || !msg) continue;
|
|
111
|
-
depth += 1;
|
|
112
|
-
|
|
113
|
-
if (!msg.usage || !msg.id) continue;
|
|
114
|
-
// "<synthetic>" is Claude Code's placeholder for locally-generated
|
|
115
|
-
// entries (e.g. error stubs) — no real API call, nothing to price.
|
|
116
|
-
if (msg.model === '<synthetic>') continue;
|
|
117
|
-
const usage = msg.usage;
|
|
118
|
-
const reqId = entry.requestId || msg.id;
|
|
119
|
-
const r = {
|
|
120
|
-
inputTokens: usage.input_tokens || 0,
|
|
121
|
-
cacheCreationTokens: usage.cache_creation_input_tokens || 0,
|
|
122
|
-
cacheReadTokens: usage.cache_read_input_tokens || 0,
|
|
123
|
-
ephemeral5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens || 0,
|
|
124
|
-
ephemeral1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens || 0,
|
|
125
|
-
outputTokens: usage.output_tokens || 0,
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
records.set(reqId, {
|
|
129
|
-
model: normalizeModelId(msg.model),
|
|
130
|
-
timestamp: entry.timestamp || null,
|
|
131
|
-
prompt_tokens: cacheWeighted ? effectivePromptTokens(r) : rawPromptTokens(r),
|
|
132
|
-
completion_tokens: r.outputTokens,
|
|
133
|
-
depth,
|
|
134
|
-
userText: includeContent ? lastUserText : '',
|
|
135
|
-
assistantText: includeContent ? contentText(msg.content) : '',
|
|
136
|
-
cwd: lastCwd,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return [...records.values()];
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Build the frugon JSONL object for one collected record. */
|
|
144
|
-
export function toFrugonRecord(rec) {
|
|
145
|
-
const msgCount = Math.max(1, Math.min(rec.depth, MAX_STUB_MESSAGES));
|
|
146
|
-
const messages = [];
|
|
147
|
-
for (let i = 0; i < msgCount - 1; i++) {
|
|
148
|
-
messages.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: '' });
|
|
149
|
-
}
|
|
150
|
-
messages.push({ role: 'user', content: rec.userText || '' });
|
|
151
|
-
|
|
152
|
-
const out = {
|
|
153
|
-
model: rec.model,
|
|
154
|
-
request: { messages },
|
|
155
|
-
response: {
|
|
156
|
-
choices: [{ message: { role: 'assistant', content: rec.assistantText || '' } }],
|
|
157
|
-
},
|
|
158
|
-
usage: {
|
|
159
|
-
prompt_tokens: rec.prompt_tokens,
|
|
160
|
-
completion_tokens: rec.completion_tokens,
|
|
161
|
-
},
|
|
162
|
-
};
|
|
163
|
-
if (rec.timestamp) out.timestamp = rec.timestamp;
|
|
164
|
-
return out;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Export Claude Code transcripts to a frugon-compatible JSONL file.
|
|
169
|
-
*
|
|
170
|
-
* @param {object} options
|
|
171
|
-
* days lookback window (default 30)
|
|
172
|
-
* projectFilter substring match on the project dir name
|
|
173
|
-
* outPath output JSONL path
|
|
174
|
-
* cacheWeighted fold cache pricing into prompt_tokens (default true)
|
|
175
|
-
* includeContent include user prompt / assistant reply text (default true)
|
|
176
|
-
* @returns {Promise<{records:number, sessions:number, models:Object, outPath:string}>}
|
|
177
|
-
*/
|
|
178
|
-
export async function exportFrugonLogs({
|
|
179
|
-
days = 30,
|
|
180
|
-
projectFilter,
|
|
181
|
-
outPath,
|
|
182
|
-
cacheWeighted = true,
|
|
183
|
-
includeContent = true,
|
|
184
|
-
} = {}) {
|
|
185
|
-
const files = await discoverSessionFiles({ days, projectFilter });
|
|
186
|
-
const models = {};
|
|
187
|
-
let recordCount = 0;
|
|
188
|
-
let sessionCount = 0;
|
|
189
|
-
|
|
190
|
-
const stream = createWriteStream(outPath, { encoding: 'utf8' });
|
|
191
|
-
for (const f of files) {
|
|
192
|
-
let recs;
|
|
193
|
-
try {
|
|
194
|
-
recs = await collectSessionRecords(f.path, { cacheWeighted, includeContent });
|
|
195
|
-
} catch {
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
if (recs.length === 0) continue;
|
|
199
|
-
sessionCount += 1;
|
|
200
|
-
for (const rec of recs) {
|
|
201
|
-
stream.write(JSON.stringify(toFrugonRecord(rec)) + '\n');
|
|
202
|
-
models[rec.model] = (models[rec.model] || 0) + 1;
|
|
203
|
-
recordCount += 1;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
await new Promise((resolve, reject) => {
|
|
207
|
-
stream.end((err) => (err ? reject(err) : resolve()));
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
return { records: recordCount, sessions: sessionCount, models, outPath };
|
|
211
|
-
}
|