@vibe-cafe/vibe-usage 0.10.0 → 0.10.2

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
@@ -50,7 +50,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
50
50
 
51
51
  | Tool | Data Location |
52
52
  |------|---------------|
53
- | Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only); also scans `$CLAUDE_CONFIG_DIR` when set (deduped), so relocated configs and GUI/CLI env mismatches are both covered |
53
+ | Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only); also scans `$CLAUDE_CONFIG_DIR` and data-bearing `~/.claude-*` profiles, selects the most complete duplicate session, and streams logs so large active sessions are not silently omitted. Cache creation tokens are included in input usage. |
54
54
  | Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`); a versioned local index avoids re-reading unchanged rollouts and reads only safe append tails for ordinary sessions, while fork/sub-agent replay matching, duplicate suppression, and live/archive deduplication retain their existing semantics |
55
55
  | Grok | `$GROK_HOME/sessions/<encoded-cwd>/<session-id>/` (default `~/.grok`); token usage from `updates.jsonl` `turn_completed.usage` (per-model `modelUsage`, cache reads, reasoning); project from `summary.json` cwd; honors `GROK_HOME` |
56
56
  | GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/api.js CHANGED
@@ -14,7 +14,7 @@ export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
14
14
  let lastError;
15
15
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
16
16
  try {
17
- return await _send(apiUrl, apiKey, buckets, opts?.onProgress, sessions);
17
+ return await _send(apiUrl, apiKey, buckets, opts, sessions);
18
18
  } catch (err) {
19
19
  lastError = err;
20
20
  // Don't retry auth errors or client errors
@@ -30,14 +30,22 @@ export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
30
30
  throw lastError;
31
31
  }
32
32
 
33
- function _send(apiUrl, apiKey, buckets, onProgress, sessions) {
33
+ export function encodeIngestBody(buckets, opts, sessions) {
34
+ const payload = { buckets };
35
+ if (sessions && sessions.length > 0) payload.sessions = sessions;
36
+ if (opts?.client) payload.client = opts.client;
37
+ const raw = Buffer.from(JSON.stringify(payload));
38
+ const useGzip = raw.length >= GZIP_MIN_BYTES;
39
+ return {
40
+ body: useGzip ? gzipSync(raw) : raw,
41
+ useGzip,
42
+ };
43
+ }
44
+
45
+ function _send(apiUrl, apiKey, buckets, opts, sessions) {
34
46
  return new Promise((resolve, reject) => {
35
47
  const url = new URL('/api/usage/ingest', apiUrl);
36
- const payload = { buckets };
37
- if (sessions && sessions.length > 0) payload.sessions = sessions;
38
- const raw = Buffer.from(JSON.stringify(payload));
39
- const useGzip = raw.length >= GZIP_MIN_BYTES;
40
- const body = useGzip ? gzipSync(raw) : raw;
48
+ const { body, useGzip } = encodeIngestBody(buckets, opts, sessions);
41
49
  const totalBytes = body.length;
42
50
  const mod = url.protocol === 'https:' ? https : http;
43
51
 
@@ -89,7 +97,7 @@ function _send(apiUrl, apiKey, buckets, onProgress, sessions) {
89
97
  while (ok && sent < totalBytes) {
90
98
  const slice = body.subarray(sent, sent + CHUNK);
91
99
  sent += slice.length;
92
- if (onProgress) onProgress(sent, totalBytes);
100
+ if (opts?.onProgress) opts.onProgress(sent, totalBytes);
93
101
  ok = req.write(slice);
94
102
  }
95
103
  if (sent < totalBytes) {
@@ -0,0 +1,81 @@
1
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import { delimiter, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ function expandHome(value) {
6
+ const trimmed = value.trim().replace(/[/\\]+$/, '');
7
+ if (trimmed === '~') return homedir();
8
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
9
+ return join(homedir(), trimmed.slice(2));
10
+ }
11
+ return trimmed;
12
+ }
13
+
14
+ function hasClaudeData(root) {
15
+ return existsSync(join(root, 'projects')) || existsSync(join(root, 'transcripts'));
16
+ }
17
+
18
+ /**
19
+ * Return every Claude Code state root visible from this process.
20
+ *
21
+ * In addition to the default and CLAUDE_CONFIG_DIR, discover the documented
22
+ * multi-profile convention (~/.claude-work, ~/.claude-personal, ...). This is
23
+ * important for launchd/systemd and GUI processes, which commonly do not
24
+ * inherit the shell environment used to launch Claude Code.
25
+ *
26
+ * VIBE_USAGE_CLAUDE_DIRS is a test/diagnostic override. It replaces discovery
27
+ * with a path.delimiter-separated root list.
28
+ */
29
+ export function getClaudeRoots() {
30
+ const override = process.env.VIBE_USAGE_CLAUDE_DIRS?.trim();
31
+ const roots = override
32
+ ? override.split(delimiter).map(expandHome).filter(Boolean)
33
+ : [join(homedir(), '.claude')];
34
+
35
+ if (!override) {
36
+ const configured = process.env.CLAUDE_CONFIG_DIR?.trim();
37
+ if (configured) roots.push(expandHome(configured));
38
+
39
+ try {
40
+ for (const entry of readdirSync(homedir(), { withFileTypes: true })) {
41
+ // Profiles are sometimes symlinked, so let hasClaudeData() follow the
42
+ // entry instead of requiring Dirent.isDirectory() here.
43
+ if (!/^\.claude-.+/.test(entry.name)) continue;
44
+ const candidate = join(homedir(), entry.name);
45
+ if (hasClaudeData(candidate)) roots.push(candidate);
46
+ }
47
+ } catch {
48
+ // The default/configured roots remain usable if home discovery fails.
49
+ }
50
+ }
51
+
52
+ const seen = new Set();
53
+ const unique = [];
54
+ for (const root of roots) {
55
+ let canonical = root;
56
+ try {
57
+ canonical = realpathSync(root);
58
+ } catch {
59
+ // Keep a missing explicit/default root so callers can report it normally.
60
+ }
61
+ if (seen.has(canonical)) continue;
62
+ seen.add(canonical);
63
+ unique.push(root);
64
+ }
65
+ return unique;
66
+ }
67
+
68
+ export function findClaudeCodeDataDirs() {
69
+ const dirs = [];
70
+ for (const root of getClaudeRoots()) {
71
+ for (const name of ['projects', 'transcripts']) {
72
+ const candidate = join(root, name);
73
+ try {
74
+ if (statSync(candidate).isDirectory()) dirs.push(candidate);
75
+ } catch {
76
+ // Missing or unreadable roots are handled by the parser.
77
+ }
78
+ }
79
+ }
80
+ return dirs;
81
+ }
@@ -0,0 +1,44 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ const pkg = JSON.parse(
5
+ readFileSync(new URL('../package.json', import.meta.url), 'utf-8'),
6
+ );
7
+
8
+ const SURFACES = new Set(['cli', 'daemon', 'mac-app', 'windows-app']);
9
+
10
+ export const COLLECTOR_VERSION = String(pkg.version);
11
+
12
+ function cleanEnv(value, maxLength = 50) {
13
+ if (typeof value !== 'string') return null;
14
+ const cleaned = value.trim().slice(0, maxLength);
15
+ return cleaned || null;
16
+ }
17
+
18
+ export function createSyncClient({ defaultSurface = 'cli', hostname } = {}) {
19
+ const requestedSurface = cleanEnv(process.env.VIBE_USAGE_SURFACE, 30);
20
+ const surface = requestedSurface && SURFACES.has(requestedSurface)
21
+ ? requestedSurface
22
+ : defaultSurface;
23
+ const runtime = process.versions.bun ? 'bun' : 'node';
24
+ const runtimeVersion = process.versions.bun || process.versions.node;
25
+
26
+ return {
27
+ collectorVersion: COLLECTOR_VERSION,
28
+ surface,
29
+ surfaceVersion: cleanEnv(process.env.VIBE_USAGE_SURFACE_VERSION) || COLLECTOR_VERSION,
30
+ runtime,
31
+ runtimeVersion,
32
+ platform: process.platform,
33
+ hostname: cleanEnv(hostname, 200) || 'unknown',
34
+ syncId: randomUUID(),
35
+ };
36
+ }
37
+
38
+ export function forBatch(client, batchIndex, batchCount) {
39
+ return {
40
+ ...client,
41
+ batchIndex,
42
+ batchCount,
43
+ };
44
+ }
@@ -43,7 +43,23 @@ function getServicePaths(plat) {
43
43
  return null;
44
44
  }
45
45
 
46
- function generateSystemdUnit(nodePath, binPath) {
46
+ function escapeSystemdEnvironment(value) {
47
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
48
+ }
49
+
50
+ function escapeXml(value) {
51
+ return value
52
+ .replace(/&/g, '&amp;')
53
+ .replace(/</g, '&lt;')
54
+ .replace(/>/g, '&gt;')
55
+ .replace(/"/g, '&quot;')
56
+ .replace(/'/g, '&apos;');
57
+ }
58
+
59
+ export function generateSystemdUnit(nodePath, binPath, claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim()) {
60
+ const claudeEnvironment = claudeConfigDir
61
+ ? `Environment="CLAUDE_CONFIG_DIR=${escapeSystemdEnvironment(claudeConfigDir)}"\n`
62
+ : '';
47
63
  return `[Unit]
