@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 +244 -0
- package/dist/event-handlers.d.ts +14 -0
- package/dist/event-handlers.d.ts.map +1 -0
- package/dist/event-handlers.js +145 -0
- package/dist/event-tracker.d.ts +21 -0
- package/dist/event-tracker.d.ts.map +1 -0
- package/dist/event-tracker.js +42 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/install.d.ts +3 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +98 -0
- package/dist/login.d.ts +3 -0
- package/dist/login.d.ts.map +1 -0
- package/dist/login.js +157 -0
- package/dist/map-opencode-event.d.ts +53 -0
- package/dist/map-opencode-event.d.ts.map +1 -0
- package/dist/map-opencode-event.js +179 -0
- package/dist/opencode-readers.d.ts +71 -0
- package/dist/opencode-readers.d.ts.map +1 -0
- package/dist/opencode-readers.js +150 -0
- package/dist/publish-events.d.ts +8 -0
- package/dist/publish-events.d.ts.map +1 -0
- package/dist/publish-events.js +45 -0
- package/dist/token-usage.d.ts +23 -0
- package/dist/token-usage.d.ts.map +1 -0
- package/dist/token-usage.js +28 -0
- package/package.json +73 -0
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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../src/install.ts"],"names":[],"mappings":""}
|
package/dist/install.js
ADDED
|
@@ -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
|
+
});
|
package/dist/login.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../src/login.ts"],"names":[],"mappings":""}
|