@chroxify/eunoe 1.1.0 → 1.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/README.md CHANGED
@@ -336,6 +336,7 @@ eunoe settings # pick a setting and change it
336
336
  eunoe settings list # show them all
337
337
  eunoe settings set keep 5 # change one directly
338
338
  eunoe status # what's running, every session, context and tokens saved
339
+ eunoe stats # tokens and API cost by day (--days 14, --weekly), and what eunoe saved
339
340
  eunoe replay # replay your whole Claude Code history through eunoe, offline, and see what it saves
340
341
  eunoe uninstall # stop and remove the background proxy
341
342
  ```
@@ -416,6 +417,18 @@ Every request is written to `~/.eunoe/requests.jsonl` with what eunoe did
416
417
  to it and how much of it the API served from cache, which is also where
417
418
  `eunoe status` gets its numbers.
418
419
 
420
+ `eunoe stats` reads the usage the API returned, from Claude Code's own session
421
+ files, subagents included, so it covers every session, routed through eunoe or
422
+ not. Claude Code writes one response as several lines, so each response is
423
+ counted once, with its final output count. Cost is at API list prices.
424
+ Its eunoe columns are only what eunoe actually did, from its request log: the
425
+ input tokens it kept out, and what that saved. Those tokens are mostly history
426
+ plain Claude Code would have read from cache, so they are priced as cache
427
+ reads, and every token a fold rewrote is charged back at the cache-write price.
428
+ `eunoe replay` and `eunoe stats` can only see what is still on disk: Claude Code
429
+ deletes sessions that have been idle for 30 days unless `cleanupPeriodDays` in
430
+ its `settings.json` says otherwise.
431
+
419
432
  ## What it doesn't do
420
433
 
421
434
  - **It is a proxy, which makes it a single point of failure.** If it isn't
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chroxify/eunoe",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Context management for Claude Code that never forgets what matters.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -7,6 +7,7 @@ import { serve } from "./serve"
7
7
  import { sessions } from "./sessions"
8
8
  import { settings } from "./settings"
9
9
  import { start } from "./start"
10
+ import { stats } from "./stats"
10
11
  import { status } from "./status"
11
12
  import { stop } from "./stop"
12
13
  import { uninstall } from "./uninstall"
@@ -20,6 +21,7 @@ export const commands: Record<string, Command> = {
20
21
  mode,
21
22
  settings,
22
23
  status,
24
+ stats,
23
25
  replay,
24
26
  sessions,
25
27
  peek,
@@ -1,20 +1,23 @@
1
- import { statSync } from "node:fs"
1
+ import { closeSync, openSync, readSync, statSync } from "node:fs"
2
2
  import { cpus } from "node:os"
3
3
  import path from "node:path"
4
4
  import { claudeConfigDirs, DEFAULTS, parseTunable, readConfig } from "../../config"
5
5
  import { listEntries, projectFolderName, projectFolders } from "../../transcript/locate"
6
6
  import type { Command } from "../command"
7
+ import { cleanupDays } from "../proxy/claude-settings"
7
8
  import { recentRequests } from "../proxy/inspect"
8
- import type { ReplayedSession, ReplayOptions } from "../replay"
9
+ import type { ReplayedSession, ReplayFailure, ReplayOptions } from "../replay"
9
10
  import { shortId } from "../sessions"
10
- import { c, fail, line, percent, plural, rows, table, tilde, tokens, useColor } from "../terminal/ui"
11
+ import { c, fail, line, percent, plural, rows, table, tilde, tokens, useColor, warn } from "../terminal/ui"
11
12
 
12
13
  interface Target {
13
14
  file: string
14
15
  bytes: number
16
+ lastActive: number
15
17
  }
16
18
 
17
19
  const WINDOW = 1_000_000
20
+ const HEAD_BYTES = 64 * 1024
18
21
  const TOP_SESSIONS = 10
19
22
 
20
23
  function targets(configDirs: string[], ids: string[], here: string | null): Target[] {
@@ -28,7 +31,8 @@ function targets(configDirs: string[], ids: string[], here: string | null): Targ
28
31
  const id = name.slice(0, -".jsonl".length)
29
32
  if (!wanted(id) || seen.has(id)) continue
30
33
  const file = path.join(folder, name)
31
- seen.set(id, { file, bytes: statSync(file).size })
34
+ const stat = statSync(file)
35
+ seen.set(id, { file, bytes: stat.size, lastActive: stat.mtimeMs })
32
36
  }
33
37
  }
34
38
  return [...seen.values()].sort((a, b) => b.bytes - a.bytes)
@@ -42,12 +46,35 @@ function fixedTokens(): { value: number; measured: boolean } {
42
46
  return { value: sizes[Math.floor(sizes.length / 2)], measured: true }
43
47
  }
44
48
 
49
+ const day = (ms: number) => new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })
50
+
45
51
  const duration = (ms: number) => (ms < 60_000 ? `${Math.max(1, Math.round(ms / 1000))}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`)
46
52
  const size = (bytes: number) => (bytes >= 1e9 ? `${(bytes / 1e9).toFixed(1)} GB` : `${Math.round(bytes / 1e6)} MB`)
47
53
  const fewer = (plain: number, eunoe: number) => (plain > 0 ? percent(1 - eunoe / plain) : "–")
48
54
 
49
- async function replayAll(list: Target[], options: ReplayOptions, onDone: (done: number, bytes: number) => void): Promise<ReplayedSession[]> {
55
+ function startedAt(target: Target): number {
56
+ const buffer = Buffer.alloc(HEAD_BYTES)
57
+ let read = 0
58
+ try {
59
+ const fd = openSync(target.file, "r")
60
+ read = readSync(fd, buffer, 0, HEAD_BYTES, 0)
61
+ closeSync(fd)
62
+ } catch {
63
+ return target.lastActive
64
+ }
65
+ const found = /"timestamp":"([^"]+)"/.exec(buffer.toString("utf8", 0, read))
66
+ const at = found ? Date.parse(found[1]) : Number.NaN
67
+ return Number.isNaN(at) ? target.lastActive : Math.min(at, target.lastActive)
68
+ }
69
+
70
+ function historyLine(list: Target[], keepDays: number): string {
71
+ const span = `${day(Math.min(...list.map(startedAt)))} – ${day(Math.max(...list.map((t) => t.lastActive)))}`
72
+ return `${span} ${c.dim(`· the sessions still on disk; Claude Code deletes sessions idle for ${keepDays} days (cleanupPeriodDays), so totals cover a moving window`)}`
73
+ }
74
+
75
+ async function replayAll(list: Target[], options: ReplayOptions, onDone: (done: number, bytes: number) => void): Promise<{ results: ReplayedSession[]; failures: ReplayFailure[] }> {
50
76
  const results: ReplayedSession[] = []
77
+ const failures: ReplayFailure[] = []
51
78
  const queue = [...list]
52
79
  let bytesDone = 0
53
80
  const workers = Math.max(1, Math.min(8, cpus().length - 1, list.length))
@@ -56,28 +83,46 @@ async function replayAll(list: Target[], options: ReplayOptions, onDone: (done:
56
83
  { length: workers },
57
84
  () =>
58
85
  new Promise<void>((resolve) => {
59
- const worker = new Worker(new URL("../replay/worker.ts", import.meta.url).href)
86
+ let worker = new Worker(new URL("../replay/worker.ts", import.meta.url).href)
87
+ let current: Target | undefined
88
+ const finish = (target: Target) => {
89
+ bytesDone += target.bytes
90
+ onDone(results.length + failures.length, bytesDone)
91
+ }
60
92
  const next = () => {
61
- const target = queue.shift()
62
- if (!target) {
93
+ current = queue.shift()
94
+ if (!current) {
63
95
  worker.terminate()
64
96
  resolve()
65
97
  return
66
98
  }
67
- worker.postMessage({ file: target.file, options })
99
+ worker.postMessage({ file: current.file, options })
68
100
  }
69
- worker.onmessage = (event: MessageEvent<ReplayedSession>) => {
70
- results.push(event.data)
71
- bytesDone += event.data.bytes
72
- onDone(results.length, bytesDone)
73
- next()
101
+ const listen = () => {
102
+ worker.onmessage = (event: MessageEvent<ReplayedSession | ReplayFailure>) => {
103
+ if ("error" in event.data) failures.push(event.data)
104
+ else results.push(event.data)
105
+ if (current) finish(current)
106
+ next()
107
+ }
108
+ worker.onerror = (event) => {
109
+ event.preventDefault()
110
+ if (current) {
111
+ failures.push({ file: current.file, error: event.message || "worker crashed" })
112
+ finish(current)
113
+ }
114
+ worker.terminate()
115
+ worker = new Worker(new URL("../replay/worker.ts", import.meta.url).href)
116
+ listen()
117
+ next()
118
+ }
74
119
  }
75
- worker.onerror = () => next()
120
+ listen()
76
121
  next()
77
122
  }),
78
123
  ),
79
124
  )
80
- return results
125
+ return { results, failures }
81
126
  }
82
127
 
83
128
  export const replay: Command = {
@@ -105,7 +150,8 @@ export const replay: Command = {
105
150
  const config = readConfig()
106
151
  const turnCap = capAt === -1 ? config.turnCap : parseTunable("turnCap", args[capAt + 1] ?? "")
107
152
  if (turnCap === null) fail("--turn-cap needs a token count or off", "eunoe replay --turn-cap off")
108
- const list = targets(claudeConfigDirs(config), ids, args.includes("--here") ? process.cwd() : null)
153
+ const configDirs = claudeConfigDirs(config)
154
+ const list = targets(configDirs, ids, args.includes("--here") ? process.cwd() : null)
109
155
  if (list.length === 0) fail("No Claude Code sessions found.")
110
156
  const totalBytes = list.reduce((total, entry) => total + entry.bytes, 0)
111
157
  const fixed = fixedTokens()
@@ -113,7 +159,7 @@ export const replay: Command = {
113
159
  const options: ReplayOptions = { config: { ...config, turnCap, mode: config.mode === "off" ? DEFAULTS.mode : config.mode }, window, fixedTokens: fixed.value }
114
160
  const started = Date.now()
115
161
  const progress = useColor && process.stdout.isTTY
116
- const results = await replayAll(list, options, (done, doneBytes) => {
162
+ const { results, failures } = await replayAll(list, options, (done, doneBytes) => {
117
163
  if (!progress) return
118
164
  const left = doneBytes > 0 ? ` · ~${duration(((Date.now() - started) / doneBytes) * (totalBytes - doneBytes))} left` : ""
119
165
  process.stdout.write(`\r\x1b[K ${c.dim(`Replaying ${done} / ${list.length} sessions · ${size(doneBytes)} of ${size(totalBytes)}${left}`)}`)
@@ -133,7 +179,8 @@ export const replay: Command = {
133
179
  line()
134
180
  rows(
135
181
  [
136
- ["Sessions", `${c.bold(String(compared.length))} replayed ${c.dim("·")} ${size(totalBytes)} ${c.dim("·")} ${duration(Date.now() - started)}`],
182
+ ["Sessions", `${c.bold(compared.length.toLocaleString())} replayed ${c.dim("·")} ${size(totalBytes)} ${c.dim("·")} ${duration(Date.now() - started)}`],
183
+ ["History", historyLine(list, cleanupDays(configDirs))],
137
184
  ["Requests", c.bold(sum.requests.toLocaleString())],
138
185
  ["Window", `${tokens(window)} ${c.dim(`· ${tokens(fixed.value)} of it system prompt and tools, ${fixed.measured ? "from your logged requests" : "assumed"}`)}`],
139
186
  ],
@@ -144,33 +191,33 @@ export const replay: Command = {
144
191
  [
145
192
  [
146
193
  "History kept",
147
- `eunoe ${c.bold(c.green("every turn"))}, folded or whole ${c.dim("·")} Claude Code compacted ${c.bold(String(sum.nativeCompactions))} time${sum.nativeCompactions === 1 ? "" : "s"}, ${c.bold(tokens(sum.discarded))} tokens of conversation replaced by summaries`,
194
+ `eunoe ${c.bold(c.green("every turn"))}, folded or whole ${c.dim("·")} Claude Code compacted ${c.bold(sum.nativeCompactions.toLocaleString())} time${sum.nativeCompactions === 1 ? "" : "s"}, ${c.bold(tokens(sum.discarded))} tokens of conversation replaced by summaries`,
148
195
  ],
149
196
  [
150
197
  "Input tokens",
151
198
  `plain ${tokens(sum.plainTokens)} ${c.dim("→")} eunoe ${c.bold(tokens(sum.eunoeTokens))} tokens ${c.green(c.bold(`${fewer(sum.plainTokens, sum.eunoeTokens)} fewer`))} ${c.dim("the conversation; system prompt and tools are the same on both sides")}`,
152
199
  ],
153
200
  ["Cost", `${c.green(c.bold(`${fewer(sum.plainCost, sum.eunoeCost)} cheaper`))} ${c.dim("cache reads at 0.1×, writes at 1.25×")}`],
154
- ["Folds", c.bold(String(sum.folds))],
155
- ["Folds dropped", `${c.bold(String(sum.dropped))} ${c.dim("folded turns removed entirely to fit the window")}`],
156
- ["Peak context", `plain ${tokens(sum.peakPlain)} ${c.dim("→")} eunoe ${c.bold(tokens(sum.peakEunoe))}`],
201
+ ["Folds", c.bold(sum.folds.toLocaleString())],
202
+ ["Folds dropped", `${c.bold(sum.dropped.toLocaleString())} ${c.dim("folded turns removed entirely to fit the window")}`],
203
+ ["Peak context", `plain ${c.green(c.bold(tokens(sum.peakPlain)))} ${c.dim("→")} eunoe ${c.bold(tokens(sum.peakEunoe))} ${c.dim("· the longest conversation whole, every compaction undone, against the most eunoe sent")}`],
157
204
  ],
158
205
  4,
159
206
  )
160
207
  const saved = [...compared]
161
208
  .filter((r) => r.nativeCompactions > 0)
162
- .sort((a, b) => b.discarded - a.discarded)
209
+ .sort((a, b) => b.peakPlain - a.peakPlain)
163
210
  .slice(0, TOP_SESSIONS)
164
211
  if (saved.length > 0) {
165
212
  line()
166
- line(` ${c.dim("Sessions Claude Code would have compacted, by history it would have lost")}`)
213
+ line(` ${c.dim("Longest sessions Claude Code would have compacted, by their whole history")}`)
167
214
  table(
168
215
  saved.map((r) => [
169
216
  c.cyan(shortId(r.id)),
170
217
  (r.title ?? (r.cwd ? tilde(r.cwd) : "")).slice(0, 40),
171
- c.bold(c.green(`${plural(r.nativeCompactions, "compaction")} avoided`)),
172
- `${tokens(r.discarded)} tokens kept`,
173
- c.dim(`${plural(r.folds, "fold")} · ${tokens(r.plainTokens)} → ${tokens(r.eunoeTokens)} tokens`),
218
+ `${c.green(c.bold(tokens(r.peakPlain)))} ${c.dim("of history")}`,
219
+ c.bold(`${plural(r.nativeCompactions, "compaction")} avoided`),
220
+ c.dim(`eunoe peak ${tokens(r.peakEunoe)} · ${plural(r.folds, "fold")}`),
174
221
  ]),
175
222
  )
176
223
  }
@@ -189,6 +236,10 @@ export const replay: Command = {
189
236
  ]),
190
237
  )
191
238
  }
239
+ if (failures.length > 0) {
240
+ line()
241
+ warn(`${plural(failures.length, "session")} couldn't be replayed and ${failures.length === 1 ? "is" : "are"} not counted: ${failures.map((f) => `${shortId(path.basename(f.file, ".jsonl"))} (${f.error})`).join(", ")}`)
242
+ }
192
243
  line()
