@brimveyn/aimux 1.22.10 → 1.22.12
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/package.json +2 -2
- package/src/index.tsx +15 -0
- package/src/integrations/claude-hooks-install.ts +3 -7
- package/src/integrations/claude-theme-sync.ts +5 -9
- package/src/platform/assistant-home.ts +25 -0
- package/src/platform/daemon-control.ts +10 -1
- package/src/services/ai-usage/adapters/claude.ts +2 -8
- package/src/services/ai-usage/adapters/codex.ts +1 -7
- package/src/services/usage-history/rollup.ts +278 -0
- package/src/services/usage-history/stats.ts +193 -0
- package/src/services/usage-history/store.ts +174 -0
- package/src/state/reducers/modal-state.ts +4 -0
- package/src/ui/components/modals/app/ai-usage-modal.tsx +351 -24
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +2 -7
- package/src/ui/format-number.ts +12 -0
- package/src/ui/root.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.12",
|
|
4
4
|
"description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
|
-
"@brimveyn/aimux-config": "0.10.
|
|
69
|
+
"@brimveyn/aimux-config": "0.10.1",
|
|
70
70
|
"@opentui/core": "^0.1.90",
|
|
71
71
|
"@opentui/react": "^0.1.90",
|
|
72
72
|
"@resvg/resvg-wasm": "^2.6.2",
|
package/src/index.tsx
CHANGED
|
@@ -51,6 +51,13 @@ if (command === 'update') {
|
|
|
51
51
|
process.exit(await runUpdate())
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// Spawned detached by `maybeSpawnUsageRollup` below. Walks hundreds of MB of
|
|
55
|
+
// assistant JSONL, so it gets its own process and never shares one with the UI.
|
|
56
|
+
if (command === 'usage-rollup') {
|
|
57
|
+
const { runUsageRollup } = await import('./services/usage-history/rollup')
|
|
58
|
+
process.exit(await runUsageRollup())
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
const { logDebug } = await import('./debug/input-log')
|
|
55
62
|
const { getRuntimeProfile } = await import('./daemon/runtime-paths')
|
|
56
63
|
const runtimeProfile = getRuntimeProfile()
|
|
@@ -87,6 +94,7 @@ const [
|
|
|
87
94
|
{ setHostPalette },
|
|
88
95
|
{ createSessionBackend },
|
|
89
96
|
{ maybeAutoInstallCompletion },
|
|
97
|
+
{ maybeSpawnUsageRollup },
|
|
90
98
|
] = await Promise.all([
|
|
91
99
|
import('@opentui/react'),
|
|
92
100
|
import('./app'),
|
|
@@ -95,6 +103,7 @@ const [
|
|
|
95
103
|
import('./ui/host-palette'),
|
|
96
104
|
import('./session-backend/bootstrap'),
|
|
97
105
|
import('./cli/completion/install'),
|
|
106
|
+
import('./services/usage-history/store'),
|
|
98
107
|
])
|
|
99
108
|
const { resolved: resolvedConfig, user: userConfig } = await loadUserConfig()
|
|
100
109
|
|
|
@@ -104,6 +113,12 @@ const { resolved: resolvedConfig, user: userConfig } = await loadUserConfig()
|
|
|
104
113
|
// AIMUX_NO_COMPLETION_INSTALL=1; `aimux doctor` reports what landed where.
|
|
105
114
|
maybeAutoInstallCompletion()
|
|
106
115
|
|
|
116
|
+
// Snapshot today's AI usage before Claude Code prunes the transcripts it came
|
|
117
|
+
// from — roughly a month survives on disk, so the aggregates aimux keeps are
|
|
118
|
+
// the only long-term record. Detached subprocess, at most once per ~day, never
|
|
119
|
+
// blocking. Opt out with AIMUX_NO_USAGE_ROLLUP=1.
|
|
120
|
+
maybeSpawnUsageRollup()
|
|
121
|
+
|
|
107
122
|
const renderer = await createCliRenderer({
|
|
108
123
|
autoFocus: true,
|
|
109
124
|
// Transparent clear color so cells untouched by BoxRenderable paints (e.g.
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// Hooks reference: https://code.claude.com/docs/en/hooks.md
|
|
7
7
|
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
9
|
-
import { homedir } from 'node:os'
|
|
10
9
|
import { dirname, join, resolve } from 'node:path'
|
|
11
10
|
import { fileURLToPath } from 'node:url'
|
|
12
11
|
|
|
13
12
|
import { logDebug } from '../debug/input-log'
|
|
13
|
+
import { claudeHome } from '../platform/assistant-home'
|
|
14
14
|
|
|
15
15
|
const HOOK_EVENTS = [
|
|
16
16
|
'UserPromptSubmit',
|
|
@@ -34,12 +34,8 @@ interface HookGroupEntry {
|
|
|
34
34
|
hooks: HookCommandEntry[]
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function claudeDir(): string {
|
|
38
|
-
return join(homedir(), '.claude')
|
|
39
|
-
}
|
|
40
|
-
|
|
41
37
|
function settingsFilePath(): string {
|
|
42
|
-
return join(
|
|
38
|
+
return join(claudeHome(), 'settings.json')
|
|
43
39
|
}
|
|
44
40
|
|
|
45
41
|
function writeAtomic(target: string, contents: string): void {
|
|
@@ -180,7 +176,7 @@ export function ensureClaudeSettingsHooks(): boolean {
|
|
|
180
176
|
parsed.hooks = nextHooks
|
|
181
177
|
|
|
182
178
|
try {
|
|
183
|
-
mkdirSync(
|
|
179
|
+
mkdirSync(claudeHome(), { recursive: true })
|
|
184
180
|
writeAtomic(target, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
185
181
|
logDebug('claude-hooks-install:wrote', { events: HOOK_EVENTS, path: target, scriptPath })
|
|
186
182
|
return true
|
|
@@ -12,24 +12,20 @@ import {
|
|
|
12
12
|
type ThemeMode,
|
|
13
13
|
} from '@brimveyn/aimux-config'
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
15
|
-
import { homedir } from 'node:os'
|
|
16
15
|
import { join } from 'node:path'
|
|
17
16
|
|
|
18
17
|
import { logDebug } from '../debug/input-log'
|
|
18
|
+
import { claudeHome } from '../platform/assistant-home'
|
|
19
19
|
|
|
20
20
|
const THEME_SLUG = 'aimux'
|
|
21
21
|
const THEME_PREF_VALUE = `custom:${THEME_SLUG}`
|
|
22
22
|
|
|
23
|
-
function claudeDir(): string {
|
|
24
|
-
return join(homedir(), '.claude')
|
|
25
|
-
}
|
|
26
|
-
|
|
27
23
|
function themeFilePath(): string {
|
|
28
|
-
return join(
|
|
24
|
+
return join(claudeHome(), 'themes', `${THEME_SLUG}.json`)
|
|
29
25
|
}
|
|
30
26
|
|
|
31
27
|
function settingsFilePath(): string {
|
|
32
|
-
return join(
|
|
28
|
+
return join(claudeHome(), 'settings.json')
|
|
33
29
|
}
|
|
34
30
|
|
|
35
31
|
function writeAtomic(target: string, contents: string): void {
|
|
@@ -67,7 +63,7 @@ export function syncClaudeTheme(resolved: ResolvedTuiTheme, mode: ThemeMode): vo
|
|
|
67
63
|
|
|
68
64
|
const target = themeFilePath()
|
|
69
65
|
try {
|
|
70
|
-
mkdirSync(join(
|
|
66
|
+
mkdirSync(join(claudeHome(), 'themes'), { recursive: true })
|
|
71
67
|
writeAtomic(target, `${JSON.stringify(theme, null, 2)}\n`)
|
|
72
68
|
} catch (error) {
|
|
73
69
|
logSyncWarn('write-failed', { err: String(error), path: target })
|
|
@@ -110,7 +106,7 @@ export function ensureClaudeSettingsThemePref(): void {
|
|
|
110
106
|
parsed.theme = THEME_PREF_VALUE
|
|
111
107
|
|
|
112
108
|
try {
|
|
113
|
-
mkdirSync(
|
|
109
|
+
mkdirSync(claudeHome(), { recursive: true })
|
|
114
110
|
writeAtomic(target, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
115
111
|
} catch (error) {
|
|
116
112
|
logSyncWarn('settings-write-failed', { err: String(error), path: target })
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where each assistant CLI keeps its state.
|
|
6
|
+
*
|
|
7
|
+
* Both relocate wholesale through an environment variable, so anything reading
|
|
8
|
+
* or writing inside those directories has to go through here — three hand-rolled
|
|
9
|
+
* copies of `join(homedir(), '.claude')` used to disagree about whether
|
|
10
|
+
* `CLAUDE_CONFIG_DIR` existed, which sent the hook installer and the theme sync
|
|
11
|
+
* to a directory Claude Code was not reading.
|
|
12
|
+
*/
|
|
13
|
+
function envHome(variable: string, fallback: string): string {
|
|
14
|
+
const value = process.env[variable]?.trim()
|
|
15
|
+
if (value != null && value !== '') return value
|
|
16
|
+
return join(homedir(), fallback)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function claudeHome(): string {
|
|
20
|
+
return envHome('CLAUDE_CONFIG_DIR', '.claude')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function codexHome(): string {
|
|
24
|
+
return envHome('CODEX_HOME', '.codex')
|
|
25
|
+
}
|
|
@@ -61,14 +61,23 @@ async function waitForSocket(socketPath: string): Promise<boolean> {
|
|
|
61
61
|
return false
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Re-run this same aimux with another subcommand, detached and unwatched.
|
|
66
|
+
*
|
|
67
|
+
* The entrypoint is resolved from this module rather than `process.argv`, which
|
|
68
|
+
* points at whatever wrapper or shim launched us.
|
|
69
|
+
*/
|
|
70
|
+
export function spawnDetachedCommand(command: string): void {
|
|
65
71
|
Bun.spawn([process.execPath, 'run', ENTRY_POINT, command], {
|
|
66
72
|
detached: true,
|
|
67
73
|
stderr: 'ignore',
|
|
68
74
|
stdin: 'ignore',
|
|
69
75
|
stdout: 'ignore',
|
|
70
76
|
}).unref()
|
|
77
|
+
}
|
|
71
78
|
|
|
79
|
+
async function spawnDetachedProcess(command: 'daemon' | 'terminal-manager', socketPath: string) {
|
|
80
|
+
spawnDetachedCommand(command)
|
|
72
81
|
return waitForSocket(socketPath)
|
|
73
82
|
}
|
|
74
83
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
|
|
2
2
|
|
|
3
3
|
import { readFile } from 'node:fs/promises'
|
|
4
|
-
import { homedir } from 'node:os'
|
|
5
4
|
import { join } from 'node:path'
|
|
6
5
|
|
|
7
6
|
import type { UsageSnapshot, UsageWindow, UsageWindowKind } from '../types'
|
|
8
7
|
|
|
8
|
+
import { claudeHome } from '../../../platform/assistant-home'
|
|
9
9
|
import { computePace, formatTimeRemaining } from '../pace'
|
|
10
10
|
import { runCli } from '../spawn'
|
|
11
11
|
|
|
@@ -81,15 +81,9 @@ function planTierFromPayload(payload: ClaudeCredentialsPayload): string | null {
|
|
|
81
81
|
return null
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
function claudeConfigDir(): string {
|
|
85
|
-
const env = process.env.CLAUDE_CONFIG_DIR?.trim()
|
|
86
|
-
if (env != null && env !== '') return env
|
|
87
|
-
return join(homedir(), '.claude')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
84
|
async function readCredsFromFile(): Promise<string | null> {
|
|
91
85
|
try {
|
|
92
|
-
const raw = await readFile(join(
|
|
86
|
+
const raw = await readFile(join(claudeHome(), '.credentials.json'), 'utf8')
|
|
93
87
|
return raw.trim() || null
|
|
94
88
|
} catch {
|
|
95
89
|
return null
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
|
|
2
2
|
|
|
3
3
|
import { readFile } from 'node:fs/promises'
|
|
4
|
-
import { homedir } from 'node:os'
|
|
5
4
|
import { join } from 'node:path'
|
|
6
5
|
|
|
7
6
|
import type { UsageSnapshot, UsageWindow, UsageWindowKind } from '../types'
|
|
8
7
|
|
|
8
|
+
import { codexHome } from '../../../platform/assistant-home'
|
|
9
9
|
import { computePace, formatTimeRemaining } from '../pace'
|
|
10
10
|
|
|
11
11
|
interface CodexAuthFile {
|
|
@@ -38,12 +38,6 @@ const DEFAULT_CHATGPT_BASE = 'https://chatgpt.com/backend-api'
|
|
|
38
38
|
const USAGE_PATH = '/wham/usage'
|
|
39
39
|
const AUTH_TIMEOUT_MS = 15_000
|
|
40
40
|
|
|
41
|
-
function codexHome(): string {
|
|
42
|
-
const env = process.env.CODEX_HOME?.trim()
|
|
43
|
-
if (env != null && env !== '') return env
|
|
44
|
-
return join(homedir(), '.codex')
|
|
45
|
-
}
|
|
46
|
-
|
|
47
41
|
function parseChatGPTBaseFromConfig(contents: string): string | null {
|
|
48
42
|
for (const rawLine of contents.split(/\r?\n/)) {
|
|
49
43
|
const line = rawLine.split('#', 1)[0]?.trim() ?? ''
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { logDebug } from '../../debug/input-log'
|
|
5
|
+
import { claudeHome, codexHome } from '../../platform/assistant-home'
|
|
6
|
+
import {
|
|
7
|
+
emptyDay,
|
|
8
|
+
localDay,
|
|
9
|
+
saveUsageHistory,
|
|
10
|
+
type UsageDay,
|
|
11
|
+
type UsageDays,
|
|
12
|
+
type UsageTools,
|
|
13
|
+
} from './store'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Rebuilds the usage aggregates from what the assistants still have on disk.
|
|
17
|
+
*
|
|
18
|
+
* Its own process (`aimux usage-rollup`). Nothing here may be imported from the
|
|
19
|
+
* startup path: it walks hundreds of megabytes of JSONL.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const FILE_CONCURRENCY = 8
|
|
23
|
+
|
|
24
|
+
function dayAt(days: UsageDays, date: string): UsageDay {
|
|
25
|
+
const existing = days[date]
|
|
26
|
+
if (existing !== undefined) return existing
|
|
27
|
+
const created = emptyDay()
|
|
28
|
+
days[date] = created
|
|
29
|
+
return created
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bump(counts: Record<string, number>, key: string, amount: number): void {
|
|
33
|
+
counts[key] = (counts[key] ?? 0) + amount
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Streamed: the largest transcript here is ~90 MB, which `Bun.file().text()` would materialize whole. */
|
|
37
|
+
export async function readJsonlLines(path: string, onLine: (line: string) => void): Promise<void> {
|
|
38
|
+
const decoder = new TextDecoder()
|
|
39
|
+
let carry = ''
|
|
40
|
+
|
|
41
|
+
for await (const chunk of Bun.file(path).stream()) {
|
|
42
|
+
// `stream: true` holds back a partial UTF-8 sequence at the chunk edge
|
|
43
|
+
// instead of replacing it with U+FFFD and corrupting the line.
|
|
44
|
+
const parts = (carry + decoder.decode(chunk, { stream: true })).split('\n')
|
|
45
|
+
carry = parts.pop() ?? ''
|
|
46
|
+
for (const line of parts) onLine(line)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
carry += decoder.decode()
|
|
50
|
+
// No trailing newline, or a file being appended to right now — the half-written
|
|
51
|
+
// last line just fails JSON.parse downstream.
|
|
52
|
+
if (carry !== '') onLine(carry)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findJsonl(root: string): string[] {
|
|
56
|
+
try {
|
|
57
|
+
// Recursive: transcripts nest at varying depths and a flat readdir misses half.
|
|
58
|
+
return readdirSync(root, { recursive: true })
|
|
59
|
+
.filter((entry): entry is string => typeof entry === 'string' && entry.endsWith('.jsonl'))
|
|
60
|
+
.map((entry) => join(root, entry))
|
|
61
|
+
} catch {
|
|
62
|
+
return [] // assistant not installed on this machine
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function forEachFile(paths: string[], run: (path: string) => Promise<void>): Promise<void> {
|
|
67
|
+
let cursor = 0
|
|
68
|
+
// `cursor++` needs no lock: no await between read and increment, single thread.
|
|
69
|
+
const workers = Array.from({ length: Math.min(FILE_CONCURRENCY, paths.length) }, async () => {
|
|
70
|
+
while (cursor < paths.length) {
|
|
71
|
+
const path = paths[cursor++]
|
|
72
|
+
if (path === undefined) return
|
|
73
|
+
try {
|
|
74
|
+
await run(path)
|
|
75
|
+
} catch {
|
|
76
|
+
// One unreadable transcript must never sink the whole rollup.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
await Promise.all(workers)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface TranscriptUsage {
|
|
84
|
+
cache_creation_input_tokens?: number
|
|
85
|
+
cache_read_input_tokens?: number
|
|
86
|
+
input_tokens?: number
|
|
87
|
+
output_tokens?: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface TranscriptLine {
|
|
91
|
+
gitBranch?: string
|
|
92
|
+
message?: { id?: string; model?: string; usage?: TranscriptUsage }
|
|
93
|
+
requestId?: string
|
|
94
|
+
timestamp?: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `seen` spans the whole run: resume, fork and compaction re-emit the same billed request across files. */
|
|
98
|
+
export function consumeTranscriptLine(line: string, seen: Set<string>, days: UsageDays): void {
|
|
99
|
+
// ~23k of ~126k lines carry usage; the rest hold base64 thinking blobs. A
|
|
100
|
+
// substring test beats parsing them.
|
|
101
|
+
if (!line.includes('"output_tokens"')) return
|
|
102
|
+
|
|
103
|
+
let entry: TranscriptLine
|
|
104
|
+
try {
|
|
105
|
+
entry = JSON.parse(line) as TranscriptLine
|
|
106
|
+
} catch {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const usage = entry.message?.usage
|
|
111
|
+
if (usage === undefined || entry.timestamp == null) return
|
|
112
|
+
|
|
113
|
+
// Placeholder messages (API errors, interrupted turns) carry junk usage.
|
|
114
|
+
const model = entry.message?.model
|
|
115
|
+
if (model === '<synthetic>') return
|
|
116
|
+
|
|
117
|
+
const id = entry.message?.id
|
|
118
|
+
const { requestId } = entry
|
|
119
|
+
// Both halves or nothing: without an id a duplicate cannot be proven, and
|
|
120
|
+
// undercounting a stats page is worse than a rare double.
|
|
121
|
+
if (id != null && id !== '' && requestId != null && requestId !== '') {
|
|
122
|
+
const key = `${id}:${requestId}`
|
|
123
|
+
if (seen.has(key)) return
|
|
124
|
+
seen.add(key)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const ms = Date.parse(entry.timestamp)
|
|
128
|
+
if (!Number.isFinite(ms)) return
|
|
129
|
+
|
|
130
|
+
const cacheRead = usage.cache_read_input_tokens ?? 0
|
|
131
|
+
const cacheWrite = usage.cache_creation_input_tokens ?? 0
|
|
132
|
+
const input = usage.input_tokens ?? 0
|
|
133
|
+
const output = usage.output_tokens ?? 0
|
|
134
|
+
const total = input + output + cacheRead + cacheWrite
|
|
135
|
+
if (total <= 0) return
|
|
136
|
+
|
|
137
|
+
const day = dayAt(days, localDay(new Date(ms)))
|
|
138
|
+
day.tokens.cacheRead += cacheRead
|
|
139
|
+
day.tokens.cacheWrite += cacheWrite
|
|
140
|
+
day.tokens.input += input
|
|
141
|
+
day.tokens.output += output
|
|
142
|
+
day.tokens.total += total
|
|
143
|
+
|
|
144
|
+
if (model != null && model !== '') bump(day.models, model, total)
|
|
145
|
+
const branch = entry.gitBranch
|
|
146
|
+
if (branch != null && branch !== '') bump(day.branches, branch, total)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `history.jsonl`, the prompt log — the only source that survives transcript pruning. */
|
|
150
|
+
export function consumeHistoryLine(line: string, days: UsageDays): void {
|
|
151
|
+
if (!line.includes('"timestamp"')) return
|
|
152
|
+
|
|
153
|
+
let entry: { timestamp?: number | string }
|
|
154
|
+
try {
|
|
155
|
+
entry = JSON.parse(line) as { timestamp?: number | string }
|
|
156
|
+
} catch {
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// A number today, quoted in past releases. `Number()` covers both, where
|
|
161
|
+
// `new Date(quoted)` returns Invalid Date and drops the entry.
|
|
162
|
+
const ms = Number(entry.timestamp)
|
|
163
|
+
if (!Number.isFinite(ms) || ms <= 0) return
|
|
164
|
+
|
|
165
|
+
dayAt(days, localDay(new Date(ms))).prompts += 1
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface CodexLine {
|
|
169
|
+
payload?: {
|
|
170
|
+
info?: {
|
|
171
|
+
last_token_usage?: {
|
|
172
|
+
cached_input_tokens?: number
|
|
173
|
+
input_tokens?: number
|
|
174
|
+
output_tokens?: number
|
|
175
|
+
total_tokens?: number
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
model?: string
|
|
179
|
+
}
|
|
180
|
+
timestamp?: string
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface CodexFileState {
|
|
184
|
+
model: string | null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `last_token_usage` is the per-turn delta; the sibling `total_token_usage` is cumulative — summing it overcounts by n²/2. */
|
|
188
|
+
export function consumeCodexLine(line: string, state: CodexFileState, days: UsageDays): void {
|
|
189
|
+
if (!line.includes('"model"') && !line.includes('"last_token_usage"')) return
|
|
190
|
+
|
|
191
|
+
let entry: CodexLine
|
|
192
|
+
try {
|
|
193
|
+
entry = JSON.parse(line) as CodexLine
|
|
194
|
+
} catch {
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const model = entry.payload?.model
|
|
199
|
+
if (model != null && model !== '') state.model = model
|
|
200
|
+
|
|
201
|
+
const usage = entry.payload?.info?.last_token_usage
|
|
202
|
+
if (usage === undefined || entry.timestamp == null) return
|
|
203
|
+
|
|
204
|
+
const ms = Date.parse(entry.timestamp)
|
|
205
|
+
if (!Number.isFinite(ms)) return
|
|
206
|
+
|
|
207
|
+
const cacheRead = usage.cached_input_tokens ?? 0
|
|
208
|
+
const total = usage.total_tokens ?? 0
|
|
209
|
+
if (total <= 0) return
|
|
210
|
+
|
|
211
|
+
const day = dayAt(days, localDay(new Date(ms)))
|
|
212
|
+
day.tokens.cacheRead += cacheRead
|
|
213
|
+
// cached_input_tokens ⊂ input_tokens, so the cached share is subtracted not added.
|
|
214
|
+
day.tokens.input += Math.max(0, (usage.input_tokens ?? 0) - cacheRead)
|
|
215
|
+
day.tokens.output += usage.output_tokens ?? 0
|
|
216
|
+
day.tokens.total += total
|
|
217
|
+
|
|
218
|
+
if (state.model != null && state.model !== '') bump(day.models, state.model, total)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function collectClaude(): Promise<UsageDays> {
|
|
222
|
+
const days: UsageDays = {}
|
|
223
|
+
const seen = new Set<string>()
|
|
224
|
+
const root = claudeHome()
|
|
225
|
+
|
|
226
|
+
await forEachFile(findJsonl(join(root, 'projects')), async (path) => {
|
|
227
|
+
await readJsonlLines(path, (line) => {
|
|
228
|
+
consumeTranscriptLine(line, seen, days)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
await readJsonlLines(join(root, 'history.jsonl'), (line) => {
|
|
234
|
+
consumeHistoryLine(line, days)
|
|
235
|
+
})
|
|
236
|
+
} catch {
|
|
237
|
+
// No prompt log: the heatmap falls back to whatever the transcripts cover.
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return days
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function collectCodex(): Promise<UsageDays> {
|
|
244
|
+
const days: UsageDays = {}
|
|
245
|
+
|
|
246
|
+
await forEachFile(findJsonl(join(codexHome(), 'sessions')), async (path) => {
|
|
247
|
+
const state: CodexFileState = { model: null }
|
|
248
|
+
await readJsonlLines(path, (line) => {
|
|
249
|
+
consumeCodexLine(line, state, days)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
return days
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function runUsageRollup(): Promise<number> {
|
|
257
|
+
try {
|
|
258
|
+
const [claude, codex] = await Promise.all([collectClaude(), collectCodex()])
|
|
259
|
+
|
|
260
|
+
const fresh: UsageTools = {}
|
|
261
|
+
if (Object.keys(claude).length > 0) fresh.claude = claude
|
|
262
|
+
if (Object.keys(codex).length > 0) fresh.codex = codex
|
|
263
|
+
|
|
264
|
+
// Nothing parsed means every source was missing. Writing would bump the
|
|
265
|
+
// mtime, which schedules the next run, and buy a day of not retrying.
|
|
266
|
+
if (Object.keys(fresh).length === 0) {
|
|
267
|
+
logDebug('usageHistory.rollupEmpty', {})
|
|
268
|
+
return 1
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return saveUsageHistory(fresh) ? 0 : 1
|
|
272
|
+
} catch (error) {
|
|
273
|
+
logDebug('usageHistory.rollupError', {
|
|
274
|
+
error: error instanceof Error ? error.message : String(error),
|
|
275
|
+
})
|
|
276
|
+
return 1
|
|
277
|
+
}
|
|
278
|
+
}
|