@vibe-cafe/vibe-usage 0.10.15 → 0.10.17
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 +4 -36
- package/package.json +1 -1
- package/src/claude-roots.js +3 -1
- package/src/index.js +1 -21
- package/src/init.js +5 -36
- package/src/parsers/aggregate.js +94 -57
- package/src/parsers/amp.js +1 -1
- package/src/parsers/cindy-ledger.js +3 -2
- package/src/parsers/claude-code.js +84 -24
- package/src/parsers/codex.js +2 -2
- package/src/parsers/droid.js +1 -1
- package/src/parsers/dsh.js +2 -2
- package/src/parsers/kiro.js +3 -3
- package/src/parsers/pi-session-jsonl.js +5 -2
- package/src/parsers/workbuddy.js +5 -2
- package/src/pi-roots.js +6 -2
- package/src/reset.js +7 -14
- package/src/sync.js +45 -145
package/README.md
CHANGED
|
@@ -8,10 +8,10 @@ Track your AI coding tool token usage and sync to [vibecafe.ai](https://vibecafe
|
|
|
8
8
|
npx @vibe-cafe/vibe-usage
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
That's it.
|
|
11
|
+
That's it. The CLI opens [vibecafe.ai/usage/device](https://vibecafe.ai/usage/device) in your browser; sign in, confirm the verification code shown in your terminal, click 「确认链接」, and the CLI receives an API key automatically.
|
|
12
12
|
|
|
13
13
|
After approval, it will:
|
|
14
|
-
1. Save your API key
|
|
14
|
+
1. Save your API key to `~/.vibe-usage/config.json`
|
|
15
15
|
2. Detect installed AI coding tools
|
|
16
16
|
3. Run an initial sync of your usage data
|
|
17
17
|
4. Prompt you to enable the background daemon for continuous syncing (recommended)
|
|
@@ -32,8 +32,6 @@ npx @vibe-cafe/vibe-usage init # Re-run setup via browser login
|
|
|
32
32
|
npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> # Skip browser, use pre-issued key (CI/headless)
|
|
33
33
|
npx @vibe-cafe/vibe-usage sync # Manual sync
|
|
34
34
|
npx @vibe-cafe/vibe-usage sync --extra-codex-home /path/to/.codex # Add another Codex Home for this run only
|
|
35
|
-
npx @vibe-cafe/vibe-usage config set uploadProject false # Never upload project names
|
|
36
|
-
npx @vibe-cafe/vibe-usage config set uploadHostname false # Use an opaque per-install device id
|
|
37
35
|
npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by model / by project)
|
|
38
36
|
npx @vibe-cafe/vibe-usage summary --days N # Same, over the last N days (1-90)
|
|
39
37
|
npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 30m, foreground)
|
|
@@ -157,40 +155,10 @@ Config stored at `~/.vibe-usage/config.json` (dev: `config.dev.json`).
|
|
|
157
155
|
|-----|-------------|
|
|
158
156
|
| `apiKey` | Your API key (starts with `vbu_`) |
|
|
159
157
|
| `apiUrl` | Server URL (default: `https://vibecafe.ai`) |
|
|
160
|
-
| `hostname` | Stable device name
|
|
161
|
-
| `uploadProject` | Local project-name control. `false` always wins over the Web setting |
|
|
162
|
-
| `uploadHostname` | Local device-name control. `false` replaces the name at the final network boundary |
|
|
163
|
-
| `deviceId` | Generated opaque per-install identity used when `uploadHostname=false` |
|
|
158
|
+
| `hostname` | Stable device name for usage tracking (set at init, reused across syncs) |
|
|
164
159
|
| `codexExtraHome` | Optional additional Codex Home scanned together with `$CODEX_HOME` / `~/.codex` |
|
|
165
160
|
|
|
166
|
-
|
|
167
|
-
these keys retain their previous behavior until you choose a value: the Web
|
|
168
|
-
project-name setting remains authoritative, and the configured device name is
|
|
169
|
-
uploaded.
|
|
170
|
-
|
|
171
|
-
```bash
|
|
172
|
-
# Local false cannot be overridden by a later Web setting.
|
|
173
|
-
npx @vibe-cafe/vibe-usage config set uploadProject false
|
|
174
|
-
|
|
175
|
-
# Replaces the device name in buckets, sessions, and sync metadata with a
|
|
176
|
-
# persistent random id such as device-0011223344556677.
|
|
177
|
-
npx @vibe-cafe/vibe-usage config set uploadHostname false
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
The sanitization happens after every parser and before hashing or HTTP
|
|
181
|
-
serialization. `cursor-cloud` remains a fixed, non-identifying sentinel so
|
|
182
|
-
Cursor account exports still deduplicate across computers.
|
|
183
|
-
|
|
184
|
-
These controls prevent future transmissions; they do not silently delete data
|
|
185
|
-
already stored in the cloud. To remove previously uploaded identifiers, run
|
|
186
|
-
`vibe-usage reset` after enabling both controls. A full reset deletes the
|
|
187
|
-
account's existing usage before re-uploading the logs available on this
|
|
188
|
-
computer, so coordinate first if the account syncs multiple computers.
|
|
189
|
-
|
|
190
|
-
When device-name upload is enabled, `hostname` is captured once during `init`
|
|
191
|
-
and reused for all future syncs. This prevents macOS mDNS hostname changes
|
|
192
|
-
(for example, `MacBook-Pro` → `MacBook-Pro-2`) from creating duplicate device
|
|
193
|
-
entries. It can also be set to a non-identifying alias:
|
|
161
|
+
The `hostname` is captured once during `init` and reused for all future syncs. This prevents macOS mDNS hostname changes (e.g., `MacBook-Pro` → `MacBook-Pro-2`) from creating duplicate device entries. To change it manually:
|
|
194
162
|
|
|
195
163
|
```bash
|
|
196
164
|
npx @vibe-cafe/vibe-usage config set hostname my-device-name
|
package/package.json
CHANGED
package/src/claude-roots.js
CHANGED
|
@@ -128,7 +128,9 @@ export function getClaudeRoots({ onWarning = () => {} } = {}) {
|
|
|
128
128
|
// The default/configured roots remain usable if home discovery fails.
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
for (const root of findClaudeDesktopRoots(getClaudeDesktopDataDirs(), onWarning)) {
|
|
132
|
+
roots.push(root);
|
|
133
|
+
}
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
const seen = new Set();
|
package/src/index.js
CHANGED
|
@@ -24,8 +24,6 @@ async function showStatus() {
|
|
|
24
24
|
if (config.codexExtraHome) {
|
|
25
25
|
console.log(` Extra Codex Home: ${config.codexExtraHome}`);
|
|
26
26
|
}
|
|
27
|
-
console.log(` Project names: ${config.uploadProject === false ? 'hidden locally' : 'server setting'}`);
|
|
28
|
-
console.log(` Device name: ${config.uploadHostname === false ? 'anonymous device id' : 'uploaded'}`);
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
console.log('\n Detected tools:');
|
|
@@ -50,15 +48,7 @@ async function showStatus() {
|
|
|
50
48
|
console.log();
|
|
51
49
|
}
|
|
52
50
|
|
|
53
|
-
const
|
|
54
|
-
const VALID_CONFIG_KEYS = [
|
|
55
|
-
'apiKey',
|
|
56
|
-
'apiUrl',
|
|
57
|
-
'hostname',
|
|
58
|
-
'uploadProject',
|
|
59
|
-
'uploadHostname',
|
|
60
|
-
'codexExtraHome',
|
|
61
|
-
];
|
|
51
|
+
const VALID_CONFIG_KEYS = ['apiKey', 'apiUrl', 'hostname', 'codexExtraHome'];
|
|
62
52
|
|
|
63
53
|
function handleConfig(args) {
|
|
64
54
|
const sub = args[0];
|
|
@@ -91,14 +81,6 @@ function handleConfig(args) {
|
|
|
91
81
|
console.error(`Valid keys: ${VALID_CONFIG_KEYS.join(', ')}`);
|
|
92
82
|
process.exit(1);
|
|
93
83
|
}
|
|
94
|
-
if (BOOLEAN_CONFIG_KEYS.has(key)) {
|
|
95
|
-
const normalized = value.toLowerCase();
|
|
96
|
-
if (normalized !== 'true' && normalized !== 'false') {
|
|
97
|
-
console.error(`${key} must be true or false.`);
|
|
98
|
-
process.exit(1);
|
|
99
|
-
}
|
|
100
|
-
value = normalized === 'true';
|
|
101
|
-
}
|
|
102
84
|
if (key === 'codexExtraHome' && value !== '') {
|
|
103
85
|
const validation = validateExtraCodexHome(value);
|
|
104
86
|
if (!validation.ok) {
|
|
@@ -251,8 +233,6 @@ export async function run(rawArgs) {
|
|
|
251
233
|
npx @vibe-cafe/vibe-usage config get <key> Get a config value
|
|
252
234
|
npx @vibe-cafe/vibe-usage config set <key> <value> Set a config value
|
|
253
235
|
npx @vibe-cafe/vibe-usage config set codexExtraHome <path> Persist another Codex Home
|
|
254
|
-
npx @vibe-cafe/vibe-usage config set uploadProject false Never upload project names
|
|
255
|
-
npx @vibe-cafe/vibe-usage config set uploadHostname false Replace the device name with an anonymous id
|
|
256
236
|
npx @vibe-cafe/vibe-usage help Show this help
|
|
257
237
|
`);
|
|
258
238
|
break;
|
package/src/init.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
|
-
import { platform } from 'node:os';
|
|
3
|
+
import { hostname as osHostname, platform } from 'node:os';
|
|
4
4
|
import { loadConfig, saveConfig } from './config.js';
|
|
5
5
|
import { ingest, requestDeviceCode, pollDeviceCode } from './api.js';
|
|
6
|
-
import {
|
|
6
|
+
import { runSync } from './sync.js';
|
|
7
7
|
import { detectInstalledTools } from './tools.js';
|
|
8
8
|
import { bigHeader, success, failure, warn, arrow, link, dim, divider } from './output.js';
|
|
9
9
|
|
|
@@ -30,14 +30,6 @@ function isDaemonPlatform() {
|
|
|
30
30
|
return process.platform === 'linux' || process.platform === 'darwin';
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
async function resolvePrivacyChoice(existingValue, key, question) {
|
|
34
|
-
const configured = resolveOptionalBoolean(existingValue, key);
|
|
35
|
-
if (configured !== undefined) return configured;
|
|
36
|
-
if (!process.stdin.isTTY) return false;
|
|
37
|
-
const answer = (await prompt(question)).toLowerCase();
|
|
38
|
-
return answer === 'y' || answer === 'yes';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
33
|
export async function runInit(options = {}) {
|
|
42
34
|
const { apiKey: providedKey, codexExtraHome } = options;
|
|
43
35
|
|
|
@@ -59,31 +51,7 @@ export async function runInit(options = {}) {
|
|
|
59
51
|
}
|
|
60
52
|
|
|
61
53
|
const apiUrl = process.env.VIBE_USAGE_API_URL || 'https://vibecafe.ai';
|
|
62
|
-
|
|
63
|
-
let uploadHostname;
|
|
64
|
-
let draftConfig;
|
|
65
|
-
let host;
|
|
66
|
-
try {
|
|
67
|
-
uploadProject = await resolvePrivacyChoice(
|
|
68
|
-
existing?.uploadProject,
|
|
69
|
-
'uploadProject',
|
|
70
|
-
'上传项目名以查看按项目统计?项目名可能包含客户或内部代号。 [y/N] ',
|
|
71
|
-
);
|
|
72
|
-
uploadHostname = await resolvePrivacyChoice(
|
|
73
|
-
existing?.uploadHostname,
|
|
74
|
-
'uploadHostname',
|
|
75
|
-
'上传设备名以区分电脑?选择否将使用匿名设备 ID。 [y/N] ',
|
|
76
|
-
);
|
|
77
|
-
draftConfig = {
|
|
78
|
-
...(existing || {}),
|
|
79
|
-
uploadProject,
|
|
80
|
-
uploadHostname,
|
|
81
|
-
};
|
|
82
|
-
host = resolveSyncHostname(draftConfig).hostname;
|
|
83
|
-
} catch (err) {
|
|
84
|
-
console.error(failure(err.message));
|
|
85
|
-
process.exit(1);
|
|
86
|
-
}
|
|
54
|
+
const host = existing?.hostname || osHostname().replace(/\.local$/, '');
|
|
87
55
|
|
|
88
56
|
let apiKey;
|
|
89
57
|
if (providedKey) {
|
|
@@ -109,9 +77,10 @@ export async function runInit(options = {}) {
|
|
|
109
77
|
}
|
|
110
78
|
|
|
111
79
|
const config = {
|
|
112
|
-
...draftConfig,
|
|
113
80
|
apiKey,
|
|
114
81
|
apiUrl,
|
|
82
|
+
hostname: host,
|
|
83
|
+
...(existing?.codexExtraHome ? { codexExtraHome: existing.codexExtraHome } : {}),
|
|
115
84
|
};
|
|
116
85
|
saveConfig(config);
|
|
117
86
|
|
package/src/parsers/aggregate.js
CHANGED
|
@@ -80,6 +80,93 @@ export function aggregateToBuckets(entries) {
|
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Incremental session aggregation for parsers whose event stream is already
|
|
85
|
+
* chronological. `extractSessions()` below keeps the sorting fallback for
|
|
86
|
+
* parsers that emit mixed or out-of-order sessions.
|
|
87
|
+
*/
|
|
88
|
+
export function createSessionAccumulator() {
|
|
89
|
+
return {
|
|
90
|
+
ordered: true,
|
|
91
|
+
first: null,
|
|
92
|
+
last: null,
|
|
93
|
+
lastTimestampMs: null,
|
|
94
|
+
activeSeconds: 0,
|
|
95
|
+
turnStartMs: null,
|
|
96
|
+
turnEndMs: null,
|
|
97
|
+
waitingForFirstResponse: false,
|
|
98
|
+
messageCount: 0,
|
|
99
|
+
userMessageCount: 0,
|
|
100
|
+
userPromptHours: new Array(24).fill(0),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function commitTurn(accumulator) {
|
|
105
|
+
const { turnStartMs, turnEndMs } = accumulator;
|
|
106
|
+
if (turnStartMs !== null && turnEndMs !== null && turnEndMs > turnStartMs) {
|
|
107
|
+
accumulator.activeSeconds += Math.round((turnEndMs - turnStartMs) / 1000);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function accumulateSessionEvent(accumulator, event) {
|
|
112
|
+
const timestampMs = event.timestamp.getTime();
|
|
113
|
+
if (accumulator.lastTimestampMs !== null && timestampMs < accumulator.lastTimestampMs) {
|
|
114
|
+
accumulator.ordered = false;
|
|
115
|
+
}
|
|
116
|
+
if (accumulator.first === null) accumulator.first = event;
|
|
117
|
+
accumulator.last = event;
|
|
118
|
+
accumulator.lastTimestampMs = timestampMs;
|
|
119
|
+
accumulator.messageCount++;
|
|
120
|
+
|
|
121
|
+
if (event.role === 'user') {
|
|
122
|
+
commitTurn(accumulator);
|
|
123
|
+
accumulator.turnStartMs = null;
|
|
124
|
+
accumulator.turnEndMs = null;
|
|
125
|
+
accumulator.waitingForFirstResponse = true;
|
|
126
|
+
accumulator.userMessageCount++;
|
|
127
|
+
accumulator.userPromptHours[event.timestamp.getUTCHours()]++;
|
|
128
|
+
} else if (accumulator.waitingForFirstResponse) {
|
|
129
|
+
accumulator.turnStartMs = timestampMs;
|
|
130
|
+
accumulator.turnEndMs = timestampMs;
|
|
131
|
+
accumulator.waitingForFirstResponse = false;
|
|
132
|
+
} else if (accumulator.turnStartMs !== null) {
|
|
133
|
+
accumulator.turnEndMs = timestampMs;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function sessionAccumulatorIsOrdered(accumulator) {
|
|
138
|
+
return accumulator.ordered;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function finalizeSessionAccumulator(accumulator, sessionId, projectOverride) {
|
|
142
|
+
if (accumulator.first === null || accumulator.last === null) return null;
|
|
143
|
+
if (!accumulator.ordered) {
|
|
144
|
+
throw new TypeError('Session accumulator received out-of-order events');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let activeSeconds = accumulator.activeSeconds;
|
|
148
|
+
const { turnStartMs, turnEndMs } = accumulator;
|
|
149
|
+
if (turnStartMs !== null && turnEndMs !== null && turnEndMs > turnStartMs) {
|
|
150
|
+
activeSeconds += Math.round((turnEndMs - turnStartMs) / 1000);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const first = accumulator.first;
|
|
154
|
+
const last = accumulator.last;
|
|
155
|
+
const sessionHash = createHash('sha256').update(sessionId).digest('hex').slice(0, 16);
|
|
156
|
+
return {
|
|
157
|
+
source: first.source,
|
|
158
|
+
project: projectOverride || first.project || 'unknown',
|
|
159
|
+
sessionHash,
|
|
160
|
+
firstMessageAt: first.timestamp.toISOString(),
|
|
161
|
+
lastMessageAt: last.timestamp.toISOString(),
|
|
162
|
+
durationSeconds: Math.round((last.timestamp - first.timestamp) / 1000),
|
|
163
|
+
activeSeconds,
|
|
164
|
+
messageCount: accumulator.messageCount,
|
|
165
|
+
userMessageCount: accumulator.userMessageCount,
|
|
166
|
+
userPromptHours: accumulator.userPromptHours,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
83
170
|
/**
|
|
84
171
|
* Extract session metadata from timing events.
|
|
85
172
|
* Each event: { sessionId, source, project, timestamp: Date, role: 'user'|'assistant' }
|
|
@@ -90,68 +177,18 @@ export function aggregateToBuckets(entries) {
|
|
|
90
177
|
*/
|
|
91
178
|
export function extractSessions(events) {
|
|
92
179
|
const groups = new Map();
|
|
93
|
-
for (const
|
|
94
|
-
if (!groups.has(
|
|
95
|
-
groups.get(
|
|
180
|
+
for (const event of events) {
|
|
181
|
+
if (!groups.has(event.sessionId)) groups.set(event.sessionId, []);
|
|
182
|
+
groups.get(event.sessionId).push(event);
|
|
96
183
|
}
|
|
97
184
|
|
|
98
185
|
const sessions = [];
|
|
99
186
|
for (const [sessionId, sessionEvents] of groups) {
|
|
100
187
|
sessionEvents.sort((a, b) => a.timestamp - b.timestamp);
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
let activeSeconds = 0;
|
|
107
|
-
let turnStart = null;
|
|
108
|
-
let turnEnd = null;
|
|
109
|
-
let waitingForFirstResponse = false;
|
|
110
|
-
|
|
111
|
-
for (const event of sessionEvents) {
|
|
112
|
-
if (event.role === 'user') {
|
|
113
|
-
if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
|
|
114
|
-
activeSeconds += Math.round((turnEnd - turnStart) / 1000);
|
|
115
|
-
}
|
|
116
|
-
turnStart = null;
|
|
117
|
-
turnEnd = null;
|
|
118
|
-
waitingForFirstResponse = true;
|
|
119
|
-
} else if (waitingForFirstResponse) {
|
|
120
|
-
turnStart = event.timestamp;
|
|
121
|
-
turnEnd = event.timestamp;
|
|
122
|
-
waitingForFirstResponse = false;
|
|
123
|
-
} else if (turnStart !== null) {
|
|
124
|
-
turnEnd = event.timestamp;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
|
|
128
|
-
activeSeconds += Math.round((turnEnd - turnStart) / 1000);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const userPromptHours = new Array(24).fill(0);
|
|
132
|
-
let userMessageCount = 0;
|
|
133
|
-
for (const event of sessionEvents) {
|
|
134
|
-
if (event.role === 'user') {
|
|
135
|
-
userMessageCount++;
|
|
136
|
-
userPromptHours[event.timestamp.getUTCHours()]++;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const sessionHash = createHash('sha256').update(sessionId).digest('hex').slice(0, 16);
|
|
141
|
-
|
|
142
|
-
sessions.push({
|
|
143
|
-
source: first.source,
|
|
144
|
-
project: first.project || 'unknown',
|
|
145
|
-
sessionHash,
|
|
146
|
-
firstMessageAt: first.timestamp.toISOString(),
|
|
147
|
-
lastMessageAt: last.timestamp.toISOString(),
|
|
148
|
-
durationSeconds,
|
|
149
|
-
activeSeconds,
|
|
150
|
-
messageCount: sessionEvents.length,
|
|
151
|
-
userMessageCount,
|
|
152
|
-
userPromptHours,
|
|
153
|
-
});
|
|
188
|
+
const accumulator = createSessionAccumulator();
|
|
189
|
+
for (const event of sessionEvents) accumulateSessionEvent(accumulator, event);
|
|
190
|
+
const session = finalizeSessionAccumulator(accumulator, sessionId);
|
|
191
|
+
if (session) sessions.push(session);
|
|
154
192
|
}
|
|
155
|
-
|
|
156
193
|
return sessions;
|
|
157
194
|
}
|
package/src/parsers/amp.js
CHANGED
|
@@ -17,7 +17,7 @@ function findThreadFiles(dir) {
|
|
|
17
17
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
18
18
|
const fullPath = join(dir, entry.name);
|
|
19
19
|
if (entry.isDirectory()) {
|
|
20
|
-
|
|
20
|
+
for (const nested of findThreadFiles(fullPath)) results.push(nested);
|
|
21
21
|
} else if (entry.isFile() && entry.name.startsWith('T-') && entry.name.endsWith('.json')) {
|
|
22
22
|
results.push(fullPath);
|
|
23
23
|
}
|
|
@@ -75,9 +75,10 @@ export function readCindyHarnessUsage(agentKind) {
|
|
|
75
75
|
const rows = [];
|
|
76
76
|
for (const dbPath of dbPaths) {
|
|
77
77
|
try {
|
|
78
|
-
|
|
78
|
+
const dbRows = queryDbJsonSnapshot(dbPath, CINDY_USAGE_SQL, {
|
|
79
79
|
tempPrefix: 'vibe-usage-cindy-',
|
|
80
|
-
})
|
|
80
|
+
});
|
|
81
|
+
for (const row of dbRows) rows.push(row);
|
|
81
82
|
} catch (error) {
|
|
82
83
|
if (isSqliteUnavailableError(error)) throw sqliteUnavailableError('Cindy');
|
|
83
84
|
// Cindy versions before the daily ledger was introduced have no usage
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { createReadStream, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
3
|
import { join, basename, sep } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
accumulateSessionEvent,
|
|
6
|
+
aggregateToBuckets,
|
|
7
|
+
createSessionAccumulator,
|
|
8
|
+
extractSessions,
|
|
9
|
+
finalizeSessionAccumulator,
|
|
10
|
+
sessionAccumulatorIsOrdered,
|
|
11
|
+
} from './aggregate.js';
|
|
5
12
|
import { projectFromCwd, toCount } from './fs-utils.js';
|
|
6
13
|
import { getClaudeRoots } from '../claude-roots.js';
|
|
7
14
|
|
|
@@ -28,7 +35,7 @@ function findJsonlFiles(dir, ctx) {
|
|
|
28
35
|
for (const entry of entries) {
|
|
29
36
|
const fullPath = join(dir, entry.name);
|
|
30
37
|
if (entry.isDirectory()) {
|
|
31
|
-
|
|
38
|
+
for (const nested of findJsonlFiles(fullPath, ctx)) results.push(nested);
|
|
32
39
|
} else if (entry.name.endsWith('.jsonl')) {
|
|
33
40
|
results.push(fullPath);
|
|
34
41
|
}
|
|
@@ -155,10 +162,46 @@ function timingEvent(obj, sessionId, project) {
|
|
|
155
162
|
role: obj.type === 'user' ? 'user' : 'assistant',
|
|
156
163
|
};
|
|
157
164
|
}
|
|
165
|
+
async function collectTimingEvents(candidate, projectForObject) {
|
|
166
|
+
const events = [];
|
|
167
|
+
await readJsonl(candidate, (obj) => {
|
|
168
|
+
const event = timingEvent(
|
|
169
|
+
obj,
|
|
170
|
+
candidate.sessionId,
|
|
171
|
+
projectForObject(obj),
|
|
172
|
+
);
|
|
173
|
+
if (event) events.push(event);
|
|
174
|
+
});
|
|
175
|
+
return events;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function finalizeCandidateSession(
|
|
179
|
+
candidate,
|
|
180
|
+
accumulator,
|
|
181
|
+
projectForObject,
|
|
182
|
+
projectOverride,
|
|
183
|
+
) {
|
|
184
|
+
if (sessionAccumulatorIsOrdered(accumulator)) {
|
|
185
|
+
return finalizeSessionAccumulator(
|
|
186
|
+
accumulator,
|
|
187
|
+
candidate.sessionId,
|
|
188
|
+
projectOverride,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// JSONL is normally append-ordered. Preserve the old sort semantics for an
|
|
193
|
+
// unusual copied/rewritten file without retaining every event on the common
|
|
194
|
+
// path: re-read only that candidate and let extractSessions() sort it.
|
|
195
|
+
const events = await collectTimingEvents(candidate, projectForObject);
|
|
196
|
+
return extractSessions(events)[0] || null;
|
|
197
|
+
}
|
|
158
198
|
|
|
159
199
|
async function scanProjectCandidate(candidate) {
|
|
160
|
-
const
|
|
161
|
-
|
|
200
|
+
const usageEntries = {
|
|
201
|
+
entriesByKey: new Map(),
|
|
202
|
+
anonymousEntries: [],
|
|
203
|
+
};
|
|
204
|
+
const sessionAccumulator = createSessionAccumulator();
|
|
162
205
|
let lastModel = null;
|
|
163
206
|
let sessionProject = candidate.fallbackProject;
|
|
164
207
|
let foundSessionCwd = false;
|
|
@@ -171,7 +214,7 @@ async function scanProjectCandidate(candidate) {
|
|
|
171
214
|
foundSessionCwd = true;
|
|
172
215
|
}
|
|
173
216
|
const event = timingEvent(obj, candidate.sessionId, sessionProject);
|
|
174
|
-
if (event)
|
|
217
|
+
if (event) accumulateSessionEvent(sessionAccumulator, event);
|
|
175
218
|
|
|
176
219
|
if (obj.type !== 'assistant' || !obj.message?.usage || !obj.timestamp) return;
|
|
177
220
|
const timestamp = new Date(obj.timestamp);
|
|
@@ -194,7 +237,7 @@ async function scanProjectCandidate(candidate) {
|
|
|
194
237
|
// inflate the CLI's bucket count with rows the server will discard anyway.
|
|
195
238
|
if (usageScore === 0) return;
|
|
196
239
|
|
|
197
|
-
|
|
240
|
+
mergeUsageEntry(usageEntries, {
|
|
198
241
|
dedupeKey: usageDedupeKey(obj),
|
|
199
242
|
usageScore,
|
|
200
243
|
source: 'claude-code',
|
|
@@ -210,22 +253,34 @@ async function scanProjectCandidate(candidate) {
|
|
|
210
253
|
|
|
211
254
|
// A cwd can appear after initial metadata/messages. Normalize the completed
|
|
212
255
|
// session in one place so early records receive the same project label.
|
|
213
|
-
for (const entry of
|
|
214
|
-
for (const
|
|
215
|
-
|
|
256
|
+
for (const entry of usageEntries.anonymousEntries) entry.project = sessionProject;
|
|
257
|
+
for (const entry of usageEntries.entriesByKey.values()) entry.project = sessionProject;
|
|
258
|
+
const session = await finalizeCandidateSession(
|
|
259
|
+
candidate,
|
|
260
|
+
sessionAccumulator,
|
|
261
|
+
() => sessionProject,
|
|
262
|
+
sessionProject,
|
|
263
|
+
);
|
|
264
|
+
return { usageEntries, session };
|
|
216
265
|
}
|
|
217
266
|
|
|
218
267
|
async function scanTranscriptCandidate(candidate) {
|
|
219
|
-
const
|
|
268
|
+
const sessionAccumulator = createSessionAccumulator();
|
|
269
|
+
const projectForObject = (obj) => projectFromCwd(obj.cwd, 'unknown');
|
|
220
270
|
await readJsonl(candidate, (obj) => {
|
|
221
271
|
const event = timingEvent(
|
|
222
272
|
obj,
|
|
223
273
|
candidate.sessionId,
|
|
224
|
-
|
|
274
|
+
projectForObject(obj),
|
|
225
275
|
);
|
|
226
|
-
if (event)
|
|
276
|
+
if (event) accumulateSessionEvent(sessionAccumulator, event);
|
|
227
277
|
});
|
|
228
|
-
|
|
278
|
+
const session = await finalizeCandidateSession(
|
|
279
|
+
candidate,
|
|
280
|
+
sessionAccumulator,
|
|
281
|
+
projectForObject,
|
|
282
|
+
);
|
|
283
|
+
return { session };
|
|
229
284
|
}
|
|
230
285
|
|
|
231
286
|
async function scanBestCandidate(candidates, scanner, ctx) {
|
|
@@ -266,11 +321,21 @@ function mergeUsageEntry(ctx, entry) {
|
|
|
266
321
|
}
|
|
267
322
|
}
|
|
268
323
|
|
|
324
|
+
function mergeUsageEntries(target, source) {
|
|
325
|
+
for (const entry of source.anonymousEntries) mergeUsageEntry(target, entry);
|
|
326
|
+
for (const entry of source.entriesByKey.values()) mergeUsageEntry(target, entry);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function* iterateUsageEntries(ctx) {
|
|
330
|
+
for (const entry of ctx.anonymousEntries) yield entry;
|
|
331
|
+
for (const entry of ctx.entriesByKey.values()) yield entry;
|
|
332
|
+
}
|
|
333
|
+
|
|
269
334
|
export async function parse() {
|
|
270
335
|
const ctx = {
|
|
271
336
|
entriesByKey: new Map(),
|
|
272
337
|
anonymousEntries: [],
|
|
273
|
-
|
|
338
|
+
sessions: [],
|
|
274
339
|
warnings: [],
|
|
275
340
|
incomplete: false,
|
|
276
341
|
};
|
|
@@ -284,25 +349,20 @@ export async function parse() {
|
|
|
284
349
|
const parsed = await scanBestCandidate(candidates, scanProjectCandidate, ctx);
|
|
285
350
|
if (!parsed) continue;
|
|
286
351
|
projectSessionIds.add(sessionId);
|
|
287
|
-
ctx.
|
|
288
|
-
|
|
352
|
+
if (parsed.session) ctx.sessions.push(parsed.session);
|
|
353
|
+
mergeUsageEntries(ctx, parsed.usageEntries);
|
|
289
354
|
}
|
|
290
355
|
|
|
291
356
|
const transcriptGroups = collectCandidates(roots, 'transcripts', ctx);
|
|
292
357
|
for (const [sessionId, candidates] of transcriptGroups) {
|
|
293
358
|
if (projectSessionIds.has(sessionId)) continue;
|
|
294
359
|
const parsed = await scanBestCandidate(candidates, scanTranscriptCandidate, ctx);
|
|
295
|
-
if (parsed) ctx.
|
|
360
|
+
if (parsed?.session) ctx.sessions.push(parsed.session);
|
|
296
361
|
}
|
|
297
362
|
|
|
298
|
-
const entries = [
|
|
299
|
-
...ctx.anonymousEntries,
|
|
300
|
-
...ctx.entriesByKey.values(),
|
|
301
|
-
].map(({ dedupeKey: _dedupeKey, usageScore: _usageScore, ...entry }) => entry);
|
|
302
|
-
|
|
303
363
|
return {
|
|
304
|
-
buckets: aggregateToBuckets(
|
|
305
|
-
sessions:
|
|
364
|
+
buckets: aggregateToBuckets(iterateUsageEntries(ctx)),
|
|
365
|
+
sessions: ctx.sessions,
|
|
306
366
|
...(ctx.incomplete ? { skipped: true } : {}),
|
|
307
367
|
...(ctx.warnings.length > 0 ? { warnings: ctx.warnings } : {}),
|
|
308
368
|
};
|
package/src/parsers/codex.js
CHANGED
|
@@ -44,7 +44,7 @@ function findJsonlFiles(dir) {
|
|
|
44
44
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
45
45
|
const fullPath = join(dir, entry.name);
|
|
46
46
|
if (entry.isDirectory()) {
|
|
47
|
-
|
|
47
|
+
for (const nested of findJsonlFiles(fullPath)) results.push(nested);
|
|
48
48
|
} else if (entry.name.endsWith('.jsonl')) {
|
|
49
49
|
results.push(fullPath);
|
|
50
50
|
}
|
|
@@ -792,7 +792,7 @@ function mergeFileResults(results) {
|
|
|
792
792
|
reasoningOutputTokens: bucket.reasoningOutputTokens,
|
|
793
793
|
});
|
|
794
794
|
}
|
|
795
|
-
|
|
795
|
+
for (const session of result.sessions || []) sessions.push(session);
|
|
796
796
|
}
|
|
797
797
|
return { buckets: aggregateToBuckets(entries), sessions };
|
|
798
798
|
}
|
package/src/parsers/droid.js
CHANGED
|
@@ -13,7 +13,7 @@ function findJsonlFiles(dir) {
|
|
|
13
13
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
14
14
|
const fullPath = join(dir, entry.name);
|
|
15
15
|
if (entry.isDirectory()) {
|
|
16
|
-
|
|
16
|
+
for (const nested of findJsonlFiles(fullPath)) results.push(nested);
|
|
17
17
|
} else if (entry.name.endsWith('.jsonl') && !entry.name.endsWith('.settings.json')) {
|
|
18
18
|
results.push(fullPath);
|
|
19
19
|
}
|
package/src/parsers/dsh.js
CHANGED
|
@@ -535,7 +535,7 @@ export async function parse() {
|
|
|
535
535
|
model.parentSessionId == null ? null : perSession.get(model.parentSessionId);
|
|
536
536
|
const skip = parent ? replaySkipCount(model, parent.model) : 0;
|
|
537
537
|
const { entries: fileEntries, events: fileEvents } = modelToResult(model, skip);
|
|
538
|
-
entries.push(
|
|
538
|
+
for (const entry of fileEntries) entries.push(entry);
|
|
539
539
|
for (const event of fileEvents) {
|
|
540
540
|
if (!eventsBySession.has(event.sessionId)) eventsBySession.set(event.sessionId, []);
|
|
541
541
|
eventsBySession.get(event.sessionId).push(event);
|
|
@@ -547,7 +547,7 @@ export async function parse() {
|
|
|
547
547
|
const events = [];
|
|
548
548
|
for (const sessionEvents of eventsBySession.values()) {
|
|
549
549
|
if (sessionEvents.some((event) => event.role === 'user')) {
|
|
550
|
-
events.push(
|
|
550
|
+
for (const event of sessionEvents) events.push(event);
|
|
551
551
|
}
|
|
552
552
|
}
|
|
553
553
|
|
package/src/parsers/kiro.js
CHANGED
|
@@ -326,7 +326,7 @@ export function conversationsToEstimateEntries(conversations) {
|
|
|
326
326
|
}
|
|
327
327
|
}
|
|
328
328
|
for (const conversation of byId.values()) {
|
|
329
|
-
|
|
329
|
+
for (const entry of conversationToEntries(conversation)) entries.push(entry);
|
|
330
330
|
}
|
|
331
331
|
return entries;
|
|
332
332
|
}
|
|
@@ -557,7 +557,7 @@ async function readCliSessionStreamEntries() {
|
|
|
557
557
|
const entries = [];
|
|
558
558
|
for (const file of files) {
|
|
559
559
|
try {
|
|
560
|
-
|
|
560
|
+
for (const entry of await readCliSessionEntries(dir, file)) entries.push(entry);
|
|
561
561
|
} catch {
|
|
562
562
|
// skip unreadable / concurrently rotated session
|
|
563
563
|
}
|
|
@@ -677,7 +677,7 @@ async function readUsageSnapshots(userPath) {
|
|
|
677
677
|
const snapshots = [];
|
|
678
678
|
for (const file of files) {
|
|
679
679
|
try {
|
|
680
|
-
|
|
680
|
+
for (const snapshot of await readLogSnapshots(file)) snapshots.push(snapshot);
|
|
681
681
|
} catch {
|
|
682
682
|
// skip unreadable / concurrently rotated logs
|
|
683
683
|
}
|
|
@@ -23,8 +23,11 @@ function findJsonlFiles(dir, includeFile, ctx) {
|
|
|
23
23
|
const files = [];
|
|
24
24
|
for (const child of children) {
|
|
25
25
|
const filePath = join(dir, child.name);
|
|
26
|
-
if (child.isDirectory())
|
|
27
|
-
|
|
26
|
+
if (child.isDirectory()) {
|
|
27
|
+
for (const nested of findJsonlFiles(filePath, includeFile, ctx)) files.push(nested);
|
|
28
|
+
} else if (child.name.endsWith('.jsonl') && includeFile(filePath)) {
|
|
29
|
+
files.push(filePath);
|
|
30
|
+
}
|
|
28
31
|
}
|
|
29
32
|
return files;
|
|
30
33
|
}
|
package/src/parsers/workbuddy.js
CHANGED
|
@@ -62,8 +62,11 @@ function findJsonlFiles(dir, ctx) {
|
|
|
62
62
|
const files = [];
|
|
63
63
|
for (const child of children) {
|
|
64
64
|
const filePath = join(dir, child.name);
|
|
65
|
-
if (child.isDirectory())
|
|
66
|
-
|
|
65
|
+
if (child.isDirectory()) {
|
|
66
|
+
for (const nested of findJsonlFiles(filePath, ctx)) files.push(nested);
|
|
67
|
+
} else if (child.isFile() && child.name.endsWith('.jsonl')) {
|
|
68
|
+
files.push(filePath);
|
|
69
|
+
}
|
|
67
70
|
}
|
|
68
71
|
return files;
|
|
69
72
|
}
|
package/src/pi-roots.js
CHANGED
|
@@ -63,7 +63,9 @@ export function getOmpSessionDirs() {
|
|
|
63
63
|
const configName = process.env.PI_CONFIG_DIR?.trim() || '.omp';
|
|
64
64
|
const configRoot = join(homedir(), configName);
|
|
65
65
|
dirs.push(join(configRoot, 'agent', 'sessions'));
|
|
66
|
-
|
|
66
|
+
for (const dir of profileSessionDirs(join(configRoot, 'profiles'), true)) {
|
|
67
|
+
dirs.push(dir);
|
|
68
|
+
}
|
|
67
69
|
|
|
68
70
|
const agentOverride = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
69
71
|
if (agentOverride) {
|
|
@@ -78,7 +80,9 @@ export function getOmpSessionDirs() {
|
|
|
78
80
|
if (xdgDataHome) {
|
|
79
81
|
const xdgRoot = join(expandHome(xdgDataHome), 'omp');
|
|
80
82
|
dirs.push(join(xdgRoot, 'sessions'));
|
|
81
|
-
|
|
83
|
+
for (const dir of profileSessionDirs(join(xdgRoot, 'profiles'), false)) {
|
|
84
|
+
dirs.push(dir);
|
|
85
|
+
}
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
package/src/reset.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
|
-
import {
|
|
2
|
+
import { hostname as getHostname } from 'node:os';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
3
4
|
import { deleteAllData } from './api.js';
|
|
4
|
-
import {
|
|
5
|
+
import { runSync } from './sync.js';
|
|
5
6
|
import { clearState } from './state.js';
|
|
6
7
|
import { success, failure, arrow, link, dim } from './output.js';
|
|
7
8
|
|
|
@@ -33,18 +34,10 @@ export async function runReset(args = [], deps = {}) {
|
|
|
33
34
|
process.exit(1);
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
// Target the
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
try {
|
|
41
|
-
hostIdentity = resolveSyncHostname(config);
|
|
42
|
-
if (hostIdentity.changed) saveConfig(config);
|
|
43
|
-
} catch (err) {
|
|
44
|
-
console.error(failure(err.message));
|
|
45
|
-
process.exit(1);
|
|
46
|
-
}
|
|
47
|
-
const currentHost = hostIdentity.hostname;
|
|
37
|
+
// Target the hostname persisted at init — the same one sync.js uploads
|
|
38
|
+
// under. A fresh os.hostname() can have drifted since (macOS mDNS adds -2
|
|
39
|
+
// suffixes), which would delete zero rows, or another machine's rows.
|
|
40
|
+
const currentHost = config.hostname || getHostname().replace(/\.local$/, '');
|
|
48
41
|
const apiUrl = config.apiUrl || 'https://vibecafe.ai';
|
|
49
42
|
|
|
50
43
|
if (hostOnly) {
|
package/src/sync.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { hostname as osHostname } from 'node:os';
|
|
2
|
-
import { randomBytes } from 'node:crypto';
|
|
3
2
|
import { loadConfig, saveConfig } from './config.js';
|
|
4
3
|
import {
|
|
5
4
|
loadState, saveState, pruneState,
|
|
@@ -15,83 +14,13 @@ import { success, failure, warn, arrow, link, dim } from './output.js';
|
|
|
15
14
|
const BATCH_SIZE = 100;
|
|
16
15
|
const SESSION_BATCH_SIZE = 500;
|
|
17
16
|
|
|
18
|
-
const ANONYMOUS_DEVICE_ID_PATTERN = /^device-[0-9a-f]{16}$/;
|
|
19
|
-
const SHARED_HOSTNAME_SENTINELS = new Set(['cursor-cloud']);
|
|
20
|
-
|
|
21
|
-
export function resolveOptionalBoolean(value, key) {
|
|
22
|
-
if (value === undefined) return undefined;
|
|
23
|
-
if (typeof value === 'boolean') return value;
|
|
24
|
-
const error = new Error(`配置 ${key} 必须是 true 或 false。`);
|
|
25
|
-
error.code = 'INVALID_CONFIG';
|
|
26
|
-
throw error;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function resolveSyncHostname(config, {
|
|
30
|
-
systemHostname = () => osHostname().replace(/\.local$/, ''),
|
|
31
|
-
createDeviceId = () => `device-${randomBytes(8).toString('hex')}`,
|
|
32
|
-
} = {}) {
|
|
33
|
-
const uploadHostname = resolveOptionalBoolean(config.uploadHostname, 'uploadHostname') ?? true;
|
|
34
|
-
const previousHostname = typeof config.hostname === 'string' && config.hostname.trim()
|
|
35
|
-
? config.hostname.trim()
|
|
36
|
-
: undefined;
|
|
37
|
-
|
|
38
|
-
if (!uploadHostname) {
|
|
39
|
-
const existingDeviceId = typeof config.deviceId === 'string'
|
|
40
|
-
? config.deviceId.trim().toLowerCase()
|
|
41
|
-
: '';
|
|
42
|
-
const hostname = ANONYMOUS_DEVICE_ID_PATTERN.test(existingDeviceId)
|
|
43
|
-
? existingDeviceId
|
|
44
|
-
: createDeviceId();
|
|
45
|
-
const changed = config.deviceId !== hostname;
|
|
46
|
-
if (changed) config.deviceId = hostname;
|
|
47
|
-
return { hostname, previousHostname, uploadHostname, changed };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const hostname = previousHostname || systemHostname();
|
|
51
|
-
const changed = config.hostname !== hostname;
|
|
52
|
-
if (changed) config.hostname = hostname;
|
|
53
|
-
return { hostname, previousHostname, uploadHostname, changed };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function applyHostnamePrivacy(records, hostname, uploadHostname) {
|
|
57
|
-
for (const record of records) {
|
|
58
|
-
if (uploadHostname) {
|
|
59
|
-
if (!record.hostname) record.hostname = hostname;
|
|
60
|
-
} else if (!SHARED_HOSTNAME_SENTINELS.has(record.hostname)) {
|
|
61
|
-
record.hostname = hostname;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// A hostname privacy toggle changes the server bucket key. Carry unchanged
|
|
67
|
-
// local state across that key change so enabling privacy does not re-upload
|
|
68
|
-
// all historical buckets beside their older server rows. Changed buckets still
|
|
69
|
-
// upload under the anonymous id; `reset` remains the explicit way to remove
|
|
70
|
-
// identifiers that were uploaded before the local control was enabled.
|
|
71
|
-
export function migrateHiddenHostnameState(state, buckets, previousHostname, hostname) {
|
|
72
|
-
if (!previousHostname || previousHostname === hostname) return false;
|
|
73
|
-
let changed = false;
|
|
74
|
-
for (const bucket of buckets) {
|
|
75
|
-
if (bucket.hostname !== hostname) continue;
|
|
76
|
-
const oldKey = bucketKey({ ...bucket, hostname: previousHostname });
|
|
77
|
-
const newKey = bucketKey(bucket);
|
|
78
|
-
const currentHash = bucketHash(bucket);
|
|
79
|
-
if (state.buckets[oldKey] !== currentHash) continue;
|
|
80
|
-
if (!(newKey in state.buckets)) state.buckets[newKey] = currentHash;
|
|
81
|
-
delete state.buckets[oldKey];
|
|
82
|
-
changed = true;
|
|
83
|
-
}
|
|
84
|
-
return changed;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
17
|
function formatBytes(bytes) {
|
|
88
18
|
if (bytes < 1024) return `${bytes}B`;
|
|
89
19
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
90
20
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
91
21
|
}
|
|
92
22
|
|
|
93
|
-
export function resolveUploadProjectSetting(settings
|
|
94
|
-
if (localSetting === false) return false;
|
|
23
|
+
export function resolveUploadProjectSetting(settings) {
|
|
95
24
|
if (typeof settings?.uploadProject !== 'boolean') {
|
|
96
25
|
const error = new Error('SETTINGS_UNAVAILABLE');
|
|
97
26
|
error.code = 'SETTINGS_UNAVAILABLE';
|
|
@@ -161,18 +90,6 @@ export async function runSync({
|
|
|
161
90
|
saveConfig(config);
|
|
162
91
|
}
|
|
163
92
|
|
|
164
|
-
let localUploadProject;
|
|
165
|
-
let hostIdentity;
|
|
166
|
-
try {
|
|
167
|
-
localUploadProject = resolveOptionalBoolean(config.uploadProject, 'uploadProject');
|
|
168
|
-
hostIdentity = resolveSyncHostname(config);
|
|
169
|
-
if (hostIdentity.changed) saveConfig(config);
|
|
170
|
-
} catch (err) {
|
|
171
|
-
console.error(failure(err.message));
|
|
172
|
-
if (throws) throw err;
|
|
173
|
-
process.exit(1);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
93
|
// Privacy is a required input, not an optional hint. If the settings API is
|
|
177
94
|
// unavailable, treating it as `false` changes every project-bearing item's
|
|
178
95
|
// incremental identity to `unknown` and can trigger a full-history upload.
|
|
@@ -180,45 +97,36 @@ export async function runSync({
|
|
|
180
97
|
// no-op: no data upload and no state mutation.
|
|
181
98
|
const apiUrl = config.apiUrl || 'https://vibecafe.ai';
|
|
182
99
|
let uploadProject;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
{ uploadProject: cachedUploadProject },
|
|
214
|
-
localUploadProject,
|
|
215
|
-
);
|
|
216
|
-
if (!quiet) console.log(warn('设置接口不可用,沿用上次的项目名设置。'));
|
|
217
|
-
} else {
|
|
218
|
-
console.error(failure('暂时无法读取上传设置,本次同步已安全取消(未上传数据)。请稍后重试。'));
|
|
219
|
-
if (throws) throw err;
|
|
220
|
-
process.exit(1);
|
|
221
|
-
}
|
|
100
|
+
try {
|
|
101
|
+
const settings = await fetchSettings(apiUrl, config.apiKey);
|
|
102
|
+
uploadProject = resolveUploadProjectSetting(settings);
|
|
103
|
+
// Scope the cached privacy choice to the server that returned it. Reusing
|
|
104
|
+
// the value after `apiUrl` changes could expose project names to a
|
|
105
|
+
// different server during its first settings outage.
|
|
106
|
+
if (
|
|
107
|
+
config.lastUploadProject !== uploadProject
|
|
108
|
+
|| config.lastUploadProjectApiUrl !== apiUrl
|
|
109
|
+
) {
|
|
110
|
+
config.lastUploadProject = uploadProject;
|
|
111
|
+
config.lastUploadProjectApiUrl = apiUrl;
|
|
112
|
+
saveConfig(config);
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err.message === 'UNAUTHORIZED') {
|
|
116
|
+
console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
|
|
117
|
+
if (throws) throw err;
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
// Settings endpoint unreachable (not auth): degrade to the last confirmed
|
|
121
|
+
// choice for this same server rather than hard-aborting every upload.
|
|
122
|
+
const cachedUploadProject = resolveCachedUploadProjectSetting(config, apiUrl);
|
|
123
|
+
if (typeof cachedUploadProject === 'boolean') {
|
|
124
|
+
uploadProject = cachedUploadProject;
|
|
125
|
+
if (!quiet) console.log(warn('设置接口不可用,沿用上次的项目名设置。'));
|
|
126
|
+
} else {
|
|
127
|
+
console.error(failure('暂时无法读取上传设置,本次同步已安全取消(未上传数据)。请稍后重试。'));
|
|
128
|
+
if (throws) throw err;
|
|
129
|
+
process.exit(1);
|
|
222
130
|
}
|
|
223
131
|
}
|
|
224
132
|
|
|
@@ -273,8 +181,8 @@ export async function runSync({
|
|
|
273
181
|
// timeout) to keep daemon logs quiet. Its empty result is not proof that
|
|
274
182
|
// its prior data disappeared, so it must not be pruned this run.
|
|
275
183
|
if (!skipped) okSources.add(source);
|
|
276
|
-
|
|
277
|
-
|
|
184
|
+
for (const bucket of buckets) allBuckets.push(bucket);
|
|
185
|
+
for (const session of sessions) allSessions.push(session);
|
|
278
186
|
if (buckets.length > 0 || sessions.length > 0) {
|
|
279
187
|
parserResults.push({ source, buckets: buckets.length, sessions: sessions.length });
|
|
280
188
|
}
|
|
@@ -313,24 +221,23 @@ export async function runSync({
|
|
|
313
221
|
}
|
|
314
222
|
}
|
|
315
223
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
224
|
+
let host = config.hostname;
|
|
225
|
+
if (!host) {
|
|
226
|
+
host = osHostname().replace(/\.local$/, '');
|
|
227
|
+
config.hostname = host;
|
|
228
|
+
saveConfig(config);
|
|
229
|
+
}
|
|
230
|
+
// Cloud-sourced parsers (e.g. cursor) pre-set their own hostname sentinel so
|
|
231
|
+
// the same account data isn't stored as separate rows per machine.
|
|
232
|
+
for (const b of allBuckets) if (!b.hostname) b.hostname = host;
|
|
233
|
+
for (const s of allSessions) if (!s.hostname) s.hostname = host;
|
|
322
234
|
|
|
323
235
|
if (!quiet) {
|
|
324
236
|
if (uploadProject) {
|
|
325
|
-
console.log(dim(' 项目名:
|
|
237
|
+
console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));
|
|
326
238
|
} else {
|
|
327
239
|
console.log(dim(' 项目名: 已隐藏'));
|
|
328
240
|
}
|
|
329
|
-
console.log(dim(
|
|
330
|
-
hostIdentity.uploadHostname
|
|
331
|
-
? ' 设备名: 上传'
|
|
332
|
-
: ` 设备名: 已替换为匿名 ID (${host})`,
|
|
333
|
-
));
|
|
334
241
|
}
|
|
335
242
|
if (!uploadProject) {
|
|
336
243
|
for (const b of allBuckets) b.project = 'unknown';
|
|
@@ -346,13 +253,6 @@ export async function runSync({
|
|
|
346
253
|
// Missing/corrupt state.json => empty maps => one-time full upload, then
|
|
347
254
|
// incremental forever after.
|
|
348
255
|
const state = loadState();
|
|
349
|
-
const migratedHostnameState = !hostIdentity.uploadHostname
|
|
350
|
-
&& migrateHiddenHostnameState(
|
|
351
|
-
state,
|
|
352
|
-
allBuckets,
|
|
353
|
-
hostIdentity.previousHostname,
|
|
354
|
-
host,
|
|
355
|
-
);
|
|
356
256
|
const changedBuckets = [];
|
|
357
257
|
const changedSessions = [];
|
|
358
258
|
const liveBucketKeys = new Set();
|
|
@@ -390,7 +290,7 @@ export async function runSync({
|
|
|
390
290
|
const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
|
|
391
291
|
pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
|
|
392
292
|
const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
|
|
393
|
-
if (pruned > 0
|
|
293
|
+
if (pruned > 0) saveState(state);
|
|
394
294
|
|
|
395
295
|
if (changedBuckets.length === 0 && changedSessions.length === 0) {
|
|
396
296
|
if (!quiet) console.log(dim('无新增数据。'));
|