Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from "./telemetry.js"
export * from "./terminal.js"
export * from "./tool.js"
export * from "./type-fu.js"
export * from "./usage.js"
export * from "./vscode.js"

export * from "./providers/index.js"
42 changes: 42 additions & 0 deletions packages/types/src/usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { z } from "zod"

/**
* Schema for persistent usage tracking item
* Stores cost and token data for completed tasks
*/
export const PersistentUsageItemSchema = z.object({
taskId: z.string(),
taskWorkspace: z.string(), // Workspace path for filtering
mode: z.string().optional(),
timestamp: z.number(),
cost: z.number().default(0),
inputTokens: z.number().default(0),
outputTokens: z.number().default(0),
cacheReads: z.number().default(0),
cacheWrites: z.number().default(0),
})

export type PersistentUsageItem = z.infer<typeof PersistentUsageItemSchema>

/**
* Usage summary data structure for frontend display
*/
export interface UsageSummary {
totalCost: number
totalInputTokens: number
totalOutputTokens: number
totalCacheReads: number
totalCacheWrites: number
modeBreakdown: Record<
string,
{
cost: number
inputTokens: number
outputTokens: number
cacheReads: number
cacheWrites: number
count: number
}
>
workspaceName?: string
}
46 changes: 46 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,42 @@ export class ClineProvider
if (!this.checkMdmCompliance()) {
await this.postMessageToWebview({ type: "action", action: "accountButtonClicked" })
}

// Load persistent usage data asynchronously
this.loadPersistentUsageDataAsync()
}

/**
* Load persistent usage data asynchronously and send to webview
*/
public async loadPersistentUsageDataAsync() {
try {
const { getUsageTrackingService } = await import("../../services/usage-tracking")
const usageService = getUsageTrackingService()

// Get both all workspaces and current workspace data
const [allData, currentData] = await Promise.all([
usageService.getUsageSummary(),
usageService.getUsageSummary(this.cwd),
])

// Send to webview
await this.postMessageToWebview({
type: "persistentUsageData",
persistentUsageData: {
all: allData,
current: currentData,
},
})

// Migrate existing task history if needed
const taskHistory = this.getGlobalState("taskHistory") ?? []
if (taskHistory.length > 0) {
await usageService.migrateFromTaskHistory(taskHistory, this.cwd)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This migration could run multiple times if loadPersistentUsageDataAsync() is called repeatedly. Consider adding a flag to track if migration has already been attempted for this session?

}
} catch (error) {
this.log(`Failed to load persistent usage data: ${error}`)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling here only logs failures. Should we consider showing a user-friendly notification if usage data loading fails? Users might wonder why their cost summary isn't appearing.

}
}

