Recently Written · git

ideamine

An idea inbox for Claude Code: /idea saves ideas at zero tokens; Claude triages them and routes each to the cheapest model that can build it.

git clone https://github.com/equwal/ideamine

Log | Files | Refs


src/usage.js (7857 bytes)

1 // Token use and price for each project. Claude Code writes one JSONL transcript for each session in
2 // ~/.claude/projects. Each answer of the model carries its model name and its token counts, and each
3 // line carries the folder of the session. This module adds those numbers up for each day, project,
4 // and model, and it puts a price on them.
5 //
6 // The scan is incremental: usage-scan.json keeps the size of each transcript that it read, so a new
7 // scan reads only the new bytes. `ideamine usage` prints the table, and the dashboard shows it.
8 //
9 // The price is the price of the API. A Claude subscription does not bill for each token, so for a
10 // subscription the number is what the same work would cost through the API.
11 
12 import fs from 'node:fs';
13 import os from 'node:os';
14 import path from 'node:path';
15 import * as store from './store.js';
16 
17 /**
18  * Price in dollars for each million tokens (2026-09-22). `read` is the price of a cache read.
19  * A cache write costs 1.25 times the input price with the 5-minute lifetime, and 2 times with the
20  * 1-hour lifetime.
21  */
22 export const PRICES = {
23   'claude-fable-5-1': { input: 10, output: 50, read: 0.25 },
24   'claude-mythos-5-1': { input: 10, output: 50, read: 0.25 },
25   'claude-fable-5': { input: 10, output: 50, read: 1 },
26   'claude-mythos-5': { input: 10, output: 50, read: 1 },
27   'claude-opus-5-5': { input: 4, output: 20, read: 0.2 },
28   'claude-opus-5': { input: 5, output: 25, read: 0.5 },
29   'claude-opus-4-8': { input: 5, output: 25, read: 0.5 },
30   'claude-opus-4-7': { input: 5, output: 25, read: 0.5 },
31   'claude-opus-4-6': { input: 5, output: 25, read: 0.5 },
32   'claude-sonnet-5': { input: 2, output: 10, read: 0.2 },
33   'claude-sonnet-4-6': { input: 3, output: 15, read: 0.3 },
34   'claude-haiku-4-5': { input: 1, output: 5, read: 0.1 },
35 };
36 
37 const MILLION = 1e6;
38 const WRITE_5M = 1.25; // times the input price
39 const WRITE_1H = 2;
40 
41 const statePath = () => path.join(store.home(), 'usage-scan.json');
42 
43 /** The name of the model without the date at its end, for example claude-haiku-4-5-20251001. */
44 export function modelName(raw) {
45   return String(raw || '')
46     .trim()
47     .replace(/-\d{8}$/, '');
48 }
49 
50 /** The price of one row in dollars. An unknown model costs 0, and `priced` says so. */
51 export function cost(row) {
52   const p = PRICES[modelName(row.model)];
53   if (!p) return 0;
54   const write = (row.write5m || 0) * p.input * WRITE_5M + (row.write1h || 0) * p.input * WRITE_1H;
55   return ((row.input || 0) * p.input + (row.output || 0) * p.output + (row.read || 0) * p.read + write) / MILLION;
56 }
57 
58 /** True when the price list knows this model. */
59 export const priced = (model) => Boolean(PRICES[modelName(model)]);
60 
61 const rowKey = (r) => `${r.day}|${r.project}|${r.model}`;
62 const emptyRow = (day, project, model) => ({ day, project, model, messages: 0, input: 0, output: 0, read: 0, write5m: 0, write1h: 0 });
63 
64 /**
65  * Add the numbers of one transcript line to `rows`. A line without token counts, or from a model
66  * that answered nothing, changes nothing.
67  */
68 export function addLine(rows, entry, seen) {
69   const usage = entry?.message?.usage;
70   if (!usage || entry.type !== 'assistant') return false;
71   const model = modelName(entry.message.model);
72   if (!model || model === '<synthetic>') return false;
73   const id = entry.message.id;
74   if (id && seen) {
75     if (seen.has(id)) return false; // the same answer twice in one file
76     seen.add(id);
77   }
78   const day = String(entry.timestamp || '').slice(0, 10);
79   if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return false;
80   const project = entry.cwd || 'unknown';
81   const key = `${day}|${project}|${model}`;
82   const row = rows.get(key) || emptyRow(day, project, model);
83   const created = usage.cache_creation || {};
84   row.messages += 1;
85   row.input += usage.input_tokens || 0;
86   row.output += usage.output_tokens || 0;
87   row.read += usage.cache_read_input_tokens || 0;
88   // Older transcripts have only the total of the cache writes. Those count as the 5-minute price.
89   const w1h = created.ephemeral_1h_input_tokens || 0;
90   const w5m = created.ephemeral_5m_input_tokens ?? Math.max(0, (usage.cache_creation_input_tokens || 0) - w1h);
91   row.write5m += w5m;
92   row.write1h += w1h;
93   rows.set(key, row);
94   return true;
95 }
96 
97 /** Read one transcript from byte `from` to its end, and add its lines to `rows`. */
98 function readFile(file, from, rows) {
99   const fd = fs.openSync(file, 'r');
100   try {
101     const size = fs.fstatSync(fd).size;
102     if (size <= from) return size;
103     const buf = Buffer.alloc(size - from);
104     fs.readSync(fd, buf, 0, buf.length, from);
105     const text = buf.toString('utf8');
106     // A read that starts inside a line drops that half line, and the next scan gets the whole file.
107     const seen = new Set();
108     for (const line of text.split('\n')) {
109       if (!line.startsWith('{')) continue;
110       try {
111         addLine(rows, JSON.parse(line), seen);
112       } catch {
113         // A line that a crash cut in half, or a line that is not JSON.
114       }
115     }
116     return size;
117   } finally {
118     fs.closeSync(fd);
119   }
120 }
121 
122 function readState() {
123   try {
124     const state = JSON.parse(fs.readFileSync(statePath(), 'utf8'));
125     if (state && state.version === 1) return state;
126   } catch {
127     // No scan yet, or a file that another version wrote.
128   }
129   return { version: 1, files: {}, rows: [] };
130 }
131 
132 function writeState(state) {
133   fs.mkdirSync(store.home(), { recursive: true });
134   store.writeAtomic(statePath(), `${JSON.stringify(state)}\n`);
135 }
136 
137 /** The folder with the transcripts of Claude Code, the same one that the prompt log reads. */
138 export const transcriptsDir = () => path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects');
139 
140 /**
141  * Read the new bytes of each transcript, add them to the numbers from before, and save the state.
142  * Resolves to { rows, files, read }: the rows for each day, project, and model, how many transcripts
143  * there are, and how many of them had new bytes.
144  */
145 export function scan({ dir = transcriptsDir() } = {}) {
146   const state = readState();
147   const rows = new Map(state.rows.map((r) => [rowKey(r), r]));
148   let files = [];
149   try {
150     files = fs.readdirSync(dir, { recursive: true }).filter((f) => String(f).endsWith('.jsonl'));
151   } catch {
152     return { rows: [...rows.values()], files: 0, read: 0 };
153   }
154   let read = 0;
155   for (const name of files) {
156     const file = path.join(dir, String(name));
157     const from = state.files[String(name)] || 0;
158     let size;
159     try {
160       size = readFile(file, from, rows);
161     } catch {
162       continue; // a file that another process writes, or that is gone
163     }
164     if (size !== from) read += 1;
165     state.files[String(name)] = size;
166   }
167   state.rows = [...rows.values()];
168   writeState(state);
169   return { rows: state.rows, files: files.length, read };
170 }
171 
172 /** The rows of the last `days` days, newest day first. 0 days means every day. */
173 export function since(rows, days) {
174   if (!days) return rows;
175   const day = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
176   return rows.filter((r) => r.day >= day);
177 }
178 
179 /** One row for each project: the tokens, the price, and the price for each model. */
180 export function byProject(rows) {
181   const out = new Map();
182   for (const r of rows) {
183     const p = out.get(r.project) || { project: r.project, messages: 0, input: 0, output: 0, read: 0, write5m: 0, write1h: 0, cost: 0, models: {} };
184     for (const k of ['messages', 'input', 'output', 'read', 'write5m', 'write1h']) p[k] += r[k] || 0;
185     const m = p.models[r.model] || { model: r.model, messages: 0, input: 0, output: 0, read: 0, write5m: 0, write1h: 0, cost: 0 };
186     for (const k of ['messages', 'input', 'output', 'read', 'write5m', 'write1h']) m[k] += r[k] || 0;
187     m.cost += cost(r);
188     p.models[r.model] = m;
189     p.cost += cost(r);
190     out.set(r.project, p);
191   }
192   return [...out.values()].sort((a, b) => b.cost - a.cost);
193 }