Skip to content

Commit b8f65a1

Browse files
committed
chore(versioning): bump version to 0.3.3
1 parent d983a03 commit b8f65a1

6 files changed

Lines changed: 108 additions & 76 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "codetime-cli-workspace",
33
"type": "module",
4-
"version": "0.3.2",
4+
"version": "0.3.3",
55
"private": true,
66
"packageManager": "pnpm@11.1.2",
77
"scripts": {

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "codetime-cli",
33
"type": "module",
4-
"version": "0.3.2",
4+
"version": "0.3.3",
55
"description": "codetime CLI — install AI-agent hooks (Claude Code, Codex, OpenCode, Pi) and report activity to codetime.dev.",
66
"license": "MIT",
77
"publishConfig": {

packages/cli/src/adapters/codex.ts

Lines changed: 25 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import type { CanonicalEvent } from '@codetime/shared'
22
import type { AdapterEnv, AgentAdapter, InstallEntry } from './types.js'
33
import { readFile } from 'node:fs/promises'
44
import path from 'node:path'
5+
// Codex's fast/priority service_tier costs ~2× standard, so we encode the tier
6+
// into the model name (e.g. `gpt-5-codex-fast`) so the backend pricing table
7+
// resolves to the tier-specific entry. We only trust per-turn evidence inside
8+
// the session file itself — never the current `config.toml`. The old behavior
9+
// (read CODEX_HOME/config.toml once, stamp every historical model.usage with
10+
// `-fast`) caused false positives whenever a user enabled fast later, because
11+
// every old session would be retroactively re-classified on the next backfill.
512
import {
613
AGENT_TIME_SCHEMA_VERSION,
714
createStableHash,
@@ -36,13 +43,14 @@ async function parseCodexSessionFile(
3643
const text = await readFile(filePath, 'utf8')
3744
const lines = text.split('\n').filter(Boolean)
3845
const sourcePathHash = `sha256:${createStableHash(filePath)}`
39-
// service_tier=fast|priority encoded into model name (see rewriteCodexModelForTier).
40-
const serviceTier = await resolveCodexServiceTier(filePath)
4146
const events: CanonicalEvent[] = []
4247
let sessionId = sessionIdFromFilePath(filePath, 'codex')
4348
let cwd: string | undefined
4449
let project: string | undefined
4550
let model: string | undefined
51+
// Per-turn service tier. Updated whenever a turn_context (or future per-turn
52+
// event) carries an explicit service_tier; never inferred from config.toml.
53+
let serviceTier: string | undefined
4654
let currentTurnId: string | undefined
4755
let lastTurnIdForComplete: string | undefined
4856
// Track the turn_id that was active when the previous user_message arrived. Older Codex
@@ -104,6 +112,14 @@ async function parseCodexSessionFile(
104112
cwd = stringField(payload, 'cwd') || cwd
105113
project = cwd ? path.basename(cwd) : project
106114
model = stringField(payload, 'model') || model
115+
// Codex hasn't shipped service_tier inside turn_context yet, but the field
116+
// is the natural per-turn location and the upstream protocol allows it.
117+
// Honor it when present so future Codex builds get accurate fast/priority
118+
// attribution without another parser change.
119+
const tier = stringField(payload, 'service_tier')
120+
if (tier) {
121+
serviceTier = tier.toLowerCase()
122+
}
107123
continue
108124
}
109125

@@ -168,6 +184,13 @@ async function parseCodexSessionFile(
168184
break
169185
}
170186
case 'token_count': {
187+
// Some Codex builds carry service_tier alongside last_token_usage, so
188+
// pick it up here too as a secondary per-turn signal.
189+
const info = objectField(payload, 'info')
190+
const tierFromInfo = stringField(info, 'service_tier') || stringField(payload, 'service_tier')
191+
if (tierFromInfo) {
192+
serviceTier = tierFromInfo.toLowerCase()
193+
}
171194
const usage = tokenUsageFromPayload(payload)
172195
if (usage) {
173196
const usageKey = [
@@ -399,59 +422,6 @@ async function parseCodexSessionFile(
399422

400423
// ── Codex-specific helpers ──
401424

402-
// Module-level cache: a single backfill run touches many session files under
403-
// the same CODEX_HOME — read config.toml once per home, not per file.
404-
const codexServiceTierCache = new Map<string, string | null>()
405-
406-
async function resolveCodexServiceTier(sessionFilePath: string): Promise<string | undefined> {
407-
const home = inferCodexHomeFromSessionPath(sessionFilePath)
408-
if (!home) {
409-
return undefined
410-
}
411-
if (!codexServiceTierCache.has(home)) {
412-
codexServiceTierCache.set(home, await readCodexServiceTier(home))
413-
}
414-
return codexServiceTierCache.get(home) ?? undefined
415-
}
416-
417-
// Codex session files live at <CODEX_HOME>/sessions/<...>/<file>.jsonl or
418-
// <CODEX_HOME>/history.jsonl. Walk parents until we hit the `sessions/` segment
419-
// or land on the history file's parent — either points at CODEX_HOME.
420-
function inferCodexHomeFromSessionPath(sessionFilePath: string): string | undefined {
421-
if (path.basename(sessionFilePath) === 'history.jsonl') {
422-
return path.dirname(sessionFilePath)
423-
}
424-
let dir = path.dirname(sessionFilePath)
425-
for (let depth = 0; depth < 10; depth += 1) {
426-
const parent = path.dirname(dir)
427-
if (parent === dir) {
428-
return undefined
429-
}
430-
if (path.basename(dir) === 'sessions') {
431-
return parent
432-
}
433-
dir = parent
434-
}
435-
return undefined
436-
}
437-
438-
async function readCodexServiceTier(codexHomePath: string): Promise<string | null> {
439-
try {
440-
const text = await readFile(path.join(codexHomePath, 'config.toml'), 'utf8')
441-
// Match `service_tier = "fast"` / `service_tier='priority'` / `service_tier=fast`
442-
// anywhere in the file. ccusage uses a similar regex for the same purpose.
443-
const match = text.match(/(?:^|\n)\s*service_tier\s*=\s*["']?([a-z_]+)["']?/i)
444-
return match ? match[1].toLowerCase() : null
445-
}
446-
catch {
447-
return null
448-
}
449-
}
450-
451-
// Codex's fast/priority service_tier costs ~2× standard. Like Claude Code's
452-
// `-fast` Opus suffix, we encode the tier into the model name so the backend
453-
// pricing table (which keys on model only) resolves to the tier-specific
454-
// entry without plumbing extra fields through SessionModelRollup.
455425
function rewriteCodexModelForTier(model: string | undefined, serviceTier: string | undefined): string | undefined {
456426
if (!model) {
457427
return model

packages/cli/src/lib/types.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@ export interface BackfillSourceFile {
3030
// Bump `BACKFILL_STATE_SCHEMA_VERSION` whenever the offline parsers
3131
// change in a way that invalidates already-uploaded rollups (e.g. the
3232
// Claude assistant-message dedup added in v2, the Codex fast/priority
33-
// model-name rewrite added in v3). The CLI compares the constant against
34-
// the on-disk schema; on a mismatch it drops every watermark so the next
35-
// sync silently re-parses all jsonl from scratch and upserts the rebuilt
36-
// rollups (`replace: true` is already set). Users get the fix
37-
// transparently the next time their agent runs.
38-
export const BACKFILL_STATE_SCHEMA_VERSION = 3
33+
// model-name rewrite added in v3, and the v4 fix that stops inferring
34+
// Codex fast/priority from CODEX_HOME/config.toml — it now requires
35+
// per-turn evidence inside the session itself). The CLI compares the
36+
// constant against the on-disk schema; on a mismatch it drops every
37+
// watermark so the next sync silently re-parses all jsonl from scratch
38+
// and upserts the rebuilt rollups (`replace: true` is already set).
39+
// Users get the fix transparently the next time their agent runs.
40+
export const BACKFILL_STATE_SCHEMA_VERSION = 4
3941

4042
export interface BackfillIncrementalState {
4143
version: typeof BACKFILL_STATE_SCHEMA_VERSION

packages/cli/test/cli.test.ts

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,7 +1253,11 @@ const SAMPLE_CODEX_SESSION = [
12531253
}),
12541254
].join('\n')
12551255

1256-
test('codex parser appends -fast to model when config.toml has service_tier="fast"', async () => {
1256+
test('codex parser does NOT append -fast when config.toml has service_tier="fast" but the session has no per-turn evidence', async () => {
1257+
// Regression: prior versions read CODEX_HOME/config.toml once per backfill
1258+
// and stamped every historical model.usage with -fast, which retroactively
1259+
// re-classified old standard-tier sessions as fast whenever the user later
1260+
// enabled fast. Only per-turn evidence inside the session counts now.
12571261
const codexHome = await mkdtemp(path.join(tmpdir(), 'codex-home-'))
12581262
const sessionsDir = path.join(codexHome, 'sessions', 'p')
12591263
await mkdir(sessionsDir, { recursive: true })
@@ -1264,20 +1268,62 @@ test('codex parser appends -fast to model when config.toml has service_tier="fas
12641268
const events = await createCodexAdapter().parseSessionFile!(sessionPath, { _: [] })
12651269
const usage = events.filter(event => event.type === 'model.usage')
12661270
assert.ok(usage.length > 0, 'expected at least one model.usage')
1271+
for (const event of usage) {
1272+
assert.equal(event.model, 'gpt-5-codex')
1273+
}
1274+
})
1275+
1276+
test('codex parser appends -fast when turn_context carries service_tier="fast"', async () => {
1277+
const codexHome = await mkdtemp(path.join(tmpdir(), 'codex-home-'))
1278+
const sessionsDir = path.join(codexHome, 'sessions', 'p')
1279+
await mkdir(sessionsDir, { recursive: true })
1280+
const sessionPath = path.join(sessionsDir, 'session.jsonl')
1281+
await writeFile(sessionPath, [
1282+
JSON.stringify({ timestamp: '2026-05-13T09:00:00.000Z', type: 'turn_context', payload: { model: 'gpt-5-codex', service_tier: 'fast' } }),
1283+
JSON.stringify({
1284+
timestamp: '2026-05-13T09:01:00.000Z',
1285+
type: 'event_msg',
1286+
payload: {
1287+
type: 'token_count',
1288+
info: {
1289+
model: 'gpt-5-codex',
1290+
last_token_usage: { input_tokens: 100, cached_input_tokens: 10, output_tokens: 50, reasoning_output_tokens: 5, total_tokens: 150 },
1291+
total_token_usage: { input_tokens: 100, cached_input_tokens: 10, output_tokens: 50, reasoning_output_tokens: 5, total_tokens: 150 },
1292+
},
1293+
},
1294+
}),
1295+
].join('\n'), 'utf8')
1296+
1297+
const events = await createCodexAdapter().parseSessionFile!(sessionPath, { _: [] })
1298+
const usage = events.filter(event => event.type === 'model.usage')
1299+
assert.ok(usage.length > 0)
12671300
for (const event of usage) {
12681301
assert.equal(event.model, 'gpt-5-codex-fast')
12691302
}
12701303
})
12711304

1272-
test('codex parser maps service_tier="priority" to the -fast model variant', async () => {
1273-
// Codex's "priority" is the second flavor of fast inference; ccusage maps both
1274-
// to its single "fast" speed bucket. We do the same so backend pricing stays simple.
1305+
test('codex parser maps per-turn service_tier="priority" to the -fast model variant', async () => {
1306+
// Codex's "priority" is the second flavor of fast inference; ccusage maps
1307+
// both to its single "fast" speed bucket. Backend pricing keys on model only.
12751308
const codexHome = await mkdtemp(path.join(tmpdir(), 'codex-home-'))
12761309
const sessionsDir = path.join(codexHome, 'sessions', 'p')
12771310
await mkdir(sessionsDir, { recursive: true })
1278-
await writeFile(path.join(codexHome, 'config.toml'), 'service_tier = "priority"\n', 'utf8')
12791311
const sessionPath = path.join(sessionsDir, 'session.jsonl')
1280-
await writeFile(sessionPath, SAMPLE_CODEX_SESSION, 'utf8')
1312+
await writeFile(sessionPath, [
1313+
JSON.stringify({ timestamp: '2026-05-13T09:00:00.000Z', type: 'turn_context', payload: { model: 'gpt-5-codex', service_tier: 'priority' } }),
1314+
JSON.stringify({
1315+
timestamp: '2026-05-13T09:01:00.000Z',
1316+
type: 'event_msg',
1317+
payload: {
1318+
type: 'token_count',
1319+
info: {
1320+
model: 'gpt-5-codex',
1321+
last_token_usage: { input_tokens: 100, cached_input_tokens: 10, output_tokens: 50, reasoning_output_tokens: 5, total_tokens: 150 },
1322+
total_token_usage: { input_tokens: 100, cached_input_tokens: 10, output_tokens: 50, reasoning_output_tokens: 5, total_tokens: 150 },
1323+
},
1324+
},
1325+
}),
1326+
].join('\n'), 'utf8')
12811327

12821328
const events = await createCodexAdapter().parseSessionFile!(sessionPath, { _: [] })
12831329
const usage = events.filter(event => event.type === 'model.usage')
@@ -1287,14 +1333,29 @@ test('codex parser maps service_tier="priority" to the -fast model variant', asy
12871333
}
12881334
})
12891335

1290-
test('codex parser keeps bare model name when config.toml omits service_tier', async () => {
1336+
test('codex parser ignores config.toml fast when the turn explicitly says default', async () => {
1337+
// Mixed scenario: user has fast on now (config.toml), but an old turn
1338+
// explicitly recorded service_tier="default". The turn wins — no -fast.
12911339
const codexHome = await mkdtemp(path.join(tmpdir(), 'codex-home-'))
12921340
const sessionsDir = path.join(codexHome, 'sessions', 'p')
12931341
await mkdir(sessionsDir, { recursive: true })
1294-
// config.toml exists but has no service_tier — must NOT append -fast.
1295-
await writeFile(path.join(codexHome, 'config.toml'), 'model = "gpt-5-codex"\n', 'utf8')
1342+
await writeFile(path.join(codexHome, 'config.toml'), 'service_tier = "fast"\n', 'utf8')
12961343
const sessionPath = path.join(sessionsDir, 'session.jsonl')
1297-
await writeFile(sessionPath, SAMPLE_CODEX_SESSION, 'utf8')
1344+
await writeFile(sessionPath, [
1345+
JSON.stringify({ timestamp: '2026-05-13T09:00:00.000Z', type: 'turn_context', payload: { model: 'gpt-5-codex', service_tier: 'default' } }),
1346+
JSON.stringify({
1347+
timestamp: '2026-05-13T09:01:00.000Z',
1348+
type: 'event_msg',
1349+
payload: {
1350+
type: 'token_count',
1351+
info: {
1352+
model: 'gpt-5-codex',
1353+
last_token_usage: { input_tokens: 100, cached_input_tokens: 10, output_tokens: 50, reasoning_output_tokens: 5, total_tokens: 150 },
1354+
total_token_usage: { input_tokens: 100, cached_input_tokens: 10, output_tokens: 50, reasoning_output_tokens: 5, total_tokens: 150 },
1355+
},
1356+
},
1357+
}),
1358+
].join('\n'), 'utf8')
12981359

12991360
const events = await createCodexAdapter().parseSessionFile!(sessionPath, { _: [] })
13001361
const usage = events.filter(event => event.type === 'model.usage')
@@ -1304,8 +1365,7 @@ test('codex parser keeps bare model name when config.toml omits service_tier', a
13041365
}
13051366
})
13061367

1307-
test('codex parser handles missing config.toml gracefully', async () => {
1308-
// No config.toml at all — common when CODEX_HOME is fresh.
1368+
test('codex parser keeps bare model name when neither config.toml nor session evidence is present', async () => {
13091369
const codexHome = await mkdtemp(path.join(tmpdir(), 'codex-home-'))
13101370
const sessionsDir = path.join(codexHome, 'sessions', 'p')
13111371
await mkdir(sessionsDir, { recursive: true })

packages/shared/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@codetime/shared",
33
"type": "module",
4-
"version": "0.3.2",
4+
"version": "0.3.3",
55
"private": true,
66
"description": "Shared event types for codetime CLI. Inlined into the published `codetime` bundle — not published independently.",
77
"license": "MIT",

0 commit comments

Comments
 (0)