@timefly/opencode-plugin 0.2.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/README.md ADDED
@@ -0,0 +1,244 @@
1
+ # @timefly/opencode-plugin
2
+
3
+ TimeFly telemetry for [OpenCode](https://opencode.ai). Tracks sessions, models, tokens, tool calls, and errors — **not** prompt content.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ # 1. Add plugin to OpenCode config
9
+ bunx @timefly/opencode-plugin setup-opencode -- --target user
10
+
11
+ # 2. Sign in with your TimeFly account (Google OAuth)
12
+ bunx @timefly/opencode-plugin login
13
+
14
+ # 3. Restart OpenCode
15
+ ```
16
+
17
+ You need a **TimeFly Supporter** plan for sync. Free accounts can install the plugin but `POST /ai/sync` returns 403.
18
+
19
+ ## Installation (step by step)
20
+
21
+ ### Prerequisites
22
+
23
+ - [OpenCode](https://opencode.ai) installed and working
24
+ - [Bun](https://bun.sh) (OpenCode uses it internally; you need it for `bunx`)
25
+ - TimeFly account with **Supporter** plan ([pricing](https://timefly.dev/pricing))
26
+
27
+ ### Option A — npm (when published)
28
+
29
+ ```bash
30
+ # Global plugin (all projects on this machine)
31
+ bunx @timefly/opencode-plugin setup-opencode -- --target user
32
+
33
+ # Sign in once
34
+ bunx @timefly/opencode-plugin login
35
+
36
+ # Restart OpenCode
37
+ ```
38
+
39
+ ### Option B — one project only
40
+
41
+ ```bash
42
+ bunx @timefly/opencode-plugin setup-opencode -- --target project --project /path/to/your/repo
43
+ bunx @timefly/opencode-plugin login
44
+ # restart OpenCode in that project
45
+ ```
46
+
47
+ Writes to `./opencode.json` in the project root.
48
+
49
+ ### Option C — local development (monorepo)
50
+
51
+ ```bash
52
+ cd ai-integrations
53
+ bun install
54
+ bun run build
55
+
56
+ bun run --filter @timefly/opencode-plugin setup-opencode -- --target user --local
57
+ bun run --filter @timefly/opencode-plugin login
58
+ # restart OpenCode
59
+ ```
60
+
61
+ `--local` adds an absolute path to `dist/index.js` instead of the npm package name.
62
+
63
+ ### What `setup-opencode` changes
64
+
65
+ It merges into your OpenCode config (does not overwrite other settings):
66
+
67
+ | Target | Config file |
68
+ |--------|-------------|
69
+ | `--target user` | `~/.config/opencode/opencode.json` (Windows: `%USERPROFILE%\.config\opencode\opencode.json`) |
70
+ | `--target project` | `<project>/opencode.json` |
71
+
72
+ Example result:
73
+
74
+ ```json
75
+ {
76
+ "$schema": "https://opencode.ai/config.json",
77
+ "plugin": ["@timefly/opencode-plugin"]
78
+ }
79
+ ```
80
+
81
+ OpenCode loads plugins at startup. **You must restart OpenCode** after install or config changes.
82
+
83
+ ### Verify it is loaded
84
+
85
+ After restart, use OpenCode normally (send a prompt, run a tool). Check:
86
+
87
+ 1. OpenCode logs — warnings if not signed in or not Supporter
88
+ 2. TimeFly dashboard → **AI Usage** card (may take ~10s after backend ingest)
89
+ 3. Local queue file — if sync fails, events appear in `<project>/.timefly-ai-queue.json`
90
+
91
+ ## How auth works
92
+
93
+ OpenCode plugins run inside the OpenCode process. There is no built-in TimeFly UI inside OpenCode — auth is handled outside:
94
+
95
+ | Step | What happens |
96
+ |------|----------------|
97
+ | `login` | Opens browser → Google OAuth → saves tokens to `~/.config/opencode/timefly-auth.json` |
98
+ | Plugin startup | Reads auth file (or `TIMEFLY_ACCESS_TOKEN` env override) |
99
+ | Each sync | Sends `Authorization: Bearer <accessToken>` to `POST /ai/sync` |
100
+ | Token expiry | SDK auto-refreshes using `refreshToken` (~30 days) and updates the auth file |
101
+ | No auth | Events queue locally in `.timefly-ai-queue.json` (project cwd) |
102
+
103
+ Manual env override (not recommended — access tokens expire in ~15 minutes):
104
+
105
+ ```bash
106
+ export TIMEFLY_ACCESS_TOKEN="..."
107
+ export TIMEFLY_API_BASE_URL="https://api.timefly.dev"
108
+ ```
109
+
110
+ ## How sync works
111
+
112
+ Unlike the VS Code extension (which syncs on a timer every ~2 minutes), the OpenCode plugin syncs **on every event** — there is no background interval.
113
+
114
+ ### Timing — when does data leave your machine?
115
+
116
+ | Trigger | What happens |
117
+ |---------|----------------|
118
+ | LLM request (`chat.params`) | Immediate sync attempt |
119
+ | Assistant turn completes | Immediate sync attempt |
120
+ | Tool call / result | Immediate sync attempt |
121
+ | Session start / end | Immediate sync attempt |
122
+ | Next event after failure | Retries queued events first, then sends new ones |
123
+
124
+ **Typical flow during a coding session:**
125
+
126
+ 1. You send a prompt → `llm_request` syncs (~instant)
127
+ 2. Model responds → `turn_complete` + `llm_response` sync (~instant when turn finishes)
128
+ 3. Agent runs tools → each `tool_call` / `tool_result` syncs
129
+ 4. Session goes idle → `session_end` with session totals syncs
130
+
131
+ So sync frequency = **how active OpenCode is**, not a fixed cron. A busy session with 10 tool calls and 3 LLM turns can produce 20+ HTTP requests in a few minutes.
132
+
133
+ ### What each sync request does
134
+
135
+ ```
136
+ 1. flushPendingQueue() ← retry anything in .timefly-ai-queue.json
137
+ 2. gzip JSON batch ← usually 1–3 events per request
138
+ 3. POST /ai/sync ← Bearer token from auth file
139
+ 4. Gateway ← JWT validate + Supporter role check
140
+ 5. Ingest → Redis queue ← accepted immediately (202-style)
141
+ 6. Backend worker (~5s) ← drains Redis → ClickHouse
142
+ 7. Dashboard ← reads aggregated data from ClickHouse
143
+ ```
144
+
145
+ End-to-end latency: **~1–10 seconds** from event to dashboard (network + 5s worker poll).
146
+
147
+ ### Offline queue
148
+
149
+ If sync fails (no network, 401, 403), events append to:
150
+
151
+ ```
152
+ <OpenCode project cwd>/.timefly-ai-queue.json
153
+ ```
154
+
155
+ On the **next successful sync**, the SDK flushes this file first. Nothing is lost unless you delete the file.
156
+
157
+ ### Auth file location
158
+
159
+ ```
160
+ ~/.config/opencode/timefly-auth.json
161
+ ```
162
+
163
+ ```json
164
+ {
165
+ "accessToken": "...",
166
+ "refreshToken": "...",
167
+ "apiBaseUrl": "https://api.timefly.dev",
168
+ "savedAt": "2026-06-20T..."
169
+ }
170
+ ```
171
+
172
+ Access token expires in ~15 minutes; the SDK refreshes automatically using `refreshToken` (~30 days).
173
+
174
+ ### Architecture diagram
175
+
176
+ ```
177
+ OpenCode hooks → @timefly/opencode-plugin → @timefly/ai-sdk
178
+ → POST /ai/sync (gzip JSON, Bearer token)
179
+ → Gateway auth + Supporter check
180
+ → Ingest queue (Redis)
181
+ → Worker (every 5s) → ClickHouse ai_usage_events
182
+ → Dashboard GET /analytics/ai-usage
183
+ ```
184
+
185
+ If sync fails:
186
+
187
+ - **401** — run `login` again
188
+ - **403** — upgrade to Supporter at [timefly.dev/pricing](https://timefly.dev/pricing)
189
+ - **Network** — events stay in local queue and retry on next event
190
+
191
+ Errors are logged via OpenCode's `client.app.log()` (service: `timefly-opencode-plugin`).
192
+
193
+ ## Install options (quick reference)
194
+
195
+ ```bash
196
+ # Global (all projects)
197
+ bunx @timefly/opencode-plugin setup-opencode -- --target user
198
+
199
+ # Single project
200
+ bunx @timefly/opencode-plugin setup-opencode -- --target project --project /path/to/repo
201
+
202
+ # Local dev build
203
+ bun run --filter @timefly/opencode-plugin setup-opencode -- --target user --local
204
+ bun run --filter @timefly/opencode-plugin login
205
+ ```
206
+
207
+ ### Manual config
208
+
209
+ Add to `opencode.json` or `~/.config/opencode/opencode.json`:
210
+
211
+ ```json
212
+ {
213
+ "$schema": "https://opencode.ai/config.json",
214
+ "plugin": ["@timefly/opencode-plugin"]
215
+ }
216
+ ```
217
+
218
+ OpenCode installs npm plugins automatically at startup via Bun.
219
+
220
+ ## Environment
221
+
222
+ | Variable | Required | Default |
223
+ |----------|----------|---------|
224
+ | Auth file from `login` | Recommended | `~/.config/opencode/timefly-auth.json` |
225
+ | `TIMEFLY_ACCESS_TOKEN` | Optional override | — |
226
+ | `TIMEFLY_API_BASE_URL` | No | `https://api.timefly.dev` |
227
+ | `TIMEFLY_OPENCODE_SESSION_ID` | No | OpenCode session ID |
228
+
229
+ ## Events captured
230
+
231
+ | OpenCode signal | TimeFly `eventType` | Data |
232
+ |-----------------|---------------------|------|
233
+ | `session.created` | `session_start` | title, project, directory |
234
+ | `session.idle` | `session_end` | session token/tool/request totals |
235
+ | `chat.params` | `llm_request` | model, provider, agent, temperature |
236
+ | `message.updated` (assistant, completed) | `turn_complete` + `llm_response` | tokens, duration, tokens/s, cost |
237
+ | `message.part.updated` (step-finish) | `llm_response` | per-step tokens |
238
+ | `tool.execute.*` | `tool_call` / `tool_result` | tool name, output length |
239
+ | `session.compacted` / compaction | `compaction` | auto/manual |
240
+ | `session.error` / retry | `error` | error metadata |
241
+
242
+ ## Privacy
243
+
244
+ Metadata and token counts only. No prompts, responses, or file contents. See [docs/PRIVACY.md](../../docs/PRIVACY.md).
@@ -0,0 +1,14 @@
1
+ import { createEventTracker } from './event-tracker.js';
2
+ import { type OpenCodeBusEvent } from './opencode-readers.js';
3
+ import type { createEventPublisher } from './publish-events.js';
4
+ type EventPublisher = ReturnType<typeof createEventPublisher>['publish'];
5
+ export declare const handleSessionCreated: (eventProperties: Record<string, unknown>, publish: EventPublisher) => Promise<void>;
6
+ export declare const handleSessionIdle: (eventProperties: Record<string, unknown>, tracker: ReturnType<typeof createEventTracker>, publish: EventPublisher) => Promise<void>;
7
+ export declare const handleMessageUpdated: (eventProperties: Record<string, unknown>, tracker: ReturnType<typeof createEventTracker>, publish: EventPublisher) => Promise<void>;
8
+ export declare const handleMessagePartUpdated: (eventProperties: Record<string, unknown>, tracker: ReturnType<typeof createEventTracker>, publish: EventPublisher) => Promise<void>;
9
+ export declare const handleSessionCompacted: (eventProperties: Record<string, unknown>, publish: EventPublisher) => Promise<void>;
10
+ export declare const handleSessionError: (eventProperties: Record<string, unknown>, publish: EventPublisher) => Promise<void>;
11
+ export declare const handleCommandExecuted: (eventProperties: Record<string, unknown>, publish: EventPublisher) => Promise<void>;
12
+ export declare const handleBusEvent: (event: OpenCodeBusEvent, tracker: ReturnType<typeof createEventTracker>, publish: EventPublisher) => Promise<void>;
13
+ export {};
14
+ //# sourceMappingURL=event-handlers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-handlers.d.ts","sourceRoot":"","sources":["../src/event-handlers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAiBvD,OAAO,EASN,KAAK,gBAAgB,EACrB,MAAM,uBAAuB,CAAA;AAC9B,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAA;AAG/D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC,SAAS,CAAC,CAAA;AAKxE,eAAO,MAAM,oBAAoB,GAAI,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,cAAc,KAAG,OAAO,CAAC,IAAI,CAapH,CAAA;AAED,eAAO,MAAM,iBAAiB,GAC7B,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,UAAU,CAAC,OAAO,kBAAkB,CAAC,EAC9C,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAad,CAAA;AAED,eAAO,MAAM,oBAAoB,GAChC,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,UAAU,CAAC,OAAO,kBAAkB,CAAC,EAC9C,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAwCd,CAAA;AAED,eAAO,MAAM,wBAAwB,GACpC,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,UAAU,CAAC,OAAO,kBAAkB,CAAC,EAC9C,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAiCd,CAAA;AAED,eAAO,MAAM,sBAAsB,GAClC,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAQd,CAAA;AAED,eAAO,MAAM,kBAAkB,GAC9B,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAmBd,CAAA;AAED,eAAO,MAAM,qBAAqB,GACjC,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAad,CAAA;AAED,eAAO,MAAM,cAAc,GAC1B,OAAO,gBAAgB,EACvB,SAAS,UAAU,CAAC,OAAO,kBAAkB,CAAC,EAC9C,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAyBd,CAAA"}
@@ -0,0 +1,145 @@
1
+ import { mapAssistantLlmResponseInput, mapAssistantTurnCompleteInput, mapCommandExecutedInput, mapCompactionInput, mapCompactionPartInput, mapErrorInput, mapRetryPartInput, mapSessionEndInput, mapSessionStartInput, mapStepFinishInput, mapUserMessageInput } from './map-opencode-event.js';
2
+ import { readAssistantMessage, readCompactionPart, readEventProperties, readRetryPart, readSessionIdFromProperties, readSessionInfo, readStepFinishPart, readUserMessage } from './opencode-readers.js';
3
+ import { sumTokenUsage } from './token-usage.js';
4
+ const isRecord = (value) => typeof value === 'object' && value !== null;
5
+ export const handleSessionCreated = (eventProperties, publish) => {
6
+ const sessionInfo = readSessionInfo(eventProperties.info);
7
+ if (!sessionInfo) {
8
+ return Promise.resolve();
9
+ }
10
+ return publish([
11
+ {
12
+ eventType: 'session_start',
13
+ ...mapSessionStartInput(sessionInfo)
14
+ }
15
+ ]);
16
+ };
17
+ export const handleSessionIdle = (eventProperties, tracker, publish) => {
18
+ const sessionId = readSessionIdFromProperties(eventProperties);
19
+ if (!sessionId) {
20
+ return Promise.resolve();
21
+ }
22
+ return publish([
23
+ {
24
+ eventType: 'session_end',
25
+ ...mapSessionEndInput(sessionId, tracker.getSessionStats(sessionId))
26
+ }
27
+ ]);
28
+ };
29
+ export const handleMessageUpdated = (eventProperties, tracker, publish) => {
30
+ const assistantMessage = readAssistantMessage(eventProperties.info);
31
+ if (assistantMessage) {
32
+ if (assistantMessage.time.completed === undefined || tracker.hasProcessedMessage(assistantMessage.id)) {
33
+ return Promise.resolve();
34
+ }
35
+ tracker.markMessageProcessed(assistantMessage.id);
36
+ const eventsToPublish = [mapAssistantTurnCompleteInput(assistantMessage), mapAssistantLlmResponseInput(assistantMessage)].filter((eventInput) => eventInput !== undefined);
37
+ if (!eventsToPublish.length) {
38
+ return Promise.resolve();
39
+ }
40
+ tracker.recordSessionStats(assistantMessage.sessionID, {
41
+ inputTokens: assistantMessage.tokens.input,
42
+ outputTokens: assistantMessage.tokens.output,
43
+ totalTokens: sumTokenUsage(assistantMessage.tokens),
44
+ turnCount: 1
45
+ });
46
+ return publish(eventsToPublish);
47
+ }
48
+ const userMessage = readUserMessage(eventProperties.info);
49
+ if (userMessage) {
50
+ if (tracker.hasProcessedUserMessage(userMessage.id)) {
51
+ return Promise.resolve();
52
+ }
53
+ tracker.markUserMessageProcessed(userMessage.id);
54
+ return publish([mapUserMessageInput(userMessage)]);
55
+ }
56
+ return Promise.resolve();
57
+ };
58
+ export const handleMessagePartUpdated = (eventProperties, tracker, publish) => {
59
+ const stepFinishPart = readStepFinishPart(eventProperties.part);
60
+ const retryPart = readRetryPart(eventProperties.part);
61
+ const compactionPart = readCompactionPart(eventProperties.part);
62
+ if (stepFinishPart) {
63
+ if (tracker.hasProcessedPart(stepFinishPart.id)) {
64
+ return Promise.resolve();
65
+ }
66
+ tracker.markPartProcessed(stepFinishPart.id);
67
+ return publish([mapStepFinishInput(stepFinishPart)]);
68
+ }
69
+ if (retryPart) {
70
+ if (tracker.hasProcessedPart(retryPart.id)) {
71
+ return Promise.resolve();
72
+ }
73
+ tracker.markPartProcessed(retryPart.id);
74
+ return publish([mapRetryPartInput(retryPart)]);
75
+ }
76
+ if (compactionPart) {
77
+ if (tracker.hasProcessedPart(compactionPart.id)) {
78
+ return Promise.resolve();
79
+ }
80
+ tracker.markPartProcessed(compactionPart.id);
81
+ return publish([mapCompactionPartInput(compactionPart)]);
82
+ }
83
+ return Promise.resolve();
84
+ };
85
+ export const handleSessionCompacted = (eventProperties, publish) => {
86
+ const sessionId = readSessionIdFromProperties(eventProperties);
87
+ if (!sessionId) {
88
+ return Promise.resolve();
89
+ }
90
+ return publish([mapCompactionInput(sessionId)]);
91
+ };
92
+ export const handleSessionError = (eventProperties, publish) => {
93
+ const sessionId = readSessionIdFromProperties(eventProperties);
94
+ const errorRecord = isRecord(eventProperties.error) ? eventProperties.error : undefined;
95
+ const errorName = errorRecord && typeof errorRecord.name === 'string' ? errorRecord.name : 'unknown';
96
+ const errorData = errorRecord && isRecord(errorRecord.data) ? errorRecord.data : undefined;
97
+ const errorMessage = errorData && typeof errorData.message === 'string' ? errorData.message : 'OpenCode session error';
98
+ if (!sessionId) {
99
+ return Promise.resolve();
100
+ }
101
+ return publish([
102
+ mapErrorInput(sessionId, {
103
+ error_name: errorName,
104
+ error_message: errorMessage,
105
+ error_scope: 'session'
106
+ })
107
+ ]);
108
+ };
109
+ export const handleCommandExecuted = (eventProperties, publish) => {
110
+ if (typeof eventProperties.name !== 'string' || typeof eventProperties.sessionID !== 'string') {
111
+ return Promise.resolve();
112
+ }
113
+ return publish([
114
+ mapCommandExecutedInput({
115
+ sessionID: eventProperties.sessionID,
116
+ name: eventProperties.name,
117
+ arguments: typeof eventProperties.arguments === 'string' ? eventProperties.arguments : '',
118
+ messageID: typeof eventProperties.messageID === 'string' ? eventProperties.messageID : 'unknown'
119
+ })
120
+ ]);
121
+ };
122
+ export const handleBusEvent = (event, tracker, publish) => {
123
+ const eventProperties = readEventProperties(event);
124
+ if (!eventProperties || typeof event.type !== 'string') {
125
+ return Promise.resolve();
126
+ }
127
+ switch (event.type) {
128
+ case 'session.created':
129
+ return handleSessionCreated(eventProperties, publish);
130
+ case 'session.idle':
131
+ return handleSessionIdle(eventProperties, tracker, publish);
132
+ case 'message.updated':
133
+ return handleMessageUpdated(eventProperties, tracker, publish);
134
+ case 'message.part.updated':
135
+ return handleMessagePartUpdated(eventProperties, tracker, publish);
136
+ case 'session.compacted':
137
+ return handleSessionCompacted(eventProperties, publish);
138
+ case 'session.error':
139
+ return handleSessionError(eventProperties, publish);
140
+ case 'command.executed':
141
+ return handleCommandExecuted(eventProperties, publish);
142
+ default:
143
+ return Promise.resolve();
144
+ }
145
+ };
@@ -0,0 +1,21 @@
1
+ export type SessionStats = {
2
+ inputTokens: number;
3
+ outputTokens: number;
4
+ totalTokens: number;
5
+ turnCount: number;
6
+ toolCallCount: number;
7
+ requestCount: number;
8
+ stepCount: number;
9
+ };
10
+ export type EventTracker = {
11
+ hasProcessedMessage: (messageId: string) => boolean;
12
+ markMessageProcessed: (messageId: string) => void;
13
+ hasProcessedUserMessage: (messageId: string) => boolean;
14
+ markUserMessageProcessed: (messageId: string) => void;
15
+ hasProcessedPart: (partId: string) => boolean;
16
+ markPartProcessed: (partId: string) => void;
17
+ recordSessionStats: (sessionId: string, delta: Partial<SessionStats>) => void;
18
+ getSessionStats: (sessionId: string) => SessionStats;
19
+ };
20
+ export declare const createEventTracker: () => EventTracker;
21
+ //# sourceMappingURL=event-tracker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-tracker.d.ts","sourceRoot":"","sources":["../src/event-tracker.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG;IAC1B,WAAW,EAAE,MAAM,CAAA;IACnB,YAAY,EAAE,MAAM,CAAA;IACpB,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,aAAa,EAAE,MAAM,CAAA;IACrB,YAAY,EAAE,MAAM,CAAA;IACpB,SAAS,EAAE,MAAM,CAAA;CACjB,CAAA;AAYD,MAAM,MAAM,YAAY,GAAG;IAC1B,mBAAmB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAA;IACnD,oBAAoB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IACjD,uBAAuB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAA;IACvD,wBAAwB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IACrD,gBAAgB,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAA;IAC7C,iBAAiB,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IAC3C,kBAAkB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,CAAA;IAC7E,eAAe,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,YAAY,CAAA;CACpD,CAAA;AAED,eAAO,MAAM,kBAAkB,QAAO,YAiCrC,CAAA"}
@@ -0,0 +1,42 @@
1
+ const emptySessionStats = () => ({
2
+ inputTokens: 0,
3
+ outputTokens: 0,
4
+ totalTokens: 0,
5
+ turnCount: 0,
6
+ toolCallCount: 0,
7
+ requestCount: 0,
8
+ stepCount: 0
9
+ });
10
+ export const createEventTracker = () => {
11
+ const processedMessageIds = new Set();
12
+ const processedUserMessageIds = new Set();
13
+ const processedPartIds = new Set();
14
+ const sessionStatsById = new Map();
15
+ return {
16
+ hasProcessedMessage: (messageId) => processedMessageIds.has(messageId),
17
+ markMessageProcessed: (messageId) => {
18
+ processedMessageIds.add(messageId);
19
+ },
20
+ hasProcessedUserMessage: (messageId) => processedUserMessageIds.has(messageId),
21
+ markUserMessageProcessed: (messageId) => {
22
+ processedUserMessageIds.add(messageId);
23
+ },
24
+ hasProcessedPart: (partId) => processedPartIds.has(partId),
25
+ markPartProcessed: (partId) => {
26
+ processedPartIds.add(partId);
27
+ },
28
+ recordSessionStats: (sessionId, delta) => {
29
+ const currentStats = sessionStatsById.get(sessionId) ?? emptySessionStats();
30
+ sessionStatsById.set(sessionId, {
31
+ inputTokens: currentStats.inputTokens + (delta.inputTokens ?? 0),
32
+ outputTokens: currentStats.outputTokens + (delta.outputTokens ?? 0),
33
+ totalTokens: currentStats.totalTokens + (delta.totalTokens ?? 0),
34
+ turnCount: currentStats.turnCount + (delta.turnCount ?? 0),
35
+ toolCallCount: currentStats.toolCallCount + (delta.toolCallCount ?? 0),
36
+ requestCount: currentStats.requestCount + (delta.requestCount ?? 0),
37
+ stepCount: currentStats.stepCount + (delta.stepCount ?? 0)
38
+ });
39
+ },
40
+ getSessionStats: (sessionId) => sessionStatsById.get(sessionId) ?? emptySessionStats()
41
+ };
42
+ };
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from '@opencode-ai/plugin';
2
+ export declare const timeflyOpenCodePlugin: Plugin;
3
+ export default timeflyOpenCodePlugin;
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAUjD,eAAO,MAAM,qBAAqB,EAAE,MA0CnC,CAAA;AAED,eAAe,qBAAqB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,43 @@
1
+ import { createEventTracker } from './event-tracker.js';
2
+ import { handleBusEvent } from './event-handlers.js';
3
+ import { mapCompactionInput, mapLlmRequestInput, mapToolCallInput, mapToolResultInput } from './map-opencode-event.js';
4
+ import { createEventPublisher } from './publish-events.js';
5
+ import packageJson from '../package.json' with { type: 'json' };
6
+ const PLUGIN_VERSION = packageJson.version;
7
+ export const timeflyOpenCodePlugin = ({ client }) => {
8
+ const tracker = createEventTracker();
9
+ const publisher = createEventPublisher(client, PLUGIN_VERSION);
10
+ return Promise.resolve({
11
+ event: (input) => handleBusEvent(input.event, tracker, publisher.publish),
12
+ 'chat.params': (input, output) => {
13
+ tracker.recordSessionStats(input.sessionID, { requestCount: 1 });
14
+ return publisher.publish([
15
+ mapLlmRequestInput({
16
+ sessionID: input.sessionID,
17
+ agent: input.agent,
18
+ providerId: input.provider.info.id,
19
+ modelId: input.model.id,
20
+ providerSource: input.provider.source,
21
+ temperature: output.temperature,
22
+ topP: output.topP,
23
+ maxOutputTokens: output.maxOutputTokens
24
+ })
25
+ ]);
26
+ },
27
+ 'tool.execute.before': (input) => {
28
+ tracker.recordSessionStats(input.sessionID, { toolCallCount: 1 });
29
+ return publisher.publish([mapToolCallInput(input)]);
30
+ },
31
+ 'tool.execute.after': (input, output) => publisher.publish([
32
+ mapToolResultInput({
33
+ sessionID: input.sessionID,
34
+ tool: input.tool,
35
+ callID: input.callID,
36
+ hasOutput: Boolean(output.output),
37
+ outputLength: output.output.length
38
+ })
39
+ ]),
40
+ 'experimental.session.compacting': (input) => publisher.publish([mapCompactionInput(input.sessionID)])
41
+ });
42
+ };
43
+ export default timeflyOpenCodePlugin;
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ export {};
3
+ //# sourceMappingURL=install.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../src/install.ts"],"names":[],"mappings":""}
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bun
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { resolveDefaultAuthFilePath } from '@timefly/ai-sdk';
6
+ const PLUGIN_PACKAGE_NAME = '@timefly/opencode-plugin';
7
+ const PRICING_URL = 'https://timefly.dev/pricing';
8
+ const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
9
+ const packageRoot = path.resolve(currentDirectory, '..');
10
+ const localPluginPath = path.join(packageRoot, 'dist', 'index.js');
11
+ const parseArguments = (argumentsList) => {
12
+ const targetFlagIndex = argumentsList.findIndex((argument) => argument === '--target');
13
+ const projectFlagIndex = argumentsList.findIndex((argument) => argument === '--project');
14
+ const useLocalPath = argumentsList.includes('--local');
15
+ const targetValue = targetFlagIndex >= 0 ? argumentsList[targetFlagIndex + 1] : 'user';
16
+ const projectValue = projectFlagIndex >= 0 ? argumentsList[projectFlagIndex + 1] : undefined;
17
+ if (targetValue !== 'user' && targetValue !== 'project') {
18
+ throw new Error('Invalid --target. Use "user" or "project".');
19
+ }
20
+ return {
21
+ target: targetValue,
22
+ projectPath: projectValue,
23
+ useLocalPath
24
+ };
25
+ };
26
+ const resolveConfigDestination = (options) => {
27
+ if (options.target === 'project') {
28
+ if (!options.projectPath) {
29
+ throw new Error('--project is required when --target project');
30
+ }
31
+ return path.join(path.resolve(options.projectPath), 'opencode.json');
32
+ }
33
+ const homeDirectory = process.env.HOME ?? process.env.USERPROFILE;
34
+ if (!homeDirectory) {
35
+ throw new Error('Could not resolve home directory for user-level install');
36
+ }
37
+ return path.join(homeDirectory, '.config', 'opencode', 'opencode.json');
38
+ };
39
+ const readConfigFile = (configPath) => readFile(configPath, 'utf8')
40
+ .then((configContent) => JSON.parse(configContent))
41
+ .catch((error) => {
42
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
43
+ return {};
44
+ }
45
+ throw error;
46
+ });
47
+ const resolvePluginEntry = (options) => options.useLocalPath ? localPluginPath.replaceAll('\\', '/') : PLUGIN_PACKAGE_NAME;
48
+ const pluginEntryExists = (pluginList, pluginEntry) => (pluginList ?? []).some((entry) => (Array.isArray(entry) ? entry[0] : entry) === pluginEntry);
49
+ const mergePluginConfig = (config, pluginEntry) => {
50
+ if (pluginEntryExists(config.plugin, pluginEntry)) {
51
+ return config;
52
+ }
53
+ return {
54
+ ...config,
55
+ $schema: config.$schema ?? 'https://opencode.ai/config.json',
56
+ plugin: [...(config.plugin ?? []), pluginEntry]
57
+ };
58
+ };
59
+ const printPostInstallInstructions = (configPath) => {
60
+ const authFilePath = resolveDefaultAuthFilePath();
61
+ console.log('');
62
+ console.log('TimeFly OpenCode plugin installed.');
63
+ console.log(`OpenCode config: ${configPath}`);
64
+ console.log('');
65
+ console.log('Next steps:');
66
+ console.log(' 1. Sign in (stores tokens in ~/.config/opencode/timefly-auth.json):');
67
+ console.log(' bunx @timefly/opencode-plugin login');
68
+ console.log(' 2. You need a TimeFly Supporter plan for sync to work.');
69
+ console.log(` Upgrade: ${PRICING_URL}`);
70
+ console.log(' 3. Restart OpenCode.');
71
+ console.log('');
72
+ console.log('Optional env overrides:');
73
+ console.log(' TIMEFLY_API_BASE_URL=https://api.timefly.dev');
74
+ console.log(' TIMEFLY_ACCESS_TOKEN=... (manual token, expires in ~15 min without refresh file)');
75
+ console.log('');
76
+ console.log(`Auth file: ${authFilePath}`);
77
+ };
78
+ const installPlugin = (options) => {
79
+ const configPath = resolveConfigDestination(options);
80
+ const configDirectory = path.dirname(configPath);
81
+ const pluginEntry = resolvePluginEntry(options);
82
+ return mkdir(configDirectory, { recursive: true })
83
+ .then(() => readConfigFile(configPath))
84
+ .then((config) => mergePluginConfig(config, pluginEntry))
85
+ .then((mergedConfig) => writeFile(configPath, `${JSON.stringify(mergedConfig, null, 2)}\n`, 'utf8'))
86
+ .then(() => {
87
+ printPostInstallInstructions(configPath);
88
+ });
89
+ };
90
+ const run = () => {
91
+ const options = parseArguments(process.argv.slice(2));
92
+ return installPlugin(options);
93
+ };
94
+ run().catch((error) => {
95
+ const message = error instanceof Error ? error.message : String(error);
96
+ console.error(`[timefly-opencode-install] ${message}`);
97
+ process.exit(1);
98
+ });
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ export {};
3
+ //# sourceMappingURL=login.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../src/login.ts"],"names":[],"mappings":""}