Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- **CodeBuddy IDE support** — detect and parse the Tencent CodeBuddy IDE (including the CN variant), whose per-message JSON logs live under `CodeBuddyExtension/Data/**/CodeBuddyIDE/**/history/<session>/<conversation>/messages/`. The existing `codebuddy` JSONL parser only covered `~/.codebuddy/projects`, so IDE usage went undetected. Token usage is read from each conversation's cumulative `statsSnapshot` (cache-miss input, cached input, output). Reported under the existing **CodeBuddy** tool via a new `codebuddy-ide` source; override the path with `AIUSAGE_CODEBUDDY_IDE_PATH`.

### Fixed
- **CodeBuddy CLI double-counted cached tokens** — the CLI logs usage under both `message.usage` (Anthropic-shaped field names but OpenAI-style semantics, where `input_tokens` already includes cached tokens) and `providerData.rawUsage`. The generic parser assumed Anthropic semantics and added `cache_read_input_tokens` on top of the cache-inclusive `input_tokens`, inflating input by the cached amount (~100× on cache-heavy turns). CodeBuddy now reads the clean `prompt_cache_hit/miss` decomposition from `rawUsage` (falling back to `message.usage` with cache reads subtracted).

## [1.5.7] - 2026-06-25

### Added
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
并遵循 [语义化版本控制](https://semver.org/lang/zh-CN/)。

## [未发布]

### 新增
- **CodeBuddy IDE 支持** — 检测并解析腾讯 CodeBuddy IDE(含 CN 变体)。其逐条消息的 JSON 日志位于 `CodeBuddyExtension/Data/**/CodeBuddyIDE/**/history/<会话>/<对话>/messages/`。原有的 `codebuddy` JSONL 解析器仅覆盖 `~/.codebuddy/projects`,因此 IDE 用量此前无法被检测到。用量数据取自每个对话累计的 `statsSnapshot`(未命中缓存的输入、缓存输入、输出)。归入现有的 **CodeBuddy** 工具,新增 `codebuddy-ide` 数据源;可用 `AIUSAGE_CODEBUDDY_IDE_PATH` 覆盖路径。

### 修复
- **CodeBuddy CLI 缓存 token 重复计数** — CLI 同时把用量写在 `message.usage`(字段名是 Anthropic 风格,但语义是 OpenAI 风格——`input_tokens` 已包含缓存 token)和 `providerData.rawUsage` 里。通用解析器按 Anthropic 语义处理,在已含缓存的 `input_tokens` 之上又加了一遍 `cache_read_input_tokens`,导致输入被缓存量虚高(缓存密集的轮次可达约 100 倍)。现在 codebuddy 改为读取 `rawUsage` 中干净的 `prompt_cache_hit/miss` 分解(缺失时回退到 `message.usage` 并减去缓存读取部分)。

## [1.5.7] - 2026-06-25

### 新增
Expand Down
246 changes: 246 additions & 0 deletions packages/cli/src/commands/parse-codebuddy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, basename, dirname } from 'node:path'
import type { StatsRecord } from '@aiusage/core'
import { generateRecordId, inferProvider, calculateCost } from '@aiusage/core'

export interface CodeBuddyImportOptions {
dataDir: string // .../CodeBuddyExtension/Data
device: string
deviceInstanceId: string
platform?: string
now: number
cursor?: number // Unix ms; skip conversations last active at/before this
exchangeRate?: number
}

export interface CodeBuddyImportResult {
records: StatsRecord[]
nextCursor: number
errors: string[]
}

function num(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}

function toMs(iso: unknown): number {
if (typeof iso !== 'string') return 0
const ms = Date.parse(iso)
return Number.isFinite(ms) ? ms : 0
}

/**
* The CodeBuddy IDE stores each chat message as its own JSON file:
* <profile>/CodeBuddyIDE/<workspace>/history/<session>/<conversation>/messages/*.json
* Only the final assistant message of each agent turn carries usage stats, under a
* stringified `extra` field. `statsSnapshot` is a running (conversation-cumulative)
* snapshot of input/cache tokens, so we take the latest snapshot per conversation and
* emit a single record for it to avoid double-counting across turns.
*/
interface MessageFile {
role: string
message?: string
extra?: string
createdAt?: string
}

