@techdivision/opencode-time-tracking 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.8.0] - 2025-03-01
6
+
7
+ ### Added
8
+
9
+ - Add detailed token columns to CSV: `tokens_input`, `tokens_output`, `tokens_reasoning`, `tokens_cache_read`, `tokens_cache_write`
10
+ - Add `cost` column to CSV tracking session cost in USD (from OpenCode SDK)
11
+ - Add `CsvWriter.ensureHeader()` for automatic CSV header management at plugin startup
12
+
13
+ ### Fixed
14
+
15
+ - Fix CSV header migration: automatically upgrade old 17-column files to 23-column format
16
+ - Fix ticket extraction: skip synthetic text parts (file contents, MCP resources) to avoid false positives from example patterns in docs
17
+ - Fix empty CSV files created without headers
18
+
19
+ ### Changed
20
+
21
+ - CSV header is now validated and repaired at plugin startup, not on each write
22
+
23
+ ## [0.7.1] - 2025-03-01
24
+
25
+ ### Fixed
26
+
27
+ - Fix `agent_defaults` lookup to be tolerant of `@` prefix in agent names
28
+ - Add `AgentMatcher` utility for consistent agent name normalization across the codebase
29
+
5
30
  ## [0.7.0] - 2025-02-04
6
31
 
7
32
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techdivision/opencode-time-tracking",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Automatic time tracking plugin for OpenCode - tracks session duration and tool usage to CSV",
5
5
  "main": "src/Plugin.ts",
6
6
  "types": "src/Plugin.ts",
