@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 +13 -0
- package/package.json +1 -1
- package/src/cli/commands/index.ts +2 -0
- package/src/cli/commands/replay.ts +79 -28
- package/src/cli/commands/start.ts +32 -6
- package/src/cli/commands/stats.ts +264 -0
- package/src/cli/commands/status.ts +3 -3
- package/src/cli/proxy/claude-settings.ts +10 -0
- package/src/cli/proxy/live.ts +57 -0
- package/src/cli/proxy/stats.ts +56 -3
- package/src/cli/replay/index.ts +10 -3
- package/src/cli/replay/worker.ts +9 -4
- package/src/cli/usage/index.ts +151 -0
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
if (!
|
|
93
|
+
current = queue.shift()
|
|
94
|
+
if (!current) {
|
|
63
95
|
worker.terminate()
|
|
64
96
|
resolve()
|
|
65
97
|
return
|
|
66
98
|
}
|
|
67
|
-
worker.postMessage({ file:
|
|
99
|
+
worker.postMessage({ file: current.file, options })
|
|
68
100
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
155
|
-
["Folds dropped", `${c.bold(
|
|
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.
|
|
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("
|
|
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.
|
|
172
|
-
`${
|
|
173
|
-
c.dim(
|
|
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
|
|
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
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/src/cli/proxy/stats.ts
CHANGED
|
@@ -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
|
|
70
|
-
if (
|
|
76
|
+
const saved = savedTokens(record)
|
|
77
|
+
if (saved !== null) {
|
|
71
78
|
out.sent += inputOf(record)
|
|
72
|
-
out.saved +=
|
|
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
|
+
}
|
package/src/cli/replay/index.ts
CHANGED
|
@@ -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)
|
package/src/cli/replay/worker.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
}
|