@vibe-cafe/vibe-usage 0.4.2 → 0.5.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 CHANGED
@@ -40,12 +40,12 @@ npx vibe-usage status # Show config & detected tools
40
40
  - Parses local session logs from each AI coding tool
41
41
  - Aggregates token usage into 30-minute buckets
42
42
  - Uploads to your vibecafe.ai dashboard
43
- - Only syncs new data since last sync (incremental)
43
+ - Stateless: computes full totals from local logs each sync (idempotent, no state files)
44
44
  - For continuous syncing, use `npx vibe-usage daemon` or the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app)
45
45
 
46
46
  ## Config
47
47
 
48
- Config stored at `~/.vibe-usage/config.json`. Contains your API key and last sync timestamp.
48
+ Config stored at `~/.vibe-usage/config.json`. Contains your API key and server URL.
49
49
 
50
50
  ## Daemon Mode
51
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,9 +22,7 @@
22
22
  "codex",
23
23
  "gemini"
24
24
  ],
25
- "dependencies": {
26
- "ccusage": "18.0.5"
27
- },
25
+ "dependencies": {},
28
26
  "repository": {
29
27
  "type": "git",
30
28
  "url": "git+https://github.com/vibe-cafe/vibe-usage.git"
@@ -1,123 +1,121 @@
1
- import { loadSessionData } from 'ccusage/data-loader';
2
- import { aggregateToBuckets } from './index.js';
1
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
+ import { join, basename, sep } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
4
+ import { aggregateToBuckets } from './index.js';
6
5
 
7
- const STATE_FILE = join(homedir(), '.vibe-usage', 'claude-code-state.json');
6
+ /**
7
+ * Stateless Claude Code parser.
8
+ * Reads ALL *.jsonl files under ~/.claude/projects/ and extracts per-message
9
+ * token usage from assistant messages. No state file needed — every sync
10
+ * computes the full bucket totals from raw data, making server-side
11
+ * ON CONFLICT ... DO UPDATE SET idempotent.
12
+ */
8
13
 
9
- /** Pending state staged during parse(), committed only after successful upload. */
10
- let _pendingState = null;
14
+ const CLAUDE_DIR = join(homedir(), '.claude', 'projects');
11
15
 
12
- function loadState() {
16
+ /**
17
+ * Recursively find all .jsonl files under a directory.
18
+ * Claude Code stores sessions in two layouts:
19
+ * 2-layer: projects/{projectPath}/{sessionId}.jsonl
20
+ * 3-layer: projects/{projectPath}/{sessionId}/subagents/agent-*.jsonl
21
+ */
22
+ function findJsonlFiles(dir) {
23
+ const results = [];
24
+ if (!existsSync(dir)) return results;
13
25
  try {
14
- return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
26
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
27
+ const fullPath = join(dir, entry.name);
28
+ if (entry.isDirectory()) {
29
+ results.push(...findJsonlFiles(fullPath));
30
+ } else if (entry.name.endsWith('.jsonl')) {
31
+ results.push(fullPath);
32
+ }
33
+ }
15
34
  } catch {
16
- return {};
35
+ // ignore unreadable directories
17
36
  }
18
- }
19
-
20
- function saveState(state) {
21
- const dir = join(homedir(), '.vibe-usage');
22
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
23
- writeFileSync(STATE_FILE, JSON.stringify(state), 'utf-8');
37
+ return results;
24
38
  }
25
39
 
26
40
  /**
27
- * Commit pending state to disk.
28
- * Called by sync.js AFTER successful upload to ensure we only advance
29
- * the watermark when data has been safely delivered to the server.
41
+ * Extract project name from file path.
42
+ * Path format: ~/.claude/projects/{encodedProjectPath}/{sessionId}.jsonl
43
+ * The encodedProjectPath uses dashes for separators (e.g. -Users-foo-myproject).
44
+ * We extract the last path segment as the project name.
30
45
  */
31
- export function commitState() {
32
- if (_pendingState) {
33
- saveState(_pendingState);
34
- _pendingState = null;
35
- }
46
+ function extractProject(filePath) {
47
+ // Get relative path from the projects dir
48
+ const projectsPrefix = CLAUDE_DIR + sep;
49
+ if (!filePath.startsWith(projectsPrefix)) return 'unknown';
50
+ const relative = filePath.slice(projectsPrefix.length);
51
+ // First segment is the encoded project path
52
+ const firstSeg = relative.split(sep)[0];
53
+ if (!firstSeg) return 'unknown';
54
+ // The encoded path uses dashes: -Users-kalasoo-Projects-myproject
55
+ // Take the last segment after splitting by dash
56
+ const parts = firstSeg.split('-').filter(Boolean);
57
+ return parts.length > 0 ? parts[parts.length - 1] : 'unknown';
36
58
  }
37
59
 
38
60
  export async function parse() {
39
- let sessions;
40
- try {
41
- sessions = await loadSessionData({ mode: 'display' });
42
- } catch {
43
- return [];
44
- }
61
+ if (!existsSync(CLAUDE_DIR)) return [];
45
62
 
46
- if (!sessions || sessions.length === 0) return [];
63
+ const files = findJsonlFiles(CLAUDE_DIR);
64
+ if (files.length === 0) return [];
47
65
 
48
- const state = loadState();
49
- const nextState = { ...state };
50
66
  const entries = [];
67
+ const seenUuids = new Set();
68
+
69
+ for (const filePath of files) {
70
+ let content;
71
+ try {
72
+ content = readFileSync(filePath, 'utf-8');
73
+ } catch {
74
+ continue;
75
+ }
51
76
 
52
- for (const session of sessions) {
53
-
54
- const project = resolveProject(session);
55
- const sessionKey = `${session.projectPath}\0${session.sessionId}`;
56
- const prev = state[sessionKey] || {};
57
-
58
- for (const breakdown of session.modelBreakdowns || []) {
59
- const model = breakdown.modelName;
60
- const prevModel = prev[model] || { i: 0, o: 0, c: 0 };
61
-
62
- const deltaInput = (breakdown.inputTokens || 0) - (prevModel.i || 0);
63
- const deltaOutput = (breakdown.outputTokens || 0) - (prevModel.o || 0);
64
- const deltaCached = (breakdown.cacheReadTokens || 0) - (prevModel.c || 0);
65
-
66
- // Always record current cumulative totals for next sync
67
- if (!nextState[sessionKey]) nextState[sessionKey] = {};
68
- nextState[sessionKey][model] = {
69
- i: breakdown.inputTokens || 0,
70
- o: breakdown.outputTokens || 0,
71
- c: breakdown.cacheReadTokens || 0,
72
- };
73
-
74
- // Only emit entries with positive deltas
75
- if (deltaInput <= 0 && deltaOutput <= 0 && deltaCached <= 0) continue;
76
-
77
- entries.push({
78
- source: 'claude-code',
79
- model,
80
- project,
81
- timestamp: new Date(session.lastActivity),
82
- inputTokens: Math.max(0, deltaInput),
83
- outputTokens: Math.max(0, deltaOutput),
84
- cachedInputTokens: Math.max(0, deltaCached),
85
- reasoningOutputTokens: 0,
86
- });
77
+ const project = extractProject(filePath);
78
+
79
+ for (const line of content.split('\n')) {
80
+ if (!line.trim()) continue;
81
+ try {
82
+ const obj = JSON.parse(line);
83
+
84
+ // Only process assistant messages with usage data
85
+ if (obj.type !== 'assistant') continue;
86
+ const msg = obj.message;
87
+ if (!msg || !msg.usage) continue;
88
+
89
+ const usage = msg.usage;
90
+ if (usage.input_tokens == null && usage.output_tokens == null) continue;
91
+
92
+ // Deduplicate by UUID across all files
93
+ const uuid = obj.uuid;
94
+ if (uuid) {
95
+ if (seenUuids.has(uuid)) continue;
96
+ seenUuids.add(uuid);
97
+ }
98
+
99
+ const timestamp = obj.timestamp;
100
+ if (!timestamp) continue;
101
+ const ts = new Date(timestamp);
102
+ if (isNaN(ts.getTime())) continue;
103
+
104
+ entries.push({
105
+ source: 'claude-code',
106
+ model: msg.model || 'unknown',
107
+ project,
108
+ timestamp: ts,
109
+ inputTokens: usage.input_tokens || 0,
110
+ outputTokens: usage.output_tokens || 0,
111
+ cachedInputTokens: usage.cache_read_input_tokens || 0,
112
+ reasoningOutputTokens: 0,
113
+ });
114
+ } catch {
115
+ continue;
116
+ }
87
117
  }
88
118
  }
89
119
 
90
- // Stage state — only persisted to disk after successful upload
91
- _pendingState = nextState;
92
-
93
120
  return aggregateToBuckets(entries);
94
121
  }
95
-
96
- /**
97
- * Resolve project name from ccusage session data.
98
- *
99
- * ccusage v18 assumes 3-layer: projects/{projectPath}/{sessionId}/{file}.jsonl
100
- * but Claude Code main sessions are 2-layer: projects/{projectPath}/{sessionId}.jsonl
101
- *
102
- * For 2-layer files ccusage incorrectly puts the project dir name into sessionId
103
- * and sets projectPath to "Unknown Project". We detect and correct this.
104
- */
105
- function resolveProject(session) {
106
- if (session.projectPath === 'Unknown Project') {
107
- // 2-layer: sessionId actually holds the project directory name
108
- return cleanProjectDir(session.sessionId);
109
- }
110
- // 3-layer: projectPath is correct, strip any session UUID suffix
111
- return cleanProjectDir(session.projectPath);
112
- }
113
-
114
- /**
115
- * Clean a raw project directory name from ccusage.
116
- * Strips session UUID suffix for subagent paths like '-Users-foo-project/77e854f9-...'.
117
- */
118
- function cleanProjectDir(raw) {
119
- if (!raw || raw === 'unknown' || raw === 'Unknown Project') return 'unknown';
120
- const slashIdx = raw.indexOf('/');
121
- if (slashIdx !== -1) raw = raw.slice(0, slashIdx);
122
- return raw;
123
- }
@@ -1,4 +1,4 @@
1
- import { parse as parseClaudeCode, commitState as commitClaudeCodeState } from './claude-code.js';
1
+ import { parse as parseClaudeCode } from './claude-code.js';
2
2
  import { parse as parseCodex } from './codex.js';
3
3
  import { parse as parseGeminiCli } from './gemini-cli.js';
4
4
  import { parse as parseOpencode } from './opencode.js';
@@ -12,7 +12,6 @@ export const parsers = {
12
12
  'openclaw': parseOpenclaw,
13
13
  };
14
14
 
15
- export const postSyncHooks = [commitClaudeCodeState];
16
15
 
17
16
  export function roundToHalfHour(date) {
18
17
  const d = new Date(date);
package/src/reset.js CHANGED
@@ -1,14 +1,11 @@
1
1
  import { createInterface } from 'node:readline';
2
- import { existsSync, unlinkSync } from 'node:fs';
2
+ import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { homedir, hostname as getHostname } from 'node:os';
5
5
  import { loadConfig, saveConfig } from './config.js';
6
6
  import { deleteAllData } from './api.js';
7
7
  import { runSync } from './sync.js';
8
8
 
9
- const STATE_FILES = [
10
- join(homedir(), '.vibe-usage', 'claude-code-state.json'),
11
- ];
12
9
 
13
10
  function prompt(question) {
14
11
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -73,15 +70,9 @@ export async function runReset(args = []) {
73
70
  }
74
71
  }
75
72
 
76
- // 2. Clear local state
73
+ // 2. Clear local state (legacy — no state files needed for current parsers)
77
74
  config.lastSync = null;
78
75
  saveConfig(config);
79
-
80
- for (const stateFile of STATE_FILES) {
81
- if (existsSync(stateFile)) {
82
- unlinkSync(stateFile);
83
- }
84
- }
85
76
  console.log('Cleared local sync state.');
86
77
 
87
78
  // 3. Re-upload everything
package/src/sync.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { hostname as osHostname } from 'node:os';
2
2
  import { loadConfig, saveConfig } from './config.js';
3
3
  import { ingest, fetchSettings } from './api.js';
4
- import { parsers, postSyncHooks } from './parsers/index.js';
4
+ import { parsers } from './parsers/index.js';
5
5
 
6
6
  const BATCH_SIZE = 100;
7
7
 
@@ -81,16 +81,6 @@ export async function runSync({ throws = false, quiet = false } = {}) {
81
81
  },
82
82
  });
83
83
  totalIngested += result.ingested ?? batch.length;
84
-
85
- // State commit happens after ALL batches complete (see postSyncHooks below)
86
- }
87
-
88
-
89
- // Commit parser state now that all data has been uploaded successfully.
90
- // State is staged during parse() but only persisted here to prevent
91
- // data loss if uploads fail (deltas would be re-computed on retry).
92
- for (const hook of postSyncHooks) {
93
- try { hook(); } catch { /* best effort */ }
94
84
  }
95
85
 
96
86
  if (totalBatches > 1 || allBuckets.length > 0) {