@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 +25 -0
- package/package.json +1 -1
- package/src/Plugin.ts +3 -0
- package/src/hooks/EventHook.ts +11 -2
- package/src/services/CsvWriter.ts +136 -12
- package/src/services/SessionManager.ts +15 -0
- package/src/services/TicketExtractor.ts +3 -1
- package/src/services/TicketResolver.ts +37 -8
- package/src/types/CsvEntryData.ts +3 -0
- package/src/types/MessagePart.ts +10 -0
- package/src/types/MessagePartUpdatedProperties.ts +3 -0
- package/src/types/SessionData.ts +3 -0
- package/src/types/StepFinishPart.ts +3 -0
- package/src/utils/AgentMatcher.ts +31 -0
- package/src/utils/CsvFormatter.ts +27 -0
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
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,
|
package/src/hooks/EventHook.ts
CHANGED
|
@@ -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
|
|
202
|
+
const normalizedAgent = agentString
|
|
203
|
+
? AgentMatcher.normalize(agentString)
|
|
204
|
+
: null
|
|
197
205
|
const isIgnoredAgent = config.ignored_agents?.some(
|
|
198
|
-
(ignored) =>
|
|
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
|
-
*
|
|
78
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
72
|
-
accountKey: this.resolveAccountKey(
|
|
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
|
|
123
|
+
* @param agentKey - The agent config key, or `null`
|
|
95
124
|
* @returns Resolved Tempo account key
|
|
96
125
|
*/
|
|
97
|
-
private resolveAccountKey(
|
|
126
|
+
private resolveAccountKey(agentKey: string | null): string {
|
|
98
127
|
// 1. Agent-specific account_key
|
|
99
|
-
if (
|
|
100
|
-
return this.config.agent_defaults[
|
|
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)
|
package/src/types/MessagePart.ts
CHANGED
|
@@ -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
|
|
package/src/types/SessionData.ts
CHANGED
|
@@ -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
|
|
|
@@ -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
|
}
|