opencode-time-tracking 0.1.5

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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @fileoverview CSV writer for exporting time tracking data.
3
+ */
4
+
5
+ import { randomUUID } from "crypto"
6
+ import { mkdir } from "fs/promises"
7
+ import { dirname } from "path"
8
+
9
+ import type { CsvEntryData } from "../types/CsvEntryData"
10
+ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
11
+
12
+ import { CsvFormatter } from "../utils/CsvFormatter"
13
+
14
+ import "../types/Bun"
15
+
16
+ /**
17
+ * CSV header row for the worklog export file.
18
+ * Compatible with Jira/Tempo time tracking import.
19
+ */
20
+ 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"
22
+
23
+ /**
24
+ * Writes time tracking entries to a CSV file.
25
+ *
26
+ * @remarks
27
+ * The CSV format is compatible with Jira/Tempo worklog imports.
28
+ * The file path can be absolute, relative to the project, or use `~/` for home directory.
29
+ */
30
+ export class CsvWriter {
31
+ /** Plugin configuration */
32
+ private config: TimeTrackingConfig
33
+
34
+ /** Project directory path */
35
+ private directory: string
36
+
37
+ /**
38
+ * Creates a new CSV writer instance.
39
+ *
40
+ * @param config - The plugin configuration
41
+ * @param directory - The project directory path
42
+ */
43
+ constructor(config: TimeTrackingConfig, directory: string) {
44
+ this.config = config
45
+ this.directory = directory
46
+ }
47
+
48
+ /**
49
+ * Resolves the CSV file path from configuration.
50
+ *
51
+ * @returns The absolute path to the CSV file
52
+ *
53
+ * @remarks
54
+ * Handles three path formats:
55
+ * - `~/path` - Expands to home directory
56
+ * - `/absolute/path` - Used as-is
57
+ * - `relative/path` - Relative to project directory
58
+ */
59
+ private resolvePath(): string {
60
+ let csvPath = this.config.csv_file
61
+
62
+ if (csvPath.startsWith("~/")) {
63
+ csvPath = csvPath.replace("~", process.env.HOME || "")
64
+ } else if (!csvPath.startsWith("/")) {
65
+ csvPath = `${this.directory}/${csvPath}`
66
+ }
67
+
68
+ return csvPath
69
+ }
70
+
71
+ /**
72
+ * Writes a time tracking entry to the CSV file.
73
+ *
74
+ * @param data - The entry data to write
75
+ *
76
+ * @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.
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * await csvWriter.write({
84
+ * ticket: "PROJ-123",
85
+ * startTime: Date.now() - 3600000,
86
+ * endTime: Date.now(),
87
+ * durationSeconds: 3600,
88
+ * description: "Implemented feature X",
89
+ * notes: "Auto-tracked: read(5x), edit(3x)",
90
+ * tokenUsage: { input: 1000, output: 500, reasoning: 0, cacheRead: 0, cacheWrite: 0 }
91
+ * })
92
+ * ```
93
+ */
94
+ async write(data: CsvEntryData): Promise<void> {
95
+ const csvPath = this.resolvePath()
96
+
97
+ try {
98
+ await mkdir(dirname(csvPath), { recursive: true })
99
+ } catch {
100
+ // Directory may already exist
101
+ }
102
+
103
+ const file = Bun.file(csvPath)
104
+ const exists = await file.exists()
105
+
106
+ const totalTokens =
107
+ data.tokenUsage.input + data.tokenUsage.output + data.tokenUsage.reasoning
108
+
109
+ const fields = [
110
+ randomUUID(),
111
+ CsvFormatter.formatDate(data.startTime),
112
+ CsvFormatter.formatDate(data.endTime),
113
+ this.config.user_email,
114
+ "",
115
+ data.ticket ?? "",
116
+ this.config.default_account_key,
117
+ CsvFormatter.formatTime(data.startTime),
118
+ CsvFormatter.formatTime(data.endTime),
119
+ data.durationSeconds.toString(),
120
+ totalTokens.toString(),
121
+ "",
122
+ "",
123
+ CsvFormatter.escape(data.description),
124
+ CsvFormatter.escape(data.notes),
125
+ ]
126
+
127
+ const csvLine = fields.map((f) => `"${f}"`).join(",")
128
+
129
+ if (!exists) {
130
+ await Bun.write(csvPath, CSV_HEADER + "\n" + csvLine + "\n")
131
+ } else {
132
+ const content = await file.text()
133
+ await Bun.write(csvPath, content + csvLine + "\n")
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @fileoverview Session state management for time tracking.
3
+ */
4
+
5
+ import type { ActivityData } from "../types/ActivityData"
6
+ import type { SessionData } from "../types/SessionData"
7
+ import type { TokenUsage } from "../types/TokenUsage"
8
+
9
+ /**
10
+ * Manages active session state for time tracking.
11
+ *
12
+ * @remarks
13
+ * Each OpenCode session is tracked separately with its own:
14
+ * - Start time
15
+ * - Ticket reference
16
+ * - Tool activities
17
+ * - Token usage statistics
18
+ *
19
+ * Sessions are stored in memory and cleaned up when completed.
20
+ */
21
+ export class SessionManager {
22
+ /** Map of session ID to session data */
23
+ private sessions = new Map<string, SessionData>()
24
+
25
+ /**
26
+ * Retrieves session data by ID.
27
+ *
28
+ * @param sessionID - The OpenCode session identifier
29
+ * @returns The session data, or `undefined` if not found
30
+ */
31
+ get(sessionID: string): SessionData | undefined {
32
+ return this.sessions.get(sessionID)
33
+ }
34
+
35
+ /**
36
+ * Checks if a session exists.
37
+ *
38
+ * @param sessionID - The OpenCode session identifier
39
+ * @returns `true` if the session exists, `false` otherwise
40
+ */
41
+ has(sessionID: string): boolean {
42
+ return this.sessions.has(sessionID)
43
+ }
44
+
45
+ /**
46
+ * Creates a new session.
47
+ *
48
+ * @param sessionID - The OpenCode session identifier
49
+ * @param ticket - Optional Jira ticket reference (e.g., "PROJ-123")
50
+ * @returns The newly created session data
51
+ */
52
+ create(sessionID: string, ticket: string | null): SessionData {
53
+ const session: SessionData = {
54
+ ticket,
55
+ startTime: Date.now(),
56
+ activities: [],
57
+ tokenUsage: {
58
+ input: 0,
59
+ output: 0,
60
+ reasoning: 0,
61
+ cacheRead: 0,
62
+ cacheWrite: 0,
63
+ },
64
+ }
65
+
66
+ this.sessions.set(sessionID, session)
67
+
68
+ return session
69
+ }
70
+
71
+ /**
72
+ * Deletes a session.
73
+ *
74
+ * @param sessionID - The OpenCode session identifier
75
+ */
76
+ delete(sessionID: string): void {
77
+ this.sessions.delete(sessionID)
78
+ }
79
+
80
+ /**
81
+ * Adds a tool activity to a session.
82
+ *
83
+ * @param sessionID - The OpenCode session identifier
84
+ * @param activity - The activity data to add
85
+ */
86
+ addActivity(sessionID: string, activity: ActivityData): void {
87
+ const session = this.sessions.get(sessionID)
88
+
89
+ if (session) {
90
+ session.activities.push(activity)
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Adds token usage to a session's cumulative totals.
96
+ *
97
+ * @param sessionID - The OpenCode session identifier
98
+ * @param tokens - The token usage to add
99
+ */
100
+ addTokenUsage(sessionID: string, tokens: TokenUsage): void {
101
+ const session = this.sessions.get(sessionID)
102
+
103
+ if (session) {
104
+ session.tokenUsage.input += tokens.input
105
+ session.tokenUsage.output += tokens.output
106
+ session.tokenUsage.reasoning += tokens.reasoning
107
+ session.tokenUsage.cacheRead += tokens.cacheRead
108
+ session.tokenUsage.cacheWrite += tokens.cacheWrite
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Updates the ticket reference for a session.
114
+ *
115
+ * @param sessionID - The OpenCode session identifier
116
+ * @param ticket - The new ticket reference, or `null` to keep existing
117
+ *
118
+ * @remarks
119
+ * Only updates if a non-null ticket is provided.
120
+ * This allows the ticket to be updated when found in later messages.
121
+ */
122
+ updateTicket(sessionID: string, ticket: string | null): void {
123
+ const session = this.sessions.get(sessionID)
124
+
125
+ if (session && ticket) {
126
+ session.ticket = ticket
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @fileoverview Extracts Jira ticket references from session context.
3
+ */
4
+
5
+ import type { MessageWithParts } from "../types/MessageWithParts"
6
+ import type { OpencodeClient } from "../types/OpencodeClient"
7
+ import type { Todo } from "../types/Todo"
8
+
9
+ /**
10
+ * Regular expression pattern for Jira ticket references.
11
+ * Matches patterns like "PROJ-123", "ABC-1", "FEATURE-9999".
12
+ */
13
+ const TICKET_PATTERN = /([A-Z]+-\d+)/
14
+
15
+ /**
16
+ * Extracts Jira ticket references from user messages and todos.
17
+ *
18
+ * @remarks
19
+ * Scans the session context for ticket patterns in this priority:
20
+ * 1. User messages (newest first)
21
+ * 2. Todo items (newest first)
22
+ *
23
+ * Returns the first match found, allowing tickets to be updated
24
+ * when mentioned in later messages.
25
+ */
26
+ export class TicketExtractor {
27
+ /** OpenCode SDK client */
28
+ private client: OpencodeClient
29
+
30
+ /**
31
+ * Creates a new ticket extractor instance.
32
+ *
33
+ * @param client - The OpenCode SDK client
34
+ */
35
+ constructor(client: OpencodeClient) {
36
+ this.client = client
37
+ }
38
+
39
+ /**
40
+ * Extracts a ticket reference from the session context.
41
+ *
42
+ * @param sessionID - The OpenCode session identifier
43
+ * @returns The ticket reference (e.g., "PROJ-123"), or `null` if not found
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * const ticket = await ticketExtractor.extract("session-123")
48
+ * // Returns "PROJ-456" if user mentioned it in a message
49
+ * ```
50
+ */
51
+ async extract(sessionID: string): Promise<string | null> {
52
+ const ticketFromMessages = await this.extractFromMessages(sessionID)
53
+
54
+ if (ticketFromMessages) {
55
+ return ticketFromMessages
56
+ }
57
+
58
+ const ticketFromTodos = await this.extractFromTodos(sessionID)
59
+
60
+ return ticketFromTodos
61
+ }
62
+
63
+ /**
64
+ * Extracts a ticket from user messages.
65
+ *
66
+ * @param sessionID - The OpenCode session identifier
67
+ * @returns The ticket reference, or `null` if not found
68
+ */
69
+ private async extractFromMessages(
70
+ sessionID: string
71
+ ): Promise<string | null> {
72
+ try {
73
+ const result = await this.client.session.messages({
74
+ path: { id: sessionID },
75
+ } as Parameters<typeof this.client.session.messages>[0])
76
+
77
+ if (!result.data) {
78
+ return null
79
+ }
80
+
81
+ const messages = result.data as MessageWithParts[]
82
+
83
+ // Scan user messages for ticket pattern (newest first)
84
+ for (let i = messages.length - 1; i >= 0; i--) {
85
+ const message = messages[i]
86
+
87
+ if (message.info.role !== "user") {
88
+ continue
89
+ }
90
+
91
+ for (const part of message.parts) {
92
+ if (part.type === "text" && part.text) {
93
+ const ticket = this.extractFromText(part.text)
94
+
95
+ if (ticket) {
96
+ return ticket
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ return null
103
+ } catch {
104
+ return null
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Extracts a ticket from todo items.
110
+ *
111
+ * @param sessionID - The OpenCode session identifier
112
+ * @returns The ticket reference, or `null` if not found
113
+ */
114
+ private async extractFromTodos(sessionID: string): Promise<string | null> {
115
+ try {
116
+ const result = await this.client.session.todo({
117
+ path: { id: sessionID },
118
+ } as Parameters<typeof this.client.session.todo>[0])
119
+
120
+ if (!result.data) {
121
+ return null
122
+ }
123
+
124
+ const todos = result.data as Todo[]
125
+
126
+ // Scan todos for ticket pattern (newest first)
127
+ for (let i = todos.length - 1; i >= 0; i--) {
128
+ const todo = todos[i]
129
+
130
+ if (todo.content) {
131
+ const ticket = this.extractFromText(todo.content)
132
+
133
+ if (ticket) {
134
+ return ticket
135
+ }
136
+ }
137
+ }
138
+
139
+ return null
140
+ } catch {
141
+ return null
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Extracts a ticket pattern from text.
147
+ *
148
+ * @param text - The text to search
149
+ * @returns The first ticket match, or `null` if not found
150
+ */
151
+ private extractFromText(text: string): string | null {
152
+ const match = text.match(TICKET_PATTERN)
153
+
154
+ return match?.[1] ?? null
155
+ }
156
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @fileoverview Activity data type for tracking tool executions.
3
+ */
4
+
5
+ /**
6
+ * Represents a single tool activity within a session.
7
+ */
8
+ export interface ActivityData {
9
+ /** The name of the tool that was executed (e.g., "read", "edit", "bash") */
10
+ tool: string
11
+
12
+ /** Unix timestamp in milliseconds when the activity occurred */
13
+ timestamp: number
14
+
15
+ /** Optional file path associated with the activity */
16
+ file?: string
17
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @fileoverview Global Bun type declarations.
3
+ *
4
+ * @remarks
5
+ * This file declares the Bun global object for TypeScript.
6
+ * The actual Bun runtime is provided by OpenCode at plugin load time.
7
+ */
8
+
9
+ declare global {
10
+ /** Bun runtime file system API */
11
+ const Bun: {
12
+ /**
13
+ * Creates a file handle for the given path.
14
+ *
15
+ * @param path - The file path
16
+ * @returns A file handle object
17
+ */
18
+ file(path: string): {
19
+ /** Checks if the file exists */
20
+ exists(): Promise<boolean>
21
+
22
+ /** Parses the file as JSON */
23
+ json(): Promise<unknown>
24
+
25
+ /** Reads the file as text */
26
+ text(): Promise<string>
27
+ }
28
+
29
+ /**
30
+ * Writes content to a file.
31
+ *
32
+ * @param path - The file path
33
+ * @param content - The content to write
34
+ * @returns The number of bytes written
35
+ */
36
+ write(path: string, content: string): Promise<number>
37
+ }
38
+ }
39
+
40
+ export {}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @fileoverview CSV entry data type for worklog exports.
3
+ */
4
+
5
+ import type { TokenUsage } from "./TokenUsage"
6
+
7
+ /**
8
+ * Data structure for a single CSV worklog entry.
9
+ */
10
+ export interface CsvEntryData {
11
+ /** Jira ticket reference (e.g., "PROJ-123"), or `null` if not found */
12
+ ticket: string | null
13
+
14
+ /** Session start time as Unix timestamp in milliseconds */
15
+ startTime: number
16
+
17
+ /** Session end time as Unix timestamp in milliseconds */
18
+ endTime: number
19
+
20
+ /** Duration of the session in seconds */
21
+ durationSeconds: number
22
+
23
+ /** Human-readable description of the work performed */
24
+ description: string
25
+
26
+ /** Additional notes (e.g., tool usage summary) */
27
+ notes: string
28
+
29
+ /** Token consumption statistics */
30
+ tokenUsage: TokenUsage
31
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @fileoverview Message info type from OpenCode SDK.
3
+ */
4
+
5
+ import type { MessageSummary } from "./MessageSummary"
6
+
7
+ /**
8
+ * Information about a message in the conversation.
9
+ */
10
+ export interface MessageInfo {
11
+ /** The role of the message sender */
12
+ role: "user" | "assistant"
13
+
14
+ /** Optional summary generated by the LLM */
15
+ summary?: MessageSummary
16
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @fileoverview Message part type from OpenCode SDK.
3
+ */
4
+
5
+ /**
6
+ * A part of a message (text, file, tool call, etc.).
7
+ */
8
+ export interface MessagePart {
9
+ /** The type of the part (e.g., "text", "tool", "file") */
10
+ type: string
11
+
12
+ /** Text content for text parts */
13
+ text?: string
14
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @fileoverview Properties for message.part.updated events.
3
+ */
4
+
5
+ import type { StepFinishPart } from "./StepFinishPart"
6
+
7
+ /**
8
+ * Properties received with message.part.updated events.
9
+ */
10
+ export interface MessagePartUpdatedProperties {
11
+ /** The updated message part */
12
+ part: {
13
+ /** The type of the part */
14
+ type: string
15
+
16
+ /** Session ID (present on step-finish parts) */
17
+ sessionID?: string
18
+
19
+ /** Token usage (present on step-finish parts) */
20
+ tokens?: StepFinishPart["tokens"]
21
+ }
22
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @fileoverview Message summary type from OpenCode SDK.
3
+ */
4
+
5
+ /**
6
+ * LLM-generated summary of a message's changes.
7
+ */
8
+ export interface MessageSummary {
9
+ /** Short title describing the changes */
10
+ title?: string
11
+
12
+ /** Longer description of the changes */
13
+ body?: string
14
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @fileoverview Message with parts type from OpenCode SDK.
3
+ */
4
+
5
+ import type { MessageInfo } from "./MessageInfo"
6
+ import type { MessagePart } from "./MessagePart"
7
+
8
+ /**
9
+ * A message with its associated parts.
10
+ */
11
+ export interface MessageWithParts {
12
+ /** Message metadata */
13
+ info: MessageInfo
14
+
15
+ /** Array of message parts (text, tools, files, etc.) */
16
+ parts: MessagePart[]
17
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @fileoverview OpenCode SDK client type.
3
+ */
4
+
5
+ import type { createOpencodeClient } from "@opencode-ai/sdk"
6
+
7
+ /**
8
+ * The OpenCode SDK client type.
9
+ *
10
+ * @remarks
11
+ * Derived from the return type of `createOpencodeClient`.
12
+ * Provides access to session, TUI, and other OpenCode APIs.
13
+ */
14
+ export type OpencodeClient = ReturnType<typeof createOpencodeClient>
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @fileoverview Session data type for time tracking state.
3
+ */
4
+
5
+ import type { ActivityData } from "./ActivityData"
6
+ import type { TokenUsage } from "./TokenUsage"
7
+
8
+ /**
9
+ * State data for a tracked session.
10
+ */
11
+ export interface SessionData {
12
+ /** Jira ticket reference, or `null` if not found */
13
+ ticket: string | null
14
+
15
+ /** Session start time as Unix timestamp in milliseconds */
16
+ startTime: number
17
+
18
+ /** Array of tool activities recorded during the session */
19
+ activities: ActivityData[]
20
+
21
+ /** Cumulative token usage for the session */
22
+ tokenUsage: TokenUsage
23
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @fileoverview Step finish part type from OpenCode SDK.
3
+ */
4
+
5
+ /**
6
+ * A step-finish message part containing token usage.
7
+ *
8
+ * @remarks
9
+ * Emitted when a model completes a reasoning step.
10
+ * Contains detailed token consumption statistics.
11
+ */
12
+ export interface StepFinishPart {
13
+ /** Part type identifier */
14
+ type: "step-finish"
15
+
16
+ /** The session this part belongs to */
17
+ sessionID: string
18
+
19
+ /** Token usage for this step */
20
+ tokens: {
21
+ /** Input tokens consumed */
22
+ input: number
23
+
24
+ /** Output tokens generated */
25
+ output: number
26
+
27
+ /** Reasoning tokens used (for o1-style models) */
28
+ reasoning: number
29
+
30
+ /** Cache statistics */
31
+ cache: {
32
+ /** Tokens read from cache */
33
+ read: number
34
+
35
+ /** Tokens written to cache */
36
+ write: number
37
+ }
38
+ }
39
+ }