48
64
  Description=VibeCafe Usage Tracker
49
65
  After=network.target
@@ -54,15 +70,18 @@ ExecStart=${nodePath} ${binPath} daemon
54
70
  Restart=on-failure
55
71
  RestartSec=10
56
72
  Environment=NODE_ENV=production
57
- WorkingDirectory=${homedir()}
73
+ ${claudeEnvironment}WorkingDirectory=${homedir()}
58
74
 
59
75
  [Install]
60
76
  WantedBy=default.target
61
77
  `;
62
78
  }
63
79
 
64
- function generateLaunchdPlist(nodePath, binPath) {
80
+ export function generateLaunchdPlist(nodePath, binPath, claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim()) {
65
81
  const logDir = join(homedir(), '.vibe-usage');
82
+ const claudeEnvironment = claudeConfigDir
83
+ ? ` <key>CLAUDE_CONFIG_DIR</key>\n <string>${escapeXml(claudeConfigDir)}</string>\n`
84
+ : '';
66
85
  return `<?xml version="1.0" encoding="UTF-8"?>
67
86
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
68
87
  <plist version="1.0">
@@ -89,7 +108,7 @@ function generateLaunchdPlist(nodePath, binPath) {
89
108
  <dict>
90
109
  <key>NODE_ENV</key>
91
110
  <string>production</string>
92
- </dict>
111
+ ${claudeEnvironment} </dict>
93
112
  </dict>
94
113
  </plist>
95
114
  `;
package/src/daemon.js CHANGED
@@ -34,7 +34,7 @@ export async function runDaemon() {
34
34
  // eslint-disable-next-line no-constant-condition
35
35
  while (true) {
36
36
  try {
37
- await runSync({ throws: true, quiet: true });
37
+ await runSync({ throws: true, quiet: true, surface: 'daemon' });
38
38
  consecutiveAuthFailures = 0;
39
39
  } catch (err) {
40
40
  if (err.message === 'UNAUTHORIZED') {
@@ -1,243 +1,305 @@
1
- import { readdirSync, readFileSync, existsSync, realpathSync } from 'node:fs';
1
+ import { createReadStream, readdirSync, statSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
2
3
  import { join, basename, sep } from 'node:path';
3
- import { homedir } from 'node:os';
4
4
  import { aggregateToBuckets, extractSessions } from './index.js';
5
+ import { getClaudeRoots } from '../claude-roots.js';
5
6
 
6
- /**
7
- * Stateless Claude Code parser.
8
- * Reads ALL *.jsonl files under <root>/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
- *
13
- * Roots: always ~/.claude, plus $CLAUDE_CONFIG_DIR when set to a different
14
- * path. Claude Code itself relocates its whole tree (incl. projects/) to
15
- * $CLAUDE_CONFIG_DIR and uses only that dir — but a GUI launched from the
16
- * Dock may not inherit the shell's env, so usage can be split across both
17
- * roots. We scan both and dedup so neither source is missed or double-counted.
18
- */
7
+ const MAX_WARNINGS = 20;
19
8
 
20
- /**
21
- * Resolve the set of Claude config roots to scan.
22
- * Always includes ~/.claude; adds $CLAUDE_CONFIG_DIR when set and it resolves
23
- * to a different real path. Deduped by canonical path.
24
- */
25
- function getClaudeRoots() {
26
- const roots = [join(homedir(), '.claude')];
27
-
28
- const cfg = process.env.CLAUDE_CONFIG_DIR?.trim();
29
- if (cfg) {
30
- let custom = cfg;
31
- if (custom.startsWith('~')) custom = join(homedir(), custom.slice(1));
32
- custom = custom.replace(/[/\\]+$/, '') || custom;
33
- roots.push(custom);
34
- }
9
+ function addWarning(ctx, message) {
10
+ ctx.incomplete = true;
11
+ if (ctx.warnings.length < MAX_WARNINGS) ctx.warnings.push(message);
12
+ }
35
13
 
36
- // Dedup by canonical path (realpath when the dir exists, else the raw string).
37
- const seen = new Set();
38
- const unique = [];
39
- for (const r of roots) {
40
- let key = r;
41
- try {
42
- key = realpathSync(r);
43
- } catch {
44
- // dir may not exist yet — fall back to the literal path
14
+ /** Recursively collect JSONL files without making one unreadable branch fatal. */
15
+ function findJsonlFiles(dir, ctx) {
16
+ let entries;
17
+ try {
18
+ entries = readdirSync(dir, { withFileTypes: true });
19
+ } catch (err) {
20
+ if (err?.code !== 'ENOENT') {
21
+ addWarning(ctx, `Claude Code: cannot read directory ${dir}: ${err.message}`);
45
22
  }
46
- if (seen.has(key)) continue;
47
- seen.add(key);
48
- unique.push(r);
23
+ return [];
49
24
  }
50
- return unique;
51
- }
52
25
 
53
- /**
54
- * Recursively find all .jsonl files under a directory.
55
- * Claude Code stores sessions in two layouts:
56
- * 2-layer: projects/{projectPath}/{sessionId}.jsonl
57
- * 3-layer: projects/{projectPath}/{sessionId}/subagents/agent-*.jsonl
58
- */
59
- function findJsonlFiles(dir) {
60
26
  const results = [];
61
- if (!existsSync(dir)) return results;
62
- try {
63
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
64
- const fullPath = join(dir, entry.name);
65
- if (entry.isDirectory()) {
66
- results.push(...findJsonlFiles(fullPath));
67
- } else if (entry.name.endsWith('.jsonl')) {
68
- results.push(fullPath);
69
- }
27
+ for (const entry of entries) {
28
+ const fullPath = join(dir, entry.name);
29
+ if (entry.isDirectory()) {
30
+ results.push(...findJsonlFiles(fullPath, ctx));
31
+ } else if (entry.name.endsWith('.jsonl')) {
32
+ results.push(fullPath);
70
33
  }
71
- } catch {
72
- // ignore unreadable directories
73
34
  }
74
35
  return results;
75
36
  }
76
37
 
77
- /**
78
- * Path of a project file relative to its root's projects/ dir, e.g.
79
- * "<root>/projects/-Users-foo-app/abc.jsonl" → "-Users-foo-app/abc.jsonl".
80
- * Used both for project-name extraction and cross-root dedup.
81
- */
82
38
  function projectRelativePath(filePath, projectsDir) {
83
39
  const prefix = projectsDir + sep;
84
40
  return filePath.startsWith(prefix) ? filePath.slice(prefix.length) : null;
85
41
  }
86
42
 
87
- /**
88
- * Extract project name from a projects-relative path.
89
- * The first segment is the dash-encoded project path (e.g. -Users-foo-myproject);
90
- * we take its last component as the project name.
91
- */
92
- function extractProject(relative) {
43
+ /** Best-effort fallback for old records without cwd. */
44
+ function projectFromRelative(relative) {
93
45
  if (!relative) return 'unknown';
94
- const firstSeg = relative.split(sep)[0];
95
- if (!firstSeg) return 'unknown';
96
- const parts = firstSeg.split('-').filter(Boolean);
97
- return parts.length > 0 ? parts[parts.length - 1] : 'unknown';
46
+ const firstSegment = relative.split(sep)[0];
47
+ if (!firstSegment) return 'unknown';
48
+ const parts = firstSegment.split('-').filter(Boolean);
49
+ return parts.at(-1) || 'unknown';
50
+ }
51
+
52
+ /** Works for Unix and Windows cwd values regardless of the current OS. */
53
+ function projectFromCwd(cwd, fallback) {
54
+ if (typeof cwd !== 'string') return fallback;
55
+ const trimmed = cwd.trim().replace(/[\\/]+$/, '');
56
+ if (!trimmed) return fallback;
57
+ return trimmed.split(/[\\/]/).filter(Boolean).at(-1) || fallback;
58
+ }
59
+
60
+ function toCount(value) {
61
+ const n = Number(value);
62
+ return Number.isFinite(n) && n > 0 ? n : 0;
63
+ }
64
+
65
+ function cacheCreationTokens(usage) {
66
+ const direct = toCount(usage.cache_creation_input_tokens);
67
+ const breakdown = usage.cache_creation || {};
68
+ const split =
69
+ toCount(breakdown.ephemeral_5m_input_tokens) +
70
+ toCount(breakdown.ephemeral_1h_input_tokens);
71
+ // Current Claude logs carry both the total and its TTL breakdown. max()
72
+ // avoids double-counting while remaining tolerant of partially populated logs.
73
+ return Math.max(direct, split);
98
74
  }
99
75
 
100
- function extractSessionId(filePath) {
101
- return basename(filePath, '.jsonl');
76
+ function candidateIsBetter(next, current) {
77
+ if (!current) return true;
78
+ if (next.size !== current.size) return next.size > current.size;
79
+ if (next.mtimeMs !== current.mtimeMs) return next.mtimeMs > current.mtimeMs;
80
+ return next.filePath.localeCompare(current.filePath) < 0;
102
81
  }
103
82
 
104
83
  /**
105
- * Scan one root's projects/ dir token entries + session events (mutates ctx).
84
+ * Group physical files by logical session id and keep candidates ordered by
85
+ * completeness. A session copied between ~/.claude and CLAUDE_CONFIG_DIR must
86
+ * use its largest/newest copy, not whichever root happened to be scanned first.
106
87
  */
107
- function scanProjectsRoot(root, ctx) {
108
- const projectsDir = join(root, 'projects');
109
-
110
- for (const filePath of findJsonlFiles(projectsDir)) {
111
- const relative = projectRelativePath(filePath, projectsDir);
112
- // Same session present under two roots (e.g. data copied between them):
113
- // process it once so session message counts aren't inflated.
114
- if (relative !== null) {
115
- if (ctx.seenProjectFiles.has(relative)) continue;
116
- ctx.seenProjectFiles.add(relative);
117
- }
118
-
119
- let content;
120
- try {
121
- content = readFileSync(filePath, 'utf-8');
122
- } catch {
123
- continue;
88
+ function collectCandidates(roots, directoryName, ctx) {
89
+ const groups = new Map();
90
+ for (const root of roots) {
91
+ const baseDir = join(root, directoryName);
92
+ for (const filePath of findJsonlFiles(baseDir, ctx)) {
93
+ let stat;
94
+ try {
95
+ stat = statSync(filePath);
96
+ } catch (err) {
97
+ addWarning(ctx, `Claude Code: cannot stat ${filePath}: ${err.message}`);
98
+ continue;
99
+ }
100
+ const sessionId = basename(filePath, '.jsonl');
101
+ const relative = projectRelativePath(filePath, baseDir);
102
+ const candidate = {
103
+ filePath,
104
+ sessionId,
105
+ size: stat.size,
106
+ mtimeMs: stat.mtimeMs,
107
+ fallbackProject: directoryName === 'projects'
108
+ ? projectFromRelative(relative)
109
+ : 'unknown',
110
+ };
111
+ const group = groups.get(sessionId) || [];
112
+ group.push(candidate);
113
+ groups.set(sessionId, group);
124
114
  }
115
+ }
116
+ for (const group of groups.values()) {
117
+ group.sort((a, b) => candidateIsBetter(a, b) ? -1 : candidateIsBetter(b, a) ? 1 : 0);
118
+ }
119
+ return groups;
120
+ }
125
121
 
126
- const project = extractProject(relative);
127
- const sessionId = extractSessionId(filePath);
128
- ctx.seenSessionIds.add(sessionId);
122
+ /** Read only the file size captured during discovery, so live appends wait. */
123
+ async function readJsonl(candidate, onObject) {
124
+ if (candidate.size === 0) return;
125
+ const stream = createReadStream(candidate.filePath, {
126
+ encoding: 'utf8',
127
+ start: 0,
128
+ end: candidate.size - 1,
129
+ });
130
+ let streamError = null;
131
+ stream.on('error', (err) => { streamError = err; });
132
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
129
133
 
130
- for (const line of content.split('\n')) {
134
+ try {
135
+ for await (const line of lines) {
131
136
  if (!line.trim()) continue;
132
137
  try {
133
- const obj = JSON.parse(line);
134
-
135
- const timestamp = obj.timestamp;
136
- if (!timestamp) continue;
137
- const ts = new Date(timestamp);
138
- if (isNaN(ts.getTime())) continue;
139
-
140
- if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
141
- ctx.sessionEvents.push({
142
- sessionId,
143
- source: 'claude-code',
144
- project,
145
- timestamp: ts,
146
- role: obj.type === 'user' ? 'user' : 'assistant',
147
- });
148
- }
149
-
150
- if (obj.type !== 'assistant') continue;
151
- const msg = obj.message;
152
- if (!msg || !msg.usage) continue;
153
-
154
- const usage = msg.usage;
155
- if (usage.input_tokens == null && usage.output_tokens == null) continue;
156
-
157
- const uuid = obj.uuid;
158
- if (uuid) {
159
- if (ctx.seenUuids.has(uuid)) continue;
160
- ctx.seenUuids.add(uuid);
161
- }
162
-
163
- ctx.entries.push({
164
- source: 'claude-code',
165
- model: msg.model || 'unknown',
166
- project,
167
- timestamp: ts,
168
- inputTokens: usage.input_tokens || 0,
169
- outputTokens: usage.output_tokens || 0,
170
- cachedInputTokens: usage.cache_read_input_tokens || 0,
171
- reasoningOutputTokens: 0,
172
- });
138
+ onObject(JSON.parse(line));
173
139
  } catch {
174
- continue;
140
+ // Claude may be appending the final JSONL record while we snapshot it.
141
+ // A later sync will see the complete line; malformed historical lines
142
+ // are isolated instead of taking the whole parser down.
175
143
  }
176
144
  }
145
+ if (streamError) throw streamError;
146
+ } finally {
147
+ lines.close();
148
+ stream.destroy();
177
149
  }
178
150
  }
179
151
 
180
- /**
181
- * Scan one root's transcripts/ dir → session events only (no token data).
182
- * Skips sessions already covered by a projects/ or transcripts/ scan.
183
- */
184
- function scanTranscriptsRoot(root, ctx) {
185
- for (const filePath of findJsonlFiles(join(root, 'transcripts'))) {
186
- const sessionId = extractSessionId(filePath);
187
- if (ctx.seenSessionIds.has(sessionId)) continue;
188
- ctx.seenSessionIds.add(sessionId);
152
+ function timingEvent(obj, sessionId, project) {
153
+ if (
154
+ obj.type !== 'user' &&
155
+ obj.type !== 'assistant' &&
156
+ obj.type !== 'tool_use' &&
157
+ obj.type !== 'tool_result'
158
+ ) return null;
159
+ if (!obj.timestamp) return null;
160
+ const timestamp = new Date(obj.timestamp);
161
+ if (Number.isNaN(timestamp.getTime())) return null;
162
+ return {
163
+ sessionId,
164
+ source: 'claude-code',
165
+ project,
166
+ timestamp,
167
+ role: obj.type === 'user' ? 'user' : 'assistant',
168
+ };
169
+ }
189
170
 
190
- let content;
191
- try {
192
- content = readFileSync(filePath, 'utf-8');
193
- } catch {
194
- continue;
171
+ async function scanProjectCandidate(candidate) {
172
+ const entries = [];
173
+ const events = [];
174
+ let lastModel = null;
175
+ let sessionProject = candidate.fallbackProject;
176
+ let foundSessionCwd = false;
177
+
178
+ await readJsonl(candidate, (obj) => {
179
+ // cwd can change after Claude runs `cd`; project attribution should remain
180
+ // the directory where this session started, not fragment into subfolders.
181
+ if (!foundSessionCwd && typeof obj.cwd === 'string' && obj.cwd.trim()) {
182
+ sessionProject = projectFromCwd(obj.cwd, candidate.fallbackProject);
183
+ foundSessionCwd = true;
195
184
  }
185
+ const event = timingEvent(obj, candidate.sessionId, sessionProject);
186
+ if (event) events.push(event);
196
187
 
197
- for (const line of content.split('\n')) {
198
- if (!line.trim()) continue;
199
- try {
200
- const obj = JSON.parse(line);
201
-
202
- const timestamp = obj.timestamp;
203
- if (!timestamp) continue;
204
- const ts = new Date(timestamp);
205
- if (isNaN(ts.getTime())) continue;
206
-
207
- if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
208
- ctx.sessionEvents.push({
209
- sessionId,
210
- source: 'claude-code',
211
- project: 'unknown',
212
- timestamp: ts,
213
- role: obj.type === 'user' ? 'user' : 'assistant',
214
- });
215
- }
216
- } catch {
217
- continue;
218
- }
188
+ if (obj.type !== 'assistant' || !obj.message?.usage || !obj.timestamp) return;
189
+ const timestamp = new Date(obj.timestamp);
190
+ if (Number.isNaN(timestamp.getTime())) return;
191
+
192
+ const usage = obj.message.usage;
193
+ const rawModel = typeof obj.message.model === 'string'
194
+ ? obj.message.model.trim()
195
+ : '';
196
+ if (rawModel && rawModel !== '<synthetic>') lastModel = rawModel;
197
+ const model = rawModel && rawModel !== '<synthetic>'
198
+ ? rawModel
199
+ : lastModel || 'claude-unknown';
200
+ const inputTokens = toCount(usage.input_tokens) + cacheCreationTokens(usage);
201
+ const outputTokens = toCount(usage.output_tokens);
202
+ const cachedInputTokens = toCount(usage.cache_read_input_tokens);
203
+ const usageScore = inputTokens + outputTokens + cachedInputTokens;
204
+
205
+ // Synthetic bookkeeping messages are common and carry zero usage. Do not
206
+ // inflate the CLI's bucket count with rows the server will discard anyway.
207
+ if (usageScore === 0) return;
208
+
209
+ entries.push({
210
+ uuid: typeof obj.uuid === 'string' && obj.uuid ? obj.uuid : null,
211
+ usageScore,
212
+ source: 'claude-code',
213
+ model,
214
+ project: sessionProject,
215
+ timestamp,
216
+ inputTokens,
217
+ outputTokens,
218
+ cachedInputTokens,
219
+ reasoningOutputTokens: 0,
220
+ });
221
+ });
222
+
223
+ // A cwd can appear after initial metadata/messages. Normalize the completed
224
+ // session in one place so early records receive the same project label.
225
+ for (const entry of entries) entry.project = sessionProject;
226
+ for (const event of events) event.project = sessionProject;
227
+ return { entries, events };
228
+ }
229
+
230
+ async function scanTranscriptCandidate(candidate) {
231
+ const events = [];
232
+ await readJsonl(candidate, (obj) => {
233
+ const event = timingEvent(
234
+ obj,
235
+ candidate.sessionId,
236
+ projectFromCwd(obj.cwd, 'unknown'),
237
+ );
238
+ if (event) events.push(event);
239
+ });
240
+ return { entries: [], events };
241
+ }
242
+
243
+ async function scanBestCandidate(candidates, scanner, ctx) {
244
+ for (const candidate of candidates) {
245
+ try {
246
+ return await scanner(candidate);
247
+ } catch (err) {
248
+ addWarning(ctx, `Claude Code: cannot read ${candidate.filePath}: ${err.message}`);
219
249
  }
220
250
  }
251
+ return null;
252
+ }
253
+
254
+ function mergeUsageEntry(ctx, entry) {
255
+ if (!entry.uuid) {
256
+ ctx.anonymousEntries.push(entry);
257
+ return;
258
+ }
259
+ const current = ctx.entriesByUuid.get(entry.uuid);
260
+ // Claude sometimes copies the same UUID into another session with zeroed
261
+ // usage. Keep the most complete payload, independent of directory order.
262
+ if (!current || entry.usageScore > current.usageScore) {
263
+ ctx.entriesByUuid.set(entry.uuid, entry);
264
+ }
221
265
  }
222
266
 
223
267
  export async function parse() {
224
268
  const ctx = {
225
- entries: [],
269
+ entriesByUuid: new Map(),
270
+ anonymousEntries: [],
226
271
  sessionEvents: [],
227
- seenUuids: new Set(),
228
- seenSessionIds: new Set(),
229
- seenProjectFiles: new Set(), // projects-relative path → dedup same session across roots
272
+ warnings: [],
273
+ incomplete: false,
230
274
  };
231
-
232
275
  const roots = getClaudeRoots();
276
+ const projectGroups = collectCandidates(roots, 'projects', ctx);
277
+ const projectSessionIds = new Set();
278
+
279
+ for (const [sessionId, candidates] of projectGroups) {
280
+ const parsed = await scanBestCandidate(candidates, scanProjectCandidate, ctx);
281
+ if (!parsed) continue;
282
+ projectSessionIds.add(sessionId);
283
+ ctx.sessionEvents.push(...parsed.events);
284
+ for (const entry of parsed.entries) mergeUsageEntry(ctx, entry);
285
+ }
286
+
287
+ const transcriptGroups = collectCandidates(roots, 'transcripts', ctx);
288
+ for (const [sessionId, candidates] of transcriptGroups) {
289
+ if (projectSessionIds.has(sessionId)) continue;
290
+ const parsed = await scanBestCandidate(candidates, scanTranscriptCandidate, ctx);
291
+ if (parsed) ctx.sessionEvents.push(...parsed.events);
292
+ }
233
293
 
234
- // projects/ yields BOTH token buckets and session events.
235
- for (const root of roots) scanProjectsRoot(root, ctx);
236
- // transcripts/ yields session events only, for sessions not already covered.
237
- for (const root of roots) scanTranscriptsRoot(root, ctx);
294
+ const entries = [
295
+ ...ctx.anonymousEntries,
296
+ ...ctx.entriesByUuid.values(),
297
+ ].map(({ uuid: _uuid, usageScore: _usageScore, ...entry }) => entry);
238
298
 
239
299
  return {
240
- buckets: aggregateToBuckets(ctx.entries),
300
+ buckets: aggregateToBuckets(entries),
241
301
  sessions: extractSessions(ctx.sessionEvents),
302
+ ...(ctx.incomplete ? { skipped: true } : {}),
303
+ ...(ctx.warnings.length > 0 ? { warnings: ctx.warnings } : {}),
242
304
  };
243
305
  }
package/src/sync.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  bucketKey, bucketHash, sessionKey, sessionHash,
6
6
  } from './state.js';
7
7
  import { ingest, fetchSettings } from './api.js';
8
+ import { createSyncClient, forBatch } from './client-meta.js';
8
9
  import { parsers } from './parsers/index.js';
9
10
  import { success, failure, arrow, link, dim } from './output.js';
10
11
 
@@ -17,7 +18,7 @@ function formatBytes(bytes) {
17
18
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
18
19
  }
19
20
 
20
- export async function runSync({ throws = false, quiet = false } = {}) {
21
+ export async function runSync({ throws = false, quiet = false, surface = 'cli' } = {}) {
21
22
  const config = loadConfig();
22
23
  if (!config?.apiKey) {
23
24
  console.error(failure('尚未配置,请先运行 `npx @vibe-cafe/vibe-usage init`。'));
@@ -51,6 +52,11 @@ export async function runSync({ throws = false, quiet = false } = {}) {
51
52
  if (result?.indexing) {
52
53
  parserProgress.push({ source, ...result.indexing });
53
54
  }
55
+ if (Array.isArray(result?.warnings)) {
56
+ for (const message of result.warnings) {
57
+ process.stderr.write(`${dim(` ${message}`)}\n`);
58
+ }
59
+ }
54
60
  // A parser may deliberately suppress a transient error (Cursor network
55
61
  // timeout) to keep daemon logs quiet. Its empty result is not proof that
56
62
  // its prior data disappeared, so it must not be pruned this run.
@@ -185,10 +191,14 @@ export async function runSync({ throws = false, quiet = false } = {}) {
185
191
  let totalIngested = 0;
186
192
  let totalSessionsSynced = 0;
187
193
  let totalDroppedBuckets = 0;
194
+ let totalDroppedUnknownModels = 0;
195
+ let totalDroppedImplausible = 0;
196
+ let totalProtectedBuckets = 0;
188
197
  const droppedSources = new Set();
189
198
  const bucketBatches = Math.ceil(allBucketsToSend.length / BATCH_SIZE);
190
199
  const sessionBatches = Math.ceil(allSessionsToSend.length / SESSION_BATCH_SIZE);
191
200
  const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
201
+ const syncClient = createSyncClient({ defaultSurface: surface, hostname: host });
192
202
 
193
203
  try {
194
204
  for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
@@ -198,6 +208,7 @@ export async function runSync({ throws = false, quiet = false } = {}) {
198
208
  const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
199
209
 
200
210
  const result = await ingest(apiUrl, config.apiKey, batch, {
211
+ client: forBatch(syncClient, batchIdx, totalBatches),
201
212
  onProgress(sent, total) {
202
213
  const pct = Math.round((sent / total) * 100);
203
214
  process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)`)}\x1b[K`);
@@ -205,16 +216,24 @@ export async function runSync({ throws = false, quiet = false } = {}) {
205
216
  }, batchSessions.length > 0 ? batchSessions : undefined);
206
217
  totalIngested += result.ingested ?? batch.length;
207
218
  totalSessionsSynced += result.sessions ?? 0;
219
+ const batchUnknownSources = new Set(result.dropped?.unknownSources || []);
208
220
  if (result.dropped) {
209
221
  totalDroppedBuckets += Number(result.dropped.buckets) || 0;
222
+ totalDroppedUnknownModels += Number(result.dropped.unknownModels) || 0;
223
+ totalDroppedImplausible += Number(result.dropped.implausible) || 0;
210
224
  for (const s of result.dropped.unknownSources || []) droppedSources.add(s);
211
225
  }
226
+ totalProtectedBuckets += Number(result.protected?.buckets) || 0;
212
227
 
213
228
  // Commit only this batch's hashes, only after it uploaded successfully.
214
229
  // A batch that throws aborts the loop with its keys still absent from
215
230
  // state, so the next sync re-sends exactly those items — no data loss,
216
231
  // no silent gaps.
217
232
  for (const b of batch) {
233
+ // A source unknown to an older backend may become valid after deploy.
234
+ // Leave those hashes uncommitted so the next sync retries them instead
235
+ // of turning a temporary release-order mismatch into permanent loss.
236
+ if (batchUnknownSources.has(b.source)) continue;
218
237
  const key = bucketKey(b);
219
238
  const entry = pendingBucketState.get(key);
220
239
  if (entry) state.buckets[key] = entry;
@@ -235,11 +254,18 @@ export async function runSync({ throws = false, quiet = false } = {}) {
235
254
  console.log(success(`已同步 ${syncParts.join(' · ')}`));
236
255
 
237
256
  if (totalDroppedBuckets > 0) {
238
- // Server doesn't (yet) recognize these source IDs — usually means the
239
- // CLI is newer than the deployed vibe-cafe. Surface so the user knows
240
- // the data wasn't lost on their end, just not stored upstream.
241
- const sourcesList = Array.from(droppedSources).sort().join(', ');
242
- console.log(dim(` ${totalDroppedBuckets} buckets dropped (服务端未收录的 source: ${sourcesList})`));
257
+ const reasons = [];
258
+ if (droppedSources.size > 0) {
259
+ reasons.push(`服务端未收录的 source: ${Array.from(droppedSources).sort().join(', ')}`);
260
+ }
261
+ if (totalDroppedUnknownModels > 0) reasons.push(`模型未知: ${totalDroppedUnknownModels}`);
262
+ if (totalDroppedImplausible > 0) reasons.push(`超出合理范围: ${totalDroppedImplausible}`);
263
+ if (reasons.length === 0) reasons.push('服务端拒绝');
264
+ console.log(dim(` ${totalDroppedBuckets} buckets dropped (${reasons.join(';')})`));
265
+ }
266
+
267
+ if (totalProtectedBuckets > 0) {
268
+ console.log(dim(` 服务端保留了 ${totalProtectedBuckets} 个更大的已有 bucket(本次较小快照未覆盖)`));
243
269
  }
244
270
 
245
271
  if (!quiet && totalSessionsSynced > 0) {
package/src/tools.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { findClaudeCodeDataDirs } from './claude-roots.js';
4
5
 
5
6
  function getCursorStateDbPath() {
6
7
  const rel = join('User', 'globalStorage', 'state.vscdb');
@@ -80,20 +81,6 @@ function findOpenclawDataDirs() {
80
81
  return dirs;
81
82
  }
82
83
 
83
- // Claude Code lives in ~/.claude/projects, but $CLAUDE_CONFIG_DIR relocates its
84
- // whole tree. Detect either so a user who only set CLAUDE_CONFIG_DIR is still
85
- // recognized (the parser scans both roots; see parsers/claude-code.js).
86
- function findClaudeCodeDataDirs() {
87
- const dirs = [join(homedir(), '.claude', 'projects')];
88
- const cfg = process.env.CLAUDE_CONFIG_DIR?.trim();
89
- if (cfg) {
90
- let custom = cfg.startsWith('~') ? join(homedir(), cfg.slice(1)) : cfg;
91
- custom = custom.replace(/[/\\]+$/, '') || custom;
92
- dirs.push(join(custom, 'projects'));
93
- }
94
- return dirs.filter(existsSync);
95
- }
96
-
97
84
  // Codex keeps live sessions in ~/.codex/sessions and moves completed ones to
98
85
  // ~/.codex/archived_sessions. Detect Codex if either dir exists, so a user
99
86
  // whose sessions have all been archived is still recognized.