interface ConversationUsage {
ts: number
model: string
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
thinkingTokens: number
cwd?: string
}

const WORKSPACE_FOLDER_RE = /Workspace Folder:\s*(.+)/

/** Extract the plain text of a user message (its `message` field is stringified JSON). */
function userMessageText(raw: string): string {
try {
const inner = JSON.parse(raw) as { content?: Array<{ type?: string; text?: string }> }
if (Array.isArray(inner.content)) {
return inner.content
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.join('\n')
}
} catch {
// Fall back to the raw string if it is not JSON.
}
return raw
}

/** Recursively collect every `messages` directory under a CodeBuddyIDE data root. */
function findMessagesDirs(root: string, depth = 0): string[] {
if (depth > 12) return []
let entries
try {
entries = readdirSync(root, { withFileTypes: true })
} catch {
return []
}
const out: string[] = []
for (const entry of entries) {
if (!entry.isDirectory()) continue
const full = join(root, entry.name)
if (entry.name === 'messages') {
out.push(full)
continue
}
out.push(...findMessagesDirs(full, depth + 1))
}
return out
}

/** Read and summarize one conversation's messages directory into a single usage record. */
function readConversation(messagesDir: string): ConversationUsage | null {
let files: string[]
try {
files = readdirSync(messagesDir).filter((f) => f.endsWith('.json'))
} catch {
return null
}
if (files.length === 0) return null

const parsed: MessageFile[] = []
for (const file of files) {
try {
const data = JSON.parse(readFileSync(join(messagesDir, file), 'utf-8')) as MessageFile
if (data && typeof data === 'object') parsed.push(data)
} catch {
// Skip unreadable/partial message files.
}
}
if (parsed.length === 0) return null

// Workspace folder (cwd) is embedded in the user prompt's <user_info> block.
let cwd: string | undefined
for (const msg of parsed) {
if (msg.role !== 'user' || typeof msg.message !== 'string') continue
const match = WORKSPACE_FOLDER_RE.exec(userMessageText(msg.message))
if (match) {
cwd = match[1].trim()
break
}
}

// Latest assistant message carrying a stats snapshot wins (cumulative snapshot).
let latest: { ts: number; extra: Record<string, unknown> } | null = null
for (const msg of parsed) {
if (msg.role !== 'assistant' || typeof msg.extra !== 'string') continue
let extra: Record<string, unknown>
try {
extra = JSON.parse(msg.extra) as Record<string, unknown>
} catch {
continue
}
const hasStats = extra.statsSnapshot != null || extra.lastStepInputTokens != null
if (!hasStats) continue
const ts = toMs(msg.createdAt)
if (!latest || ts >= latest.ts) latest = { ts, extra }
}
if (!latest) return null

const extra = latest.extra
const snapshot = (extra.statsSnapshot ?? {}) as Record<string, unknown>

// Prefer the cumulative snapshot; fall back to per-step fields when absent.
const cacheReadTokens = num(snapshot.cachedInputTokens ?? extra.lastStepCachedInputTokens)
const inputTokens = snapshot.cacheMissTokens != null
? num(snapshot.cacheMissTokens)
: Math.max(0, num(extra.lastStepInputTokens) - cacheReadTokens)
const outputTokens = num(snapshot.lastOutputTokens ?? extra.lastStepOutputTokens)
const cacheWriteTokens = num(snapshot.cacheWriteTokens)
const thinkingTokens = num(snapshot.thinkingTokens)

const model = typeof extra.modelId === 'string' && extra.modelId.trim()
? extra.modelId.trim()
: 'unknown'

return {
ts: latest.ts,
model,
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
thinkingTokens,
cwd,
}
}

