@vibescore/tracker 0.2.1 → 0.2.3
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 +1 -0
- package/README.zh-CN.md +1 -0
- package/package.json +1 -1
- package/src/commands/init.js +41 -1
- package/src/commands/status.js +18 -0
- package/src/commands/sync.js +39 -3
- package/src/commands/uninstall.js +44 -0
- package/src/lib/browser-auth.js +50 -15
- package/src/lib/diagnostics.js +22 -2
- package/src/lib/gemini-config.js +284 -0
- package/src/lib/opencode-config.js +98 -0
- package/src/lib/rollout.js +163 -1
package/README.md
CHANGED
|
@@ -64,6 +64,7 @@ npx --yes @vibescore/tracker init
|
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
Note: If `~/.code/config.toml` exists (or `CODE_HOME`), `init` also configures Every Code `notify` automatically. No further user intervention is required for data sync.
|
|
67
|
+
Note: If Gemini CLI home exists, `init` installs a `SessionEnd` hook in `~/.gemini/settings.json` and sets `tools.enableHooks = true` so hooks execute. This enables all Gemini hooks; disable by setting `tools.enableHooks = false` (or disabling the `vibescore-tracker` hook).
|
|
67
68
|
|
|
68
69
|
### Sync & Status
|
|
69
70
|
|
package/README.zh-CN.md
CHANGED
|
@@ -64,6 +64,7 @@ npx --yes @vibescore/tracker init
|
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
说明:若存在 `~/.code/config.toml`(或 `CODE_HOME`),`init` 会自动配置 Every Code 的 `notify`。配置完成后,数据同步完全自动化,无需后续人工干预。
|
|
67
|
+
说明:若检测到 Gemini CLI home,`init` 会在 `~/.gemini/settings.json` 安装 `SessionEnd` hook,并将 `tools.enableHooks = true` 以确保 hook 生效。这会启用所有 Gemini hooks;如需关闭,可将 `tools.enableHooks = false`(或禁用 `vibescore-tracker` hook)。
|
|
67
68
|
|
|
68
69
|
### 同步与状态查看
|
|
69
70
|
|
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -14,6 +14,13 @@ const {
|
|
|
14
14
|
loadEveryCodeNotifyOriginal
|
|
15
15
|
} = require('../lib/codex-config');
|
|
16
16
|
const { upsertClaudeHook, buildClaudeHookCommand } = require('../lib/claude-config');
|
|
17
|
+
const {
|
|
18
|
+
resolveGeminiConfigDir,
|
|
19
|
+
resolveGeminiSettingsPath,
|
|
20
|
+
buildGeminiHookCommand,
|
|
21
|
+
upsertGeminiHook
|
|
22
|
+
} = require('../lib/gemini-config');
|
|
23
|
+
const { resolveOpencodeConfigDir, upsertOpencodePlugin } = require('../lib/opencode-config');
|
|
17
24
|
const { beginBrowserAuth } = require('../lib/browser-auth');
|
|
18
25
|
const {
|
|
19
26
|
issueDeviceTokenWithPassword,
|
|
@@ -162,6 +169,24 @@ async function cmdInit(argv) {
|
|
|
162
169
|
});
|
|
163
170
|
}
|
|
164
171
|
|
|
172
|
+
const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
|
|
173
|
+
const geminiConfigExists = await isDir(geminiConfigDir);
|
|
174
|
+
const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
|
|
175
|
+
let geminiResult = null;
|
|
176
|
+
if (geminiConfigExists) {
|
|
177
|
+
const geminiHookCommand = buildGeminiHookCommand(notifyPath);
|
|
178
|
+
geminiResult = await upsertGeminiHook({
|
|
179
|
+
settingsPath: geminiSettingsPath,
|
|
180
|
+
hookCommand: geminiHookCommand
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
|
|
185
|
+
const opencodeResult = await upsertOpencodePlugin({
|
|
186
|
+
configDir: opencodeConfigDir,
|
|
187
|
+
notifyPath
|
|
188
|
+
});
|
|
189
|
+
|
|
165
190
|
process.stdout.write(
|
|
166
191
|
[
|
|
167
192
|
'Installed:',
|
|
@@ -186,6 +211,16 @@ async function cmdInit(argv) {
|
|
|
186
211
|
? `- Claude hooks: updated (${claudeSettingsPath})`
|
|
187
212
|
: `- Claude hooks: already set (${claudeSettingsPath})`
|
|
188
213
|
: '- Claude hooks: skipped (~/.claude not found)',
|
|
214
|
+
geminiConfigExists
|
|
215
|
+
? geminiResult?.changed
|
|
216
|
+
? `- Gemini hooks: updated (${geminiSettingsPath})`
|
|
217
|
+
: `- Gemini hooks: already set (${geminiSettingsPath})`
|
|
218
|
+
: `- Gemini hooks: skipped (${geminiConfigDir} not found)`,
|
|
219
|
+
opencodeResult?.skippedReason === 'config-missing'
|
|
220
|
+
? '- Opencode plugin: skipped (config dir missing)'
|
|
221
|
+
: opencodeResult?.changed
|
|
222
|
+
? `- Opencode plugin: updated (${opencodeConfigDir})`
|
|
223
|
+
: `- Opencode plugin: already set (${opencodeConfigDir})`,
|
|
189
224
|
deviceToken ? `- Device token: stored (${maskSecret(deviceToken)})` : '- Device token: not configured (set VIBESCORE_DEVICE_TOKEN and re-run init)',
|
|
190
225
|
''
|
|
191
226
|
].join('\n')
|
|
@@ -306,7 +341,12 @@ try {
|
|
|
306
341
|
|
|
307
342
|
// Chain the original notify if present (Codex/Every Code only).
|
|
308
343
|
try {
|
|
309
|
-
const originalPath =
|
|
344
|
+
const originalPath =
|
|
345
|
+
source === 'every-code'
|
|
346
|
+
? codeOriginalPath
|
|
347
|
+
: source === 'claude' || source === 'opencode' || source === 'gemini'
|
|
348
|
+
? null
|
|
349
|
+
: codexOriginalPath;
|
|
310
350
|
if (originalPath) {
|
|
311
351
|
const original = JSON.parse(fs.readFileSync(originalPath, 'utf8'));
|
|
312
352
|
const cmd = Array.isArray(original?.notify) ? original.notify : null;
|
package/src/commands/status.js
CHANGED
|
@@ -5,6 +5,13 @@ const fs = require('node:fs/promises');
|
|
|
5
5
|
const { readJson } = require('../lib/fs');
|
|
6
6
|
const { readCodexNotify, readEveryCodeNotify } = require('../lib/codex-config');
|
|
7
7
|
const { isClaudeHookConfigured, buildClaudeHookCommand } = require('../lib/claude-config');
|
|
8
|
+
const {
|
|
9
|
+
resolveGeminiConfigDir,
|
|
10
|
+
resolveGeminiSettingsPath,
|
|
11
|
+
isGeminiHookConfigured,
|
|
12
|
+
buildGeminiHookCommand
|
|
13
|
+
} = require('../lib/gemini-config');
|
|
14
|
+
const { resolveOpencodeConfigDir, isOpencodePluginInstalled } = require('../lib/opencode-config');
|
|
8
15
|
const { normalizeState: normalizeUploadState } = require('../lib/upload-throttle');
|
|
9
16
|
const { collectTrackerDiagnostics } = require('../lib/diagnostics');
|
|
10
17
|
|
|
@@ -31,7 +38,11 @@ async function cmdStatus(argv = []) {
|
|
|
31
38
|
const codeHome = process.env.CODE_HOME || path.join(home, '.code');
|
|
32
39
|
const codeConfigPath = path.join(codeHome, 'config.toml');
|
|
33
40
|
const claudeSettingsPath = path.join(home, '.claude', 'settings.json');
|
|
41
|
+
const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
|
|
42
|
+
const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
|
|
43
|
+
const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
|
|
34
44
|
const claudeHookCommand = buildClaudeHookCommand(path.join(home, '.vibescore', 'bin', 'notify.cjs'));
|
|
45
|
+
const geminiHookCommand = buildGeminiHookCommand(path.join(home, '.vibescore', 'bin', 'notify.cjs'));
|
|
35
46
|
|
|
36
47
|
const config = await readJson(configPath);
|
|
37
48
|
const cursors = await readJson(cursorsPath);
|
|
@@ -53,6 +64,11 @@ async function cmdStatus(argv = []) {
|
|
|
53
64
|
settingsPath: claudeSettingsPath,
|
|
54
65
|
hookCommand: claudeHookCommand
|
|
55
66
|
});
|
|
67
|
+
const geminiHookConfigured = await isGeminiHookConfigured({
|
|
68
|
+
settingsPath: geminiSettingsPath,
|
|
69
|
+
hookCommand: geminiHookCommand
|
|
70
|
+
});
|
|
71
|
+
const opencodePluginConfigured = await isOpencodePluginInstalled({ configDir: opencodeConfigDir });
|
|
56
72
|
|
|
57
73
|
const lastUpload = uploadThrottle.lastSuccessMs
|
|
58
74
|
? parseEpochMsToIso(uploadThrottle.lastSuccessMs)
|
|
@@ -88,6 +104,8 @@ async function cmdStatus(argv = []) {
|
|
|
88
104
|
`- Codex notify: ${notifyConfigured ? JSON.stringify(codexNotify) : 'unset'}`,
|
|
89
105
|
`- Every Code notify: ${everyCodeConfigured ? JSON.stringify(everyCodeNotify) : 'unset'}`,
|
|
90
106
|
`- Claude hooks: ${claudeHookConfigured ? 'set' : 'unset'}`,
|
|
107
|
+
`- Gemini hooks: ${geminiHookConfigured ? 'set' : 'unset'}`,
|
|
108
|
+
`- Opencode plugin: ${opencodePluginConfigured ? 'set' : 'unset'}`,
|
|
91
109
|
''
|
|
92
110
|
]
|
|
93
111
|
.filter(Boolean)
|
package/src/commands/sync.js
CHANGED
|
@@ -8,9 +8,11 @@ const {
|
|
|
8
8
|
listRolloutFiles,
|
|
9
9
|
listClaudeProjectFiles,
|
|
10
10
|
listGeminiSessionFiles,
|
|
11
|
+
listOpencodeMessageFiles,
|
|
11
12
|
parseRolloutIncremental,
|
|
12
13
|
parseClaudeIncremental,
|
|
13
|
-
parseGeminiIncremental
|
|
14
|
+
parseGeminiIncremental,
|
|
15
|
+
parseOpencodeIncremental
|
|
14
16
|
} = require('../lib/rollout');
|
|
15
17
|
const { drainQueueToCloud } = require('../lib/uploader');
|
|
16
18
|
const { createProgress, renderBar, formatNumber, formatBytes } = require('../lib/progress');
|
|
@@ -54,6 +56,9 @@ async function cmdSync(argv) {
|
|
|
54
56
|
const claudeProjectsDir = path.join(home, '.claude', 'projects');
|
|
55
57
|
const geminiHome = process.env.GEMINI_HOME || path.join(home, '.gemini');
|
|
56
58
|
const geminiTmpDir = path.join(geminiHome, 'tmp');
|
|
59
|
+
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
|
|
60
|
+
const opencodeHome = process.env.OPENCODE_HOME || path.join(xdgDataHome, 'opencode');
|
|
61
|
+
const opencodeStorageDir = path.join(opencodeHome, 'storage');
|
|
57
62
|
|
|
58
63
|
const sources = [
|
|
59
64
|
{ source: 'codex', sessionsDir: path.join(codexHome, 'sessions') },
|
|
@@ -136,6 +141,29 @@ async function cmdSync(argv) {
|
|
|
136
141
|
});
|
|
137
142
|
}
|
|
138
143
|
|
|
144
|
+
const opencodeFiles = await listOpencodeMessageFiles(opencodeStorageDir);
|
|
145
|
+
let opencodeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
146
|
+
if (opencodeFiles.length > 0) {
|
|
147
|
+
if (progress?.enabled) {
|
|
148
|
+
progress.start(`Parsing Opencode ${renderBar(0)} 0/${formatNumber(opencodeFiles.length)} files | buckets 0`);
|
|
149
|
+
}
|
|
150
|
+
opencodeResult = await parseOpencodeIncremental({
|
|
151
|
+
messageFiles: opencodeFiles,
|
|
152
|
+
cursors,
|
|
153
|
+
queuePath,
|
|
154
|
+
onProgress: (p) => {
|
|
155
|
+
if (!progress?.enabled) return;
|
|
156
|
+
const pct = p.total > 0 ? p.index / p.total : 1;
|
|
157
|
+
progress.update(
|
|
158
|
+
`Parsing Opencode ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
|
|
159
|
+
p.total
|
|
160
|
+
)} files | buckets ${formatNumber(p.bucketsQueued)}`
|
|
161
|
+
);
|
|
162
|
+
},
|
|
163
|
+
source: 'opencode'
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
139
167
|
cursors.updatedAt = new Date().toISOString();
|
|
140
168
|
await writeJson(cursorsPath, cursors);
|
|
141
169
|
|
|
@@ -261,8 +289,16 @@ async function cmdSync(argv) {
|
|
|
261
289
|
});
|
|
262
290
|
|
|
263
291
|
if (!opts.auto) {
|
|
264
|
-
const totalParsed =
|
|
265
|
-
|
|
292
|
+
const totalParsed =
|
|
293
|
+
parseResult.filesProcessed +
|
|
294
|
+
claudeResult.filesProcessed +
|
|
295
|
+
geminiResult.filesProcessed +
|
|
296
|
+
opencodeResult.filesProcessed;
|
|
297
|
+
const totalBuckets =
|
|
298
|
+
parseResult.bucketsQueued +
|
|
299
|
+
claudeResult.bucketsQueued +
|
|
300
|
+
geminiResult.bucketsQueued +
|
|
301
|
+
opencodeResult.bucketsQueued;
|
|
266
302
|
process.stdout.write(
|
|
267
303
|
[
|
|
268
304
|
'Sync finished:',
|
|
@@ -4,6 +4,13 @@ const fs = require('node:fs/promises');
|
|
|
4
4
|
|
|
5
5
|
const { restoreCodexNotify, restoreEveryCodeNotify } = require('../lib/codex-config');
|
|
6
6
|
const { removeClaudeHook, buildClaudeHookCommand } = require('../lib/claude-config');
|
|
7
|
+
const {
|
|
8
|
+
resolveGeminiConfigDir,
|
|
9
|
+
resolveGeminiSettingsPath,
|
|
10
|
+
buildGeminiHookCommand,
|
|
11
|
+
removeGeminiHook
|
|
12
|
+
} = require('../lib/gemini-config');
|
|
13
|
+
const { resolveOpencodeConfigDir, removeOpencodePlugin } = require('../lib/opencode-config');
|
|
7
14
|
|
|
8
15
|
async function cmdUninstall(argv) {
|
|
9
16
|
const opts = parseArgs(argv);
|
|
@@ -14,16 +21,22 @@ async function cmdUninstall(argv) {
|
|
|
14
21
|
const codeHome = process.env.CODE_HOME || path.join(home, '.code');
|
|
15
22
|
const codeConfigPath = path.join(codeHome, 'config.toml');
|
|
16
23
|
const claudeSettingsPath = path.join(home, '.claude', 'settings.json');
|
|
24
|
+
const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
|
|
25
|
+
const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
|
|
26
|
+
const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
|
|
17
27
|
const notifyPath = path.join(binDir, 'notify.cjs');
|
|
18
28
|
const notifyOriginalPath = path.join(trackerDir, 'codex_notify_original.json');
|
|
19
29
|
const codeNotifyOriginalPath = path.join(trackerDir, 'code_notify_original.json');
|
|
20
30
|
const codexNotifyCmd = ['/usr/bin/env', 'node', notifyPath];
|
|
21
31
|
const codeNotifyCmd = ['/usr/bin/env', 'node', notifyPath, '--source=every-code'];
|
|
22
32
|
const claudeHookCommand = buildClaudeHookCommand(notifyPath);
|
|
33
|
+
const geminiHookCommand = buildGeminiHookCommand(notifyPath);
|
|
23
34
|
|
|
24
35
|
const codexConfigExists = await isFile(codexConfigPath);
|
|
25
36
|
const codeConfigExists = await isFile(codeConfigPath);
|
|
26
37
|
const claudeConfigExists = await isFile(claudeSettingsPath);
|
|
38
|
+
const geminiConfigExists = await isDir(geminiConfigDir);
|
|
39
|
+
const opencodeConfigExists = await isDir(opencodeConfigDir);
|
|
27
40
|
const codexRestore = codexConfigExists
|
|
28
41
|
? await restoreCodexNotify({
|
|
29
42
|
codexConfigPath,
|
|
@@ -41,6 +54,12 @@ async function cmdUninstall(argv) {
|
|
|
41
54
|
const claudeRemove = claudeConfigExists
|
|
42
55
|
? await removeClaudeHook({ settingsPath: claudeSettingsPath, hookCommand: claudeHookCommand })
|
|
43
56
|
: { removed: false, skippedReason: 'config-missing' };
|
|
57
|
+
const geminiRemove = geminiConfigExists
|
|
58
|
+
? await removeGeminiHook({ settingsPath: geminiSettingsPath, hookCommand: geminiHookCommand })
|
|
59
|
+
: { removed: false, skippedReason: 'config-missing' };
|
|
60
|
+
const opencodeRemove = opencodeConfigExists
|
|
61
|
+
? await removeOpencodePlugin({ configDir: opencodeConfigDir })
|
|
62
|
+
: { removed: false, skippedReason: 'config-missing' };
|
|
44
63
|
|
|
45
64
|
// Remove installed notify handler.
|
|
46
65
|
await fs.unlink(notifyPath).catch(() => {});
|
|
@@ -76,6 +95,22 @@ async function cmdUninstall(argv) {
|
|
|
76
95
|
? '- Claude hooks: no change'
|
|
77
96
|
: '- Claude hooks: skipped'
|
|
78
97
|
: '- Claude hooks: skipped (settings.json not found)',
|
|
98
|
+
geminiConfigExists
|
|
99
|
+
? geminiRemove?.removed
|
|
100
|
+
? `- Gemini hooks removed: ${geminiSettingsPath}`
|
|
101
|
+
: geminiRemove?.skippedReason === 'hook-missing'
|
|
102
|
+
? '- Gemini hooks: no change'
|
|
103
|
+
: '- Gemini hooks: skipped'
|
|
104
|
+
: `- Gemini hooks: skipped (${geminiConfigDir} not found)`,
|
|
105
|
+
opencodeConfigExists
|
|
106
|
+
? opencodeRemove?.removed
|
|
107
|
+
? `- Opencode plugin removed: ${opencodeConfigDir}`
|
|
108
|
+
: opencodeRemove?.skippedReason === 'plugin-missing'
|
|
109
|
+
? '- Opencode plugin: no change'
|
|
110
|
+
: opencodeRemove?.skippedReason === 'unexpected-content'
|
|
111
|
+
? '- Opencode plugin: skipped (unexpected content)'
|
|
112
|
+
: '- Opencode plugin: skipped'
|
|
113
|
+
: `- Opencode plugin: skipped (${opencodeConfigDir} not found)`,
|
|
79
114
|
opts.purge ? `- Purged: ${path.join(home, '.vibescore')}` : '- Purge: skipped (use --purge)',
|
|
80
115
|
''
|
|
81
116
|
].join('\n')
|
|
@@ -102,3 +137,12 @@ async function isFile(p) {
|
|
|
102
137
|
return false;
|
|
103
138
|
}
|
|
104
139
|
}
|
|
140
|
+
|
|
141
|
+
async function isDir(p) {
|
|
142
|
+
try {
|
|
143
|
+
const st = await fs.stat(p);
|
|
144
|
+
return st.isDirectory();
|
|
145
|
+
} catch (_e) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/lib/browser-auth.js
CHANGED
|
@@ -7,10 +7,13 @@ const DEFAULT_BASE_URL = 'https://5tmappuk.us-east.insforge.app';
|
|
|
7
7
|
async function beginBrowserAuth({ baseUrl, dashboardUrl, timeoutMs, open }) {
|
|
8
8
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
9
9
|
const callbackPath = `/vibescore/callback/${nonce}`;
|
|
10
|
-
|
|
11
|
-
const { callbackUrl, waitForCallback } = await startLocalCallbackServer({ callbackPath, timeoutMs });
|
|
12
|
-
|
|
13
10
|
const authUrl = dashboardUrl ? new URL('/', dashboardUrl) : new URL('/auth/sign-up', baseUrl);
|
|
11
|
+
const postAuthRedirect = resolvePostAuthRedirect({ dashboardUrl, authUrl });
|
|
12
|
+
const { callbackUrl, waitForCallback } = await startLocalCallbackServer({
|
|
13
|
+
callbackPath,
|
|
14
|
+
timeoutMs,
|
|
15
|
+
redirectUrl: postAuthRedirect
|
|
16
|
+
});
|
|
14
17
|
authUrl.searchParams.set('redirect', callbackUrl);
|
|
15
18
|
if (dashboardUrl && baseUrl && baseUrl !== DEFAULT_BASE_URL) authUrl.searchParams.set('base_url', baseUrl);
|
|
16
19
|
|
|
@@ -19,7 +22,7 @@ async function beginBrowserAuth({ baseUrl, dashboardUrl, timeoutMs, open }) {
|
|
|
19
22
|
return { authUrl: authUrl.toString(), waitForCallback };
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
async function startLocalCallbackServer({ callbackPath, timeoutMs }) {
|
|
25
|
+
async function startLocalCallbackServer({ callbackPath, timeoutMs, redirectUrl }) {
|
|
23
26
|
let resolved = false;
|
|
24
27
|
let resolveResult;
|
|
25
28
|
let rejectResult;
|
|
@@ -58,17 +61,34 @@ async function startLocalCallbackServer({ callbackPath, timeoutMs }) {
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
resolved = true;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
'
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
if (redirectUrl) {
|
|
65
|
+
res.writeHead(302, {
|
|
66
|
+
Location: redirectUrl,
|
|
67
|
+
'Content-Type': 'text/html; charset=utf-8'
|
|
68
|
+
});
|
|
69
|
+
res.end(
|
|
70
|
+
[
|
|
71
|
+
'<!doctype html>',
|
|
72
|
+
'<html><head><meta charset="utf-8"><title>VibeScore</title></head>',
|
|
73
|
+
'<body>',
|
|
74
|
+
'<h2>Login succeeded</h2>',
|
|
75
|
+
`<p>Redirecting to <a href="${redirectUrl}">dashboard</a>...</p>`,
|
|
76
|
+
'</body></html>'
|
|
77
|
+
].join('')
|
|
78
|
+
);
|
|
79
|
+
} else {
|
|
80
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
81
|
+
res.end(
|
|
82
|
+
[
|
|
83
|
+
'<!doctype html>',
|
|
84
|
+
'<html><head><meta charset="utf-8"><title>VibeScore</title></head>',
|
|
85
|
+
'<body>',
|
|
86
|
+
'<h2>Login succeeded</h2>',
|
|
87
|
+
'<p>You can close this tab and return to the CLI.</p>',
|
|
88
|
+
'</body></html>'
|
|
89
|
+
].join('')
|
|
90
|
+
);
|
|
91
|
+
}
|
|
72
92
|
|
|
73
93
|
resolveResult({
|
|
74
94
|
accessToken,
|
|
@@ -134,6 +154,21 @@ function openInBrowser(url) {
|
|
|
134
154
|
} catch (_e) {}
|
|
135
155
|
}
|
|
136
156
|
|
|
157
|
+
function resolvePostAuthRedirect({ dashboardUrl, authUrl }) {
|
|
158
|
+
try {
|
|
159
|
+
if (dashboardUrl) {
|
|
160
|
+
const target = new URL('/', dashboardUrl);
|
|
161
|
+
if (target.protocol === 'http:' || target.protocol === 'https:') {
|
|
162
|
+
return target.toString();
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
} catch (_e) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
137
172
|
module.exports = {
|
|
138
173
|
beginBrowserAuth
|
|
139
174
|
};
|
package/src/lib/diagnostics.js
CHANGED
|
@@ -5,6 +5,13 @@ const fs = require('node:fs/promises');
|
|
|
5
5
|
const { readJson } = require('./fs');
|
|
6
6
|
const { readCodexNotify, readEveryCodeNotify } = require('./codex-config');
|
|
7
7
|
const { isClaudeHookConfigured, buildClaudeHookCommand } = require('./claude-config');
|
|
8
|
+
const {
|
|
9
|
+
resolveGeminiConfigDir,
|
|
10
|
+
resolveGeminiSettingsPath,
|
|
11
|
+
buildGeminiHookCommand,
|
|
12
|
+
isGeminiHookConfigured
|
|
13
|
+
} = require('./gemini-config');
|
|
14
|
+
const { resolveOpencodeConfigDir, isOpencodePluginInstalled } = require('./opencode-config');
|
|
8
15
|
const { normalizeState: normalizeUploadState } = require('./upload-throttle');
|
|
9
16
|
|
|
10
17
|
async function collectTrackerDiagnostics({
|
|
@@ -24,6 +31,9 @@ async function collectTrackerDiagnostics({
|
|
|
24
31
|
const codexConfigPath = path.join(codexHome, 'config.toml');
|
|
25
32
|
const codeConfigPath = path.join(codeHome, 'config.toml');
|
|
26
33
|
const claudeConfigPath = path.join(home, '.claude', 'settings.json');
|
|
34
|
+
const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
|
|
35
|
+
const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
|
|
36
|
+
const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
|
|
27
37
|
|
|
28
38
|
const config = await readJson(configPath);
|
|
29
39
|
const cursors = await readJson(cursorsPath);
|
|
@@ -49,6 +59,12 @@ async function collectTrackerDiagnostics({
|
|
|
49
59
|
settingsPath: claudeConfigPath,
|
|
50
60
|
hookCommand: claudeHookCommand
|
|
51
61
|
});
|
|
62
|
+
const geminiHookCommand = buildGeminiHookCommand(path.join(home, '.vibescore', 'bin', 'notify.cjs'));
|
|
63
|
+
const geminiHookConfigured = await isGeminiHookConfigured({
|
|
64
|
+
settingsPath: geminiSettingsPath,
|
|
65
|
+
hookCommand: geminiHookCommand
|
|
66
|
+
});
|
|
67
|
+
const opencodePluginConfigured = await isOpencodePluginInstalled({ configDir: opencodeConfigDir });
|
|
52
68
|
|
|
53
69
|
const lastSuccessAt = uploadThrottle.lastSuccessMs ? new Date(uploadThrottle.lastSuccessMs).toISOString() : null;
|
|
54
70
|
const autoRetryAt = parseEpochMsToIso(autoRetry?.retryAtMs);
|
|
@@ -68,7 +84,9 @@ async function collectTrackerDiagnostics({
|
|
|
68
84
|
codex_config: redactValue(codexConfigPath, home),
|
|
69
85
|
code_home: redactValue(codeHome, home),
|
|
70
86
|
code_config: redactValue(codeConfigPath, home),
|
|
71
|
-
claude_config: redactValue(claudeConfigPath, home)
|
|
87
|
+
claude_config: redactValue(claudeConfigPath, home),
|
|
88
|
+
gemini_config: redactValue(geminiSettingsPath, home),
|
|
89
|
+
opencode_config: redactValue(opencodeConfigDir, home)
|
|
72
90
|
},
|
|
73
91
|
config: {
|
|
74
92
|
base_url: typeof config?.baseUrl === 'string' ? config.baseUrl : null,
|
|
@@ -93,7 +111,9 @@ async function collectTrackerDiagnostics({
|
|
|
93
111
|
codex_notify: codexNotify,
|
|
94
112
|
every_code_notify_configured: everyCodeConfigured,
|
|
95
113
|
every_code_notify: everyCodeNotify,
|
|
96
|
-
claude_hook_configured: claudeHookConfigured
|
|
114
|
+
claude_hook_configured: claudeHookConfigured,
|
|
115
|
+
gemini_hook_configured: geminiHookConfigured,
|
|
116
|
+
opencode_plugin_configured: opencodePluginConfigured
|
|
97
117
|
},
|
|
98
118
|
upload: {
|
|
99
119
|
last_success_at: lastSuccessAt,
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
const os = require('node:os');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
|
|
5
|
+
const { ensureDir, readJson, writeJson } = require('./fs');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_EVENT = 'SessionEnd';
|
|
8
|
+
const DEFAULT_HOOK_NAME = 'vibescore-tracker';
|
|
9
|
+
const DEFAULT_MATCHER = 'exit|clear|logout|prompt_input_exit|other';
|
|
10
|
+
|
|
11
|
+
function resolveGeminiConfigDir({ home = os.homedir(), env = process.env } = {}) {
|
|
12
|
+
const explicit = typeof env.GEMINI_HOME === 'string' ? env.GEMINI_HOME.trim() : '';
|
|
13
|
+
if (explicit) return path.resolve(explicit);
|
|
14
|
+
return path.join(home, '.gemini');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolveGeminiSettingsPath({ configDir }) {
|
|
18
|
+
return path.join(configDir, 'settings.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function upsertGeminiHook({
|
|
22
|
+
settingsPath,
|
|
23
|
+
hookCommand,
|
|
24
|
+
hookName = DEFAULT_HOOK_NAME,
|
|
25
|
+
matcher = DEFAULT_MATCHER,
|
|
26
|
+
event = DEFAULT_EVENT
|
|
27
|
+
}) {
|
|
28
|
+
const existing = await readJson(settingsPath);
|
|
29
|
+
const settings = normalizeSettings(existing);
|
|
30
|
+
const enableResult = ensureHooksEnabled(settings);
|
|
31
|
+
const baseSettings = enableResult.settings;
|
|
32
|
+
const hooks = normalizeHooks(settings.hooks);
|
|
33
|
+
const entries = normalizeEntries(hooks[event]);
|
|
34
|
+
|
|
35
|
+
const normalized = normalizeEntriesForHook(entries, { hookCommand, hookName });
|
|
36
|
+
let nextEntries = normalized.entries;
|
|
37
|
+
let changed = normalized.changed || enableResult.changed;
|
|
38
|
+
|
|
39
|
+
if (!hasHook(nextEntries, { hookCommand, hookName })) {
|
|
40
|
+
nextEntries = nextEntries.concat([buildHookEntry({ hookCommand, hookName, matcher })]);
|
|
41
|
+
changed = true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!changed) return { changed: false, backupPath: null };
|
|
45
|
+
|
|
46
|
+
const nextHooks = { ...hooks, [event]: nextEntries };
|
|
47
|
+
const nextSettings = { ...baseSettings, hooks: nextHooks };
|
|
48
|
+
const backupPath = await writeGeminiSettings({ settingsPath, settings: nextSettings });
|
|
49
|
+
return { changed: true, backupPath };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function removeGeminiHook({
|
|
53
|
+
settingsPath,
|
|
54
|
+
hookCommand,
|
|
55
|
+
hookName = DEFAULT_HOOK_NAME,
|
|
56
|
+
event = DEFAULT_EVENT
|
|
57
|
+
}) {
|
|
58
|
+
const existing = await readJson(settingsPath);
|
|
59
|
+
if (!existing) return { removed: false, skippedReason: 'settings-missing' };
|
|
60
|
+
|
|
61
|
+
const settings = normalizeSettings(existing);
|
|
62
|
+
const hooks = normalizeHooks(settings.hooks);
|
|
63
|
+
const entries = normalizeEntries(hooks[event]);
|
|
64
|
+
if (entries.length === 0) return { removed: false, skippedReason: 'hook-missing' };
|
|
65
|
+
|
|
66
|
+
let removed = false;
|
|
67
|
+
const nextEntries = [];
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const res = stripHookFromEntry(entry, { hookCommand, hookName });
|
|
70
|
+
if (res.removed) removed = true;
|
|
71
|
+
if (res.entry) nextEntries.push(res.entry);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!removed) return { removed: false, skippedReason: 'hook-missing' };
|
|
75
|
+
|
|
76
|
+
const nextHooks = { ...hooks };
|
|
77
|
+
if (nextEntries.length > 0) nextHooks[event] = nextEntries;
|
|
78
|
+
else delete nextHooks[event];
|
|
79
|
+
|
|
80
|
+
const nextSettings = { ...settings };
|
|
81
|
+
if (Object.keys(nextHooks).length > 0) nextSettings.hooks = nextHooks;
|
|
82
|
+
else delete nextSettings.hooks;
|
|
83
|
+
|
|
84
|
+
const backupPath = await writeGeminiSettings({ settingsPath, settings: nextSettings });
|
|
85
|
+
return { removed: true, skippedReason: null, backupPath };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function isGeminiHookConfigured({
|
|
89
|
+
settingsPath,
|
|
90
|
+
hookCommand,
|
|
91
|
+
hookName = DEFAULT_HOOK_NAME,
|
|
92
|
+
event = DEFAULT_EVENT
|
|
93
|
+
}) {
|
|
94
|
+
const settings = await readJson(settingsPath);
|
|
95
|
+
if (!settings || typeof settings !== 'object') return false;
|
|
96
|
+
const hooks = settings.hooks;
|
|
97
|
+
if (!hooks || typeof hooks !== 'object') return false;
|
|
98
|
+
const entries = normalizeEntries(hooks[event]);
|
|
99
|
+
return hasHook(entries, { hookCommand, hookName });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildGeminiHookCommand(notifyPath) {
|
|
103
|
+
const cmd = typeof notifyPath === 'string' ? notifyPath : '';
|
|
104
|
+
return `/usr/bin/env node ${quoteArg(cmd)} --source=gemini`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildHookEntry({ hookCommand, hookName, matcher }) {
|
|
108
|
+
const hook = {
|
|
109
|
+
name: hookName,
|
|
110
|
+
type: 'command',
|
|
111
|
+
command: hookCommand
|
|
112
|
+
};
|
|
113
|
+
const entry = { hooks: [hook] };
|
|
114
|
+
if (typeof matcher === 'string' && matcher.length > 0) entry.matcher = matcher;
|
|
115
|
+
return entry;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeSettings(raw) {
|
|
119
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeHooks(raw) {
|
|
123
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeTools(raw) {
|
|
127
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizeEntries(raw) {
|
|
131
|
+
return Array.isArray(raw) ? raw.slice() : [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeCommand(cmd) {
|
|
135
|
+
if (Array.isArray(cmd)) return cmd.map((v) => String(v)).join('\u0000');
|
|
136
|
+
if (typeof cmd === 'string') return cmd.trim();
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function normalizeName(name) {
|
|
141
|
+
if (typeof name !== 'string') return null;
|
|
142
|
+
const trimmed = name.trim();
|
|
143
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function ensureHooksEnabled(settings) {
|
|
147
|
+
const tools = normalizeTools(settings.tools);
|
|
148
|
+
if (tools.enableHooks === true) return { settings, changed: false };
|
|
149
|
+
const nextTools = { ...tools, enableHooks: true };
|
|
150
|
+
return { settings: { ...settings, tools: nextTools }, changed: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function hookMatches(hook, { hookCommand, hookName, requireCommand = false }) {
|
|
154
|
+
if (!hook || typeof hook !== 'object') return false;
|
|
155
|
+
const name = normalizeName(hook.name);
|
|
156
|
+
const targetName = normalizeName(hookName);
|
|
157
|
+
const cmd = normalizeCommand(hook.command);
|
|
158
|
+
const targetCmd = normalizeCommand(hookCommand);
|
|
159
|
+
const commandMatches = Boolean(cmd && targetCmd && cmd === targetCmd);
|
|
160
|
+
if (requireCommand) return commandMatches;
|
|
161
|
+
const nameMatches = Boolean(name && targetName && name === targetName);
|
|
162
|
+
return Boolean(commandMatches || nameMatches);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function entryMatches(entry, { hookCommand, hookName, requireCommand = false }) {
|
|
166
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
167
|
+
if (entry.command || entry.name) return hookMatches(entry, { hookCommand, hookName, requireCommand });
|
|
168
|
+
if (!Array.isArray(entry.hooks)) return false;
|
|
169
|
+
return entry.hooks.some((hook) => hookMatches(hook, { hookCommand, hookName, requireCommand }));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function hasHook(entries, { hookCommand, hookName }) {
|
|
173
|
+
return entries.some((entry) => entryMatches(entry, { hookCommand, hookName }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeEntriesForHook(entries, { hookCommand, hookName }) {
|
|
177
|
+
let changed = false;
|
|
178
|
+
const nextEntries = entries.map((entry) => {
|
|
179
|
+
if (!entry || typeof entry !== 'object') return entry;
|
|
180
|
+
|
|
181
|
+
if (entry.command || entry.name) {
|
|
182
|
+
if (hookMatches(entry, { hookCommand, hookName })) {
|
|
183
|
+
const next = normalizeHookObject(entry, { hookCommand, hookName });
|
|
184
|
+
if (next !== entry) changed = true;
|
|
185
|
+
return next;
|
|
186
|
+
}
|
|
187
|
+
return entry;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : null;
|
|
191
|
+
if (!hooks) return entry;
|
|
192
|
+
|
|
193
|
+
let hooksChanged = false;
|
|
194
|
+
const nextHooks = hooks.map((hook) => {
|
|
195
|
+
if (hookMatches(hook, { hookCommand, hookName })) {
|
|
196
|
+
const next = normalizeHookObject(hook, { hookCommand, hookName });
|
|
197
|
+
if (next !== hook) hooksChanged = true;
|
|
198
|
+
return next;
|
|
199
|
+
}
|
|
200
|
+
return hook;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (!hooksChanged) return entry;
|
|
204
|
+
changed = true;
|
|
205
|
+
return { ...entry, hooks: nextHooks };
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return { entries: nextEntries, changed };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeHookObject(hook, { hookCommand, hookName }) {
|
|
212
|
+
const next = { ...hook };
|
|
213
|
+
let changed = false;
|
|
214
|
+
|
|
215
|
+
if (next.type !== 'command') {
|
|
216
|
+
next.type = 'command';
|
|
217
|
+
changed = true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (hookCommand && next.command !== hookCommand) {
|
|
221
|
+
next.command = hookCommand;
|
|
222
|
+
changed = true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (hookName && next.name !== hookName) {
|
|
226
|
+
next.name = hookName;
|
|
227
|
+
changed = true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return changed ? next : hook;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function stripHookFromEntry(entry, { hookCommand, hookName }) {
|
|
234
|
+
if (!entry || typeof entry !== 'object') return { entry, removed: false };
|
|
235
|
+
|
|
236
|
+
if (entry.command || entry.name) {
|
|
237
|
+
if (hookMatches(entry, { hookCommand, hookName, requireCommand: true })) return { entry: null, removed: true };
|
|
238
|
+
return { entry, removed: false };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : null;
|
|
242
|
+
if (!hooks) return { entry, removed: false };
|
|
243
|
+
|
|
244
|
+
const nextHooks = hooks.filter((hook) => !hookMatches(hook, { hookCommand, hookName, requireCommand: true }));
|
|
245
|
+
if (nextHooks.length === hooks.length) return { entry, removed: false };
|
|
246
|
+
if (nextHooks.length === 0) return { entry: null, removed: true };
|
|
247
|
+
|
|
248
|
+
return { entry: { ...entry, hooks: nextHooks }, removed: true };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function quoteArg(value) {
|
|
252
|
+
const v = typeof value === 'string' ? value : '';
|
|
253
|
+
if (!v) return '""';
|
|
254
|
+
if (/^[A-Za-z0-9_\-./:@]+$/.test(v)) return v;
|
|
255
|
+
return `"${v.replace(/"/g, '\\"')}"`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function writeGeminiSettings({ settingsPath, settings }) {
|
|
259
|
+
await ensureDir(path.dirname(settingsPath));
|
|
260
|
+
let backupPath = null;
|
|
261
|
+
try {
|
|
262
|
+
const st = await fs.stat(settingsPath);
|
|
263
|
+
if (st && st.isFile()) {
|
|
264
|
+
backupPath = `${settingsPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
265
|
+
await fs.copyFile(settingsPath, backupPath);
|
|
266
|
+
}
|
|
267
|
+
} catch (_e) {
|
|
268
|
+
// Ignore missing file.
|
|
269
|
+
}
|
|
270
|
+
await writeJson(settingsPath, settings);
|
|
271
|
+
return backupPath;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = {
|
|
275
|
+
DEFAULT_EVENT,
|
|
276
|
+
DEFAULT_HOOK_NAME,
|
|
277
|
+
DEFAULT_MATCHER,
|
|
278
|
+
resolveGeminiConfigDir,
|
|
279
|
+
resolveGeminiSettingsPath,
|
|
280
|
+
buildGeminiHookCommand,
|
|
281
|
+
upsertGeminiHook,
|
|
282
|
+
removeGeminiHook,
|
|
283
|
+
isGeminiHookConfigured
|
|
284
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const os = require('node:os');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
|
|
5
|
+
const { ensureDir } = require('./fs');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_PLUGIN_NAME = 'vibescore-tracker.js';
|
|
8
|
+
const PLUGIN_MARKER = 'VIBESCORE_TRACKER_PLUGIN';
|
|
9
|
+
const DEFAULT_EVENT = 'session.idle';
|
|
10
|
+
|
|
11
|
+
function resolveOpencodeConfigDir({ home = os.homedir(), env = process.env } = {}) {
|
|
12
|
+
const explicit = typeof env.OPENCODE_CONFIG_DIR === 'string' ? env.OPENCODE_CONFIG_DIR.trim() : '';
|
|
13
|
+
if (explicit) return path.resolve(explicit);
|
|
14
|
+
const xdg = typeof env.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
|
|
15
|
+
const base = xdg || path.join(home, '.config');
|
|
16
|
+
return path.join(base, 'opencode');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveOpencodePluginDir({ configDir }) {
|
|
20
|
+
return path.join(configDir, 'plugin');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildOpencodePlugin({ notifyPath }) {
|
|
24
|
+
const safeNotifyPath = typeof notifyPath === 'string' ? notifyPath : '';
|
|
25
|
+
return `// ${PLUGIN_MARKER}\n` +
|
|
26
|
+
`const notifyPath = ${JSON.stringify(safeNotifyPath)};\n` +
|
|
27
|
+
`export const VibeScorePlugin = async ({ $ }) => {\n` +
|
|
28
|
+
` return {\n` +
|
|
29
|
+
` event: async ({ event }) => {\n` +
|
|
30
|
+
` if (!event || event.type !== ${JSON.stringify(DEFAULT_EVENT)}) return;\n` +
|
|
31
|
+
` try {\n` +
|
|
32
|
+
` if (!notifyPath) return;\n` +
|
|
33
|
+
` const proc = $\`/usr/bin/env node ${'${notifyPath}'} --source=opencode\`;\n` +
|
|
34
|
+
` if (proc && typeof proc.catch === 'function') proc.catch(() => {});\n` +
|
|
35
|
+
` } catch (_) {}\n` +
|
|
36
|
+
` }\n` +
|
|
37
|
+
` };\n` +
|
|
38
|
+
`};\n`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function upsertOpencodePlugin({
|
|
42
|
+
configDir,
|
|
43
|
+
notifyPath,
|
|
44
|
+
pluginName = DEFAULT_PLUGIN_NAME
|
|
45
|
+
}) {
|
|
46
|
+
if (!configDir) return { changed: false, pluginPath: null, skippedReason: 'config-missing' };
|
|
47
|
+
const pluginDir = resolveOpencodePluginDir({ configDir });
|
|
48
|
+
const pluginPath = path.join(pluginDir, pluginName);
|
|
49
|
+
const next = buildOpencodePlugin({ notifyPath });
|
|
50
|
+
const existing = await fs.readFile(pluginPath, 'utf8').catch(() => null);
|
|
51
|
+
|
|
52
|
+
if (existing === next) {
|
|
53
|
+
return { changed: false, pluginPath, skippedReason: null };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await ensureDir(pluginDir);
|
|
57
|
+
|
|
58
|
+
let backupPath = null;
|
|
59
|
+
if (existing != null) {
|
|
60
|
+
backupPath = `${pluginPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
61
|
+
await fs.copyFile(pluginPath, backupPath).catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await fs.writeFile(pluginPath, next, 'utf8');
|
|
65
|
+
return { changed: true, pluginPath, backupPath, skippedReason: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function removeOpencodePlugin({ configDir, pluginName = DEFAULT_PLUGIN_NAME }) {
|
|
69
|
+
if (!configDir) return { removed: false, skippedReason: 'config-missing' };
|
|
70
|
+
const pluginPath = path.join(resolveOpencodePluginDir({ configDir }), pluginName);
|
|
71
|
+
const existing = await fs.readFile(pluginPath, 'utf8').catch(() => null);
|
|
72
|
+
if (existing == null) return { removed: false, skippedReason: 'plugin-missing' };
|
|
73
|
+
if (!hasPluginMarker(existing)) return { removed: false, skippedReason: 'unexpected-content' };
|
|
74
|
+
await fs.unlink(pluginPath).catch(() => {});
|
|
75
|
+
return { removed: true, skippedReason: null };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function isOpencodePluginInstalled({ configDir, pluginName = DEFAULT_PLUGIN_NAME }) {
|
|
79
|
+
if (!configDir) return false;
|
|
80
|
+
const pluginPath = path.join(resolveOpencodePluginDir({ configDir }), pluginName);
|
|
81
|
+
const existing = await fs.readFile(pluginPath, 'utf8').catch(() => null);
|
|
82
|
+
if (!existing) return false;
|
|
83
|
+
return hasPluginMarker(existing);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasPluginMarker(text) {
|
|
87
|
+
return typeof text === 'string' && text.includes(PLUGIN_MARKER);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
DEFAULT_PLUGIN_NAME,
|
|
92
|
+
resolveOpencodeConfigDir,
|
|
93
|
+
resolveOpencodePluginDir,
|
|
94
|
+
buildOpencodePlugin,
|
|
95
|
+
upsertOpencodePlugin,
|
|
96
|
+
removeOpencodePlugin,
|
|
97
|
+
isOpencodePluginInstalled
|
|
98
|
+
};
|
package/src/lib/rollout.js
CHANGED
|
@@ -61,6 +61,14 @@ async function listGeminiSessionFiles(tmpDir) {
|
|
|
61
61
|
return out;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
async function listOpencodeMessageFiles(storageDir) {
|
|
65
|
+
const out = [];
|
|
66
|
+
const messageDir = path.join(storageDir, 'message');
|
|
67
|
+
await walkOpencodeMessages(messageDir, out);
|
|
68
|
+
out.sort((a, b) => a.localeCompare(b));
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
64
72
|
async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onProgress, source }) {
|
|
65
73
|
await ensureDir(path.dirname(queuePath));
|
|
66
74
|
let filesProcessed = 0;
|
|
@@ -270,6 +278,91 @@ async function parseGeminiIncremental({ sessionFiles, cursors, queuePath, onProg
|
|
|
270
278
|
return { filesProcessed, eventsAggregated, bucketsQueued };
|
|
271
279
|
}
|
|
272
280
|
|
|
281
|
+
async function parseOpencodeIncremental({ messageFiles, cursors, queuePath, onProgress, source }) {
|
|
282
|
+
await ensureDir(path.dirname(queuePath));
|
|
283
|
+
let filesProcessed = 0;
|
|
284
|
+
let eventsAggregated = 0;
|
|
285
|
+
|
|
286
|
+
const cb = typeof onProgress === 'function' ? onProgress : null;
|
|
287
|
+
const files = Array.isArray(messageFiles) ? messageFiles : [];
|
|
288
|
+
const totalFiles = files.length;
|
|
289
|
+
const hourlyState = normalizeHourlyState(cursors?.hourly);
|
|
290
|
+
const touchedBuckets = new Set();
|
|
291
|
+
const defaultSource = normalizeSourceInput(source) || 'opencode';
|
|
292
|
+
|
|
293
|
+
if (!cursors.files || typeof cursors.files !== 'object') {
|
|
294
|
+
cursors.files = {};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (let idx = 0; idx < files.length; idx++) {
|
|
298
|
+
const entry = files[idx];
|
|
299
|
+
const filePath = typeof entry === 'string' ? entry : entry?.path;
|
|
300
|
+
if (!filePath) continue;
|
|
301
|
+
const fileSource =
|
|
302
|
+
typeof entry === 'string' ? defaultSource : normalizeSourceInput(entry?.source) || defaultSource;
|
|
303
|
+
const st = await fs.stat(filePath).catch(() => null);
|
|
304
|
+
if (!st || !st.isFile()) continue;
|
|
305
|
+
|
|
306
|
+
const key = filePath;
|
|
307
|
+
const prev = cursors.files[key] || null;
|
|
308
|
+
const inode = st.ino || 0;
|
|
309
|
+
const size = Number.isFinite(st.size) ? st.size : 0;
|
|
310
|
+
const mtimeMs = Number.isFinite(st.mtimeMs) ? st.mtimeMs : 0;
|
|
311
|
+
const unchanged = prev && prev.inode === inode && prev.size === size && prev.mtimeMs === mtimeMs;
|
|
312
|
+
if (unchanged) {
|
|
313
|
+
filesProcessed += 1;
|
|
314
|
+
if (cb) {
|
|
315
|
+
cb({
|
|
316
|
+
index: idx + 1,
|
|
317
|
+
total: totalFiles,
|
|
318
|
+
filePath,
|
|
319
|
+
filesProcessed,
|
|
320
|
+
eventsAggregated,
|
|
321
|
+
bucketsQueued: touchedBuckets.size
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const lastTotals = prev && prev.inode === inode ? prev.lastTotals || null : null;
|
|
328
|
+
const result = await parseOpencodeMessageFile({
|
|
329
|
+
filePath,
|
|
330
|
+
lastTotals,
|
|
331
|
+
hourlyState,
|
|
332
|
+
touchedBuckets,
|
|
333
|
+
source: fileSource
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
cursors.files[key] = {
|
|
337
|
+
inode,
|
|
338
|
+
size,
|
|
339
|
+
mtimeMs,
|
|
340
|
+
lastTotals: result.lastTotals,
|
|
341
|
+
updatedAt: new Date().toISOString()
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
filesProcessed += 1;
|
|
345
|
+
eventsAggregated += result.eventsAggregated;
|
|
346
|
+
|
|
347
|
+
if (cb) {
|
|
348
|
+
cb({
|
|
349
|
+
index: idx + 1,
|
|
350
|
+
total: totalFiles,
|
|
351
|
+
filePath,
|
|
352
|
+
filesProcessed,
|
|
353
|
+
eventsAggregated,
|
|
354
|
+
bucketsQueued: touchedBuckets.size
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
|
|
360
|
+
hourlyState.updatedAt = new Date().toISOString();
|
|
361
|
+
cursors.hourly = hourlyState;
|
|
362
|
+
|
|
363
|
+
return { filesProcessed, eventsAggregated, bucketsQueued };
|
|
364
|
+
}
|
|
365
|
+
|
|
273
366
|
async function parseRolloutFile({
|
|
274
367
|
filePath,
|
|
275
368
|
startOffset,
|
|
@@ -457,6 +550,37 @@ async function parseGeminiFile({
|
|
|
457
550
|
};
|
|
458
551
|
}
|
|
459
552
|
|
|
553
|
+
async function parseOpencodeMessageFile({ filePath, lastTotals, hourlyState, touchedBuckets, source }) {
|
|
554
|
+
const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
|
|
555
|
+
if (!raw.trim()) return { lastTotals, eventsAggregated: 0 };
|
|
556
|
+
|
|
557
|
+
let msg;
|
|
558
|
+
try {
|
|
559
|
+
msg = JSON.parse(raw);
|
|
560
|
+
} catch (_e) {
|
|
561
|
+
return { lastTotals, eventsAggregated: 0 };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const currentTotals = normalizeOpencodeTokens(msg?.tokens);
|
|
565
|
+
if (!currentTotals) return { lastTotals, eventsAggregated: 0 };
|
|
566
|
+
|
|
567
|
+
const delta = diffGeminiTotals(currentTotals, lastTotals);
|
|
568
|
+
if (!delta || isAllZeroUsage(delta)) return { lastTotals: currentTotals, eventsAggregated: 0 };
|
|
569
|
+
|
|
570
|
+
const timestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
|
|
571
|
+
if (!timestampMs) return { lastTotals, eventsAggregated: 0 };
|
|
572
|
+
|
|
573
|
+
const tsIso = new Date(timestampMs).toISOString();
|
|
574
|
+
const bucketStart = toUtcHalfHourStart(tsIso);
|
|
575
|
+
if (!bucketStart) return { lastTotals, eventsAggregated: 0 };
|
|
576
|
+
|
|
577
|
+
const model = normalizeModelInput(msg?.modelID) || DEFAULT_MODEL;
|
|
578
|
+
const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
|
|
579
|
+
addTotals(bucket.totals, delta);
|
|
580
|
+
touchedBuckets.add(bucketKey(source, model, bucketStart));
|
|
581
|
+
return { lastTotals: currentTotals, eventsAggregated: 1 };
|
|
582
|
+
}
|
|
583
|
+
|
|
460
584
|
async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }) {
|
|
461
585
|
if (!touchedBuckets || touchedBuckets.size === 0) return 0;
|
|
462
586
|
|
|
@@ -984,6 +1108,23 @@ function normalizeGeminiTokens(tokens) {
|
|
|
984
1108
|
};
|
|
985
1109
|
}
|
|
986
1110
|
|
|
1111
|
+
function normalizeOpencodeTokens(tokens) {
|
|
1112
|
+
if (!tokens || typeof tokens !== 'object') return null;
|
|
1113
|
+
const input = toNonNegativeInt(tokens.input);
|
|
1114
|
+
const output = toNonNegativeInt(tokens.output);
|
|
1115
|
+
const reasoning = toNonNegativeInt(tokens.reasoning);
|
|
1116
|
+
const cached = toNonNegativeInt(tokens.cache?.read);
|
|
1117
|
+
const total = input + output + reasoning;
|
|
1118
|
+
|
|
1119
|
+
return {
|
|
1120
|
+
input_tokens: input,
|
|
1121
|
+
cached_input_tokens: cached,
|
|
1122
|
+
output_tokens: output,
|
|
1123
|
+
reasoning_output_tokens: reasoning,
|
|
1124
|
+
total_tokens: total
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
987
1128
|
function sameGeminiTotals(a, b) {
|
|
988
1129
|
if (!a || !b) return false;
|
|
989
1130
|
return (
|
|
@@ -1125,6 +1266,13 @@ function toNonNegativeInt(v) {
|
|
|
1125
1266
|
return Math.floor(n);
|
|
1126
1267
|
}
|
|
1127
1268
|
|
|
1269
|
+
function coerceEpochMs(v) {
|
|
1270
|
+
const n = Number(v);
|
|
1271
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
1272
|
+
if (n < 1e12) return Math.floor(n * 1000);
|
|
1273
|
+
return Math.floor(n);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1128
1276
|
async function safeReadDir(dir) {
|
|
1129
1277
|
try {
|
|
1130
1278
|
return await fs.readdir(dir, { withFileTypes: true });
|
|
@@ -1145,11 +1293,25 @@ async function walkClaudeProjects(dir, out) {
|
|
|
1145
1293
|
}
|
|
1146
1294
|
}
|
|
1147
1295
|
|
|
1296
|
+
async function walkOpencodeMessages(dir, out) {
|
|
1297
|
+
const entries = await safeReadDir(dir);
|
|
1298
|
+
for (const entry of entries) {
|
|
1299
|
+
const fullPath = path.join(dir, entry.name);
|
|
1300
|
+
if (entry.isDirectory()) {
|
|
1301
|
+
await walkOpencodeMessages(fullPath, out);
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
if (entry.isFile() && entry.name.startsWith('msg_') && entry.name.endsWith('.json')) out.push(fullPath);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1148
1308
|
module.exports = {
|
|
1149
1309
|
listRolloutFiles,
|
|
1150
1310
|
listClaudeProjectFiles,
|
|
1151
1311
|
listGeminiSessionFiles,
|
|
1312
|
+
listOpencodeMessageFiles,
|
|
1152
1313
|
parseRolloutIncremental,
|
|
1153
1314
|
parseClaudeIncremental,
|
|
1154
|
-
parseGeminiIncremental
|
|
1315
|
+
parseGeminiIncremental,
|
|
1316
|
+
parseOpencodeIncremental
|
|
1155
1317
|
};
|