193
244
  },
194
245
  }
@@ -1,14 +1,16 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import path from "node:path"
3
- import { type Config, claudeConfigDirs, compactionOwner, readConfig, saveConfig } from "../../config"
3
+ import { type Config, claudeConfigDirs, compactionOwner, modeFor, readConfig, saveConfig } from "../../config"
4
+ import { seenSessions } from "../../proxy/sessions"
4
5
  import type { Command } from "../command"
5
6
  import { editSettings, withEunoe } from "../proxy/claude-settings"
6
7
  import { routedDirs } from "../proxy/inspect"
8
+ import { isIdle, isTerminal, type LiveSession, listLiveSessions, restartSessions, unroutedSessions } from "../proxy/live"
7
9
  import { startService } from "../proxy/service"
8
10
  import { clearWatch } from "../proxy/watch"
9
11
  import { isKnown, sessionArgs, shortId } from "../sessions"
10
12
  import { flagValues } from "../terminal/args"
11
- import { c, fail, line, listFiles, note, ok, rows, tilde } from "../terminal/ui"
13
+ import { c, fail, line, listFiles, note, ok, plural, rows, tilde } from "../terminal/ui"
12
14
  import { MODE_DESCRIPTIONS } from "./mode"
13
15
 
14
16
  function scoped(config: Config, ids: string[]): Partial<Config> {
@@ -30,13 +32,34 @@ function managedSessions(config: Config): string {
30
32
  return `only ${ids.join(", ")}`
31
33
  }
32
34
 
35
+ function restartUnrouted(config: Config, skip: boolean): { restarted: LiveSession[]; busy: LiveSession[] } {
36
+ const lastSeen = new Map(seenSessions().map(([id, seen]) => [id, seen.lastSeen]))
37
+ const managed = (id: string) => modeFor(config, id) !== "off"
38
+ const unrouted = unroutedSessions(listLiveSessions(claudeConfigDirs(config)), lastSeen, managed, process.env.CLAUDE_CODE_SESSION_ID)
39
+ if (skip) return { restarted: [], busy: unrouted }
40
+ return { restarted: restartSessions(unrouted.filter(isIdle)), busy: unrouted.filter((session) => !isIdle(session)) }
41
+ }
42
+
43
+ function reportRestarts({ restarted, busy }: { restarted: LiveSession[]; busy: LiveSession[] }) {
44
+ if (restarted.length > 0) {
45
+ ok(`Restarted ${plural(restarted.length, "idle session")} so they go through eunoe`)
46
+ if (restarted.some((session) => !isTerminal(session))) note("App sessions come back on their next message.")
47
+ if (restarted.some(isTerminal)) note("Resume the terminal ones with:")
48
+ for (const session of restarted.filter(isTerminal)) line(` ${c.dim(`cd ${tilde(session.cwd)} && claude --resume ${session.sessionId}`)}`)
49
+ line()
50
+ }
51
+ if (busy.length > 0) note(`Still on the direct route until restarted: ${busy.map((session) => shortId(session.sessionId)).join(", ")}.`)
52
+ }
53
+
33
54
  export const start: Command = {
34
55
  summary: "Route Claude Code through eunoe",
35
- usage: "eunoe start [--session <id>] [--config-dir <dir>]",
56
+ usage: "eunoe start [--session <id>] [--config-dir <dir>] [--no-restart]",
36
57
  details: [
37
58
  "Starts the background proxy if it isn't running (setting it up the first time)",
38
59
  "and points Claude Code at it through ANTHROPIC_BASE_URL in settings.json.",
39
- "New sessions pick it up; restart the ones already running.",
60
+ "New sessions pick it up. Idle sessions already running are restarted so they",
61
+ "pick it up too: app-hosted ones resume on their next message, terminal ones",
62
+ "print the command that resumes them. Busy sessions are left alone.",
40
63
  "",
41
64
  "With --session, eunoe manages only the sessions you name and passes the rest",
42
65
  "through untouched. Session ids are in Claude Code's /status, or `eunoe status`.",
@@ -44,6 +67,7 @@ export const start: Command = {
44
67
  options: [
45
68
  ["--session <id>", "Only manage this session (repeatable; a unique prefix is enough)"],
46
69
  ["--config-dir <dir>", "Also manage this Claude config directory (repeatable, remembered)"],
70
+ ["--no-restart", "Leave running sessions alone"],
47
71
  ],
48
72
  async run(args) {
49
73
  const added = flagValues(args, "--config-dir").map((dir) => path.resolve(dir))
@@ -65,9 +89,11 @@ export const start: Command = {
65
89
  ["Sessions", managedSessions(config)],
66
90
  ])
67
91
  line()
68
- const unseen = ids.filter((id) => !isKnown(id))
92
+ const restarts = restartUnrouted(config, args.includes("--no-restart"))
93
+ reportRestarts(restarts)
94
+ const unseen = ids.filter((id) => !isKnown(id) && !restarts.restarted.some((session) => session.sessionId === id))
69
95
  if (unseen.length > 0) note(`Not seen through eunoe yet: ${unseen.map(shortId).join(", ")}. It applies once that session's requests come through; one started before routing needs a restart.`)
70
- else note(ids.length > 0 ? "Takes effect on the session's next request." : "New sessions go through eunoe. Restart any that are already running.")
96
+ else note(ids.length > 0 ? "Takes effect on the session's next request." : "New sessions go through eunoe.")
71
97
  line()
72
98
  },
73
99
  }
@@ -0,0 +1,264 @@
1
+ import { claudeConfigDirs, readConfig } from "../../config"
2
+ import type { Command } from "../command"
3
+ import { cleanupDays } from "../proxy/claude-settings"
4
+ import { type RequestRecord, recentRequests } from "../proxy/inspect"
5
+ import { nativeCompactions, savedTokens, type ThreadPeak, threadPeaks, totals } from "../proxy/stats"
6
+ import { REPLAY_NATIVE_COMPACT_AT } from "../replay"
7
+ import { shortId } from "../sessions"
8
+ import { c, fail, line, percent, plural, table, tokens, wrap } from "../terminal/ui"
9
+ import { costOf, ratesOf, readUsage, type UsageEntry, usageFiles } from "../usage"
10
+
11
+ interface Bucket {
12
+ start: number
13
+ label: string
14
+ usage: UsageEntry[]
15
+ logged: RequestRecord[]
16
+ }
17
+
18
+ type Matched = Map<RequestRecord, UsageEntry>
19
+
20
+ const DAY = 86_400_000
21
+ const MATCH_WITHIN = 10 * 60_000
22
+ const NOTE_WIDTH = 110
23
+ const DEFAULT_DAYS = 7
24
+ const DEFAULT_WEEKS = 8
25
+ const LONGEST = 5
26
+
27
+ const midnight = (ms: number) => new Date(new Date(ms).toDateString()).getTime()
28
+ const addDays = (ms: number, days: number) => {
29
+ const date = new Date(ms)
30
+ date.setDate(date.getDate() + days)
31
+ return date.getTime()
32
+ }
33
+ const monday = (ms: number) => addDays(midnight(ms), -((new Date(ms).getDay() + 6) % 7))
34
+ const short = (ms: number) => new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" })
35
+ const dayLabel = (ms: number) => new Date(ms).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
36
+ const dollars = (amount: number) => `$${amount >= 100 ? Math.round(amount).toLocaleString() : amount.toFixed(2)}`
37
+
38
+ export function buckets(now: number, weekly: boolean, count: number): Bucket[] {
39
+ const step = weekly ? 7 : 1
40
+ const last = weekly ? monday(now) : midnight(now)
41
+ return Array.from({ length: count }, (_, i) => {
42
+ const start = addDays(last, -step * (count - 1 - i))
43
+ const label = weekly ? `${short(start)} – ${short(addDays(start, 6))}` : dayLabel(start)
44
+ return { start, label, usage: [], logged: [] }
45
+ })
46
+ }
47
+
48
+ function place(list: Bucket[], at: number, add: (bucket: Bucket) => void) {
49
+ for (let i = list.length - 1; i >= 0; i -= 1) {
50
+ if (at < list[i].start) continue
51
+ add(list[i])
52
+ return
53
+ }
54
+ }
55
+
56
+ export function summarize(usage: UsageEntry[]) {
57
+ const sum = { requests: usage.length, input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0, unpriced: 0 }
58
+ for (const entry of usage) {
59
+ sum.input += entry.input
60
+ sum.output += entry.output
61
+ sum.cacheWrite += entry.cacheWrite5m + entry.cacheWrite1h
62
+ sum.cacheRead += entry.cacheRead
63
+ const cost = costOf(entry)
64
+ if (cost === null) sum.unpriced += 1
65
+ else sum.cost += cost
66
+ }
67
+ return sum
68
+ }
69
+
70
+ const usageKey = (cacheRead: number, cacheWrite: number, uncached: number) => `${cacheRead}:${cacheWrite}:${uncached}`
71
+
72
+ export function matchLogged(usage: UsageEntry[], logged: RequestRecord[]): Matched {
73
+ const index = new Map<string, UsageEntry[]>()
74
+ for (const entry of usage) {
75
+ const key = usageKey(entry.cacheRead, entry.cacheWrite5m + entry.cacheWrite1h, entry.input)
76
+ index.set(key, [...(index.get(key) ?? []), entry])
77
+ }
78
+ const matched: Matched = new Map()
79
+ for (const record of logged) {
80
+ if (!record.input) continue
81
+ const at = Date.parse(record.ts)
82
+ const distance = (entry: UsageEntry) => Math.abs(entry.at - at)
83
+ const nearest = (index.get(usageKey(record.cacheRead ?? 0, record.cacheWrite ?? 0, record.uncached ?? 0)) ?? []).reduce<UsageEntry | null>((best, entry) => (!best || distance(entry) < distance(best) ? entry : best), null)
84
+ if (nearest && distance(nearest) <= MATCH_WITHIN) matched.set(record, nearest)
85
+ }
86
+ return matched
87
+ }
88
+
89
+ export function costSaved(record: RequestRecord, entry: UsageEntry): number | null {
90
+ const saved = savedTokens(record)
91
+ const rates = ratesOf(entry)
92
+ if (saved === null || !rates) return null
93
+ const foldRewrite = record.compacted ? (record.cacheWrite ?? 0) * (rates.write - rates.read) : 0
94
+ return saved * rates.read - foldRewrite
95
+ }
96
+
97
+ export function savings(logged: RequestRecord[], matched: Matched) {
98
+ const sum = { handled: 0, priced: 0, unsized: 0, unmatched: 0, actual: 0, saved: 0 }
99
+ for (const record of logged) {
100
+ if (!record.input) continue
101
+ sum.handled += 1
102
+ const entry = matched.get(record)
103
+ if (savedTokens(record) === null) sum.unsized += 1
104
+ else if (!entry) sum.unmatched += 1
105
+ const saved = entry ? costSaved(record, entry) : null
106
+ const actual = entry ? costOf(entry) : null
107
+ if (saved === null || actual === null) continue
108
+ sum.priced += 1
109
+ sum.actual += actual
110
+ sum.saved += saved
111
+ }
112
+ return sum
113
+ }
114
+
115
+ function leftOut(sum: ReturnType<typeof savings>) {
116
+ const reasons = [
117
+ sum.unsized ? `${sum.unsized.toLocaleString()} predate size logging` : "",
118
+ sum.unmatched ? `${sum.unmatched.toLocaleString()} aren't in session files` : "",
119
+ sum.handled - sum.priced - sum.unsized - sum.unmatched > 0 ? `${sum.handled - sum.priced - sum.unsized - sum.unmatched} to models without a price` : "",
120
+ ]
121
+ const listed = reasons.filter(Boolean)
122
+ return listed.length > 1 ? `${listed.slice(0, -1).join(", ")} and ${listed.at(-1)}` : listed.join("")
123
+ }
124
+
125
+ function notSentCell(logged: RequestRecord[]) {
126
+ const sum = totals(logged)
127
+ if (sum.requests === 0) return c.dim("–")
128
+ if (sum.saved <= 0) return c.dim("±0")
129
+ return `${c.green(c.bold(tokens(sum.saved)))} ${c.dim(plural(sum.folds, "fold"))}`
130
+ }
131
+
132
+ function avoidedCell(logged: RequestRecord[], avoided: Set<RequestRecord>) {
133
+ if (!logged.some((record) => record.input)) return c.dim("–")
134
+ const count = logged.filter((record) => avoided.has(record)).length
135
+ return count > 0 ? c.green(c.bold(count.toLocaleString())) : c.dim("0")
136
+ }
137
+
138
+ function costSavedCell(logged: RequestRecord[], matched: Matched) {
139
+ const sum = savings(logged, matched)
140
+ if (sum.priced === 0) return c.dim("–")
141
+ if (Math.abs(sum.saved) < 0.005) return c.dim("±0")
142
+ const share = c.dim(percent(sum.saved / (sum.actual + sum.saved)))
143
+ return sum.saved > 0 ? `${dollars(sum.saved)} ${share}` : `${c.yellow(`−${dollars(-sum.saved)}`)} ${share}`
144
+ }
145
+
146
+ function row(label: string, usage: UsageEntry[], logged: RequestRecord[], matched: Matched, avoided: Set<RequestRecord>, bold = false) {
147
+ const sum = summarize(usage)
148
+ const strong = (text: string) => (bold ? c.bold(text) : text)
149
+ const eunoe = [avoidedCell(logged, avoided), notSentCell(logged), costSavedCell(logged, matched)]
150
+ if (sum.requests === 0) return [strong(label), c.dim("–"), "", "", "", "", "", ...eunoe]
151
+ return [strong(label), strong(sum.requests.toLocaleString()), tokens(sum.input), tokens(sum.output), tokens(sum.cacheWrite), tokens(sum.cacheRead), strong(`${dollars(sum.cost)}${sum.unpriced ? c.dim("*") : ""}`), ...eunoe]
152
+ }
153
+
154
+ function byModel(usage: UsageEntry[]) {
155
+ const models = new Map<string, UsageEntry[]>()
156
+ for (const entry of usage) models.set(entry.model, [...(models.get(entry.model) ?? []), entry])
157
+ const total = summarize(usage).cost
158
+ return [...models]
159
+ .map(([model, entries]) => ({ model, sum: summarize(entries) }))
160
+ .sort((a, b) => b.sum.cost - a.sum.cost || b.sum.requests - a.sum.requests)
161
+ .map(({ model, sum }) => [
162
+ model,
163
+ sum.requests.toLocaleString(),
164
+ tokens(sum.input),
165
+ tokens(sum.output),
166
+ tokens(sum.cacheWrite),
167
+ tokens(sum.cacheRead),
168
+ sum.unpriced ? c.dim("no price") : c.bold(dollars(sum.cost)),
169
+ c.dim(total > 0 && !sum.unpriced ? percent(sum.cost / total) : ""),
170
+ ])
171
+ }
172
+
173
+ function longestRow(peak: ThreadPeak) {
174
+ return [
175
+ c.cyan(shortId(peak.session)),
176
+ `${c.green(c.bold(tokens(peak.plain)))} ${c.dim("of history")}`,
177
+ peak.compactions > 0 ? `${c.bold(plural(peak.compactions, "compaction"))} avoided` : c.dim("no compaction needed"),
178
+ c.dim(`eunoe sent ${tokens(peak.sent)}${peak.turns ? ` · ${plural(peak.turns, "turn")}` : ""} · ${dayLabel(Date.parse(peak.lastSeen))}`),
179
+ ]
180
+ }
181
+
182
+ const countArg = (args: string[], flag: string, fallback: number) => {
183
+ const at = args.indexOf(flag)
184
+ if (at === -1) return fallback
185
+ const value = Number(args[at + 1])
186
+ if (!Number.isInteger(value) || value < 1) fail(`${flag} needs a whole number`, `eunoe stats ${flag} 14`)
187
+ return value
188
+ }
189
+
190
+ export const stats: Command = {
191
+ summary: "Token usage and cost by day or week, and what eunoe saved",
192
+ usage: "eunoe stats [--days <n>] [--weekly] [--weeks <n>]",
193
+ details: [
194
+ "Reads the usage the API returned for every request in your Claude Code",
195
+ "sessions, subagents included, counting each response once. Cost is at",
196
+ "API list prices. The eunoe columns come from its request log: the",
197
+ "compactions Claude Code would have run on the conversations it handled,",
198
+ "the input tokens it kept out, and what that saved at cache-read prices",
199
+ "once the cache writes its folds caused are paid for.",
200
+ ],
201
+ options: [
202
+ ["--days <n>", `Days to show (default ${DEFAULT_DAYS})`],
203
+ ["--weekly", `Group by week, Monday first (default ${DEFAULT_WEEKS} weeks)`],
204
+ ["--weeks <n>", "Weeks to show; implies --weekly"],
205
+ ],
206
+ run(args) {
207
+ const weekly = args.includes("--weekly") || args.includes("--weeks")
208
+ const count = weekly ? countArg(args, "--weeks", DEFAULT_WEEKS) : countArg(args, "--days", DEFAULT_DAYS)
209
+ const now = Date.now()
210
+ const list = buckets(now, weekly, count)
211
+ const since = list[0].start
212
+ const configDirs = claudeConfigDirs(readConfig())
213
+ const usage = readUsage(usageFiles(configDirs, since), since)
214
+ const all = recentRequests(Number.POSITIVE_INFINITY)
215
+ const avoided = nativeCompactions(all)
216
+ const logged = all.filter((record) => Date.parse(record.ts) >= since)
217
+ for (const entry of usage) place(list, entry.at, (bucket) => bucket.usage.push(entry))
218
+ const matched = matchLogged(usage, logged)
219
+ for (const record of logged) place(list, Date.parse(record.ts), (bucket) => bucket.logged.push(record))
220
+
221
+ line()
222
+ line(` ${c.bold("eunoe stats")} ${c.dim(`the last ${count} ${weekly ? "weeks" : "days"} of Claude Code, from your session files`)}`)
223
+ line()
224
+ table([
225
+ [weekly ? "Week" : "Day", "Requests", "Input", "Output", "Cache write", "Cache read", "API cost", "Compactions avoided", "Tokens saved", "Cost saved"].map((title) => c.dim(title)),
226
+ ...list.map((bucket) => row(bucket.label, bucket.usage, bucket.logged, matched, avoided)),
227
+ row("Total", usage, logged, matched, avoided, true),
228
+ ])
229
+
230
+ const longest = threadPeaks(all, avoided)
231
+ .filter((peak) => Date.parse(peak.lastSeen) >= since)
232
+ .slice(0, LONGEST)
233
+ if (longest.length > 0) {
234
+ line()
235
+ line(` ${c.dim("Longest conversations, by the history Claude Code would have had to carry")}`)
236
+ table(longest.map(longestRow))
237
+ }
238
+
239
+ const models = byModel(usage)
240
+ if (models.length > 0) {
241
+ line()
242
+ table([["Model", "Requests", "Input", "Output", "Cache write", "Cache read", "API cost", "Share"].map((title) => c.dim(title)), ...models])
243
+ }
244
+
245
+ const keepDays = cleanupDays(configDirs)
246
+ const handled = savings(logged, matched)
247
+ const notes: Array<[label: string, text: string]> = [
248
+ ["API cost", "At list prices, including cache writes and fast mode."],
249
+ ["Compactions", `Times Claude Code would have compacted a conversation eunoe handled, at ${Math.round(REPLAY_NATIVE_COMPACT_AT * 100)}% of its window. eunoe kept every turn instead.`],
250
+ ["Tokens saved", "Input eunoe kept out, mostly history Claude Code would have read from cache."],
251
+ ["Cost saved", "Those tokens at cache-read prices, minus what each fold cost to rewrite."],
252
+ ...(handled.priced < handled.handled ? [["Not priced", `${(handled.handled - handled.priced).toLocaleString()} of ${handled.handled.toLocaleString()} eunoe requests: ${leftOut(handled)}.`] as [string, string]] : []),
253
+ ...(summarize(usage).unpriced ? [["* No price", "Some models have no known price, so their tokens count but their cost doesn't."] as [string, string]] : []),
254
+ ...(since < now - keepDays * DAY ? [["History", `Claude Code deletes sessions after ${keepDays} idle days, so older rows may be incomplete.`] as [string, string]] : []),
255
+ ]
256
+ const labelWidth = Math.max(...notes.map(([label]) => label.length)) + 3
257
+ const width = Math.min(process.stdout.columns || NOTE_WIDTH, NOTE_WIDTH) - 2 - labelWidth
258
+ for (const [label, text] of notes) {
259
+ line()
260
+ for (const [index, part] of wrap(text, width).entries()) line(` ${c.dim((index === 0 ? label : "").padEnd(labelWidth))}${part}`)
261
+ }
262
+ line()
263
+ },
264
+ }
@@ -29,7 +29,7 @@ function summary(title: string, sum: Totals) {
29
29
  line(` ${title}`)
30
30
  rows(
31
31
  [
32
- ["Requests", `${c.bold(String(sum.requests))}${folds}`],
32
+ ["Requests", `${c.bold(sum.requests.toLocaleString())}${folds}`],
33
33
  ["Cache", `${c.bold(tokens(sum.cacheRead))} read ${c.dim("·")} ${tokens(sum.cacheWrite)} written ${c.dim("·")} ${tokens(sum.uncached)} uncached${hitRate}`],
34
34
  ["Saved", saved],
35
35
  ],
@@ -100,7 +100,7 @@ export const status: Command = {
100
100
  const routed = routedDirs(config)
101
101
  const all = recentRequests(Number.POSITIVE_INFINITY)
102
102
  const recent = all.slice(-RECENT_REQUESTS)
103
- const sum = totals(withinWindow(recent))
103
+ const sum = totals(withinWindow(all))
104
104
  const allTime = totals(all)
105
105
 
106
106
  line()
@@ -114,7 +114,7 @@ export const status: Command = {
114
114
  ["Recall", recallLine(config, recent)],
115
115
  ])
116
116
 
117
- sessionsTable(config, perSession(recent))
117
+ sessionsTable(config, perSession(all))
118
118
 
119
119
  if (sum.requests) summary(c.dim("Last 24h"), sum)
120
120
  if (allTime.requests > sum.requests) summary(`${c.dim("All time")} ${c.dim(`since ${new Date(all[0].ts).toLocaleDateString()}`)}`, allTime)
@@ -7,6 +7,8 @@ export interface Settings {
7
7
  [key: string]: unknown
8
8
  }
9
9
 
10
+ const CLAUDE_CLEANUP_DAYS = 30
11
+
10
12
  export const BLOCKING_LIMIT_ENV = "CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE"
11
13
  export const BLOCKING_LIMIT_OFF = "1000000000"
12
14
 
@@ -44,6 +46,14 @@ export function readSettings(dir: string): Settings {
44
46
  }
45
47
  }
46
48
 
49
+ export function cleanupDays(configDirs: string[]): number {
50
+ for (const dir of configDirs) {
51
+ const days = readSettings(dir).cleanupPeriodDays
52
+ if (typeof days === "number") return days
53
+ }
54
+ return CLAUDE_CLEANUP_DAYS
55
+ }
56
+
47
57
  export function editSettings(dirs: string[], edit: (settings: Settings) => Settings): string[] {
48
58
  const touched: string[] = []
49
59
  for (const dir of dirs) {
@@ -0,0 +1,57 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ export interface LiveSession {
6
+ pid: number
7
+ sessionId: string
8
+ cwd: string
9
+ startedAt: number
10
+ status: string
11
+ entrypoint: string
12
+ }
13
+
14
+ const isLiveSession = (value: Partial<LiveSession>): value is LiveSession => typeof value.pid === "number" && typeof value.sessionId === "string" && typeof value.startedAt === "number" && typeof value.status === "string"
15
+
16
+ function runsClaude(pid: number) {
17
+ const result = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" })
18
+ return result.status === 0 && /claude/i.test(result.stdout)
19
+ }
20
+
21
+ export function listLiveSessions(configDirs: string[]): LiveSession[] {
22
+ const live: LiveSession[] = []
23
+ for (const dir of configDirs) {
24
+ const sessionsDir = path.join(dir, "sessions")
25
+ if (!existsSync(sessionsDir)) continue
26
+ for (const file of readdirSync(sessionsDir).filter((name) => /^\d+\.json$/.test(name))) {
27
+ try {
28
+ const session = JSON.parse(readFileSync(path.join(sessionsDir, file), "utf8"))
29
+ if (isLiveSession(session) && runsClaude(session.pid)) live.push(session)
30
+ } catch {}
31
+ }
32
+ }
33
+ return live
34
+ }
35
+
36
+ export function unroutedSessions(live: LiveSession[], lastSeen: Map<string, string>, managed: (sessionId: string) => boolean, self: string | undefined): LiveSession[] {
37
+ return live.filter((session) => {
38
+ if (session.sessionId === self || !managed(session.sessionId)) return false
39
+ const seen = lastSeen.get(session.sessionId)
40
+ return !seen || Date.parse(seen) < session.startedAt
41
+ })
42
+ }
43
+
44
+ export const isIdle = (session: LiveSession) => session.status === "idle"
45
+
46
+ export const isTerminal = (session: LiveSession) => session.entrypoint === "cli"
47
+
48
+ export function restartSessions(sessions: LiveSession[]): LiveSession[] {
49
+ return sessions.filter((session) => {
50
+ try {
51
+ process.kill(session.pid, "SIGTERM")
52
+ return true
53
+ } catch {
54
+ return false
55
+ }
56
+ })
57
+ }
@@ -1,3 +1,5 @@
1
+ import { DEFAULT_RATIO } from "../../proxy/estimate"
2
+ import { NATIVE_SUMMARY_CHARS, REPLAY_NATIVE_COMPACT_AT } from "../replay"
1
3
  import type { RequestRecord } from "./inspect"
2
4
 
3
5
  export interface SessionStats {
@@ -31,6 +33,11 @@ function scaled(record: RequestRecord, chars: number | undefined): number | null
31
33
  return input > 0 && charsOut > 0 && chars && chars > 0 ? Math.round((input * chars) / charsOut) : null
32
34
  }
33
35
 
36
+ export function savedTokens(record: RequestRecord): number | null {
37
+ const plain = scaled(record, record.charsIn)
38
+ return plain === null ? null : plain - inputOf(record)
39
+ }
40
+
34
41
  export function withinWindow(records: RequestRecord[], now = Date.now(), window = 24 * 3_600_000): RequestRecord[] {
35
42
  return records.filter((record) => now - Date.parse(record.ts) <= window)
36
43
  }
@@ -66,10 +73,10 @@ export function totals(records: RequestRecord[]): Totals {
66
73
  out.folds += 1
67
74
  out.lastFold = record.ts
68
75
  }
69
- const plain = scaled(record, record.charsIn)
70
- if (plain !== null) {
76
+ const saved = savedTokens(record)
77
+ if (saved !== null) {
71
78
  out.sent += inputOf(record)
72
- out.saved += plain - inputOf(record)
79
+ out.saved += saved
73
80
  }
74
81
  }
75
82
  return out
@@ -79,3 +86,49 @@ export function savedShare(context: number, plain: number | null): number | null
79
86
  if (!plain) return null
80
87
  return plain > context ? 1 - context / plain : 0
81
88
  }
89
+
90
+ export function nativeCompactions(records: RequestRecord[]): Set<RequestRecord> {
91
+ const summary = Math.round(NATIVE_SUMMARY_CHARS * DEFAULT_RATIO)
92
+ const offsets = new Map<string, number>()
93
+ const compacted = new Set<RequestRecord>()
94
+ for (const record of records) {
95
+ const plain = scaled(record, record.charsIn)
96
+ const window = typeof record.window === "number" ? record.window : 0
97
+ if (typeof record.thread !== "string" || plain === null || window <= 0) continue
98
+ if (plain - (offsets.get(record.thread) ?? 0) <= REPLAY_NATIVE_COMPACT_AT * window) continue
99
+ compacted.add(record)
100
+ offsets.set(record.thread, plain - (scaled(record, record.charsFixed) ?? 0) - summary)
101
+ }
102
+ return compacted
103
+ }
104
+
105
+ export interface ThreadPeak {
106
+ thread: string
107
+ session: string
108
+ plain: number
109
+ sent: number
110
+ turns: number | null
111
+ compactions: number
112
+ lastSeen: string
113
+ }
114
+
115
+ export function threadPeaks(records: RequestRecord[], compacted: Set<RequestRecord>): ThreadPeak[] {
116
+ const peaks = new Map<string, ThreadPeak>()
117
+ for (const record of records) {
118
+ const plain = scaled(record, record.charsIn)
119
+ const session = sessionOf(record)
120
+ if (typeof record.thread !== "string" || !session || plain === null) continue
121
+ const previous = peaks.get(record.thread)
122
+ const higher = !previous || plain >= previous.plain
123
+ peaks.set(record.thread, {
124
+ thread: record.thread,
125
+ session,
126
+ plain: higher ? plain : previous.plain,
127
+ sent: higher ? inputOf(record) : previous.sent,
128
+ turns: record.turns ?? previous?.turns ?? null,
129
+ compactions: (previous?.compactions ?? 0) + (compacted.has(record) ? 1 : 0),
130
+ lastSeen: record.ts,
131
+ })
132
+ }
133
+ return [...peaks.values()].sort((a, b) => b.plain - a.plain)
134
+ }
@@ -36,9 +36,14 @@ export interface ReplayedSession extends Replay {
36
36
  ms: number
37
37
  }
38
38
 
39
+ export interface ReplayFailure {
40
+ file: string
41
+ error: string
42
+ }
43
+
39
44
  export const REPLAY_NATIVE_COMPACT_AT = 0.92
40
45
 
41
- const NATIVE_SUMMARY_CHARS = 30_000
46
+ export const NATIVE_SUMMARY_CHARS = 30_000
42
47
  const CACHE_READ_WEIGHT = 0.1
43
48
  const CACHE_WRITE_WEIGHT = 1.25
44
49
 
@@ -80,7 +85,7 @@ export function replaySession(messages: Message[], { config, window, fixedTokens
80
85
 
81
86
  const state: ThreadState = {}
82
87
  const eunoe = { sent: [] as Message[], sum: 0, count: 0, upTo: 0, recall: 0, compactedTurn: -2, previous: 0 }
83
- const plain = { sum: 0, count: 0, since: 0, previous: 0 }
88
+ const plain = { sum: 0, count: 0, since: 0, previous: 0, whole: 0, wholeCount: 0 }
84
89
 
85
90
  const sendEunoe = (i: number) => {
86
91
  const turn = turnAt[i - 1]
@@ -114,10 +119,12 @@ export function replaySession(messages: Message[], { config, window, fixedTokens
114
119
  for (; plain.since < i; plain.since += 1) {
115
120
  plain.sum += sizes[plain.since]
116
121
  plain.count += 1
122
+ plain.whole += sizes[plain.since]
123
+ plain.wholeCount += 1
117
124
  }
125
+ out.peakPlain = Math.max(out.peakPlain, tokens(fixedChars + listChars(plain.whole, plain.wholeCount)))
118
126
  let chars = fixedChars + listChars(plain.sum, plain.count)
119
127
  let cacheable = plain.previous
120
- out.peakPlain = Math.max(out.peakPlain, tokens(chars))
121
128
  if (tokens(chars) > REPLAY_NATIVE_COMPACT_AT * window) {
122
129
  out.nativeCompactions += 1
123
130
  out.discarded += tokens(chars - fixedChars)
@@ -2,14 +2,19 @@ import { statSync } from "node:fs"
2
2
  import { basename } from "node:path"
3
3
  import { cwdOf, titleOf } from "../../proxy/sessions"
4
4
  import { sessionMessages } from "../../transcript/messages"
5
- import { type ReplayedSession, type ReplayOptions, replaySession } from "."
5
+ import { type ReplayedSession, type ReplayFailure, type ReplayOptions, replaySession } from "."
6
6
 
7
7
  declare var self: Worker
8
8
 
9
9
  self.onmessage = (event: MessageEvent<{ file: string; options: ReplayOptions }>) => {
10
10
  const { file, options } = event.data
11
11
  const started = performance.now()
12
- const replay = replaySession(sessionMessages(file), options)
13
- const done: ReplayedSession = { ...replay, id: basename(file, ".jsonl"), file, bytes: statSync(file).size, cwd: cwdOf(file) ?? null, title: titleOf(file) ?? null, ms: Math.round(performance.now() - started) }
14
- postMessage(done)
12
+ try {
13
+ const replay = replaySession(sessionMessages(file), options)
14
+ const done: ReplayedSession = { ...replay, id: basename(file, ".jsonl"), file, bytes: statSync(file).size, cwd: cwdOf(file) ?? null, title: titleOf(file) ?? null, ms: Math.round(performance.now() - started) }
15
+ postMessage(done)
16
+ } catch (error) {
17
+ const failed: ReplayFailure = { file, error: String(error) }
18
+ postMessage(failed)
19
+ }
15
20
  }
@@ -0,0 +1,151 @@
1
+ import { readFileSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { listEntries, projectFolders } from "../../transcript/locate"
4
+ import { subagentsDir } from "../../transcript/subagents"
5
+
6
+ export interface UsageEntry {
7
+ at: number
8
+ model: string
9
+ input: number
10
+ output: number
11
+ cacheWrite5m: number
12
+ cacheWrite1h: number
13
+ cacheRead: number
14
+ fast: boolean
15
+ }
16
+
17
+ interface Price {
18
+ input: number
19
+ output: number
20
+ cacheRead: number
21
+ }
22
+
23
+ interface RawUsage {
24
+ input_tokens?: number
25
+ output_tokens?: number
26
+ cache_creation_input_tokens?: number
27
+ cache_read_input_tokens?: number
28
+ cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
29
+ service_tier?: string
30
+ speed?: string
31
+ }
32
+
33
+ interface RawLine {
34
+ timestamp?: string
35
+ requestId?: string
36
+ message?: { id?: string; model?: string; usage?: RawUsage }
37
+ }
38
+
39
+ const PRICES: Array<[prefix: string, price: Price]> = [
40
+ ["claude-fable-5-1", { input: 10, output: 50, cacheRead: 0.25 }],
41
+ ["claude-mythos-5-1", { input: 10, output: 50, cacheRead: 0.25 }],
42
+ ["claude-fable-5", { input: 10, output: 50, cacheRead: 1 }],
43
+ ["claude-mythos-5", { input: 10, output: 50, cacheRead: 1 }],
44
+ ["claude-opus-5-5", { input: 4, output: 20, cacheRead: 0.2 }],
45
+ ["claude-opus-5", { input: 5, output: 25, cacheRead: 0.5 }],
46
+ ["claude-opus-4-8", { input: 5, output: 25, cacheRead: 0.5 }],
47
+ ["claude-opus-4-7", { input: 5, output: 25, cacheRead: 0.5 }],
48
+ ["claude-opus-4-6", { input: 5, output: 25, cacheRead: 0.5 }],
49
+ ["claude-opus-4-5", { input: 5, output: 25, cacheRead: 0.5 }],
50
+ ["claude-sonnet-5-5", { input: 2, output: 10, cacheRead: 0.2 }],
51
+ ["claude-sonnet-5", { input: 2, output: 10, cacheRead: 0.2 }],
52
+ ["claude-sonnet-4", { input: 3, output: 15, cacheRead: 0.3 }],
53
+ ["claude-haiku-4-5", { input: 1, output: 5, cacheRead: 0.1 }],
54
+ ]
55
+
56
+ const CACHE_WRITE_5M = 1.25
57
+ const CACHE_WRITE_1H = 2
58
+ const FAST_MULTIPLIER = 2
59
+ const PLACEHOLDER_INPUT = 1000
60
+
61
+ const priceFor = (model: string) => PRICES.find(([prefix]) => model === prefix || model.startsWith(`${prefix}-`))?.[1] ?? null
62
+
63
+ export function ratesOf(entry: UsageEntry): { read: number; write: number } | null {
64
+ const price = priceFor(entry.model)
65
+ if (!price) return null
66
+ const writes = entry.cacheWrite5m + entry.cacheWrite1h
67
+ const writeShare = writes > 0 ? (entry.cacheWrite5m * CACHE_WRITE_5M + entry.cacheWrite1h * CACHE_WRITE_1H) / writes : CACHE_WRITE_1H
68
+ const scale = (entry.fast ? FAST_MULTIPLIER : 1) / 1e6
69
+ return { read: price.cacheRead * scale, write: price.input * writeShare * scale }
70
+ }
71
+
72
+ export function costOf(entry: UsageEntry): number | null {
73
+ const price = priceFor(entry.model)
74
+ if (!price) return null
75
+ const dollars = entry.input * price.input + entry.output * price.output + entry.cacheRead * price.cacheRead + entry.cacheWrite5m * price.input * CACHE_WRITE_5M + entry.cacheWrite1h * price.input * CACHE_WRITE_1H
76
+ return (dollars / 1e6) * (entry.fast ? FAST_MULTIPLIER : 1)
77
+ }
78
+
79
+ const fromApi = (usage: RawUsage) => usage.service_tier !== undefined
80
+
81
+ const isPlaceholder = (usage: RawUsage) => !fromApi(usage) && usage.input_tokens === PLACEHOLDER_INPUT && !usage.cache_read_input_tokens && !usage.cache_creation_input_tokens
82
+
83
+ const supersedes = (next: RawUsage, known: RawUsage) =>
84
+ fromApi(next) !== fromApi(known)
85
+ ? fromApi(next)
86
+ : (next.output_tokens ?? 0) > (known.output_tokens ?? 0) || ((next.output_tokens ?? 0) === (known.output_tokens ?? 0) && (next.cache_creation_input_tokens ?? 0) > (known.cache_creation_input_tokens ?? 0))
87
+
88
+ function entryOf(line: RawLine, usage: RawUsage, at: number): UsageEntry {
89
+ const written = usage.cache_creation_input_tokens ?? 0
90
+ const hour = Math.min(written, usage.cache_creation?.ephemeral_1h_input_tokens ?? 0)
91
+ return {
92
+ at,
93
+ model: line.message?.model ?? "unknown",
94
+ input: usage.input_tokens ?? 0,
95
+ output: usage.output_tokens ?? 0,
96
+ cacheWrite5m: written - hour,
97
+ cacheWrite1h: hour,
98
+ cacheRead: usage.cache_read_input_tokens ?? 0,
99
+ fast: usage.speed === "fast",
100
+ }
101
+ }
102
+
103
+ export function usageFiles(configDirs: string[], since: number): string[] {
104
+ const files: string[] = []
105
+ const recent = (file: string) => {
106
+ try {
107
+ return statSync(file).mtimeMs >= since
108
+ } catch {
109
+ return false
110
+ }
111
+ }
112
+ for (const folder of projectFolders(configDirs)) {
113
+ for (const name of listEntries(folder)) {
114
+ if (!name.endsWith(".jsonl")) continue
115
+ const file = path.join(folder, name)
116
+ if (recent(file)) files.push(file)
117
+ const subagents = subagentsDir(file)
118
+ for (const agent of listEntries(subagents)) if (agent.endsWith(".jsonl") && recent(path.join(subagents, agent))) files.push(path.join(subagents, agent))
119
+ }
120
+ }
121
+ return files.sort()
122
+ }
123
+
124
+ export function readUsage(files: string[], since: number): UsageEntry[] {
125
+ const seen = new Map<string, { usage: RawUsage; entry: UsageEntry }>()
126
+ for (const file of files) {
127
+ let text: string
128
+ try {
129
+ text = readFileSync(file, "utf8")
130
+ } catch {
131
+ continue
132
+ }
133
+ for (const [index, raw] of text.split("\n").entries()) {
134
+ if (!raw.includes('"usage"')) continue
135
+ let line: RawLine
136
+ try {
137
+ line = JSON.parse(raw)
138
+ } catch {
139
+ continue
140
+ }
141
+ const usage = line.message?.usage
142
+ const at = Date.parse(line.timestamp ?? "")
143
+ if (!usage || !(at >= since) || line.message?.model === "<synthetic>") continue
144
+ const key = line.message?.id ? `${line.message.id}:${line.requestId ?? ""}` : `${file}:${index}`
145
+ const known = seen.get(key)
146
+ if (known && !supersedes(usage, known.usage)) continue
147
+ seen.set(key, { usage, entry: entryOf(line, usage, at) })
148
+ }
149
+ }
150
+ return [...seen.values()].filter(({ usage }) => !isPlaceholder(usage)).map(({ entry }) => entry)
151
+ }