export function runParseCodeBuddy(options: CodeBuddyImportOptions): CodeBuddyImportResult {
const { dataDir, device, deviceInstanceId, platform, now, cursor, exchangeRate } = options
const records: StatsRecord[] = []
const errors: string[] = []
let nextCursor = cursor ?? 0

if (!existsSync(dataDir)) {
return { records, nextCursor, errors }
}

let messagesDirs: string[]
try {
messagesDirs = findMessagesDirs(dataDir)
} catch (e) {
return { records, nextCursor, errors: [String(e)] }
}

for (const messagesDir of messagesDirs) {
const conversationDir = dirname(messagesDir)
const sessionId = basename(conversationDir)
let usage: ConversationUsage | null
try {
usage = readConversation(messagesDir)
} catch (e) {
errors.push(`${messagesDir}: ${e instanceof Error ? e.message : e}`)
continue
}
if (!usage) continue
if (usage.inputTokens + usage.outputTokens + usage.cacheReadTokens === 0) continue

// Skip conversations already imported (last activity at or before the cursor).
if (cursor && usage.ts <= cursor) continue
if (usage.ts > nextCursor) nextCursor = usage.ts

const provider = inferProvider(usage.model)
const tokenArgs = {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheReadTokens: usage.cacheReadTokens,
cacheWriteTokens: usage.cacheWriteTokens,
thinkingTokens: usage.thinkingTokens,
}
const cost = calculateCost(usage.model, tokenArgs, exchangeRate)
const recordId = generateRecordId(deviceInstanceId, conversationDir, usage.ts)

records.push({
id: recordId,
ts: usage.ts,
ingestedAt: now,
updatedAt: now,
lineOffset: 0,
tool: 'codebuddy',
model: usage.model,
provider,
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheReadTokens: usage.cacheReadTokens,
cacheWriteTokens: usage.cacheWriteTokens,
thinkingTokens: usage.thinkingTokens,
cost,
costSource: cost > 0 ? 'pricing' : 'unknown',
sessionId,
sourceFile: conversationDir,
cwd: usage.cwd,
device,
deviceInstanceId,
platform,
})
}

return { records, nextCursor, errors }
}
28 changes: 28 additions & 0 deletions packages/cli/src/commands/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { runParseZed } from './parse-zed.js'
import { runParseKiro } from './parse-kiro.js'
import { runParseZcode } from './parse-zcode.js'
import { runParseTrae } from './parse-trae.js'
import { runParseCodeBuddy } from './parse-codebuddy.js'
import type { ProgressInfo } from '../progress.js'

