reread-cli 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +205 -2
- package/bin/reread.js +5 -0
- package/package.json +59 -4
- package/src/analyze.js +430 -0
- package/src/antigravity.js +190 -0
- package/src/args.js +155 -0
- package/src/claude.js +208 -0
- package/src/cli.js +127 -0
- package/src/codex.js +135 -0
- package/src/files.js +116 -0
- package/src/findings.js +122 -0
- package/src/format.js +23 -0
- package/src/meta.js +10 -0
- package/src/prices.js +172 -0
- package/src/render.js +213 -0
- package/src/report.html +1192 -0
- package/src/report.js +69 -0
- package/src/statusline.js +188 -0
- package/src/tools.js +137 -0
package/src/files.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Walking folders for session files and reading them line by line, for every tool's reader.
|
|
3
|
+
import { closeSync, existsSync, fstatSync, openSync, readdirSync, readSync } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Every file under a folder that `keep` accepts. Symbolic links are not followed, so a link loop cannot hang the
|
|
8
|
+
* walk, and a folder that cannot be read is skipped.
|
|
9
|
+
* @param {string} dir
|
|
10
|
+
* @param {(file: string) => boolean} keep
|
|
11
|
+
* @returns {string[]}
|
|
12
|
+
*/
|
|
13
|
+
export function filesUnder(dir, keep) {
|
|
14
|
+
/** @type {string[]} */
|
|
15
|
+
const out = []
|
|
16
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
17
|
+
let entries
|
|
18
|
+
try {
|
|
19
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
20
|
+
} catch {
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
for (const e of entries) {
|
|
24
|
+
const p = path.join(dir, e.name)
|
|
25
|
+
if (e.isDirectory()) out.push(...filesUnder(p, keep))
|
|
26
|
+
else if (e.isFile() && keep(p)) out.push(p)
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether a path is a folder or inside it. Paths are compared as written, ignoring case on macOS and Windows, whose
|
|
33
|
+
* disks usually do.
|
|
34
|
+
* @param {string} p
|
|
35
|
+
* @param {string} folder
|
|
36
|
+
*/
|
|
37
|
+
export function isInside(p, folder) {
|
|
38
|
+
const fold = process.platform === 'darwin' || process.platform === 'win32'
|
|
39
|
+
const a = path.resolve(p)
|
|
40
|
+
const b = path.resolve(folder)
|
|
41
|
+
const [x, y] = fold ? [a.toLowerCase(), b.toLowerCase()] : [a, b]
|
|
42
|
+
return x === y || x.startsWith(y.endsWith(path.sep) ? y : y + path.sep)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The folders that exist. */
|
|
46
|
+
export const existingDirs = (/** @type {string[]} */ dirs) => dirs.filter((d) => existsSync(d))
|
|
47
|
+
|
|
48
|
+
const CHUNK = 1 << 20
|
|
49
|
+
const NEWLINE = 10
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read the complete lines of a file from a byte offset, synchronously and in 1 MB chunks. Lines are handed over as
|
|
53
|
+
* Buffers, so a long line that is not wanted is never turned into a string.
|
|
54
|
+
* @param {string} file
|
|
55
|
+
* @param {number} from the byte offset to start at; must be the start of a line
|
|
56
|
+
* @param {(line: Buffer) => void} onLine
|
|
57
|
+
* @returns {{ offset: number, rest: Buffer, size: number }} offset: where the unfinished last line starts; rest: that line
|
|
58
|
+
*/
|
|
59
|
+
export function readLines(file, from, onLine) {
|
|
60
|
+
const fd = openSync(file, 'r')
|
|
61
|
+
try {
|
|
62
|
+
const size = fstatSync(fd).size
|
|
63
|
+
/** @type {Buffer[]} */
|
|
64
|
+
let pieces = []
|
|
65
|
+
let pending = 0
|
|
66
|
+
let pos = from
|
|
67
|
+
while (pos < size) {
|
|
68
|
+
const chunk = Buffer.allocUnsafe(Math.min(CHUNK, size - pos))
|
|
69
|
+
const got = readSync(fd, chunk, 0, chunk.length, pos)
|
|
70
|
+
if (got <= 0) break
|
|
71
|
+
pos += got
|
|
72
|
+
const buf = got < chunk.length ? chunk.subarray(0, got) : chunk
|
|
73
|
+
let start = 0
|
|
74
|
+
let nl
|
|
75
|
+
while ((nl = buf.indexOf(NEWLINE, start)) !== -1) {
|
|
76
|
+
const part = buf.subarray(start, nl)
|
|
77
|
+
onLine(pieces.length ? Buffer.concat([...pieces, part]) : part)
|
|
78
|
+
pieces = []
|
|
79
|
+
pending = 0
|
|
80
|
+
start = nl + 1
|
|
81
|
+
}
|
|
82
|
+
if (start < buf.length) {
|
|
83
|
+
pieces.push(buf.subarray(start))
|
|
84
|
+
pending += buf.length - start
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { offset: pos - pending, rest: Buffer.concat(pieces), size }
|
|
88
|
+
} finally {
|
|
89
|
+
closeSync(fd)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Hand each line of a buffer to onLine, the last one too.
|
|
95
|
+
* @param {Buffer} buf
|
|
96
|
+
* @param {(line: Buffer) => void} onLine
|
|
97
|
+
*/
|
|
98
|
+
export function splitLines(buf, onLine) {
|
|
99
|
+
let start = 0
|
|
100
|
+
let nl
|
|
101
|
+
while ((nl = buf.indexOf(NEWLINE, start)) !== -1) {
|
|
102
|
+
onLine(buf.subarray(start, nl))
|
|
103
|
+
start = nl + 1
|
|
104
|
+
}
|
|
105
|
+
if (start < buf.length) onLine(buf.subarray(start))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Every line of a file, the last one too even with no newline after it.
|
|
110
|
+
* @param {string} file
|
|
111
|
+
* @param {(line: Buffer) => void} onLine
|
|
112
|
+
*/
|
|
113
|
+
export function eachLine(file, onLine) {
|
|
114
|
+
const { rest } = readLines(file, 0, onLine)
|
|
115
|
+
if (rest.length) onLine(rest)
|
|
116
|
+
}
|
package/src/findings.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Key findings: a few short cards, each with one number, what it means and what to do, the costliest first.
|
|
3
|
+
import { pct, round2, tokens, usd } from './format.js'
|
|
4
|
+
import { TOOLS } from './tools.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {object} Finding
|
|
8
|
+
* @property {string} id
|
|
9
|
+
* @property {string} value the number on the card
|
|
10
|
+
* @property {string} title
|
|
11
|
+
* @property {string} text
|
|
12
|
+
* @property {string} action
|
|
13
|
+
* @property {number | null} usd what it is about, in US dollars at API prices; null when it has no price
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Context sizes counted as long in the long-context finding (from 150k up). */
|
|
17
|
+
const LONG = ['150–200k', '200–500k', '500k+']
|
|
18
|
+
|
|
19
|
+
/** The rebuild reason's words: why it happened, and what to do. */
|
|
20
|
+
const LOST = {
|
|
21
|
+
cold: {
|
|
22
|
+
why: (/** @type {number} */ n) => `mostly after breaks longer than the cache's life (${n} times)`,
|
|
23
|
+
action: 'Before a long break, compact or finish the session; a cold cache writes the whole history again.',
|
|
24
|
+
},
|
|
25
|
+
model: {
|
|
26
|
+
why: (/** @type {number} */ n) => `mostly from switching model mid-session (${n} times)`,
|
|
27
|
+
action: 'Pick the model at the start of a task; each model has its own cache.',
|
|
28
|
+
},
|
|
29
|
+
prefix: {
|
|
30
|
+
why: (/** @type {number} */ n) =>
|
|
31
|
+
`mostly from changes near the start of the context, such as tools, MCP servers or settings (${n} times)`,
|
|
32
|
+
action: 'Avoid changing tools, MCP servers or settings in the middle of a long session.',
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The compaction advice, for the tools used.
|
|
38
|
+
* @param {import('./tools.js').Tool[]} tools
|
|
39
|
+
*/
|
|
40
|
+
function freshAction(tools) {
|
|
41
|
+
if (tools.length === 1 && tools[0] === 'claude') {
|
|
42
|
+
return 'Run /compact earlier, or /clear when you switch task, instead of waiting for the automatic compaction.'
|
|
43
|
+
}
|
|
44
|
+
if (tools.length === 1 && tools[0] === 'codex') {
|
|
45
|
+
return 'Run /compact earlier, or /new when you switch task, instead of waiting for the automatic compaction.'
|
|
46
|
+
}
|
|
47
|
+
return `Compact earlier, or start fresh when you switch task (${tools.map((t) => TOOLS[t].fresh).join(', ')}).`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {import('./analyze.js').Analysis} a
|
|
52
|
+
* @param {import('./tools.js').Tool[]} [tools] the tools the sessions came from
|
|
53
|
+
* @returns {Finding[]}
|
|
54
|
+
*/
|
|
55
|
+
export function findingsOf(a, tools = ['claude']) {
|
|
56
|
+
const { total, sessions } = a
|
|
57
|
+
/** @type {Finding[]} */
|
|
58
|
+
const out = []
|
|
59
|
+
|
|
60
|
+
const top10 = sessions.slice(0, 10).reduce((sum, s) => sum + s.cost, 0)
|
|
61
|
+
const topShare = pct(top10, total.cost)
|
|
62
|
+
if (sessions.length >= 15 && topShare >= 30) {
|
|
63
|
+
out.push({
|
|
64
|
+
id: 'concentration',
|
|
65
|
+
value: `${topShare.toFixed(0)}%`,
|
|
66
|
+
title: `Your 10 costliest sessions made ${topShare.toFixed(0)}% of your usage`,
|
|
67
|
+
text: `Out of ${sessions.length} sessions. A few long sessions drive most of the bill.`,
|
|
68
|
+
action: 'Open them in the sessions table to see where each one grew.',
|
|
69
|
+
usd: round2(top10),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let longCost = 0
|
|
74
|
+
let longRead = 0
|
|
75
|
+
let longCalls = 0
|
|
76
|
+
for (const [label, v] of a.buckets) {
|
|
77
|
+
if (!LONG.includes(label)) continue
|
|
78
|
+
longCost += v.usd
|
|
79
|
+
longRead += v.readUsd
|
|
80
|
+
longCalls += v.calls
|
|
81
|
+
}
|
|
82
|
+
const longShare = pct(longCost, total.cost)
|
|
83
|
+
if (longShare >= 20) {
|
|
84
|
+
out.push({
|
|
85
|
+
id: 'long-context',
|
|
86
|
+
value: `${longShare.toFixed(0)}%`,
|
|
87
|
+
title: `Replies past 150k context made ${longShare.toFixed(0)}% of your usage`,
|
|
88
|
+
text: `${longCalls.toLocaleString('en-US')} replies, each re-reading at least 150k tokens of history; ${pct(longRead, longCost).toFixed(0)}% of what they cost was that re-reading.`,
|
|
89
|
+
action: 'Start a fresh session for each new task, so every reply re-reads less.',
|
|
90
|
+
usd: round2(longCost),
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (total.compact.n >= 3) {
|
|
95
|
+
const avg = tokens(total.compact.ctx / total.compact.n)
|
|
96
|
+
out.push({
|
|
97
|
+
id: 'compaction',
|
|
98
|
+
value: avg,
|
|
99
|
+
title: `Your context was compacted or cleared ${total.compact.n} times, at ${avg} on average`,
|
|
100
|
+
text: 'Every reply before that point re-read the whole context, so the longer it grows before a compaction, the more each reply costs.',
|
|
101
|
+
action: freshAction(tools.length ? tools : ['claude']),
|
|
102
|
+
usd: null,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const rb = total.rebuilds
|
|
107
|
+
const reasons = /** @type {const} */ (['cold', 'model', 'prefix'])
|
|
108
|
+
const [worst] = [...reasons].sort((x, y) => rb[y].cost - rb[x].cost)
|
|
109
|
+
const lostShare = pct(total.rebuildCost, total.cost)
|
|
110
|
+
if (lostShare >= 1 && rb[worst].n) {
|
|
111
|
+
out.push({
|
|
112
|
+
id: 'cache-lost',
|
|
113
|
+
value: usd(total.rebuildCost),
|
|
114
|
+
title: `Losing the cache cost ${usd(total.rebuildCost)}, ${lostShare.toFixed(1)}% of your usage`,
|
|
115
|
+
text: `The whole history was written again ${rb.cold.n + rb.model.n + rb.prefix.n} times, ${LOST[worst].why(rb[worst].n)}.`,
|
|
116
|
+
action: LOST[worst].action,
|
|
117
|
+
usd: round2(total.rebuildCost),
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return out.sort((x, y) => (y.usd ?? 0) - (x.usd ?? 0))
|
|
122
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Small number helpers shared by the analysis and the outputs.
|
|
3
|
+
|
|
4
|
+
/** a as a percentage of b, or 0 when b is 0. */
|
|
5
|
+
export const pct = (/** @type {number} */ a, /** @type {number} */ b) => (b ? (100 * a) / b : 0)
|
|
6
|
+
export const round1 = (/** @type {number} */ v) => Math.round(v * 10) / 10
|
|
7
|
+
export const round2 = (/** @type {number} */ v) => Math.round(v * 100) / 100
|
|
8
|
+
|
|
9
|
+
/** A count in the en-US style: 10,551. */
|
|
10
|
+
export const int = (/** @type {number} */ n) => n.toLocaleString('en-US')
|
|
11
|
+
|
|
12
|
+
/** US dollars: $2.40, or $1,250 from $100 up. */
|
|
13
|
+
export const usd = (/** @type {number} */ v) => `$${v >= 100 ? int(Math.round(v)) : v.toFixed(2)}`
|
|
14
|
+
|
|
15
|
+
/** Tokens: 950, 176k, 1.2M. */
|
|
16
|
+
export const tokens = (/** @type {number} */ n) =>
|
|
17
|
+
n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(Math.round(n))
|
|
18
|
+
|
|
19
|
+
/** The local calendar day of a time, as YYYY-MM-DD. */
|
|
20
|
+
export function dayOf(/** @type {number} */ ts) {
|
|
21
|
+
const d = new Date(ts)
|
|
22
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
23
|
+
}
|
package/src/meta.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
5
|
+
|
|
6
|
+
/** The version in package.json, the only place it is written. */
|
|
7
|
+
export const VERSION = /** @type {string} */ (pkg.version)
|
|
8
|
+
|
|
9
|
+
/** Where ReRead lives, for links in the outputs. */
|
|
10
|
+
export const HOMEPAGE = 'https://github.com/tecyfyai/reread'
|
package/src/prices.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// What each kind of token costs on the API, used only to weigh token kinds against each other the way the API bill
|
|
3
|
+
// does. ReRead never sends anything anywhere; these numbers are a fixed table.
|
|
4
|
+
//
|
|
5
|
+
// Sources (read 2026-10-19; to update, change the rows, the date here and CHANGELOG.md, then run `npm test`):
|
|
6
|
+
// Anthropic: https://platform.claude.com/docs/en/about-claude/pricing
|
|
7
|
+
// OpenAI: https://developers.openai.com/api/docs/pricing
|
|
8
|
+
// Google: https://ai.google.dev/gemini-api/docs/pricing
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* US dollars per million tokens: [input, cache write, cache read, output], optionally followed by the rates that apply
|
|
12
|
+
* once the context passes a size: [above, input, cache write, cache read, output]. Anthropic's 1-hour cache write
|
|
13
|
+
* costs 2 times input, so it is not listed. Where a maker has no cache-write price, a write costs input; where a model
|
|
14
|
+
* has no cache, a read costs input.
|
|
15
|
+
* @typedef {[number, number, number, number]} Rates
|
|
16
|
+
* @type {Record<string, Rates | [number, number, number, number, [number, number, number, number, number]]>}
|
|
17
|
+
*/
|
|
18
|
+
export const PRICES = {
|
|
19
|
+
// Anthropic
|
|
20
|
+
'claude-fable-5-1': [10, 12.5, 0.25, 50],
|
|
21
|
+
'claude-mythos-5-1': [10, 12.5, 0.25, 50],
|
|
22
|
+
'claude-fable-5': [10, 12.5, 1, 50],
|
|
23
|
+
'claude-mythos-5': [10, 12.5, 1, 50],
|
|
24
|
+
'claude-opus-5-5': [4, 5, 0.2, 20],
|
|
25
|
+
'claude-opus-5': [5, 6.25, 0.5, 25],
|
|
26
|
+
'claude-opus-4-8': [5, 6.25, 0.5, 25],
|
|
27
|
+
'claude-opus-4-7': [5, 6.25, 0.5, 25],
|
|
28
|
+
'claude-opus-4-6': [5, 6.25, 0.5, 25],
|
|
29
|
+
'claude-opus-4-5': [5, 6.25, 0.5, 25],
|
|
30
|
+
'claude-opus-4-1': [15, 18.75, 1.5, 75],
|
|
31
|
+
'claude-opus-4': [15, 18.75, 1.5, 75],
|
|
32
|
+
'claude-sonnet-5-5': [2, 2.5, 0.1, 10],
|
|
33
|
+
'claude-sonnet-5': [2, 2.5, 0.2, 10],
|
|
34
|
+
'claude-sonnet-4-6': [3, 3.75, 0.3, 15],
|
|
35
|
+
'claude-sonnet-4-5': [3, 3.75, 0.3, 15],
|
|
36
|
+
'claude-sonnet-4': [3, 3.75, 0.3, 15],
|
|
37
|
+
'claude-haiku-5-5': [0.1, 0.125, 0.01, 0.5, [100e3, 0.5, 0.625, 0.05, 2.5]],
|
|
38
|
+
'claude-haiku-4-5': [1, 1.25, 0.1, 5],
|
|
39
|
+
'claude-haiku-3-5': [0.8, 1, 0.08, 4],
|
|
40
|
+
// OpenAI
|
|
41
|
+
'gpt-6-astra': [10, 12.5, 1, 50, [272e3, 20, 25, 2, 75]],
|
|
42
|
+
'gpt-6.1-sol': [2, 2.5, 0.1, 10, [272e3, 4, 5, 0.2, 15]],
|
|
43
|
+
'gpt-6-sol': [2, 2.5, 0.2, 10, [272e3, 4, 5, 0.4, 15]],
|
|
44
|
+
'gpt-6-luna': [0.1, 0.125, 0.01, 0.5, [272e3, 0.2, 0.25, 0.02, 0.75]],
|
|
45
|
+
'gpt-5.6-sol': [4, 5, 0.4, 20, [272e3, 8, 10, 0.8, 30]],
|
|
46
|
+
'gpt-5.6-terra': [2, 2.5, 0.2, 12, [272e3, 4, 5, 0.4, 18]],
|
|
47
|
+
'gpt-5.6-luna': [0.2, 0.25, 0.02, 1.2, [272e3, 0.4, 0.5, 0.04, 1.8]],
|
|
48
|
+
'gpt-5.5': [5, 5, 0.5, 30, [272e3, 10, 10, 1, 45]],
|
|
49
|
+
'gpt-5.5-pro': [30, 30, 30, 180, [272e3, 60, 60, 60, 270]],
|
|
50
|
+
'gpt-5.4': [2.5, 2.5, 0.25, 15, [272e3, 5, 5, 0.5, 22.5]],
|
|
51
|
+
'gpt-5.4-mini': [0.75, 0.75, 0.075, 4.5],
|
|
52
|
+
'gpt-5.4-nano': [0.2, 0.2, 0.02, 1.25],
|
|
53
|
+
'gpt-5.4-pro': [30, 30, 30, 180, [272e3, 60, 60, 60, 270]],
|
|
54
|
+
'gpt-5.2': [1.75, 1.75, 0.175, 14],
|
|
55
|
+
'gpt-5.2-pro': [21, 21, 21, 168],
|
|
56
|
+
'gpt-5.1': [1.25, 1.25, 0.125, 10],
|
|
57
|
+
'gpt-5': [1.25, 1.25, 0.125, 10],
|
|
58
|
+
'gpt-5-mini': [0.25, 0.25, 0.025, 2],
|
|
59
|
+
'gpt-5-nano': [0.05, 0.05, 0.005, 0.4],
|
|
60
|
+
'gpt-5-pro': [15, 15, 15, 120],
|
|
61
|
+
'gpt-4.1': [2, 2, 0.5, 8],
|
|
62
|
+
'gpt-4.1-mini': [0.4, 0.4, 0.1, 1.6],
|
|
63
|
+
'gpt-4.1-nano': [0.1, 0.1, 0.025, 0.4],
|
|
64
|
+
'gpt-4o': [2.5, 2.5, 1.25, 10],
|
|
65
|
+
'gpt-4o-mini': [0.15, 0.15, 0.075, 0.6],
|
|
66
|
+
o3: [2, 2, 0.5, 8],
|
|
67
|
+
'o4-mini': [1.1, 1.1, 0.275, 4.4],
|
|
68
|
+
// Google (Gemini caches on its own, with no write price)
|
|
69
|
+
'gemini-4-argon': [2, 2, 0.1, 10],
|
|
70
|
+
'gemini-3.8-flash': [0.75, 0.75, 0.075, 3.75],
|
|
71
|
+
'gemini-3.6-flash': [0.75, 0.75, 0.075, 3.75],
|
|
72
|
+
'gemini-3.5-flash-lite': [0.3, 0.3, 0.03, 2.5],
|
|
73
|
+
'gemini-3.1-pro-preview': [2, 2, 0.2, 12, [200e3, 4, 4, 0.4, 18]],
|
|
74
|
+
'gemini-3.1-flash-lite': [0.25, 0.25, 0.025, 1.5],
|
|
75
|
+
'gemini-3-flash-preview': [0.5, 0.5, 0.05, 3],
|
|
76
|
+
'gemini-2.5-pro': [1.25, 1.25, 0.125, 10, [200e3, 2.5, 2.5, 0.25, 15]],
|
|
77
|
+
'gemini-2.5-flash': [0.3, 0.3, 0.03, 2.5],
|
|
78
|
+
'gemini-2.5-flash-lite': [0.1, 0.1, 0.01, 0.4],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** For a model id ReRead does not know: the newest model of the family its name names, else FALLBACK_MODEL. */
|
|
82
|
+
const FAMILIES = /** @type {const} */ ([
|
|
83
|
+
[/fable/, 'claude-fable-5-1'],
|
|
84
|
+
[/mythos/, 'claude-mythos-5-1'],
|
|
85
|
+
[/opus/, 'claude-opus-5-5'],
|
|
86
|
+
[/sonnet/, 'claude-sonnet-5-5'],
|
|
87
|
+
[/haiku/, 'claude-haiku-5-5'],
|
|
88
|
+
[/^gpt-.*astra/, 'gpt-6-astra'],
|
|
89
|
+
[/^gpt-.*luna/, 'gpt-6-luna'],
|
|
90
|
+
[/^gpt-.*terra/, 'gpt-5.6-terra'],
|
|
91
|
+
[/^(gpt-|o\d|codex)/, 'gpt-6.1-sol'],
|
|
92
|
+
[/^gemini-.*flash-lite/, 'gemini-3.5-flash-lite'],
|
|
93
|
+
[/^gemini-.*flash/, 'gemini-3.8-flash'],
|
|
94
|
+
[/^gemini-.*pro/, 'gemini-3.1-pro-preview'],
|
|
95
|
+
[/^gemini/, 'gemini-4-argon'],
|
|
96
|
+
])
|
|
97
|
+
export const FALLBACK_MODEL = 'claude-opus-5-5'
|
|
98
|
+
|
|
99
|
+
// Longest first, so 'gpt-5.4-mini' is tried before 'gpt-5.4'.
|
|
100
|
+
const IDS = Object.keys(PRICES).sort((a, b) => b.length - a.length)
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether a model id is a known id, or one with a suffix: a date ('claude-opus-4-1-20250805') or a variant
|
|
104
|
+
* ('gpt-5.1-codex-max'). A newer version is not a suffix: 'claude-opus-4-9' is not 'claude-opus-4', and 'gpt-5.6-x' is
|
|
105
|
+
* not 'gpt-5'.
|
|
106
|
+
* @param {string} model
|
|
107
|
+
* @param {string} id
|
|
108
|
+
*/
|
|
109
|
+
function isOf(model, id) {
|
|
110
|
+
if (model === id) return true
|
|
111
|
+
if (!model.startsWith(`${id}-`)) return false
|
|
112
|
+
const rest = model.slice(id.length + 1)
|
|
113
|
+
return /^\d{8}(\b|$)/.test(rest) || /^[a-z]/i.test(rest)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @typedef {{ input: number, write5m: number, write1h: number, read: number, output: number }} Price
|
|
118
|
+
* @typedef {object} Call One model response: its token counts by kind.
|
|
119
|
+
* @property {string} key unique id, to count it once
|
|
120
|
+
* @property {number} ts time, ms since 1970
|
|
121
|
+
* @property {string} model
|
|
122
|
+
* @property {number} input new input, not read from the cache and not written to it
|
|
123
|
+
* @property {number} cw5 tokens written to the cache (Anthropic's 5-minute cache, or OpenAI's)
|
|
124
|
+
* @property {number} cw1 tokens written to Anthropic's 1-hour cache
|
|
125
|
+
* @property {number} read tokens read from the cache
|
|
126
|
+
* @property {number} out output, reasoning included
|
|
127
|
+
* @property {boolean} [seen] counted already in an earlier file
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/** @type {Map<string, { base: Price, long?: { above: number, price: Price } }>} */
|
|
131
|
+
const cache = new Map()
|
|
132
|
+
|
|
133
|
+
/** @param {number[]} r */
|
|
134
|
+
const priceFrom = ([input, write5m, read, output]) => ({ input, write5m, write1h: input * 2, read, output })
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The price of a model id for a reply with a given context size. An unknown id is priced as the newest model of its
|
|
138
|
+
* family, or as FALLBACK_MODEL.
|
|
139
|
+
* @param {string} [model]
|
|
140
|
+
* @param {number} [context] the reply's context in tokens, for prices that rise past a size
|
|
141
|
+
* @returns {Price}
|
|
142
|
+
*/
|
|
143
|
+
export function priceOf(model = '', context = 0) {
|
|
144
|
+
let entry = cache.get(model)
|
|
145
|
+
if (!entry) {
|
|
146
|
+
const id =
|
|
147
|
+
IDS.find((k) => isOf(model, k)) ?? FAMILIES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? FALLBACK_MODEL
|
|
148
|
+
const row = PRICES[id]
|
|
149
|
+
const long = row[4]
|
|
150
|
+
entry = {
|
|
151
|
+
base: priceFrom(/** @type {number[]} */ (row.slice(0, 4))),
|
|
152
|
+
long: long ? { above: long[0], price: priceFrom(long.slice(1)) } : undefined,
|
|
153
|
+
}
|
|
154
|
+
cache.set(model, entry)
|
|
155
|
+
}
|
|
156
|
+
return entry.long && context > entry.long.above ? entry.long.price : entry.base
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The tokens a reply sent: new input, cache writes and cache reads.
|
|
161
|
+
* @param {Call} c
|
|
162
|
+
*/
|
|
163
|
+
export const contextOf = (c) => c.input + c.cw5 + c.cw1 + c.read
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What a reply would cost on the API, in US dollars.
|
|
167
|
+
* @param {Call} c
|
|
168
|
+
*/
|
|
169
|
+
export function costOf(c) {
|
|
170
|
+
const p = priceOf(c.model, contextOf(c))
|
|
171
|
+
return (c.input * p.input + c.cw5 * p.write5m + c.cw1 * p.write1h + c.read * p.read + c.out * p.output) / 1e6
|
|
172
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The terminal summary, the Markdown summary and the CSV. Each returns a string; the CLI prints it.
|
|
3
|
+
import { REASONS } from './analyze.js'
|
|
4
|
+
import { int, pct, tokens, usd } from './format.js'
|
|
5
|
+
import { HOMEPAGE, VERSION } from './meta.js'
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('./analyze.js').Report} Report */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A count and its noun: '1 session', '12 sessions'.
|
|
11
|
+
* @param {number} n
|
|
12
|
+
* @param {string} one
|
|
13
|
+
* @param {string} [many]
|
|
14
|
+
*/
|
|
15
|
+
const plural = (n, one, many = `${one}s`) => `${int(n)} ${n === 1 ? one : many}`
|
|
16
|
+
|
|
17
|
+
const NOTE = 'Plans count usage in their own way; the shares weigh each kind of token by its API price.'
|
|
18
|
+
|
|
19
|
+
const REASON_TEXT = {
|
|
20
|
+
cold: 'Cache went cold (a break longer than its life)',
|
|
21
|
+
model: 'Model changed mid-session',
|
|
22
|
+
prefix: 'Something early in the context changed',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The summary for the terminal.
|
|
27
|
+
* @param {Report} data
|
|
28
|
+
* @param {{ top: number, by: '' | 'project' | 'model' | 'day' | 'tool', color: boolean }} opts
|
|
29
|
+
*/
|
|
30
|
+
export function renderText(data, { top, by, color }) {
|
|
31
|
+
const paint = (/** @type {number} */ code) => (/** @type {unknown} */ t) =>
|
|
32
|
+
color ? `\x1b[${code}m${t}\x1b[0m` : String(t)
|
|
33
|
+
const bold = paint(1)
|
|
34
|
+
const dim = paint(2)
|
|
35
|
+
const yellow = paint(33)
|
|
36
|
+
const cyan = paint(36)
|
|
37
|
+
const pad = (/** @type {unknown} */ t, /** @type {number} */ n) => String(t).padEnd(n)
|
|
38
|
+
const lpad = (/** @type {unknown} */ t, /** @type {number} */ n) => String(t).padStart(n)
|
|
39
|
+
const S = data.summary
|
|
40
|
+
|
|
41
|
+
const out = [
|
|
42
|
+
'',
|
|
43
|
+
` ${bold(yellow(`${S.rereadShare.toFixed(0)}%`))} ${bold(`of your ${data.product} usage was re-reading context`)}`,
|
|
44
|
+
dim(
|
|
45
|
+
` ${data.span} · ${plural(S.sessions, 'session')} · ${plural(S.calls, 'reply', 'replies')} · ${usd(S.apiEquivalentUSD)} at API prices`,
|
|
46
|
+
),
|
|
47
|
+
'',
|
|
48
|
+
` ${pad('Reading history from the cache', 34)}${lpad(`${S.cacheReadShare.toFixed(1)}%`, 7)} ${dim(`${S.tokensReadFromCache}% of all tokens read`)}`,
|
|
49
|
+
` ${pad('Writing history again', 34)}${lpad(`${S.rebuildShare.toFixed(1)}%`, 7)} ${dim('after the cache was lost')}`,
|
|
50
|
+
` ${pad('Everything else', 34)}${lpad(`${S.otherShare.toFixed(1)}%`, 7)} ${dim('new input, first cache writes, output')}`,
|
|
51
|
+
'',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const reasons = REASONS.filter((w) => S.rebuilds[w].count)
|
|
55
|
+
if (reasons.length) {
|
|
56
|
+
out.push(bold(' Times the whole history was written again'))
|
|
57
|
+
for (const w of reasons) {
|
|
58
|
+
out.push(
|
|
59
|
+
` ${pad(REASON_TEXT[w], 48)}${lpad(S.rebuilds[w].count, 5)} ${dim(`${S.rebuilds[w].share}% of usage`)}`,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
out.push('')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (by) {
|
|
66
|
+
/** @type {[string, number, number, number][]} */
|
|
67
|
+
const rows =
|
|
68
|
+
by === 'project'
|
|
69
|
+
? data.projects.map((p) => [p.project, p.calls, p.usd, p.rereadShare])
|
|
70
|
+
: by === 'model'
|
|
71
|
+
? data.models.map((m) => [m.model, m.calls, m.usd, pct(m.readUsd + m.rebuildUsd, m.usd)])
|
|
72
|
+
: by === 'tool'
|
|
73
|
+
? data.tools.map((t) => [t.name, t.calls, t.usd, t.rereadShare])
|
|
74
|
+
: data.daily
|
|
75
|
+
.filter((d) => d.calls)
|
|
76
|
+
.map((d) => [d.day, d.calls, d.usd, pct(d.readUsd + d.rebuildUsd, d.usd)])
|
|
77
|
+
out.push(bold(` By ${by}`))
|
|
78
|
+
out.push(dim(` ${pad(by, 30)}${lpad('replies', 9)}${lpad('cost', 10)}${lpad('re-read', 9)}`))
|
|
79
|
+
for (const [name, calls, cost, rr] of rows) {
|
|
80
|
+
out.push(
|
|
81
|
+
` ${pad(name.slice(0, 28), 30)}${lpad(int(calls), 9)}${lpad(usd(cost), 10)}${lpad(`${rr.toFixed(0)}%`, 9)}`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
out.push('')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (top > 0) {
|
|
88
|
+
out.push(bold(' Costliest sessions') + dim(' (API price of the same tokens)'))
|
|
89
|
+
out.push(
|
|
90
|
+
dim(
|
|
91
|
+
` ${pad('started', 12)}${pad('project', 24)}${lpad('replies', 8)}${lpad('peak ctx', 10)}${lpad('cost', 10)}${lpad('re-read', 9)}`,
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
for (const s of data.sessions.slice(0, top)) {
|
|
95
|
+
out.push(
|
|
96
|
+
` ${pad(s.started.slice(0, 10), 12)}${pad((s.project || s.id).slice(0, 22), 24)}${lpad(s.calls, 8)}${lpad(tokens(s.peak), 10)}${lpad(usd(s.usd), 10)}${lpad(`${s.rereadShare.toFixed(0)}%`, 9)}`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
out.push('')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (data.findings.length) {
|
|
103
|
+
out.push(bold(' Key findings'))
|
|
104
|
+
for (const f of data.findings) out.push(` · ${f.title}. ${dim(f.action)}`)
|
|
105
|
+
out.push('')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
out.push(dim(` ${NOTE}`), '', ` ${dim('Share it:')} ${cyan(data.share)}`, '')
|
|
109
|
+
return out.join('\n') + '\n'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A Markdown summary, to paste into an issue or a pull request.
|
|
114
|
+
* @param {Report} data
|
|
115
|
+
* @param {{ top: number }} opts
|
|
116
|
+
*/
|
|
117
|
+
export function renderMarkdown(data, { top }) {
|
|
118
|
+
const S = data.summary
|
|
119
|
+
const cell = (/** @type {unknown} */ t) => String(t).replace(/\|/g, '\\|')
|
|
120
|
+
const out = [
|
|
121
|
+
`## ${S.rereadShare.toFixed(0)}% of my ${data.product} usage was re-reading context`,
|
|
122
|
+
'',
|
|
123
|
+
`_${data.span} · ${plural(S.sessions, 'session')} · ${plural(S.calls, 'reply', 'replies')} · ${usd(S.apiEquivalentUSD)} at API prices · [ReRead](${HOMEPAGE}) ${VERSION}_`,
|
|
124
|
+
'',
|
|
125
|
+
'| Where the usage went | Share |',
|
|
126
|
+
'|---|---:|',
|
|
127
|
+
`| Reading history from the cache | ${S.cacheReadShare.toFixed(1)}% |`,
|
|
128
|
+
`| Writing history again, after the cache was lost | ${S.rebuildShare.toFixed(1)}% |`,
|
|
129
|
+
`| Everything else (new input, first cache writes, output) | ${S.otherShare.toFixed(1)}% |`,
|
|
130
|
+
'',
|
|
131
|
+
]
|
|
132
|
+
const reasons = REASONS.filter((w) => S.rebuilds[w].count)
|
|
133
|
+
if (reasons.length) {
|
|
134
|
+
out.push(
|
|
135
|
+
'| Why the history was written again | Times | Share |',
|
|
136
|
+
'|---|---:|---:|',
|
|
137
|
+
...reasons.map((w) => `| ${REASON_TEXT[w]} | ${S.rebuilds[w].count} | ${S.rebuilds[w].share}% |`),
|
|
138
|
+
'',
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
if (top > 0) {
|
|
142
|
+
out.push(
|
|
143
|
+
'| Costliest sessions | Project | Replies | Peak context | API price | Re-read |',
|
|
144
|
+
'|---|---|---:|---:|---:|---:|',
|
|
145
|
+
...data.sessions
|
|
146
|
+
.slice(0, top)
|
|
147
|
+
.map(
|
|
148
|
+
(s) =>
|
|
149
|
+
`| ${s.started.slice(0, 10)} \`${s.id}\` | ${cell(s.project)} | ${s.calls} | ${tokens(s.peak)} | ${usd(s.usd)} | ${s.rereadShare.toFixed(0)}% |`,
|
|
150
|
+
),
|
|
151
|
+
'',
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
if (data.findings.length) {
|
|
155
|
+
out.push('### Key findings', '', ...data.findings.map((f) => `- **${f.title}.** ${f.text} ${f.action}`), '')
|
|
156
|
+
}
|
|
157
|
+
out.push(`_${NOTE}_`)
|
|
158
|
+
return out.join('\n') + '\n'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One CSV cell. A value that starts like a formula gets a leading ' so a spreadsheet app shows it as text.
|
|
163
|
+
* @param {unknown} v
|
|
164
|
+
*/
|
|
165
|
+
export function csvCell(v) {
|
|
166
|
+
let t = String(v)
|
|
167
|
+
if (/^[=+\-@\t\r]/.test(t)) t = `'${t}`
|
|
168
|
+
return /[",\n\r]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The CSV header, shared with the report's own CSV export. */
|
|
172
|
+
export const CSV_HEADER = [
|
|
173
|
+
'session',
|
|
174
|
+
'tool',
|
|
175
|
+
'project',
|
|
176
|
+
'branch',
|
|
177
|
+
'started',
|
|
178
|
+
'ended',
|
|
179
|
+
'replies',
|
|
180
|
+
'peak_context',
|
|
181
|
+
'api_usd',
|
|
182
|
+
'reread_pct',
|
|
183
|
+
'rebuilds_cold',
|
|
184
|
+
'rebuilds_model',
|
|
185
|
+
'rebuilds_prefix',
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Every session as CSV.
|
|
190
|
+
* @param {Report} data
|
|
191
|
+
*/
|
|
192
|
+
export function renderCsv(data) {
|
|
193
|
+
const rows = data.sessions.map((s) =>
|
|
194
|
+
[
|
|
195
|
+
s.id,
|
|
196
|
+
s.tool,
|
|
197
|
+
s.project,
|
|
198
|
+
s.branch,
|
|
199
|
+
s.started,
|
|
200
|
+
s.ended,
|
|
201
|
+
s.calls,
|
|
202
|
+
s.peak,
|
|
203
|
+
s.usd,
|
|
204
|
+
s.rereadShare,
|
|
205
|
+
s.rebuilds.cold,
|
|
206
|
+
s.rebuilds.model,
|
|
207
|
+
s.rebuilds.prefix,
|
|
208
|
+
]
|
|
209
|
+
.map(csvCell)
|
|
210
|
+
.join(','),
|
|
211
|
+
)
|
|
212
|
+
return [CSV_HEADER.join(','), ...rows].join('\n') + '\n'
|
|
213
|
+
}
|