@vibe-cafe/vibe-usage 0.6.5 → 0.6.8
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 +26 -1
- package/package.json +1 -1
- package/src/index.js +7 -0
- package/src/parsers/amp.js +144 -0
- package/src/parsers/droid.js +113 -0
- package/src/parsers/index.js +6 -0
- package/src/parsers/pi-coding-agent.js +144 -0
- package/src/skill.js +134 -0
- package/src/tools.js +15 -0
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ npx @vibe-cafe/vibe-usage sync # Manual sync
|
|
|
22
22
|
npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 5 minutes)
|
|
23
23
|
npx @vibe-cafe/vibe-usage reset # Delete all data and re-upload from local logs
|
|
24
24
|
npx @vibe-cafe/vibe-usage reset --local # Delete this host's data only and re-upload
|
|
25
|
+
npx @vibe-cafe/vibe-usage skill # Install skill for AI coding assistants
|
|
26
|
+
npx @vibe-cafe/vibe-usage skill --remove # Remove installed skills
|
|
25
27
|
npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
26
28
|
```
|
|
27
29
|
|
|
@@ -35,18 +37,41 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
35
37
|
| Gemini CLI | `~/.gemini/tmp/` |
|
|
36
38
|
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite, `json_extract` query) |
|
|
37
39
|
| OpenClaw | `~/.openclaw/agents/` |
|
|
40
|
+
| pi | `~/.pi/agent/sessions/` |
|
|
38
41
|
| Qwen Code | `~/.qwen/tmp/` |
|
|
39
42
|
| Kimi Code | `~/.kimi/sessions/` |
|
|
43
|
+
| Amp | `~/.local/share/amp/threads/` |
|
|
44
|
+
| Droid | `~/.factory/sessions/` |
|
|
40
45
|
|
|
41
46
|
## How It Works
|
|
42
47
|
|
|
43
48
|
- Parses local session logs from each AI coding tool
|
|
44
49
|
- Aggregates token usage into 30-minute buckets
|
|
45
|
-
- Extracts session metadata from all
|
|
50
|
+
- Extracts session metadata from all 10 parsers: active time (AI generation time, excluding queue/TTFT wait), total duration, message counts
|
|
46
51
|
- Uploads buckets + sessions to your vibecafe.ai dashboard
|
|
47
52
|
- Stateless: computes full totals from local logs each sync (idempotent, no state files)
|
|
48
53
|
- 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
54
|
|
|
55
|
+
## AI Skill
|
|
56
|
+
|
|
57
|
+
Install vibe-usage as a skill for your AI coding assistant, so it knows how to sync usage data on your behalf:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx @vibe-cafe/vibe-usage skill
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
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:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npx @vibe-cafe/vibe-usage skill --remove
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
You can also install via the [open skills ecosystem](https://github.com/vercel-labs/skills):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx skills add vibe-cafe/vibe-usage
|
|
73
|
+
```
|
|
74
|
+
|
|
50
75
|
## Development
|
|
51
76
|
|
|
52
77
|
Test against a local vibe-cafe dev server without publishing:
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -113,6 +113,11 @@ export async function run(args) {
|
|
|
113
113
|
await runDaemon();
|
|
114
114
|
break;
|
|
115
115
|
}
|
|
116
|
+
case 'skill': {
|
|
117
|
+
const { runSkill } = await import('./skill.js');
|
|
118
|
+
await runSkill(args.slice(1));
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
116
121
|
case 'config': {
|
|
117
122
|
handleConfig(args.slice(1));
|
|
118
123
|
break;
|
|
@@ -134,6 +139,8 @@ export async function run(args) {
|
|
|
134
139
|
npx @vibe-cafe/vibe-usage daemon Continuous sync (every 5m)
|
|
135
140
|
npx @vibe-cafe/vibe-usage reset Delete all data and re-upload
|
|
136
141
|
npx @vibe-cafe/vibe-usage reset --local Delete data for this host only and re-upload
|
|
142
|
+
npx @vibe-cafe/vibe-usage skill Install skill for AI coding tools
|
|
143
|
+
npx @vibe-cafe/vibe-usage skill --remove Remove installed skills
|
|
137
144
|
npx @vibe-cafe/vibe-usage status Show config and detected tools
|
|
138
145
|
npx @vibe-cafe/vibe-usage config show Show full config as JSON
|
|
139
146
|
npx @vibe-cafe/vibe-usage config get <key> Get a config value
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
|
+
|
|
6
|
+
function resolveThreadsDir() {
|
|
7
|
+
if (process.env.AMP_DATA_DIR) return process.env.AMP_DATA_DIR;
|
|
8
|
+
if (process.env.XDG_DATA_HOME) return join(process.env.XDG_DATA_HOME, 'amp', 'threads');
|
|
9
|
+
return join(homedir(), '.local', 'share', 'amp', 'threads');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function findThreadFiles(dir) {
|
|
13
|
+
const results = [];
|
|
14
|
+
if (!existsSync(dir)) return results;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
18
|
+
const fullPath = join(dir, entry.name);
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
results.push(...findThreadFiles(fullPath));
|
|
21
|
+
} else if (entry.isFile() && entry.name.startsWith('T-') && entry.name.endsWith('.json')) {
|
|
22
|
+
results.push(fullPath);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setMessageTimestamp(map, messageId, timestamp) {
|
|
32
|
+
if (!Number.isInteger(messageId)) return;
|
|
33
|
+
const current = map.get(messageId);
|
|
34
|
+
if (!current || timestamp < current) {
|
|
35
|
+
map.set(messageId, timestamp);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildMessageTimestampMap(events) {
|
|
40
|
+
const map = new Map();
|
|
41
|
+
if (!Array.isArray(events)) return map;
|
|
42
|
+
|
|
43
|
+
for (const event of events) {
|
|
44
|
+
const ts = new Date(event?.timestamp);
|
|
45
|
+
if (isNaN(ts.getTime())) continue;
|
|
46
|
+
|
|
47
|
+
setMessageTimestamp(map, event.fromMessageId, ts);
|
|
48
|
+
setMessageTimestamp(map, event.toMessageId, ts);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return map;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function parse() {
|
|
55
|
+
const threadsDir = resolveThreadsDir();
|
|
56
|
+
const threadFiles = findThreadFiles(threadsDir);
|
|
57
|
+
if (threadFiles.length === 0) return { buckets: [], sessions: [] };
|
|
58
|
+
|
|
59
|
+
const entries = [];
|
|
60
|
+
const sessionEvents = [];
|
|
61
|
+
|
|
62
|
+
for (const filePath of threadFiles) {
|
|
63
|
+
let thread;
|
|
64
|
+
try {
|
|
65
|
+
thread = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sessionId = thread?.id || filePath;
|
|
71
|
+
const messages = Array.isArray(thread?.messages) ? thread.messages : [];
|
|
72
|
+
const ledgerEvents = Array.isArray(thread?.usageLedger?.events) ? thread.usageLedger.events : [];
|
|
73
|
+
const hasLedger = ledgerEvents.length > 0;
|
|
74
|
+
|
|
75
|
+
if (hasLedger) {
|
|
76
|
+
for (const event of ledgerEvents) {
|
|
77
|
+
const ts = new Date(event?.timestamp);
|
|
78
|
+
if (isNaN(ts.getTime())) continue;
|
|
79
|
+
|
|
80
|
+
const inputTokens = event?.tokens?.input || 0;
|
|
81
|
+
const outputTokens = event?.tokens?.output || 0;
|
|
82
|
+
if (inputTokens === 0 && outputTokens === 0) continue;
|
|
83
|
+
|
|
84
|
+
const toMessage = Number.isInteger(event.toMessageId) ? messages[event.toMessageId] : null;
|
|
85
|
+
const cacheReadInputTokens = toMessage?.usage?.cacheReadInputTokens || 0;
|
|
86
|
+
|
|
87
|
+
entries.push({
|
|
88
|
+
source: 'amp',
|
|
89
|
+
model: event?.model || 'unknown',
|
|
90
|
+
project: 'unknown',
|
|
91
|
+
timestamp: ts,
|
|
92
|
+
inputTokens,
|
|
93
|
+
outputTokens,
|
|
94
|
+
cachedInputTokens: cacheReadInputTokens,
|
|
95
|
+
reasoningOutputTokens: 0,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
for (const message of messages) {
|
|
100
|
+
const usage = message?.usage;
|
|
101
|
+
if (!usage) continue;
|
|
102
|
+
|
|
103
|
+
const ts = new Date(message?.timestamp || thread?.created);
|
|
104
|
+
if (isNaN(ts.getTime())) continue;
|
|
105
|
+
|
|
106
|
+
const inputTokens = usage.inputTokens || 0;
|
|
107
|
+
const outputTokens = usage.outputTokens || 0;
|
|
108
|
+
if (inputTokens === 0 && outputTokens === 0 && (usage.cacheReadInputTokens || 0) === 0) continue;
|
|
109
|
+
|
|
110
|
+
entries.push({
|
|
111
|
+
source: 'amp',
|
|
112
|
+
model: usage.model || 'unknown',
|
|
113
|
+
project: 'unknown',
|
|
114
|
+
timestamp: ts,
|
|
115
|
+
inputTokens,
|
|
116
|
+
outputTokens,
|
|
117
|
+
cachedInputTokens: usage.cacheReadInputTokens || 0,
|
|
118
|
+
reasoningOutputTokens: 0,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const messageTsMap = buildMessageTimestampMap(ledgerEvents);
|
|
124
|
+
const baseTimestamp = new Date(thread?.created);
|
|
125
|
+
const hasBaseTimestamp = !isNaN(baseTimestamp.getTime());
|
|
126
|
+
|
|
127
|
+
for (let i = 0; i < messages.length; i++) {
|
|
128
|
+
const message = messages[i];
|
|
129
|
+
const mappedTs = messageTsMap.get(i);
|
|
130
|
+
const ts = mappedTs || (hasBaseTimestamp ? baseTimestamp : null);
|
|
131
|
+
if (!ts || isNaN(ts.getTime())) continue;
|
|
132
|
+
|
|
133
|
+
sessionEvents.push({
|
|
134
|
+
sessionId,
|
|
135
|
+
source: 'amp',
|
|
136
|
+
project: 'unknown',
|
|
137
|
+
timestamp: ts,
|
|
138
|
+
role: message?.role === 'user' ? 'user' : 'assistant',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
144
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, basename, dirname } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
|
+
|
|
6
|
+
const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions');
|
|
7
|
+
|
|
8
|
+
function findJsonlFiles(dir) {
|
|
9
|
+
const results = [];
|
|
10
|
+
if (!existsSync(dir)) return results;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
14
|
+
const fullPath = join(dir, entry.name);
|
|
15
|
+
if (entry.isDirectory()) {
|
|
16
|
+
results.push(...findJsonlFiles(fullPath));
|
|
17
|
+
} else if (entry.name.endsWith('.jsonl') && !entry.name.endsWith('.settings.json')) {
|
|
18
|
+
results.push(fullPath);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return results;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractProjectFromSlug(slug) {
|
|
28
|
+
const parts = slug.split('-').filter(Boolean);
|
|
29
|
+
return parts.length > 0 ? parts[parts.length - 1] : 'unknown';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toSafeNumber(value) {
|
|
33
|
+
const n = Number(value);
|
|
34
|
+
return Number.isFinite(n) ? n : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function parse() {
|
|
38
|
+
const entries = [];
|
|
39
|
+
const sessionEvents = [];
|
|
40
|
+
const sessionFiles = findJsonlFiles(DROID_SESSIONS_DIR);
|
|
41
|
+
|
|
42
|
+
for (const filePath of sessionFiles) {
|
|
43
|
+
const sessionId = basename(filePath, '.jsonl');
|
|
44
|
+
const slug = basename(dirname(filePath));
|
|
45
|
+
const project = extractProjectFromSlug(slug);
|
|
46
|
+
let firstMessageTimestamp = null;
|
|
47
|
+
|
|
48
|
+
let content;
|
|
49
|
+
try {
|
|
50
|
+
content = readFileSync(filePath, 'utf-8');
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const line of content.split('\n')) {
|
|
56
|
+
if (!line.trim()) continue;
|
|
57
|
+
|
|
58
|
+
let obj;
|
|
59
|
+
try {
|
|
60
|
+
obj = JSON.parse(line);
|
|
61
|
+
} catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (obj.type !== 'message') continue;
|
|
66
|
+
if (!obj.timestamp) continue;
|
|
67
|
+
|
|
68
|
+
const ts = new Date(obj.timestamp);
|
|
69
|
+
if (isNaN(ts.getTime())) continue;
|
|
70
|
+
|
|
71
|
+
if (firstMessageTimestamp === null) firstMessageTimestamp = ts;
|
|
72
|
+
|
|
73
|
+
sessionEvents.push({
|
|
74
|
+
sessionId,
|
|
75
|
+
source: 'droid',
|
|
76
|
+
project,
|
|
77
|
+
timestamp: ts,
|
|
78
|
+
role: obj.message?.role === 'user' ? 'user' : 'assistant',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const settingsPath = join(dirname(filePath), `${sessionId}.settings.json`);
|
|
83
|
+
if (!existsSync(settingsPath) || firstMessageTimestamp === null) continue;
|
|
84
|
+
|
|
85
|
+
let settings;
|
|
86
|
+
try {
|
|
87
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
88
|
+
} catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const tokenUsage = settings?.tokenUsage;
|
|
93
|
+
if (!tokenUsage) continue;
|
|
94
|
+
|
|
95
|
+
const cacheReadTokens = toSafeNumber(tokenUsage.cacheReadTokens);
|
|
96
|
+
const thinkingTokens = toSafeNumber(tokenUsage.thinkingTokens);
|
|
97
|
+
const inputTokens = Math.max(0, toSafeNumber(tokenUsage.inputTokens) - cacheReadTokens);
|
|
98
|
+
const outputTokens = Math.max(0, toSafeNumber(tokenUsage.outputTokens) - thinkingTokens);
|
|
99
|
+
|
|
100
|
+
entries.push({
|
|
101
|
+
source: 'droid',
|
|
102
|
+
model: settings.model || 'unknown',
|
|
103
|
+
project,
|
|
104
|
+
timestamp: firstMessageTimestamp,
|
|
105
|
+
inputTokens,
|
|
106
|
+
outputTokens,
|
|
107
|
+
cachedInputTokens: cacheReadTokens,
|
|
108
|
+
reasoningOutputTokens: thinkingTokens,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
113
|
+
}
|
package/src/parsers/index.js
CHANGED
|
@@ -7,6 +7,9 @@ import { parse as parseOpencode } from './opencode.js';
|
|
|
7
7
|
import { parse as parseOpenclaw } from './openclaw.js';
|
|
8
8
|
import { parse as parseQwenCode } from './qwen-code.js';
|
|
9
9
|
import { parse as parseKimiCode } from './kimi-code.js';
|
|
10
|
+
import { parse as parseAmp } from './amp.js';
|
|
11
|
+
import { parse as parseDroid } from './droid.js';
|
|
12
|
+
import { parse as parsePiCodingAgent } from './pi-coding-agent.js';
|
|
10
13
|
|
|
11
14
|
export const parsers = {
|
|
12
15
|
'claude-code': parseClaudeCode,
|
|
@@ -17,6 +20,9 @@ export const parsers = {
|
|
|
17
20
|
'openclaw': parseOpenclaw,
|
|
18
21
|
'qwen-code': parseQwenCode,
|
|
19
22
|
'kimi-code': parseKimiCode,
|
|
23
|
+
'amp': parseAmp,
|
|
24
|
+
'droid': parseDroid,
|
|
25
|
+
'pi-coding-agent': parsePiCodingAgent,
|
|
20
26
|
};
|
|
21
27
|
|
|
22
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',
|
|
@@ -43,6 +48,16 @@ export const TOOLS = [
|
|
|
43
48
|
id: 'kimi-code',
|
|
44
49
|
dataDir: join(homedir(), '.kimi', 'sessions'),
|
|
45
50
|
},
|
|
51
|
+
{
|
|
52
|
+
name: 'Amp',
|
|
53
|
+
id: 'amp',
|
|
54
|
+
dataDir: join(homedir(), '.local', 'share', 'amp', 'threads'),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'Droid',
|
|
58
|
+
id: 'droid',
|
|
59
|
+
dataDir: join(homedir(), '.factory', 'sessions'),
|
|
60
|
+
},
|
|
46
61
|
];
|
|
47
62
|
|
|
48
63
|
export function detectInstalledTools() {
|