@vibe-cafe/vibe-usage 0.2.4 → 0.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,39 @@
1
1
  import { loadSessionData } from 'ccusage/data-loader';
2
2
  import { aggregateToBuckets } from './index.js';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
6
+
7
+ const STATE_FILE = join(homedir(), '.vibe-usage', 'claude-code-state.json');
8
+
9
+ /** Pending state staged during parse(), committed only after successful upload. */
10
+ let _pendingState = null;
11
+
12
+ function loadState() {
13
+ try {
14
+ return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
15
+ } catch {
16
+ return {};
17
+ }
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');
24
+ }
25
+
26
+ /**
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.
30
+ */
31
+ export function commitState() {
32
+ if (_pendingState) {
33
+ saveState(_pendingState);
34
+ _pendingState = null;
35
+ }
36
+ }
3
37
 
4
38
  export async function parse(lastSync) {
5
39
  let sessions;
@@ -11,36 +45,80 @@ export async function parse(lastSync) {
11
45
 
12
46
  if (!sessions || sessions.length === 0) return [];
13
47
 
48
+ const state = loadState();
49
+ const nextState = { ...state };
14
50
  const entries = [];
15
51
 
16
52
  for (const session of sessions) {
17
53
  if (lastSync && new Date(session.lastActivity) <= new Date(lastSync)) continue;
18
54
 
55
+ const project = resolveProject(session);
56
+ const sessionKey = `${session.projectPath}\0${session.sessionId}`;
57
+ const prev = state[sessionKey] || {};
58
+
19
59
  for (const breakdown of session.modelBreakdowns || []) {
60
+ const model = breakdown.modelName;
61
+ const prevModel = prev[model] || { i: 0, o: 0, c: 0 };
62
+
63
+ const deltaInput = (breakdown.inputTokens || 0) - (prevModel.i || 0);
64
+ const deltaOutput = (breakdown.outputTokens || 0) - (prevModel.o || 0);
65
+ const deltaCached = (breakdown.cacheReadTokens || 0) - (prevModel.c || 0);
66
+
67
+ // Always record current cumulative totals for next sync
68
+ if (!nextState[sessionKey]) nextState[sessionKey] = {};
69
+ nextState[sessionKey][model] = {
70
+ i: breakdown.inputTokens || 0,
71
+ o: breakdown.outputTokens || 0,
72
+ c: breakdown.cacheReadTokens || 0,
73
+ };
74
+
75
+ // Only emit entries with positive deltas
76
+ if (deltaInput <= 0 && deltaOutput <= 0 && deltaCached <= 0) continue;
77
+
20
78
  entries.push({
21
79
  source: 'claude-code',
22
- model: breakdown.modelName,
23
- project: stripSessionId(session.projectPath),
80
+ model,
81
+ project,
24
82
  timestamp: new Date(session.lastActivity),
25
- inputTokens: breakdown.inputTokens,
26
- outputTokens: breakdown.outputTokens,
27
- cachedInputTokens: breakdown.cacheReadTokens,
83
+ inputTokens: Math.max(0, deltaInput),
84
+ outputTokens: Math.max(0, deltaOutput),
85
+ cachedInputTokens: Math.max(0, deltaCached),
28
86
  reasoningOutputTokens: 0,
29
87
  });
30
88
  }
31
89
  }
32
90
 
91
+ // Stage state — only persisted to disk after successful upload
92
+ _pendingState = nextState;
93
+
33
94
  return aggregateToBuckets(entries);
34
95
  }
35
96
 
36
97
  /**
37
- * Strip session UUID suffix from ccusage project path.
38
- * ccusage returns paths like '-Users-foo-project/77e854f9-...' for subagent sessions.
39
- * We only keep the directory name part before the first '/'.
98
+ * Resolve project name from ccusage session data.
99
+ *
100
+ * ccusage v18 assumes 3-layer: projects/{projectPath}/{sessionId}/{file}.jsonl
101
+ * but Claude Code main sessions are 2-layer: projects/{projectPath}/{sessionId}.jsonl
102
+ *
103
+ * For 2-layer files ccusage incorrectly puts the project dir name into sessionId
104
+ * and sets projectPath to "Unknown Project". We detect and correct this.
105
+ */
106
+ function resolveProject(session) {
107
+ if (session.projectPath === 'Unknown Project') {
108
+ // 2-layer: sessionId actually holds the project directory name
109
+ return cleanProjectDir(session.sessionId);
110
+ }
111
+ // 3-layer: projectPath is correct, strip any session UUID suffix
112
+ return cleanProjectDir(session.projectPath);
113
+ }
114
+
115
+ /**
116
+ * Clean a raw project directory name from ccusage.
117
+ * Strips session UUID suffix for subagent paths like '-Users-foo-project/77e854f9-...'.
40
118
  */
41
- function stripSessionId(raw) {
119
+ function cleanProjectDir(raw) {
42
120
  if (!raw || raw === 'unknown' || raw === 'Unknown Project') return 'unknown';
43
121
  const slashIdx = raw.indexOf('/');
44
- if (slashIdx !== -1) return raw.slice(0, slashIdx);
122
+ if (slashIdx !== -1) raw = raw.slice(0, slashIdx);
45
123
  return raw;
46
124
  }
@@ -1,4 +1,4 @@
1
- import { parse as parseClaudeCode } from './claude-code.js';
1
+ import { parse as parseClaudeCode, commitState as commitClaudeCodeState } 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,6 +12,8 @@ export const parsers = {
12
12
  'openclaw': parseOpenclaw,
13
13
  };
14
14
 
15
+ export const postSyncHooks = [commitClaudeCodeState];
16
+
15
17
  export function roundToHalfHour(date) {
16
18
  const d = new Date(date);
17
19
  d.setMinutes(d.getMinutes() < 30 ? 0 : 30, 0, 0);
package/src/sync.js CHANGED
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { loadConfig, saveConfig } from './config.js';
6
6
  import { ingest } from './api.js';
7
- import { parsers } from './parsers/index.js';
7
+ import { parsers, postSyncHooks } from './parsers/index.js';
8
8
  import { TOOLS } from './hooks.js';
9
9
 
10
10
  const BATCH_SIZE = 100;
@@ -75,6 +75,14 @@ export async function runSync() {
75
75
  saveConfig(config);
76
76
  }
77
77
 
78
+
79
+ // Commit parser state now that all data has been uploaded successfully.
80
+ // State is staged during parse() but only persisted here to prevent
81
+ // data loss if uploads fail (deltas would be re-computed on retry).
82
+ for (const hook of postSyncHooks) {
83
+ try { hook(); } catch { /* best effort */ }
84
+ }
85
+
78
86
  if (totalBatches > 1 || allBuckets.length > 0) {
79
87
  process.stdout.write('\n');
80
88
  }