@vibe-cafe/vibe-usage 0.10.0 → 0.10.1
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 +1 -1
- package/package.json +1 -1
- package/src/claude-roots.js +81 -0
- package/src/daemon-service.js +23 -4
- package/src/parsers/claude-code.js +250 -188
- package/src/sync.js +22 -5
- package/src/tools.js +1 -14
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`
|
|
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
|
@@ -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
|
+
}
|
package/src/daemon-service.js
CHANGED
|
@@ -43,7 +43,23 @@ function getServicePaths(plat) {
|
|
|
43
43
|
return null;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
function
|
|
46
|
+
function escapeSystemdEnvironment(value) {
|
|
47
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function escapeXml(value) {
|
|
51
|
+
return value
|
|
52
|
+
.replace(/&/g, '&')
|
|
53
|
+
.replace(/</g, '<')
|
|
54
|
+
.replace(/>/g, '>')
|
|
55
|
+
.replace(/"/g, '"')
|
|
56
|
+
.replace(/'/g, ''');
|
|
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
|
`;
|
|
@@ -1,243 +1,305 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
95
|
-
if (!
|
|
96
|
-
const parts =
|
|
97
|
-
return parts.
|
|
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
|
|
101
|
-
|
|
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
|
-
*
|
|
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
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
134
|
+
try {
|
|
135
|
+
for await (const line of lines) {
|
|
131
136
|
if (!line.trim()) continue;
|
|
132
137
|
try {
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
269
|
+
entriesByUuid: new Map(),
|
|
270
|
+
anonymousEntries: [],
|
|
226
271
|
sessionEvents: [],
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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(
|
|
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
|
@@ -51,6 +51,11 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
51
51
|
if (result?.indexing) {
|
|
52
52
|
parserProgress.push({ source, ...result.indexing });
|
|
53
53
|
}
|
|
54
|
+
if (Array.isArray(result?.warnings)) {
|
|
55
|
+
for (const message of result.warnings) {
|
|
56
|
+
process.stderr.write(`${dim(` ${message}`)}\n`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
54
59
|
// A parser may deliberately suppress a transient error (Cursor network
|
|
55
60
|
// timeout) to keep daemon logs quiet. Its empty result is not proof that
|
|
56
61
|
// its prior data disappeared, so it must not be pruned this run.
|
|
@@ -185,6 +190,8 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
185
190
|
let totalIngested = 0;
|
|
186
191
|
let totalSessionsSynced = 0;
|
|
187
192
|
let totalDroppedBuckets = 0;
|
|
193
|
+
let totalDroppedUnknownModels = 0;
|
|
194
|
+
let totalDroppedImplausible = 0;
|
|
188
195
|
const droppedSources = new Set();
|
|
189
196
|
const bucketBatches = Math.ceil(allBucketsToSend.length / BATCH_SIZE);
|
|
190
197
|
const sessionBatches = Math.ceil(allSessionsToSend.length / SESSION_BATCH_SIZE);
|
|
@@ -205,8 +212,11 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
205
212
|
}, batchSessions.length > 0 ? batchSessions : undefined);
|
|
206
213
|
totalIngested += result.ingested ?? batch.length;
|
|
207
214
|
totalSessionsSynced += result.sessions ?? 0;
|
|
215
|
+
const batchUnknownSources = new Set(result.dropped?.unknownSources || []);
|
|
208
216
|
if (result.dropped) {
|
|
209
217
|
totalDroppedBuckets += Number(result.dropped.buckets) || 0;
|
|
218
|
+
totalDroppedUnknownModels += Number(result.dropped.unknownModels) || 0;
|
|
219
|
+
totalDroppedImplausible += Number(result.dropped.implausible) || 0;
|
|
210
220
|
for (const s of result.dropped.unknownSources || []) droppedSources.add(s);
|
|
211
221
|
}
|
|
212
222
|
|
|
@@ -215,6 +225,10 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
215
225
|
// state, so the next sync re-sends exactly those items — no data loss,
|
|
216
226
|
// no silent gaps.
|
|
217
227
|
for (const b of batch) {
|
|
228
|
+
// A source unknown to an older backend may become valid after deploy.
|
|
229
|
+
// Leave those hashes uncommitted so the next sync retries them instead
|
|
230
|
+
// of turning a temporary release-order mismatch into permanent loss.
|
|
231
|
+
if (batchUnknownSources.has(b.source)) continue;
|
|
218
232
|
const key = bucketKey(b);
|
|
219
233
|
const entry = pendingBucketState.get(key);
|
|
220
234
|
if (entry) state.buckets[key] = entry;
|
|
@@ -235,11 +249,14 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
235
249
|
console.log(success(`已同步 ${syncParts.join(' · ')}`));
|
|
236
250
|
|
|
237
251
|
if (totalDroppedBuckets > 0) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
252
|
+
const reasons = [];
|
|
253
|
+
if (droppedSources.size > 0) {
|
|
254
|
+
reasons.push(`服务端未收录的 source: ${Array.from(droppedSources).sort().join(', ')}`);
|
|
255
|
+
}
|
|
256
|
+
if (totalDroppedUnknownModels > 0) reasons.push(`模型未知: ${totalDroppedUnknownModels}`);
|
|
257
|
+
if (totalDroppedImplausible > 0) reasons.push(`超出合理范围: ${totalDroppedImplausible}`);
|
|
258
|
+
if (reasons.length === 0) reasons.push('服务端拒绝');
|
|
259
|
+
console.log(dim(` ${totalDroppedBuckets} buckets dropped (${reasons.join(';')})`));
|
|
243
260
|
}
|
|
244
261
|
|
|
245
262
|
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.
|