/**
Expand Down Expand Up @@ -1957,6 +1993,16 @@ export class ClineProvider
}

await this.updateGlobalState("taskHistory", history)

// Update persistent usage tracking
try {
const { getUsageTrackingService } = await import("../../services/usage-tracking")
const usageService = getUsageTrackingService()
await usageService.updateUsageTracking(item, this.cwd)
} catch (error) {
this.log(`Failed to update usage tracking: ${error}`)
}

return history
}

Expand Down
5 changes: 5 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2604,5 +2604,10 @@ export const webviewMessageHandler = async (
}
break
}
case "requestPersistentUsageData": {
// Load persistent usage data asynchronously
provider.loadPersistentUsageDataAsync()
break
}
}
}
250 changes: 250 additions & 0 deletions src/services/usage-tracking/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import * as path from "path"
import * as os from "os"
import { PersistentUsageItem, PersistentUsageItemSchema, UsageSummary, HistoryItem } from "@roo-code/types"
import { safeWriteJson } from "../../utils/safeWriteJson"
import { fileExistsAtPath } from "../../utils/fs"
import * as fs from "fs/promises"

/**
* Service for managing persistent usage tracking data
* Stores data in ~/.roo/usage-tracking.json
*/
export class UsageTrackingService {
private static instance: UsageTrackingService | null = null
private readonly filePath: string

private constructor() {
// Store in global ~/.roo directory
const rooDir = path.join(os.homedir(), ".roo")
this.filePath = path.join(rooDir, "usage-tracking.json")
}

public static getInstance(): UsageTrackingService {
if (!UsageTrackingService.instance) {
UsageTrackingService.instance = new UsageTrackingService()
}
return UsageTrackingService.instance
}

/**
* Load existing usage data from disk
*/
private async loadData(): Promise<PersistentUsageItem[]> {
try {
if (!(await fileExistsAtPath(this.filePath))) {
return []
}

const content = await fs.readFile(this.filePath, "utf-8")
const data = JSON.parse(content)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.parse is wrapped in a try/catch, which is good. For easier debugging, consider including the file path in the error log when parsing fails.

This comment was generated because it violated a code review rule: irule_PTI8rjtnhwrWq6jS.


// Validate and parse each item
if (Array.isArray(data)) {
return data
.map((item) => {
try {
return PersistentUsageItemSchema.parse(item)
} catch {
// Skip invalid items
return null
}
})
.filter((item): item is PersistentUsageItem => item !== null)
}

return []
} catch (error) {
console.error("Failed to load usage tracking data:", error)
return []
}
}

/**
* Save usage data to disk
*/
private async saveData(data: PersistentUsageItem[]): Promise<void> {
try {
await safeWriteJson(this.filePath, data)
} catch (error) {
console.error("Failed to save usage tracking data:", error)
throw error
}
}

/**
* Update usage tracking with new task data
*/
public async updateUsageTracking(historyItem: HistoryItem, workspacePath: string): Promise<void> {
try {
const data = await this.loadData()

// Remove existing entry for this task if it exists
const filteredData = data.filter((item) => item.taskId !== historyItem.id)

// Calculate total cost and tokens from the history item
let totalCost = 0
let totalInputTokens = 0
let totalOutputTokens = 0
let totalCacheReads = 0
let totalCacheWrites = 0

// Sum up all API request info from the task
if (historyItem.totalCost) {
totalCost = historyItem.totalCost
}
if (historyItem.tokensIn) {
totalInputTokens = historyItem.tokensIn
}
if (historyItem.tokensOut) {
totalOutputTokens = historyItem.tokensOut
}
if (historyItem.cacheReads) {
totalCacheReads = historyItem.cacheReads
}
if (historyItem.cacheWrites) {
totalCacheWrites = historyItem.cacheWrites
}

// Add new usage item
const newItem: PersistentUsageItem = {
taskId: historyItem.id,
taskWorkspace: workspacePath,
mode: historyItem.mode,
timestamp: historyItem.ts,
cost: totalCost,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
cacheReads: totalCacheReads,
cacheWrites: totalCacheWrites,
}

filteredData.push(newItem)

// Save updated data
await this.saveData(filteredData)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For users with extensive history, this JSON file could grow indefinitely. Should we implement data pruning for records older than a certain threshold (e.g., 90 days)?

} catch (error) {
console.error("Failed to update usage tracking:", error)
// Don't throw - we don't want to break the main flow
}
}

/**
* Get usage summary for display
*/
public async getUsageSummary(workspacePath?: string): Promise<UsageSummary> {
try {
const data = await this.loadData()

// Filter by workspace if specified
const filteredData = workspacePath ? data.filter((item) => item.taskWorkspace === workspacePath) : data

// Calculate totals
let totalCost = 0
let totalInputTokens = 0
let totalOutputTokens = 0
let totalCacheReads = 0
let totalCacheWrites = 0
const modeBreakdown: UsageSummary["modeBreakdown"] = {}

for (const item of filteredData) {
totalCost += item.cost
totalInputTokens += item.inputTokens
totalOutputTokens += item.outputTokens
totalCacheReads += item.cacheReads
totalCacheWrites += item.cacheWrites

// Update mode breakdown
const mode = item.mode || "unknown"
if (!modeBreakdown[mode]) {
modeBreakdown[mode] = {
cost: 0,
inputTokens: 0,
outputTokens: 0,
cacheReads: 0,
cacheWrites: 0,
count: 0,
}
}

modeBreakdown[mode].cost += item.cost
modeBreakdown[mode].inputTokens += item.inputTokens
modeBreakdown[mode].outputTokens += item.outputTokens
modeBreakdown[mode].cacheReads += item.cacheReads
modeBreakdown[mode].cacheWrites += item.cacheWrites
modeBreakdown[mode].count += 1
}

// Get workspace name from path
const workspaceName = workspacePath ? path.basename(workspacePath) : undefined

return {
totalCost,
totalInputTokens,
totalOutputTokens,
totalCacheReads,
totalCacheWrites,
modeBreakdown,
workspaceName,
}
} catch (error) {
console.error("Failed to get usage summary:", error)
// Return empty summary on error
return {
totalCost: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReads: 0,
totalCacheWrites: 0,
modeBreakdown: {},
}
}
}

/**
* Migrate existing task history to persistent storage
*/
public async migrateFromTaskHistory(taskHistory: HistoryItem[], workspacePath: string): Promise<void> {
try {
const data = await this.loadData()
const existingTaskIds = new Set(data.map((item) => item.taskId))

// Only migrate tasks that aren't already in persistent storage
const tasksToMigrate = taskHistory.filter((task) => !existingTaskIds.has(task.id))

for (const task of tasksToMigrate) {
if (task.totalCost || task.tokensIn || task.tokensOut) {
await this.updateUsageTracking(task, workspacePath)
}
}
} catch (error) {
console.error("Failed to migrate task history:", error)
}
}

/**
* Clear all usage data (for testing or reset)
*/
public async clearAllData(): Promise<void> {
try {
await this.saveData([])
} catch (error) {
console.error("Failed to clear usage data:", error)
}
}

/**
* Remove usage data for a specific task
*/
public async removeTask(taskId: string): Promise<void> {
try {
const data = await this.loadData()
const filteredData = data.filter((item) => item.taskId !== taskId)
await this.saveData(filteredData)
} catch (error) {
console.error("Failed to remove task from usage tracking:", error)
}
}
}

// Export singleton instance getter
export const getUsageTrackingService = () => UsageTrackingService.getInstance()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This service needs test coverage. The PR claims tests pass but doesn't add any tests for this critical new functionality. Could we add unit tests to verify data persistence, workspace filtering, and migration logic?

5 changes: 5 additions & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export interface ExtensionMessage {
| "showEditMessageDialog"
| "commands"
| "insertTextIntoTextarea"
| "persistentUsageData"
text?: string
payload?: any // Add a generic payload for now, can refine later
action?:
Expand Down Expand Up @@ -195,6 +196,10 @@ export interface ExtensionMessage {
messageTs?: number
context?: string
commands?: Command[]
persistentUsageData?: {
all: any // UsageSummary type
current: any // UsageSummary type
}
}

export type ExtensionState = Pick<
Expand Down
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export interface WebviewMessage {
| "deleteCommand"
| "createCommand"
| "insertTextIntoTextarea"
| "requestPersistentUsageData"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"
Expand Down
Loading
Loading