Skip to content

Commit 2d4d465

Browse files
committed
feat(cli): add amp backfill support and codex tier rewrite
1 parent 36e0908 commit 2d4d465

9 files changed

Lines changed: 567 additions & 666 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ editor time also tracks your agent time.
4343
| Codex CLI | `codex` | `~/.codex/sessions/**` |
4444
| OpenCode | `opencode` | `~/.local/share/opencode/**` |
4545
| Pi | `pi` | `~/.pi/sessions/**` |
46+
| Amp | `amp` | `~/.local/share/amp/threads/**` (backfill only) |
4647

4748
## Install
4849

@@ -103,7 +104,7 @@ that leave your machine.
103104

104105
**Session shape** (per agent session)
105106

106-
- Agent name (`claude` / `codex` / `opencode` / `pi`), session id,
107+
- Agent name (`claude` / `codex` / `opencode` / `pi` / `amp`), session id,
107108
turn ids, timestamps, durations.
108109
- Workspace / project name — typically the basename of the working
109110
directory the agent was launched in (e.g. `codetime-cli`).

packages/cli/src/adapters/amp.ts

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import type { CanonicalEvent, MetricBag } from '@codetime/shared'
2+
import type { AdapterEnv, AgentAdapter, InstallEntry } from './types.js'
3+
import { readFile, stat } from 'node:fs/promises'
4+
import path from 'node:path'
5+
import {
6+
AGENT_TIME_SCHEMA_VERSION,
7+
createWorkspaceId,
8+
} from '@codetime/shared'
9+
import { matchesBackfillFilters } from '../lib/backfill.js'
10+
import {
11+
isPlainObject,
12+
numberField,
13+
objectField,
14+
stringField,
15+
stringRefs,
16+
} from '../lib/fields.js'
17+
import { listFilesByExtensions, pathExists } from '../lib/fs.js'
18+
import { timestampFrom } from '../lib/jsonl.js'
19+
import { SessionParserState } from '../lib/session-state.js'
20+
21+
interface BackfillSourceFile {
22+
path: string
23+
modifiedAt: string
24+
}
25+
26+
// ── Parser ──
27+
28+
async function parseAmpSessionFile(
29+
filePath: string,
30+
options: Record<string, unknown> & { _: string[] },
31+
): Promise<CanonicalEvent[]> {
32+
const text = await readFile(filePath, 'utf8')
33+
let raw: unknown
34+
try {
35+
raw = JSON.parse(text)
36+
}
37+
catch {
38+
return []
39+
}
40+
if (!isPlainObject(raw)) {
41+
return []
42+
}
43+
44+
const threadId = stringField(raw, 'id') || path.basename(filePath, '.json')
45+
const sessionId = `amp_${threadId}`
46+
const messages = Array.isArray(raw.messages)
47+
? raw.messages.filter(isPlainObject) as Record<string, unknown>[]
48+
: []
49+
const ledger = objectField(raw, 'usageLedger')
50+
const rawEvents = Array.isArray(ledger.events)
51+
? ledger.events.filter(isPlainObject) as Record<string, unknown>[]
52+
: []
53+
if (rawEvents.length === 0) {
54+
return []
55+
}
56+
57+
// Amp ledger entries can be written out of order; sort by timestamp so the
58+
// session/turn boundary derivations and final aggregation are stable.
59+
const events = [...rawEvents].sort((a, b) => {
60+
const ta = timestampFrom(stringField(a, 'timestamp')) || ''
61+
const tb = timestampFrom(stringField(b, 'timestamp')) || ''
62+
return ta.localeCompare(tb)
63+
})
64+
65+
const state = new SessionParserState(filePath, options, event => baseAmpEvent(event))
66+
state.sessionId = sessionId
67+
68+
const firstTs = timestampFrom(stringField(events[0], 'timestamp')) || new Date().toISOString()
69+
state.ensureSessionStarted(firstTs, 0)
70+
71+
let lastTs = firstTs
72+
for (const [index, ev] of events.entries()) {
73+
const ts = timestampFrom(stringField(ev, 'timestamp')) || lastTs
74+
lastTs = ts
75+
const model = stringField(ev, 'model') || undefined
76+
const tokens = objectField(ev, 'tokens')
77+
const input = numberField(tokens, 'input') || 0
78+
const output = numberField(tokens, 'output') || 0
79+
const toMessageId = numberField(ev, 'toMessageId')
80+
const cache = cacheTokensFor(messages, toMessageId)
81+
const cacheRead = cache.cacheReadInputTokens
82+
const cacheWrite = cache.cacheCreationInputTokens
83+
const totalTokens = input + output + cacheRead + cacheWrite
84+
if (totalTokens <= 0) {
85+
continue
86+
}
87+
88+
const credits = numberField(ev, 'credits')
89+
const operationType = stringField(ev, 'operationType')
90+
const fromMessageId = numberField(ev, 'fromMessageId')
91+
92+
const metrics: Partial<MetricBag> = {
93+
tokensInput: (input + cacheRead + cacheWrite) || undefined,
94+
tokensOutput: output || undefined,
95+
tokensCachedInput: (cacheRead + cacheWrite) || undefined,
96+
tokensCacheReadInput: cacheRead || undefined,
97+
tokensCacheCreationInput: cacheWrite || undefined,
98+
tokensTotal: totalTokens,
99+
modelCalls: 1,
100+
}
101+
102+
state.push(
103+
baseAmpEvent({
104+
ts,
105+
type: 'model.usage',
106+
sessionId,
107+
model,
108+
confidence: 'exact',
109+
metrics,
110+
refs: stringRefs({
111+
threadId,
112+
operationType,
113+
fromMessageId: fromMessageId === undefined ? undefined : String(fromMessageId),
114+
toMessageId: toMessageId === undefined ? undefined : String(toMessageId),
115+
// Credits are Amp's proprietary billing unit; preserved here so the
116+
// backend can correlate token counts with what the user is charged.
117+
credits: typeof credits === 'number' && Number.isFinite(credits)
118+
? String(credits)
119+
: undefined,
120+
}),
121+
}),
122+
index + 1,
123+
'ledger',
124+
'model.usage',
125+
)
126+
}
127+
128+
state.push(
129+
baseAmpEvent({
130+
ts: lastTs,
131+
type: 'session.ended',
132+
sessionId,
133+
confidence: 'derived',
134+
}),
135+
events.length,
136+
'ledger',
137+
'session',
138+
)
139+
140+
return state.events.filter(event => matchesBackfillFilters(event, options))
141+
}
142+
143+
// ── Amp-specific helpers ──
144+
145+
function baseAmpEvent(
146+
event: Omit<CanonicalEvent, 'schemaVersion' | 'source' | 'agent' | 'workspaceId'>,
147+
): CanonicalEvent {
148+
return {
149+
schemaVersion: AGENT_TIME_SCHEMA_VERSION,
150+
source: 'amp',
151+
agent: 'amp',
152+
// Amp threads carry no cwd/project metadata; pin all events to a stable
153+
// synthetic workspace so downstream rollups keep them grouped together.
154+
workspaceId: createWorkspaceId({ projectName: 'amp' }),
155+
...event,
156+
}
157+
}
158+
159+
function cacheTokensFor(
160+
messages: Record<string, unknown>[],
161+
toMessageId: number | undefined,
162+
): {
163+
cacheReadInputTokens: number
164+
cacheCreationInputTokens: number
165+
} {
166+
if (toMessageId === undefined) {
167+
return { cacheReadInputTokens: 0, cacheCreationInputTokens: 0 }
168+
}
169+
const match = messages.find(message =>
170+
stringField(message, 'role') === 'assistant'
171+
&& numberField(message, 'messageId') === toMessageId,
172+
)
173+
if (!match) {
174+
return { cacheReadInputTokens: 0, cacheCreationInputTokens: 0 }
175+
}
176+
const usage = objectField(match, 'usage')
177+
return {
178+
cacheReadInputTokens: numberField(usage, 'cacheReadInputTokens') || 0,
179+
cacheCreationInputTokens: numberField(usage, 'cacheCreationInputTokens') || 0,
180+
}
181+
}
182+
183+
// ── Adapter factory ──
184+
185+
// Amp lets users relocate its data dir via AMP_DATA_DIR. ccusage matches that
186+
// convention; we follow suit so identical environments work for both tools.
187+
function ampDataDir(home: string, env?: AdapterEnv): string {
188+
const override = env?.AMP_DATA_DIR
189+
if (override && override.trim()) {
190+
return path.resolve(override)
191+
}
192+
return path.join(home, '.local', 'share', 'amp')
193+
}
194+
195+
function ampThreadsDir(home: string, env?: AdapterEnv): string {
196+
return path.join(ampDataDir(home, env), 'threads')
197+
}
198+
199+
export async function ampBackfillFiles(
200+
sourceRoot: string | undefined,
201+
home: string,
202+
env: AdapterEnv | undefined,
203+
): Promise<BackfillSourceFile[]> {
204+
const root = sourceRoot ? path.resolve(sourceRoot) : ampThreadsDir(home, env)
205+
const files = await listFilesByExtensions(root, ['.json'])
206+
return Promise.all(files.map(async (filePath) => {
207+
const info = await stat(filePath)
208+
return { path: filePath, modifiedAt: info.mtime.toISOString() }
209+
}))
210+
}
211+
212+
export function createAmpAdapter(): AgentAdapter {
213+
return {
214+
id: 'amp',
215+
label: 'Amp',
216+
agentName: 'amp',
217+
kind: 'agent',
218+
219+
detectPath(home, env) {
220+
return ampDataDir(home, env)
221+
},
222+
// Amp has no plugin/hook surface, so there is nothing for codetime to
223+
// "install". Report installed = true whenever the threads dir is on disk
224+
// so `detect` accurately shows "already covered via backfill".
225+
installedPath(home, env) {
226+
return ampThreadsDir(home, env)
227+
},
228+
async isInstalled(home, env) {
229+
try {
230+
return await pathExists(ampThreadsDir(home, env))
231+
}
232+
catch {
233+
return false
234+
}
235+
},
236+
installEntries(): InstallEntry[] {
237+
return []
238+
},
239+
240+
sourcePaths(home, env) {
241+
return [ampThreadsDir(home, env)]
242+
},
243+
244+
parseSessionFile: parseAmpSessionFile,
245+
}
246+
}