interface ParseResult {
Expand Down Expand Up @@ -1054,6 +1055,33 @@ export async function runParse(db: Database.Database, filterTool?: string, optio
onProgress({ phase: 'Parsing SQLite', tool: 'trae', current: 1, total: 1, records: parsedCount, toolCalls: toolCallCount })
}

// CodeBuddy IDE: per-message JSON files under CodeBuddyExtension/Data.
const codeBuddyIdeDir = getDbPath('codebuddy-ide') ?? ''
if ((!filterTool || filterTool === 'codebuddy') && existsSync(codeBuddyIdeDir)) {
try {
const cbCursor = wm.getCodeBuddyIdeCursor()
const result = runParseCodeBuddy({
dataDir: codeBuddyIdeDir,
device,
deviceInstanceId,
platform: devicePlatform,
now: Date.now(),
cursor: cbCursor,
exchangeRate,
})
for (const record of result.records) insertRecord(db, record)
parsedCount += result.records.length
errors.push(...result.errors)
if (result.nextCursor > cbCursor) {
wm.setCodeBuddyIdeCursor(result.nextCursor)
wm.save()
}
onProgress({ phase: 'Parsing logs', tool: 'codebuddy', current: 1, total: 1, records: parsedCount, toolCalls: toolCallCount })
} catch (e) {
errors.push(`${codeBuddyIdeDir}: ${e instanceof Error ? e.message : e}`)
}
}

// Fix historical records that were parsed before init created state.json.
// If the current device UUID is known, backfill any records with 'unknown' device_instance_id.
if (deviceInstanceId !== 'unknown') {
Expand Down
58 changes: 57 additions & 1 deletion packages/cli/src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,59 @@ function probeCodeBuddy(ctx: ProbeContext): string | null {
return existsSync(dir) ? dir : null
}

/** CodeBuddy IDE (Tencent) stores per-message JSON under CodeBuddyExtension/Data. */
function codeBuddyIdeRoots(ctx: ProbeContext): string[] {
const roots: string[] = []
if (platform() === 'darwin') {
roots.push(join(ctx.home, 'Library', 'Application Support', 'CodeBuddyExtension', 'Data'))
} else if (platform() === 'win32') {
const appData = ctx.env.APPDATA ?? join(ctx.home, 'AppData', 'Roaming')
roots.push(join(appData, 'CodeBuddyExtension', 'Data'))
} else {
const config = ctx.env.XDG_CONFIG_HOME ?? join(ctx.home, '.config')
roots.push(join(config, 'CodeBuddyExtension', 'Data'))
}
return unique(roots)
}

function probeCodeBuddyIde(ctx: ProbeContext): string | null {
const override = envOverride('codebuddy-ide', ctx.env)
if (override) return override
const legacy = ctx.legacySources?.['codebuddy-ide']
if (legacy) return legacy
for (const root of codeBuddyIdeRoots(ctx)) {
if (existsSync(root)) return root
}
return null
}

/** Count CodeBuddy IDE conversations that contain at least one message file. */
function countCodeBuddyIdeConversations(dir: string): number {
let count = 0
const walk = (root: string, depth: number): void => {
if (depth > 12) return
let entries
try {
entries = readdirSync(root, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (!entry.isDirectory()) continue
const full = join(root, entry.name)
if (entry.name === 'messages') {
try {
if (readdirSync(full).some((f) => f.endsWith('.json'))) count++
} catch {}
continue
}
walk(full, depth + 1)
}
}
walk(dir, 0)
return count
}

function probeKiro(ctx: ProbeContext): string | null {
const override = envOverride('kiro', ctx.env)
if (override) return override
Expand Down Expand Up @@ -607,6 +660,7 @@ const TOOL_REGISTRY: readonly ToolEntry[] = [
{ tool: 'gemini', sourceKey: 'gemini', label: 'Gemini CLI', probe: probeGemini },
{ tool: 'kimi', sourceKey: 'kimi', label: 'Kimi Code', probe: probeKimi },
{ tool: 'codebuddy', sourceKey: 'codebuddy', label: 'CodeBuddy', probe: probeCodeBuddy },
{ tool: 'codebuddy', sourceKey: 'codebuddy-ide', label: 'CodeBuddy (IDE)', probe: probeCodeBuddyIde },
{ tool: 'kiro', sourceKey: 'kiro', label: 'Kiro', probe: probeKiro },
{ tool: 'grok', sourceKey: 'grok', label: 'Grok Build', probe: probeGrok },
{ tool: 'antigravity', sourceKey: 'antigravity', label: 'Antigravity', probe: probeAntigravity },
Expand Down Expand Up @@ -654,7 +708,9 @@ export function discoverTools(env: NodeJS.ProcessEnv = process.env): DetectedToo
try {
const stat = statSync(detectedPath)
if (stat.isDirectory()) {
if (entry.sourceKey === 'roocode' || entry.sourceKey === 'kilocode') {
if (entry.sourceKey === 'codebuddy-ide') {
fileCount += countCodeBuddyIdeConversations(detectedPath)
} else if (entry.sourceKey === 'roocode' || entry.sourceKey === 'kilocode') {
fileCount += findJsonFiles(detectedPath).filter((p) => basename(p) === 'ui_messages.json').length
} else if (entry.sourceKey === 'kelivo') {
fileCount += findJsonFiles(detectedPath).filter((p) => basename(p) === 'chats.json').length
Expand Down
Loading
Loading