tokmax 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eugene Shilow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # tokmax
2
+
3
+ Считает, сколько токенов ты сжёг в **Codex** и **Claude Code** на этой машине,
4
+ переводит это в **API-equivalent** доллары и публикует агрегат на публичный
5
+ лидерборд [tokenmax.ru](https://tokenmax.ru).
6
+
7
+ Одной командой:
8
+
9
+ ```bash
10
+ npx tokmax <nick>
11
+ ```
12
+
13
+ После публикации твой профиль виден на `https://tokenmax.ru/<nick>`.
14
+
15
+ ## Что именно уходит наружу (и что — нет)
16
+
17
+ Это инструмент про доверие, поэтому граница жёсткая и проверяемая в коде
18
+ (`src/adapters/*.mjs`):
19
+
20
+ - **Уходит только агрегат:** числа токенов по моделям (`input`, `output`,
21
+ `cacheCreate`, `cacheRead`, `reasoning`), даты дней и метка машины.
22
+ - **Никогда не уходит:** текст промптов, содержимое файлов, вывод инструментов,
23
+ API-ключи, сырые строки логов. Адаптеры читают из лога ровно четыре-пять
24
+ числовых полей usage + id модели + timestamp и выкидывают всё остальное.
25
+
26
+ Открытый исходник — чтобы это можно было прочитать самому, а не верить на слово.
27
+
28
+ ## Установка и запуск
29
+
30
+ Нужен Node.js >= 18. Зависимостей в рантайме — ноль (только встроенные модули
31
+ Node и глобальный `fetch`).
32
+
33
+ ```bash
34
+ # разово, без установки
35
+ npx tokmax <nick>
36
+
37
+ # или глобально
38
+ npm i -g tokmax
39
+ tokenmax <nick>
40
+ ```
41
+
42
+ ### Опции
43
+
44
+ ```text
45
+ tokenmax <nick> [options]
46
+
47
+ --since YYYY-MM-DD считать только с этого дня (по умолчанию — вся история)
48
+ --key <secret> capability-токен для обновления уже занятого ника
49
+ --api <baseUrl> базовый URL API
50
+ --machine <label> метка машины (по умолчанию hostname)
51
+ --dry-run показать превью и тело запроса, ничего не публиковать
52
+ --yes, -y не спрашивать подтверждение
53
+ --help, -h справка
54
+ ```
55
+
56
+ `--dry-run` показывает превью и точное тело запроса, которое ушло бы на сервер —
57
+ удобно убедиться глазами, что наружу идут только числа.
58
+
59
+ ## Как считается доллар
60
+
61
+ CLI забирает прайсинг с `/api/tmx/pricing` и считает превью **той же формулой**,
62
+ что сервер применяет при публикации, поэтому превью == опубликованное число.
63
+ Стоимость = сумма по моделям от
64
+ `(input·r.input + output·r.output + cacheCreate·r.cacheCreate +
65
+ cacheRead·r.cacheRead + reasoning·r.reasoning) / 1e6`, где `r` — ставки
66
+ per-million для распознанной модели (или fallback для незнакомой).
67
+
68
+ Сервер пересчитывает доллар авторитетно из присланных токенов — CLI ничего не
69
+ может «накрутить».
70
+
71
+ ## Откуда берутся числа
72
+
73
+ - **Claude Code:** `~/.claude*/projects/**/*.jsonl` — usage из assistant-сообщений
74
+ (`message.usage`).
75
+ - **Codex:** `~/.codex/sessions/**/*.jsonl` и `~/.codex/archived_sessions/*.jsonl`
76
+ — события `token_count` (дельты per-turn).
77
+
78
+ Каждый источник изолирован в свой адаптер (`src/adapters/`) и работает
79
+ защитно: битые строки и отсутствующие поля просто пропускаются (по умолчанию 0).
80
+
81
+ ## Capability-токен
82
+
83
+ При первой публикации ника сервер возвращает секрет, который CLI кладёт в
84
+ `~/.config/tokenmax/<nick>.json` (права `0600`). Он нужен, чтобы
85
+ позже **обновлять** свой ник. Не теряй его; для обновления на другой машине
86
+ передай его через `--key <secret>`.
87
+
88
+ ## Endpoint
89
+
90
+ По умолчанию CLI ходит в изолированный tokenmax-деплой
91
+ (`https://chatty-boar-479.convex.site`), где живут hardened-роуты публикации и
92
+ прайсинга. Переопределить можно флагом `--api <baseUrl>`.
93
+
94
+ ## Лицензия
95
+
96
+ MIT — см. [LICENSE](LICENSE).
package/bin/tokmax.mjs ADDED
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // tokenmax — scan local Codex + Claude Code logs, aggregate per-model
4
+ // token usage, preview the API-equivalent $, and publish the aggregate to the
5
+ // tokenmax leaderboard (tokenmax.ru).
6
+ //
7
+ // Happy path is one command:
8
+ // npx tokmax <nick>
9
+ //
10
+ // SAFETY INVARIANT: only numeric token aggregates per model + dates leave this
11
+ // machine. Never prompt text, file contents, API keys, or raw log lines.
12
+
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import readline from 'node:readline';
16
+ import { readFile } from 'node:fs/promises';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ import { scanClaudeCode } from '../src/adapters/claude-code.mjs';
20
+ import { scanCodex } from '../src/adapters/codex.mjs';
21
+ import { aggregate } from '../src/aggregate.mjs';
22
+ import { fetchPricing, previewCost } from '../src/pricing.mjs';
23
+ import { publish } from '../src/publish.mjs';
24
+ import { loadSecret, saveSecret } from '../src/secrets.mjs';
25
+
26
+ const DEFAULT_API = 'https://chatty-boar-479.convex.site';
27
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
+
29
+ function parseArgs(argv) {
30
+ const opts = {
31
+ nick: null,
32
+ since: null,
33
+ key: null,
34
+ api: DEFAULT_API,
35
+ machine: os.hostname(),
36
+ dryRun: false,
37
+ yes: false,
38
+ help: false,
39
+ };
40
+ const rest = [];
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const a = argv[i];
43
+ switch (a) {
44
+ case '--since':
45
+ opts.since = argv[++i];
46
+ break;
47
+ case '--key':
48
+ opts.key = argv[++i];
49
+ break;
50
+ case '--api':
51
+ opts.api = argv[++i];
52
+ break;
53
+ case '--machine':
54
+ opts.machine = argv[++i];
55
+ break;
56
+ case '--dry-run':
57
+ opts.dryRun = true;
58
+ break;
59
+ case '--yes':
60
+ case '-y':
61
+ opts.yes = true;
62
+ break;
63
+ case '--help':
64
+ case '-h':
65
+ opts.help = true;
66
+ break;
67
+ default:
68
+ if (a && a.startsWith('-')) {
69
+ console.error(`Неизвестный флаг: ${a}`);
70
+ process.exit(2);
71
+ }
72
+ rest.push(a);
73
+ }
74
+ }
75
+ if (!opts.nick && rest.length) opts.nick = rest[0];
76
+ if (opts.api) opts.api = opts.api.replace(/\/+$/, '');
77
+ return opts;
78
+ }
79
+
80
+ const HELP = `tokmax <nick> [options]
81
+
82
+ Сканирует локальные логи Codex и Claude Code, считает токены по моделям,
83
+ показывает API-equivalent в долларах и публикует агрегат на лидерборд
84
+ tokenmax.ru. Наружу уходят только числа — не логи и не ключи.
85
+
86
+ Options:
87
+ --since YYYY-MM-DD считать только с этого дня (по умолчанию — вся история)
88
+ --key <secret> capability-токен для обновления уже занятого ника
89
+ --api <baseUrl> базовый URL API (по умолчанию tokenmax deployment)
90
+ --machine <label> метка машины (по умолчанию hostname)
91
+ --dry-run показать превью и тело запроса, ничего не публиковать
92
+ --yes, -y не спрашивать подтверждение
93
+ --help, -h показать эту справку`;
94
+
95
+ function fmtUsd(n) {
96
+ return n.toLocaleString('en-US', {
97
+ minimumFractionDigits: 2,
98
+ maximumFractionDigits: 2,
99
+ });
100
+ }
101
+
102
+ function fmtInt(n) {
103
+ return n.toLocaleString('en-US');
104
+ }
105
+
106
+ async function readPackageVersion() {
107
+ try {
108
+ const pkg = JSON.parse(
109
+ await readFile(path.join(__dirname, '..', 'package.json'), 'utf8'),
110
+ );
111
+ return pkg.version || '0.0.0';
112
+ } catch {
113
+ return '0.0.0';
114
+ }
115
+ }
116
+
117
+ function confirm(question) {
118
+ return new Promise((resolve) => {
119
+ const rl = readline.createInterface({
120
+ input: process.stdin,
121
+ output: process.stdout,
122
+ });
123
+ rl.question(question, (answer) => {
124
+ rl.close();
125
+ resolve(/^y(es)?$/i.test(answer.trim()));
126
+ });
127
+ });
128
+ }
129
+
130
+ async function main() {
131
+ const opts = parseArgs(process.argv.slice(2));
132
+
133
+ if (opts.help) {
134
+ console.log(HELP);
135
+ return 0;
136
+ }
137
+ if (!opts.nick) {
138
+ console.error('Укажи ник: npx tokmax <nick> (--help для справки)');
139
+ return 2;
140
+ }
141
+ if (opts.since && !/^\d{4}-\d{2}-\d{2}$/.test(opts.since)) {
142
+ console.error(`--since должен быть YYYY-MM-DD, получено: ${opts.since}`);
143
+ return 2;
144
+ }
145
+
146
+ const nick = opts.nick.trim();
147
+ const nickKey = nick.toLowerCase();
148
+ const cliVersion = await readPackageVersion();
149
+
150
+ console.log(`tokmax v${cliVersion} · ник: ${nick}`);
151
+ console.log('Сканирую локальные логи…');
152
+
153
+ const [claude, codex] = await Promise.all([scanClaudeCode(), scanCodex()]);
154
+
155
+ console.log(
156
+ ` Claude Code: ${claude.sessionCount} сессий · Codex: ${codex.sessionCount} сессий`,
157
+ );
158
+
159
+ const agg = aggregate([claude, codex], { since: opts.since });
160
+
161
+ if (!agg.models.length || agg.totalTokens === 0) {
162
+ console.error(
163
+ 'Не нашёл токенов в логах (с учётом --since). Публиковать нечего.',
164
+ );
165
+ return 1;
166
+ }
167
+
168
+ let pricing;
169
+ try {
170
+ pricing = await fetchPricing(opts.api);
171
+ } catch (err) {
172
+ console.error(`Не удалось получить прайсинг: ${err.message}`);
173
+ return 1;
174
+ }
175
+
176
+ const usd = previewCost(pricing, agg.models);
177
+
178
+ console.log(
179
+ `Период: ${agg.firstDay} → ${agg.lastDay} (всё, что нашлось)` +
180
+ (opts.since ? ` · фильтр --since ${opts.since}` : ''),
181
+ );
182
+ console.log('Модели:');
183
+ for (const m of agg.models) {
184
+ const tot = m.input + m.output + m.cacheCreate + m.cacheRead + m.reasoning;
185
+ console.log(` ${m.tool}/${m.model}: ${fmtInt(tot)} токенов`);
186
+ }
187
+ console.log(`Всего токенов: ${fmtInt(agg.totalTokens)}`);
188
+ console.log(`API-equivalent: $${fmtUsd(usd)}`);
189
+ console.log('Наружу уйдёт только агрегат (числа), не логи и не ключи.');
190
+
191
+ const body = {
192
+ nick,
193
+ cliVersion,
194
+ pricingVersion: pricing.version,
195
+ firstDay: agg.firstDay,
196
+ lastDay: agg.lastDay,
197
+ machineLabel: opts.machine,
198
+ models: agg.models,
199
+ daily: agg.daily,
200
+ };
201
+
202
+ if (opts.dryRun) {
203
+ console.log('\n--dry-run: тело запроса, которое было бы отправлено:');
204
+ console.log(JSON.stringify(body, null, 2));
205
+ console.log('\n(ничего не опубликовано)');
206
+ return 0;
207
+ }
208
+
209
+ // Capability token: explicit --key wins, else a saved secret for this nick.
210
+ const savedSecret = await loadSecret(nickKey);
211
+ const secret = opts.key || savedSecret;
212
+ if (secret) body.secret = secret;
213
+
214
+ if (!opts.yes) {
215
+ const ok = await confirm(`Опубликовать как ${nick}? [y/N] `);
216
+ if (!ok) {
217
+ console.log('Отменено.');
218
+ return 0;
219
+ }
220
+ }
221
+
222
+ const { status, json } = await publish(opts.api, body);
223
+
224
+ if (!json) {
225
+ console.error(`Неожиданный ответ сервера: HTTP ${status}`);
226
+ return 1;
227
+ }
228
+
229
+ if (json.ok) {
230
+ if (json.created && json.secret) {
231
+ const file = await saveSecret(nickKey, {
232
+ nick: json.nick || nickKey,
233
+ secret: json.secret,
234
+ url: json.url,
235
+ createdAt: Date.now(),
236
+ });
237
+ console.log(`\nСохранил capability-токен: ${file} (chmod 600)`);
238
+ console.log('Не теряй его — он нужен для будущих обновлений ника.');
239
+ } else {
240
+ console.log('\nОбновил существующий профиль.');
241
+ }
242
+ console.log(`costUsd: $${fmtUsd(json.costUsd)} · токенов: ${fmtInt(json.totalTokens)}`);
243
+ if (json.suspicious) {
244
+ console.log('⚠️ Сервер пометил сабмит как suspicious (на ручную проверку).');
245
+ }
246
+ console.log(`\n ${json.url}\n`);
247
+ return 0;
248
+ }
249
+
250
+ // Documented error reasons.
251
+ switch (json.reason) {
252
+ case 'nick_taken':
253
+ console.error(`Ник занят: ${json.message || nick}`);
254
+ if (json.suggestion) console.error(`Попробуй: ${json.suggestion}`);
255
+ console.error(
256
+ 'Если это твой ник — обнови через --key <secret> (capability-токен).',
257
+ );
258
+ return 1;
259
+ case 'rate_limited':
260
+ console.error(`Слишком часто: ${json.message || 'rate limited'}`);
261
+ return 1;
262
+ case 'nick_invalid':
263
+ console.error(`Недопустимый ник: ${json.message || nick}`);
264
+ return 1;
265
+ case 'empty_usage':
266
+ console.error('Сервер: пустой агрегат (empty_usage).');
267
+ return 1;
268
+ case 'invalid_payload':
269
+ console.error(`Некорректное тело запроса: ${json.message || ''}`);
270
+ return 1;
271
+ default:
272
+ console.error(`Ошибка публикации (HTTP ${status}): ${JSON.stringify(json)}`);
273
+ return 1;
274
+ }
275
+ }
276
+
277
+ main()
278
+ .then((code) => process.exit(code || 0))
279
+ .catch((err) => {
280
+ console.error(`Сбой: ${err && err.stack ? err.stack : err}`);
281
+ process.exit(1);
282
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "tokmax",
3
+ "version": "0.1.0",
4
+ "description": "Scan your local Codex + Claude Code logs, aggregate per-model token usage, and publish the API-equivalent $ to the tokenmax leaderboard (tokenmax.ru). Aggregates only — never your prompts, files, or keys.",
5
+ "type": "module",
6
+ "bin": {
7
+ "tokmax": "bin/tokmax.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/eugeneshilow/tokmax.git"
16
+ },
17
+ "homepage": "https://tokenmax.ru",
18
+ "keywords": [
19
+ "codex",
20
+ "claude-code",
21
+ "tokens",
22
+ "usage",
23
+ "leaderboard",
24
+ "tokenmax",
25
+ "vibecoding"
26
+ ],
27
+ "files": [
28
+ "bin/",
29
+ "src/",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "scripts": {
34
+ "start": "node bin/tokmax.mjs"
35
+ }
36
+ }
@@ -0,0 +1,86 @@
1
+ // Claude Code adapter.
2
+ //
3
+ // Reads ~/.claude*/projects/**/*.jsonl. Each line is a JSON event; assistant
4
+ // messages carry message.usage and message.model plus a top-level ISO
5
+ // timestamp. We extract ONLY numeric token counts + the model id + the day.
6
+ //
7
+ // SAFETY INVARIANT: this module never reads, stores, or transmits prompt text,
8
+ // tool output, file contents, or keys. It pulls four integers and a model name
9
+ // out of `message.usage` / `message.model` and discards everything else.
10
+
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { readdir, readFile } from 'node:fs/promises';
14
+ import { num, isoDay, walkJsonl, lines } from '../util.mjs';
15
+
16
+ /** All ~/.claude* directories that contain a projects/ tree. */
17
+ async function claudeProjectRoots() {
18
+ const home = os.homedir();
19
+ let entries;
20
+ try {
21
+ entries = await readdir(home, { withFileTypes: true });
22
+ } catch {
23
+ return [];
24
+ }
25
+ const roots = [];
26
+ for (const e of entries) {
27
+ // Match the .claude directory and any sibling like .claude-foo, but not
28
+ // files such as .claude.json.
29
+ if (e.isDirectory() && e.name.startsWith('.claude')) {
30
+ roots.push(path.join(home, e.name, 'projects'));
31
+ }
32
+ }
33
+ return roots;
34
+ }
35
+
36
+ /**
37
+ * @returns {Promise<{ tool:'claude-code', sessionCount:number, records:Array }>}
38
+ * records: { tool, model, date, input, output, cacheCreate, cacheRead, reasoning }
39
+ */
40
+ export async function scanClaudeCode() {
41
+ const records = [];
42
+ let sessionCount = 0;
43
+
44
+ for (const root of await claudeProjectRoots()) {
45
+ for await (const file of walkJsonl(root)) {
46
+ let text;
47
+ try {
48
+ text = await readFile(file, 'utf8');
49
+ } catch {
50
+ continue;
51
+ }
52
+ sessionCount++;
53
+ for (const line of lines(text)) {
54
+ let d;
55
+ try {
56
+ d = JSON.parse(line);
57
+ } catch {
58
+ continue; // skip malformed lines defensively
59
+ }
60
+ if (!d || d.type !== 'assistant') continue;
61
+ const msg = d.message;
62
+ const usage = msg && msg.usage;
63
+ if (!usage) continue;
64
+
65
+ const model = String((msg && msg.model) || 'unknown');
66
+ if (model === '<synthetic>') continue; // not a real billed turn
67
+
68
+ const date = isoDay(d.timestamp);
69
+ if (!date) continue;
70
+
71
+ records.push({
72
+ tool: 'claude-code',
73
+ model,
74
+ date,
75
+ input: num(usage.input_tokens),
76
+ output: num(usage.output_tokens),
77
+ cacheCreate: num(usage.cache_creation_input_tokens),
78
+ cacheRead: num(usage.cache_read_input_tokens),
79
+ reasoning: 0,
80
+ });
81
+ }
82
+ }
83
+ }
84
+
85
+ return { tool: 'claude-code', sessionCount, records };
86
+ }
@@ -0,0 +1,113 @@
1
+ // Codex adapter.
2
+ //
3
+ // Reads ~/.codex/sessions/**/*.jsonl and ~/.codex/archived_sessions/*.jsonl.
4
+ // Each line is a JSON event with { timestamp, type, payload }.
5
+ //
6
+ // Usage events look like:
7
+ // { type:'event_msg', payload:{ type:'token_count', info:{
8
+ // total_token_usage:{...cumulative...},
9
+ // last_token_usage:{ input_tokens, cached_input_tokens, output_tokens,
10
+ // reasoning_output_tokens, total_tokens } } } }
11
+ // `last_token_usage` is the per-turn DELTA; summing the deltas across a session
12
+ // equals the final `total_token_usage` (verified on real logs), so deltas give
13
+ // correct per-day buckets with no double counting.
14
+ //
15
+ // Codex accounting nests its buckets: input_tokens INCLUDES cached_input_tokens,
16
+ // and output_tokens INCLUDES reasoning_output_tokens (input + output ==
17
+ // total_tokens). The leaderboard server treats input/output/cacheCreate/
18
+ // cacheRead/reasoning as DISJOINT additive buckets, so we split them:
19
+ // cacheRead = cached_input_tokens
20
+ // input = input_tokens - cached_input_tokens
21
+ // reasoning = reasoning_output_tokens
22
+ // output = output_tokens - reasoning_output_tokens
23
+ // cacheCreate = 0 (Codex does not report cache creation separately)
24
+ //
25
+ // The model id is not on the token_count event; it is declared on `turn_context`
26
+ // (payload.model) and `session_meta`, which precede the turn's usage. We track
27
+ // the most recent model id and attribute usage to it.
28
+ //
29
+ // SAFETY INVARIANT: this module never reads, stores, or transmits prompt text,
30
+ // tool output, file contents, or keys — only token integers, the model id, and
31
+ // the day.
32
+
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+ import { readFile } from 'node:fs/promises';
36
+ import { num, isoDay, walkJsonl, lines } from '../util.mjs';
37
+
38
+ const DEFAULT_MODEL = 'gpt-5.5';
39
+
40
+ /**
41
+ * @returns {Promise<{ tool:'codex', sessionCount:number, records:Array }>}
42
+ */
43
+ export async function scanCodex() {
44
+ const home = os.homedir();
45
+ const dirs = [
46
+ path.join(home, '.codex', 'sessions'),
47
+ path.join(home, '.codex', 'archived_sessions'),
48
+ ];
49
+
50
+ const records = [];
51
+ let sessionCount = 0;
52
+
53
+ for (const dir of dirs) {
54
+ for await (const file of walkJsonl(dir)) {
55
+ let text;
56
+ try {
57
+ text = await readFile(file, 'utf8');
58
+ } catch {
59
+ continue;
60
+ }
61
+ sessionCount++;
62
+
63
+ let curModel = DEFAULT_MODEL;
64
+ for (const line of lines(text)) {
65
+ let d;
66
+ try {
67
+ d = JSON.parse(line);
68
+ } catch {
69
+ continue;
70
+ }
71
+ const p = (d && d.payload) || {};
72
+
73
+ // Track the active model from whichever event last declared one.
74
+ if (typeof p.model === 'string' && p.model) {
75
+ curModel = p.model;
76
+ } else if (
77
+ p.collaboration_mode &&
78
+ p.collaboration_mode.settings &&
79
+ typeof p.collaboration_mode.settings.model === 'string' &&
80
+ p.collaboration_mode.settings.model
81
+ ) {
82
+ curModel = p.collaboration_mode.settings.model;
83
+ }
84
+
85
+ if (p.type !== 'token_count') continue;
86
+ const info = p.info || {};
87
+ const lt = info.last_token_usage;
88
+ if (!lt) continue;
89
+
90
+ const date = isoDay(d.timestamp);
91
+ if (!date) continue;
92
+
93
+ const inTok = num(lt.input_tokens);
94
+ const cached = num(lt.cached_input_tokens);
95
+ const outTok = num(lt.output_tokens);
96
+ const reasoning = num(lt.reasoning_output_tokens);
97
+
98
+ records.push({
99
+ tool: 'codex',
100
+ model: curModel,
101
+ date,
102
+ input: Math.max(0, inTok - cached),
103
+ output: Math.max(0, outTok - reasoning),
104
+ cacheCreate: 0,
105
+ cacheRead: cached,
106
+ reasoning,
107
+ });
108
+ }
109
+ }
110
+ }
111
+
112
+ return { tool: 'codex', sessionCount, records };
113
+ }
@@ -0,0 +1,72 @@
1
+ // Merge raw adapter records into the publish payload shape:
2
+ // - models: one disjoint-bucket entry per (tool, model)
3
+ // - daily : per-day { codexTokens, claudeTokens } totals
4
+ // - firstDay / lastDay actually observed (after --since filtering)
5
+ //
6
+ // All inputs are numeric aggregates already — this module is pure arithmetic.
7
+
8
+ /**
9
+ * @param {Array<{records:Array}>} adapterResults
10
+ * @param {{ since?: string|null }} opts since = inclusive YYYY-MM-DD lower bound
11
+ */
12
+ export function aggregate(adapterResults, { since = null } = {}) {
13
+ const modelMap = new Map(); // `${tool}|${model}` -> bucket
14
+ const dayMap = new Map(); // date -> { date, codexTokens, claudeTokens }
15
+ let firstDay = null;
16
+ let lastDay = null;
17
+
18
+ for (const result of adapterResults) {
19
+ for (const rec of result.records) {
20
+ if (since && rec.date < since) continue;
21
+
22
+ if (!firstDay || rec.date < firstDay) firstDay = rec.date;
23
+ if (!lastDay || rec.date > lastDay) lastDay = rec.date;
24
+
25
+ const key = `${rec.tool}|${rec.model}`;
26
+ let bucket = modelMap.get(key);
27
+ if (!bucket) {
28
+ bucket = {
29
+ model: rec.model,
30
+ tool: rec.tool,
31
+ input: 0,
32
+ output: 0,
33
+ cacheCreate: 0,
34
+ cacheRead: 0,
35
+ reasoning: 0,
36
+ };
37
+ modelMap.set(key, bucket);
38
+ }
39
+ bucket.input += rec.input;
40
+ bucket.output += rec.output;
41
+ bucket.cacheCreate += rec.cacheCreate;
42
+ bucket.cacheRead += rec.cacheRead;
43
+ bucket.reasoning += rec.reasoning;
44
+
45
+ const total =
46
+ rec.input + rec.output + rec.cacheCreate + rec.cacheRead + rec.reasoning;
47
+ let day = dayMap.get(rec.date);
48
+ if (!day) {
49
+ day = { date: rec.date, codexTokens: 0, claudeTokens: 0 };
50
+ dayMap.set(rec.date, day);
51
+ }
52
+ if (rec.tool === 'codex') day.codexTokens += total;
53
+ else day.claudeTokens += total;
54
+ }
55
+ }
56
+
57
+ const models = [...modelMap.values()].filter(
58
+ (m) =>
59
+ m.input + m.output + m.cacheCreate + m.cacheRead + m.reasoning > 0,
60
+ );
61
+ const daily = [...dayMap.values()].sort((a, b) =>
62
+ a.date < b.date ? -1 : a.date > b.date ? 1 : 0,
63
+ );
64
+
65
+ const totalTokens = models.reduce(
66
+ (s, m) =>
67
+ s + m.input + m.output + m.cacheCreate + m.cacheRead + m.reasoning,
68
+ 0,
69
+ );
70
+
71
+ return { models, daily, firstDay, lastDay, totalTokens };
72
+ }
@@ -0,0 +1,67 @@
1
+ // Pricing factpack: fetch the authoritative per-million rates the server uses,
2
+ // resolve a model id to its rates, and compute the API-equivalent $ with the
3
+ // exact same formula the server applies — so the CLI preview equals the
4
+ // published number.
5
+
6
+ /** GET <apiBase>/api/tmx/pricing */
7
+ export async function fetchPricing(apiBase) {
8
+ const res = await fetch(`${apiBase}/api/tmx/pricing`);
9
+ if (!res.ok) throw new Error(`pricing request failed: HTTP ${res.status}`);
10
+ return res.json();
11
+ }
12
+
13
+ /**
14
+ * Resolve a model id to its perMillion rates.
15
+ * Order: exact id -> alias -> longest prefix match (family heuristic) -> fallback.
16
+ */
17
+ export function resolveRates(pricing, model) {
18
+ const id = String(model || '').toLowerCase();
19
+ const models = pricing.models || [];
20
+
21
+ for (const m of models) {
22
+ if (String(m.id).toLowerCase() === id) return m.perMillion;
23
+ }
24
+ for (const m of models) {
25
+ if ((m.aliases || []).some((a) => String(a).toLowerCase() === id)) {
26
+ return m.perMillion;
27
+ }
28
+ }
29
+ // Family heuristic: pick the model whose id/alias is the longest prefix of the
30
+ // requested id (e.g. "claude-opus-4-8-20990101" -> claude-opus-4-8). Longest
31
+ // wins so "gpt-5.5" never collapses to the shorter "gpt-5" alias of a mini.
32
+ let best = null;
33
+ let bestLen = -1;
34
+ for (const m of models) {
35
+ for (const cand of [m.id, ...(m.aliases || [])]) {
36
+ const c = String(cand).toLowerCase();
37
+ if (id.startsWith(c) && c.length > bestLen) {
38
+ best = m.perMillion;
39
+ bestLen = c.length;
40
+ }
41
+ }
42
+ }
43
+ if (best) return best;
44
+
45
+ return pricing.fallback;
46
+ }
47
+
48
+ /** costUsd for a single model's token buckets, matching the server formula. */
49
+ export function costForModel(rates, tok) {
50
+ return (
51
+ (tok.input * rates.input +
52
+ tok.output * rates.output +
53
+ tok.cacheCreate * rates.cacheCreate +
54
+ tok.cacheRead * rates.cacheRead +
55
+ tok.reasoning * rates.reasoning) /
56
+ 1e6
57
+ );
58
+ }
59
+
60
+ /** Sum costUsd across all aggregated model buckets. */
61
+ export function previewCost(pricing, models) {
62
+ let usd = 0;
63
+ for (const m of models) {
64
+ usd += costForModel(resolveRates(pricing, m.model), m);
65
+ }
66
+ return usd;
67
+ }
@@ -0,0 +1,17 @@
1
+ // Thin POST wrapper for /api/tmx/publish. Returns { status, json } and never
2
+ // throws on non-2xx — the CLI decides how to present each documented reason.
3
+
4
+ export async function publish(apiBase, body) {
5
+ const res = await fetch(`${apiBase}/api/tmx/publish`, {
6
+ method: 'POST',
7
+ headers: { 'content-type': 'application/json' },
8
+ body: JSON.stringify(body),
9
+ });
10
+ let json = null;
11
+ try {
12
+ json = await res.json();
13
+ } catch {
14
+ json = null;
15
+ }
16
+ return { status: res.status, json };
17
+ }
@@ -0,0 +1,39 @@
1
+ // Capability-token storage at ~/.config/tokenmax/<nick>.json.
2
+ // The secret is the only credential that lets a nick be updated later. We store
3
+ // it 0600 in a 0700 directory. No prompts, files, or keys ever go here.
4
+
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
8
+
9
+ function configDir() {
10
+ return path.join(os.homedir(), '.config', 'tokenmax');
11
+ }
12
+
13
+ function secretFile(nick) {
14
+ return path.join(configDir(), `${nick}.json`);
15
+ }
16
+
17
+ /** Return the saved secret for nick, or null if none/unreadable. */
18
+ export async function loadSecret(nick) {
19
+ try {
20
+ const text = await readFile(secretFile(nick), 'utf8');
21
+ const data = JSON.parse(text);
22
+ return typeof data.secret === 'string' && data.secret ? data.secret : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Persist { nick, secret, url, createdAt } with restrictive permissions.
30
+ * @returns absolute path written
31
+ */
32
+ export async function saveSecret(nick, data) {
33
+ await mkdir(configDir(), { recursive: true, mode: 0o700 });
34
+ const file = secretFile(nick);
35
+ await writeFile(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
36
+ // mkdir/writeFile mode is umask-masked; force the bits explicitly.
37
+ await chmod(file, 0o600).catch(() => {});
38
+ return file;
39
+ }
package/src/util.mjs ADDED
@@ -0,0 +1,46 @@
1
+ // Shared, dependency-free helpers used by the adapters and aggregator.
2
+ // SAFETY: nothing here ever touches prompt text, file contents, or keys —
3
+ // only numbers, model id strings, and ISO timestamps.
4
+
5
+ import { readdir } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+
8
+ /** Coerce any value into a finite, non-negative integer token count. */
9
+ export function num(v) {
10
+ const n = Number(v);
11
+ if (!Number.isFinite(n) || n < 0) return 0;
12
+ return Math.round(n);
13
+ }
14
+
15
+ /** Extract the YYYY-MM-DD day from an ISO timestamp, or null if unusable. */
16
+ export function isoDay(ts) {
17
+ if (typeof ts !== 'string' || ts.length < 10) return null;
18
+ const day = ts.slice(0, 10);
19
+ return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
20
+ }
21
+
22
+ /** Recursively yield every *.jsonl file under dir (missing dir => nothing). */
23
+ export async function* walkJsonl(dir) {
24
+ let entries;
25
+ try {
26
+ entries = await readdir(dir, { withFileTypes: true });
27
+ } catch {
28
+ return;
29
+ }
30
+ for (const e of entries) {
31
+ const p = path.join(dir, e.name);
32
+ if (e.isDirectory()) {
33
+ yield* walkJsonl(p);
34
+ } else if (e.isFile() && e.name.endsWith('.jsonl')) {
35
+ yield p;
36
+ }
37
+ }
38
+ }
39
+
40
+ /** Iterate non-empty trimmed lines of a string. */
41
+ export function* lines(text) {
42
+ for (const line of text.split('\n')) {
43
+ const t = line.trim();
44
+ if (t) yield t;
45
+ }
46
+ }