@vibe-cafe/vibe-usage 0.6.6 → 0.7.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 +54 -3
- package/package.json +1 -1
- package/src/daemon-service.js +272 -0
- package/src/index.js +21 -3
- package/src/init.js +4 -0
- package/src/parsers/index.js +2 -0
- package/src/parsers/pi-coding-agent.js +144 -0
- package/src/skill.js +134 -0
- package/src/tools.js +5 -0
package/README.md
CHANGED
|
@@ -19,9 +19,16 @@ This will:
|
|
|
19
19
|
npx @vibe-cafe/vibe-usage # Init (first run) or sync (subsequent runs)
|
|
20
20
|
npx @vibe-cafe/vibe-usage init # Re-run setup
|
|
21
21
|
npx @vibe-cafe/vibe-usage sync # Manual sync
|
|
22
|
-
npx @vibe-cafe/vibe-usage daemon # Continuous sync (every
|
|
22
|
+
npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 5m, foreground)
|
|
23
|
+
npx @vibe-cafe/vibe-usage daemon install # Install background service (systemd/launchd)
|
|
24
|
+
npx @vibe-cafe/vibe-usage daemon uninstall # Remove background service
|
|
25
|
+
npx @vibe-cafe/vibe-usage daemon status # Show background service status
|
|
26
|
+
npx @vibe-cafe/vibe-usage daemon stop # Stop background service
|
|
27
|
+
npx @vibe-cafe/vibe-usage daemon restart # Restart background service
|
|
23
28
|
npx @vibe-cafe/vibe-usage reset # Delete all data and re-upload from local logs
|
|
24
29
|
npx @vibe-cafe/vibe-usage reset --local # Delete this host's data only and re-upload
|
|
30
|
+
npx @vibe-cafe/vibe-usage skill # Install skill for AI coding assistants
|
|
31
|
+
npx @vibe-cafe/vibe-usage skill --remove # Remove installed skills
|
|
25
32
|
npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
26
33
|
```
|
|
27
34
|
|
|
@@ -35,18 +42,41 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
35
42
|
| Gemini CLI | `~/.gemini/tmp/` |
|
|
36
43
|
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite, `json_extract` query) |
|
|
37
44
|
| OpenClaw | `~/.openclaw/agents/` |
|
|
45
|
+
| pi | `~/.pi/agent/sessions/` |
|
|
38
46
|
| Qwen Code | `~/.qwen/tmp/` |
|
|
39
47
|
| Kimi Code | `~/.kimi/sessions/` |
|
|
48
|
+
| Amp | `~/.local/share/amp/threads/` |
|
|
49
|
+
| Droid | `~/.factory/sessions/` |
|
|
40
50
|
|
|
41
51
|
## How It Works
|
|
42
52
|
|
|
43
53
|
- Parses local session logs from each AI coding tool
|
|
44
54
|
- Aggregates token usage into 30-minute buckets
|
|
45
|
-
- Extracts session metadata from all
|
|
55
|
+
- Extracts session metadata from all 10 parsers: active time (AI generation time, excluding queue/TTFT wait), total duration, message counts
|
|
46
56
|
- Uploads buckets + sessions to your vibecafe.ai dashboard
|
|
47
57
|
- Stateless: computes full totals from local logs each sync (idempotent, no state files)
|
|
48
58
|
- For continuous syncing, use `npx @vibe-cafe/vibe-usage daemon` or the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app)
|
|
49
59
|
|
|
60
|
+
## AI Skill
|
|
61
|
+
|
|
62
|
+
Install vibe-usage as a skill for your AI coding assistant, so it knows how to sync usage data on your behalf:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx @vibe-cafe/vibe-usage skill
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
This auto-detects installed AI tools (Claude Code, Cursor, Windsurf, Codex CLI) and writes a `SKILL.md` to each tool's global skills directory. To remove:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npx @vibe-cafe/vibe-usage skill --remove
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
You can also install via the [open skills ecosystem](https://github.com/vercel-labs/skills):
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npx skills add vibe-cafe/vibe-usage
|
|
78
|
+
```
|
|
79
|
+
|
|
50
80
|
## Development
|
|
51
81
|
|
|
52
82
|
Test against a local vibe-cafe dev server without publishing:
|
|
@@ -64,13 +94,34 @@ Config stored at `~/.vibe-usage/config.json` (dev: `config.dev.json`). Contains
|
|
|
64
94
|
|
|
65
95
|
## Daemon Mode
|
|
66
96
|
|
|
97
|
+
### Background service (recommended)
|
|
98
|
+
|
|
99
|
+
Install as a system service for automatic background syncing:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npx @vibe-cafe/vibe-usage daemon install
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
This creates a user-level service (systemd on Linux, launchd on macOS) that syncs every 5 minutes and starts automatically on login. Manage with:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npx @vibe-cafe/vibe-usage daemon status
|
|
109
|
+
npx @vibe-cafe/vibe-usage daemon stop
|
|
110
|
+
npx @vibe-cafe/vibe-usage daemon restart
|
|
111
|
+
npx @vibe-cafe/vibe-usage daemon uninstall
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
For reliable operation, install globally first: `npm install -g @vibe-cafe/vibe-usage`
|
|
115
|
+
|
|
116
|
+
### Foreground mode
|
|
117
|
+
|
|
67
118
|
Run continuous syncing in the foreground (every 5 minutes):
|
|
68
119
|
|
|
69
120
|
```bash
|
|
70
121
|
npx @vibe-cafe/vibe-usage daemon
|
|
71
122
|
```
|
|
72
123
|
|
|
73
|
-
Press Ctrl+C to stop.
|
|
124
|
+
Press Ctrl+C to stop.
|
|
74
125
|
|
|
75
126
|
## License
|
|
76
127
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { writeFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir, platform } from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const SERVICE_NAME = 'vibe-usage';
|
|
8
|
+
const LAUNCHD_LABEL = 'ai.vibecafe.vibe-usage';
|
|
9
|
+
|
|
10
|
+
function detectPlatform() {
|
|
11
|
+
const os = platform();
|
|
12
|
+
if (os === 'linux') {
|
|
13
|
+
if (existsSync('/run/systemd/system')) return 'systemd';
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (os === 'darwin') {
|
|
17
|
+
return 'launchd';
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolvePaths() {
|
|
23
|
+
const nodePath = process.execPath;
|
|
24
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
25
|
+
const binPath = join(thisFile, '..', '..', 'bin', 'vibe-usage.js');
|
|
26
|
+
|
|
27
|
+
// npx cache paths are unstable — service will break when cache is cleared
|
|
28
|
+
const isNpxCache = binPath.includes('.npm/_npx');
|
|
29
|
+
|
|
30
|
+
return { nodePath, binPath, isNpxCache };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getServicePaths(plat) {
|
|
34
|
+
if (plat === 'systemd') {
|
|
35
|
+
const dir = join(homedir(), '.config', 'systemd', 'user');
|
|
36
|
+
return { dir, file: join(dir, `${SERVICE_NAME}.service`) };
|
|
37
|
+
}
|
|
38
|
+
if (plat === 'launchd') {
|
|
39
|
+
const dir = join(homedir(), 'Library', 'LaunchAgents');
|
|
40
|
+
return { dir, file: join(dir, `${LAUNCHD_LABEL}.plist`) };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function generateSystemdUnit(nodePath, binPath) {
|
|
46
|
+
return `[Unit]
|
|
47
|
+
Description=VibeCafe Usage Tracker
|
|
48
|
+
After=network.target
|
|
49
|
+
|
|
50
|
+
[Service]
|
|
51
|
+
Type=simple
|
|
52
|
+
ExecStart=${nodePath} ${binPath} daemon
|
|
53
|
+
Restart=on-failure
|
|
54
|
+
RestartSec=10
|
|
55
|
+
Environment=NODE_ENV=production
|
|
56
|
+
WorkingDirectory=${homedir()}
|
|
57
|
+
|
|
58
|
+
[Install]
|
|
59
|
+
WantedBy=default.target
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function generateLaunchdPlist(nodePath, binPath) {
|
|
64
|
+
const logDir = join(homedir(), '.vibe-usage');
|
|
65
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
66
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
67
|
+
<plist version="1.0">
|
|
68
|
+
<dict>
|
|
69
|
+
<key>Label</key>
|
|
70
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
71
|
+
<key>ProgramArguments</key>
|
|
72
|
+
<array>
|
|
73
|
+
<string>${nodePath}</string>
|
|
74
|
+
<string>${binPath}</string>
|
|
75
|
+
<string>daemon</string>
|
|
76
|
+
</array>
|
|
77
|
+
<key>RunAtLoad</key>
|
|
78
|
+
<true/>
|
|
79
|
+
<key>KeepAlive</key>
|
|
80
|
+
<true/>
|
|
81
|
+
<key>WorkingDirectory</key>
|
|
82
|
+
<string>${homedir()}</string>
|
|
83
|
+
<key>StandardOutPath</key>
|
|
84
|
+
<string>${join(logDir, 'daemon.log')}</string>
|
|
85
|
+
<key>StandardErrorPath</key>
|
|
86
|
+
<string>${join(logDir, 'daemon.err')}</string>
|
|
87
|
+
<key>EnvironmentVariables</key>
|
|
88
|
+
<dict>
|
|
89
|
+
<key>NODE_ENV</key>
|
|
90
|
+
<string>production</string>
|
|
91
|
+
</dict>
|
|
92
|
+
</dict>
|
|
93
|
+
</plist>
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function run(cmd, args) {
|
|
98
|
+
try {
|
|
99
|
+
const output = execFileSync(cmd, args, {
|
|
100
|
+
encoding: 'utf-8',
|
|
101
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
102
|
+
});
|
|
103
|
+
return { ok: true, output: output.trim() };
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return { ok: false, output: (err.stderr || err.stdout || err.message || '').trim() };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function install() {
|
|
110
|
+
const plat = detectPlatform();
|
|
111
|
+
if (!plat) {
|
|
112
|
+
console.log('Daemon install is not supported on this platform.');
|
|
113
|
+
console.log('Supported: Linux (systemd), macOS (launchd).');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { nodePath, binPath, isNpxCache } = resolvePaths();
|
|
118
|
+
|
|
119
|
+
if (isNpxCache) {
|
|
120
|
+
console.log('Warning: vibe-usage appears to be running from the npx cache.');
|
|
121
|
+
console.log('The daemon may break when the cache is cleared.');
|
|
122
|
+
console.log('For reliable operation, install globally first:');
|
|
123
|
+
console.log(' npm install -g @vibe-cafe/vibe-usage\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const paths = getServicePaths(plat);
|
|
127
|
+
|
|
128
|
+
if (existsSync(paths.file)) {
|
|
129
|
+
console.log('Service is already installed. Run `vibe-usage daemon restart` or `daemon uninstall` first.');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
mkdirSync(paths.dir, { recursive: true });
|
|
134
|
+
|
|
135
|
+
if (plat === 'systemd') {
|
|
136
|
+
writeFileSync(paths.file, generateSystemdUnit(nodePath, binPath), 'utf-8');
|
|
137
|
+
console.log(`Created ${paths.file}`);
|
|
138
|
+
|
|
139
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
140
|
+
const result = run('systemctl', ['--user', 'enable', '--now', `${SERVICE_NAME}.service`]);
|
|
141
|
+
if (!result.ok) {
|
|
142
|
+
console.error(`Failed to start service: ${result.output}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
console.log('Service enabled and started.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (plat === 'launchd') {
|
|
149
|
+
mkdirSync(join(homedir(), '.vibe-usage'), { recursive: true });
|
|
150
|
+
writeFileSync(paths.file, generateLaunchdPlist(nodePath, binPath), 'utf-8');
|
|
151
|
+
console.log(`Created ${paths.file}`);
|
|
152
|
+
|
|
153
|
+
const result = run('launchctl', ['load', paths.file]);
|
|
154
|
+
if (!result.ok) {
|
|
155
|
+
console.error(`Failed to load service: ${result.output}`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
console.log('Service loaded and started.');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log('\nDaemon installed. Usage data will sync automatically every 5 minutes.');
|
|
162
|
+
console.log('Run `vibe-usage daemon status` to check.');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function uninstall() {
|
|
166
|
+
const plat = detectPlatform();
|
|
167
|
+
if (!plat) {
|
|
168
|
+
console.log('No supported service platform detected.');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const paths = getServicePaths(plat);
|
|
173
|
+
|
|
174
|
+
if (!existsSync(paths.file)) {
|
|
175
|
+
console.log('No daemon service is installed.');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (plat === 'systemd') {
|
|
180
|
+
run('systemctl', ['--user', 'stop', `${SERVICE_NAME}.service`]);
|
|
181
|
+
run('systemctl', ['--user', 'disable', `${SERVICE_NAME}.service`]);
|
|
182
|
+
unlinkSync(paths.file);
|
|
183
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
184
|
+
console.log('Service stopped, disabled, and removed.');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (plat === 'launchd') {
|
|
188
|
+
run('launchctl', ['unload', paths.file]);
|
|
189
|
+
unlinkSync(paths.file);
|
|
190
|
+
console.log('Service unloaded and removed.');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function status() {
|
|
195
|
+
const plat = detectPlatform();
|
|
196
|
+
if (!plat) {
|
|
197
|
+
console.log('No supported service platform detected.');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const paths = getServicePaths(plat);
|
|
202
|
+
|
|
203
|
+
if (!existsSync(paths.file)) {
|
|
204
|
+
console.log('No daemon service is installed.');
|
|
205
|
+
console.log('Run `vibe-usage daemon install` to set up.');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (plat === 'systemd') {
|
|
210
|
+
const result = run('systemctl', ['--user', 'status', `${SERVICE_NAME}.service`]);
|
|
211
|
+
console.log(result.output);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (plat === 'launchd') {
|
|
215
|
+
const result = run('launchctl', ['list', LAUNCHD_LABEL]);
|
|
216
|
+
if (result.ok) {
|
|
217
|
+
console.log(`Service: ${LAUNCHD_LABEL}`);
|
|
218
|
+
console.log(result.output);
|
|
219
|
+
} else {
|
|
220
|
+
console.log('Service is installed but not currently running.');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function stop() {
|
|
226
|
+
const plat = detectPlatform();
|
|
227
|
+
if (!plat) {
|
|
228
|
+
console.log('No supported service platform detected.');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (plat === 'systemd') {
|
|
233
|
+
const result = run('systemctl', ['--user', 'stop', `${SERVICE_NAME}.service`]);
|
|
234
|
+
console.log(result.ok ? 'Service stopped.' : `Failed: ${result.output}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (plat === 'launchd') {
|
|
238
|
+
const result = run('launchctl', ['stop', LAUNCHD_LABEL]);
|
|
239
|
+
console.log(result.ok ? 'Service stopped.' : `Failed: ${result.output}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function restart() {
|
|
244
|
+
const plat = detectPlatform();
|
|
245
|
+
if (!plat) {
|
|
246
|
+
console.log('No supported service platform detected.');
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (plat === 'systemd') {
|
|
251
|
+
const result = run('systemctl', ['--user', 'restart', `${SERVICE_NAME}.service`]);
|
|
252
|
+
console.log(result.ok ? 'Service restarted.' : `Failed: ${result.output}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (plat === 'launchd') {
|
|
256
|
+
run('launchctl', ['stop', LAUNCHD_LABEL]);
|
|
257
|
+
const result = run('launchctl', ['start', LAUNCHD_LABEL]);
|
|
258
|
+
console.log(result.ok ? 'Service restarted.' : `Failed: ${result.output}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const SUBCOMMANDS = { install, uninstall, status, stop, restart };
|
|
263
|
+
|
|
264
|
+
export async function manageDaemon(subcommand) {
|
|
265
|
+
const fn = SUBCOMMANDS[subcommand];
|
|
266
|
+
if (!fn) {
|
|
267
|
+
console.error(`Unknown daemon subcommand: ${subcommand}`);
|
|
268
|
+
console.error('Usage: vibe-usage daemon <install|uninstall|status|stop|restart>');
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
fn();
|
|
272
|
+
}
|
package/src/index.js
CHANGED
|
@@ -109,8 +109,19 @@ export async function run(args) {
|
|
|
109
109
|
}
|
|
110
110
|
case 'daemon':
|
|
111
111
|
case '--daemon': {
|
|
112
|
-
const
|
|
113
|
-
|
|
112
|
+
const sub = args[1];
|
|
113
|
+
if (sub && ['install', 'uninstall', 'status', 'stop', 'restart'].includes(sub)) {
|
|
114
|
+
const { manageDaemon } = await import('./daemon-service.js');
|
|
115
|
+
await manageDaemon(sub);
|
|
116
|
+
} else {
|
|
117
|
+
const { runDaemon } = await import('./daemon.js');
|
|
118
|
+
await runDaemon();
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case 'skill': {
|
|
123
|
+
const { runSkill } = await import('./skill.js');
|
|
124
|
+
await runSkill(args.slice(1));
|
|
114
125
|
break;
|
|
115
126
|
}
|
|
116
127
|
case 'config': {
|
|
@@ -131,9 +142,16 @@ export async function run(args) {
|
|
|
131
142
|
npx @vibe-cafe/vibe-usage Init (first run) or sync
|
|
132
143
|
npx @vibe-cafe/vibe-usage init Set up API key
|
|
133
144
|
npx @vibe-cafe/vibe-usage sync Manually sync usage data
|
|
134
|
-
npx @vibe-cafe/vibe-usage daemon Continuous sync (every 5m)
|
|
145
|
+
npx @vibe-cafe/vibe-usage daemon Continuous sync (every 5m, foreground)
|
|
146
|
+
npx @vibe-cafe/vibe-usage daemon install Install background service (systemd/launchd)
|
|
147
|
+
npx @vibe-cafe/vibe-usage daemon uninstall Remove background service
|
|
148
|
+
npx @vibe-cafe/vibe-usage daemon status Show background service status
|
|
149
|
+
npx @vibe-cafe/vibe-usage daemon stop Stop background service
|
|
150
|
+
npx @vibe-cafe/vibe-usage daemon restart Restart background service
|
|
135
151
|
npx @vibe-cafe/vibe-usage reset Delete all data and re-upload
|
|
136
152
|
npx @vibe-cafe/vibe-usage reset --local Delete data for this host only and re-upload
|
|
153
|
+
npx @vibe-cafe/vibe-usage skill Install skill for AI coding tools
|
|
154
|
+
npx @vibe-cafe/vibe-usage skill --remove Remove installed skills
|
|
137
155
|
npx @vibe-cafe/vibe-usage status Show config and detected tools
|
|
138
156
|
npx @vibe-cafe/vibe-usage config show Show full config as JSON
|
|
139
157
|
npx @vibe-cafe/vibe-usage config get <key> Get a config value
|
package/src/init.js
CHANGED
|
@@ -75,4 +75,8 @@ export async function runInit() {
|
|
|
75
75
|
await runSync();
|
|
76
76
|
|
|
77
77
|
console.log(`\nSetup complete! View your dashboard at: ${apiUrl}/usage`);
|
|
78
|
+
|
|
79
|
+
if (process.platform === 'linux' || process.platform === 'darwin') {
|
|
80
|
+
console.log('\nTip: Run `npx @vibe-cafe/vibe-usage daemon install` to sync automatically in the background.');
|
|
81
|
+
}
|
|
78
82
|
}
|
package/src/parsers/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { parse as parseQwenCode } from './qwen-code.js';
|
|
|
9
9
|
import { parse as parseKimiCode } from './kimi-code.js';
|
|
10
10
|
import { parse as parseAmp } from './amp.js';
|
|
11
11
|
import { parse as parseDroid } from './droid.js';
|
|
12
|
+
import { parse as parsePiCodingAgent } from './pi-coding-agent.js';
|
|
12
13
|
|
|
13
14
|
export const parsers = {
|
|
14
15
|
'claude-code': parseClaudeCode,
|
|
@@ -21,6 +22,7 @@ export const parsers = {
|
|
|
21
22
|
'kimi-code': parseKimiCode,
|
|
22
23
|
'amp': parseAmp,
|
|
23
24
|
'droid': parseDroid,
|
|
25
|
+
'pi-coding-agent': parsePiCodingAgent,
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* pi-coding-agent parser.
|
|
8
|
+
* Reads JSONL session files from ~/.pi/agent/sessions/ (or $PI_CODING_AGENT_DIR/sessions/).
|
|
9
|
+
*
|
|
10
|
+
* Session file layout:
|
|
11
|
+
* sessions/<encoded-cwd>/{timestamp}_{sessionId}.jsonl
|
|
12
|
+
*
|
|
13
|
+
* Each JSONL line is a session entry:
|
|
14
|
+
* - type "session": header with id, cwd, version
|
|
15
|
+
* - type "message": contains message object with role, usage, model, timestamp
|
|
16
|
+
* - type "model_change", "compaction", etc.: metadata (ignored for usage)
|
|
17
|
+
*
|
|
18
|
+
* Assistant messages carry per-message token usage:
|
|
19
|
+
* message.usage = { input, output, cacheRead, cacheWrite, totalTokens }
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function getSessionsDir() {
|
|
23
|
+
const envDir = process.env.PI_CODING_AGENT_DIR;
|
|
24
|
+
if (envDir) return join(envDir, 'sessions');
|
|
25
|
+
return join(homedir(), '.pi', 'agent', 'sessions');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function findJsonlFiles(dir) {
|
|
29
|
+
const results = [];
|
|
30
|
+
if (!existsSync(dir)) return results;
|
|
31
|
+
try {
|
|
32
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
33
|
+
const fullPath = join(dir, entry.name);
|
|
34
|
+
if (entry.isDirectory()) {
|
|
35
|
+
results.push(...findJsonlFiles(fullPath));
|
|
36
|
+
} else if (entry.name.endsWith('.jsonl')) {
|
|
37
|
+
results.push(fullPath);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// ignore unreadable directories
|
|
42
|
+
}
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractProjectFromCwd(cwd) {
|
|
47
|
+
if (!cwd) return 'unknown';
|
|
48
|
+
const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
49
|
+
return parts.length > 0 ? parts[parts.length - 1] : 'unknown';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractProjectFromDir(filePath, sessionsDir) {
|
|
53
|
+
const relative = filePath.slice(sessionsDir.length + 1);
|
|
54
|
+
const firstSeg = relative.split('/')[0] || relative.split('\\')[0];
|
|
55
|
+
if (!firstSeg) return 'unknown';
|
|
56
|
+
const parts = firstSeg.split('-').filter(Boolean);
|
|
57
|
+
return parts.length > 0 ? parts[parts.length - 1] : 'unknown';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function parse() {
|
|
61
|
+
const sessionsDir = getSessionsDir();
|
|
62
|
+
const entries = [];
|
|
63
|
+
const sessionEvents = [];
|
|
64
|
+
const seenEntryIds = new Set();
|
|
65
|
+
|
|
66
|
+
const sessionFiles = findJsonlFiles(sessionsDir);
|
|
67
|
+
|
|
68
|
+
for (const filePath of sessionFiles) {
|
|
69
|
+
let content;
|
|
70
|
+
try {
|
|
71
|
+
content = readFileSync(filePath, 'utf-8');
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let sessionId = basename(filePath, '.jsonl');
|
|
77
|
+
let project = extractProjectFromDir(filePath, sessionsDir);
|
|
78
|
+
|
|
79
|
+
for (const line of content.split('\n')) {
|
|
80
|
+
if (!line.trim()) continue;
|
|
81
|
+
|
|
82
|
+
let obj;
|
|
83
|
+
try {
|
|
84
|
+
obj = JSON.parse(line);
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (obj.type === 'session') {
|
|
90
|
+
if (obj.id) sessionId = obj.id;
|
|
91
|
+
if (obj.cwd) project = extractProjectFromCwd(obj.cwd);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (obj.type !== 'message') continue;
|
|
96
|
+
|
|
97
|
+
const msg = obj.message;
|
|
98
|
+
if (!msg) continue;
|
|
99
|
+
|
|
100
|
+
let ts;
|
|
101
|
+
if (obj.timestamp) {
|
|
102
|
+
ts = new Date(obj.timestamp);
|
|
103
|
+
} else if (msg.timestamp) {
|
|
104
|
+
ts = new Date(msg.timestamp);
|
|
105
|
+
}
|
|
106
|
+
if (!ts || isNaN(ts.getTime())) continue;
|
|
107
|
+
|
|
108
|
+
if (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'toolResult') {
|
|
109
|
+
sessionEvents.push({
|
|
110
|
+
sessionId,
|
|
111
|
+
source: 'pi-coding-agent',
|
|
112
|
+
project,
|
|
113
|
+
timestamp: ts,
|
|
114
|
+
role: msg.role === 'user' ? 'user' : 'assistant',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (msg.role !== 'assistant') continue;
|
|
119
|
+
if (!msg.usage) continue;
|
|
120
|
+
|
|
121
|
+
const usage = msg.usage;
|
|
122
|
+
if (usage.input == null && usage.output == null) continue;
|
|
123
|
+
|
|
124
|
+
const entryId = obj.id;
|
|
125
|
+
if (entryId) {
|
|
126
|
+
if (seenEntryIds.has(entryId)) continue;
|
|
127
|
+
seenEntryIds.add(entryId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
entries.push({
|
|
131
|
+
source: 'pi-coding-agent',
|
|
132
|
+
model: msg.model || 'unknown',
|
|
133
|
+
project,
|
|
134
|
+
timestamp: ts,
|
|
135
|
+
inputTokens: usage.input || 0,
|
|
136
|
+
outputTokens: usage.output || 0,
|
|
137
|
+
cachedInputTokens: usage.cacheRead || 0,
|
|
138
|
+
reasoningOutputTokens: 0,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
144
|
+
}
|
package/src/skill.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync, rmdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
const SKILL_TARGETS = [
|
|
6
|
+
{
|
|
7
|
+
name: 'Claude Code',
|
|
8
|
+
detectDir: join(homedir(), '.claude'),
|
|
9
|
+
skillDir: join(homedir(), '.claude', 'skills', 'vibe-usage'),
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: 'Codex CLI',
|
|
13
|
+
detectDir: join(homedir(), '.codex'),
|
|
14
|
+
skillDir: join(homedir(), '.codex', 'skills', 'vibe-usage'),
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'Cursor',
|
|
18
|
+
detectDir: join(homedir(), '.cursor'),
|
|
19
|
+
skillDir: join(homedir(), '.cursor', 'skills', 'vibe-usage'),
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'Windsurf',
|
|
23
|
+
detectDir: join(homedir(), '.codeium', 'windsurf'),
|
|
24
|
+
skillDir: join(homedir(), '.codeium', 'windsurf', 'skills', 'vibe-usage'),
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function tildePath(absPath) {
|
|
29
|
+
const home = homedir();
|
|
30
|
+
return absPath.startsWith(home) ? absPath.replace(home, '~') : absPath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SKILL_CONTENT = `---
|
|
34
|
+
name: vibe-usage
|
|
35
|
+
description: Track and sync AI coding tool token usage to vibecafe.ai dashboard.
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
# Vibe Usage
|
|
39
|
+
|
|
40
|
+
Track your AI coding tool token usage and sync to [vibecafe.ai](https://vibecafe.ai/usage).
|
|
41
|
+
|
|
42
|
+
## Setup
|
|
43
|
+
|
|
44
|
+
First-time setup (interactive — asks for API key):
|
|
45
|
+
|
|
46
|
+
\`\`\`bash
|
|
47
|
+
npx @vibe-cafe/vibe-usage
|
|
48
|
+
\`\`\`
|
|
49
|
+
|
|
50
|
+
Get your API key at https://vibecafe.ai/usage/setup
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
When the user asks to sync usage, check costs, or track tokens, run:
|
|
55
|
+
|
|
56
|
+
\`\`\`bash
|
|
57
|
+
npx @vibe-cafe/vibe-usage sync
|
|
58
|
+
\`\`\`
|
|
59
|
+
|
|
60
|
+
Other available commands:
|
|
61
|
+
|
|
62
|
+
| Command | Description |
|
|
63
|
+
|---------|-------------|
|
|
64
|
+
| \`npx @vibe-cafe/vibe-usage sync\` | Sync latest usage data |
|
|
65
|
+
| \`npx @vibe-cafe/vibe-usage status\` | Show config and detected tools |
|
|
66
|
+
| \`npx @vibe-cafe/vibe-usage daemon\` | Continuous sync every 5 minutes |
|
|
67
|
+
| \`npx @vibe-cafe/vibe-usage reset\` | Delete all data and re-upload |
|
|
68
|
+
| \`npx @vibe-cafe/vibe-usage reset --local\` | Delete this host's data and re-upload |
|
|
69
|
+
|
|
70
|
+
## When to Use
|
|
71
|
+
|
|
72
|
+
- User says "sync my usage", "upload usage", "track tokens"
|
|
73
|
+
- User asks "how much have I spent?", "what's my cost?"
|
|
74
|
+
- User wants to check if sync is working: run \`status\`
|
|
75
|
+
- User wants continuous background sync: run \`daemon\`
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- Requires initial setup with an API key (run \`npx @vibe-cafe/vibe-usage\` first)
|
|
80
|
+
- Config is stored at \`~/.vibe-usage/config.json\`
|
|
81
|
+
- Supports: Claude Code, Codex CLI, Copilot CLI, Gemini CLI, OpenCode, OpenClaw, Qwen Code, Kimi Code, Amp, Droid
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
export async function runSkill(args = []) {
|
|
85
|
+
const remove = args.includes('--remove');
|
|
86
|
+
|
|
87
|
+
console.log('\nvibe-usage skill\n');
|
|
88
|
+
|
|
89
|
+
console.log(' AI coding tools:');
|
|
90
|
+
for (const t of SKILL_TARGETS) {
|
|
91
|
+
const found = existsSync(t.detectDir);
|
|
92
|
+
console.log(` ${found ? '\u2713' : '\u2717'} ${t.name}`);
|
|
93
|
+
}
|
|
94
|
+
console.log();
|
|
95
|
+
|
|
96
|
+
const detected = SKILL_TARGETS.filter(t => existsSync(t.detectDir));
|
|
97
|
+
|
|
98
|
+
if (detected.length === 0) {
|
|
99
|
+
console.log(' No supported tools detected. Nothing to do.\n');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (remove) {
|
|
104
|
+
let removed = 0;
|
|
105
|
+
for (const t of detected) {
|
|
106
|
+
const skillFile = join(t.skillDir, 'SKILL.md');
|
|
107
|
+
if (existsSync(skillFile)) {
|
|
108
|
+
unlinkSync(skillFile);
|
|
109
|
+
try { rmdirSync(t.skillDir); } catch {}
|
|
110
|
+
console.log(` Removed: ${tildePath(skillFile)}`);
|
|
111
|
+
removed++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (removed === 0) {
|
|
115
|
+
console.log(' No skills installed to remove.\n');
|
|
116
|
+
} else {
|
|
117
|
+
console.log(`\n Removed vibe-usage skill from ${removed} tool${removed > 1 ? 's' : ''}.\n`);
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let installed = 0;
|
|
123
|
+
for (const t of detected) {
|
|
124
|
+
const skillFile = join(t.skillDir, 'SKILL.md');
|
|
125
|
+
mkdirSync(t.skillDir, { recursive: true });
|
|
126
|
+
writeFileSync(skillFile, SKILL_CONTENT, 'utf-8');
|
|
127
|
+
console.log(` Installed: ${tildePath(skillFile)}`);
|
|
128
|
+
installed++;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log(`\n Installed vibe-usage skill for ${installed} tool${installed > 1 ? 's' : ''}.\n`);
|
|
132
|
+
console.log(' Your AI coding assistant now knows how to sync usage data.');
|
|
133
|
+
console.log(' Try asking: "sync my vibe usage" or "how much have I spent?"\n');
|
|
134
|
+
}
|
package/src/tools.js
CHANGED
|
@@ -33,6 +33,11 @@ export const TOOLS = [
|
|
|
33
33
|
id: 'openclaw',
|
|
34
34
|
dataDir: join(homedir(), '.openclaw', 'agents'),
|
|
35
35
|
},
|
|
36
|
+
{
|
|
37
|
+
name: 'pi',
|
|
38
|
+
id: 'pi-coding-agent',
|
|
39
|
+
dataDir: join(homedir(), '.pi', 'agent', 'sessions'),
|
|
40
|
+
},
|
|
36
41
|
{
|
|
37
42
|
name: 'Qwen Code',
|
|
38
43
|
id: 'qwen-code',
|