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/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
+ }
@@ -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
+ }