packages/cli/src/adapters/codex.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ async function parseCodexSessionFile(
3636
const text = await readFile(filePath, 'utf8')
3737
const lines = text.split('\n').filter(Boolean)
3838
const sourcePathHash = `sha256:${createStableHash(filePath)}`
39+
// service_tier=fast|priority encoded into model name (see rewriteCodexModelForTier).
40+
const serviceTier = await resolveCodexServiceTier(filePath)
3941
const events: CanonicalEvent[] = []
4042
let sessionId = sessionIdFromFilePath(filePath, 'codex')
4143
let cwd: string | undefined
@@ -186,7 +188,10 @@ async function parseCodexSessionFile(
186188
turnId: currentTurnId,
187189
cwd,
188190
project,
189-
model,
191+
// Only the model.usage event carries the -fast suffix; tool and
192+
// turn events keep the bare model so other queries (e.g. "what
193+
// model was the user on") don't show tier-specific names.
194+
model: rewriteCodexModelForTier(model, serviceTier),
190195
confidence: 'partial',
191196
metrics: usage,
192197
}), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }))
@@ -394,6 +399,67 @@ async function parseCodexSessionFile(
394399

395400
// ── Codex-specific helpers ──
396401

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.
455+
function rewriteCodexModelForTier(model: string | undefined, serviceTier: string | undefined): string | undefined {
456+
if (!model) {
457+
return model
458+
}
459+
const isFastTier = serviceTier === 'fast' || serviceTier === 'priority'
460+
return isFastTier ? `${model}-fast` : model
461+
}
462+
397463
function baseCodexEvent(
398464
event: Omit<CanonicalEvent, 'schemaVersion' | 'source' | 'agent' | 'workspaceId'>,
399465
): CanonicalEvent {

0 commit comments

Comments
 (0)