Repository navigation
Expand file tree
/
Copy pathcline-cli.ts
More file actions
433 lines (381 loc) · 16.5 KB
/
Copy pathcline-cli.ts
File metadata and controls
433 lines (381 loc) · 16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
import { readdir } from 'fs/promises'
import { homedir } from 'os'
import { basename, join } from 'path'
import { extractBashCommands } from '../bash-utils.js'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost, getShortModelName, ownValue } from '../models.js'
import type { ToolCall } from '../types.js'
import type { ParsedProviderCall, ProbeRoot, Provider, SessionParser, SessionSource } from './types.js'
// The Cline CLI (npm `cline`, 3.x) stores sessions in a layout unrelated to the
// VS Code extension's tasks/ui_messages.json tree that `cline.ts` reads:
//
// <sessions>/<sessionId>/<sessionId>.json metadata + rolled-up usage
// <sessions>/<sessionId>/<sessionId>.messages.json per-message metrics
//
// Kept as its own provider rather than a third root on `cline` so the shared
// Cline-family parser (also serving KiloCode and IBM Bob) stays untouched.
const PROVIDER_NAME = 'cline-cli'
const DISPLAY_NAME = 'Cline CLI'
const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000
// Mirrors the CLI's own resolution chain, each level individually overridable:
// sessions := CLINE_SESSION_DATA_DIR ?? <data>/sessions
// data := CLINE_DATA_DIR ?? <root>/data
// root := CLINE_DIR ?? ~/.cline
function clineRootDir(): string {
return process.env['CLINE_DIR']?.trim() || join(homedir(), '.cline')
}
function clineDataDir(): string {
return process.env['CLINE_DATA_DIR']?.trim() || join(clineRootDir(), 'data')
}
export function getClineCliSessionsDir(): string {
return process.env['CLINE_SESSION_DATA_DIR']?.trim() || join(clineDataDir(), 'sessions')
}
const toolNameMap: Record<string, string> = {
run_commands: 'Bash',
read_files: 'Read',
editor: 'Edit',
apply_patch: 'Edit',
search_codebase: 'Grep',
fetch_web_content: 'WebFetch',
skills: 'Skill',
spawn_agent: 'Agent',
team_spawn_teammate: 'Agent',
team_run_task: 'Agent',
ask_question: 'AskUser',
}
function mapToolName(rawTool: string): string {
return ownValue(toolNameMap, rawTool) ?? rawTool
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function safeNonNegativeNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
}
function safeTokenCount(value: unknown): number {
return Math.floor(Math.min(safeNonNegativeNumber(value), Number.MAX_SAFE_INTEGER))
}
// A cost counts as metered only when it is actually present and non-negative.
// `0` is a legitimate metered value (a free/cached call) and must stay reported,
// so this is a presence check, not a truthiness check.
function isReportedCost(value: unknown): boolean {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
}
type ClineCliMetrics = {
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
cost: number
costReported: boolean
}
function parseMetrics(value: unknown): ClineCliMetrics | null {
if (!isRecord(value)) return null
const cacheReadTokens = safeTokenCount(value['cacheReadTokens'])
const cacheWriteTokens = safeTokenCount(value['cacheWriteTokens'])
const metrics: ClineCliMetrics = {
// Cline's inputTokens is the full prompt, cache read and write included
// (its own pricing bills max(0, input - cacheRead - cacheWrite) at the input rate).
inputTokens: Math.max(0, safeTokenCount(value['inputTokens']) - cacheReadTokens - cacheWriteTokens),
outputTokens: safeTokenCount(value['outputTokens']),
cacheReadTokens,
cacheWriteTokens,
cost: safeNonNegativeNumber(value['cost']),
// A negative cost is not a credit we can represent — treat it as absent and
// fall back to token pricing, rather than reporting a clamped $0 as metered.
costReported: isReportedCost(value['cost']),
}
const hasTokens = metrics.inputTokens > 0 || metrics.outputTokens > 0
|| metrics.cacheReadTokens > 0 || metrics.cacheWriteTokens > 0
return hasTokens || metrics.cost > 0 ? metrics : null
}
function projectName(workspace: string | undefined): string {
if (!workspace) return DISPLAY_NAME
const parts = workspace.replace(/[\\/]+$/, '').split(/[\\/]/).filter(Boolean)
return parts.at(-1) ?? DISPLAY_NAME
}
function isoTimestamp(value: unknown, fallback: string): string {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
// The CLI writes epoch milliseconds, but a seconds-resolution value would
// otherwise silently land in 1970. Promote it and reject what stays
// implausible, matching the guard kiro.ts uses on the same hazard.
const ms = value < MIN_REASONABLE_TIMESTAMP_MS ? value * 1000 : value
const date = new Date(ms)
if (!Number.isNaN(date.getTime()) && date.getTime() >= MIN_REASONABLE_TIMESTAMP_MS) {
return date.toISOString()
}
}
const parsed = nonEmptyString(value)
if (parsed) {
const date = new Date(parsed)
if (!Number.isNaN(date.getTime())) return date.toISOString()
}
return fallback
}
// `run_commands` carries its commands as a JSON-encoded array in a string
// field; anything else is treated as a single command line.
function commandsFrom(input: unknown): string[] {
if (!isRecord(input)) return []
const raw = input['commands'] ?? input['command']
if (Array.isArray(raw)) return raw.filter((c): c is string => typeof c === 'string')
const text = nonEmptyString(raw)
if (!text) return []
if (text.startsWith('[')) {
try {
const parsed = JSON.parse(text) as unknown
if (Array.isArray(parsed)) return parsed.filter((c): c is string => typeof c === 'string')
} catch {
// Not JSON after all - fall through and treat the whole string as one command.
}
}
return [text]
}
function firstString(input: unknown, keys: string[]): string | undefined {
if (!isRecord(input)) return undefined
for (const key of keys) {
const value = nonEmptyString(input[key])
if (value) return value
}
return undefined
}
type CollectedTools = {
tools: string[]
bashCommands: string[]
toolSequence: ToolCall[][]
skills: string[]
subagentTypes: string[]
webSearchRequests: number
}
function collectTools(content: unknown): CollectedTools {
const collected: CollectedTools = {
tools: [], bashCommands: [], toolSequence: [], skills: [], subagentTypes: [], webSearchRequests: 0,
}
if (!Array.isArray(content)) return collected
const turnTools: ToolCall[] = []
for (const block of content) {
if (!isRecord(block) || block['type'] !== 'tool_use') continue
const rawName = nonEmptyString(block['name'])
if (!rawName) continue
const mapped = mapToolName(rawName)
const input = block['input']
const toolCall: ToolCall = { tool: mapped }
const file = firstString(input, ['path', 'file_path', 'paths', 'file'])
if (file) toolCall.file = file
if (mapped === 'Bash') {
const commands = commandsFrom(input)
const [first] = commands
if (first) toolCall.command = first
for (const command of commands) collected.bashCommands.push(...extractBashCommands(command))
}
if (mapped === 'Skill') {
const skill = firstString(input, ['name', 'skill', 'skill_name'])
if (skill) collected.skills.push(skill)
}
if (mapped === 'Agent') {
const subagentType = firstString(input, ['agent', 'agent_type', 'type', 'name'])
if (subagentType) collected.subagentTypes.push(subagentType)
}
if (mapped === 'WebFetch') collected.webSearchRequests++
collected.tools.push(mapped)
turnTools.push(toolCall)
}
if (turnTools.length > 0) collected.toolSequence.push(turnTools)
return collected
}
function textFromContent(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
for (const block of content) {
if (!isRecord(block) || block['type'] !== 'text') continue
const text = nonEmptyString(block['text'])
if (text) return text
}
return ''
}
function firstUserMessage(messages: unknown[]): string {
for (const message of messages) {
if (!isRecord(message) || message['role'] !== 'user') continue
const text = textFromContent(message['content'])
// Tool results come back as role:user too; they carry no text block.
if (text) return text
}
return ''
}
async function readJson(path: string): Promise<unknown> {
const raw = await readSessionFile(path)
if (raw === null) return null
try {
return JSON.parse(raw) as unknown
} catch {
return null
}
}
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
// `source.path` is the growing `<sessionId>.messages.json` file (see
// `discoverSessions` / review finding C-G1) — the static per-session
// metadata lives in the sibling `<sessionId>.json`, read below.
const metaPath = source.path.replace(/\.messages\.json$/, '.json')
const meta = await readJson(metaPath)
if (!isRecord(meta)) return
const sessionId = nonEmptyString(meta['session_id']) ?? basename(metaPath).replace(/\.json$/, '')
const metadata = isRecord(meta['metadata']) ? meta['metadata'] : {}
const workspace = nonEmptyString(meta['workspace_root']) ?? nonEmptyString(meta['cwd'])
const sessionModel = nonEmptyString(meta['model']) ?? 'unknown'
const startedAt = isoTimestamp(meta['started_at'], new Date(0).toISOString())
// Always populated by discoverSessions; projectName falls back to the
// display name, so there is nothing left to default to here.
const project = source.project
// Prefer the co-located messages file (source.path itself) over a
// recorded absolute path, which is stale once a session directory is
// copied between machines.
let doc = await readJson(source.path)
if (!isRecord(doc)) {
const recorded = nonEmptyString(meta['messages_path'])
if (recorded) doc = await readJson(recorded)
}
const messages = isRecord(doc) && Array.isArray(doc['messages']) ? doc['messages'] : []
const userMessage = firstUserMessage(messages)
// Whether the session carried any per-message metrics at all. Set before
// the dedup check below so a session whose calls were all deduped (e.g. a
// duplicated session directory reusing a session_id) still declines the
// rollup fallback rather than double-counting its cost through it.
let hadMetrics = false
for (const [index, message] of messages.entries()) {
if (!isRecord(message) || message['role'] !== 'assistant') continue
const metrics = parseMetrics(message['metrics'])
if (!metrics) continue
hadMetrics = true
const modelInfo = isRecord(message['modelInfo']) ? message['modelInfo'] : {}
const model = nonEmptyString(modelInfo['id']) ?? sessionModel
const messageId = nonEmptyString(message['id']) ?? String(index)
const deduplicationKey = `${PROVIDER_NAME}:${sessionId}:${messageId}`
if (seenKeys.has(deduplicationKey)) continue
seenKeys.add(deduplicationKey)
const { tools, bashCommands, toolSequence, skills, subagentTypes, webSearchRequests }
= collectTools(message['content'])
yield {
provider: PROVIDER_NAME,
model,
inputTokens: metrics.inputTokens,
outputTokens: metrics.outputTokens,
cacheCreationInputTokens: metrics.cacheWriteTokens,
cacheReadInputTokens: metrics.cacheReadTokens,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests,
costUSD: metrics.costReported
? metrics.cost
: calculateCost(model, metrics.inputTokens, metrics.outputTokens, metrics.cacheWriteTokens, metrics.cacheReadTokens, 0),
costIsEstimated: !metrics.costReported,
tools,
bashCommands,
skills: skills.length > 0 ? skills : undefined,
subagentTypes: subagentTypes.length > 0 ? subagentTypes : undefined,
timestamp: isoTimestamp(message['ts'], startedAt),
speed: 'standard',
deduplicationKey,
turnId: `${sessionId}:${messageId}`,
toolSequence: toolSequence.length > 0 ? toolSequence : undefined,
userMessage,
sessionId,
project,
projectPath: workspace,
workingDirectory: nonEmptyString(meta['cwd']),
}
}
if (hadMetrics) return
// No per-message metrics: fall back to the session rollup so an
// interrupted or older session still reports its spend. Deliberately
// reads `usage` and not `aggregateUsage`, which folds in spawned
// subagents that are themselves separate session directories.
const rollup = parseMetrics(isRecord(metadata['usage']) ? metadata['usage'] : null)
if (!rollup) return
const deduplicationKey = `${PROVIDER_NAME}:${sessionId}:rollup`
if (seenKeys.has(deduplicationKey)) return
seenKeys.add(deduplicationKey)
// Same presence-not-truthiness rule as the per-message path: a metered
// $0 rollup stays reported instead of being re-estimated from tokens.
const rawRollupCost = (isRecord(metadata['usage']) ? metadata['usage']['totalCost'] : undefined)
?? metadata['totalCost']
const rollupCostReported = isReportedCost(rawRollupCost)
const rollupCost = safeNonNegativeNumber(rawRollupCost)
yield {
provider: PROVIDER_NAME,
model: sessionModel,
inputTokens: rollup.inputTokens,
outputTokens: rollup.outputTokens,
cacheCreationInputTokens: rollup.cacheWriteTokens,
cacheReadInputTokens: rollup.cacheReadTokens,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
costUSD: rollupCostReported
? rollupCost
: calculateCost(sessionModel, rollup.inputTokens, rollup.outputTokens, rollup.cacheWriteTokens, rollup.cacheReadTokens, 0),
costIsEstimated: !rollupCostReported,
tools: [],
bashCommands: [],
timestamp: isoTimestamp(meta['ended_at'], startedAt),
speed: 'standard',
deduplicationKey,
turnId: `${sessionId}:rollup`,
userMessage,
sessionId,
project,
projectPath: workspace,
workingDirectory: nonEmptyString(meta['cwd']),
}
},
}
}
export function createClineCliProvider(overrideDir?: string): Provider {
const sessionsDir = (): string => overrideDir ?? getClineCliSessionsDir()
return {
name: PROVIDER_NAME,
displayName: DISPLAY_NAME,
modelDisplayName(model: string): string {
return getShortModelName(model)
},
toolDisplayName(rawTool: string): string {
return mapToolName(rawTool)
},
async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: sessionsDir(), label: 'Cline CLI sessions' }]
},
async discoverSessions(): Promise<SessionSource[]> {
const dir = sessionsDir()
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
const sources: SessionSource[] = []
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (!entry.isDirectory()) continue
const sessionId = entry.name
const metaPath = join(dir, sessionId, `${sessionId}.json`)
const meta = await readJson(metaPath)
if (!isRecord(meta)) continue
// Fingerprint the sibling that actually accumulates new turns, not
// the metadata file written once at session start — a live session's
// corpus fingerprint must move as new turns append, or a menubar
// poll can serve the pre-activity snapshot forever (review finding
// C-G1). `computeCorpusFingerprint` degrades this to "contributes
// nothing yet" if the messages file doesn't exist (a session with no
// turns), the same as any other missing/not-yet-created file.
const workspace = nonEmptyString(meta['workspace_root']) ?? nonEmptyString(meta['cwd'])
sources.push({
path: join(dir, sessionId, `${sessionId}.messages.json`),
project: projectName(workspace),
provider: PROVIDER_NAME,
})
}
return sources
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createParser(source, seenKeys)
},
}
}
export const clineCli = createClineCliProvider()