package/src/Plugin.ts CHANGED
@@ -64,6 +64,9 @@ export const plugin: Plugin = async ({
64
64
  const ticketExtractor = new TicketExtractor(client, config.valid_projects)
65
65
  const ticketResolver = new TicketResolver(config, ticketExtractor)
66
66
 
67
+ // Ensure CSV file has a valid header at startup
68
+ await csvWriter.ensureHeader()
69
+
67
70
  const hooks: Hooks = {
68
71
  "tool.execute.after": createToolExecuteAfterHook(
69
72
  sessionManager,
@@ -12,6 +12,7 @@ import type { MessageWithParts } from "../types/MessageWithParts"
12
12
  import type { OpencodeClient } from "../types/OpencodeClient"
13
13
  import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
14
14
 
15
+ import { AgentMatcher } from "../utils/AgentMatcher"
15
16
  import { DescriptionGenerator } from "../utils/DescriptionGenerator"
16
17
 
17
18
  /**
@@ -139,6 +140,11 @@ export function createEventHook(
139
140
  cacheRead: part.tokens.cache.read,
140
141
  cacheWrite: part.tokens.cache.write,
141
142
  })
143
+
144
+ // Track cost from step-finish events
145
+ if (part.cost !== undefined) {
146
+ sessionManager.addCost(part.sessionID, part.cost)
147
+ }
142
148
  }
143
149
 
144
150
  return
@@ -193,9 +199,11 @@ export function createEventHook(
193
199
  const agentString = session.agent?.name ?? null
194
200
 
195
201
  // Check if agent should be ignored (tolerant matching: with or without @ prefix)
196
- const normalizedAgent = agentString?.replace(/^@/, "")
202
+ const normalizedAgent = agentString
203
+ ? AgentMatcher.normalize(agentString)
204
+ : null
197
205
  const isIgnoredAgent = config.ignored_agents?.some(
198
- (ignored) => ignored.replace(/^@/, "") === normalizedAgent
206
+ (ignored) => AgentMatcher.normalize(ignored) === normalizedAgent
199
207
  )
200
208
 
201
209
  if (agentString && isIgnoredAgent) {
@@ -221,6 +229,7 @@ export function createEventHook(
221
229
  description,
222
230
  notes: `Auto-tracked: ${toolSummary}`,
223
231
  tokenUsage: session.tokenUsage,
232
+ cost: session.cost,
224
233
  model: modelString,
225
234
  agent: agentString,
226
235
  })
@@ -16,9 +16,51 @@ import "../types/Bun"
16
16
  /**
17
17
  * CSV header row for the worklog export file.
18
18
  * Compatible with Jira/Tempo time tracking import.
19
+ *
20
+ * @remarks
21
+ * Columns 1-17: Original format (v0.5.0 - v0.7.x)
22
+ * Columns 18-23: Extended format (v0.8.0+) with token details and cost
19
23
  */
20
24
  const CSV_HEADER =
21
- "id,start_date,end_date,user,ticket_name,issue_key,account_key,start_time,end_time,duration_seconds,tokens_used,tokens_remaining,story_points,description,notes,model,agent"
25
+ "id,start_date,end_date,user,ticket_name,issue_key,account_key,start_time,end_time,duration_seconds,tokens_used,tokens_remaining,story_points,description,notes,model,agent,tokens_input,tokens_output,tokens_reasoning,tokens_cache_read,tokens_cache_write,cost"
26
+
27
+ /** Number of columns in the current CSV format */
28
+ const CSV_COLUMN_COUNT = 23
29
+
30
+ /**
31
+ * Checks if a line is a CSV header row.
32
+ *
33
+ * @param line - The line to check
34
+ * @returns `true` if the line appears to be a header
35
+ */
36
+ function isHeaderLine(line: string): boolean {
37
+ return line.startsWith('"id"') || line.startsWith("id,")
38
+ }
39
+
40
+ /**
41
+ * Pads a CSV line with empty fields to match the expected column count.
42
+ *
43
+ * @param line - The CSV line to pad
44
+ * @param currentColumns - The current number of columns in the line
45
+ * @param targetColumns - The target number of columns
46
+ * @returns The padded line, or the original line if no padding needed
47
+ */
48
+ function padCsvLine(
49
+ line: string,
50
+ currentColumns: number,
51
+ targetColumns: number
52
+ ): string {
53
+ if (currentColumns >= targetColumns) {
54
+ return line
55
+ }
56
+
57
+ const missingColumns = targetColumns - currentColumns
58
+ const padding = ',""\n'.repeat(missingColumns).slice(0, -1) // Remove trailing newline, keep commas
59
+ const emptyFields = Array(missingColumns).fill('""').join(",")
60
+
61
+ // Append empty fields to the line
62
+ return line + "," + emptyFields
63
+ }
22
64
 
23
65
  /**
24
66
  * Writes time tracking entries to a CSV file.
@@ -68,15 +110,95 @@ export class CsvWriter {
68
110
  return csvPath
69
111
  }
70
112
 
113
+ /**
114
+ * Ensures the CSV file exists and has a valid, up-to-date header.
115
+ *
116
+ * @remarks
117
+ * Call this once at plugin startup. Handles these cases:
118
+ * - File doesn't exist: creates it with header
119
+ * - File is empty: writes header
120
+ * - File has data but no header: prepends header and pads rows if needed
121
+ * - File has outdated header (fewer columns): replaces header and pads all rows
122
+ * - File already has current header: no action needed
123
+ *
124
+ * @returns `true` if file was modified, `false` if already valid
125
+ */
126
+ async ensureHeader(): Promise<boolean> {
127
+ const csvPath = this.resolvePath()
128
+
129
+ try {
130
+ await mkdir(dirname(csvPath), { recursive: true })
131
+ } catch {
132
+ // Directory may already exist
133
+ }
134
+
135
+ const file = Bun.file(csvPath)
136
+ const exists = await file.exists()
137
+
138
+ if (!exists) {
139
+ // Create new file with header
140
+ await Bun.write(csvPath, CSV_HEADER + "\n")
141
+ return true
142
+ }
143
+
144
+ const content = await file.text()
145
+ const trimmedContent = content.trim()
146
+
147
+ if (trimmedContent.length === 0) {
148
+ // Empty file: write header
149
+ await Bun.write(csvPath, CSV_HEADER + "\n")
150
+ return true
151
+ }
152
+
153
+ const lines = trimmedContent.split("\n")
154
+ const firstLine = lines[0]
155
+ const hasHeader = isHeaderLine(firstLine)
156
+
157
+ // Determine column count from first data line
158
+ const dataLineIndex = hasHeader ? 1 : 0
159
+ if (dataLineIndex >= lines.length) {
160
+ // Only header, no data - ensure header is current
161
+ if (hasHeader && CsvFormatter.countColumns(firstLine) < CSV_COLUMN_COUNT) {
162
+ await Bun.write(csvPath, CSV_HEADER + "\n")
163
+ return true
164
+ }
165
+ return false
166
+ }
167
+
168
+ const firstDataLine = lines[dataLineIndex]
169
+ const columnCount = CsvFormatter.countColumns(firstDataLine)
170
+
171
+ // Check if migration is needed
172
+ if (columnCount >= CSV_COLUMN_COUNT) {
173
+ // Already has enough columns
174
+ if (!hasHeader) {
175
+ // Just prepend header
176
+ await Bun.write(csvPath, CSV_HEADER + "\n" + trimmedContent + "\n")
177
+ return true
178
+ }
179
+ return false
180
+ }
181
+
182
+ // Migration needed: pad all data rows to CSV_COLUMN_COUNT
183
+ const dataLines = hasHeader ? lines.slice(1) : lines
184
+ const paddedLines = dataLines.map((line) => {
185
+ const lineColumnCount = CsvFormatter.countColumns(line)
186
+ return padCsvLine(line, lineColumnCount, CSV_COLUMN_COUNT)
187
+ })
188
+
189
+ // Write new header + padded data
190
+ await Bun.write(csvPath, CSV_HEADER + "\n" + paddedLines.join("\n") + "\n")
191
+ return true
192
+ }
193
+
71
194
  /**
72
195
  * Writes a time tracking entry to the CSV file.
73
196
  *
74
197
  * @param data - The entry data to write
75
198
  *
76
199
  * @remarks
77
- * Creates the CSV file with headers if it doesn't exist.
78
- * Appends to existing file if it exists.
79
- * Creates parent directories as needed.
200
+ * Assumes `ensureHeader()` was called at startup.
201
+ * Simply appends the new entry to the file.
80
202
  *
81
203
  * @example
82
204
  * ```typescript
@@ -87,19 +209,13 @@ export class CsvWriter {
87
209
  * durationSeconds: 3600,
88
210
  * description: "Implemented feature X",
89
211
  * notes: "Auto-tracked: read(5x), edit(3x)",
90
- * tokenUsage: { input: 1000, output: 500, reasoning: 0, cacheRead: 0, cacheWrite: 0 }
212
+ * tokenUsage: { input: 1000, output: 500, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
213
+ * cost: 0.0234
91
214
  * })
92
215
  * ```
93
216
  */
94
217
  async write(data: CsvEntryData): Promise<void> {
95
218
  const csvPath = this.resolvePath()
96
-
97
- try {
98
- await mkdir(dirname(csvPath), { recursive: true })
99
- } catch {
100
- // Directory may already exist
101
- }
102
-
103
219
  const file = Bun.file(csvPath)
104
220
  const exists = await file.exists()
105
221
 
@@ -124,11 +240,19 @@ export class CsvWriter {
124
240
  CsvFormatter.escape(data.notes),
125
241
  data.model ?? "",
126
242
  data.agent ?? "",
243
+ // Extended columns (v0.8.0+)
244
+ data.tokenUsage.input.toString(),
245
+ data.tokenUsage.output.toString(),
246
+ data.tokenUsage.reasoning.toString(),
247
+ data.tokenUsage.cacheRead.toString(),
248
+ data.tokenUsage.cacheWrite.toString(),
249
+ data.cost.toFixed(6),
127
250
  ]
128
251
 
129
252
  const csvLine = fields.map((f) => `"${f}"`).join(",")
130
253
 
131
254
  if (!exists) {
255
+ // Fallback: create file with header if ensureHeader() wasn't called
132
256
  await Bun.write(csvPath, CSV_HEADER + "\n" + csvLine + "\n")
133
257
  } else {
134
258
  const content = await file.text()
@@ -63,6 +63,7 @@ export class SessionManager {
63
63
  cacheRead: 0,
64
64
  cacheWrite: 0,
65
65
  },
66
+ cost: 0,
66
67
  model: null,
67
68
  agent: null,
68
69
  }
@@ -134,6 +135,20 @@ export class SessionManager {
134
135
  }
135
136
  }
136
137
 
138
+ /**
139
+ * Adds cost to a session's cumulative total.
140
+ *
141
+ * @param sessionID - The OpenCode session identifier
142
+ * @param cost - The cost in USD to add
143
+ */
144
+ addCost(sessionID: string, cost: number): void {
145
+ const session = this.sessions.get(sessionID)
146
+
147
+ if (session) {
148
+ session.cost += cost
149
+ }
150
+ }
151
+
137
152
  /**
138
153
  * Updates the ticket reference for a session.
139
154
  *
@@ -116,7 +116,9 @@ export class TicketExtractor {
116
116
  }
117
117
 
118
118
  for (const part of message.parts) {
119
- if (part.type === "text" && part.text) {
119
+ // Skip synthetic parts (file contents, MCP resources, etc.)
120
+ // These may contain example ticket patterns from docs
121
+ if (part.type === "text" && part.text && !part.synthetic) {
120
122
  const ticket = this.extractFromText(part.text)
121
123
 
122
124
  if (ticket) {
@@ -6,6 +6,8 @@ import type { ResolvedTicketInfo } from "../types/ResolvedTicketInfo"
6
6
  import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
7
7
  import type { TicketExtractor } from "./TicketExtractor"
8
8
 
9
+ import { AgentMatcher } from "../utils/AgentMatcher"
10
+
9
11
  /**
10
12
  * Resolves tickets and account keys using fallback hierarchy.
11
13
  *
@@ -65,11 +67,13 @@ export class TicketResolver {
65
67
  }
66
68
  }
67
69
 
68
- // 2. Try agent default
69
- if (agentName && this.config.agent_defaults?.[agentName]) {
70
+ // 2. Try agent default (tolerant matching: with or without @ prefix)
71
+ const agentKey = agentName ? this.findAgentKey(agentName) : null
72
+
73
+ if (agentKey) {
70
74
  return {
71
- ticket: this.config.agent_defaults[agentName].issue_key,
72
- accountKey: this.resolveAccountKey(agentName),
75
+ ticket: this.config.agent_defaults![agentKey].issue_key,
76
+ accountKey: this.resolveAccountKey(agentKey),
73
77
  }
74
78
  }
75
79
 
@@ -88,16 +92,41 @@ export class TicketResolver {
88
92
  }
89
93
  }
90
94
 
95
+ /**
96
+ * Finds the matching config key for an agent name.
97
+ *
98
+ * @param agentName - The agent name from the SDK
99
+ * @returns The matching config key, or `null` if not found
100
+ *
101
+ * @remarks
102
+ * Normalizes both the agent name and config keys to ensure
103
+ * matching works regardless of @ prefix.
104
+ */
105
+ private findAgentKey(agentName: string): string | null {
106
+ const defaults = this.config.agent_defaults
107
+
108
+ if (!defaults) {
109
+ return null
110
+ }
111
+
112
+ const normalized = AgentMatcher.normalize(agentName)
113
+ const key = Object.keys(defaults).find(
114
+ (k) => AgentMatcher.normalize(k) === normalized
115
+ )
116
+
117
+ return key ?? null
118
+ }
119
+
91
120
  /**
92
121
  * Resolves account key using fallback hierarchy.
93
122
  *
94
- * @param agentName - The agent name, or `null`
123
+ * @param agentKey - The agent config key, or `null`
95
124
  * @returns Resolved Tempo account key
96
125
  */
97
- private resolveAccountKey(agentName: string | null): string {
126
+ private resolveAccountKey(agentKey: string | null): string {
98
127
  // 1. Agent-specific account_key
99
- if (agentName && this.config.agent_defaults?.[agentName]?.account_key) {
100
- return this.config.agent_defaults[agentName].account_key!
128
+ if (agentKey && this.config.agent_defaults?.[agentKey]?.account_key) {
129
+ return this.config.agent_defaults[agentKey].account_key!
101
130
  }
102
131
 
103
132
  // 2. Global default account_key (required)
@@ -29,6 +29,9 @@ export interface CsvEntryData {
29
29
  /** Token consumption statistics */
30
30
  tokenUsage: TokenUsage
31
31
 
32
+ /** Total cost in USD */
33
+ cost: number
34
+
32
35
  /**
33
36
  * Model identifier in format `providerID/modelID`.
34
37
  *
@@ -11,4 +11,14 @@ export interface MessagePart {
11
11
 
12
12
  /** Text content for text parts */
13
13
  text?: string
14
+
15
+ /**
16
+ * Whether this part was synthetically generated by OpenCode.
17
+ *
18
+ * @remarks
19
+ * OpenCode creates synthetic text parts for file contents, MCP resources,
20
+ * and other system-injected content. These should be excluded from
21
+ * ticket extraction as they may contain example patterns from docs.
22
+ */
23
+ synthetic?: boolean
14
24
  }
@@ -16,6 +16,9 @@ export interface MessagePartUpdatedProperties {
16
16
  /** Session ID (present on step-finish and agent parts) */
17
17
  sessionID?: string
18
18
 
19
+ /** Cost in USD (present on step-finish parts) */
20
+ cost?: number
21
+
19
22
  /** Token usage (present on step-finish parts) */
20
23
  tokens?: StepFinishPart["tokens"]
21
24
 
@@ -23,6 +23,9 @@ export interface SessionData {
23
23
  /** Cumulative token usage for the session */
24
24
  tokenUsage: TokenUsage
25
25
 
26
+ /** Cumulative cost in USD for the session */
27
+ cost: number
28
+
26
29
  /** Model used in this session, or `null` if not detected */
27
30
  model: ModelInfo | null
28
31
 
@@ -16,6 +16,9 @@ export interface StepFinishPart {
16
16
  /** The session this part belongs to */
17
17
  sessionID: string
18
18
 
19
+ /** Cost in USD for this step */
20
+ cost: number
21
+
19
22
  /** Token usage for this step */
20
23
  tokens: {
21
24
  /** Input tokens consumed */
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @fileoverview Utility for normalizing agent names.
3
+ */
4
+
5
+ /**
6
+ * Normalizes agent names to a canonical form.
7
+ *
8
+ * @remarks
9
+ * Agent names can appear with or without the "@" prefix
10
+ * depending on the source (SDK, config, user input).
11
+ * This class ensures consistent comparison by normalizing
12
+ * all agent names to the "@<name>" format.
13
+ */
14
+ export class AgentMatcher {
15
+ /**
16
+ * Normalizes an agent name to canonical form (with @ prefix).
17
+ *
18
+ * @param agentName - The agent name to normalize
19
+ * @returns The normalized agent name with @ prefix
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * AgentMatcher.normalize("developer") // → "@developer"
24
+ * AgentMatcher.normalize("@developer") // → "@developer"
25
+ * AgentMatcher.normalize("@time-tracking") // → "@time-tracking"
26
+ * ```
27
+ */
28
+ static normalize(agentName: string): string {
29
+ return agentName.startsWith("@") ? agentName : `@${agentName}`
30
+ }
31
+ }
@@ -54,4 +54,31 @@ export class CsvFormatter {
54
54
  static formatTime(timestamp: number): string {
55
55
  return new Date(timestamp).toTimeString().split(" ")[0]
56
56
  }
57
+
58
+ /**
59
+ * Counts the number of columns in a CSV line.
60
+ *
61
+ * @param csvLine - A single line from a CSV file (with quoted fields)
62
+ * @returns The number of columns
63
+ *
64
+ * @remarks
65
+ * Handles quoted fields correctly by counting occurrences of `","` pattern.
66
+ * Assumes all fields are double-quoted as our CSV writer produces.
67
+ *
68
+ * @example
69
+ * ```typescript
70
+ * CsvFormatter.countColumns('"a","b","c"') // Returns: 3
71
+ * CsvFormatter.countColumns('"single"') // Returns: 1
72
+ * ```
73
+ */
74
+ static countColumns(csvLine: string): number {
75
+ if (!csvLine || csvLine.trim().length === 0) {
76
+ return 0
77
+ }
78
+
79
+ // Count occurrences of "," which separates quoted fields
80
+ // Add 1 because n separators means n+1 fields
81
+ const matches = csvLine.match(/","/g)
82
+ return matches ? matches.length + 1 : 1
83
+ }
57
84
  }